Space Lua: Add <const> attribute (#1715)

* Space Lua: Implement <const> attribute

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

* Space Lua: Implement <const> attribute

- Add Lua attributes with parse time validation
- Add `<const>` attribute semantic tests

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

---------

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>
This commit is contained in:
Matouš Jan Fialka
2025-12-03 15:44:15 +01:00
committed by GitHub
parent ff02481ede
commit 5b03ed872c
5 changed files with 352 additions and 6 deletions
+5
View File
@@ -139,10 +139,15 @@ export type LuaLocalStatement = {
expressions?: LuaExpression[];
} & ASTContext;
export enum LuaAttribute {
Const = "const",
}
export type LuaAttName = {
type: "AttName";
name: string;
attribute?: string;
attributes?: LuaAttribute[];
} & ASTContext;
export type LuaFunctionCallStatement = {
+301
View File
@@ -0,0 +1,301 @@
import {
assertEquals,
assertInstanceOf,
assertStringIncludes,
} from "@std/assert";
import { parse } from "./parse.ts";
import { evalStatement } from "./eval.ts";
import { LuaEnv, LuaRuntimeError, LuaStackFrame } from "./runtime.ts";
import { luaBuildStandardEnv } from "./stdlib.ts";
async function evalBlock(code: string, env?: LuaEnv): Promise<LuaEnv> {
const ast = parse(code);
const G = luaBuildStandardEnv();
const base = env ?? new LuaEnv(G);
const sf = LuaStackFrame.createWithGlobalEnv(G, ast.ctx);
const r = evalStatement(ast, base, sf, false);
if (r instanceof Promise) {
await r;
}
return base;
}
async function runAndCatch(code: string, ref = "const_attribute.lua") {
const ast = parse(code, { ref });
const G = luaBuildStandardEnv();
const env = new LuaEnv(G);
const sf = LuaStackFrame.createWithGlobalEnv(G, ast.ctx);
try {
const r = evalStatement(ast, env, sf, false);
if (r instanceof Promise) {
await r;
}
throw new Error("Expected LuaRuntimeError but evaluation succeeded");
} catch (e: unknown) {
return { e, code, ref };
}
}
Deno.test("const: Unknown attribute (parse-time)", () => {
let threw = false;
try {
parse(`local x<close> = 1`, { ref: "unknown_attribute.lua" });
} catch (e: any) {
threw = true;
assertStringIncludes(String(e?.message ?? e), "unknown attribute 'close'");
}
if (!threw) {
throw new Error("Expected parse error for unknown attribute");
}
});
Deno.test("const: Case-sensitive attribute (parse-time)", () => {
let threw = false;
try {
parse(`local x<Const> = 1`, { ref: "unknown_attribute.lua" });
} catch (e: any) {
threw = true;
assertStringIncludes(String(e?.message ?? e), "unknown attribute 'Const'");
}
if (!threw) {
throw new Error("Expected parse error for unknown attribute");
}
});
Deno.test("const: Requires initializer", async () => {
const { e } = await runAndCatch(`
local x <const>
`);
assertInstanceOf(e, LuaRuntimeError);
assertStringIncludes((e as LuaRuntimeError).message, "must be initialized");
});
Deno.test("const: Initialization ok, reassignment error", async () => {
const env = await evalBlock(`
local x<const> = 1
y = x
`);
assertEquals(env.get("y"), 1);
const { e } = await runAndCatch(`
local x<const> = 1
x = 2
`);
assertInstanceOf(e, LuaRuntimeError);
assertStringIncludes(
(e as LuaRuntimeError).message,
"attempt to assign to const variable 'x'",
);
});
Deno.test("const: Initialization to nil ok, reassignment error", async () => {
const { e } = await runAndCatch(`
local x <const> = nil
x = 2
`);
assertInstanceOf(e, LuaRuntimeError);
assertStringIncludes(
(e as LuaRuntimeError).message,
"attempt to assign to const variable 'x'",
);
});
Deno.test("const: Multi-declaration and missing RHS", async () => {
{
const env = await evalBlock(`
local a <const>, b = 1
y1 = a
y2 = b
`);
assertEquals(env.get("y1"), 1);
assertEquals(env.get("y2"), null);
}
{
const { e } = await runAndCatch(`
local a<const>, b <const>; -- missing assignment
`);
assertInstanceOf(e, LuaRuntimeError);
assertStringIncludes((e as LuaRuntimeError).message, "must be initialized");
}
});
Deno.test("const: Multi-assignment after declaration fails", async () => {
const { e } = await runAndCatch(`
local a<const>, b = 1, 2
a, b = 3, 4
`);
assertInstanceOf(e, LuaRuntimeError);
assertStringIncludes(
(e as LuaRuntimeError).message,
"attempt to assign to const variable 'a'",
);
});
Deno.test("const: Second position", async () => {
const { e } = await runAndCatch(`
local a, b<const> = 1, 2
b = 3
`);
assertInstanceOf(e, LuaRuntimeError);
assertStringIncludes(
(e as LuaRuntimeError).message,
"attempt to assign to const variable 'b'",
);
});
Deno.test("const: Initialization from function returns", async () => {
const env = await evalBlock(`
local function f()
return 7
end
local a <const>, b <const> = f()
y1, y2 = a, b
`);
assertEquals(env.get("y1"), 7);
assertEquals(env.get("y2"), null);
const { e } = await runAndCatch(`
local function f()
return 7
end
local a<const>, b<const> = f()
a = 2
`);
assertInstanceOf(e, LuaRuntimeError);
assertStringIncludes(
(e as LuaRuntimeError).message,
"attempt to assign to const variable 'a'",
);
});
Deno.test("const: Shadowing", async () => {
{
const { e } = await runAndCatch(`
local a<const> = 1
do
local a<const> = 2
a = 3
end
`);
assertInstanceOf(e, LuaRuntimeError);
assertStringIncludes(
(e as LuaRuntimeError).message,
"attempt to assign to const variable 'a'",
);
}
{
const env = await evalBlock(`
local a<const> = 1
do
local a = 2
a = 3
end
y = a
`);
assertEquals(env.get("y"), 1);
}
});
Deno.test("const: Globals and fields not affected", async () => {
const env = await evalBlock(`
local a<const> = 1
G = a
local T = {}
T.x = a
y1, y2 = G, T.x
`);
assertEquals(env.get("y1"), 1);
assertEquals(env.get("y2"), 1);
});
Deno.test("const: Table binding vs table contents", async () => {
const env = await evalBlock(`
local t<const> = { a = 1, 2, 3 }
t.a = 10 -- mutate property
t[2] = 20 -- mutate array part
y1, y2, y3 = t.a, t[2], #t
`);
assertEquals(env.get("y1"), 10);
assertEquals(env.get("y2"), 20);
assertEquals(env.get("y3"), 2);
});
Deno.test("const: Rebinding table fails", async () => {
const { e } = await runAndCatch(
`
local t<const> = {
a = 1
}
t = {
b = 2
}
`,
"const_table_rebind.lua",
);
assertInstanceOf(e, LuaRuntimeError);
assertStringIncludes(
(e as LuaRuntimeError).message,
"attempt to assign to const variable 't'",
);
});
Deno.test("const: Inner block reassignment", async () => {
const { e } = await runAndCatch(`
local a<const> = 1
do
a = 2 -- must fail
end
`);
assertInstanceOf(e, LuaRuntimeError);
assertStringIncludes(
(e as LuaRuntimeError).message,
"attempt to assign to const variable 'a'",
);
});
Deno.test("const: Closure reassignment", async () => {
const { e } = await runAndCatch(`
local a<const> = 1
local function f()
a = 2 -- must error
end
f()
`);
assertInstanceOf(e, LuaRuntimeError);
assertStringIncludes(
(e as LuaRuntimeError).message,
"attempt to assign to const variable 'a'",
);
});
Deno.test("const: Closure mutates table", async () => {
const env = await evalBlock(`
local t<const> = { x = 1 }
local function bump()
t.x = t.x + 1
end
bump()
y = t.x
`);
assertEquals(env.get("y"), 2);
});
+25 -3
View File
@@ -6,6 +6,7 @@ import type {
LuaStatement,
NumericType,
} from "./ast.ts";
import { LuaAttribute } from "./ast.ts";
import { evalPromiseValues } from "./util.ts";
import {
getMetatable,
@@ -1182,11 +1183,32 @@ export function evalStatement(
}
case "Local": {
const l = asLocal(s);
if (l.expressions) {
const valuesRP = evalExpressions(l.expressions, env, sf);
const hasInit = Array.isArray(l.expressions) && l.expressions.length > 0;
for (const att of l.names) {
const isConst = att.attributes?.includes(LuaAttribute.Const) === true;
if (isConst && !hasInit) {
throw new LuaRuntimeError(
`const variable '${att.name}' must be initialized`,
sf.withCtx(att.ctx),
);
}
}
if (hasInit) {
const valuesRP = evalExpressions(l.expressions!, env, sf);
const setAll = (values: LuaValue[]) => {
for (let i = 0; i < l.names.length; i++) {
env.setLocal(l.names[i].name, values[i]);
const name = l.names[i];
const v = values[i];
const isConst =
name.attributes?.includes(LuaAttribute.Const) === true;
if (isConst) {
env.setLocalConst(name.name, v);
} else {
env.setLocal(name.name, v);
}
}
return;
};
+9 -3
View File
@@ -23,6 +23,7 @@ import type {
LuaStatement,
LuaTableField,
} from "./ast.ts";
import { LuaAttribute } from "./ast.ts";
const luaStyleTags = styleTags({
Name: t.variableName,
@@ -362,12 +363,17 @@ function parseAttName(t: ParseTree, ctx: ASTCtx): LuaAttName {
if (t.type !== "AttName") {
throw new Error(`Expected AttName, got ${t.type}`);
}
const attribute = t.children![1].children![1]
? t.children![1].children![1].children![0].text!
: undefined;
if (attribute && attribute !== LuaAttribute.Const) {
throw new Error(`unknown attribute '${attribute}'`);
}
return {
type: "AttName",
name: t.children![0].children![0].text!,
attribute: t.children![1].children![1]
? t.children![1].children![1].children![0].text!
: undefined,
attribute,
attributes: attribute ? [LuaAttribute.Const] : undefined,
ctx: context(t, ctx),
};
}
+12
View File
@@ -59,6 +59,7 @@ const EMPTY_CTX = {} as ASTCtx;
export class LuaEnv implements ILuaSettable, ILuaGettable {
variables = new Map<string, LuaValue>();
private readonly consts = new Set<string>();
constructor(readonly parent?: LuaEnv) {
}
@@ -67,8 +68,19 @@ export class LuaEnv implements ILuaSettable, ILuaGettable {
this.variables.set(name, value);
}
setLocalConst(name: string, value: LuaValue) {
this.variables.set(name, value);
this.consts.add(name);
}
set(key: string, value: LuaValue, sf?: LuaStackFrame): void {
if (this.variables.has(key) || !this.parent) {
if (this.consts.has(key)) {
throw new LuaRuntimeError(
`attempt to assign to const variable '${key}'`,
sf || LuaStackFrame.lostFrame,
);
}
this.variables.set(key, value);
} else {
this.parent.set(key, value, sf);