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 KconfigRegexRole(XRefRole):
237    """Role for creating links to Kconfig regex searches."""
238
239    def process_link(self, env: BuildEnvironment, refnode: nodes.Element, has_explicit_title: bool,
240                     title: str, target: str) -> tuple[str, str]:
241        # render as "normal" text when explicit title is provided, literal otherwise
242        if has_explicit_title:
243            self.innernodeclass = nodes.inline
244        else:
245            self.innernodeclass = nodes.literal
246        return title, target
247
248
249class KconfigDomain(Domain):
250    """Kconfig domain"""
251
252    name = "kconfig"
253    label = "Kconfig"
254    object_types = {"option": ObjType("option", "option")}
255    roles = {"option": XRefRole(), "option-regex": KconfigRegexRole()}
256    directives = {"search": KconfigSearch}
257    initial_data: dict[str, Any] = {"options": set()}
258
259    def get_objects(self) -> Iterable[tuple[str, str, str, str, str, int]]:
260        yield from self.data["options"]
261
262    def merge_domaindata(self, docnames: list[str], otherdata: dict) -> None:
263        self.data["options"].update(otherdata["options"])
264
265    def resolve_xref(
266        self,
267        env: BuildEnvironment,
268        fromdocname: str,
269        builder: Builder,
270        typ: str,
271        target: str,
272        node: pending_xref,
273        contnode: nodes.Element,
274    ) -> nodes.Element | None:
275        if typ == "option-regex":
276            # Handle regex search links
277            search_docname = self._find_search_docname(env)
278            if search_docname:
279                # Create a reference to the search page with the regex as a fragment
280                ref_uri = builder.get_relative_uri(fromdocname, search_docname) + f"#!{target}"
281                ref_node = nodes.reference('', '', refuri=ref_uri, internal=True)
282                ref_node.append(contnode)
283                return ref_node
284            else:
285                # Fallback to plain text if no search page is found
286                return contnode
287        else:
288            # Handle regular option links
289            match = [
290                (docname, anchor)
291                for name, _, _, docname, anchor, _ in self.get_objects()
292                if name == target
293            ]
294
295            if match:
296                todocname, anchor = match[0]
297
298                return make_refnode(
299                    builder, fromdocname, todocname, anchor, contnode, anchor
300                )
301            else:
302                return None
303
304    def _find_search_docname(self, env: BuildEnvironment) -> str | None:
305        """Find the document containing the kconfig search directive."""
306        # Cache the result to avoid repeated searches
307        if hasattr(env, '_kconfig_search_docname'):
308            return env._kconfig_search_docname
309
310        for docname in env.all_docs:
311            try:
312                doctree = env.get_doctree(docname)
313                visitor = _FindKconfigSearchDirectiveVisitor(doctree)
314                doctree.walk(visitor)
315                if visitor.found_kconfig_search_directive:
316                    env._kconfig_search_docname = docname
317                    return docname
318            except Exception:
319                # Skip documents that can't be loaded
320                continue
321
322        # No search directive found
323        env._kconfig_search_docname = None
324        return None
325
326    def add_option(self, option):
327        """Register a new Kconfig option to the domain."""
328
329        self.data["options"].add(
330            (option, option, "option", self.env.docname, option, 1)
331        )
332
333
334def sc_fmt(sc):
335    if isinstance(sc, kconfiglib.Symbol):
336        if sc.nodes:
337            return f'<a href="#CONFIG_{sc.name}">CONFIG_{sc.name}</a>'
338    elif isinstance(sc, kconfiglib.Choice):
339        if not sc.name:
340            return "&ltchoice&gt"
341        return f'&ltchoice <a href="#CONFIG_{sc.name}">CONFIG_{sc.name}</a>&gt'
342
343    return kconfiglib.standard_sc_expr_str(sc)
344
345
346def kconfig_build_resources(app: Sphinx) -> None:
347    """Build the Kconfig database and install HTML resources."""
348
349    if not app.config.kconfig_generate_db:
350        return
351
352    with progress_message("Building Kconfig database..."):
353        kconfig, module_paths = kconfig_load(app)
354        db = list()
355
356        for sc in sorted(
357            chain(kconfig.unique_defined_syms, kconfig.unique_choices),
358            key=lambda sc: sc.name if sc.name else "",
359        ):
360            # skip nameless symbols
361            if not sc.name:
362                continue
363
364            # store alternative defaults (from defconfig files)
365            alt_defaults = list()
366            for node in sc.nodes:
367                if "defconfig" not in node.filename:
368                    continue
369
370                for value, cond in node.orig_defaults:
371                    fmt = kconfiglib.expr_str(value, sc_fmt)
372                    if cond is not sc.kconfig.y:
373                        fmt += f" if {kconfiglib.expr_str(cond, sc_fmt)}"
374                    alt_defaults.append([fmt, node.filename])
375
376            # build list of symbols that select/imply the current one
377            # note: all reverse dependencies are ORed together, and conditionals
378            # (e.g. select/imply A if B) turns into A && B. So we first split
379            # by OR to include all entries, and we split each one by AND to just
380            # take the first entry.
381            selected_by = list()
382            if isinstance(sc, kconfiglib.Symbol) and sc.rev_dep != sc.kconfig.n:
383                for select in kconfiglib.split_expr(sc.rev_dep, kconfiglib.OR):
384                    sym = kconfiglib.split_expr(select, kconfiglib.AND)[0]
385                    selected_by.append(f"CONFIG_{sym.name}")
386
387            implied_by = list()
388            if isinstance(sc, kconfiglib.Symbol) and sc.weak_rev_dep != sc.kconfig.n:
389                for select in kconfiglib.split_expr(sc.weak_rev_dep, kconfiglib.OR):
390                    sym = kconfiglib.split_expr(select, kconfiglib.AND)[0]
391                    implied_by.append(f"CONFIG_{sym.name}")
392
393            # only process nodes with prompt or help
394            nodes = [node for node in sc.nodes if node.prompt or node.help]
395
396            inserted_paths = list()
397            for node in nodes:
398                # avoid duplicate symbols by forcing unique paths. this can
399                # happen due to dependencies on 0, a trick used by some modules
400                path = f"{node.filename}:{node.linenr}"
401                if path in inserted_paths:
402                    continue
403                inserted_paths.append(path)
404
405                dependencies = None
406                if node.dep is not sc.kconfig.y:
407                    dependencies = kconfiglib.expr_str(node.dep, sc_fmt)
408
409                defaults = list()
410                for value, cond in node.orig_defaults:
411                    fmt = kconfiglib.expr_str(value, sc_fmt)
412                    if cond is not sc.kconfig.y:
413                        fmt += f" if {kconfiglib.expr_str(cond, sc_fmt)}"
414                    defaults.append(fmt)
415
416                selects = list()
417                for value, cond in node.orig_selects:
418                    fmt = kconfiglib.expr_str(value, sc_fmt)
419                    if cond is not sc.kconfig.y:
420                        fmt += f" if {kconfiglib.expr_str(cond, sc_fmt)}"
421                    selects.append(fmt)
422
423                implies = list()
424                for value, cond in node.orig_implies:
425                    fmt = kconfiglib.expr_str(value, sc_fmt)
426                    if cond is not sc.kconfig.y:
427                        fmt += f" if {kconfiglib.expr_str(cond, sc_fmt)}"
428                    implies.append(fmt)
429
430                ranges = list()
431                for min, max, cond in node.orig_ranges:
432                    fmt = (
433                        f"[{kconfiglib.expr_str(min, sc_fmt)}, "
434                        f"{kconfiglib.expr_str(max, sc_fmt)}]"
435                    )
436                    if cond is not sc.kconfig.y:
437                        fmt += f" if {kconfiglib.expr_str(cond, sc_fmt)}"
438                    ranges.append(fmt)
439
440                choices = list()
441                if isinstance(sc, kconfiglib.Choice):
442                    for sym in sc.syms:
443                        choices.append(kconfiglib.expr_str(sym, sc_fmt))
444
445                menupath = ""
446                iternode = node
447                while iternode.parent is not iternode.kconfig.top_node:
448                    iternode = iternode.parent
449                    if iternode.prompt:
450                        title = iternode.prompt[0]
451                    else:
452                        title = kconfiglib.standard_sc_expr_str(iternode.item)
453                    menupath = f" > {title}" + menupath
454
455                menupath = "(Top)" + menupath
456
457                filename = node.filename
458                for name, path in module_paths.items():
459                    path += "/"
460                    if node.filename.startswith(path):
461                        filename = node.filename.replace(path, f"<module:{name}>/")
462                        break
463
464                db.append(
465                    {
466                        "name": f"CONFIG_{sc.name}",
467                        "prompt": node.prompt[0] if node.prompt else None,
468                        "type": kconfiglib.TYPE_TO_STR[sc.type],
469                        "help": node.help,
470                        "dependencies": dependencies,
471                        "defaults": defaults,
472                        "alt_defaults": alt_defaults,
473                        "selects": selects,
474                        "selected_by": selected_by,
475                        "implies": implies,
476                        "implied_by": implied_by,
477                        "ranges": ranges,
478                        "choices": choices,
479                        "filename": filename,
480                        "linenr": node.linenr,
481                        "menupath": menupath,
482                    }
483                )
484
485        app.env.kconfig_db = db  # type: ignore
486
487        outdir = Path(app.outdir) / "kconfig"
488        outdir.mkdir(exist_ok=True)
489
490        kconfig_db_file = outdir / "kconfig.json"
491
492        kconfig_db = {
493            "gh_base_url": app.config.kconfig_gh_link_base_url,
494            "zephyr_version": app.config.kconfig_zephyr_version,
495            "symbols": db,
496        }
497
498        with open(kconfig_db_file, "w") as f:
499            json.dump(kconfig_db, f)
500
501    app.config.html_extra_path.append(kconfig_db_file.as_posix())
502    app.config.html_static_path.append(RESOURCES_DIR.as_posix())
503
504
505def kconfig_install(
506    app: Sphinx,
507    pagename: str,
508    templatename: str,
509    context: dict,
510    doctree: nodes.Node | None,
511) -> None:
512    """Install the Kconfig library files on pages that require it."""
513    if (
514        not app.config.kconfig_generate_db
515        or app.builder.format != "html"
516        or not doctree
517    ):
518        return
519
520    visitor = _FindKconfigSearchDirectiveVisitor(doctree)
521    doctree.walk(visitor)
522    if visitor.found_kconfig_search_directive:
523        app.add_css_file("kconfig.css")
524        app.add_js_file("kconfig.mjs", type="module")
525
526
527def setup(app: Sphinx):
528    app.add_config_value("kconfig_generate_db", False, "env")
529    app.add_config_value("kconfig_ext_paths", [], "env")
530    app.add_config_value("kconfig_gh_link_base_url", "", "")
531    app.add_config_value("kconfig_zephyr_version", "", "")
532
533    app.add_node(
534        KconfigSearchNode,
535        html=(kconfig_search_visit_html, None),
536        latex=(kconfig_search_visit_latex, None),
537    )
538
539    app.add_domain(KconfigDomain)
540
541    app.connect("builder-inited", kconfig_build_resources)
542    app.connect("html-page-context", kconfig_install)
543
544    return {
545        "version": __version__,
546        "parallel_read_safe": True,
547        "parallel_write_safe": True,
548    }
549