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