shithub: choc

ref: 40d1ce5541da78b93348f7102c05f92e8df69062
dir: /man/docgen/

View raw version
#!/usr/bin/env python
# 
# Chocolate Doom self-documentation tool.  This works similar to javadoc
# or doxygen, but documents command line parameters and configuration
# file values, generating documentation in Unix manpage, wikitext and
# plain text forms.
#
# Comments are read from the source code in the following form:
#
#   //!
#   // @arg <extra arguments>
#   // @category Category
#   // @platform <some platform that the parameter is specific to>
#   //
#   // Long description of the parameter
#   //
#
#   something_involving = M_CheckParm("-param");
#
# For configuration file values:
#
#   //! @begin_config_file myconfig
#
#   //!
#   // Description of the configuration file value.
#   //
#
#   CONFIG_VARIABLE_INT(my_variable,       c_variable),
#

import sys
import os
import re
import glob
import getopt

INCLUDE_STATEMENT_RE = re.compile("@include\s+(\S+)")

# Find the maximum width of a list of parameters (for plain text output)

def parameter_list_width(params):
    w = 0

    for p in params:
        pw = len(p.name) + 5

        if p.args:
            pw += len(p.args)

        if pw > w:
            w = pw

    return w

class ConfigFile:
    def __init__(self, filename):
        self.filename = filename
        self.variables = []

    def add_variable(self, variable):
        self.variables.append(variable)

    def manpage_output(self):
        result = ".SH CONFIGURATION VARIABLES\n"

        for v in self.variables:
            result += ".TP\n"
            result += v.manpage_output()

        return result

    def plaintext_output(self):
        result = ""

        w = parameter_list_width(self.variables)

        for p in self.variables:
            result += p.plaintext_output(w)

        result = result.rstrip() + "\n"

        return result

class Category:
    def __init__(self, description):
        self.description = description
        self.params = []

    def add_param(self, param):
        self.params.append(param)

    # Plain text output

    def plaintext_output(self):
        result = "=== %s ===\n\n" % self.description

        self.params.sort()

        w = parameter_list_width(self.params)

        for p in self.params:
            if p.should_show():
                result += p.plaintext_output(w)

        result = result.rstrip() + "\n"

        return result

    def completion_output(self):
        result = ""

        self.params.sort()

        for p in self.params:
            if p.should_show():
                result += p.completion_output(0)

        result = result.rstrip()

        return result

    def manpage_output(self):
        result = ".SH " + self.description.upper() + "\n"

        self.params.sort()

        for p in self.params:
            if p.should_show():
                result += ".TP\n"
                result += p.manpage_output()

        return result

    def wiki_output(self):
        result = "=== %s ===\n" % self.description

        self.params.sort()

        for p in self.params:
            if p.should_show():
                result += "; " + p.wiki_output() + "\n"

        # Escape special HTML characters

        result = result.replace("&", "&amp;")
        result = result.replace("<", "&lt;")
        result = result.replace(">", "&gt;")

        return result

categories = {
    None:      Category("General options"),
    "video":   Category("Display options"),
    "demo":    Category("Demo options"),
    "net":     Category("Networking options"),
    "mod":     Category("Dehacked and WAD merging"),
    "compat":  Category("Compatibility"),
}

wikipages = []
config_files = {}

# Show options that are in Vanilla Doom? Or only new options?

show_vanilla_options = True

class Parameter:
    def __lt__(self, other):
        return self.name < other.name

    def __init__(self):
        self.text = ""
        self.name = ""
        self.args = None
        self.platform = None
        self.category = None
        self.vanilla_option = False
        self.games = None

    def should_show(self):
        return not self.vanilla_option or show_vanilla_options

    def add_text(self, text):
        if len(text) <= 0:
            pass
        elif text[0] == "@":
            match = re.match('@(\S+)\s*(.*)', text)

            if not match:
                raise "Malformed option line: %s" % text

            option_type = match.group(1)
            data = match.group(2)
            
            if option_type == "arg":
                self.args = data
            elif option_type == "platform":
                self.platform = data
            elif option_type == "category":
                self.category = data
            elif option_type == "vanilla":
                self.vanilla_option = True
            elif option_type == "game":
                self.games = re.split(r'\s+', data.strip())
            else:
                raise "Unknown option type '%s'" % option_type

        else:
            self.text += text + " "

    def _games_only_text(self, pattern="(%s only)"):
        if not match_game and self.games:
            games_list = ", ".join(map(str.capitalize, self.games))
            return " " + (pattern % games_list)
        else:
            return ""

    def manpage_output(self):
        result = self.name

        if self.args:
            result += " " + self.args

        result = '\\fB' + result + '\\fR'

        result += "\n"

        if self.platform:
            result += "[%s only] " % self.platform

        escaped = re.sub('\\\\', '\\\\\\\\', self.text)

        result += escaped + self._games_only_text() + "\n"

        return result

    def wiki_output(self):
        result = self.name

        if self.args:
            result += " " + self.args

        result += ": "

        result += add_wiki_links(self.text)

        if self.platform:
            result += "'''(%s only)'''" % self.platform
        result += self._games_only_text("'''(%s only)'''")

        return result

    def plaintext_output(self, w):

        # Build the first line, with the argument on

        line = "  " + self.name
        if self.args:
            line += " " + self.args

        # pad up to the plaintext width

        line += " " * (w - len(line))

        # Build the description text

        description = self.text

        if self.platform:
            description += " (%s only)" % self.platform

        description += self._games_only_text()

        # Build the complete text for the argument
        # Split the description into words and add a word at a time

        result = ""
        for word in re.split('\s+', description):

            # Break onto the next line?

            if len(line) + len(word) + 1 > 75:
                result += line + "\n"
                line = " " * w

            # Add another word

            line += word + " "

        result += line + "\n\n"

        return result

    def completion_output(self, w):

        result = self.name + " "

        return result

