You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
674 lines
21 KiB
674 lines
21 KiB
#!/usr/bin/env python3 |
|
|
|
# Copyright 2020 David Robillard <d@drobilla.net> |
|
# |
|
# Permission to use, copy, modify, and/or distribute this software for any |
|
# purpose with or without fee is hereby granted, provided that the above |
|
# copyright notice and this permission notice appear in all copies. |
|
# |
|
# THIS SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES |
|
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF |
|
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR |
|
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES |
|
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN |
|
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF |
|
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. |
|
|
|
""" |
|
Write Sphinx markup from Doxygen XML. |
|
|
|
Takes a path to a directory of XML generated by Doxygen, and emits a directory |
|
with a reStructuredText file for every documented symbol. |
|
""" |
|
|
|
import argparse |
|
import os |
|
import sys |
|
import textwrap |
|
import xml.etree.ElementTree |
|
|
|
__author__ = "David Robillard" |
|
__date__ = "2020-11-18" |
|
__email__ = "d@drobilla.net" |
|
__license__ = "ISC" |
|
__version__ = __date__.replace("-", ".") |
|
|
|
|
|
def load_index(index_path): |
|
""" |
|
Load the index from XML. |
|
|
|
:returns: A dictionary from ID to skeleton records with basic information |
|
for every documented entity. Some records have an ``xml_filename`` key |
|
with the filename of a definition file. These files will be loaded later |
|
to flesh out the records in the index. |
|
""" |
|
|
|
root = xml.etree.ElementTree.parse(index_path).getroot() |
|
index = {} |
|
|
|
for compound in root: |
|
compound_id = compound.get("refid") |
|
compound_kind = compound.get("kind") |
|
compound_name = compound.find("name").text |
|
if compound_kind in ["dir", "file", "page"]: |
|
continue |
|
|
|
# Add record for compound (compounds appear only once in the index) |
|
assert compound_id not in index |
|
index[compound_id] = { |
|
"kind": compound_kind, |
|
"name": compound_name, |
|
"xml_filename": compound_id + ".xml", |
|
"children": [], |
|
} |
|
|
|
name_prefix = ( |
|
("%s::" % compound_name) if compound_kind == "namespace" else "" |
|
) |
|
|
|
for child in compound.findall("member"): |
|
if child.get("refid") in index: |
|
assert compound_kind == "group" |
|
continue |
|
|
|
# Everything has a kind and a name |
|
child_record = { |
|
"kind": child.get("kind"), |
|
"name": name_prefix + child.find("name").text, |
|
} |
|
|
|
if child.get("kind") == "enum": |
|
# Enums are not compounds, but we want to resolve the parent of |
|
# their values so they are not written as top level documents |
|
child_record["children"] = [] |
|
|
|
if child.get("kind") == "enumvalue": |
|
# Remove namespace prefix |
|
child_record["name"] = child.find("name").text |
|
|
|
index[child.get("refid")] = child_record |
|
|
|
return index |
|
|
|
|
|
def resolve_index(index, root): |
|
""" |
|
Walk a definition document and extend the index for linking. |
|
|
|
This does two things: sets the "parent" and "children" fields of all |
|
applicable records, and sets the "strong" field of enums so that the |
|
correct Sphinx role can be used when referring to them. |
|
""" |
|
|
|
def add_child(index, parent_id, child_id): |
|
parent = index[parent_id] |
|
child = index[child_id] |
|
|
|
if child["kind"] == "enumvalue": |
|
assert parent["kind"] == "enum" |
|
assert "parent" not in child or child["parent"] == parent_id |
|
child["parent"] = parent_id |
|
|
|
else: |
|
if parent["kind"] in ["class", "struct", "union"]: |
|
assert "parent" not in child or child["parent"] == parent_id |
|
child["parent"] = parent_id |
|
|
|
if child_id not in parent["children"]: |
|
parent["children"] += [child_id] |
|
|
|
compound = root.find("compounddef") |
|
compound_kind = compound.get("kind") |
|
|
|
if compound_kind == "group": |
|
for subgroup in compound.findall("innergroup"): |
|
add_child(index, compound.get("id"), subgroup.get("refid")) |
|
|
|
for klass in compound.findall("innerclass"): |
|
add_child(index, compound.get("id"), klass.get("refid")) |
|
|
|
for section in compound.findall("sectiondef"): |
|
if section.get("kind").startswith("private"): |
|
for member in section.findall("memberdef"): |
|
if member.get("id") in index: |
|
del index[member.get("id")] |
|
else: |
|
for member in section.findall("memberdef"): |
|
member_id = member.get("id") |
|
add_child(index, compound.get("id"), member_id) |
|
|
|
if member.get("kind") == "enum": |
|
index[member_id]["strong"] = member.get("strong") == "yes" |
|
for value in member.findall("enumvalue"): |
|
add_child(index, member_id, value.get("id")) |
|
|
|
|
|
def sphinx_role(record, lang): |
|
""" |
|
Return the Sphinx role used for a record. |
|
|
|
This is used for the description directive like ".. c:function::", and |
|
links like ":c:func:`foo`. |
|
""" |
|
|
|
kind = record["kind"] |
|
|
|
if kind in ["class", "function", "namespace", "struct", "union"]: |
|
return lang + ":" + kind |
|
|
|
if kind == "define": |
|
return "c:macro" |
|
|
|
if kind == "enum": |
|
return lang + (":enum-class" if record["strong"] else ":enum") |
|
|
|
if kind == "typedef": |
|
return lang + ":type" |
|
|
|
if kind == "enumvalue": |
|
return lang + ":enumerator" |
|
|
|
if kind == "variable": |
|
return lang + (":member" if "parent" in record else ":var") |
|
|
|
raise RuntimeError("No known role for kind '%s'" % kind) |
|
|
|
|
|
def child_identifier(lang, parent_name, child_name): |
|
""" |
|
Return the identifier for an enum value or struct member. |
|
|
|
Sphinx, for some reason, uses a different syntax for this in C and C++. |
|
""" |
|
|
|
separator = "::" if lang == "cpp" else "." |
|
|
|
return "%s%s%s" % (parent_name, separator, child_name) |
|
|
|
|
|
def link_markup(index, lang, refid): |
|
"""Return a Sphinx link for a Doxygen reference.""" |
|
|
|
record = index[refid] |
|
kind, name = record["kind"], record["name"] |
|
role = sphinx_role(record, lang) |
|
|
|
if kind in ["class", "enum", "struct", "typedef", "union"]: |
|
return ":%s:`%s`" % (role, name) |
|
|
|
if kind == "function": |
|
return ":%s:func:`%s`" % (lang, name) |
|
|
|
if kind == "enumvalue": |
|
parent_name = index[record["parent"]]["name"] |
|
return ":%s:`%s`" % (role, child_identifier(lang, parent_name, name)) |
|
|
|
if kind == "variable": |
|
if "parent" not in record: |
|
return ":%s:var:`%s`" % (lang, name) |
|
|
|
parent_name = index[record["parent"]]["name"] |
|
return ":%s:`%s`" % (role, child_identifier(lang, parent_name, name)) |
|
|
|
raise RuntimeError("Unknown link target kind: %s" % kind) |
|
|
|
|
|
def indent(markup, depth): |
|
""" |
|
Indent markup to a depth level. |
|
|
|
Like textwrap.indent() but takes an integer and works in reST indentation |
|
levels for clarity." |
|
""" |
|
|
|
return textwrap.indent(markup, " " * depth) |
|
|
|
|
|
def heading(text, level): |
|
""" |
|
Return a ReST heading at a given level. |
|
|
|
Follows the style in the Python documentation guide, see |
|
<https://devguide.python.org/documenting/#sections>. |
|
""" |
|
|
|
assert 1 <= level <= 6 |
|
|
|
chars = ("#", "*", "=", "-", "^", '"') |
|
line = chars[level] * len(text) |
|
|
|
return "%s\n%s\n%s\n\n" % (line if level < 3 else "", text, line) |
|
|
|
|
|
def dox_to_rst(index, lang, node): |
|
""" |
|
Convert documentation commands (docCmdGroup) to Sphinx markup. |
|
|
|
This is used to convert the content of descriptions in the documentation. |
|
It recursively parses all children tags and raises a RuntimeError if any |
|
unknown tag is encountered. |
|
""" |
|
|
|
def field_value(markup): |
|
"""Return a value for a field as a single line or indented block.""" |
|
if "\n" in markup.strip(): |
|
return "\n" + indent(markup, 1) |
|
|
|
return " " + markup.strip() |
|
|
|
if node.tag == "computeroutput": |
|
# assert len(node) == 0 FIXME |
|
return "``%s``" % node.text |
|
|
|
if node.tag == "itemizedlist": |
|
markup = "" |
|
for item in node.findall("listitem"): |
|
assert len(item) == 1 |
|
markup += "\n- %s" % dox_to_rst(index, lang, item[0]) |
|
|
|
return markup |
|
|
|
if node.tag == "para": |
|
markup = node.text if node.text is not None else "" |
|
for child in node: |
|
markup += dox_to_rst(index, lang, child) |
|
markup += child.tail if child.tail is not None else "" |
|
|
|
return markup.strip() + "\n\n" |
|
|
|
if node.tag == "parameterlist": |
|
markup = "" |
|
for item in node.findall("parameteritem"): |
|
name = item.find("parameternamelist/parametername") |
|
description = item.find("parameterdescription") |
|
assert len(description) == 1 |
|
markup += "\n\n:param %s:%s" % ( |
|
name.text, |
|
field_value(dox_to_rst(index, lang, description[0])), |
|
) |
|
|
|
return markup + "\n" |
|
|
|
if node.tag == "programlisting": |
|
return "\n.. code-block:: %s\n\n%s" % ( |
|
lang, |
|
indent(plain_text(node), 1), |
|
) |
|
|
|
if node.tag == "ref": |
|
refid = node.get("refid") |
|
if refid not in index: |
|
sys.stderr.write("warning: Unresolved link: %s\n" % refid) |
|
return node.text |
|
|
|
assert len(node) == 0 |
|
assert len(link_markup(index, lang, refid)) > 0 |
|
return link_markup(index, lang, refid) |
|
|
|
if node.tag == "simplesect": |
|
assert len(node) == 1 |
|
|
|
if node.get("kind") == "return": |
|
return "\n:returns:" + field_value( |
|
dox_to_rst(index, lang, node[0]) |
|
) |
|
|
|
if node.get("kind") == "see": |
|
return dox_to_rst(index, lang, node[0]) |
|
|
|
raise RuntimeError("Unknown simplesect kind: %s" % node.get("kind")) |
|
|
|
if node.tag == "ulink": |
|
return "`%s <%s>`_" % (node.text, node.get("url")) |
|
|
|
raise RuntimeError("Unknown documentation command: %s" % node.tag) |
|
|
|
|
|
def description_markup(index, lang, node): |
|
"""Return the markup for a brief or detailed description.""" |
|
|
|
assert node.tag == "briefdescription" or node.tag == "detaileddescription" |
|
assert not (node.tag == "briefdescription" and len(node) > 1) |
|
assert len(node.text.strip()) == 0 |
|
|
|
return "".join([dox_to_rst(index, lang, child) for child in node]) |
|
|
|
|
|
def set_descriptions(index, lang, definition, record): |
|
"""Set a record's brief/detailed descriptions from the XML definition.""" |
|
|
|
for tag in ["briefdescription", "detaileddescription"]: |
|
node = definition.find(tag) |
|
if node is not None: |
|
record[tag] = description_markup(index, lang, node) |
|
|
|
|
|
def set_template_params(node, record): |
|
"""Set a record's template_params from the XML definition.""" |
|
|
|
template_param_list = node.find("templateparamlist") |
|
if template_param_list is not None: |
|
params = [] |
|
for param in template_param_list.findall("param"): |
|
if param.find("declname") is not None: |
|
# Value parameter |
|
type_text = plain_text(param.find("type")) |
|
name_text = plain_text(param.find("declname")) |
|
|
|
params += ["%s %s" % (type_text, name_text)] |
|
else: |
|
# Type parameter |
|
params += ["%s" % (plain_text(param.find("type")))] |
|
|
|
record["template_params"] = "%s" % ", ".join(params) |
|
|
|
|
|
def plain_text(node): |
|
""" |
|
Return the plain text of a node with all tags ignored. |
|
|
|
This is needed where Doxygen may include refs but Sphinx needs plain text |
|
because it parses things itself to generate links. |
|
""" |
|
|
|
if node.tag == "sp": |
|
markup = " " |
|
elif node.text is not None: |
|
markup = node.text |
|
else: |
|
markup = "" |
|
|
|
for child in node: |
|
markup += plain_text(child) |
|
markup += child.tail if child.tail is not None else "" |
|
|
|
return markup |
|
|
|
|
|
def local_name(name): |
|
"""Return a name with all namespace prefixes stripped.""" |
|
|
|
return name[name.rindex("::") + 2 :] if "::" in name else name |
|
|
|
|
|
def read_definition_doc(index, lang, root): |
|
"""Walk a definition document and update described records in the index.""" |
|
|
|
# Set descriptions for the compound itself |
|
compound = root.find("compounddef") |
|
compound_record = index[compound.get("id")] |
|
set_descriptions(index, lang, compound, compound_record) |
|
set_template_params(compound, compound_record) |
|
|
|
if compound.find("title") is not None: |
|
compound_record["title"] = compound.find("title").text.strip() |
|
|
|
# Set documentation for all children |
|
for section in compound.findall("sectiondef"): |
|
if section.get("kind").startswith("private"): |
|
continue |
|
|
|
for member in section.findall("memberdef"): |
|
kind = member.get("kind") |
|
record = index[member.get("id")] |
|
set_descriptions(index, lang, member, record) |
|
set_template_params(member, record) |
|
|
|
if compound.get("kind") in ["class", "struct", "union"]: |
|
assert kind in ["function", "typedef", "variable"] |
|
record["type"] = plain_text(member.find("type")) |
|
|
|
if kind == "enum": |
|
for value in member.findall("enumvalue"): |
|
set_descriptions( |
|
index, lang, value, index[value.get("id")] |
|
) |
|
|
|
elif kind == "function": |
|
record["prototype"] = "%s %s%s" % ( |
|
plain_text(member.find("type")), |
|
member.find("name").text, |
|
member.find("argsstring").text, |
|
) |
|
|
|
elif kind == "typedef": |
|
name = local_name(record["name"]) |
|
args_text = member.find("argsstring").text |
|
target_text = plain_text(member.find("type")) |
|
if args_text is not None: # Function pointer |
|
assert target_text[-2:] == "(*" and args_text[0] == ")" |
|
record["type"] = target_text + args_text |
|
record["definition"] = target_text + name + args_text |
|
else: # Normal named typedef |
|
assert target_text is not None |
|
record["type"] = target_text |
|
if member.find("definition").text.startswith("using"): |
|
record["definition"] = "%s = %s" % ( |
|
name, |
|
target_text, |
|
) |
|
else: |
|
record["definition"] = "%s %s" % ( |
|
target_text, |
|
name, |
|
) |
|
|
|
elif kind == "variable": |
|
record["definition"] = member.find("definition").text |
|
|
|
|
|
def declaration_string(record): |
|
""" |
|
Return the string that describes a declaration. |
|
|
|
This is what follows the directive, and is in C/C++ syntax, except without |
|
keywords like "typedef" and "using" as expected by Sphinx. For example, |
|
"struct ThingImpl Thing" or "void run(int value)". |
|
""" |
|
|
|
kind = record["kind"] |
|
result = "" |
|
|
|
if "template_params" in record: |
|
result = "template <%s> " % record["template_params"] |
|
|
|
if kind == "function": |
|
result += record["prototype"] |
|
elif kind == "typedef": |
|
result += record["definition"] |
|
elif kind == "variable": |
|
if "parent" in record: |
|
result += "%s %s" % (record["type"], local_name(record["name"])) |
|
else: |
|
result += record["definition"] |
|
elif "type" in record: |
|
result += "%s %s" % (record["type"], local_name(record["name"])) |
|
else: |
|
result += local_name(record["name"]) |
|
|
|
return result |
|
|
|
|
|
def document_markup(index, lang, record): |
|
"""Return the complete document that describes some documented entity.""" |
|
|
|
kind = record["kind"] |
|
role = sphinx_role(record, lang) |
|
name = record["name"] |
|
markup = "" |
|
|
|
if name != local_name(name): |
|
markup += ".. cpp:namespace:: %s\n\n" % name[0 : name.rindex("::")] |
|
|
|
# Write top-level directive |
|
markup += ".. %s:: %s\n" % (role, declaration_string(record)) |
|
|
|
# Write main description blurb |
|
markup += "\n" |
|
markup += indent(record["briefdescription"], 1) |
|
markup += indent(record["detaileddescription"], 1) |
|
|
|
assert ( |
|
kind in ["class", "enum", "namespace", "struct", "union"] |
|
or "children" not in record |
|
) |
|
|
|
# Sphinx C++ namespaces work by setting a scope, they have no content |
|
child_indent = 0 if kind == "namespace" else 1 |
|
|
|
# Write inline children if applicable |
|
markup += "\n" |
|
for child_id in record.get("children", []): |
|
child_record = index[child_id] |
|
child_role = sphinx_role(child_record, lang) |
|
|
|
child_header = ".. %s:: %s\n\n" % ( |
|
child_role, |
|
declaration_string(child_record), |
|
) |
|
|
|
markup += "\n" |
|
markup += indent(child_header, child_indent) |
|
markup += indent(child_record["briefdescription"], child_indent + 1) |
|
markup += indent(child_record["detaileddescription"], child_indent + 1) |
|
markup += "\n" |
|
|
|
return markup |
|
|
|
|
|
def symbol_filename(name): |
|
"""Adapt the name of a symbol to be suitable for use as a filename.""" |
|
|
|
return name.replace("::", "__") |
|
|
|
|
|
def emit_symbols(index, lang, symbol_dir, force): |
|
"""Write a description file for every symbol documented in the index.""" |
|
|
|
for record in index.values(): |
|
if ( |
|
record["kind"] in ["group", "namespace"] |
|
or "parent" in record |
|
and index[record["parent"]]["kind"] != "group" |
|
): |
|
continue |
|
|
|
name = record["name"] |
|
filename = os.path.join(symbol_dir, symbol_filename("%s.rst" % name)) |
|
if not force and os.path.exists(filename): |
|
raise FileExistsError("File already exists: '%s'" % filename) |
|
|
|
with open(filename, "w") as rst: |
|
rst.write(heading(local_name(name), 3)) |
|
rst.write(document_markup(index, lang, record)) |
|
|
|
|
|
def emit_groups(index, output_dir, symbol_dir_name, force): |
|
"""Write a description file for every group documented in the index.""" |
|
|
|
for record in index.values(): |
|
if record["kind"] != "group": |
|
continue |
|
|
|
name = record["name"] |
|
filename = os.path.join(output_dir, "%s.rst" % name) |
|
if not force and os.path.exists(filename): |
|
raise FileExistsError("File already exists: '%s'" % filename) |
|
|
|
with open(filename, "w") as rst: |
|
rst.write(heading(record["title"], 2)) |
|
|
|
# Get all child group and symbol names |
|
group_names = [] |
|
symbol_names = [] |
|
for child_id in record["children"]: |
|
child = index[child_id] |
|
if child["kind"] == "group": |
|
group_names += [child["name"]] |
|
else: |
|
symbol_names += [child["name"]] |
|
|
|
# Emit description (document body) |
|
rst.write(record["briefdescription"] + "\n\n") |
|
rst.write(record["detaileddescription"] + "\n\n") |
|
|
|
# Emit TOC |
|
rst.write(".. toctree::\n") |
|
|
|
# Emit groups at the top of the TOC |
|
for group_name in group_names: |
|
rst.write("\n" + indent(group_name, 1)) |
|
|
|
# Emit symbols in sorted order |
|
for symbol_name in sorted(symbol_names): |
|
path = "/".join( |
|
[symbol_dir_name, symbol_filename(symbol_name)] |
|
) |
|
rst.write("\n" + indent(path, 1)) |
|
|
|
rst.write("\n") |
|
|
|
|
|
def run(index_xml_path, output_dir, symbol_dir_name, language, force): |
|
"""Write a directory of Sphinx files from a Doxygen XML directory.""" |
|
|
|
# Build skeleton index from index.xml |
|
xml_dir = os.path.dirname(index_xml_path) |
|
index = load_index(index_xml_path) |
|
|
|
# Load all definition documents |
|
definition_docs = [] |
|
for record in index.values(): |
|
if "xml_filename" in record: |
|
xml_path = os.path.join(xml_dir, record["xml_filename"]) |
|
definition_docs += [xml.etree.ElementTree.parse(xml_path)] |
|
|
|
# Do an initial pass of the definition documents to resolve the index |
|
for root in definition_docs: |
|
resolve_index(index, root) |
|
|
|
# Finally read the documentation from definition documents |
|
for root in definition_docs: |
|
read_definition_doc(index, language, root) |
|
|
|
# Emit output files |
|
symbol_dir = os.path.join(output_dir, symbol_dir_name) |
|
os.makedirs(symbol_dir, exist_ok=True) |
|
emit_symbols(index, language, symbol_dir, force) |
|
emit_groups(index, output_dir, symbol_dir_name, force) |
|
|
|
|
|
if __name__ == "__main__": |
|
ap = argparse.ArgumentParser( |
|
usage="%(prog)s [OPTION]... XML_DIR OUTPUT_DIR", |
|
description=__doc__, |
|
formatter_class=argparse.RawDescriptionHelpFormatter, |
|
) |
|
|
|
ap.add_argument( |
|
"-f", |
|
"--force", |
|
action="store_true", |
|
help="overwrite files", |
|
) |
|
|
|
ap.add_argument( |
|
"-l", |
|
"--language", |
|
default="c", |
|
choices=["c", "cpp"], |
|
help="language domain for output", |
|
) |
|
|
|
ap.add_argument( |
|
"-s", |
|
"--symbol-dir-name", |
|
default="symbols", |
|
help="name for subdirectory of symbol documentation files", |
|
) |
|
|
|
ap.add_argument("index_xml_path", help="path index.xml from Doxygen") |
|
ap.add_argument("output_dir", help="output directory") |
|
|
|
run(**vars(ap.parse_args(sys.argv[1:])))
|
|
|