#!/usr/bin/env python3

# Copyright 2020-2025 David Robillard <d@drobilla.net>
# SPDX-License-Identifier: ISC

"""
Write Sphinx markup from Doxygen XML.

Takes a path to the index.xml in a directory generated by Doxygen, and emits a
directory with reStructuredText files for all documented symbols.
"""

import argparse
import os
import sys
import textwrap
import xml.etree.ElementTree

__author__ = "David Robillard"
__date__ = "2025-01-18"
__email__ = "d@drobilla.net"
__license__ = "ISC"
__version__ = "1.0.12"


entities = {
    # HTML 4 entities in XML-ese
    "nonbreakablespace": " ",
    "iexcl": "¡",
    "cent": "¢",
    "pound": "£",
    "curren": "¤",
    "yen": "¥",
    "brvbar": "¦",
    "sect": "§",
    "umlaut": "¨",
    "copy": "©",
    "ordf": "ª",
    "laquo": "«",
    "not": "¬",
    "shy": "­",
    "registered": "®",
    "macr": "¯",
    "deg": "°",
    "plusmn": "±",
    "sup2": "²",
    "sup3": "³",
    "acute": "´",
    "micro": "µ",
    # "para": "¶", # Overlap with Doxygen <para> tag
    "middot": "·",
    "cedil": "¸",
    "sup1": "¹",
    "ordm": "º",
    "raquo": "»",
    "frac14": "¼",
    "frac12": "½",
    "frac34": "¾",
    "iquest": "¿",
    "Agrave": "À",
    "Aacute": "Á",
    "Acirc": "Â",
    "Atilde": "Ã",
    "Aumlaut": "Ä",
    "Aring": "Å",
    "AElig": "Æ",
    "Ccedil": "Ç",
    "Egrave": "È",
    "Eacute": "É",
    "Ecirc": "Ê",
    "Eumlaut": "Ë",
    "Igrave": "Ì",
    "Iacute": "Í",
    "Icirc": "Î",
    "Iumlaut": "Ï",
    "ETH": "Ð",
    "Ntilde": "Ñ",
    "Ograve": "Ò",
    "Oacute": "Ó",
    "Ocirc": "Ô",
    "Otilde": "Õ",
    "Oumlaut": "Ö",
    "times": "×",
    "Oslash": "Ø",
    "Ugrave": "Ù",
    "Uacute": "Ú",
    "Ucirc": "Û",
    "Uumlaut": "Ü",
    "Yacute": "Ý",
    "THORN": "Þ",
    "szlig": "ß",
    "agrave": "à",
    "aacute": "á",
    "acirc": "â",
    "atilde": "ã",
    "aumlaut": "ä",
    "aring": "å",
    "aelig": "æ",
    "ccedil": "ç",
    "egrave": "è",
    "eacute": "é",
    "ecirc": "ê",
    "eumlaut": "ë",
    "igrave": "ì",
    "iacute": "í",
    "icirc": "î",
    "iumlaut": "ï",
    "eth": "ð",
    "ntilde": "ñ",
    "ograve": "ò",
    "oacute": "ó",
    "ocirc": "ô",
    "otilde": "õ",
    "oumlaut": "ö",
    "divide": "÷",
    "oslash": "ø",
    "ugrave": "ù",
    "uacute": "ú",
    "ucirc": "û",
    "uumlaut": "ü",
    "yacute": "ý",
    "thorn": "þ",
    "yumlaut": "ÿ",
    "fnof": "ƒ",
    "Alpha": "Α",
    "Beta": "Β",
    "Gamma": "Γ",
    "Delta": "Δ",
    "Epsilon": "Ε",
    "Zeta": "Ζ",
    "Eta": "Η",
    "Theta": "Θ",
    "Iota": "Ι",
    "Kappa": "Κ",
    "Lambda": "Λ",
    "Mu": "Μ",
    "Nu": "Ν",
    "Xi": "Ξ",
    "Omicron": "Ο",
    "Pi": "Π",
    "Rho": "Ρ",
    "Sigma": "Σ",
    "Tau": "Τ",
    "Upsilon": "Υ",
    "Phi": "Φ",
    "Chi": "Χ",
    "Psi": "Ψ",
    "Omega": "Ω",
    "alpha": "α",
    "beta": "β",
    "gamma": "γ",
    "delta": "δ",
    "epsilon": "ε",
    "zeta": "ζ",
    "eta": "η",
    "theta": "θ",
    "iota": "ι",
    "kappa": "κ",
    "lambda": "λ",
    "mu": "μ",
    "nu": "ν",
    "xi": "ξ",
    "omicron": "ο",
    "pi": "π",
    "rho": "ρ",
    "sigmaf": "ς",
    "sigma": "σ",
    "tau": "τ",
    "upsilon": "υ",
    "phi": "φ",
    "chi": "χ",
    "psi": "ψ",
    "omega": "ω",
    "thetasym": "ϑ",
    "upsih": "ϒ",
    "piv": "ϖ",
    "bull": "•",
    "hellip": "…",
    "prime": "′",
    "Prime": "″",
    "oline": "‾",
    "frasl": "⁄",
    "weierp": "℘",
    "imaginary": "ℑ",
    "real": "ℜ",
    "trademark": "™",
    "alefsym": "ℵ",
    "larr": "←",
    "uarr": "↑",
    "rarr": "→",
    "darr": "↓",
    "harr": "↔",
    "crarr": "↵",
    "lArr": "⇐",
    "uArr": "⇑",
    "rArr": "⇒",
    "dArr": "⇓",
    "hArr": "⇔",
    "forall": "∀",
    "part": "∂",
    "exist": "∃",
    "empty": "∅",
    "nabla": "∇",
    "isin": "∈",
    "notin": "∉",
    "ni": "∋",
    "prod": "∏",
    "sum": "∑",
    "minus": "−",
    "lowast": "∗",
    "radic": "√",
    "prop": "∝",
    "infin": "∞",
    "ang": "∠",
    "and": "∧",
    "or": "∨",
    "cap": "∩",
    "cup": "∪",
    "int": "∫",
    "there4": "∴",
    "sim": "∼",
    "cong": "≅",
    "asymp": "≈",
    "ne": "≠",
    "equiv": "≡",
    "le": "≤",
    "ge": "≥",
    "sub": "⊂",
    "sup": "⊃",
    "nsub": "⊄",
    "sube": "⊆",
    "supe": "⊇",
    "oplus": "⊕",
    "otimes": "⊗",
    "perp": "⊥",
    "sdot": "⋅",
    "lceil": "⌈",
    "rceil": "⌉",
    "lfloor": "⌊",
    "rfloor": "⌋",
    "lang": "⟨",
    "rang": "⟩",
    "loz": "◊",
    "spades": "♠",
    "clubs": "♣",
    "hearts": "♥",
    "diams": "♦",
    '"': '"',
    "&amp;": "&",
    "&lt;": "",
    "&gt;": ">",
    "OElig": "Œ",
    "oelig": "œ",
    "Scaron": "Š",
    "scaron": "š",
    "Yumlaut": "Ÿ",
    "circ": "ˆ",
    "tilde": "˜",
    "ensp": " ",
    "emsp": " ",
    "thinsp": " ",
    "zwnj": "\u200c‌",
    "zwj": "\u200d‍",
    "lrm": "\u200e",
    "rlm": "\u200f",
    "ndash": "–",
    "mdash": "—",
    "lsquo": "‘",
    "rsquo": "’",
    "sbquo": "‚",
    "ldquo": "“",
    "rdquo": "”",
    "bdquo": "„",
    "dagger": "†",
    "Dagger": "‡",
    "permil": "‰",
    "lsaquo": "‹",
    "rsaquo": "›",
    "euro": "€",
    # Doxygen extension
    "tm": "™",
}


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": [],
        }

        for child in compound.findall("member"):
            if child.get("refid") in index:
                continue

            # Everything has a kind and a name
            child_record = {
                "kind": child.get("kind"),
                "name": 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"] = []

            elif child.get("kind") == "enumvalue":
                child_record["name"] = child.find("name").text

            elif child.get("kind") == "variable":
                if child_record["name"] is None:
                    child_record["name"] = ""
                elif child_record["name"][0] == "@":
                    # Remove placeholder name from anonymous struct or union
                    child_record["name"] = ""
                else:
                    # 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":
            child["parent"] = parent_id

        elif parent["kind"] in ["class", "struct", "union"]:
            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"):
        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 description directives like ".. c:function::".  Note that
    link roles might be different, like ":c:func:`foo`".
    """

    simple = {
        "define": "c:macro",
        "enumvalue": lang + ":enumerator",
        "group": "ref",
        "typedef": lang + ":type",
    }

    kind = record["kind"]

    if kind in ["class", "function", "namespace", "struct", "union"]:
        return lang + ":" + kind

    if kind in simple:
        return simple[kind]

    if kind == "enum":
        return lang + (":enum-class" if record["strong"] else ":enum")

    if kind == "variable":
        if "type" in record and "::@" in record["type"]:
            return lang + ":struct"

        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)

    direct = ["class", "define", "enum", "group", "struct", "typedef", "union"]
    if kind in direct:
        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%s\n%s\n" % (line + "\n" 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."""

        markup = markup.strip()

        if "\n" in markup:
            return "\n" + indent(markup, 1)

        return " " + markup

    def convert_initializer():
        return convert_para()

    def convert_itemizedlist():
        markup = ""
        for item in node.findall("listitem"):
            assert len(item) == 1
            markup += "\n- %s" % dox_to_rst(index, lang, item[0])

        return markup

    def convert_orderedlist():
        markup = ""
        number = 1
        for item in node.findall("listitem"):
            assert len(item) == 1
            markup += "\n%d. %s" % (number, dox_to_rst(index, lang, item[0]))
            number += 1

        return markup

    def convert_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"

    def convert_parblock():
        markup = "\n"

        for child in node.findall("para"):
            markup += dox_to_rst(index, lang, child)

        return markup.strip() + "\n\n"

    def convert_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\n" % (
                name.text,
                field_value(dox_to_rst(index, lang, description[0])),
            )

        return markup

    def convert_programlisting():
        return "\n\n.. code-block:: %s\n\n%s" % (
            lang,
            indent(plain_text(node), 1),
        )

    def convert_ref():
        refid = node.get("refid")
        if refid not in index:
            raise RuntimeError("Unresolved link: %s\n" % refid)

        return link_markup(index, lang, refid)

    def convert_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 "\nSee also: " + dox_to_rst(index, lang, node[0])

        raise RuntimeError("Unknown simplesect kind: %s" % node.get("kind"))

    if node.tag in entities:
        return entities[node.tag]

    handlers = {
        "computeroutput": lambda: "``{}``".format(plain_text(node)),
        "emphasis": lambda: "*%s*" % plain_text(node),
        "initializer": convert_initializer,
        "itemizedlist": convert_itemizedlist,
        "linebreak": lambda: "\n\n",
        "orderedlist": convert_orderedlist,
        "para": convert_para,
        "parameterlist": convert_parameterlist,
        "parblock": convert_parblock,
        "programlisting": convert_programlisting,
        "ref": convert_ref,
        "simplesect": convert_simplesect,
        "ulink": lambda: "`%s <%s>`_" % (node.text, node.get("url")),
        "verbatim": lambda: "``{}``".format(plain_text(node)),
    }

    if node.tag in handlers:
        return handlers[node.tag]()

    raise RuntimeError("Unknown documentation command: %s" % node.tag)


