1
0
tic80-lsp-addon/generate.py

336 lines
13 KiB
Python
Executable File

#!/bin/python3
import requests
from os import path
import os
import re
from dataclasses import dataclass
@dataclass
class MarkdownSection:
name: str
body: str
subsections: dict[str, "MarkdownSection"]
ROOT_RAW_URL = "https://raw.githubusercontent.com/wiki/nesbox/TIC-80"
ROOT_URL = "https://github.com/nesbox/TIC-80/wiki"
def get_url_for(item: str) -> str:
return path.join(ROOT_RAW_URL, item) + ".md"
def get_contents(url: str):
res = requests.get(url)
return res.content.decode()
def group_by_sections(markdown: str, min_level = 1) -> list[MarkdownSection]:
sections = []
# Find the lowest given section level from the text
level = min_level
header_prefix = ""
while True:
header_prefix = "#" * level
if re.search(f"^{header_prefix}\\s+(.+)\\s*\n", markdown, re.MULTILINE) != None:
break
level += 1
if level == 8:
return []
# Find all headers of the same level
header_prefix = "#" * level
headers = list(h for h in re.finditer(f"^{header_prefix}\\s+(.+)\\s*\n", markdown, re.MULTILINE))
for i in range(len(headers)):
header = headers[i]
header_start = header.end()
# The size of the whole section (including sub-sections)
section_end = -1
if i+1 < len(headers):
section_end = headers[i+1].start()
section_markdown = markdown[header_start:section_end]
# Find all subsections and save them
subsections = {}
for subsection in group_by_sections(section_markdown, level+1):
subsections[subsection.name] = subsection
# Determine body size (just text that isin't part of a subsection)
body_end = -1
next_neighbour_header = re.search(f"^{header_prefix}.+\n", markdown[header_start:], re.MULTILINE)
if next_neighbour_header != None:
body_end = header_start + next_neighbour_header.start()
body = markdown[header_start:body_end].strip()
# Save subsection
sections.append(MarkdownSection(
name = header.group(1),
body = body,
subsections = subsections
))
return sections
def extract_function_names(functions_section: MarkdownSection) -> dict[str, list[str]]:
function_names = {}
for subsection in functions_section.subsections.values():
names = []
for line in subsection.body.splitlines():
match = re.search(r"\[(.*?)\]", line)
if not match:
raise Exception(f"Couldn't extract function name from: '{line}'")
names.append(match.group(1))
function_names[subsection.name.lower()] = names
return function_names
def list_available_functions() -> dict[str, list[str]]:
api_url = get_url_for("API")
contents = get_contents(api_url)
top_section = group_by_sections(contents)[0]
return extract_function_names(top_section.subsections["Functions"])
def get_root_section_of_function(function_name) -> MarkdownSection:
contents = get_contents(get_url_for(function_name))
top_sections = group_by_sections(contents)
if len(top_sections) == 0:
match = re.search(r"^Please see: \[.+\]\((.+)\)", contents, re.MULTILINE)
if match == None:
raise Exception(f"Failed to fetch contents of function: '{function_name}'")
contents = get_contents(get_url_for(match.group(1)))
top_sections = group_by_sections(contents)
# TYPO: Because there is a type in `rectb`, where the top level header starts from "##"
# There needs to be some extra logic to see if that is the case
if len(top_sections) > 1:
# If it is the case, just skip the first "#" character
top_sections = group_by_sections(contents[1:])
return top_sections[0]
# Example line: `mouse() -> x, y, left, middle, right, scrollx, scrolly`
def get_function_signature(function_name: str, description: str) -> tuple[list[tuple[str, str|None]], list[str]]:
# TYPO: Because there is a typo in the signature for `keyp`.
# The simplest solution is to just hard-code the value for the time being.
if function_name == "keyp":
return ([("code", "nil"), ("hold", "nil"), ("period", "nil")], ["pressed"])
# TYPO: Because `vbank` is not documented in a strictly formatted way,
# it needs to be hard-coded here
if function_name == "vbank":
return ([("id", None)], [])
params: list[tuple[str, str|None]] = []
return_vars: list[str] = []
# There could exist multiple signatures, so its important too loop through
# all of them
for match in re.finditer(rf"`{function_name}\s*\((.*)\)( -> ([\w\, ]+))?`", description):
if match.group(3) != None and len(return_vars) == 0:
return_vars = list(v.strip().replace(" ", "_") for v in match.group(3).split(","))
if len(match.group(1)) > 0:
match_params = match.group(1).split(",")
for i in range(len(match_params)):
match_param = match_params[i].strip()
default = None
name = match_param
if name[0] == "[" or name[-1] == "]":
equal_sign = match_param.find("=")
# TYPO: Because there is a typo in `btnp` there needs to be some
# extra logic for stripping extra "[]" symbols
if equal_sign > 0:
name = match_param[1:equal_sign]
default = match_param[equal_sign+1:-1]
else:
name = match_param
default = "nil"
name = name.strip("[]")
if i == len(params):
params.append((name.replace(" ", "_"), default))
return (params, return_vars)
# Example line: * **text** : any string to be printed to the screen
# Example line: * **x, y** : the [coordinates](coordinate) of the circle's center
# Example line (from clip): * **x**, **y** : [coordinates](coordinate) of the top left of the clipping region
# Example line (from mouse): * **x y** : [coordinates](coordinate) of the mouse pointer
def parse_variable_description_line(line: str) -> tuple[list[str], str]:
match = re.match(r"[\*-] \*\*(.+)\*\*\s*:\s*(.+)", line)
if not match:
raise Exception(f"Failed to parse parameter descriptions: '{line}'")
description = match.group(2)
description = replace_relative_links(description)
names = list(name.strip(" *").replace(" ", "_") for name in match.group(1).split(","))
# TYPO: Adjust result, because there is a typo in `mouse`
if "x_y" in names:
names.remove("x_y")
names.append("x")
names.append("y")
return (names, description)
def get_variable_descriptions(root: MarkdownSection) -> dict[str, str]:
descriptions = {}
search_lines = []
if "Parameters" in root.subsections:
search_lines.extend(root.subsections["Parameters"].body.splitlines())
elif "**Parameters**" in root.body:
params_header_match = re.search(r"\*\*Parameters\*\*", root.body)
assert params_header_match
params_start = params_header_match.end()
search_lines.extend(root.body[params_start:].strip().splitlines())
if "Returns" in root.subsections:
search_lines.extend(root.subsections["Returns"].body.splitlines())
for line in search_lines:
if line.startswith("* **") or line.startswith("- **"):
names, description = parse_variable_description_line(line)
for name in names:
descriptions[name] = replace_relative_links(description)
# TYPO: Because a line explaning the variable "value" in `peek` is missing,
# It is hard-coded here
if root.name == "peek*":
descriptions["value"] = "a number that depends on how many bits were read"
# TYPO: Because "bitaddr", "bitval", "addr2", "addr4", "val2", "val4" are
# not explicitly explained in `peek` and `poke`, it is hard-coded here
if root.name == "peek*" or root.name == "poke*":
descriptions["bitaddr"] = "the address of `RAM` you desire to write"
descriptions["bitval"] = "the integer value write to RAM"
descriptions["addr2"] = "the address of `RAM` you desire to write (segmented on 2)"
descriptions["val2"] = "the integer value write to RAM (segmented on 2)"
descriptions["addr4"] = "the address of `RAM` you desire to write (segmented on 4)"
descriptions["val4"] = "the integer value write to RAM (segmented on 4)"
# Because the return type in `fget` is "bool" and is not explicitly documented
# it is addded here
descriptions["bool"] = "a boolean"
return descriptions
def replace_relative_links(markdown: str) -> str:
return re.sub(
r"\[(.+?)\]\((.+?)\)",
lambda s: s.group(0) if s.group(2).startswith("http") else f"`{s.group(1)}`",
markdown
)
def remove_non_printable(text: str) -> str:
return ''.join(c for c in text if c.isprintable() or c == '\n')
def get_function_description(root: MarkdownSection) -> str:
# TYPO: Because `exit` is not strictly formatted, the description is hard-coded
if root.name == "exit":
return """This function causes program execution to be terminated **after** the current `TIC` function ends. The entire function is executed, including any code that follows `exit()`. When the program ends you are returned to the [console](console).
See the example below for a demonstration of this behavior."""
# TYPO: Because `reset` is not strictly formatted, the description is hard-coded
if root.name == "reset":
return """Resets the TIC virtual "hardware" and immediately restarts the cartridge.
To simply return to the console, please use `exit`.
See also:
- `exit`"""
description = root.subsections["Description"].body
return replace_relative_links(description)
def prefix_lines(text: str, prefix: str) -> str:
return "\n".join(prefix+line for line in text.splitlines())
def guess_variable_types(descriptions: dict[str, str], func_params: list[tuple[str, str|None]]) -> dict[str, str]:
type_names = {}
default_type_lookup = {}
for param in func_params:
if param[1] != None:
if param[1] == "false" or param[1] == "true":
default_type_lookup[param[0]] = "boolean"
elif param[1].isdigit():
default_type_lookup[param[0]] = "number"
number_variable_names = [
"id", "track", "tempo", "speed", "duration", "note",
"value", "val32",
"radius", "scale", "w", "h", "sx", "sy",
"period", "hold", "code", "length", "timestamp", "scrollx", "scrolly"
]
for var_name, desc in descriptions.items():
if var_name in default_type_lookup:
type_names[var_name] = default_type_lookup[var_name]
elif var_name in number_variable_names or \
"width" in var_name or \
"height" in var_name or \
"color" in var_name or \
"index" in desc or \
"number" in desc or \
"coordinate" in desc or \
"integer" in desc or \
"address" in desc or \
"radius" in desc:
type_names[var_name] = "number"
elif var_name in ["pressed", "bool"] or \
"true" in desc or \
"false" in desc:
type_names[var_name] = "boolean"
elif var_name == "text" or "message" in desc:
type_names[var_name] = "string"
elif "function" in desc:
type_names[var_name] = "function"
else:
type_names[var_name] = "MISSING_TYPE"
return type_names
def create_lua_function(function_name: str):
root = get_root_section_of_function(function_name)
param_vars, return_vars = get_function_signature(function_name, root.body)
func_signature = f"function {function_name}("
func_signature += ", ".join(list(p[0] for p in param_vars))
func_signature += ") end"
doc_comment = "---\n"
func_description = get_function_description(root)
func_description = remove_non_printable(func_description)
doc_comment += prefix_lines(func_description, "---")
doc_comment += "\n---"
var_descriptions = get_variable_descriptions(root)
var_types = guess_variable_types(var_descriptions, param_vars)
for param in param_vars:
param_name, default = param
is_optional = "?" if default != None else ""
param_description = var_descriptions[param_name]
type_name = var_types[param_name]
doc_comment += f"\n---@param {param_name}{is_optional} {type_name} # {param_description}"
if "default" not in param_description and default != None and default != "nil":
doc_comment += f" (default: {default})"
for return_var in return_vars:
return_description = var_descriptions[return_var]
type_name = var_types[return_var]
doc_comment += f"\n---@return {type_name} {return_var} # {return_description}"
return doc_comment + "\n" + func_signature
def main():
library_path = "library"
os.makedirs(library_path, exist_ok=True)
for section_name, funcs in list_available_functions().items():
with open(f"{library_path}/{section_name}.lua", "w") as f:
f.write("---@meta\n\n")
for func in funcs:
f.write(create_lua_function(func))
f.write("\n\n")
if __name__ == "__main__":
main()