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 "<choice>" 341 return f'<choice <a href="#CONFIG_{sc.name}">CONFIG_{sc.name}</a>>' 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