Files
2026-08-04 10:21:18 -04:00

250 lines
9.8 KiB
Python

#!/usr/bin/env python3
"""Generate LuaLS declarations from shared binding registration annotations.
Each binding source declares one or more Lua modules, immediately above the luaL_Reg
table that registers them:
// @lua-preamble ---@alias Feature "touch"|"lcd"
// @lua-module sys SysLib creates the global
// @lua-augment gui GuiLib extends a global another file created
// @lua-global core/runtime app callbacks, written to an explicit path
// @lua-const MAX_READ_BYTES integer 65536 Largest portable read.
// @lua-postamble -- notes rendered after the module
Per function, `// --- text`, `// @param name type desc`, and `// @return type desc`. Functions come
from the luaL_Reg entries below the directive, or from `// @lua-fn name` for the app callbacks the
runtime calls rather than registers.
"""
import argparse
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SOURCES = [ROOT / "native/src/bindings", ROOT / "native/src/runtime"]
OUTPUT = ROOT / "lua/api"
MODULE = re.compile(r"^// @lua-(?P<kind>module|augment) (?P<name>\w+) (?P<class_>\w+)$")
GLOBAL = re.compile(r"^// @lua-global(?: (?P<path>\S+))?$")
FUNCTION = re.compile(r"^// @lua-fn (?P<name>\w+)$")
CONST = re.compile(r"^// @lua-const (?P<name>\w+) (?P<type>\S+) (?P<value>\S+)(?: (?P<desc>.*))?$")
FIX = re.compile(r"^// @lua-(?P<where>preamble|postamble) ?(?P<text>.*)$")
TABLE = re.compile(r"^\s*const luaL_Reg (?P<var>\w+)\[\] = \{$")
ENTRY = re.compile(r'^\s*\{"(?P<name>\w+)",\s*\w+\},?$')
DOC = re.compile(r"^\s*// ---\s?(?P<text>.*)$")
PARAM = re.compile(r"^\s*// @param (?P<name>\w+) (?P<type>\S+)(?: (?P<desc>.*))?$")
RETURN = re.compile(r"^\s*// @return (?P<type>\S+)(?: (?P<desc>.*))?$")
TAG = re.compile(r"^\s*// @(?P<tag>[\w-]+)")
CONTINUATION = re.compile(r"^\s*// (?P<text>(?!@|---)\S.*)$")
TYPES = {"int": "integer", "bool": "boolean"}
def lua_type(raw):
optional = raw.endswith("|nil")
if optional:
raw = raw[:-4]
return TYPES.get(raw, raw) + ("?" if optional else "")
def new_module(kind, name, class_name, path=None):
return {
"kind": kind,
"name": name,
"class": class_name,
"path": path,
"consts": [],
"preamble": [],
"postamble": [],
"functions": [],
}
def parse(path):
"""Return the modules a binding source declares, in file order."""
modules = []
pending_module = None
current = None
doc = {"doc": [], "params": [], "returns": []}
in_table = False
continuation = None
def reset():
return {"doc": [], "params": [], "returns": []}
for number, line in enumerate(path.read_text().splitlines(), 1):
stripped = line.strip()
where = f"{path.relative_to(ROOT)}:{number}"
wrapped = CONTINUATION.match(line)
if continuation and wrapped:
items, index, field = continuation
if field is None:
items[index] += " " + wrapped.group("text")
else:
item = list(items[index])
item[field] += " " + wrapped.group("text")
items[index] = tuple(item)
continue
continuation = None
module = MODULE.match(stripped)
if module:
pending_module = new_module(module.group("kind"), module.group("name"), module.group("class_"))
modules.append(pending_module)
continue
glob = GLOBAL.match(stripped)
if glob:
# Callbacks are globals an app defines, so they need no table and no class.
pending_module = new_module("global", None, None, glob.group("path"))
modules.append(pending_module)
current, in_table = pending_module, False
doc = reset()
continue
function = FUNCTION.match(stripped)
if function:
if not current or current["kind"] != "global":
raise SystemExit(f"{where}: @lua-fn outside a @lua-global block")
if not doc["doc"]:
raise SystemExit(f"{where}: {function.group('name')} has no description")
current["functions"].append((function.group("name"), doc))
doc = reset()
continue
target = pending_module or current
const = CONST.match(stripped)
if const:
if not target:
raise SystemExit(f"{where}: @lua-const outside a module")
target["consts"].append(
(const.group("name"), lua_type(const.group("type")), const.group("value"), const.group("desc") or "")
)
continuation = (target["consts"], len(target["consts"]) - 1, 3)
continue
fix = FIX.match(stripped)
if fix:
if not target:
raise SystemExit(f"{where}: @lua-{fix.group('where')} outside a module")
items = target[fix.group("where")]
items.append(fix.group("text"))
continuation = (items, len(items) - 1, None)
continue
table = TABLE.match(line)
if table:
if not pending_module:
raise SystemExit(f"{where}: {table.group('var')} has no @lua-module or @lua-augment")
current, pending_module, in_table, doc = pending_module, None, True, reset()
continue
# A @lua-global block documents callbacks the runtime calls, so it has no table to sit in.
if not in_table and not (current and current["kind"] == "global"):
continue
if stripped == "};":
in_table = False
continue
match = DOC.match(line)
if match:
doc["doc"].append(match.group("text").strip())
continuation = (doc["doc"], len(doc["doc"]) - 1, None)
continue
match = PARAM.match(line)
if match:
doc["params"].append((match.group("name"), lua_type(match.group("type")), match.group("desc") or ""))
continuation = (doc["params"], len(doc["params"]) - 1, 2)
continue
match = RETURN.match(line)
if match:
doc["returns"].append((lua_type(match.group("type")), match.group("desc") or ""))
continuation = (doc["returns"], len(doc["returns"]) - 1, 1)
continue
match = TAG.match(line)
if match:
raise SystemExit(f"{where}: unknown annotation @{match.group('tag')}")
match = ENTRY.match(line)
if match:
name = match.group("name")
if not doc["doc"]:
raise SystemExit(f"{where}: {name} has no description")
current["functions"].append((name, doc))
doc = reset()
for module in modules:
if module["kind"] == "global":
continue
if not module["functions"]:
raise SystemExit(f"{path.relative_to(ROOT)}: {module['name']} registers no annotated functions")
return modules
def render(source, modules):
lines = ["---@meta", "", f"-- Generated from {source.relative_to(ROOT)}. Do not edit.", ""]
for module in modules:
name = module["name"]
prefix = f"{name}." if name else ""
lines.extend(module["preamble"])
if module["preamble"]:
lines.append("")
if module["kind"] != "global":
lines.append(f"---@class {module['class']}")
for const, type_, _, desc in module["consts"]:
lines.append(f"---@field {const} {type_}{(' ' + desc) if desc else ''}")
lines.append(f"{name} = {name} or {{}}" if module["kind"] == "augment" else f"{name} = {{}}")
for const, _, value, _ in module["consts"]:
lines.append(f"{name}.{const} = {value}")
lines.append("")
for function, doc in module["functions"]:
for text in doc["doc"]:
lines.append(f"---{text}")
for param, type_, desc in doc["params"]:
# LuaLS marks an optional parameter on the name, and an optional return on the type.
optional = type_.endswith("?")
lines.append(
f"---@param {param}{'?' if optional else ''} {type_.rstrip('?')}{(' ' + desc) if desc else ''}"
)
for type_, desc in doc["returns"]:
lines.append(f"---@return {type_}{(' ' + desc) if desc else ''}")
args = ", ".join(param for param, _, _ in doc["params"])
lines.extend((f"function {prefix}{function}({args}) end", ""))
if module["postamble"]:
lines.extend(module["postamble"] + [""])
return "\n".join(lines[:-1] + [""])
def generated_files():
for root in SOURCES:
for source in sorted(root.glob("**/*.cpp")):
modules = parse(source)
if not modules:
continue
explicit = next((module["path"] for module in modules if module["path"]), None)
relative = f"{explicit}.lua" if explicit else str(source.relative_to(root).with_suffix(".lua"))
yield source, OUTPUT / relative, render(source, modules)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
generated = list(generated_files())
if not generated:
raise SystemExit("no annotated bindings found")
stale = []
for source, output, content in generated:
if args.check:
if not output.exists() or output.read_text() != content:
stale.append(str(output.relative_to(ROOT)))
else:
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(content)
print(f"wrote {output.relative_to(ROOT)} from {source.relative_to(ROOT)}")
if stale:
raise SystemExit("stale generated declarations: " + ", ".join(stale) + " (run make api)")
if __name__ == "__main__":
main()