8045faddb4
A magic global that the runtime reads once at startup could not be changed later, gave no feedback when misspelled, and was a second spelling of a mechanism the sibling firmware already had. app.setTickInterval(ms) clamps to 33..3600000, takes 0 to stop, and errors when on_tick() is not defined -- by the time init() runs the chunk body has finished, so a missing callback is a typo rather than a race. Also drops the code comments pointing at the other repo. Where the two APIs agree or differ belongs in docs/lua-api-parity.md; a comment beside a constant explaining that another firmware picked the same number is noise a reader here cannot act on.
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() 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()
|