qapidoc.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583
  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. qapidoc is a Sphinx extension that implements the qapi-doc directive
  11. The purpose of this extension is to read the documentation comments
  12. in QAPI schema files, and insert them all into the current document.
  13. It implements one new rST directive, "qapi-doc::".
  14. Each qapi-doc:: directive takes one argument, which is the
  15. pathname of the schema file to process, relative to the source tree.
  16. The docs/conf.py file must set the qapidoc_srctree config value to
  17. the root of the QEMU source tree.
  18. The Sphinx documentation on writing extensions is at:
  19. https://www.sphinx-doc.org/en/master/development/index.html
  20. """
  21. from __future__ import annotations
  22. from contextlib import contextmanager
  23. import os
  24. from pathlib import Path
  25. import re
  26. import sys
  27. from typing import TYPE_CHECKING
  28. from docutils import nodes
  29. from docutils.parsers.rst import Directive, directives
  30. from docutils.statemachine import StringList
  31. from qapi.error import QAPIError
  32. from qapi.parser import QAPIDoc
  33. from qapi.schema import (
  34. QAPISchema,
  35. QAPISchemaArrayType,
  36. QAPISchemaCommand,
  37. QAPISchemaDefinition,
  38. QAPISchemaEnumMember,
  39. QAPISchemaFeature,
  40. QAPISchemaMember,
  41. QAPISchemaObjectTypeMember,
  42. QAPISchemaType,
  43. QAPISchemaVisitor,
  44. )
  45. from qapi.source import QAPISourceInfo
  46. from qapidoc_legacy import QAPISchemaGenRSTVisitor # type: ignore
  47. from sphinx import addnodes
  48. from sphinx.directives.code import CodeBlock
  49. from sphinx.errors import ExtensionError
  50. from sphinx.util.docutils import switch_source_input
  51. from sphinx.util.nodes import nested_parse_with_titles
  52. if TYPE_CHECKING:
  53. from typing import (
  54. Any,
  55. Generator,
  56. List,
  57. Optional,
  58. Sequence,
  59. Union,
  60. )
  61. from sphinx.application import Sphinx
  62. from sphinx.util.typing import ExtensionMetadata
  63. __version__ = "1.0"
  64. class Transmogrifier:
  65. # pylint: disable=too-many-public-methods
  66. # Field names used for different entity types:
  67. field_types = {
  68. "enum": "value",
  69. "struct": "memb",
  70. "union": "memb",
  71. "event": "memb",
  72. "command": "arg",
  73. "alternate": "alt",
  74. }
  75. def __init__(self) -> None:
  76. self._curr_ent: Optional[QAPISchemaDefinition] = None
  77. self._result = StringList()
  78. self.indent = 0
  79. @property
  80. def entity(self) -> QAPISchemaDefinition:
  81. assert self._curr_ent is not None
  82. return self._curr_ent
  83. @property
  84. def member_field_type(self) -> str:
  85. return self.field_types[self.entity.meta]
  86. # General-purpose rST generation functions
  87. def get_indent(self) -> str:
  88. return " " * self.indent
  89. @contextmanager
  90. def indented(self) -> Generator[None]:
  91. self.indent += 1
  92. try:
  93. yield
  94. finally:
  95. self.indent -= 1
  96. def add_line_raw(self, line: str, source: str, *lineno: int) -> None:
  97. """Append one line of generated reST to the output."""
  98. # NB: Sphinx uses zero-indexed lines; subtract one.
  99. lineno = tuple((n - 1 for n in lineno))
  100. if line.strip():
  101. # not a blank line
  102. self._result.append(
  103. self.get_indent() + line.rstrip("\n"), source, *lineno
  104. )
  105. else:
  106. self._result.append("", source, *lineno)
  107. def add_line(self, content: str, info: QAPISourceInfo) -> None:
  108. # NB: We *require* an info object; this works out OK because we
  109. # don't document built-in objects that don't have
  110. # one. Everything else should.
  111. self.add_line_raw(content, info.fname, info.line)
  112. def add_lines(
  113. self,
  114. content: str,
  115. info: QAPISourceInfo,
  116. ) -> None:
  117. lines = content.splitlines(True)
  118. for i, line in enumerate(lines):
  119. self.add_line_raw(line, info.fname, info.line + i)
  120. def ensure_blank_line(self) -> None:
  121. # Empty document -- no blank line required.
  122. if not self._result:
  123. return
  124. # Last line isn't blank, add one.
  125. if self._result[-1].strip(): # pylint: disable=no-member
  126. fname, line = self._result.info(-1)
  127. assert isinstance(line, int)
  128. # New blank line is credited to one-after the current last line.
  129. # +2: correct for zero/one index, then increment by one.
  130. self.add_line_raw("", fname, line + 2)
  131. def add_field(
  132. self,
  133. kind: str,
  134. name: str,
  135. body: str,
  136. info: QAPISourceInfo,
  137. typ: Optional[str] = None,
  138. ) -> None:
  139. if typ:
  140. text = f":{kind} {typ} {name}: {body}"
  141. else:
  142. text = f":{kind} {name}: {body}"
  143. self.add_lines(text, info)
  144. def format_type(
  145. self, ent: Union[QAPISchemaDefinition | QAPISchemaMember]
  146. ) -> Optional[str]:
  147. if isinstance(ent, (QAPISchemaEnumMember, QAPISchemaFeature)):
  148. return None
  149. qapi_type = ent
  150. optional = False
  151. if isinstance(ent, QAPISchemaObjectTypeMember):
  152. qapi_type = ent.type
  153. optional = ent.optional
  154. if isinstance(qapi_type, QAPISchemaArrayType):
  155. ret = f"[{qapi_type.element_type.doc_type()}]"
  156. else:
  157. assert isinstance(qapi_type, QAPISchemaType)
  158. tmp = qapi_type.doc_type()
  159. assert tmp
  160. ret = tmp
  161. if optional:
  162. ret += "?"
  163. return ret
  164. def generate_field(
  165. self,
  166. kind: str,
  167. member: QAPISchemaMember,
  168. body: str,
  169. info: QAPISourceInfo,
  170. ) -> None:
  171. typ = self.format_type(member)
  172. self.add_field(kind, member.name, body, info, typ)
  173. # Transmogrification helpers
  174. def visit_paragraph(self, section: QAPIDoc.Section) -> None:
  175. # Squelch empty paragraphs.
  176. if not section.text:
  177. return
  178. self.ensure_blank_line()
  179. self.add_lines(section.text, section.info)
  180. self.ensure_blank_line()
  181. def visit_member(self, section: QAPIDoc.ArgSection) -> None:
  182. # FIXME: ifcond for members
  183. # TODO: features for members (documented at entity-level,
  184. # but sometimes defined per-member. Should we add such
  185. # information to member descriptions when we can?)
  186. assert section.text and section.member
  187. self.generate_field(
  188. self.member_field_type,
  189. section.member,
  190. section.text,
  191. section.info,
  192. )
  193. def visit_feature(self, section: QAPIDoc.ArgSection) -> None:
  194. # FIXME - ifcond for features is not handled at all yet!
  195. # Proposal: decorate the right-hand column with some graphical
  196. # element to indicate conditional availability?
  197. assert section.text # Guaranteed by parser.py
  198. assert section.member
  199. self.generate_field("feat", section.member, section.text, section.info)
  200. def visit_returns(self, section: QAPIDoc.Section) -> None:
  201. assert isinstance(self.entity, QAPISchemaCommand)
  202. rtype = self.entity.ret_type
  203. # q_empty can produce None, but we won't be documenting anything
  204. # without an explicit return statement in the doc block, and we
  205. # should not have any such explicit statements when there is no
  206. # return value.
  207. assert rtype
  208. typ = self.format_type(rtype)
  209. assert typ
  210. assert section.text
  211. self.add_field("return", typ, section.text, section.info)
  212. def visit_errors(self, section: QAPIDoc.Section) -> None:
  213. # FIXME: the formatting for errors may be inconsistent and may
  214. # or may not require different newline placement to ensure
  215. # proper rendering as a nested list.
  216. self.add_lines(f":error:\n{section.text}", section.info)
  217. def preamble(self, ent: QAPISchemaDefinition) -> None:
  218. """
  219. Generate option lines for QAPI entity directives.
  220. """
  221. if ent.doc and ent.doc.since:
  222. assert ent.doc.since.kind == QAPIDoc.Kind.SINCE
  223. # Generated from the entity's docblock; info location is exact.
  224. self.add_line(f":since: {ent.doc.since.text}", ent.doc.since.info)
  225. if ent.ifcond.is_present():
  226. doc = ent.ifcond.docgen()
  227. assert ent.info
  228. # Generated from entity definition; info location is approximate.
  229. self.add_line(f":ifcond: {doc}", ent.info)
  230. # Hoist special features such as :deprecated: and :unstable:
  231. # into the options block for the entity. If, in the future, new
  232. # special features are added, qapi-domain will chirp about
  233. # unrecognized options and fail until they are handled in
  234. # qapi-domain.
  235. for feat in ent.features:
  236. if feat.is_special():
  237. # FIXME: handle ifcond if present. How to display that
  238. # information is TBD.
  239. # Generated from entity def; info location is approximate.
  240. assert feat.info
  241. self.add_line(f":{feat.name}:", feat.info)
  242. self.ensure_blank_line()
  243. def visit_sections(self, ent: QAPISchemaDefinition) -> None:
  244. sections = ent.doc.all_sections if ent.doc else []
  245. # Add sections in source order:
  246. for section in sections:
  247. if section.kind == QAPIDoc.Kind.PLAIN:
  248. self.visit_paragraph(section)
  249. elif section.kind == QAPIDoc.Kind.MEMBER:
  250. assert isinstance(section, QAPIDoc.ArgSection)
  251. self.visit_member(section)
  252. elif section.kind == QAPIDoc.Kind.FEATURE:
  253. assert isinstance(section, QAPIDoc.ArgSection)
  254. self.visit_feature(section)
  255. elif section.kind in (QAPIDoc.Kind.SINCE, QAPIDoc.Kind.TODO):
  256. # Since is handled in preamble, TODO is skipped intentionally.
  257. pass
  258. elif section.kind == QAPIDoc.Kind.RETURNS:
  259. self.visit_returns(section)
  260. elif section.kind == QAPIDoc.Kind.ERRORS:
  261. self.visit_errors(section)
  262. else:
  263. assert False
  264. self.ensure_blank_line()
  265. # Transmogrification core methods
  266. def visit_module(self, path: str) -> None:
  267. name = Path(path).stem
  268. # module directives are credited to the first line of a module file.
  269. self.add_line_raw(f".. qapi:module:: {name}", path, 1)
  270. self.ensure_blank_line()
  271. def visit_freeform(self, doc: QAPIDoc) -> None:
  272. # TODO: Once the old qapidoc transformer is deprecated, freeform
  273. # sections can be updated to pure rST, and this transformed removed.
  274. #
  275. # For now, translate our micro-format into rST. Code adapted
  276. # from Peter Maydell's freeform().
  277. assert len(doc.all_sections) == 1, doc.all_sections
  278. body = doc.all_sections[0]
  279. text = body.text
  280. info = doc.info
  281. if re.match(r"=+ ", text):
  282. # Section/subsection heading (if present, will always be the
  283. # first line of the block)
  284. (heading, _, text) = text.partition("\n")
  285. (leader, _, heading) = heading.partition(" ")
  286. # Implicit +1 for heading in the containing .rst doc
  287. level = len(leader) + 1
  288. # https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html#sections
  289. markers = ' #*=_^"'
  290. overline = level <= 2
  291. marker = markers[level]
  292. self.ensure_blank_line()
  293. # This credits all 2 or 3 lines to the single source line.
  294. if overline:
  295. self.add_line(marker * len(heading), info)
  296. self.add_line(heading, info)
  297. self.add_line(marker * len(heading), info)
  298. self.ensure_blank_line()
  299. # Eat blank line(s) and advance info
  300. trimmed = text.lstrip("\n")
  301. text = trimmed
  302. info = info.next_line(len(text) - len(trimmed) + 1)
  303. self.add_lines(text, info)
  304. self.ensure_blank_line()
  305. def visit_entity(self, ent: QAPISchemaDefinition) -> None:
  306. assert ent.info
  307. try:
  308. self._curr_ent = ent
  309. # Squish structs and unions together into an "object" directive.
  310. meta = ent.meta
  311. if meta in ("struct", "union"):
  312. meta = "object"
  313. # This line gets credited to the start of the /definition/.
  314. self.add_line(f".. qapi:{meta}:: {ent.name}", ent.info)
  315. with self.indented():
  316. self.preamble(ent)
  317. self.visit_sections(ent)
  318. finally:
  319. self._curr_ent = None
  320. class QAPISchemaGenDepVisitor(QAPISchemaVisitor):
  321. """A QAPI schema visitor which adds Sphinx dependencies each module
  322. This class calls the Sphinx note_dependency() function to tell Sphinx
  323. that the generated documentation output depends on the input
  324. schema file associated with each module in the QAPI input.
  325. """
  326. def __init__(self, env: Any, qapidir: str) -> None:
  327. self._env = env
  328. self._qapidir = qapidir
  329. def visit_module(self, name: str) -> None:
  330. if name != "./builtin":
  331. qapifile = self._qapidir + "/" + name
  332. self._env.note_dependency(os.path.abspath(qapifile))
  333. super().visit_module(name)
  334. class NestedDirective(Directive):
  335. def run(self) -> Sequence[nodes.Node]:
  336. raise NotImplementedError
  337. def do_parse(self, rstlist: StringList, node: nodes.Node) -> None:
  338. """
  339. Parse rST source lines and add them to the specified node
  340. Take the list of rST source lines rstlist, parse them as
  341. rST, and add the resulting docutils nodes as children of node.
  342. The nodes are parsed in a way that allows them to include
  343. subheadings (titles) without confusing the rendering of
  344. anything else.
  345. """
  346. with switch_source_input(self.state, rstlist):
  347. nested_parse_with_titles(self.state, rstlist, node)
  348. class QAPIDocDirective(NestedDirective):
  349. """Extract documentation from the specified QAPI .json file"""
  350. required_argument = 1
  351. optional_arguments = 1
  352. option_spec = {
  353. "qapifile": directives.unchanged_required,
  354. "transmogrify": directives.flag,
  355. }
  356. has_content = False
  357. def new_serialno(self) -> str:
  358. """Return a unique new ID string suitable for use as a node's ID"""
  359. env = self.state.document.settings.env
  360. return "qapidoc-%d" % env.new_serialno("qapidoc")
  361. def transmogrify(self, schema: QAPISchema) -> nodes.Element:
  362. raise NotImplementedError
  363. def legacy(self, schema: QAPISchema) -> nodes.Element:
  364. vis = QAPISchemaGenRSTVisitor(self)
  365. vis.visit_begin(schema)
  366. for doc in schema.docs:
  367. if doc.symbol:
  368. vis.symbol(doc, schema.lookup_entity(doc.symbol))
  369. else:
  370. vis.freeform(doc)
  371. return vis.get_document_node() # type: ignore
  372. def run(self) -> Sequence[nodes.Node]:
  373. env = self.state.document.settings.env
  374. qapifile = env.config.qapidoc_srctree + "/" + self.arguments[0]
  375. qapidir = os.path.dirname(qapifile)
  376. transmogrify = "transmogrify" in self.options
  377. try:
  378. schema = QAPISchema(qapifile)
  379. # First tell Sphinx about all the schema files that the
  380. # output documentation depends on (including 'qapifile' itself)
  381. schema.visit(QAPISchemaGenDepVisitor(env, qapidir))
  382. except QAPIError as err:
  383. # Launder QAPI parse errors into Sphinx extension errors
  384. # so they are displayed nicely to the user
  385. raise ExtensionError(str(err)) from err
  386. if transmogrify:
  387. contentnode = self.transmogrify(schema)
  388. else:
  389. contentnode = self.legacy(schema)
  390. return contentnode.children
  391. class QMPExample(CodeBlock, NestedDirective):
  392. """
  393. Custom admonition for QMP code examples.
  394. When the :annotated: option is present, the body of this directive
  395. is parsed as normal rST, but with any '::' code blocks set to use
  396. the QMP lexer. Code blocks must be explicitly written by the user,
  397. but this allows for intermingling explanatory paragraphs with
  398. arbitrary rST syntax and code blocks for more involved examples.
  399. When :annotated: is absent, the directive body is treated as a
  400. simple standalone QMP code block literal.
  401. """
  402. required_argument = 0
  403. optional_arguments = 0
  404. has_content = True
  405. option_spec = {
  406. "annotated": directives.flag,
  407. "title": directives.unchanged,
  408. }
  409. def _highlightlang(self) -> addnodes.highlightlang:
  410. """Return the current highlightlang setting for the document"""
  411. node = None
  412. doc = self.state.document
  413. if hasattr(doc, "findall"):
  414. # docutils >= 0.18.1
  415. for node in doc.findall(addnodes.highlightlang):
  416. pass
  417. else:
  418. for elem in doc.traverse():
  419. if isinstance(elem, addnodes.highlightlang):
  420. node = elem
  421. if node:
  422. return node
  423. # No explicit directive found, use defaults
  424. node = addnodes.highlightlang(
  425. lang=self.env.config.highlight_language,
  426. force=False,
  427. # Yes, Sphinx uses this value to effectively disable line
  428. # numbers and not 0 or None or -1 or something. ¯\_(ツ)_/¯
  429. linenothreshold=sys.maxsize,
  430. )
  431. return node
  432. def admonition_wrap(self, *content: nodes.Node) -> List[nodes.Node]:
  433. title = "Example:"
  434. if "title" in self.options:
  435. title = f"{title} {self.options['title']}"
  436. admon = nodes.admonition(
  437. "",
  438. nodes.title("", title),
  439. *content,
  440. classes=["admonition", "admonition-example"],
  441. )
  442. return [admon]
  443. def run_annotated(self) -> List[nodes.Node]:
  444. lang_node = self._highlightlang()
  445. content_node: nodes.Element = nodes.section()
  446. # Configure QMP highlighting for "::" blocks, if needed
  447. if lang_node["lang"] != "QMP":
  448. content_node += addnodes.highlightlang(
  449. lang="QMP",
  450. force=False, # "True" ignores lexing errors
  451. linenothreshold=lang_node["linenothreshold"],
  452. )
  453. self.do_parse(self.content, content_node)
  454. # Restore prior language highlighting, if needed
  455. if lang_node["lang"] != "QMP":
  456. content_node += addnodes.highlightlang(**lang_node.attributes)
  457. return content_node.children
  458. def run(self) -> List[nodes.Node]:
  459. annotated = "annotated" in self.options
  460. if annotated:
  461. content_nodes = self.run_annotated()
  462. else:
  463. self.arguments = ["QMP"]
  464. content_nodes = super().run()
  465. return self.admonition_wrap(*content_nodes)
  466. def setup(app: Sphinx) -> ExtensionMetadata:
  467. """Register qapi-doc directive with Sphinx"""
  468. app.add_config_value("qapidoc_srctree", None, "env")
  469. app.add_directive("qapi-doc", QAPIDocDirective)
  470. app.add_directive("qmp-example", QMPExample)
  471. return {
  472. "version": __version__,
  473. "parallel_read_safe": True,
  474. "parallel_write_safe": True,
  475. }