refactor(api)!: declare callbacks as classes an app composes

The runtime has always called fields on the table main.lua returns, but
@lua-global declared them as loose functions, so the stubs type-checked
something that does not exist and read as "define a global".

Callbacks are now @lua-app blocks that generate a class: App for the core
contract, TouchHandlers and ButtonHandlers beside the namespaces they belong
to. An app composes what it implements:

  ---@class PaintApp : App, TouchHandlers

Names follow the rest of the surface: onTouchDown rather than on_touch_down,
with the field names the runtime looks up renamed to match. @lua-field carries
the plain fields (home, data) that were prose in a preamble before.
This commit is contained in:
2026-08-05 10:42:01 -04:00
parent 75b3a2c490
commit b9f7c9347c
11 changed files with 153 additions and 87 deletions
+56 -15
View File
@@ -7,13 +7,16 @@ table that registers them:
// @lua-preamble ---@alias Feature "touch"|"lcd"
// @lua-module sys SysLib creates the global
// @lua-augment gui GuiLib extends a global another file created
// @lua-global core/runtime app callbacks, written to an explicit path
// @lua-app App a class of fields on the table an app returns
// @lua-const MAX_READ_BYTES integer 65536 Largest portable read.
// @lua-field home? string a plain field of a @lua-app class
// @lua-postamble -- notes rendered after the module
Per function, `// --- text`, `// @param name type desc`, and `// @return type desc`. Functions come
from the luaL_Reg entries below the directive, or from `// @lua-fn name` for the app callbacks the
runtime calls rather than registers.
from the luaL_Reg entries below the directive, or from `// @lua-fn name` for the callbacks the
runtime calls on an app's table rather than registers. A `@lua-fn` name ending in `?` is one the
app may leave out; the class it lands in is what an app composes, so a device without the feature
contributes no fields rather than the runtime hiding some.
"""
import argparse
@@ -25,9 +28,10 @@ SOURCES = [ROOT / "native/src/bindings", ROOT / "native/src/runtime"]
OUTPUT = ROOT / "lua/api"
MODULE = re.compile(r"^// @lua-(?P<kind>module|augment) (?P<name>\w+) (?P<class_>\w+)$")
GLOBAL = re.compile(r"^// @lua-global(?: (?P<path>\S+))?$")
FUNCTION = re.compile(r"^// @lua-fn (?P<name>\w+)$")
APP = re.compile(r"^// @lua-app (?P<class_>\w+)(?: (?P<path>\S+))?$")
FUNCTION = re.compile(r"^// @lua-fn (?P<name>\w+\??)$")
CONST = re.compile(r"^// @lua-const (?P<name>\w+) (?P<type>\S+) (?P<value>\S+)(?: (?P<desc>.*))?$")
FIELD = re.compile(r"^// @lua-field (?P<name>\w+\??) (?P<type>\S+)(?: (?P<desc>.*))?$")
FIX = re.compile(r"^// @lua-(?P<where>preamble|postamble) ?(?P<text>.*)$")
TABLE = re.compile(r"^\s*const luaL_Reg (?P<var>\w+)\[\] = \{$")
ENTRY = re.compile(r'^\s*\{"(?P<name>\w+)",\s*\w+\},?$')
@@ -53,6 +57,7 @@ def new_module(kind, name, class_name, path=None):
"class": class_name,
"path": path,
"consts": [],
"fields": [],
"preamble": [],
"postamble": [],
"functions": [],
@@ -92,18 +97,19 @@ def parse(path):
pending_module = new_module(module.group("kind"), module.group("name"), module.group("class_"))
modules.append(pending_module)
continue
glob = GLOBAL.match(stripped)
if glob:
# Callbacks are globals an app defines, so they need no table and no class.
pending_module = new_module("global", None, None, glob.group("path"))
app = APP.match(stripped)
if app:
# Callbacks are fields of the table an app returns, so the block is a class
# with no table of its own to register.
pending_module = new_module("app", None, app.group("class_"), app.group("path"))
modules.append(pending_module)
current, in_table = pending_module, False
doc = reset()
continue
function = FUNCTION.match(stripped)
if function:
if not current or current["kind"] != "global":
raise SystemExit(f"{where}: @lua-fn outside a @lua-global block")
if not current or current["kind"] != "app":
raise SystemExit(f"{where}: @lua-fn outside a @lua-app block")
if not doc["doc"]:
raise SystemExit(f"{where}: {function.group('name')} has no description")
current["functions"].append((function.group("name"), doc))
@@ -120,6 +126,15 @@ def parse(path):
)
continuation = (target["consts"], len(target["consts"]) - 1, 3)
continue
field = FIELD.match(stripped)
if field:
if not target or target["kind"] != "app":
raise SystemExit(f"{where}: @lua-field outside a @lua-app block")
target["fields"].append(
(field.group("name"), lua_type(field.group("type")), field.group("desc") or "")
)
continuation = (target["fields"], len(target["fields"]) - 1, 2)
continue
fix = FIX.match(stripped)
if fix:
if not target:
@@ -135,8 +150,8 @@ def parse(path):
raise SystemExit(f"{where}: {table.group('var')} has no @lua-module or @lua-augment")
current, pending_module, in_table, doc = pending_module, None, True, reset()
continue
# A @lua-global block documents callbacks the runtime calls, so it has no table to sit in.
if not in_table and not (current and current["kind"] == "global"):
# A @lua-app block documents callbacks the runtime calls, so it has no table to sit in.
if not in_table and not (current and current["kind"] == "app"):
continue
if stripped == "};":
in_table = False
@@ -169,13 +184,28 @@ def parse(path):
doc = reset()
for module in modules:
if module["kind"] == "global":
if module["kind"] == "app":
continue
if not module["functions"]:
raise SystemExit(f"{path.relative_to(ROOT)}: {module['name']} registers no annotated functions")
return modules
def signature(doc):
"""The `fun(...)` type of one callback, for the class an app composes."""
params = ", ".join(
f"{param}{'?' if type_.endswith('?') else ''}: {type_.rstrip('?')}" for param, type_, _ in doc["params"]
)
returns = ", ".join(type_ for type_, _ in doc["returns"])
return f"fun({params})" + (f": {returns}" if returns else "")
def describe(doc):
"""A field carries one line, so a wrapped description joins back into a sentence."""
text = " ".join(doc["doc"])
return f" {text}" if text else ""
def render(source, modules):
lines = ["---@meta", "", f"-- Generated from {source.relative_to(ROOT)}. Do not edit.", ""]
for module in modules:
@@ -184,7 +214,18 @@ def render(source, modules):
lines.extend(module["preamble"])
if module["preamble"]:
lines.append("")
if module["kind"] != "global":
if module["kind"] == "app":
lines.append(f"---@class {module['class']}")
for field, type_, desc in module["fields"]:
lines.append(f"---@field {field} {type_}{(' ' + desc) if desc else ''}")
for function, doc in module["functions"]:
lines.append(f"---@field {function} {signature(doc)}{describe(doc)}")
lines.append("")
if module["postamble"]:
lines.extend(module["postamble"] + [""])
continue
if module["kind"] != "app":
lines.append(f"---@class {module['class']}")
for const, type_, _, desc in module["consts"]:
lines.append(f"---@field {const} {type_}{(' ' + desc) if desc else ''}")
+25 -1
View File
@@ -3,7 +3,7 @@
import tempfile
from pathlib import Path
from gen_api import ROOT, parse
from gen_api import ROOT, parse, render
with tempfile.TemporaryDirectory(dir=ROOT) as directory:
@@ -30,4 +30,28 @@ doc = module["functions"][0][1]
assert doc["doc"] == ["Reads one complete file."]
assert doc["params"][0][2] == "Absolute file path."
assert doc["returns"][0][1] == "File contents."
# Callbacks render as fields of a class an app composes, never as global functions.
with tempfile.TemporaryDirectory(dir=ROOT) as directory:
source = Path(directory) / "handlers.cpp"
source.write_text(
"""// @lua-app TouchHandlers
// @lua-field home? string The route back lands on.
// ---Fired when the finger lands.
// @param x integer
// @param y integer
// @lua-fn onTouchDown?
// ---Mounts the route.
// @param route string
// @param arg string|nil
// @lua-fn start
"""
)
output = render(source, parse(source))
assert "---@class TouchHandlers" in output
assert "---@field home? string The route back lands on." in output
assert "---@field onTouchDown? fun(x: integer, y: integer) Fired when the finger lands." in output
assert "---@field start fun(route: string, arg?: string) Mounts the route." in output
assert "function " not in output
print("ok")