d338dfe3e8
crosspoint-reader calls it init() and requires it; this called it setup() and treated it as optional. Same concept, two spellings, so an app could not move between the two firmwares for no reason worth defending. init() wins because it is also the stricter contract: a misspelled entry point is now an error instead of an app that starts, draws nothing, and explains nothing. Requiring it exposed that error screens were unreadable. fail() painted the message and the host relaunched the launcher over it on the very next frame, so every Lua error was serial-only -- which would have made "Missing init()" useless to anyone holding the device rather than a console.
161 lines
5.7 KiB
Python
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/esp32lcd.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() every TICK_MS, when that global is set",
|
|
"-- 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()
|