#!/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-(?Pmodule|augment) (?P\w+) (?P\w+)$") GLOBAL = re.compile(r"^// @lua-global(?: (?P\S+))?$") FUNCTION = re.compile(r"^// @lua-fn (?P\w+)$") CONST = re.compile(r"^// @lua-const (?P\w+) (?P\S+) (?P\S+)(?: (?P.*))?$") FIX = re.compile(r"^// @lua-(?Ppreamble|postamble) ?(?P.*)$") TABLE = re.compile(r"^\s*const luaL_Reg (?P\w+)\[\] = \{$") ENTRY = re.compile(r'^\s*\{"(?P\w+)",\s*\w+\},?$') DOC = re.compile(r"^\s*// ---\s?(?P.*)$") PARAM = re.compile(r"^\s*// @param (?P\w+) (?P\S+)(?: (?P.*))?$") RETURN = re.compile(r"^\s*// @return (?P\S+)(?: (?P.*))?$") TAG = re.compile(r"^\s*// @(?P[\w-]+)") 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 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}" 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 "") ) continue fix = FIX.match(stripped) if fix: if not target: raise SystemExit(f"{where}: @lua-{fix.group('where')} outside a module") target[fix.group("where")].append(fix.group("text")) 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()) continue match = PARAM.match(line) if match: doc["params"].append((match.group("name"), lua_type(match.group("type")), match.group("desc") or "")) continue match = RETURN.match(line) if match: doc["returns"].append((lua_type(match.group("type")), match.group("desc") or "")) 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()