Files
slate32/scripts/gen_lua_stubs.py
T
evan 6f2d618b8d feat: http bindings matching crosspoint-reader, plus generated Lua stubs
The http table copies crosspoint-reader's signatures exactly -- get/head/delete/post/
patch returning (body|nil, status), download taking maxBytes/expectedSize/sha256, the
same 50000 byte body cap and the same -1 for a request that never left the device --
so a script that talks to a server runs on either firmware. docs/lua-api-parity.md
records that, and every other place the two APIs agree, differ for a reason, or differ
because nobody noticed.

Two crosspoint behaviours are deliberately not copied. It reinterprets a string in
argument 2 of a GET as a request body, which turns a mistyped headers table into a
silent protocol error. More seriously it calls setInsecure() on every request, so TLS
is encrypted but unauthenticated on the very path a firmware update would use; this
verifies against the root bundle already sitting in the framework, and the emulator
confirms expired.badssl.com is refused while a wrong sha256 deletes the file.

Downloading exposed two failures worth naming. A 2KB read buffer on the stack tripped
the loop task's canary because a TLS handshake had already spent it, and the
hand-rolled read loop spun forever on a stream that stopped producing -- HTTPClient's
own writeToStream handles both, so the loop is gone and the loop task gets 16KB.

scripts/gen_lua_stubs.py generates stubs/esp32lcd.lua in the same LuaLS format
crosspoint uses, reading annotations off the luaL_Reg tables so a module's docs sit
with its registration. make test runs --check, which crosspoint's copy never wired up.
2026-08-01 17:33:47 -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/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:",
"-- setup() once, before the first draw",
"-- 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()