1"""
2Kconfig Extension
3#################
4
5Copyright (c) 2022 Nordic Semiconductor ASA
6SPDX-License-Identifier: Apache-2.0
7
8Introduction
9============
10
11This extension adds a new domain (``kconfig``) for the Kconfig language. Unlike
12many other domains, the Kconfig options are not rendered by Sphinx directly but
13on the client side using a database built by the extension. A special directive
14``.. kconfig:search::`` can be inserted on any page to render a search box that
15allows to browse the database. References to Kconfig options can be created by
16using the ``:kconfig:option:`` role. Kconfig options behave as regular domain
17objects, so they can also be referenced by other projects using Intersphinx.
18
19Options
20=======
21
22- kconfig_generate_db: Set to True if you want to generate the Kconfig database.
23  This is only required if you want to use the ``.. kconfig:search::``
24  directive, not if you just need support for Kconfig domain (e.g. when using
25  Intersphinx in another project). Defaults to False.
26- kconfig_ext_paths: A list of base paths where to search for external modules
27  Kconfig files when they use ``kconfig-ext: True``. The extension will look for
28  ${BASE_PATH}/modules/${MODULE_NAME}/Kconfig.
29"""
30
31import argparse
32import json
33import os
34import re
35import sys
36from collections.abc import Iterable
37from itertools import chain
38from pathlib import Path
39from tempfile import TemporaryDirectory
40from typing import Any
41
42from docutils import nodes
43from sphinx.addnodes import pending_xref
44from sphinx.application import Sphinx
45from sphinx.builders import Builder
46from sphinx.domains import Domain, ObjType
47from sphinx.environment import BuildEnvironment
48from sphinx.errors import ExtensionError
49from sphinx.roles import XRefRole
50from sphinx.util.display import progress_message
51from sphinx.util.docutils import SphinxDirective
52from sphinx.util.nodes import make_refnode
53
54__version__ = "0.1.0"
55
56
57sys.path.insert(0, str(Path(__file__).parents[4] / "scripts"))
58sys.path.insert(0, str(Path(__file__).parents[4] / "scripts/kconfig"))
59
60import kconfiglib
61import list_boards
62import list_hardware
63import zephyr_module
64
65RESOURCES_DIR = Path(__file__).parent / "static"
66ZEPHYR_BASE = Path(__file__).parents[4]
67
68
69def kconfig_load(app: Sphinx) -> tuple[kconfiglib.Kconfig, dict[str, str]]:
70    """Load Kconfig"""
71    with TemporaryDirectory() as td:
72        modules = zephyr_module.parse_modules(ZEPHYR_BASE)
73
74        # generate Kconfig.modules file
75        kconfig = ""
76        for module in modules:
77            kconfig += zephyr_module.process_kconfig(module.project, module.meta)
78
79        with open(Path(td) / "Kconfig.modules", "w") as f:
80            f.write(kconfig)
81
82        # generate dummy Kconfig.dts file
83        kconfig = ""
84
85        with open(Path(td) / "Kconfig.dts", "w") as f:
86            f.write(kconfig)
87
88        (Path(td) / 'soc').mkdir(exist_ok=True)
89        root_args = argparse.Namespace(**{'soc_roots': [Path(ZEPHYR_BASE)]})
90        v2_systems = list_hardware.find_v2_systems(root_args)
91
92        soc_folders = {soc.folder[0] for soc in v2_systems.get_socs()}
93        with open(Path(td) / "soc" / "Kconfig.defconfig", "w") as f:
94            f.write('')
95
96        with open(Path(td) / "soc" / "Kconfig.soc", "w") as f:
97            for folder in soc_folders:
98                f.write('source "' + (Path(folder) / 'Kconfig.soc').as_posix() + '"\n')
99
100        with open(Path(td) / "soc" / "Kconfig", "w") as f:
101            for folder in soc_folders:
102                f.write('osource "' + (Path(folder) / 'Kconfig').as_posix() + '"\n')
103
104        (Path(td) / 'arch').mkdir(exist_ok=True)
105        root_args = argparse.Namespace(**{'arch_roots': [Path(ZEPHYR_BASE)], 'arch': None})
106        v2_archs = list_hardware.find_v2_archs(root_args)
107        kconfig = ""
108        for arch in v2_archs['archs']:
109            kconfig += 'source "' + (Path(arch['path']) / 'Kconfig').as_posix() + '"\n'
110        with open(Path(td) / "arch" / "Kconfig", "w") as f:
111            f.write(kconfig)
112
113        (Path(td) / 'boards').mkdir(exist_ok=True)
114        root_args = argparse.Namespace(**{'board_roots': [Path(ZEPHYR_BASE)],
115                                          'soc_roots': [Path(ZEPHYR_BASE)], 'board': None,
116                                          'board_dir': []})
117        v2_boards = list_boards.find_v2_boards(root_args).values()
118
119        with open(Path(td) / "boards" / "Kconfig.boards", "w") as f:
120            for board in v2_boards:
121                board_str = 'BOARD_' + re.sub(r"[^a-zA-Z0-9_]", "_", board.name).upper()
122                f.write('config  ' + board_str + '\n')
123                f.write('\t bool\n')
124                for qualifier in list_boards.board_v2_qualifiers(board):
125                    board_str = 'BOARD_' + re.sub(r"[^a-zA-Z0-9_]", "_", qualifier).upper()
126                    f.write('config  ' + board_str + '\n')
127                    f.write('\t bool\n')
128                f.write('source "' + (board.dir / ('Kconfig.' + board.name)).as_posix() + '"\n\n')
129
130        # base environment
131        os.environ["ZEPHYR_BASE"] = str(ZEPHYR_BASE)
132        os.environ["srctree"] = str(ZEPHYR_BASE)  # noqa: SIM112
133        os.environ["KCONFIG_DOC_MODE"] = "1"
134        os.environ["KCONFIG_BINARY_DIR"] = td
135
136        # include all archs and boards
137        os.environ["ARCH_DIR"] = "arch"
138        os.environ["ARCH"] = "[!v][!2]*"
139        os.environ["HWM_SCHEME"] = "v2"
140
141        os.environ["BOARD"] = "boards"
142        os.environ["KCONFIG_BOARD_DIR"] = str(Path(td) / "boards")
143
144        # insert external Kconfigs to the environment
145        module_paths = dict()
146        for module in modules:
147            name = module.meta["name"]
148            name_var = module.meta["name-sanitized"].upper()
149            module_paths[name] = module.project
150
151            build_conf = module.meta.get("build")
152            if not build_conf:
153                continue
154
155            if build_conf.get("kconfig"):
156                kconfig = Path(module.project) / build_conf["kconfig"]
157                os.environ[f"ZEPHYR_{name_var}_KCONFIG"] = str(kconfig)
158            elif build_conf.get("kconfig-ext"):
159                for path in app.config.kconfig_ext_paths:
160                    kconfig = Path(path) / "modules" / name / "Kconfig"
161                    if kconfig.exists():
162                        os.environ[f"ZEPHYR_{name_var}_KCONFIG"] = str(kconfig)
163
164        return kconfiglib.Kconfig(ZEPHYR_BASE / "Kconfig"), module_paths
165
166
167class KconfigSearchNode(nodes.Element):
168    @staticmethod
169    def html():
170        return '<div id="__kconfig-search"></div>'
171
172
173def kconfig_search_visit_html(self, node: nodes.Node) -> None:
174    self.body.append(node.html())
175    raise nodes.SkipNode
176
177
178def kconfig_search_visit_latex(self, node: nodes.Node) -> None:
179    self.body.append("Kconfig search is only available on HTML output")
180    raise nodes.SkipNode
181
182
183class KconfigSearch(SphinxDirective):
184    """Kconfig search directive"""
185
186    has_content = False
187
188    def run(self):
189        if not self.config.kconfig_generate_db:
190            raise ExtensionError(
191                "Kconfig search directive can not be used without database"
192            )
193
194        if "kconfig_search_inserted" in self.env.temp_data:
195            raise ExtensionError("Kconfig search directive can only be used once")
196
197        self.env.temp_data["kconfig_search_inserted"] = True
198
199        # register all options to the domain at this point, so that they all
200        # resolve to the page where the kconfig:search directive is inserted
201        domain = self.env.get_domain("kconfig")
202        unique = set({option["name"] for option in self.env.kconfig_db})
203        for option in unique:
204            domain.add_option(option)
205
206        return [KconfigSearchNode()]
207
208
209class _FindKconfigSearchDirectiveVisitor(nodes.NodeVisitor):
210    def __init__(self, document):
211        super().__init__(document)
212        self._found = False
213
214    def unknown_visit(self, node: nodes.Node) -> None:
215        if self._found:
216            return
217
218        self._found = isinstance(node, KconfigSearchNode)
219
220    @property
221    def found_kconfig_search_directive(self) -> bool:
222        return self._found
223
224
225class KconfigDomain(Domain):
226    """Kconfig domain"""
227
228    name = "kconfig"
229    label = "Kconfig"
230    object_types = {"option": ObjType("option", "option")}
231    roles = {"option": XRefRole()}
232    directives = {"search": KconfigSearch}
233    initial_data: dict[str, Any] = {"options": set()}
234
235    def get_objects(self) -> Iterable[tuple[str, str, str, str, str, int]]:
236        yield from self.data["options"]
237
238    def merge_domaindata(self, docnames: list[str], otherdata: dict) -> None:
239        self.data["options"].update(otherdata["options"])
240
241    def resolve_xref(
242        self,
243        env: BuildEnvironment,
244        fromdocname: str,
245        builder: Builder,
246        typ: str,
247        target: str,
248        node: pending_xref,
249        contnode: nodes.Element,
250    ) -> nodes.Element | None:
251        match = [
252            (docname, anchor)
253            for name, _, _, docname, anchor, _ in self.get_objects()
254            if name == target
255        ]
256
257        if match:
258            todocname, anchor = match[0]
259
260            return make_refnode(
261                builder, fromdocname, todocname, anchor, contnode, anchor
262            )
263        else:
264            return None
265
266    def add_option(self, option):
267        """Register a new Kconfig option to the domain."""
268
269        self.data["options"].add(
270            (option, option, "option", self.env.docname, option, 1)
271        )
272
273
274def sc_fmt(sc):
275    if isinstance(sc, kconfiglib.Symbol):
276        if sc.nodes:
277            return f'<a href="#CONFIG_{sc.name}">CONFIG_{sc.name}</a>'
278    elif isinstance(sc, kconfiglib.Choice):
279        if not sc.name:
280            return "&ltchoice&gt"
281        return f'&ltchoice <a href="#CONFIG_{sc.name}">CONFIG_{sc.name}</a>&gt'
282
283    return kconfiglib.standard_sc_expr_str(sc)
284
285
286def kconfig_build_resources(app: Sphinx) -> None:
287    """Build the Kconfig database and install HTML resources."""
288
289    if not app.config.kconfig_generate_db:
290        return
291
292    with progress_message("Building Kconfig database..."):
293        kconfig, module_paths = kconfig_load(app)
294        db = list()
295
296        for sc in sorted(
297            chain(kconfig.unique_defined_syms, kconfig.unique_choices),
298            key=lambda sc: sc.name if sc.name else "",
299        ):
300            # skip nameless symbols
301            if not sc.name:
302                continue
303
304            # store alternative defaults (from defconfig files)
305            alt_defaults = list()
306            for node in sc.nodes:
307                if "defconfig" not in node.filename:
308                    continue
309
310                for value, cond in node.orig_defaults:
311                    fmt = kconfiglib.expr_str(value, sc_fmt)
312                    if cond is not sc.kconfig.y:
313                        fmt += f" if {kconfiglib.expr_str(cond, sc_fmt)}"
314                    alt_defaults.append([fmt, node.filename])
315
316            # build list of symbols that select/imply the current one
317            # note: all reverse dependencies are ORed together, and conditionals
318            # (e.g. select/imply A if B) turns into A && B. So we first split
319            # by OR to include all entries, and we split each one by AND to just
320            # take the first entry.
321            selected_by = list()
322            if isinstance(sc, kconfiglib.Symbol) and sc.rev_dep != sc.kconfig.n:
323                for select in kconfiglib.split_expr(sc.rev_dep, kconfiglib.OR):
324                    sym = kconfiglib.split_expr(select, kconfiglib.AND)[0]
325                    selected_by.append(f"CONFIG_{sym.name}")
326
327            implied_by = list()
328            if isinstance(sc, kconfiglib.Symbol) and sc.weak_rev_dep != sc.kconfig.n:
329                for select in kconfiglib.split_expr(sc.weak_rev_dep, kconfiglib.OR):
330                    sym = kconfiglib.split_expr(select, kconfiglib.AND)[0]
331                    implied_by.append(f"CONFIG_{sym.name}")
332
333            # only process nodes with prompt or help
334            nodes = [node for node in sc.nodes if node.prompt or node.help]
335
336            inserted_paths = list()
337            for node in nodes:
338                # avoid duplicate symbols by forcing unique paths. this can
339                # happen due to dependencies on 0, a trick used by some modules
340                path = f"{node.filename}:{node.linenr}"
341                if path in inserted_paths:
342                    continue
343                inserted_paths.append(path)
344
345                dependencies = None
346                if node.dep is not sc.kconfig.y:
347                    dependencies = kconfiglib.expr_str(node.dep, sc_fmt)
348
349                defaults = list()
350                for value, cond in node.orig_defaults:
351                    fmt = kconfiglib.expr_str(value, sc_fmt)
352                    if cond is not sc.kconfig.y:
353                        fmt += f" if {kconfiglib.expr_str(cond, sc_fmt)}"
354                    defaults.append(fmt)
355
356                selects = list()
357                for value, cond in node.orig_selects:
358                    fmt = kconfiglib.expr_str(value, sc_fmt)
359                    if cond is not sc.kconfig.y:
360                        fmt += f" if {kconfiglib.expr_str(cond, sc_fmt)}"
361                    selects.append(fmt)
362
363                implies = list()
364                for value, cond in node.orig_implies:
365                    fmt = kconfiglib.expr_str(value, sc_fmt)
366                    if cond is not sc.kconfig.y:
367                        fmt += f" if {kconfiglib.expr_str(cond, sc_fmt)}"
368                    implies.append(fmt)
369
370                ranges = list()
371                for min, max, cond in node.orig_ranges:
372                    fmt = (
373                        f"[{kconfiglib.expr_str(min, sc_fmt)}, "
374                        f"{kconfiglib.expr_str(max, sc_fmt)}]"
375                    )
376                    if cond is not sc.kconfig.y:
377                        fmt += f" if {kconfiglib.expr_str(cond, sc_fmt)}"
378                    ranges.append(fmt)
379
380                choices = list()
381                if isinstance(sc, kconfiglib.Choice):
382                    for sym in sc.syms:
383                        choices.append(kconfiglib.expr_str(sym, sc_fmt))
384
385                menupath = ""
386                iternode = node
387                while iternode.parent is not iternode.kconfig.top_node:
388                    iternode = iternode.parent
389                    if iternode.prompt:
390                        title = iternode.prompt[0]
391                    else:
392                        title = kconfiglib.standard_sc_expr_str(iternode.item)
393                    menupath = f" > {title}" + menupath
394
395                menupath = "(Top)" + menupath
396
397                filename = node.filename
398                for name, path in module_paths.items():
399                    path += "/"
400                    if node.filename.startswith(path):
401                        filename = node.filename.replace(path, f"<module:{name}>/")
402                        break
403
404                db.append(
405                    {
406                        "name": f"CONFIG_{sc.name}",
407                        "prompt": node.prompt[0] if node.prompt else None,
408                        "type": kconfiglib.TYPE_TO_STR[sc.type],
409                        "help": node.help,
410                        "dependencies": dependencies,
411                        "defaults": defaults,
412                        "alt_defaults": alt_defaults,
413                        "selects": selects,
414                        "selected_by": selected_by,
415                        "implies": implies,
416                        "implied_by": implied_by,
417                        "ranges": ranges,
418                        "choices": choices,
419                        "filename": filename,
420                        "linenr": node.linenr,
421                        "menupath": menupath,
422                    }
423                )
424
425        app.env.kconfig_db = db  # type: ignore
426
427        outdir = Path(app.outdir) / "kconfig"
428        outdir.mkdir(exist_ok=True)
429
430        kconfig_db_file = outdir / "kconfig.json"
431
432        with open(kconfig_db_file, "w") as f:
433            json.dump(db, f)
434
435    app.config.html_extra_path.append(kconfig_db_file.as_posix())
436    app.config.html_static_path.append(RESOURCES_DIR.as_posix())
437
438
439def kconfig_install(
440    app: Sphinx,
441    pagename: str,
442    templatename: str,
443    context: dict,
444    doctree: nodes.Node | None,
445) -> None:
446    """Install the Kconfig library files on pages that require it."""
447    if (
448        not app.config.kconfig_generate_db
449        or app.builder.format != "html"
450        or not doctree
451    ):
452        return
453
454    visitor = _FindKconfigSearchDirectiveVisitor(doctree)
455    doctree.walk(visitor)
456    if visitor.found_kconfig_search_directive:
457        app.add_css_file("kconfig.css")
458        app.add_js_file("kconfig.mjs", type="module")
459
460
461def setup(app: Sphinx):
462    app.add_config_value("kconfig_generate_db", False, "env")
463    app.add_config_value("kconfig_ext_paths", [], "env")
464
465    app.add_node(
466        KconfigSearchNode,
467        html=(kconfig_search_visit_html, None),
468        latex=(kconfig_search_visit_latex, None),
469    )
470
471    app.add_domain(KconfigDomain)
472
473    app.connect("builder-inited", kconfig_build_resources)
474    app.connect("html-page-context", kconfig_install)
475
476    return {
477        "version": __version__,
478        "parallel_read_safe": True,
479        "parallel_write_safe": True,
480    }
481