addon-sdk/source/python-lib/cuddlefish/docs/apiparser.py
author Phil Ringnalda <philringnalda@gmail.com>
Tue, 19 Nov 2013 14:38:29 -0800
changeset 171124 78fb435aa0d2a1130271ae2016c3c98042c1d887
parent 130502 a874d2756f6530a8ee7c8dc6dbcb7102944d5127
permissions -rw-r--r--
Backed out 4 changesets (bug 672843) for xpcshell bustage Backed out changeset bbb7760083ae (bug 672843) Backed out changeset eaf2fd75d7fc (bug 672843) Backed out changeset eb08cc206b8d (bug 672843) Backed out changeset 6a0e4afd52ab (bug 672843)

# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.

import sys, re, textwrap

VERSION = 4

class ParseError(Exception):
    # args[1] is the line number that caused the problem
    def __init__(self, why, lineno):
        self.why = why
        self.lineno = lineno
    def __str__(self):
        return ("ParseError: the JS API docs were unparseable on line %d: %s" %
                        (self.lineno, self.why))

class Accumulator:
    def __init__(self, holder, firstline):
        self.holder = holder
        self.firstline = firstline
        self.otherlines = []
    def addline(self, line):
        self.otherlines.append(line)
    def finish(self):
        # take a list of strings like:
        #    "initial stuff"    (this is in firstline)
        #    "  more stuff"     (this is in lines[0])
        #    "  yet more stuff"
        #    "      indented block"
        #    "      indented block"
        #    "  nonindented stuff"  (lines[-1])
        #
        # calculate the indentation level by looking at all but the first
        # line, and removing the whitespace they all have in common. Then
        # join the results with newlines and return a single string.
        pieces = []
        if self.firstline:
            pieces.append(self.firstline)
        if self.otherlines:
            pieces.append(textwrap.dedent("\n".join(self.otherlines)))
        self.holder["description"] = "\n".join(pieces)


class APIParser:
    def parse(self, lines, lineno):
        api = {"line_number": lineno + 1}
# assign the name from the first line, of the form "<api name="API_NAME">"
        title_line = lines[lineno].rstrip("\n")
        api["name"] = self._parse_title_line(title_line, lineno + 1)
        lineno += 1
# finished with the first line, assigned the name
        working_set = self._initialize_working_set()
        props = []
        currentPropHolder = api
# fetch the next line, of the form "@tag [name] {datatype} description"
# and parse it into tag, info, description
        tag, info, firstline = self._parseTypeLine(lines[lineno], lineno + 1)
        api["type"] = tag