def description_markup(index, lang, node):
    """Return the markup for a brief or detailed description."""

    return "".join([dox_to_rst(index, lang, child) for child in node]).strip()


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 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.
    """

    markup = ""
    if node.tag == "sp":
        markup += " "
    elif node.text is not None:
        markup += node.text

    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."""

    try:
        sep_end = name.rindex("::") + 2
        return name[sep_end:]
    except ValueError:
        return name


def read_member_doc(index, lang, member):
    """Set the documentation in the index for a member from the XML."""

    kind = member.get("kind")
    record = index[member.get("id")]
    set_descriptions(index, lang, member, record)

    def read_define():
        if member.find("param") is not None:
            record["prototype"] = "%s(%s)" % (
                record["name"],
                ", ".join(
                    [
                        param.find("defname").text
                        for param in member.findall("param")
                        if param.find("defname") is not None
                    ]
                ),
            )
        elif member.find("initializer") is not None:
            record["initializer"] = dox_to_rst(
                index, lang, member.find("initializer")
            )

    def read_enum():
        for value in member.findall("enumvalue"):
            set_descriptions(index, lang, value, index[value.get("id")])

    def read_function():
        templateparamlist = member.find("templateparamlist")
        record["prototype"] = ""
        if templateparamlist is not None:
            params = []
            for param in templateparamlist.findall("param"):
                declname = param.find("declname")
                params += [
                    plain_text(param.find("type"))
                    + ("" if declname is None else (" " + declname.text))
                ]

            record["prototype"] += "template <{}> ".format(", ".join(params))

        record["prototype"] += "%s %s%s" % (
            plain_text(member.find("type")),
            member.find("name").text,
            member.find("argsstring").text,
        )

    def read_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:  # Data type alias
            record["type"] = target_text
            record["definition"] = "%s %s" % (target_text, name)

    def read_variable():
        if "@" in plain_text(member.find("type")):  # Anonymous member
            record["type"] = plain_text(member.find("type"))
            record["definition"] = plain_text(member.find("name"))
        else:  # Named variable
            record["type"] = plain_text(member.find("type"))
            record["name"] = plain_text(member.find("name"))
            record["args"] = plain_text(member.find("argsstring"))

    handlers = {
        "define": read_define,
        "enum": read_enum,
        "function": read_function,
        "typedef": read_typedef,
        "variable": read_variable,
    }

    handlers[kind]()


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")]
    compound_kind = compound.get("kind")
    set_descriptions(index, lang, compound, compound_record)

    if compound.find("title") is not None:
        compound_record["title"] = compound.find("title").text.strip()
    elif compound_kind == "namespace":
        compound_record["title"] = compound.find("compoundname").text.strip()

    if compound_kind in ["class", "struct"]:
        name = compound_record["name"]
        prefix = local_name(name)

        compound_record["supers"] = [
            "{} {}".format(base.get("prot"), base.text.replace(prefix, ""))
            for base in compound.findall("basecompoundref")
        ]

    # Set documentation for all children
    for section in compound.findall("sectiondef"):
        for member in section.findall("memberdef"):
            if member.get("id") in index:
                read_member_doc(index, lang, member)


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 kind == "define" and "prototype" in record:
        result += record["prototype"]
    elif kind == "function":
        result += record["prototype"]
    elif kind == "struct":
        result += local_name(record["name"])
    elif kind == "typedef":
        result += record["definition"]
    elif kind == "variable":
        if "type" in record and "name" in record and "args" in record:
            result += "%s %s%s" % (
                record["type"],
                local_name(record["name"]),
                record["args"],
            )
        else:
            result += record["definition"]
    else:
        result += local_name(record["name"])

    assert "\n" not in result
    return result


