diff --git a/common/space_lua/eval.test.ts b/common/space_lua/eval.test.ts index b82eb725..3686c854 100644 --- a/common/space_lua/eval.test.ts +++ b/common/space_lua/eval.test.ts @@ -1,8 +1,8 @@ import { assertEquals } from "@std/assert/equals"; import { LuaEnv, LuaNativeJSFunction, singleResult } from "./runtime.ts"; import { parse } from "./parse.ts"; -import type { LuaFunctionCallStatement } from "./ast.ts"; -import { evalExpression } from "./eval.ts"; +import type { LuaBlock, LuaFunctionCallStatement } from "./ast.ts"; +import { evalExpression, evalStatement } from "./eval.ts"; function evalExpr(s: string, e = new LuaEnv()): any { return evalExpression( @@ -12,6 +12,10 @@ function evalExpr(s: string, e = new LuaEnv()): any { ); } +function evalBlock(s: string, e = new LuaEnv()): Promise { + return evalStatement(parse(s) as LuaBlock, e); +} + Deno.test("Evaluator test", async () => { const env = new LuaEnv(); env.set("test", new LuaNativeJSFunction((n) => n)); @@ -22,11 +26,19 @@ Deno.test("Evaluator test", async () => { assertEquals(evalExpr(`4 // 3`), 1); assertEquals(evalExpr(`4 % 3`), 1); + // Strings + assertEquals(evalExpr(`"a" .. "b"`), "ab"); + + // Logic + assertEquals(evalExpr(`true and false`), false); + assertEquals(evalExpr(`true or false`), true); + assertEquals(evalExpr(`not true`), false); + // Tables const tbl = evalExpr(`{3, 1, 2}`); - assertEquals(tbl.entries.get(1), 3); - assertEquals(tbl.entries.get(2), 1); - assertEquals(tbl.entries.get(3), 2); + assertEquals(tbl.get(1), 3); + assertEquals(tbl.get(2), 1); + assertEquals(tbl.get(3), 2); assertEquals(tbl.toArray(), [3, 1, 2]); assertEquals(evalExpr(`{name=test("Zef"), age=100}`, env).toObject(), { @@ -51,9 +63,126 @@ Deno.test("Evaluator test", async () => { assertEquals(evalExpr(`#{1, 2, 3}`), 3); // Unary operators - assertEquals(await evalExpr(`-asyncTest(3)`, env), -3); + // Function calls assertEquals(singleResult(evalExpr(`test(3)`, env)), 3); assertEquals(singleResult(await evalExpr(`asyncTest(3) + 1`, env)), 4); }); + +Deno.test("Statement evaluation", async () => { + const env = new LuaEnv(); + env.set("test", new LuaNativeJSFunction((n) => n)); + env.set("asyncTest", new LuaNativeJSFunction((n) => Promise.resolve(n))); + + assertEquals(undefined, await evalBlock(`a = 3`, env)); + assertEquals(env.get("a"), 3); + assertEquals(undefined, await evalBlock(`b = test(3)`, env)); + assertEquals(env.get("b"), 3); + + await evalBlock(`c = asyncTest(3)`, env); + assertEquals(env.get("c"), 3); + + // Multiple assignments + const env2 = new LuaEnv(); + assertEquals(undefined, await evalBlock(`a, b = 1, 2`, env2)); + assertEquals(env2.get("a"), 1); + assertEquals(env2.get("b"), 2); + + // Other lvalues + const env3 = new LuaEnv(); + await evalBlock(`tbl = {1, 2, 3}`, env3); + await evalBlock(`tbl[1] = 3`, env3); + assertEquals(env3.get("tbl").toArray(), [3, 2, 3]); + await evalBlock("tbl.name = 'Zef'", env3); + assertEquals(env3.get("tbl").get("name"), "Zef"); + await evalBlock(`tbl[2] = {age=10}`, env3); + await evalBlock(`tbl[2].age = 20`, env3); + assertEquals(env3.get("tbl").get(2).get("age"), 20); + + // Blocks and scopes + const env4 = new LuaEnv(); + env4.set("print", new LuaNativeJSFunction(console.log)); + await evalBlock( + ` + a = 1 + do + -- sets global a to 3 + a = 3 + print("The number is: " .. a) + end`, + env4, + ); + assertEquals(env4.get("a"), 3); + + const env5 = new LuaEnv(); + env5.set("print", new LuaNativeJSFunction(console.log)); + + await evalBlock( + ` + a = 1 + if a > 0 then + a = 3 + else + a = 0 + end`, + env5, + ); + assertEquals(env5.get("a"), 3); + + await evalBlock( + ` + if a < 0 then + a = -1 + elseif a > 0 then + a = 1 + else + a = 0 + end`, + env5, + ); + assertEquals(env5.get("a"), 1); + + await evalBlock( + ` + var = 1 + do + local var + var = 2 + end`, + env5, + ); + assertEquals(env5.get("var"), 1); + + // While loop + const env6 = new LuaEnv(); + await evalBlock( + ` + c = 0 + while true do + c = c + 1 + if c == 3 then + break + end + end + `, + env6, + ); + assertEquals(env6.get("c"), 3); + + // Repeat loop + const env7 = new LuaEnv(); + await evalBlock( + ` + c = 0 + repeat + c = c + 1 + if c == 3 then + break + end + until false + `, + env7, + ); + assertEquals(env7.get("c"), 3); +}); diff --git a/common/space_lua/eval.ts b/common/space_lua/eval.ts index f392e2be..773d4784 100644 --- a/common/space_lua/eval.ts +++ b/common/space_lua/eval.ts @@ -1,18 +1,26 @@ -import type { LuaExpression } from "$common/space_lua/ast.ts"; +import type { + LuaExpression, + LuaLValue, + LuaStatement, +} from "$common/space_lua/ast.ts"; import { evalPromiseValues } from "$common/space_lua/util.ts"; import { type ILuaFunction, - type LuaEnv, + LuaBreak, + LuaEnv, luaGet, luaLen, + type LuaLValueContainer, LuaTable, + luaTruthy, + type LuaValue, singleResult, } from "./runtime.ts"; export function evalExpression( e: LuaExpression, env: LuaEnv, -): Promise | any { +): Promise | LuaValue { switch (e.type) { case "String": // TODO: Deal with escape sequences @@ -101,13 +109,13 @@ export function evalExpression( const value = evalExpression(field.value, env); if (value instanceof Promise) { promises.push(value.then((value) => { - table.entries.set( + table.set( field.key, singleResult(value), ); })); } else { - table.entries.set(field.key, singleResult(value)); + table.set(field.key, singleResult(value)); } break; } @@ -126,14 +134,14 @@ export function evalExpression( ? value : Promise.resolve(value), ]).then(([key, value]) => { - table.entries.set( + table.set( singleResult(key), singleResult(value), ); }), ); } else { - table.entries.set( + table.set( singleResult(key), singleResult(value), ); @@ -145,15 +153,15 @@ export function evalExpression( if (value instanceof Promise) { promises.push(value.then((value) => { // +1 because Lua tables are 1-indexed - table.entries.set( - table.entries.size + 1, + table.set( + table.length + 1, singleResult(value), ); })); } else { // +1 because Lua tables are 1-indexed - table.entries.set( - table.entries.size + 1, + table.set( + table.length + 1, singleResult(value), ); } @@ -175,7 +183,7 @@ export function evalExpression( function evalPrefixExpression( e: LuaExpression, env: LuaEnv, -): Promise | any { +): Promise | LuaValue { switch (e.type) { case "Variable": { const value = env.get(e.name); @@ -262,3 +270,160 @@ function luaOp(op: string, left: any, right: any): any { throw new Error(`Unknown operator ${op}`); } } + +export async function evalStatement( + s: LuaStatement, + env: LuaEnv, +): Promise { + switch (s.type) { + case "Assignment": { + const values = await evalPromiseValues( + s.expressions.map((value) => evalExpression(value, env)), + ); + const lvalues = await evalPromiseValues(s.variables + .map((lval) => evalLValue(lval, env))); + + for (let i = 0; i < lvalues.length; i++) { + lvalues[i].env.set(lvalues[i].key, values[i]); + } + + break; + } + case "Local": { + for (let i = 0; i < s.names.length; i++) { + if (!s.expressions || s.expressions[i] === undefined) { + env.setLocal(s.names[i].name, null); + } else { + const value = await evalExpression(s.expressions[i], env); + env.setLocal(s.names[i].name, value); + } + } + break; + } + case "Semicolon": + break; + case "Label": + case "Goto": + throw new Error("Labels and gotos are not supported yet"); + case "Block": { + const newEnv = new LuaEnv(env); + for (const statement of s.statements) { + await evalStatement(statement, newEnv); + } + break; + } + case "If": { + for (const cond of s.conditions) { + if (luaTruthy(await evalExpression(cond.condition, env))) { + return evalStatement(cond.block, env); + } + } + if (s.elseBlock) { + return evalStatement(s.elseBlock, env); + } + break; + } + case "While": { + while (luaTruthy(await evalExpression(s.condition, env))) { + try { + await evalStatement(s.block, env); + } catch (e: any) { + if (e instanceof LuaBreak) { + break; + } else { + throw e; + } + } + } + break; + } + case "Repeat": { + do { + try { + await evalStatement(s.block, env); + } catch (e: any) { + if (e instanceof LuaBreak) { + break; + } else { + throw e; + } + } + } while (!luaTruthy(await evalExpression(s.condition, env))); + break; + } + case "Break": + throw new LuaBreak(); + case "FunctionCallStatement": { + return evalExpression(s.call, env); + } + default: + throw new Error(`Unknown statement type ${s.type}`); + } +} + +function evalLValue( + lval: LuaLValue, + env: LuaEnv, +): LuaLValueContainer | Promise { + switch (lval.type) { + case "Variable": + return { env, key: lval.name }; + case "TableAccess": { + const objValue = evalExpression( + lval.object, + env, + ); + const keyValue = evalExpression(lval.key, env); + if ( + objValue instanceof Promise || + keyValue instanceof Promise + ) { + return Promise.all([ + objValue instanceof Promise + ? objValue + : Promise.resolve(objValue), + keyValue instanceof Promise + ? keyValue + : Promise.resolve(keyValue), + ]).then(([objValue, keyValue]) => ({ + env: singleResult(objValue), + key: singleResult(keyValue), + })); + } else { + return { + env: singleResult(objValue), + key: singleResult(keyValue), + }; + } + } + case "PropertyAccess": { + const objValue = evalExpression( + lval.object, + env, + ); + if (objValue instanceof Promise) { + return objValue.then((objValue) => { + if (!objValue.set) { + throw new Error( + `Not a settable object: ${objValue}`, + ); + } + return { + env: objValue, + key: lval.property, + }; + }); + } else { + if (!objValue.set) { + throw new Error( + `Not a settable object: ${objValue}`, + ); + } + return { + env: objValue, + key: lval.property, + }; + } + } + } +} diff --git a/common/space_lua/parse.ts b/common/space_lua/parse.ts index 10beebef..1b0892d4 100644 --- a/common/space_lua/parse.ts +++ b/common/space_lua/parse.ts @@ -165,6 +165,8 @@ function parseStatement(n: CrudeAST): LuaStatement { names: parseAttNames(t[2]), expressions: t[4] ? parseExpList(t[4]) : [], }; + case "break": + return { type: "Break" }; default: console.error(t); throw new Error(`Unknown statement type: ${t[0]}`); @@ -359,6 +361,12 @@ function parsePrefixExpression(n: CrudeAST): LuaPrefixExpression { object: parsePrefixExpression(t[1]), property: t[3][1] as string, }; + case "MemberExpression": + return { + type: "TableAccess", + object: parsePrefixExpression(t[1]), + key: parseExpression(t[3]), + }; case "Parens": return { type: "Parenthesized", expression: parseExpression(t[2]) }; default: diff --git a/common/space_lua/runtime.ts b/common/space_lua/runtime.ts index f8702ffe..f0e0d1f3 100644 --- a/common/space_lua/runtime.ts +++ b/common/space_lua/runtime.ts @@ -1,15 +1,24 @@ import type { LuaFunctionBody } from "./ast.ts"; -export class LuaEnv { - variables = new Map(); +export class LuaEnv implements ILuaSettable { + variables = new Map(); + constructor(readonly parent?: LuaEnv) { } - set(name: string, value: any) { + setLocal(name: string, value: LuaValue) { this.variables.set(name, value); } - get(name: string): any { + set(key: string, value: LuaValue): void { + if (this.variables.has(key) || !this.parent) { + this.variables.set(key, value); + } else { + this.parent.set(key, value); + } + } + + get(name: string): LuaValue { if (this.variables.has(name)) { return this.variables.get(name); } @@ -40,71 +49,130 @@ export function singleResult(value: any): any { } } +// These types are for documentation only +export type LuaValue = any; +export type JSValue = any; + export interface ILuaFunction { - call(...args: any[]): Promise | LuaMultiRes; + call(...args: LuaValue[]): Promise | LuaValue; +} + +export interface ILuaSettable { + set(key: LuaValue, value: LuaValue): void; } export class LuaFunction implements ILuaFunction { constructor(private body: LuaFunctionBody, private closure: LuaEnv) { } - call(..._args: any[]): Promise | LuaMultiRes { + call(...args: LuaValue[]): Promise | LuaValue { + // Create a new environment for this function call + const env = new LuaEnv(this.closure); + // Assign the passed arguments to the parameters + for (let i = 0; i < this.body.parameters.length; i++) { + let arg = args[i]; + if (arg === undefined) { + arg = null; + } + env.set(this.body.parameters[i], arg); + } throw new Error("Not yet implemented funciton call"); } } export class LuaNativeJSFunction implements ILuaFunction { - constructor(readonly fn: (...args: any[]) => any) { + constructor(readonly fn: (...args: JSValue[]) => JSValue) { } - call(...args: any[]): Promise | LuaMultiRes { - const result = this.fn(...args); + call(...args: LuaValue[]): Promise | LuaValue { + const result = this.fn(...args.map(luaValueToJS)); if (result instanceof Promise) { - return result.then((result) => new LuaMultiRes([result])); + return result.then(jsToLuaValue); } else { - return new LuaMultiRes([result]); + return jsToLuaValue(result); } } } -export class LuaTable { - constructor(readonly entries: Map = new Map()) { +export class LuaTable implements ILuaSettable { + // To optimize the table implementation we use a combination of different data structures + // When tables are used as maps, the common case is that they are string keys, so we use a simple object for that + private stringKeys: Record; + // Other keys we can support using a Map as a fallback + private otherKeys: Map | null; + // When tables are used as arrays, we use a native JavaScript array for that + private arrayPart: any[]; + + // TODO: Actually implement metatables + private metatable: LuaTable | null; + + constructor() { + // For efficiency and performance reasons we pre-allocate these (modern JS engines are very good at optimizing this) + this.stringKeys = {}; + this.arrayPart = []; + this.otherKeys = null; // Only create this when needed + this.metatable = null; } - get(key: any): any { - return this.entries.get(key); + get length(): number { + return this.arrayPart.length; } - set(key: any, value: any) { - this.entries.set(key, value); + set(key: LuaValue, value: LuaValue) { + if (typeof key === "string") { + this.stringKeys[key] = value; + } else if (Number.isInteger(key) && key >= 1) { + this.arrayPart[key - 1] = value; + } else { + if (!this.otherKeys) { + this.otherKeys = new Map(); + } + this.otherKeys.set(key, value); + } } - /** - * Convert the table to a a JavaScript array, assuming it uses integer keys - * @returns - */ - toArray(): any[] { - const result = []; - const keys = Array.from(this.entries.keys()).sort(); - for (const key of keys) { - result.push(this.entries.get(key)); + get(key: LuaValue): LuaValue { + if (typeof key === "string") { + return this.stringKeys[key]; + } else if (Number.isInteger(key) && key >= 1) { + return this.arrayPart[key - 1]; + } else if (this.otherKeys) { + return this.otherKeys.get(key); + } + return undefined; + } + + toArray(): JSValue[] { + return this.arrayPart; + } + + toObject(): Record { + const result = { ...this.stringKeys }; + for (const i in this.arrayPart) { + result[parseInt(i) + 1] = this.arrayPart[i]; } return result; } - /** - * Convert the table to a JavaScript object, assuming it uses string keys - * @returns - */ - toObject(): Record { - const result: Record = {}; - for (const [key, value] of this.entries.entries()) { - result[key] = value; + static fromArray(arr: JSValue[]): LuaTable { + const table = new LuaTable(); + for (let i = 0; i < arr.length; i++) { + table.set(i + 1, arr[i]); } - return result; + return table; + } + + static fromObject(obj: Record): LuaTable { + const table = new LuaTable(); + for (const key in obj) { + table.set(key, obj[key]); + } + return table; } } +export type LuaLValueContainer = { env: ILuaSettable; key: LuaValue }; + export function luaSet(obj: any, key: any, value: any) { if (obj instanceof LuaTable) { obj.set(key, value); @@ -130,3 +198,41 @@ export function luaLen(obj: any): number { return 0; } } + +export class LuaBreak extends Error { +} + +export function luaTruthy(value: any): boolean { + if (value === undefined || value === null || value === false) { + return false; + } + if (value instanceof LuaTable) { + return value.length > 0; + } + return true; +} + +export function jsToLuaValue(value: any): any { + if (value instanceof LuaTable) { + return value; + } else if (Array.isArray(value)) { + return LuaTable.fromArray(value.map(jsToLuaValue)); + } else if (typeof value === "object") { + return LuaTable.fromObject(value); + } else { + return value; + } +} + +export function luaValueToJS(value: any): any { + if (value instanceof LuaTable) { + // This is a heuristic: if this table is used as an array, we return an array + if (value.length > 0) { + return value.toArray(); + } else { + return value.toObject(); + } + } else { + return value; + } +} diff --git a/plug-api/lib/tree.ts b/plug-api/lib/tree.ts index 5228d177..d94802f7 100644 --- a/plug-api/lib/tree.ts +++ b/plug-api/lib/tree.ts @@ -226,7 +226,7 @@ export function parseTreeToAST(tree: ParseTree, omitTrimmable = true): AST { } const ast: AST = [tree.type!]; for (const node of tree.children!) { - if (node.type && !node.type.endsWith("Mark")) { + if (node.type && !node.type.endsWith("Mark") && node.type !== "Comment") { ast.push(parseTreeToAST(node, omitTrimmable)); } if (node.text && (omitTrimmable && node.text.trim() || !omitTrimmable)) {