diff --git a/doc/_extensions/zephyr/kconfig/__init__.py b/doc/_extensions/zephyr/kconfig/__init__.py new file mode 100644 index 00000000000..27492bf72f0 --- /dev/null +++ b/doc/_extensions/zephyr/kconfig/__init__.py @@ -0,0 +1,398 @@ +""" +Kconfig Extension +################# + +Copyright (c) 2022 Nordic Semiconductor ASA +SPDX-License-Identifier: Apache-2.0 + +Introduction +============ + +This extension adds a new domain (``kconfig``) for the Kconfig language. Unlike +many other domains, the Kconfig options are not rendered by Sphinx directly but +on the client side using a database built by the extension. A special directive +``.. kconfig:search::`` can be inserted on any page to render a search box that +allows to browse the database. References to Kconfig options can be created by +using the ``:kconfig:option:`` role. Kconfig options behave as regular domain +objects, so they can also be referenced by other projects using Intersphinx. + +Options +======= + +- kconfig_generate_db: Set to True if you want to generate the Kconfig database. + This is only required if you want to use the ``.. kconfig:search::`` + directive, not if you just need support for Kconfig domain (e.g. when using + Intersphinx in another project). Defaults to False. +- kconfig_ext_paths: A list of base paths where to search for external modules + Kconfig files when they use ``kconfig-ext: True``. The extension will look for + ${BASE_PATH}/modules/${MODULE_NAME}/Kconfig. +""" + +from distutils.command.build import build +from itertools import chain +import json +from operator import mod +import os +from pathlib import Path +import re +import sys +from tempfile import TemporaryDirectory +from typing import Any, Dict, Iterable, List, Optional, Tuple + +from docutils import nodes +from sphinx.addnodes import pending_xref +from sphinx.application import Sphinx +from sphinx.builders import Builder +from sphinx.domains import Domain, ObjType +from sphinx.environment import BuildEnvironment +from sphinx.errors import ExtensionError +from sphinx.roles import XRefRole +from sphinx.util import progress_message +from sphinx.util.docutils import SphinxDirective +from sphinx.util.nodes import make_refnode + + +__version__ = "0.1.0" + + +RESOURCES_DIR = Path(__file__).parent / "static" +ZEPHYR_BASE = Path(__file__).parents[4] + +SCRIPTS = ZEPHYR_BASE / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +KCONFIGLIB = SCRIPTS / "kconfig" +sys.path.insert(0, str(KCONFIGLIB)) + +import zephyr_module +import kconfiglib + + +def kconfig_load(app: Sphinx) -> Tuple[kconfiglib.Kconfig, Dict[str, str]]: + """Load Kconfig""" + with TemporaryDirectory() as td: + projects = zephyr_module.west_projects() + projects = [p.posixpath for p in projects["projects"]] if projects else None + modules = zephyr_module.parse_modules(ZEPHYR_BASE, projects) + + # generate Kconfig.modules file + kconfig = "" + for module in modules: + kconfig += zephyr_module.process_kconfig(module.project, module.meta) + + with open(Path(td) / "Kconfig.modules", "w") as f: + f.write(kconfig) + + # base environment + os.environ["ZEPHYR_BASE"] = str(ZEPHYR_BASE) + os.environ["srctree"] = str(ZEPHYR_BASE) + os.environ["KCONFIG_DOC_MODE"] = "1" + os.environ["KCONFIG_BINARY_DIR"] = td + + # include all archs and boards + os.environ["ARCH_DIR"] = "arch" + os.environ["ARCH"] = "*" + os.environ["BOARD_DIR"] = "boards/*/*" + + # insert external Kconfigs to the environment + module_paths = dict() + for module in modules: + name = module.meta["name"] + name_var = module.meta["name-sanitized"].upper() + module_paths[name] = module.project + + build_conf = module.meta.get("build") + if not build_conf: + continue + + if build_conf.get("kconfig"): + kconfig = Path(module.project) / build_conf["kconfig"] + os.environ[f"ZEPHYR_{name_var}_KCONFIG"] = str(kconfig) + elif build_conf.get("kconfig-ext"): + for path in app.config.kconfig_ext_paths: + kconfig = Path(path) / "modules" / name / "Kconfig" + if kconfig.exists(): + os.environ[f"ZEPHYR_{name_var}_KCONFIG"] = str(kconfig) + + return kconfiglib.Kconfig(ZEPHYR_BASE / "Kconfig"), module_paths + + +class KconfigSearchNode(nodes.Element): + @staticmethod + def html(): + return '
' + + +def kconfig_search_visit_html(self, node: nodes.Node) -> None: + self.body.append(node.html()) + raise nodes.SkipNode + + +def kconfig_search_visit_latex(self, node: nodes.Node) -> None: + self.body.append("Kconfig search is only available on HTML output") + raise nodes.SkipNode + + +class KconfigSearch(SphinxDirective): + """Kconfig search directive""" + + has_content = False + + def run(self): + if not self.config.kconfig_generate_db: + raise ExtensionError( + "Kconfig search directive can not be used without database" + ) + + if "kconfig_search_inserted" in self.env.temp_data: + raise ExtensionError("Kconfig search directive can only be used once") + + self.env.temp_data["kconfig_search_inserted"] = True + + # register all options to the domain at this point, so that they all + # resolve to the page where the kconfig:search directive is inserted + domain = self.env.get_domain("kconfig") + unique = set({option["name"] for option in self.env.kconfig_db}) + for option in unique: + domain.add_option(option) + + return [KconfigSearchNode()] + + +class _FindKconfigSearchDirectiveVisitor(nodes.NodeVisitor): + def __init__(self, document): + super().__init__(document) + self._found = False + + def unknown_visit(self, node: nodes.Node) -> None: + if self._found: + return + + self._found = isinstance(node, KconfigSearchNode) + + @property + def found_kconfig_search_directive(self) -> bool: + return self._found + + +class KconfigDomain(Domain): + """Kconfig domain""" + + name = "kconfig" + label = "Kconfig" + object_types = {"option": ObjType("option", "option")} + roles = {"option": XRefRole()} + directives = {"search": KconfigSearch} + initial_data: Dict[str, Any] = {"options": []} + + def get_objects(self) -> Iterable[Tuple[str, str, str, str, str, int]]: + for obj in self.data["options"]: + yield obj + + def merge_domaindata(self, docnames: List[str], otherdata: Dict) -> None: + self.data["options"] += otherdata["options"] + + def resolve_xref( + self, + env: BuildEnvironment, + fromdocname: str, + builder: Builder, + typ: str, + target: str, + node: pending_xref, + contnode: nodes.Element, + ) -> Optional[nodes.Element]: + match = [ + (docname, anchor) + for name, _, _, docname, anchor, _ in self.get_objects() + if name == target + ] + + if match: + todocname, anchor = match[0] + + return make_refnode( + builder, fromdocname, todocname, anchor, contnode, anchor + ) + else: + return None + + def add_option(self, option): + """Register a new Kconfig option to the domain.""" + + self.data["options"].append( + (option, option, "option", self.env.docname, option, -1) + ) + + +def sc_fmt(sc): + if isinstance(sc, kconfiglib.Symbol): + if sc.nodes: + return f'CONFIG_{sc.name}' + elif isinstance(sc, kconfiglib.Choice): + if not sc.name: + return "<choice>" + return f'<choice CONFIG_{sc.name}>' + + return kconfiglib.standard_sc_expr_str(sc) + + +def kconfig_build_resources(app: Sphinx) -> None: + """Build the Kconfig database and install HTML resources.""" + + if not app.config.kconfig_generate_db: + return + + with progress_message("Building Kconfig database..."): + kconfig, module_paths = kconfig_load(app) + db = list() + + for sc in chain(kconfig.unique_defined_syms, kconfig.unique_choices): + # skip nameless symbols + if not sc.name: + continue + + # store alternative defaults (from defconfig files) + alt_defaults = list() + for node in sc.nodes: + if "defconfig" not in node.filename: + continue + + for value, cond in node.orig_defaults: + fmt = kconfiglib.expr_str(value, sc_fmt) + if cond is not sc.kconfig.y: + fmt += f" if {kconfiglib.expr_str(cond, sc_fmt)}" + alt_defaults.append([fmt, node.filename]) + + # only process nodes with prompt or help + nodes = [node for node in sc.nodes if node.prompt or node.help] + + inserted_paths = list() + for node in nodes: + # avoid duplicate symbols by forcing unique paths. this can + # happen due to dependencies on 0, a trick used by some modules + path = f"{node.filename}:{node.linenr}" + if path in inserted_paths: + continue + inserted_paths.append(path) + + dependencies = None + if node.dep is not sc.kconfig.y: + dependencies = kconfiglib.expr_str(node.dep, sc_fmt) + + defaults = list() + for value, cond in node.orig_defaults: + fmt = kconfiglib.expr_str(value, sc_fmt) + if cond is not sc.kconfig.y: + fmt += f" if {kconfiglib.expr_str(cond, sc_fmt)}" + defaults.append(fmt) + + selects = list() + for value, cond in node.orig_selects: + fmt = kconfiglib.expr_str(value, sc_fmt) + if cond is not sc.kconfig.y: + fmt += f" if {kconfiglib.expr_str(cond, sc_fmt)}" + selects.append(fmt) + + implies = list() + for value, cond in node.orig_implies: + fmt = kconfiglib.expr_str(value, sc_fmt) + if cond is not sc.kconfig.y: + fmt += f" if {kconfiglib.expr_str(cond, sc_fmt)}" + implies.append(fmt) + + ranges = list() + for min, max, cond in node.orig_ranges: + fmt = ( + f"[{kconfiglib.expr_str(min, sc_fmt)}, " + f"{kconfiglib.expr_str(max, sc_fmt)}]" + ) + if cond is not sc.kconfig.y: + fmt += f" if {kconfiglib.expr_str(cond, sc_fmt)}" + ranges.append(fmt) + + choices = list() + if isinstance(sc, kconfiglib.Choice): + for sym in sc.syms: + choices.append(kconfiglib.expr_str(sym, sc_fmt)) + + filename = node.filename + for name, path in module_paths.items(): + if node.filename.startswith(path): + filename = node.filename.replace(path, f"
+ ${entry[1]}
+
`;
+ });
+
+ const show = document.createElement('a');
+ show.onclick = () => {
+ if (list.style.display === 'none') {
+ list.style.display = 'block';
+ } else {
+ list.style.display = 'none';
+ }
+ };
+ details.appendChild(show);
+
+ const showText = document.createTextNode('Show/Hide other defaults');
+ show.appendChild(showText);
+ }
+}
+
+/**
+ * Render a Kconfig entry.
+ * @param {Object} entry Kconfig entry.
+ */
+function renderKconfigEntry(entry) {
+ const container = document.createElement('dl');
+ container.className = 'kconfig';
+
+ /* title (name and permalink) */
+ const title = document.createElement('dt');
+ title.className = 'sig sig-object';
+ container.appendChild(title);
+
+ const name = document.createElement('span');
+ name.className = 'pre';
+ title.appendChild(name);
+
+ const nameText = document.createTextNode(entry.name);
+ name.appendChild(nameText);
+
+ const permalink = document.createElement('a');
+ permalink.className = 'headerlink';
+ permalink.href = '#' + entry.name;
+ title.appendChild(permalink);
+
+ const permalinkText = document.createTextNode('\uf0c1');
+ permalink.appendChild(permalinkText);
+
+ /* details */
+ const details = document.createElement('dd');
+ container.append(details);
+
+ /* prompt and help */
+ const prompt = document.createElement('p');
+ details.appendChild(prompt);
+
+ const promptTitle = document.createElement('em');
+ prompt.appendChild(promptTitle);
+
+ const promptTitleText = document.createTextNode('');
+ promptTitle.appendChild(promptTitleText);
+ if (entry.prompt) {
+ promptTitleText.nodeValue = entry.prompt;
+ } else {
+ promptTitleText.nodeValue = 'No prompt - not directly user assignable.';
+ }
+
+ if (entry.help) {
+ const help = document.createElement('p');
+ details.appendChild(help);
+
+ const helpText = document.createTextNode(entry.help);
+ help.appendChild(helpText);
+ }
+
+ /* symbol properties (defaults, selects, etc.) */
+ const props = document.createElement('dl');
+ props.className = 'field-list simple';
+ details.appendChild(props);
+
+ renderKconfigPropLiteral(props, 'Type', entry.type);
+ if (entry.dependencies) {
+ renderKconfigPropList(props, 'Dependencies', [entry.dependencies]);
+ }
+ renderKconfigDefaults(props, entry.defaults, entry.alt_defaults);
+ renderKconfigPropList(props, 'Selects', entry.selects);
+ renderKconfigPropList(props, 'Implies', entry.implies);
+ renderKconfigPropList(props, 'Ranges', entry.ranges);
+ renderKconfigPropList(props, 'Choices', entry.choices);
+ renderKconfigPropLiteral(props, 'Location', `${entry.filename}:${entry.linenr}`);
+
+ return container;
+}
+
+/** Perform a search and display the results. */
+function doSearch() {
+ /* replace current state (to handle back button) */
+ history.replaceState({
+ value: input.value,
+ searchOffset: searchOffset
+ }, '', window.location);
+
+ /* nothing to search for */
+ if (!input.value) {
+ summaryText.nodeValue = '';
+ results.replaceChildren();
+ navigation.style.visibility = 'hidden';
+ return;
+ }
+
+ /* perform search */
+ let pattern = new RegExp(input.value, 'i');
+ let count = 0;
+
+ const searchResults = db.filter(entry => {
+ if (entry.name.match(pattern)) {
+ count++;
+ if (count > searchOffset && count <= (searchOffset + MAX_RESULTS)) {
+ return true;
+ }
+ }
+
+ return false;
+ });
+
+ /* show results count */
+ summaryText.nodeValue = `${count} options match your search.`;
+
+ /* update navigation */
+ navigation.style.visibility = 'visible';
+ navigationPrev.disabled = searchOffset - MAX_RESULTS < 0;
+ navigationNext.disabled = searchOffset + MAX_RESULTS > count;
+
+ const currentPage = Math.floor(searchOffset / MAX_RESULTS) + 1;
+ const totalPages = Math.floor(count / MAX_RESULTS) + 1;
+ navigationPagesText.nodeValue = `Page ${currentPage} of ${totalPages}`;
+
+ /* render Kconfig entries */
+ results.replaceChildren();
+ searchResults.forEach(entry => {
+ results.appendChild(renderKconfigEntry(entry));
+ });
+}
+
+/** Do a search from URL hash */
+function doSearchFromURL() {
+ const rawOption = window.location.hash.substring(1);
+ if (!rawOption) {
+ return;
+ }
+
+ const option = rawOption.replace(/[^A-Za-z0-9_]+/g, '');
+ input.value = '^' + option + '$';
+
+ searchOffset = 0;
+ doSearch();
+}
+
+function setupKconfigSearch() {
+ /* populate kconfig-search container */
+ const container = document.getElementById('__kconfig-search');
+ if (!container) {
+ console.error("Couldn't find Kconfig search container");
+ return;
+ }
+
+ /* create input field */
+ input = document.createElement('input');
+ input.placeholder = 'Type a Kconfig option name (RegEx allowed)';
+ input.type = 'text';
+ container.appendChild(input);
+
+ /* create search summary */
+ const searchSummary = document.createElement('p');
+ searchSummary.className = 'search-summary';
+ container.appendChild(searchSummary);
+
+ summaryText = document.createTextNode('');
+ searchSummary.appendChild(summaryText);
+
+ /* create search results container */
+ results = document.createElement('div');
+ container.appendChild(results);
+
+ /* create search navigation */
+ navigation = document.createElement('div');
+ navigation.className = 'search-nav';
+ navigation.style.visibility = 'hidden';
+ container.appendChild(navigation);
+
+ navigationPrev = document.createElement('button');
+ navigationPrev.className = 'btn';
+ navigationPrev.disabled = true;
+ navigationPrev.onclick = () => {
+ searchOffset -= MAX_RESULTS;
+ doSearch();
+ window.scroll(0, 0);
+ }
+ navigation.appendChild(navigationPrev);
+
+ const navigationPrevText = document.createTextNode('Previous');
+ navigationPrev.appendChild(navigationPrevText);
+
+ const navigationPages = document.createElement('p');
+ navigation.appendChild(navigationPages);
+
+ navigationPagesText = document.createTextNode('');
+ navigationPages.appendChild(navigationPagesText);
+
+ navigationNext = document.createElement('button');
+ navigationNext.className = 'btn';
+ navigationNext.disabled = true;
+ navigationNext.onclick = () => {
+ searchOffset += MAX_RESULTS;
+ doSearch();
+ window.scroll(0, 0);
+ }
+ navigation.appendChild(navigationNext);
+
+ const navigationNextText = document.createTextNode('Next');
+ navigationNext.appendChild(navigationNextText);
+
+ /* load database */
+ showProgress('Loading database...');
+
+ fetch(DB_FILE)
+ .then(response => response.json())
+ .then(json => {
+ db = json;
+
+ results.replaceChildren();
+
+ /* perform initial search */
+ doSearchFromURL();
+
+ /* install event listeners */
+ input.addEventListener('keyup', () => {
+ searchOffset = 0;
+ doSearch();
+ });
+
+ /* install hash change listener (for links) */
+ window.addEventListener('hashchange', doSearchFromURL);
+
+ /* handle back/forward navigation */
+ window.addEventListener('popstate', (event) => {
+ if (!event.state) {
+ return;
+ }
+
+ input.value = event.state.value;
+ searchOffset = event.state.searchOffset;
+ doSearch();
+ });
+ })
+ .catch(error => {
+ showError(`Kconfig database could not be loaded (${error})`);
+ });
+}
+
+setupKconfigSearch();