# if this API element is a property then datatype must be set
        if tag == 'property':
            api['datatype'] = info['datatype']
        # info is ignored
        currentAccumulator = Accumulator(api, firstline)
        lineno += 1
        while (lineno) < len(lines):
            line = lines[lineno].rstrip("\n")
            # accumulate any multiline descriptive text belonging to
            # the preceding "@" section
            if self._is_description_line(line):
                currentAccumulator.addline(line)
            else:
                currentAccumulator.finish()
                if line.startswith("<api"):
                # then we should recursively handle a nested element
                    nested_api, lineno = self.parse(lines, lineno)
                    self._update_working_set(nested_api, working_set)
                elif line.startswith("</api"):
                # then we have finished parsing this api element
                    currentAccumulator.finish()
                    if props and currentPropHolder:
                        currentPropHolder["props"] = props
                    self._assemble_api_element(api, working_set)
                    return api, lineno
                else:
                # then we are looking at a subcomponent of an <api> element
                    tag, info, desc = self._parseTypeLine(line, lineno + 1)
                    currentAccumulator = Accumulator(info, desc)
                    if tag == "prop":
                        # build up props[]
                        props.append(info)
                    elif tag == "returns":
                        # close off the @prop list
                        if props and currentPropHolder:
                            currentPropHolder["props"] = props
                            props = []
                        api["returns"] = info
                        currentPropHolder = info
                    elif tag == "param":
                        # close off the @prop list
                        if props and currentPropHolder:
                            currentPropHolder["props"] = props
                            props = []
                        working_set["params"].append(info)
                        currentPropHolder = info
                    elif tag == "argument":
                        # close off the @prop list
                        if props and currentPropHolder:
                            currentPropHolder["props"] = props
                            props = []
                        working_set["arguments"].append(info)
                        currentPropHolder = info
                    else:
                        raise ParseError("unknown '@' section header %s in \
                                           '%s'" % (tag, line), lineno + 1)
            lineno += 1
        raise ParseError("closing </api> tag not found for <api name=\"" +
                         api["name"] + "\">", lineno + 1)

    def _parse_title_line(self, title_line, lineno):
        if "name" not in title_line:
            raise ParseError("Opening <api> tag must have a name attribute.",
                            lineno)
        m = re.search("name=['\"]{0,1}([-\w\.]*?)['\"]", title_line)
        if not m:
            raise ParseError("No value for name attribute found in "
                                     "opening <api> tag.", lineno)
        return m.group(1)

    def _is_description_line(self, line):
        return not ( (line.lstrip().startswith("@")) or
               (line.lstrip().startswith("<api")) or
               (line.lstrip().startswith("</api")) )

    def _initialize_working_set(self):
        # working_set accumulates api elements
        # that might belong to a parent api element
        working_set = {}
        working_set["constructors"] = []
        working_set["methods"] = []
        working_set["properties"] = []
        working_set["params"] = []
        working_set["events"] = []
        working_set["arguments"] = []
        return working_set

    def _update_working_set(self, nested_api, working_set):
        # add this api element to whichever list is appropriate
        if nested_api["type"] == "constructor":
            working_set["constructors"].append(nested_api)
        if nested_api["type"] == "method":
            working_set["methods"].append(nested_api)
        if nested_api["type"] == "property":
            working_set["properties"].append(nested_api)
        if nested_api["type"] == "event":
            working_set["events"].append(nested_api)

    def _assemble_signature(self, api_element, params):
        signature = api_element["name"] + "("
        if len(params) > 0:
            signature += params[0]["name"]
            for param in params[1:]:
                signature += ", " + param["name"]
        signature += ")"
        api_element["signature"] = signature

    def _assemble_api_element(self, api_element, working_set):
        # if any of this working set's lists are non-empty,
        # add it to the current api element
        if (api_element["type"] == "constructor") or \
           (api_element["type"] == "function") or \
           (api_element["type"] == "method"):
           self._assemble_signature(api_element, working_set["params"])
        if len(working_set["params"]) > 0:
            api_element["params"] = working_set["params"]
        if len(working_set["properties"]) > 0:
            api_element["properties"] = working_set["properties"]
        if len(working_set["constructors"]) > 0:
            api_element["constructors"] = working_set["constructors"]
        if len(working_set["methods"]) > 0:
            api_element["methods"] = working_set["methods"]
        if len(working_set["events"]) > 0:
            api_element["events"] = working_set["events"]
        if len(working_set["arguments"]) > 0:
            api_element["arguments"] = working_set["arguments"]

    def _validate_info(self, tag, info, line, lineno):
        if tag == 'property':
            if not 'datatype' in info:
                raise ParseError("No type found for @property.", lineno)
        elif tag == "param":
            if info.get("required", False) and "default" in info:
                raise ParseError(
                    "required parameters should not have defaults: '%s'"
                                     % line, lineno)
        elif tag == "prop":
            if "datatype" not in info:
                raise ParseError("@prop lines must include {type}: '%s'" %
                                  line, lineno)
            if "name" not in info:
                raise ParseError("@prop lines must provide a name: '%s'" %
                                  line, lineno)

    def _parseTypeLine(self, line, lineno):
        # handle these things:
        #    @method
        #    @returns description
        #    @returns {string} description
        #    @param NAME {type} description
        #    @param NAME
        #    @prop NAME {type} description
        #    @prop NAME
        # returns:
        #    tag: type of api element
        #    info: linenumber, required, default, name, datatype
        #    description

        info = {"line_number": lineno}
        line = line.rstrip("\n")
        pieces = line.split()

        if not pieces:
            raise ParseError("line is too short: '%s'" % line, lineno)
        if not pieces[0].startswith("@"):
            raise ParseError("type line should start with @: '%s'" % line,
                             lineno)
        tag = pieces[0][1:]
        skip = 1

        expect_name = tag in ("param", "prop")

        if len(pieces) == 1:
            description = ""
        else:
            if pieces[1].startswith("{"):
                # NAME is missing, pieces[1] is TYPE
                pass
            else:
                if expect_name:
                    info["required"] = not pieces[1].startswith("[")
                    name = pieces[1].strip("[ ]")
                    if "=" in name:
                        name, info["default"] = name.split("=")
                    info["name"] = name
                    skip += 1

            if len(pieces) > skip and pieces[skip].startswith("{"):
                info["datatype"] = pieces[skip].strip("{ }")
                skip += 1

            # we've got the metadata, now extract the description
            pieces = line.split(None, skip)
            if len(pieces) > skip:
                description = pieces[skip]
            else:
                description = ""
        self._validate_info(tag, info, line, lineno)
        return tag, info, description