def single_newline(string):
    """Trim a string so that it ends with exactly one newline."""

    while len(string) >= 2 and string[-1] == "\n" and string[-2] == "\n":
        string = string[0:-1]

    if len(string) >= 1 and string[-1] != "\n":
        string = string + "\n"

    return string


def description_text(record, depth):
    """Write the complete (brief and detailed) description of an entity."""

    brief = single_newline(record["briefdescription"])
    detailed = single_newline(record["detaileddescription"])

    markup = "\n"
    if record["kind"] == "define" and "initializer" in record:
        markup += "{}\n".format(indent(record["initializer"], depth))

    markup += indent(brief, depth)

    if len(detailed) > 0:
        markup += "\n" + indent(detailed, depth)

    return single_newline(markup)


def document_markup(index, lang, record):
    """Return the complete document that describes some documented entity."""

    kind = record["kind"]
    role = sphinx_role(record, lang)
    markup = ""

    if (
        len(record["briefdescription"].strip()) == 0
        and len(record["detaileddescription"].strip()) == 0
    ):
        return markup

    # Write top-level directive and main description blurb
    markup += ".. %s:: %s\n" % (role, declaration_string(record))
    markup += description_text(record, 1)

    assert (
        kind in ["class", "enum", "namespace", "struct", "union"]
        or "children" not in record
    )

    # Write inline children if applicable
    child_indent = 1
    children = record.get("children", [])
    markup += "\n" if children else ""
    for child_id in children:
        child_record = index[child_id]
        brief = single_newline(child_record["briefdescription"])
        detailed = single_newline(child_record["detaileddescription"])

        if len(brief) > 0 or len(detailed) > 0:
            child_header = "\n.. %s:: %s\n" % (
                sphinx_role(child_record, lang),
                single_newline(declaration_string(child_record)),
            )

            markup += indent(child_header, child_indent)

            if len(brief) > 0:
                markup += "\n" + indent(brief, child_indent + 1)

            if len(detailed) > 0:
                markup += "\n" + indent(detailed, child_indent + 1)

    return "\n" + markup + "\n"


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, symbols, rst):
    """Write the descriptions for all symbols in a group."""

    sorted_symbols = [entry[1] for entry in sorted(symbols.items())]

    # Emit preprocessor and type symbols first
    for symbol_kind in ["define", "typedef", "enum", "var"]:
        for symbol in sorted_symbols:
            if symbol["kind"] == symbol_kind:
                rst.write(document_markup(index, lang, symbol))

    # Emit all other symbols
    for symbol in sorted_symbols:
        if symbol["kind"] not in ["define", "typedef", "enum", "var"]:
            rst.write(document_markup(index, lang, symbol))


