singe/util/zbstudioApi.py

551 lines
21 KiB
Python

# Builds ZeroBrane Studio's autocompletion and tooltips for Singe, zbstudio/packages/singe/api*.lua,
# from two sources that cannot drift apart: the names a running Singe really defines, and what the
# Reference says about each one. One file per language the Reference is kept in; the package picks
# the one matching ZeroBrane Studio's own language setting.
#
# The names come from Singe itself, run headless on a script that lists its globals after
# Framework.singe has loaded, so nothing here has to know how the engine registers them. The words
# come from each language's Reference.adoc: an entry's signatures, its first paragraph, its Returns
# and Since lines, and, for the constants, the Engine Constants table. Argument types come from the
# English Parameters lists whatever the language, since Lua's type names are English everywhere.
#
# Along the way it lists every engine function the Reference has no entry for, and every entry
# naming a function the engine does not define, which is how a missing entry gets found. --strict
# makes either kind fatal.
#
# Usage: python3 util/zbstudioApi.py [--singe BINARY] [--strict]
import glob
import os
import re
import subprocess
import sys
import tempfile
from singeBinary import singeBinary
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DOCS = os.path.join(REPO, "docs")
OUT = os.path.join(REPO, "zbstudio", "packages", "singe")
DUMP_TAG = "ZBSTUDIO_API"
TIMEOUT = 120
TYPE_WORDS = ("integer", "number", "string", "boolean", "table", "function", "nil")
# How each language writes a range of names: `GAMEPAD_0` to `GAMEPAD_3`.
RANGE_WORDS = "to|\u00e0|bis|a"
# Globals whose value depends on the run that listed them, so the tooltip gives no value.
VARYING = ("DIR", "SINGE_DEAD_ZONE", "SINGE_DISC", "SINGE_LEGACY_SPRITE_ARGS", "SINGE_TRIGGER_THRESHOLD")
# The label words of an entry, per language, as the translations write them. Non-ASCII letters are
# escaped so this file stays ASCII.
LABELS = {
"en": {"Parameters": "params", "Returns": "returns", "Notes": "notes", "Since": "since", "See also": "seeAlso"},
"fr": {"Param\u00e8tres": "params", "Renvoie": "returns", "Remarques": "notes", "Depuis": "since", "Voir aussi": "seeAlso"},
"it": {"Parametri": "params", "Restituisce": "returns", "Note": "notes", "Disponibile da": "since", "Vedi anche": "seeAlso"},
"de": {"Parameter": "params", "R\u00fcckgabe": "returns", "Hinweise": "notes", "Seit": "since", "Siehe auch": "seeAlso"},
"es": {"Par\u00e1metros": "params", "Devuelve": "returns", "Notas": "notes", "Desde": "since", "V\u00e9ase tambi\u00e9n": "seeAlso"},
}
# The standard library, which ZeroBrane Studio already describes.
STANDARD = set("""
_G _VERSION assert collectgarbage coroutine debug dofile error getmetatable io ipairs load loadfile
math next os package pairs pcall print rawequal rawget rawlen rawset require select setmetatable
string table tonumber tostring type utf8 warn xpcall
""".split())
# Lists the globals, one line each, and the members of every table that is not the standard
# library's. A member that is itself a table (SCANCODE.LEFT) shows its own keys, so the tooltip can
# say what is in it.
DUMP_SCRIPT = """
dofile("Singe/Framework.singe")
local function scalar(v)
if type(v) == "string" or type(v) == "number" or type(v) == "boolean" then
return tostring(v):gsub("[\\t\\n]", " ")
end
return ""
end
local skip = {}
for name in ([[%s]]):gmatch("%%S+") do
skip[name] = true
end
for name, value in pairs(_G) do
if type(name) == "string" and not skip[name] then
-- A function's identity, so an alias can be told apart from the function it names.
debugPrint("%s\\tG\\t" .. name .. "\\t" .. type(value) .. "\\t" .. ((type(value) == "function") and tostring(value) or scalar(value)))
if type(value) == "table" then
for key, member in pairs(value) do
if type(key) == "string" then
local keys = ""
if type(member) == "table" then
local list = {}
for k, v in pairs(member) do
list[#list + 1] = tostring(k) .. ((scalar(v) ~= "") and (" = " .. ((type(v) == "string") and ("\\"" .. scalar(v) .. "\\"") or scalar(v))) or "")
end
table.sort(list)
keys = table.concat(list, ", ")
end
debugPrint("%s\\tC\\t" .. name .. "\\t" .. key .. "\\t" .. type(member) .. "\\t" .. (keys ~= "" and keys or scalar(member)))
end
end
end
end
end
singeQuit()
"""
# Undocumented globals that are the very function a documented name is: Framework.singe's
# discGetLanguageDescription is videoGetLanguageDescription, for one.
def findAliases(globalsFound, english):
byIdentity = {}
aliases = {}
for name in sorted(english):
found = globalsFound.get(name)
if found and (found["type"] == "function"):
byIdentity.setdefault(found["value"], name)
for name, found in sorted(globalsFound.items()):
if (found["type"] == "function") and (name not in english) and (found["value"] in byIdentity):
aliases[name] = byIdentity[found["value"]]
return aliases
def asciiDocToText(text):
text = re.sub(r"<<[^,>]+,([^>]+)>>", r"\1", text)
text = re.sub(r"<<([^>]+)>>", r"\1", text)
text = re.sub(r"xref:[^\[]+\[([^\]]*)\]", r"\1", text)
text = re.sub(r"(https?://\S+?)\[([^\]]*)\]", r"\2 (\1)", text)
text = re.sub(r"`\+?(.*?)\+?`", r"\1", text)
text = re.sub(r"(?<![\w*])\*([^*\n]+)\*(?![\w*])", r"\1", text)
text = text.replace("{nbsp}", " ").replace(" +\n", "\n")
text = text.replace("\u00a0", " ").replace("\u202f", " ")
return re.sub(r"[ \t]+", " ", text).strip()
def autoId(title):
return "_" + re.sub(r"[^a-z0-9]+", "_", title.lower()).strip("_")
def dumpGlobals(singe):
script = DUMP_SCRIPT % (" ".join(sorted(STANDARD)), DUMP_TAG, DUMP_TAG)
with tempfile.TemporaryDirectory() as work:
with open(os.path.join(work, "dump.singe"), "w") as f:
f.write(script)
env = dict(os.environ, SDL_VIDEO_DRIVER="offscreen", SDL_GPU_DRIVER="nothing", SDL_AUDIO_DRIVER="dummy")
run = subprocess.run([singe, "-k", "-s", "-d", os.path.join(work, "data"), "dump.singe"], cwd=work, env=env, capture_output=True, text=True, timeout=TIMEOUT)
globalsFound = {}
for line in run.stdout.splitlines():
parts = line.split("\t")
if (len(parts) < 2) or (parts[0] != DUMP_TAG):
continue
if parts[1] == "G":
globalsFound[parts[2]] = {"type": parts[3], "value": parts[4], "members": {}}
elif parts[1] == "C":
globalsFound[parts[2]]["members"][parts[3]] = {"type": parts[4], "value": parts[5]}
if not globalsFound:
sys.exit("Singe listed no globals:\n" + run.stdout[-2000:])
return globalsFound
def languages():
found = ["en"]
for locale in sorted(glob.glob(os.path.join(DOCS, "*", "locale.adoc"))):
found.append(os.path.basename(os.path.dirname(locale)))
return found
def luaString(text):
text = text.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n")
return "\"" + text + "\""
# The first paragraph, the Returns and Since lines, and, when the forms differ in more than their
# trailing arguments, every form; enough for a tooltip, with the Reference a click away for the rest.
def describe(entry, allForms):
parts = []
if allForms and (len(entry["signatures"]) > 1):
parts.append("\n".join(entry["signatures"]))
if entry["paragraphs"]:
parts.append(asciiDocToText(entry["paragraphs"][0]))
for warning in entry["warnings"]:
parts.append(asciiDocToText(warning))
for field in ("returns", "since"):
if entry["fields"].get(field):
parts.append(entry["fieldLabels"][field] + " " + asciiDocToText(entry["fields"][field]))
return "\n".join(parts)
# The rows of every table in lines, as (cells, line number) pairs. A cell starts at a '|'; a row is
# as many cells as the table has columns, whether they sit on one line or one a line.
def parseTables(lines):
rows = []
cells = None
columns = 0
start = 0
inListing = False
for number, line in enumerate(lines):
if re.match(r"^-{4,}$", line):
inListing = not inListing
continue
if inListing:
continue
attribute = re.match(r'^\[cols="([^"]+)"', line)
if attribute:
columns = len(attribute.group(1).split(","))
continue
if line.startswith("|==="):
if cells is None:
cells = []
first = True
start = number
else:
for i in range(0, len(cells) - columns + 1, max(columns, 1)):
rows.append((cells[i:i + columns], start))
cells = None
columns = 0
continue
if cells is None:
continue
if line.startswith("|"):
parts = [part.strip() for part in line.split("|")[1:]]
if first and (columns == 0):
columns = len(parts)
first = False
cells.extend(parts)
elif line.strip() and cells:
cells[-1] = (cells[-1] + " " + line.strip()).strip()
return rows
def parseEntry(lines, labels):
entry = {"signatures": [], "paragraphs": [], "warnings": [], "params": [], "fields": {}, "fieldLabels": {}}
index = 0
paragraph = []
field = None
inListing = False
# The signature block is the first listing.
while index < len(lines):
if lines[index].startswith("[source"):
index += 2
while (index < len(lines)) and (lines[index] != "----"):
if lines[index].strip() and (lines[index].strip() != "end"):
entry["signatures"].append(lines[index].strip())
index += 1
index += 1
break
index += 1
def flush():
if paragraph:
text = " ".join(paragraph).strip()
if field == "params":
pass
elif field is not None:
entry["fields"][field] = (entry["fields"].get(field, "") + " " + text).strip()
elif re.match(r"^(WARNING|IMPORTANT|CAUTION):", text):
entry["warnings"].append(text)
else:
entry["paragraphs"].append(text)
paragraph.clear()
while index < len(lines):
line = lines[index]
index += 1
if re.match(r"^-{4,}$", line) or re.match(r"^\.{4,}$", line):
inListing = not inListing
continue
if inListing:
continue
if line.startswith(".Example") or line.startswith("[source") or line.startswith("|===") or line.startswith("[cols"):
flush()
field = "skip"
continue
label = re.match(r"^\*([^*]+?)\s*:\*\s*(.*)$", line.replace("\u00a0", " ").replace("\u202f", " "))
if label and (label.group(1).strip() in labels):
flush()
field = labels[label.group(1).strip()]
entry["fieldLabels"][field] = label.group(1).strip() + ":"
if label.group(2):
paragraph.append(label.group(2))
continue
if field == "params":
bullet = re.match(r"^\*\s+(.*)$", line)
if bullet:
entry["params"].append(bullet.group(1))
elif line.strip() and entry["params"]:
entry["params"][-1] += " " + line.strip()
elif not line.strip() and entry["params"]:
field = "skip"
continue
if field == "skip":
continue
if not line.strip():
flush()
if field is not None:
field = "skip"
continue
if line.startswith("[") and line.endswith("]"):
continue
paragraph.append(line.strip())
flush()
entry["fields"].pop("skip", None)
return entry
# Each language's API chapter entries, keyed by the names in the heading, and the Engine
# Constants table as (names cell, meaning) rows.
def parseReference(path, lang):
lines = open(path, encoding="utf-8").read().split("\n")
headings = []
pendingId = None
inListing = False
for number, line in enumerate(lines):
if re.match(r"^-{4,}$", line):
inListing = not inListing
continue
if inListing:
continue
anchor = re.match(r"^\[(?:#|\[)([^\],]+)", line)
if anchor and (line.endswith("]") or line.endswith("]]")):
pendingId = anchor.group(1)
continue
heading = re.match(r"^(=+) (.+)$", line)
if heading:
headings.append({"level": len(heading.group(1)), "title": heading.group(2).strip(), "id": pendingId or autoId(heading.group(2)), "line": number})
pendingId = None
elif line.strip():
pendingId = None
for i, heading in enumerate(headings):
end = len(lines)
for later in headings[i + 1:]:
if later["level"] <= heading["level"]:
end = later["line"]
break
heading["end"] = end
chapter = next(h for h in headings if h["id"] == "reference")
entries = {}
for heading in headings:
if (heading["level"] != 4) or not (chapter["line"] < heading["line"] < chapter["end"]):
continue
names = [n.strip() for n in heading["title"].split("/")]
if not all(re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", n) for n in names):
continue
entry = parseEntry(lines[heading["line"] + 1:heading["end"]], LABELS[lang])
entry["anchor"] = heading["id"]
for name in names:
entries[name] = entry
# The calls the chapter's tables describe, the aliases and the score calls among them, for any
# name without an entry of its own.
for row, number in parseTables(lines[chapter["line"]:chapter["end"]]):
if len(row) < 2:
continue
# The Reference opens at the section holding the table.
number += chapter["line"]
section = max((h for h in headings if (h["line"] < number) and (h["level"] >= 3)), key=lambda h: h["line"])
for token in re.findall(r"`([A-Za-z_][A-Za-z0-9_]*(?:\([^`]*\))?)`", row[0]):
name = re.match(r"^[A-Za-z_][A-Za-z0-9_]*", token).group(0)
# Constants are named in capitals and described by the Engine Constants table.
if (name in entries) or (name.upper() == name):
continue
signature = token if "(" in token else ""
text = " ".join(cell for cell in row[1:] if cell not in ("--", ""))
entries[name] = {"signatures": [signature] if signature else [], "paragraphs": [text], "warnings": [], "params": [], "fields": {}, "fieldLabels": {}, "anchor": section["id"]}
# The Engine Constants rows; the book's paragraphs cover the rest, what Framework.singe adds among them.
constants = []
section = next(h for h in headings if h["id"] == "_engine_constants")
for line in lines[section["line"] + 1:section["end"]]:
row = re.match(r"^\|\s*(.+?)\s*\|\s*(.+)$", line)
if row and ("`" in row.group(1)):
constants.append((row.group(1), row.group(2)))
return entries, {"table": constants, "paragraphs": bookParagraphs(lines)}
# Where a constant is described: its Engine Constants row, a row naming its range or family, the
# first paragraph naming it, or the first naming its family.
def constantMeaning(name, constants):
exact = re.compile(r"`%s[`.\[]" % re.escape(name))
def family(cell):
for token in re.findall(r"`([A-Z][A-Z0-9_]*\*?)`", cell):
prefix = token[:-1] if token.endswith("*") else token.rsplit("_", 1)[0] + "_"
ranged = token.endswith("*") or ("..." in cell) or re.search(r"`%s`\s+(?:%s)\s+`" % (re.escape(token), RANGE_WORDS), cell)
if ranged and name.startswith(prefix):
return True
return False
for test in (lambda cell: exact.search(cell), family):
for cell, meaning in constants["table"]:
if test(cell):
return asciiDocToText(meaning)
for test in (lambda cell: exact.search(cell), family):
for paragraph in constants["paragraphs"]:
if test(paragraph):
return asciiDocToText(paragraph)
return ""
# Every prose paragraph of a book, in order, without listings, tables or block attributes.
def bookParagraphs(lines):
paragraphs = []
paragraph = []
inListing = False
inTable = False
for line in lines + [""]:
if re.match(r"^-{4,}$", line) or re.match(r"^\.{4,}$", line):
inListing = not inListing
continue
if line.startswith("|==="):
inTable = not inTable
continue
if inListing or inTable:
continue
if line.strip() and not line.startswith(("[", "=", "*", ".", "//")):
paragraph.append(line.strip())
elif paragraph:
paragraphs.append(" ".join(paragraph))
paragraph = []
return paragraphs
def argList(entry):
forms = []
for signature in entry["signatures"]:
call = re.match(r"^(?:function\s+)?(?:[\w, ]+?\s*=\s*)?[A-Za-z_][\w.:]*\s*\((.*)\)\s*$", signature)
if call:
forms.append([a.strip() for a in call.group(1).split(",") if a.strip()])
if not forms:
return "()", True
types = {}
for param in entry["params"]:
names = re.findall(r"`([A-Za-z_][A-Za-z0-9_]*)`", param.split("--")[0])
after = param.split("--", 1)[1].strip() if "--" in param else ""
words = re.sub(r"^optional\s+", "", after).split(";")[0].split(",")[0].strip()
kind = words.split(" ")[0] if words.split(" ")[0] in TYPE_WORDS else ""
for name in names:
types[name] = kind
longest = max(forms, key=len)
shortest = min(forms, key=len)
prefixed = all(form == longest[:len(form)] for form in forms)
def typed(name):
return name + (": " + types[name] if types.get(name) else "")
if not prefixed:
return "(" + ", ".join(typed(n) for n in forms[0]) + ")", True
required = ", ".join(typed(n) for n in shortest)
optional = ", ".join(typed(n) for n in longest[len(shortest):])
if optional:
return "(" + required + (" [, " if required else "[") + optional + "])", False
return "(" + required + ")", False
def returnList(entry):
lhs = None
for signature in entry["signatures"]:
match = re.match(r"^([\w, ]+?)\s*=\s*[A-Za-z_]", signature)
if match:
lhs = [n.strip() for n in match.group(1).split(",")]
break
if not lhs:
return "()"
first = entry["fields"].get("returns", "").split(" ")[0].strip(".,;").lower()
if (len(lhs) == 1) and (first in TYPE_WORDS):
return "(" + first + ")"
return "(" + ", ".join(lhs) + ")"
def build(globalsFound, english, entries, constants):
api = {}
for name, entry in sorted(entries.items()):
englishEntry = english.get(name, entry)
args, allForms = argList(englishEntry)
api[name] = {"type": "function", "description": describe(entry, allForms), "args": args, "returns": returnList(englishEntry)}
# Where the package's Look Up opens the Reference, when it is not the name itself.
if englishEntry["anchor"] != name.lower():
api[name]["anchor"] = englishEntry["anchor"]
for name, found in sorted(globalsFound.items()):
if name in api:
continue
if found["type"] in ("number", "string", "boolean"):
meaning = constantMeaning(name, constants)
value = found["value"] if found["type"] != "string" else "\"" + found["value"] + "\""
if name in VARYING:
api[name] = {"type": "value", "description": meaning}
else:
api[name] = {"type": "value", "description": (meaning + "\n" if meaning else "") + "= " + value}
elif (found["type"] == "table") and found["members"]:
childs = {}
for key, member in sorted(found["members"].items()):
if member["type"] == "table":
childs[key] = {"type": "value", "description": "{ " + member["value"] + " }"}
elif member["type"] in ("number", "string", "boolean"):
childs[key] = {"type": "value", "description": "= " + member["value"]}
elif member["type"] == "function":
childs[key] = {"type": "function", "description": "", "args": "(...)", "returns": "()"}
meaning = constantMeaning(name, constants)
api[name] = {"type": "lib", "description": meaning, "childs": childs}
return api
def writeLua(path, api, lang):
out = []
out.append("-- Singe's API for ZeroBrane Studio's autocompletion and tooltips (%s)." % lang)
out.append("-- Generated by util/zbstudioApi.py from the Reference and Singe's own globals; do not edit.")
out.append("return {")
def emit(table, indent):
for name, item in sorted(table.items()):
out.append("%s%s = {" % (indent, name if re.match(r"^[A-Za-z_]\w*$", name) else "[" + luaString(name) + "]"))
for field in ("type", "description", "args", "returns", "anchor"):
if field in item:
out.append("%s\t%s = %s," % (indent, field, luaString(item[field])))
if "childs" in item:
out.append("%s\tchilds = {" % indent)
emit(item["childs"], indent + "\t\t")
out.append("%s\t}," % indent)
out.append("%s}," % indent)
emit(api, "\t")
out.append("}")
with open(path, "w", encoding="utf-8", newline="\n") as f:
f.write("\n".join(out) + "\n")
def main():
strict = "--strict" in sys.argv
if "--singe" in sys.argv:
singe = sys.argv[sys.argv.index("--singe") + 1]
else:
singe = singeBinary()
if not os.access(singe, os.X_OK):
sys.exit("No Singe binary at %s to ask for its globals; build one or pass --singe." % singe)
globalsFound = dumpGlobals(singe)
english, englishConstants = parseReference(os.path.join(DOCS, "Reference.adoc"), "en")
functions = set(n for n, g in globalsFound.items() if g["type"] == "function")
callbacks = set(n for n, e in english.items() if any(sig.startswith("function ") for sig in e["signatures"]))
aliases = findAliases(globalsFound, english)
undocumented = sorted(functions - set(english) - set(aliases))
unknown = sorted(set(english) - functions - callbacks)
for name in undocumented:
print("Not in the Reference: %s" % name)
for name in unknown:
print("In the Reference but not defined: %s" % name)
os.makedirs(OUT, exist_ok=True)
for lang in languages():
path = os.path.join(DOCS, "Reference.adoc") if lang == "en" else os.path.join(DOCS, lang, "Reference.adoc")
entries, constants = (english, englishConstants) if lang == "en" else parseReference(path, lang)
api = build(globalsFound, english, entries, constants)
for alias, target in aliases.items():
api[alias] = dict(api[target])
api[alias]["description"] = "= " + target + "\n" + api[target]["description"]
api[alias]["anchor"] = api[target].get("anchor", target.lower())
target = os.path.join(OUT, "api.lua" if lang == "en" else "api-%s.lua" % lang)
writeLua(target, api, lang)
print("%s %d names, %d bytes" % (os.path.relpath(target, REPO), len(api), os.path.getsize(target)))
if strict and (undocumented or unknown):
sys.exit(1)
main()