mirror of
https://github.com/acemod/ACE3.git
synced 2024-08-30 18:23:18 +00:00
Add public function documentation script (#5253)
* Remove obsolete event rename script * Add initial function documenting python script - Currently only finds all public/invalid function files - Method "document_public" is in place to handle the data taken from a function header, but currently unimplemented * Add author/description/arguments processing * Improve console logging and add return processing * Use class based approach for better data handling * Add argument processing with support for notes * Implement rudimentary doc output * Add return and example processing * Fix example variable * Fix documenting no arguments/return * Fix malformed return handling * Improve control flow
This commit is contained in:
parent
bc2d84289b
commit
0cd904bdc3
306
docs/tools/document_functions.py
Normal file
306
docs/tools/document_functions.py
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Author: SilentSpike
|
||||||
|
Crawl function headers to produce appropriate documentation of public functions.
|
||||||
|
|
||||||
|
Supported header sections:
|
||||||
|
- Author(s) (with description below)
|
||||||
|
- Arguments
|
||||||
|
- Return Value
|
||||||
|
- Example(s)
|
||||||
|
- Public (by default function will only be documented if set to "Yes")
|
||||||
|
|
||||||
|
EXAMPLES
|
||||||
|
document_functions common --debug
|
||||||
|
Crawl only functions in addons/common and only reports debug messages.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
class FunctionFile:
|
||||||
|
def __init__(self, directory="."):
|
||||||
|
self.directory = directory
|
||||||
|
|
||||||
|
# False unless specified in processing
|
||||||
|
self.debug = False
|
||||||
|
|
||||||
|
# Empty until imported from file
|
||||||
|
self.path = ""
|
||||||
|
self.header = ""
|
||||||
|
|
||||||
|
# Defaults until header is processed
|
||||||
|
self.public = False
|
||||||
|
self.authors = []
|
||||||
|
self.description = ""
|
||||||
|
self.arguments = []
|
||||||
|
self.return_value = []
|
||||||
|
self.example = ""
|
||||||
|
|
||||||
|
# Filepath should only be logged once
|
||||||
|
self.logged = False
|
||||||
|
|
||||||
|
def import_header(self, file_path):
|
||||||
|
self.path = file_path
|
||||||
|
|
||||||
|
with open(file_path) as file:
|
||||||
|
code = file.read()
|
||||||
|
|
||||||
|
header_match = re.match(r"\s*/\*.+?\*/", code, re.S)
|
||||||
|
if header_match:
|
||||||
|
self.header = header_match.group(0)
|
||||||
|
else:
|
||||||
|
self.feedback("Missing header", 3)
|
||||||
|
|
||||||
|
def has_header(self):
|
||||||
|
return bool(self.header)
|
||||||
|
|
||||||
|
def process_header(self, debug=False):
|
||||||
|
# Detailed debugging occurs here so value is set
|
||||||
|
self.debug = debug
|
||||||
|
|
||||||
|
# Preemptively cut away the comment characters (and leading/trailing whitespace)
|
||||||
|
self.header_text = "\n".join([x[3:].strip() for x in self.header.splitlines()])
|
||||||
|
|
||||||
|
# Split the header into expected sections
|
||||||
|
self.sections = re.split(r"^(Author|Argument|Return Value|Example|Public)s?:\s?", self.header_text, 0, re.M)
|
||||||
|
|
||||||
|
# If public section is missing we can't continue
|
||||||
|
public_raw = self.get_section("Public")
|
||||||
|
if not public_raw:
|
||||||
|
self.feedback("Public value undefined", 3)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Determine whether the header is public
|
||||||
|
self.public = self.process_public(public_raw)
|
||||||
|
|
||||||
|
# Don't bother to process the rest if private
|
||||||
|
# Unless in debug mode
|
||||||
|
if not self.public and not self.debug:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Retrieve the raw sections text for processing
|
||||||
|
author_raw = self.get_section("Author")
|
||||||
|
arguments_raw = self.get_section("Argument")
|
||||||
|
return_value_raw = self.get_section("Return Value")
|
||||||
|
example_raw = self.get_section("Example")
|
||||||
|
|
||||||
|
# Author and description are stored in first section
|
||||||
|
if author_raw:
|
||||||
|
self.authors = self.process_author(author_raw)
|
||||||
|
self.description = self.process_description(author_raw)
|
||||||
|
|
||||||
|
if arguments_raw:
|
||||||
|
self.arguments = self.process_arguments(arguments_raw)
|
||||||
|
|
||||||
|
# Process return
|
||||||
|
if return_value_raw:
|
||||||
|
self.return_value = self.process_return_value(return_value_raw)
|
||||||
|
|
||||||
|
# Process example
|
||||||
|
if example_raw:
|
||||||
|
self.example = example_raw.strip()
|
||||||
|
|
||||||
|
def get_section(self, section_name):
|
||||||
|
try:
|
||||||
|
section_text = self.sections[self.sections.index(section_name) + 1]
|
||||||
|
return section_text
|
||||||
|
except ValueError:
|
||||||
|
self.feedback("Missing \"{}\" header section".format(section_name), 2)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def process_public(self, raw):
|
||||||
|
# Raw just includes an EOL character
|
||||||
|
public_text = raw[:-1]
|
||||||
|
|
||||||
|
if not re.match(r"(Yes|No)", public_text, re.I):
|
||||||
|
self.feedback("Invalid public value \"{}\"".format(public_text), 2)
|
||||||
|
|
||||||
|
return public_text.capitalize() == "Yes"
|
||||||
|
|
||||||
|
def is_public(self):
|
||||||
|
return self.public
|
||||||
|
|
||||||
|
def process_author(self, raw):
|
||||||
|
# Authors are listed on the first line
|
||||||
|
authors_text = raw.splitlines()[0]
|
||||||
|
|
||||||
|
# Seperate authors are divided by commas
|
||||||
|
return authors_text.split(", ")
|
||||||
|
|
||||||
|
def process_description(self, raw):
|
||||||
|
# Just use all the lines after the authors line
|
||||||
|
description_text = "".join(raw.splitlines(1)[1:])
|
||||||
|
|
||||||
|
return description_text
|
||||||
|
|
||||||
|
def process_arguments(self, raw):
|
||||||
|
lines = raw.splitlines()
|
||||||
|
|
||||||
|
if lines[0] == "None":
|
||||||
|
return []
|
||||||
|
|
||||||
|
if lines.count("") == len(lines):
|
||||||
|
self.feedback("No arguments provided (use \"None\" where appropriate)", 2)
|
||||||
|
return []
|
||||||
|
|
||||||
|
if lines[-1] == "":
|
||||||
|
lines.pop()
|
||||||
|
else:
|
||||||
|
self.feedback("No blank line after arguments list", 1)
|
||||||
|
|
||||||
|
arguments = []
|
||||||
|
for argument in lines:
|
||||||
|
valid = re.match(r"^(\d+):\s(.+?)\<([\s\w]+?)\>(\s\(default: (.+)\))?$", argument)
|
||||||
|
|
||||||
|
if valid:
|
||||||
|
arg_index = valid.group(1)
|
||||||
|
arg_name = valid.group(2)
|
||||||
|
arg_types = valid.group(3)
|
||||||
|
arg_default = valid.group(5)
|
||||||
|
arg_notes = []
|
||||||
|
|
||||||
|
if arg_index != str(len(arguments)):
|
||||||
|
self.feedback("Argument index {} does not match listed order".format(arg_index), 1)
|
||||||
|
|
||||||
|
if arg_default == None:
|
||||||
|
arg_default = ""
|
||||||
|
|
||||||
|
arguments.append([arg_index, arg_name, arg_types, arg_default, arg_notes])
|
||||||
|
else:
|
||||||
|
# Notes about the above argument won't start with an index
|
||||||
|
# Only applies if there exists an above argument
|
||||||
|
if re.match(r"^(\d+):", argument) or not arguments:
|
||||||
|
self.feedback("Malformed argument \"{}\"".format(argument), 2)
|
||||||
|
arguments.append(["?", "Malformed", "?", "?", []])
|
||||||
|
else:
|
||||||
|
arguments[-1][-1].append(argument)
|
||||||
|
|
||||||
|
return arguments
|
||||||
|
|
||||||
|
def process_return_value(self, raw):
|
||||||
|
return_value = raw.strip()
|
||||||
|
|
||||||
|
if return_value == "None":
|
||||||
|
return []
|
||||||
|
|
||||||
|
valid = re.match(r"^(.+?)\<([\s\w]+?)\>", return_value)
|
||||||
|
|
||||||
|
if valid:
|
||||||
|
return_name = valid.group(1)
|
||||||
|
return_types = valid.group(2)
|
||||||
|
else:
|
||||||
|
self.feedback("Malformed return value \"{}\"".format(return_value), 2)
|
||||||
|
return ["Malformed",""]
|
||||||
|
|
||||||
|
return [return_name, return_types]
|
||||||
|
|
||||||
|
def document(self, component):
|
||||||
|
str_list = []
|
||||||
|
|
||||||
|
# Title
|
||||||
|
str_list.append("\n## ace_{}_fnc_{}\n".format(component,os.path.basename(self.path)[4:-4]))
|
||||||
|
# Description
|
||||||
|
str_list.append("__Description__\n\n" + self.description)
|
||||||
|
# Arguments
|
||||||
|
if self.arguments:
|
||||||
|
str_list.append("__Parameters__\n\nIndex | Description | Datatype(s) | Default Value\n--- | --- | --- | ---\n")
|
||||||
|
for argument in self.arguments:
|
||||||
|
str_list.append("{} | {} | {} | {}\n".format(*argument))
|
||||||
|
str_list.append("\n")
|
||||||
|
else:
|
||||||
|
str_list.append("__Parameters__\n\nNone\n\n")
|
||||||
|
# Return Value
|
||||||
|
if self.return_value:
|
||||||
|
str_list.append("__Return Value__\n\nDescription | Datatype(s)\n--- | ---\n{} | {}\n\n".format(*self.return_value))
|
||||||
|
else:
|
||||||
|
str_list.append("__Return Value__\n\nNone\n\n")
|
||||||
|
# Example
|
||||||
|
str_list.append("__Example__\n\n```sqf\n{}\n```\n\n".format(self.example))
|
||||||
|
# Authors
|
||||||
|
str_list.append("\n__Authors__\n\n")
|
||||||
|
for author in self.authors:
|
||||||
|
str_list.append("- {}\n".format(author))
|
||||||
|
# Horizontal rule
|
||||||
|
str_list.append("\n---\n")
|
||||||
|
|
||||||
|
return ''.join(str_list)
|
||||||
|
|
||||||
|
def log_file(self, error=False):
|
||||||
|
# When in debug mode we only want to see the files with errors
|
||||||
|
if not self.debug or error:
|
||||||
|
if not self.logged:
|
||||||
|
rel_path = os.path.relpath(self.path, self.directory)
|
||||||
|
|
||||||
|
self.write("Processing... {}".format(rel_path), 1)
|
||||||
|
self.logged = True
|
||||||
|
|
||||||
|
def feedback(self, message, level=0):
|
||||||
|
priority_str = ["Info","Warning","Error","Aborted"][level]
|
||||||
|
|
||||||
|
self.log_file(level > 0)
|
||||||
|
self.write("{0}: {1}".format(priority_str, message))
|
||||||
|
|
||||||
|
def write(self, message, indent=2):
|
||||||
|
to_print = [" "]*indent
|
||||||
|
to_print.append(message)
|
||||||
|
print("".join(to_print))
|
||||||
|
|
||||||
|
def document_functions(components):
|
||||||
|
os.makedirs('../wiki/functions/', exist_ok=True)
|
||||||
|
|
||||||
|
for component in components:
|
||||||
|
output = os.path.join('../wiki/functions/',component) + ".md"
|
||||||
|
with open(output, "w") as file:
|
||||||
|
for function in components[component]:
|
||||||
|
file.write(function.document(component))
|
||||||
|
|
||||||
|
def crawl_dir(directory, debug=False):
|
||||||
|
components = {}
|
||||||
|
|
||||||
|
for root, dirs, files in os.walk(directory):
|
||||||
|
for file in files:
|
||||||
|
if file.endswith(".sqf") and file.startswith("fnc_"):
|
||||||
|
file_path = os.path.join(root, file)
|
||||||
|
|
||||||
|
# Attempt to import the header from file
|
||||||
|
function = FunctionFile(directory)
|
||||||
|
function.import_header(file_path)
|
||||||
|
|
||||||
|
# Undergo data extraction and detailed debug
|
||||||
|
if function.has_header():
|
||||||
|
function.process_header(debug)
|
||||||
|
|
||||||
|
if function.is_public() and not debug:
|
||||||
|
# Add functions to component key (initalise key if necessary)
|
||||||
|
component = os.path.basename(os.path.dirname(root))
|
||||||
|
components.setdefault(component,[]).append(function)
|
||||||
|
|
||||||
|
function.feedback("Publicly documented")
|
||||||
|
|
||||||
|
document_functions(components)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("""
|
||||||
|
#########################
|
||||||
|
# Documenting Functions #
|
||||||
|
#########################
|
||||||
|
""")
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('directory', nargs="?", type=str, default=".", help='only crawl specified module addon folder')
|
||||||
|
parser.add_argument('--debug', action="store_true", help='only check for header debug messages')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# abspath is just used for the terminal output
|
||||||
|
prospective_dir = os.path.abspath(os.path.join('../../addons/',args.directory))
|
||||||
|
if os.path.isdir(prospective_dir):
|
||||||
|
print("Directory: {}".format(prospective_dir))
|
||||||
|
crawl_dir(prospective_dir, args.debug)
|
||||||
|
else:
|
||||||
|
print("Invalid directory: {}".format(prospective_dir))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
@ -1,115 +0,0 @@
|
|||||||
import os
|
|
||||||
import sys
|
|
||||||
import re
|
|
||||||
|
|
||||||
un_capitalize = lambda s: s[:1].lower() + s[1:] if s else ''
|
|
||||||
|
|
||||||
def add_to_config(old, new):
|
|
||||||
with open(add_to_config.file, "r+") as file:
|
|
||||||
contents = file.read()
|
|
||||||
|
|
||||||
events_class = re.search(r"class\s+ACE_newEvents\s+{\n",contents,re.I)
|
|
||||||
|
|
||||||
if events_class:
|
|
||||||
newline_index = events_class.end()
|
|
||||||
insert = " {0} = \"{1}\";\n".format(old,new)
|
|
||||||
else:
|
|
||||||
newline_index = len(contents)
|
|
||||||
insert = "\nclass ACE_newEvents {{\n {0} = \"{1}\";\n}};".format(old,new)
|
|
||||||
|
|
||||||
contents = contents[:newline_index] + insert + contents[newline_index:]
|
|
||||||
|
|
||||||
file.seek(0)
|
|
||||||
file.write(contents)
|
|
||||||
file.truncate()
|
|
||||||
|
|
||||||
def event_replace(match):
|
|
||||||
event = un_capitalize(match.group(1))
|
|
||||||
add_to_config(match.group(1), "ace_" + event)
|
|
||||||
|
|
||||||
return "[\"ace_{0}\", {1}] call CBA_fnc_{2}".format(event,match.group(2),match.group(3))
|
|
||||||
|
|
||||||
def process_directory(dir, config=""):
|
|
||||||
if not config:
|
|
||||||
config = os.path.join(dir,"config.cpp")
|
|
||||||
if os.path.isfile(config):
|
|
||||||
add_to_config.file = config
|
|
||||||
|
|
||||||
for p in os.listdir(dir):
|
|
||||||
path = os.path.join(dir, p)
|
|
||||||
if os.path.isdir(path):
|
|
||||||
process_directory(path, config)
|
|
||||||
continue
|
|
||||||
|
|
||||||
ext = os.path.splitext(path)[1]
|
|
||||||
if ext not in [".sqf",".hpp",".cpp"]:
|
|
||||||
continue
|
|
||||||
|
|
||||||
with open(path, "r+") as file:
|
|
||||||
contents = file.read()
|
|
||||||
|
|
||||||
# Simple single-line substitutions
|
|
||||||
find = r"\[\s*\"(?!ace_)(\w+)\"\s*,\s*(.+?)\s*\]\s+call\s+CBA_fnc_((add|remove|local|global|target|server)Event(Handler)?)"
|
|
||||||
contents, subbed = re.subn(find,event_replace,contents,0,re.I)
|
|
||||||
|
|
||||||
# Handle multi-line code blocks
|
|
||||||
for match in re.finditer(r"\[\s*\"(?!ace_)(\w+)\"\s*,\s*({.+?})\s*\]\s+call\s*CBA_fnc_(add|remove)EventHandler",contents,re.I|re.S):
|
|
||||||
pair = 0
|
|
||||||
for i, c in enumerate(contents[match.start(2):]):
|
|
||||||
if c == "{":
|
|
||||||
pair += 1
|
|
||||||
elif c == "}":
|
|
||||||
pair -= 1
|
|
||||||
if pair == 0:
|
|
||||||
pair = i
|
|
||||||
break
|
|
||||||
if re.match(r"\s*\]\s+call\s+CBA_fnc_(add|remove)EventHandler",contents[pair+match.start(2)+1:],re.I):
|
|
||||||
event = un_capitalize(match.group(1))
|
|
||||||
add_to_config(match.group(1), "ace_" + event)
|
|
||||||
|
|
||||||
contents = contents[:match.start(1)] + "ace_" + event + contents[match.end(1):]
|
|
||||||
subbed += 1
|
|
||||||
|
|
||||||
# Handle multi-line argument arrays
|
|
||||||
for match in re.finditer(r"\[\s*\"(?!ace_)(\w+)\"\s*,\s*(\[.+?\])\s*\]\s+call\s*CBA_fnc_(local|global|server)Event",contents,re.I|re.S):
|
|
||||||
pair = 0
|
|
||||||
for i, c in enumerate(contents[match.start(2):]):
|
|
||||||
if c == "[":
|
|
||||||
pair += 1
|
|
||||||
elif c == "]":
|
|
||||||
pair -= 1
|
|
||||||
if pair == 0:
|
|
||||||
pair = i
|
|
||||||
break
|
|
||||||
if re.match(r"\s*\]\s+call\s+CBA_fnc_(local|global|server)Event",contents[pair+match.start(2)+1:],re.I):
|
|
||||||
event = un_capitalize(match.group(1))
|
|
||||||
add_to_config(match.group(1), "ace_" + event)
|
|
||||||
|
|
||||||
contents = contents[:match.start(1)] + "ace_" + event + contents[match.end(1):]
|
|
||||||
subbed += 1
|
|
||||||
|
|
||||||
if subbed > 0:
|
|
||||||
print("Making {0} substitutions: {1}".format(subbed,path))
|
|
||||||
|
|
||||||
file.seek(0)
|
|
||||||
file.write(contents)
|
|
||||||
file.truncate()
|
|
||||||
|
|
||||||
def main():
|
|
||||||
scriptpath = os.path.realpath(sys.argv[0])
|
|
||||||
projectpath = os.path.dirname(os.path.dirname(scriptpath))
|
|
||||||
addonspath = os.path.join(projectpath, "addons")
|
|
||||||
|
|
||||||
os.chdir(addonspath)
|
|
||||||
|
|
||||||
for p in os.listdir(addonspath):
|
|
||||||
path = os.path.join(addonspath, p)
|
|
||||||
if not os.path.isdir(path):
|
|
||||||
continue
|
|
||||||
if p[0] == ".":
|
|
||||||
continue
|
|
||||||
|
|
||||||
process_directory(path)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
Loading…
Reference in New Issue
Block a user