1# coding=utf-8 2# 3# QEMU qapidoc QAPI file parsing extension 4# 5# Copyright (c) 2020 Linaro 6# 7# This work is licensed under the terms of the GNU GPLv2 or later. 8# See the COPYING file in the top-level directory. 9 10""" 11qapidoc is a Sphinx extension that implements the qapi-doc directive 12 13The purpose of this extension is to read the documentation comments 14in QAPI schema files, and insert them all into the current document. 15 16It implements one new rST directive, "qapi-doc::". 17Each qapi-doc:: directive takes one argument, which is the 18pathname of the schema file to process, relative to the source tree. 19 20The docs/conf.py file must set the qapidoc_srctree config value to 21the root of the QEMU source tree. 22 23The Sphinx documentation on writing extensions is at: 24https://www.sphinx-doc.org/en/master/development/index.html 25""" 26 27from __future__ import annotations 28 29from contextlib import contextmanager 30import os 31from pathlib import Path 32import sys 33from typing import TYPE_CHECKING 34 35from docutils import nodes 36from docutils.parsers.rst import Directive, directives 37from docutils.statemachine import StringList 38from qapi.error import QAPIError 39from qapi.schema import QAPISchema, QAPISchemaVisitor 40from qapi.source import QAPISourceInfo 41 42from qapidoc_legacy import QAPISchemaGenRSTVisitor # type: ignore 43from sphinx import addnodes 44from sphinx.directives.code import CodeBlock 45from sphinx.errors import ExtensionError 46from sphinx.util.docutils import switch_source_input 47from sphinx.util.nodes import nested_parse_with_titles 48 49 50if TYPE_CHECKING: 51 from typing import ( 52 Any, 53 Generator, 54 List, 55 Sequence, 56 ) 57 58 from sphinx.application import Sphinx 59 from sphinx.util.typing import ExtensionMetadata 60 61 62__version__ = "1.0" 63 64 65class Transmogrifier: 66 def __init__(self) -> None: 67 self._result = StringList() 68 self.indent = 0 69 70 # General-purpose rST generation functions 71 72 def get_indent(self) -> str: 73 return " " * self.indent 74 75 @contextmanager 76 def indented(self) -> Generator[None]: 77 self.indent += 1 78 try: 79 yield 80 finally: 81 self.indent -= 1 82 83 def add_line_raw(self, line: str, source: str, *lineno: int) -> None: 84 """Append one line of generated reST to the output.""" 85 86 # NB: Sphinx uses zero-indexed lines; subtract one. 87 lineno = tuple((n - 1 for n in lineno)) 88 89 if line.strip(): 90 # not a blank line 91 self._result.append( 92 self.get_indent() + line.rstrip("\n"), source, *lineno 93 ) 94 else: 95 self._result.append("", source, *lineno) 96 97 def add_line(self, content: str, info: QAPISourceInfo) -> None: 98 # NB: We *require* an info object; this works out OK because we 99 # don't document built-in objects that don't have 100 # one. Everything else should. 101 self.add_line_raw(content, info.fname, info.line) 102 103 def add_lines( 104 self, 105 content: str, 106 info: QAPISourceInfo, 107 ) -> None: 108 lines = content.splitlines(True) 109 for i, line in enumerate(lines): 110 self.add_line_raw(line, info.fname, info.line + i) 111 112 def ensure_blank_line(self) -> None: 113 # Empty document -- no blank line required. 114 if not self._result: 115 return 116 117 # Last line isn't blank, add one. 118 if self._result[-1].strip(): # pylint: disable=no-member 119 fname, line = self._result.info(-1) 120 assert isinstance(line, int) 121 # New blank line is credited to one-after the current last line. 122 # +2: correct for zero/one index, then increment by one. 123 self.add_line_raw("", fname, line + 2) 124 125 # Transmogrification core methods 126 127 def visit_module(self, path: str) -> None: 128 name = Path(path).stem 129 # module directives are credited to the first line of a module file. 130 self.add_line_raw(f".. qapi:module:: {name}", path, 1) 131 self.ensure_blank_line() 132 133 134class QAPISchemaGenDepVisitor(QAPISchemaVisitor): 135 """A QAPI schema visitor which adds Sphinx dependencies each module 136 137 This class calls the Sphinx note_dependency() function to tell Sphinx 138 that the generated documentation output depends on the input 139 schema file associated with each module in the QAPI input. 140 """ 141 142 def __init__(self, env: Any, qapidir: str) -> None: 143 self._env = env 144 self._qapidir = qapidir 145 146 def visit_module(self, name: str) -> None: 147 if name != "./builtin": 148 qapifile = self._qapidir + "/" + name 149 self._env.note_dependency(os.path.abspath(qapifile)) 150 super().visit_module(name) 151 152 153class NestedDirective(Directive): 154 def run(self) -> Sequence[nodes.Node]: 155 raise NotImplementedError 156 157 def do_parse(self, rstlist: StringList, node: nodes.Node) -> None: 158 """ 159 Parse rST source lines and add them to the specified node 160 161 Take the list of rST source lines rstlist, parse them as 162 rST, and add the resulting docutils nodes as children of node. 163 The nodes are parsed in a way that allows them to include 164 subheadings (titles) without confusing the rendering of 165 anything else. 166 """ 167 with switch_source_input(self.state, rstlist): 168 nested_parse_with_titles(self.state, rstlist, node) 169 170 171class QAPIDocDirective(NestedDirective): 172 """Extract documentation from the specified QAPI .json file""" 173 174 required_argument = 1 175 optional_arguments = 1 176 option_spec = { 177 "qapifile": directives.unchanged_required, 178 "transmogrify": directives.flag, 179 } 180 has_content = False 181 182 def new_serialno(self) -> str: 183 """Return a unique new ID string suitable for use as a node's ID""" 184 env = self.state.document.settings.env 185 return "qapidoc-%d" % env.new_serialno("qapidoc") 186 187 def transmogrify(self, schema: QAPISchema) -> nodes.Element: 188 raise NotImplementedError 189 190 def legacy(self, schema: QAPISchema) -> nodes.Element: 191 vis = QAPISchemaGenRSTVisitor(self) 192 vis.visit_begin(schema) 193 for doc in schema.docs: 194 if doc.symbol: 195 vis.symbol(doc, schema.lookup_entity(doc.symbol)) 196 else: 197 vis.freeform(doc) 198 return vis.get_document_node() # type: ignore 199 200 def run(self) -> Sequence[nodes.Node]: 201 env = self.state.document.settings.env 202 qapifile = env.config.qapidoc_srctree + "/" + self.arguments[0] 203 qapidir = os.path.dirname(qapifile) 204 transmogrify = "transmogrify" in self.options 205 206 try: 207 schema = QAPISchema(qapifile) 208 209 # First tell Sphinx about all the schema files that the 210 # output documentation depends on (including 'qapifile' itself) 211 schema.visit(QAPISchemaGenDepVisitor(env, qapidir)) 212 except QAPIError as err: 213 # Launder QAPI parse errors into Sphinx extension errors 214 # so they are displayed nicely to the user 215 raise ExtensionError(str(err)) from err 216 217 if transmogrify: 218 contentnode = self.transmogrify(schema) 219 else: 220 contentnode = self.legacy(schema) 221 222 return contentnode.children 223 224 225class QMPExample(CodeBlock, NestedDirective): 226 """ 227 Custom admonition for QMP code examples. 228 229 When the :annotated: option is present, the body of this directive 230 is parsed as normal rST, but with any '::' code blocks set to use 231 the QMP lexer. Code blocks must be explicitly written by the user, 232 but this allows for intermingling explanatory paragraphs with 233 arbitrary rST syntax and code blocks for more involved examples. 234 235 When :annotated: is absent, the directive body is treated as a 236 simple standalone QMP code block literal. 237 """ 238 239 required_argument = 0 240 optional_arguments = 0 241 has_content = True 242 option_spec = { 243 "annotated": directives.flag, 244 "title": directives.unchanged, 245 } 246 247 def _highlightlang(self) -> addnodes.highlightlang: 248 """Return the current highlightlang setting for the document""" 249 node = None 250 doc = self.state.document 251 252 if hasattr(doc, "findall"): 253 # docutils >= 0.18.1 254 for node in doc.findall(addnodes.highlightlang): 255 pass 256 else: 257 for elem in doc.traverse(): 258 if isinstance(elem, addnodes.highlightlang): 259 node = elem 260 261 if node: 262 return node 263 264 # No explicit directive found, use defaults 265 node = addnodes.highlightlang( 266 lang=self.env.config.highlight_language, 267 force=False, 268 # Yes, Sphinx uses this value to effectively disable line 269 # numbers and not 0 or None or -1 or something. ¯\_(ツ)_/¯ 270 linenothreshold=sys.maxsize, 271 ) 272 return node 273 274 def admonition_wrap(self, *content: nodes.Node) -> List[nodes.Node]: 275 title = "Example:" 276 if "title" in self.options: 277 title = f"{title} {self.options['title']}" 278 279 admon = nodes.admonition( 280 "", 281 nodes.title("", title), 282 *content, 283 classes=["admonition", "admonition-example"], 284 ) 285 return [admon] 286 287 def run_annotated(self) -> List[nodes.Node]: 288 lang_node = self._highlightlang() 289 290 content_node: nodes.Element = nodes.section() 291 292 # Configure QMP highlighting for "::" blocks, if needed 293 if lang_node["lang"] != "QMP": 294 content_node += addnodes.highlightlang( 295 lang="QMP", 296 force=False, # "True" ignores lexing errors 297 linenothreshold=lang_node["linenothreshold"], 298 ) 299 300 self.do_parse(self.content, content_node) 301 302 # Restore prior language highlighting, if needed 303 if lang_node["lang"] != "QMP": 304 content_node += addnodes.highlightlang(**lang_node.attributes) 305 306 return content_node.children 307 308 def run(self) -> List[nodes.Node]: 309 annotated = "annotated" in self.options 310 311 if annotated: 312 content_nodes = self.run_annotated() 313 else: 314 self.arguments = ["QMP"] 315 content_nodes = super().run() 316 317 return self.admonition_wrap(*content_nodes) 318 319 320def setup(app: Sphinx) -> ExtensionMetadata: 321 """Register qapi-doc directive with Sphinx""" 322 app.add_config_value("qapidoc_srctree", None, "env") 323 app.add_directive("qapi-doc", QAPIDocDirective) 324 app.add_directive("qmp-example", QMPExample) 325 326 return { 327 "version": __version__, 328 "parallel_read_safe": True, 329 "parallel_write_safe": True, 330 } 331