From 0cd904bdc324a22a0d30f6f9a27a514e808f12ab Mon Sep 17 00:00:00 2001 From: SilentSpike Date: Sat, 23 Mar 2019 22:21:33 +0000 Subject: [PATCH] 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 --- docs/tools/document_functions.py | 306 +++++++++++++++++++++++++++++++ tools/event_rename.py | 115 ------------ 2 files changed, 306 insertions(+), 115 deletions(-) create mode 100644 docs/tools/document_functions.py delete mode 100644 tools/event_rename.py diff --git a/docs/tools/document_functions.py b/docs/tools/document_functions.py new file mode 100644 index 0000000000..b51aae18a3 --- /dev/null +++ b/docs/tools/document_functions.py @@ -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() diff --git a/tools/event_rename.py b/tools/event_rename.py deleted file mode 100644 index 99dd261a02..0000000000 --- a/tools/event_rename.py +++ /dev/null @@ -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())