Files
plainleaf/client/space_lua/labels.ts
T
Matouš Jan FialkaandGitHub 7912f05949 Space Lua: Add goto and labels support (#1686)
* Space Lua: Add AST block type label/goto and duplicated label bools
* Space Lua: Add label/goto resolution and evaluation

Add per block map of labels metadata, per function block metadata cache
and `processBlock` to track AST blocks (`If`, `While`, `Repeat`, `For`
and `ForIn`, and `Do`) while recording labels and their positions.

Align with Lua scoping rules (_e.g._, a `goto` can not jump into the
scope of a local). Also allow the super-special "safe" end of block
cases (except inside `Repeat`). Duplicated labels are detected (error
`label 'foo' is already defined` etc.). The AST context for error
reporting is preserved. The `WeakMap` caches map any block to its
function's metadata.

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Space Lua: The `goto` parse flags and eval path

At parser mark each block with `hasLabel`/`hasGoto` and record the first
duplicated label. Flags then propagate through blocks and control flow.

If no `goto`, run the block in a simple loop and raise duplicate label
error if any is present. If `goto` exists, jump via a label map.

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Space Lua: Add labels and `goto` statement test suite

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Space Lua: Minor `goto`/labels improvement and test hotfix

- The `getBlockGotoMeta` now called only when a block has either
  `hasGoto` or `hasLabel`.

- Add `BlocksFlags` type to get rid of some `(... as any)`s by view.

- Add `blockMetaOrThrow` helper.

- Fix test to reflect standard Lua semantics.

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Space Lua: Goto: Add missing Lua semantics checks

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Space Lua: Goto: Add Lua semantics checks also covered in Lua tests

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Space Lua: Goto: Fix semantic divergence revealed by fixing unfinished test

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Fix typo

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Space Lua: Optimize block env creation and cache goto metadata

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Avoid using the venomous snake case

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Add comment for WeakMap use

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Space Lua: Block Label Fast-path Optimization

Add block flag `hasLabelHere` during parsing and use it in eval to skip
expensive metadata generation for blocks and entire functions without
label or goto.

Refactor eval logic:
- early fast path when a function is known to have no label/goto,
- later fast path for blocks in functions with goto.

Fetch metadata only when needed.

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Space Lua: Remove `BlockFlags` indirection from eval

- Add `hasLabelHere` flag to `LuaBlock`.
- Remove `BlockFlags` indirection from eval.
- Set `hasLabelHere` directly.
- Add some "guards".
- Improve consistency of child filtering.

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Remove redundant return statement

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

---------

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>
2025-11-18 16:35:08 +01:00

404 lines
9.3 KiB
TypeScript

// Goto/label resolution and validation for function bodies
import type {
ASTCtx,
LuaBlock,
LuaForInStatement,
LuaForStatement,
LuaGotoStatement,
LuaIfStatement,
LuaLabelStatement,
LuaLocalStatement,
LuaRepeatStatement,
LuaWhileStatement,
} from "./ast.ts";
import { asBlock } from "./ast_narrow.ts";
type BlockGotoMeta = {
labels: Map<string, number>; // in this block
};
type FunctionMeta = {
// Stores per-block data and drops entries automatically when a block
// is no longer used.
blockMeta: WeakMap<LuaBlock, BlockGotoMeta>;
funcHasGotos: boolean;
};
class LabelResolveError extends Error {
constructor(msg: string, public astCtx: ASTCtx) {
super(msg);
}
}
// Cache
const functionMetaByRoot = new WeakMap<LuaBlock, FunctionMeta>();
const functionMetaByAnyBlock = new WeakMap<LuaBlock, FunctionMeta>();
export function getBlockGotoMeta(
block: LuaBlock,
): (BlockGotoMeta & { funcHasGotos: boolean }) | undefined {
let fm = functionMetaByAnyBlock.get(block);
if (!fm) {
fm = resolveFunction(block);
}
const bm = fm.blockMeta.get(block);
if (!bm) {
return undefined;
}
return { ...bm, funcHasGotos: fm.funcHasGotos };
}
type LocalID = number;
type GotoInfo = {
node: LuaGotoStatement;
active: Set<LocalID>;
block: LuaBlock;
};
type ValidationCtx = {
labelActiveByBlock: WeakMap<LuaBlock, Map<string, Set<LocalID>>>;
labelLocByBlock: WeakMap<
LuaBlock,
Map<string, { index: number; ctx: ASTCtx }>
>;
gotos: GotoInfo[];
hasGoto: boolean;
nextLocalId: number;
};
type BlockRole =
| "Root"
| "Do"
| "If"
| "While"
| "Repeat"
| "For"
| "ForIn";
function resolveFunction(root: LuaBlock): FunctionMeta {
const existing = functionMetaByRoot.get(root);
if (existing) {
return existing;
}
const blockMeta = new WeakMap<LuaBlock, BlockGotoMeta>();
const vctx: ValidationCtx = {
labelActiveByBlock: new WeakMap(),
labelLocByBlock: new WeakMap(),
gotos: [],
hasGoto: false,
nextLocalId: 1,
};
const seenBlocks = new Set<LuaBlock>();
const parentByBlock = new WeakMap<LuaBlock, LuaBlock | undefined>();
const roleByBlock = new WeakMap<LuaBlock, BlockRole>();
processBlock(
root,
undefined,
"Root",
new Set<LocalID>(),
new Set<string>(),
blockMeta,
vctx,
seenBlocks,
parentByBlock,
roleByBlock,
);
// Validate gotos
for (const g of vctx.gotos) {
const target = g.node.name;
// Search current block for the label, then ancestors
let searchBlock: LuaBlock | undefined = g.block;
let labelIndex: number | undefined;
let labelDefBlock: LuaBlock | undefined;
while (searchBlock) {
const meta = blockMeta.get(searchBlock);
if (meta && meta.labels.has(target)) {
labelIndex = meta.labels.get(target);
labelDefBlock = searchBlock;
break;
}
searchBlock = parentByBlock.get(searchBlock);
}
if (labelIndex === undefined || !labelDefBlock) {
throw new LabelResolveError(
`no visible label '${target}' for goto`,
g.node.ctx,
);
}
const activeMap = vctx.labelActiveByBlock.get(labelDefBlock);
const locMap = vctx.labelLocByBlock.get(labelDefBlock);
if (!activeMap || !locMap) {
throw new LabelResolveError(
`no visible label '${target}' for goto`,
g.node.ctx,
);
}
const lset = activeMap.get(target);
const lloc = locMap.get(target);
if (!lset || !lloc) {
throw new LabelResolveError(
`no visible label '${target}' for goto`,
g.node.ctx,
);
}
// Local scope forward jump check
let entersLocalScope = false;
for (const id of lset) {
if (!g.active.has(id)) {
entersLocalScope = true;
break;
}
}
if (entersLocalScope) {
const safeEnd = isSafeEndLabel(labelDefBlock, lloc.index, roleByBlock);
if (!safeEnd) {
throw new LabelResolveError(
`goto '${target}' jumps into the scope of a local variable`,
g.node.ctx,
);
}
}
}
const fm: FunctionMeta = {
blockMeta,
funcHasGotos: vctx.hasGoto,
};
functionMetaByRoot.set(root, fm);
for (const block of seenBlocks) {
functionMetaByAnyBlock.set(block, fm);
}
return fm;
}
function isSafeEndLabel(
block: LuaBlock,
labelIndex: number,
roleByBlock: WeakMap<LuaBlock, BlockRole>,
): boolean {
const role = roleByBlock.get(block);
if (role === "Repeat") {
return false;
}
for (let i = labelIndex + 1; i < block.statements.length; i++) {
const t = block.statements[i].type;
if (t !== "Label" && t !== "Semicolon") {
return false;
}
}
return true;
}
function cloneSet<T>(s: Set<T>): Set<T> {
const c = new Set<T>();
for (const v of s) {
c.add(v);
}
return c;
}
function processBlock(
block: LuaBlock,
parent: LuaBlock | undefined,
role: BlockRole,
active: Set<LocalID>,
visibleLabels: Set<string>,
blockMeta: WeakMap<LuaBlock, BlockGotoMeta>,
vctx: ValidationCtx,
seen: Set<LuaBlock>,
parentByBlock: WeakMap<LuaBlock, LuaBlock | undefined>,
roleByBlock: WeakMap<LuaBlock, BlockRole>,
): void {
const labels = new Map<string, number>();
blockMeta.set(block, { labels });
seen.add(block);
parentByBlock.set(block, parent);
roleByBlock.set(block, role);
const labelActiveMap = new Map<string, Set<LocalID>>();
const labelLocMap = new Map<string, { index: number; ctx: ASTCtx }>();
vctx.labelActiveByBlock.set(block, labelActiveMap);
vctx.labelLocByBlock.set(block, labelLocMap);
const curActive = cloneSet(active);
const vis = new Set<string>(visibleLabels);
const stmts = block.statements;
for (let i = 0; i < stmts.length; i++) {
const s = stmts[i];
switch (s.type) {
case "Label": {
const lab = s as LuaLabelStatement;
if (labels.has(lab.name)) {
throw new LabelResolveError(
`label '${lab.name}' already defined`,
lab.ctx,
);
}
labels.set(lab.name, i);
const actSet = cloneSet(curActive);
labelActiveMap.set(lab.name, actSet);
labelLocMap.set(lab.name, { index: i, ctx: lab.ctx });
vis.add(lab.name);
break;
}
case "Goto": {
const g = s as LuaGotoStatement;
vctx.hasGoto = true;
vctx.gotos.push({ node: g, active: cloneSet(curActive), block });
break;
}
case "Local": {
const l = s as LuaLocalStatement;
for (let j = 0; j < l.names.length; j++) {
curActive.add(vctx.nextLocalId++);
}
break;
}
case "LocalFunction": {
curActive.add(vctx.nextLocalId++);
break;
}
case "Function": {
break;
}
case "For": {
const fr = s as LuaForStatement;
const childActive = cloneSet(curActive);
childActive.add(vctx.nextLocalId++);
processBlock(
fr.block,
block,
"For",
childActive,
new Set<string>(vis),
blockMeta,
vctx,
seen,
parentByBlock,
roleByBlock,
);
break;
}
case "ForIn": {
const fi = s as LuaForInStatement;
const childActive = cloneSet(curActive);
for (let j = 0; j < fi.names.length; j++) {
childActive.add(vctx.nextLocalId++);
}
processBlock(
fi.block,
block,
"ForIn",
childActive,
new Set<string>(vis),
blockMeta,
vctx,
seen,
parentByBlock,
roleByBlock,
);
break;
}
case "While": {
const w = s as LuaWhileStatement;
processBlock(
w.block,
block,
"While",
cloneSet(curActive),
new Set<string>(vis),
blockMeta,
vctx,
seen,
parentByBlock,
roleByBlock,
);
break;
}
case "Repeat": {
const r = s as LuaRepeatStatement;
processBlock(
r.block,
block,
"Repeat",
cloneSet(curActive),
new Set<string>(vis),
blockMeta,
vctx,
seen,
parentByBlock,
roleByBlock,
);
break;
}
case "If": {
const iff = s as LuaIfStatement;
for (let k = 0; k < iff.conditions.length; k++) {
processBlock(
iff.conditions[k].block,
block,
"If",
cloneSet(curActive),
new Set<string>(vis),
blockMeta,
vctx,
seen,
parentByBlock,
roleByBlock,
);
}
if (iff.elseBlock) {
processBlock(
iff.elseBlock,
block,
"If",
cloneSet(curActive),
new Set<string>(vis),
blockMeta,
vctx,
seen,
parentByBlock,
roleByBlock,
);
}
break;
}
case "Block": {
const child = asBlock(s);
processBlock(
child,
block,
"Do",
cloneSet(curActive),
new Set<string>(vis),
blockMeta,
vctx,
seen,
parentByBlock,
roleByBlock,
);
break;
}
default: {
break;
}
}
}
}