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