def emit_groups(index, lang, output_dir, force):
    """Write a description file for every group documented in the index."""

    for record in index.values():
        name = record["name"]
        if record["kind"] not in ["group", "namespace"] or "::" in name:
            continue

        filename = os.path.join(output_dir, "%s.rst" % symbol_filename(name))
        if not force and os.path.exists(filename):
            raise FileExistsError("File already exists: '%s'" % filename)

        with open(filename, "w", encoding="utf-8") as rst:
            # Write heading with anchor
            rst.write(".. _%s:\n\n" % name)
            rst.write(heading(record["title"], 1))

            # Get all child group and symbol names
            child_groups = {}
            child_symbols = {}
            for child_id in record["children"]:
                child = index[child_id]
                if child["kind"] in ["group", "namespace"]:
                    child_groups[child["name"]] = child
                else:
                    child_symbols[child["name"]] = child

            # Emit description (document body)
            rst.write(description_text(record, 0))

            if len(child_groups) > 0:
                # Emit TOC for child groups
                rst.write("\n.. toctree::\n\n")
                for name, group in child_groups.items():
                    rst.write(indent(group["name"], 1) + "\n")

            # Emit descriptions for child symbols
            emit_symbols(index, lang, child_symbols, rst)


def run(index_xml, output_dir, 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)
    index = load_index(index_xml)

    # 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)

    # Create output directory
    try:
        os.makedirs(output_dir)
    except OSError:
        pass

    # Emit output files
    emit_groups(index, language, output_dir, force)


def main():
    """Run the command line utility."""

    parser = argparse.ArgumentParser(
        usage="%(prog)s [OPTION]... INDEX_XML OUTPUT_DIR",
        description=__doc__,
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )

    parser.add_argument(
        "-V", "--version", action="version", version="%(prog)s " + __version__
    )

    parser.add_argument(
        "-f", "--force", action="store_true", help="overwrite files"
    )

    parser.add_argument(
        "-l",
        "--language",
        default="c",
        choices=["c", "cpp"],
        help="language domain for output",
    )

    parser.add_argument("index_xml", help="path to index.xml from Doxygen")
    parser.add_argument("output_dir", help="output directory")

    run(**vars(parser.parse_args(sys.argv[1:])))


if __name__ == "__main__":
    main()
