Files
slate32/scripts/gen_lua_stubs.py
T
evan 7925eace57 chore: rename the project to slate32
esp32-lcd named the substrate rather than the thing, and both halves would age: the
chip is swappable and the panel technology is incidental. What the project actually is
is a Lua app platform that happens to run on a cheap touchscreen, so the name is now a
model number rather than a parts list. slate32 is also unclaimed, where slate alone
collides with several well known projects.

Board identifiers stay as they were -- the e32r40t QEMU machine, the esp32-32e build
env and the test skill name all refer to real hardware, which did not get renamed.
2026-08-01 18:58:16 -04:00

161 lines
5.7 KiB
Python

#!/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/slate32.lua"
TYPES = {"int": "integer", "bool": "boolean"}
ENTRY = re.compile(r'^\s*\{"(?P<name>\w+)",\s*\w+\}')
TABLE = re.compile(r"static const luaL_Reg (?P<var>\w+)\[\]")
BIND = re.compile(r'luaL_newlib\(L,\s*(?P<var>\w+)\);\s*\n\s*lua_setglobal\(L,\s*"(?P<global>\w+)"\)')
DOC = re.compile(r"^\s*//\s*---\s?(?P<text>.*)$")
PARAM = re.compile(r"^\s*//\s*@param\s+(?P<name>\w+)\s+(?P<type>\S+)(?:\s+(?P<desc>.*))?$")
RETURN = re.compile(r"^\s*//\s*@return\s+(?P<type>\S+)(?:\s+(?P<desc>.*))?$")
TAG = re.compile(r"^\s*//\s*@(?P<tag>\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()