# Read list of wiki pages

def read_wikipages():
    with open("wikipages") as f:
        for line in f:
            line = line.rstrip()

            line = re.sub('\#.*$', '', line)

            if not re.match('^\s*$', line):
                wikipages.append(line)

# Add wiki page links

def add_wiki_links(text):
    for pagename in wikipages:
        page_re = re.compile('(%s)' % pagename, re.IGNORECASE)
    #   text = page_re.sub("SHOES", text)
        text = page_re.sub('[[\\1]]', text)

    return text

def add_parameter(param, line, config_file):

    # If we're only targeting a particular game, check this is one of
    # the ones we're targeting.

    if match_game and param.games and match_game not in param.games:
        return

    # Is this documenting a command line parameter?

    match = re.search('(M_CheckParm(WithArgs)|M_ParmExists)?\s*\(\s*"(.*?)"',
                      line)

    if match:
        param.name = match.group(3)
        categories[param.category].add_param(param)
        return

    # Documenting a configuration file variable?

    match = re.search('CONFIG_VARIABLE_\S+\s*\(\s*(\S+?)\),', line)

    if match:
        param.name = match.group(1)
        config_file.add_variable(param)
        return

    raise Exception(param.text)

def process_file(filename):

    current_config_file = None

    with open(filename) as f:
        param = None
        waiting_for_checkparm = False

        for line in f:
            line = line.rstrip()

            # Ignore empty lines

            if re.match('\s*$', line):
                continue

            # Currently reading a doc comment?

            if param:
                # End of doc comment

                if not re.match('\s*//', line):
                    waiting_for_checkparm = True

                # The first non-empty line after the documentation comment
                # ends must contain the thing being documented.

                if waiting_for_checkparm:
                    add_parameter(param, line, current_config_file)
                    param = None
                else:
                    # More documentation text

                    munged_line = re.sub('\s*\/\/\s*', '', line, 1)
                    munged_line = re.sub('\s*$', '', munged_line)
                    param.add_text(munged_line)

            # Check for start of a doc comment

            if re.search("//!", line):
                match = re.search("@begin_config_file\s*(\S+)", line)

                if match:
                    # Beginning a configuration file
                    tagname = match.group(1)
                    current_config_file = ConfigFile(tagname)
                    config_files[tagname] = current_config_file
                else:
                    # Start of a normal comment
                    param = Parameter()
                    waiting_for_checkparm = False

def process_files(path):
    # Process all C source files.

    if os.path.isdir(path):
        files = glob.glob(path + "/*.c")

        for filename in files:
            process_file(filename)
    else:
        # Special case to allow a single file to be specified as a target

        process_file(path)

def print_template(template_file, content):
    with open(template_file) as f:
        for line in f:
            match = INCLUDE_STATEMENT_RE.search(line)
            if match:
                filename = match.group(1)
                filename = os.path.join(os.path.dirname(template_file),
                                        filename)
                print_template(filename, content)
            else:
                line = line.replace("@content", content)
                print(line.rstrip())

def manpage_output(targets, template_file): 
    
    content = ""

    for t in targets:
        content += t.manpage_output() + "\n"

    content = content.replace("-", "\\-")

    print_template(template_file, content)

def wiki_output(targets, template):
    read_wikipages()

    for t in targets:
        print(t.wiki_output())

def plaintext_output(targets, template_file):

    content = ""

    for t in targets:
        content += t.plaintext_output() + "\n"

    print_template(template_file, content)

def completion_output(targets, template_file):

    content = ""

    for t in targets:
        content += t.completion_output() + "\n"

    print_template(template_file, content)

def usage():
    print("Usage: %s [-V] [-c tag] [-g game] ( -m | -w | -p ) <dir>..." \
            % sys.argv[0])
    print("   -c :  Provide documentation for the specified configuration file")
    print("         (matches the given tag name in the source file)")
    print("   -m :  Manpage output")
    print("   -w :  Wikitext output")
    print("   -p :  Plaintext output")
    print("   -b :  Bash-Completion output")
    print("   -V :  Don't show Vanilla Doom options")
    print("   -g :  Only document options for specified game.")
    sys.exit(0)

# Parse command line

opts, args = getopt.getopt(sys.argv[1:], "m:wp:b:c:g:V")

output_function = None
template = None
doc_config_file = None
match_game = None

for opt in opts:
    if opt[0] == "-m":
        output_function = manpage_output
        template = opt[1]
    elif opt[0] == "-w":
        output_function = wiki_output
    elif opt[0] == "-p":
        output_function = plaintext_output
        template = opt[1]
    elif opt[0] == "-b":
        output_function = completion_output
        template = opt[1]
    elif opt[0] == "-V":
        show_vanilla_options = False
    elif opt[0] == "-c":
        doc_config_file = opt[1]
    elif opt[0] == "-g":
        match_game = opt[1]

if output_function == None or len(args) < 1:
    usage()
else:
    # Process specified files

    for path in args:
        process_files(path)

    # Build a list of things to document

    documentation_targets = []

    if doc_config_file:
        documentation_targets.append(config_files[doc_config_file])
    else:
        documentation_targets.append(categories[None])

        for c in categories:
            if c != None:
                documentation_targets.append(categories[c])

    # Generate the output

    output_function(documentation_targets, template)