The text output mode included useless whitespace at the end of lines and also occasionally generated lines containing nothing but whitespace. Clean up the code that generates text output and fix these bugs.
556 lines
14 KiB
Python
Executable file
556 lines
14 KiB
Python
Executable file
#!/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 io
|
|
import sys
|
|
import os
|
|
import re
|
|
import glob
|
|
import getopt
|
|
|
|
TEXT_WRAP_WIDTH = 78
|
|
INCLUDE_STATEMENT_RE = re.compile("@include\s+(\S+)")
|
|
|
|
# Use appropriate stdout function for Python 2 or 3
|
|
|
|
def stdout(buf):
|
|
if sys.version_info.major < 3:
|
|
sys.stdout.write(buf)
|
|
else:
|
|
sys.stdout.buffer.write(buf)
|
|
|
|
# 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("&", "&")
|
|
result = result.replace("<", "<")
|
|
result = result.replace(">", ">")
|
|
|
|
return result
|
|
|
|
categories = (
|
|
(None, Category("General options")),
|
|
("game", Category("Game start options")),
|
|
("video", Category("Display options")),
|
|
("net", Category("Networking options")),
|
|
("mod", Category("Dehacked and WAD merging")),
|
|
("demo", Category("Demo options")),
|
|
("compat", Category("Compatibility")),
|
|
("obscure", Category("Obscure and less-used options")),
|
|
)
|
|
|
|
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, indent):
|
|
# Build the first line, with the argument on
|
|
start = " " + self.name
|
|
if self.args:
|
|
start += " " + self.args
|
|
|
|
# pad up to the plaintext width
|
|
start += " " * (indent - len(start))
|
|
|
|
# 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 = ""
|
|
words = [word for word in re.split('\s+', description) if word]
|
|
maxlen = TEXT_WRAP_WIDTH - indent
|
|
outlines = [[]]
|
|
for word in words:
|
|
linelen = sum(len(w) + 1 for w in outlines[-1])
|
|
if linelen + len(word) > maxlen:
|
|
outlines.append([])
|
|
outlines[-1].append(word)
|
|
|
|
linesep = "\n" + " " * indent
|
|
|
|
return (start +
|
|
linesep.join(" ".join(line) for line in outlines) +
|
|
"\n\n")
|
|
|
|
def completion_output(self, w):
|
|
|
|
result = self.name + " "
|
|
|
|
return result
|
|
|
|
# Read list of wiki pages
|
|
|
|
def read_wikipages():
|
|
f = io.open("wikipages", encoding='UTF-8')
|
|
|
|
try:
|
|
for line in f:
|
|
line = line.rstrip()
|
|
|
|
line = re.sub('\#.*$', '', line)
|
|
|
|
if not re.match(r'^\s*$', line):
|
|
wikipages.append(line)
|
|
finally:
|
|
f.close()
|
|
|
|
# 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)
|
|
category = dict(categories)[param.category]
|
|
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
|
|
|
|
f = io.open(filename, encoding='UTF-8')
|
|
|
|
try:
|
|
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
|
|
finally:
|
|
f.close()
|
|
|
|
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, substs, content):
|
|
f = io.open(template_file, encoding='UTF-8')
|
|
|
|
try:
|
|
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, substs, content)
|
|
else:
|
|
line = line.replace("@content", content)
|
|
for k,v in substs.items():
|
|
line = line.replace(k,v)
|
|
stdout(line.rstrip().encode('UTF-8') + b'\n')
|
|
finally:
|
|
f.close()
|
|
|
|
def manpage_output(targets, substs, template_file):
|
|
|
|
content = ""
|
|
|
|
for t in targets:
|
|
content += t.manpage_output() + "\n"
|
|
|
|
content = content.replace("-", "\\-")
|
|
|
|
print_template(template_file, substs, content)
|
|
|
|
def wiki_output(targets, template):
|
|
read_wikipages()
|
|
|
|
for t in targets:
|
|
stdout(t.wiki_output().encode('UTF-8') + b'\n')
|
|
|
|
def plaintext_output(targets, substs, template_file):
|
|
|
|
content = ""
|
|
|
|
for t in targets:
|
|
content += t.plaintext_output() + "\n"
|
|
|
|
print_template(template_file, substs, content)
|
|
|
|
def completion_output(targets, substs, template_file):
|
|
|
|
content = ""
|
|
|
|
for t in targets:
|
|
content += t.completion_output() + "\n"
|
|
|
|
print_template(template_file, substs, content)
|
|
|
|
def usage():
|
|
print("Usage: %s [-V] [-c tag] [-g game] -n program_name -s package_name [ -z shortname ] ( -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(" -s : Package name, e.g. Chocolate Doom (for substitution)")
|
|
print(" -z : Package short-name, e.g. Chocolate (for substitution)")
|
|
print(" -n : Program name, e.g. chocolate (for substitution)")
|
|
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:], "n:s:z:m:wp:b:c:g:V")
|
|
|
|
output_function = None
|
|
template = None
|
|
doc_config_file = None
|
|
match_game = None
|
|
substs = {}
|
|
|
|
for opt in opts:
|
|
if opt[0] == "-n":
|
|
substs["@PROGRAM_SPREFIX@"] = opt[1]
|
|
if opt[0] == "-s":
|
|
substs["@PACKAGE_NAME@"] = opt[1]
|
|
if opt[0] == "-z":
|
|
substs["@PACKAGE_SHORTNAME@"] = opt[1]
|
|
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]
|
|
substs["@GAME@"] = opt[1]
|
|
substs["@GAME_UPPER@"] = opt[1].title()
|
|
if "doom" == opt[1]:
|
|
substs["@CFGFILE@"] = "default.cfg"
|
|
else:
|
|
substs["@CFGFILE@"] = opt[1] + ".cfg"
|
|
|
|
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
|
|
if doc_config_file:
|
|
documentation_targets = [config_files[doc_config_file]]
|
|
else:
|
|
documentation_targets = [c for _, c in categories]
|
|
|
|
# Generate the output
|
|
output_function(documentation_targets, substs, template)
|
|
|