def parse_hunks(text):
    # return a list of tuples. Each is one of:
    #    ("raw", string)         : non-API blocks
    #    ("api-json", dict)  : API blocks
    yield ("version", VERSION)
    lines = text.splitlines(True)
    line_number = 0
    markdown_string = ""
    while line_number < len(lines):
        line = lines[line_number]
        if line.startswith("<api"):
            if len(markdown_string) > 0:
                yield ("markdown", markdown_string)
                markdown_string = ""
            api, line_number = APIParser().parse(lines, line_number)
            # this business with 'leftover' is a horrible thing to do,
            # and exists only to collect the \n after the closing /api tag.
            # It's not needed probably, except to help keep compatibility
            # with the previous behaviour
            leftover = lines[line_number].lstrip("</api>")
            if len(leftover) > 0:
                markdown_string += leftover
            line_number = line_number + 1
            yield ("api-json", api)
        else:
            markdown_string += line
            line_number = line_number + 1
    if len(markdown_string) > 0:
        yield ("markdown", markdown_string)

class TestRenderer:
    # render docs for test purposes

    def getm(self, d, key):
        return d.get(key, "<MISSING>")

    def join_lines(self, text):
        return " ".join([line.strip() for line in text.split("\n")])

    def render_prop(self, p):
        s = "props[%s]: " % self.getm(p, "name")
        pieces = []
        for k in ("type", "description", "required", "default"):
            if k in p:
                pieces.append("%s=%s" % (k, self.join_lines(str(p[k]))))
        return s + ", ".join(pieces)

    def render_param(self, p):
        pieces = []
        for k in ("name", "type", "description", "required", "default"):
            if k in p:
                pieces.append("%s=%s" % (k, self.join_lines(str(p[k]))))
        yield ", ".join(pieces)
        for prop in p.get("props", []):
            yield " " + self.render_prop(prop)

    def render_method(self, method):
        yield "name= %s" % self.getm(method, "name")
        yield "type= %s" % self.getm(method, "type")
        yield "description= %s" % self.getm(method, "description")
        signature = method.get("signature")
        if signature:
            yield "signature= %s" % self.getm(method, "signature")
        params = method.get("params", [])
        if params:
            yield "parameters:"
            for p in params:
                for pline in self.render_param(p):
                    yield " " + pline
        r = method.get("returns", None)
        if r:
            yield "returns:"
            if "type" in r:
                yield " type= %s" % r["type"]
            if "description" in r:
                yield " description= %s" % self.join_lines(r["description"])
            props = r.get("props", [])
            for p in props:
                yield "  " + self.render_prop(p)

    def format_api(self, api):
        for mline in self.render_method(api):
            yield mline
        constructors = api.get("constructors", [])
        if constructors:
            yield "constructors:"
            for m in constructors:
                for mline in self.render_method(m):
                    yield " " + mline
        methods = api.get("methods", [])
        if methods:
            yield "methods:"
            for m in methods:
                for mline in self.render_method(m):
                    yield " " + mline
        properties = api.get("properties", [])
        if properties:
            yield "properties:"
            for p in properties:
                yield "  " + self.render_prop(p)

    def render_docs(self, docs_json, outf=sys.stdout):

        for (t,data) in docs_json:
            if t == "api-json":
                for line in self.format_api(data):
                    line = line.rstrip("\n")
                    outf.write("API: " + line + "\n")
            else:
                for line in str(data).split("\n"):
                    outf.write("MD :" +  line + "\n")

def hunks_to_dict(docs_json):
    exports = {}
    for (t,data) in docs_json:
        if t != "api-json":
            continue
        if data["name"]:
            exports[data["name"]] = data
    return exports

if __name__ == "__main__":
    json = False
    if sys.argv[1] == "--json":
        json = True
        del sys.argv[1]
    docs_text = open(sys.argv[1]).read()
    docs_parsed = list(parse_hunks(docs_text))
    if json:
        import simplejson
        print simplejson.dumps(docs_parsed, indent=2)
    else:
        TestRenderer().render_docs(docs_parsed)