BashDocGenerator/generator.py

249 lines
11 KiB
Python
Executable file

#!/usr/bin/python3
# Small bash markdown documentation generation for bash for my libraries.
# Copyright (C) 2022 Ad5001
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
# This file generates a markdown reference for bash scripts based on publicly declared variables and functions.
from sys import argv, exit
from os import path
from re import compile as RegEx, IGNORECASE
copy_reg = RegEx("^#\s*(Copyright \\(C\\) .+)$") # Check for default copyright declaration.
func_reg = RegEx("^([^|()=&^$_#\s][^|()=&$ #\\s.]*(\\.[^|()=&^$_#\s][^|()=&$ #\\s]*)?)\\s*\\(\\)(\\s*{)?", IGNORECASE) # Function declaration regex.
var_reg = RegEx("^([a-z][\\w]*)=(.+)$", IGNORECASE) # Variable declaration regex.
class_reg = RegEx("(static_)?class\\s+([a-z][\\w]*)\\s+([\"'][a-z][\\w.]*\\.shc['\"]|[a-z][\\w.]*\\.shc)", IGNORECASE) # Class declaration regex.
property_reg = RegEx("^property\\s+[^|()=&^$_#\s][^|()=&$ #\\s]*\\.([^|()=&^$_#\s][^|()=&$ #\\s]*)(\\s+[^\\s].*)?$", IGNORECASE)
if len(argv) != 2:
print(f"Usage: {argv[0]} <bash source file>")
exit(1)
if not path.exists(argv[1]):
print(f"File {argv[1]} does not exist.")
exit(1)
LICENSES_TEXT = {
"GNU Lesser General Public License": "Under [LGPLv3](https://www.gnu.org/licenses/lgpl-3.0.en.html)",
"GNU General Public License": "Under [GPLv3](https://www.gnu.org/licenses/gpl-3.0.en.html)",
"GNU Affero General Public License": "Under [AGPLv3](https://www.gnu.org/licenses/agpl-3.0.en.html)"
}
class FileType:
LIBRARY = 0
NAMESPACE = 1
CLASS = 2
class Variable:
def __init__(self, name: str, var_type: str, comment: str, default_value: str):
self.name = name
self.type = var_type
self.comment = comment
self.default_value = default_value
class Function:
def __init__(self, name: str, signature: str, comment: str):
self.name = name
self.signature = signature
self.comment = comment
class Class:
def __init__(self, name: str, filename: str, comment: str, static: bool, signature: str):
self.name = name
self.filename = filename
self.comment = comment
self.static = static
self.signature = signature
def run():
f = open(argv[1])
filename = path.basename(argv[1])
lines = f.readlines()
title = filename + " Library Reference."
subtitle = ""
legal_text = ""
if filename[-3:] == ".sh":
# For libraries
copy_match = copy_reg.match(lines[2].strip())
if copy_match is not None:
# Copyright declaration
legal_text = copy_match.group(1)
for text in LICENSES_TEXT:
if text in lines[5]:
legal_text = LICENSES_TEXT[text] + " - " + legal_text
# Basic info line
info_line = lines[1].strip()[1:].strip().split(" - ")
project_name = info_line[0]
project_desc = info_line[1:]
title = f"{project_name} Library Reference ({filename})"
subtitle = " - ".join(project_desc)
elif filename[-4:] == ".shc":
# For classes
title = f"{filename.split('.')[0]} Class Reference ({filename})"
namespace, properties, methods, variables, classes = parse_data(filename)
if namespace != "" and filename[-4:] == ".shn":
title = f"{namespace} Namespace Reference ({filename})"
# Printing result.
print(f"# {title}")
if subtitle != "":
print(f"*{subtitle}* ")
if legal_text != "":
print(f"{legal_text}\n")
if subtitle != "" or legal_text != "":
print( "---\n\n")
# Table of contents.
print( "|Contents |")
print( "|-------------------------------------------|")
if len(variables) != 0:
print("|<ul><li>[Variables](#variables)</li></ul> |")
if len(classes) != 0:
print("|<ul><li>[Classes](#classes)</li></ul> |")
if len(properties) != 0:
print("|<ul><li>[Properties](#properties)</li></ul>|")
if len(methods) != 0:
print("|<ul><li>[Methods](#methods)</li></ul> |")
print("\n\n") # Skip a few liness
# Declare variables.
if len(variables) != 0:
print("## Variables\n")
print("### Reference\n")
for var in variables:
print(f"- [`{var.type} {var.name}`](#var-{var.name})")
print("\n### Detailed Documentation\n")
for var in variables:
print(f'\n<ul id="var-{var.name}"><li><code>{var.type} {var.name} = {var.default_value}</code></li></ul>\n')
print("\n ".join(var.comment.replace("NOTE:", "**Note**:").split("\n")) + "\n")
# Declare classes.
if len(classes) != 0:
print("## Classes\n")
print("**Note**: Classes requires the [BashOOP](https://git.ad5001.eu/Ad5001/BashOOP) library to be imported. ")
print("### Reference\n")
for cls in classes:
print(f"- [`{'static ' if cls.static else ''}class {cls.name}`](#class-{cls.name})")
print("\n### Detailed Documentation\n")
for cls in classes:
print(f'\n<ul id="class-{cls.name}"><li><code>{"static" if cls.static else ""} class {cls.name}</code></li></ul>\n')
print(f'*Class imported from file `{cls.filename}`.* ')
print("\n ".join(cls.comment.replace("NOTE:", "**Note**:").split("\n")) + "\n")
if not cls.static:
print(f'An instance of {cls.name} can be created using `{cls.name} <varName> {cls.signature}`.')
# Declare properties.
if len(properties) != 0:
print("## Properties\n")
print("**Note**: Properties requires the [BashOOP](https://git.ad5001.eu/Ad5001/BashOOP) library to be imported. ")
print("**Note**: Properties can be accessed using `$(<varName>.<propertyName>)` and set with `<varName>.<propertyName> = <value>`.\n")
print("### Reference\n")
for prop in properties:
print(f"- [`{prop.type} {prop.name}`](#prop-{prop.name})")
print("\n### Detailed Documentation\n")
for prop in properties:
print(f'\n<ul id="prop-{prop.name}"><li><code>{prop.type} {prop.name}{" = " + prop.default_value if prop.default_value != "" else ""}</code></li></ul>\n')
print("\n ".join(prop.comment.replace("NOTE:", "**Note**:").split("\n")) + "\n")
# Declare methods
if len(methods) != 0:
print("\n## Methods")
print("**Note**: Arguments between <> are considered mandatory, while the ones within [] are considered optionnal.\n")
print("### Reference\n")
for func in methods:
print(f"- [`{func.name} {func.signature}`](#method-{func.name})")
print("\n### Detailed Documentation\n")
for func in methods:
print(f'\n<ul id="method-{func.name}"><li><code>{func.name}{func.signature.replace("<", "&lt;").replace(">", "&gt;")}</code></li></ul>\n')
print(" \n".join(func.comment.replace("NOTE:", "**Note**:").split("\n")) + "\n")
def parse_data(filename: str) -> tuple:
"""
Parses doc data from a file.
"""
methods = []
properties = []
variables = []
classes = []
namespace = ""
f = open(filename)
folder = path.dirname(filename)
lines = f.readlines()
for i in range(len(lines)):
line = lines[i]
# Check for namespace
if filename[-4:] == ".shn" and line[:10] == "namespace ":
namespace = line[10:].strip()
# Check for properties
prop_match = property_reg.match(line)
if prop_match is not None:
comment, found_data = find_full_comment(lines, i-1)
default = prop_match.group(2)
properties.append(Variable(prop_match.group(1), found_data["type"], comment, default.strip() if default is not None else ""))
# Check for function
func_match = func_reg.match(line)
if func_match is not None:
comment, found_data = find_full_comment(lines, i-1)
methods.append(Function(func_match.group(1).split(".")[-1], found_data["signature"], comment))
# Check for variable
var_match = var_reg.match(line)
if var_match is not None:
comment, found_data = find_full_comment(lines, i-1)
variables.append(Variable(var_match.group(1), found_data["type"], comment, var_match.group(2).strip()))
# Check for classes
class_match = class_reg.match(line)
if class_match is not None:
comment, found_data = find_full_comment(lines, i-1)
class_file = class_match.group(3)
if class_file[0] == '"' or class_file[0] == "'":
class_file = class_file[1:-1]
# Get constructor from parsed file data.
n, p, m, v, c = parse_data(path.join(folder, class_file))
constructor = ""
for method in m:
if method.name == "constructor":
constructor = method.signature
classes.append(Class(class_match.group(2), class_file, comment, class_match.group(1) is not None, constructor))
return namespace, properties, methods, variables, classes
def find_full_comment(lines: list, line_index: int) -> tuple:
"""
Finds and returns a comment string without the comment begin,
with index starting at the last line of the comment.
It also searches for signature and type, and if not found returns it's default values.
"""
cmt = " "
variables = {
"signature": "() → void",
"type": "string",
}
while line_index >= 0 and lines[line_index][0] == "#":
line = lines[line_index][1:].strip()
if line[:10] == "Signature:":
variables["signature"] = line[10:].strip().replace("->", "").replace("(", "").replace(")", "").replace(", ", " ")
elif line[:5] == "Type:":
variables["type"] = line[5:].strip()
elif len(line) > 0:
cmt = line + ("\n" if line[-1] == "." or cmt[0] == "-" else " ") + cmt # Only add newline if the comment finishes by a dot.
line_index-=1
return cmt, variables
run()