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