#!/usr/bin/env python3 """Generate the LuaLS stub for the firmware's Lua API. Adapted from crosspoint-reader's scripts/gen_lua_stubs.py. The emitted format is deliberately identical so an editor configured for one repo works for the other; the parser differs because this firmware registers bindings as annotated luaL_Reg tables rather than addFunction() calls, which keeps a module's documentation contiguous. python3 scripts/gen_lua_stubs.py # write the stub python3 scripts/gen_lua_stubs.py --check # exit 1 if it is stale """ import re import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent SOURCES = sorted((ROOT / "src/lua/bindings").glob("*.cpp")) OUTPUT = ROOT / "stubs/esp32lcd.lua" TYPES = {"int": "integer", "bool": "boolean"} ENTRY = re.compile(r'^\s*\{"(?P\w+)",\s*\w+\}') TABLE = re.compile(r"static const luaL_Reg (?P\w+)\[\]") BIND = re.compile(r'luaL_newlib\(L,\s*(?P\w+)\);\s*\n\s*lua_setglobal\(L,\s*"(?P\w+)"\)') DOC = re.compile(r"^\s*//\s*---\s?(?P.*)$") PARAM = re.compile(r"^\s*//\s*@param\s+(?P\w+)\s+(?P\S+)(?:\s+(?P.*))?$") RETURN = re.compile(r"^\s*//\s*@return\s+(?P\S+)(?:\s+(?P.*))?$") TAG = re.compile(r"^\s*//\s*@(?P\w+)") def lua_type(raw): """`int|nil` becomes `integer?`, matching how LuaLS spells optionality.""" optional = raw.endswith("|nil") if optional: raw = raw[: -len("|nil")] mapped = TYPES.get(raw, raw) return f"{mapped}?" if optional else mapped def parse(path): """Collect {var: [function, ...]} and {var: global name} from one file.""" text = path.read_text() tables, bindings = {}, {} for match in BIND.finditer(text): bindings[match.group("var")] = match.group("global") lines = text.splitlines() var, pending = None, {"doc": [], "params": [], "returns": []} def reset(): return {"doc": [], "params": [], "returns": []} for line in lines: table = TABLE.search(line) if table: var = table.group("var") tables.setdefault(var, []) pending = reset() continue if var is None: continue doc = DOC.match(line) if doc: pending["doc"].append(doc.group("text").strip()) continue param = PARAM.match(line) if param: pending["params"].append( (param.group("name"), lua_type(param.group("type")), (param.group("desc") or "").strip()) ) continue ret = RETURN.match(line) if ret: pending["returns"].append((lua_type(ret.group("type")), (ret.group("desc") or "").strip())) continue tag = TAG.match(line) if tag: raise SystemExit(f"{path.name}: unknown tag @{tag.group('tag')}") entry = ENTRY.match(line) if entry: name = entry.group("name") if not pending["doc"]: raise SystemExit(f"{path.name}: {name} is registered without a description") tables[var].append((name, pending)) pending = reset() continue if "};" in line: var = None return tables, bindings def render(modules): out = [ "---@meta", "", "-- Generated by scripts/gen_lua_stubs.py. Do not edit.", "-- Point your editor's Lua language server at this file to get completion for the", "-- firmware API inside sdcard/apps and sdcard/lib.", "", ] for global_name in sorted(modules): functions = modules[global_name] out.append(f"---@class {global_name}lib") out.append(f"{global_name} = {{}}") out.append("") for name, meta in functions: for line in meta["doc"]: out.append(f"--- {line}" if line else "---") for param, type_, desc in meta["params"]: out.append(f"---@param {param} {type_}{(' ' + desc) if desc else ''}") for type_, desc in meta["returns"]: out.append(f"---@return {type_}{(' ' + desc) if desc else ''}") args = ", ".join(param for param, _, _ in meta["params"]) out.append(f"function {global_name}.{name}({args}) end") out.append("") out.extend( [ "-- Callbacks an app may define as globals:", "-- init() once, before the first draw (required)", "-- draw() every 33 ms while the app runs", "-- on_tick() at the interval app.setTickInterval() asked for", "-- on_touch_down(x, y) finger down", "-- on_touch_up(x, y) finger up", "-- on_touch(x, y) tap, fired on release like a click", "", ] ) return "\n".join(out) def main(): modules = {} for path in SOURCES: tables, bindings = parse(path) for var, functions in tables.items(): if var not in bindings: continue # a table that is never installed as a global documents nothing modules.setdefault(bindings[var], []).extend(functions) if not modules: raise SystemExit("no annotated bindings found") rendered = render(modules) if "--check" in sys.argv: current = OUTPUT.read_text() if OUTPUT.exists() else "" if current != rendered: raise SystemExit(f"{OUTPUT.relative_to(ROOT)} is stale; run scripts/gen_lua_stubs.py") return OUTPUT.parent.mkdir(parents=True, exist_ok=True) OUTPUT.write_text(rendered) print(f"wrote {OUTPUT.relative_to(ROOT)}: {sum(len(f) for f in modules.values())} functions") if __name__ == "__main__": main()