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