diff --git a/common/space_lua.ts b/common/space_lua.ts index 074c02be..9abd0797 100644 --- a/common/space_lua.ts +++ b/common/space_lua.ts @@ -62,14 +62,16 @@ export class SpaceLuaEnvironment { for (const globalName of this.env.keys()) { const value = this.env.get(globalName); if (value instanceof LuaFunction) { - console.log("Now registering Lua function", globalName); + console.log( + `[Lua] Registering global function '${globalName}' (source: ${value.body.ctx.ref})`, + ); scriptEnv.registerFunction({ name: globalName }, (...args: any[]) => { const sf = new LuaStackFrame(new LuaEnv(), value.body.ctx); return luaValueToJS(value.call(sf, ...args.map(jsToLuaValue))); }); } } - console.log("Loaded", allScripts.length, "Lua scripts"); + console.log("[Lua] Loaded", allScripts.length, "scripts"); } } diff --git a/common/space_lua/eval.test.ts b/common/space_lua/eval.test.ts index 38a55510..e17a1da0 100644 --- a/common/space_lua/eval.test.ts +++ b/common/space_lua/eval.test.ts @@ -3,6 +3,7 @@ import { LuaEnv, LuaNativeJSFunction, LuaStackFrame, + LuaTable, luaValueToJS, singleResult, } from "./runtime.ts"; @@ -11,9 +12,9 @@ import type { LuaBlock, LuaFunctionCallStatement } from "./ast.ts"; import { evalExpression, evalStatement } from "./eval.ts"; import { luaBuildStandardEnv } from "$common/space_lua/stdlib.ts"; -function evalExpr(s: string, e = new LuaEnv()): any { +function evalExpr(s: string, e = new LuaEnv(), sf?: LuaStackFrame): any { const node = parse(`e(${s})`).statements[0] as LuaFunctionCallStatement; - const sf = new LuaStackFrame(e, node.ctx); + sf = sf || new LuaStackFrame(e, node.ctx); return evalExpression( node.call.args[0], e, @@ -277,3 +278,149 @@ Deno.test("Statement evaluation", async () => { luaBuildStandardEnv(), ); }); + +Deno.test("Thread local _CTX", async () => { + const env = new LuaEnv(); + const threadLocal = new LuaEnv(); + threadLocal.setLocal("threadValue", "test123"); + + const sf = new LuaStackFrame(threadLocal, null); + + await evalBlock( + ` + function test() + return _CTX.threadValue + end + `, + env, + ); + + const result = await evalExpr("test()", env, sf); + assertEquals(singleResult(result), "test123"); +}); + +Deno.test("Thread local _CTX - advanced cases", async () => { + // Create environment with standard library + const env = new LuaEnv(luaBuildStandardEnv()); + const threadLocal = new LuaEnv(); + + // Set up some thread local values + threadLocal.setLocal("user", "alice"); + threadLocal.setLocal("permissions", new LuaTable()); + threadLocal.get("permissions").set("admin", true); + threadLocal.setLocal("data", { + id: 123, + settings: { theme: "dark" }, + }); + + const sf = new LuaStackFrame(threadLocal, null); + + // Test 1: Nested function access + await evalBlock( + ` + function outer() + local function inner() + return _CTX.user + end + return inner() + end + `, + env, + ); + assertEquals(await evalExpr("outer()", env, sf), "alice"); + + // Test 2: Table access and modification + await evalBlock( + ` + function checkAdmin() + return _CTX.permissions.admin + end + + function revokeAdmin() + _CTX.permissions.admin = false + return _CTX.permissions.admin + end + `, + env, + ); + assertEquals(await evalExpr("checkAdmin()", env, sf), true); + assertEquals(await evalExpr("revokeAdmin()", env, sf), false); + assertEquals(threadLocal.get("permissions").get("admin"), false); + + // Test 3: Complex data structures + await evalBlock( + ` + function getNestedData() + return _CTX.data.settings.theme + end + + function updateTheme(newTheme) + _CTX.data.settings.theme = newTheme + return _CTX.data.settings.theme + end + `, + env, + ); + assertEquals(await evalExpr("getNestedData()", env, sf), "dark"); + assertEquals(await evalExpr("updateTheme('light')", env, sf), "light"); + + // Test 4: Multiple thread locals + const threadLocal2 = new LuaEnv(); + threadLocal2.setLocal("user", "bob"); + const sf2 = new LuaStackFrame(threadLocal2, null); + + await evalBlock( + ` + function getUser() + return _CTX.user + end + `, + env, + ); + + // Same function, different thread contexts + assertEquals(await evalExpr("getUser()", env, sf), "alice"); + assertEquals(await evalExpr("getUser()", env, sf2), "bob"); + + // Test 5: Async operations with _CTX + env.set( + "asyncOperation", + new LuaNativeJSFunction(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return "done"; + }), + ); + + await evalBlock( + ` + function asyncTest() + _CTX.status = "starting" + local result = asyncOperation() + _CTX.status = "completed" + return _CTX.status + end + `, + env, + ); + + assertEquals(await evalExpr("asyncTest()", env, sf), "completed"); + assertEquals(threadLocal.get("status"), "completed"); + + // Test 6: Error handling with _CTX + await evalBlock( + ` + function errorTest() + _CTX.error = nil + local status, err = pcall(function() + error("test error") + end) + _CTX.error = "caught" + return _CTX.error + end + `, + env, + ); + + assertEquals(await evalExpr("errorTest()", env, sf), "caught"); + assertEquals(threadLocal.get("error"), "caught"); +}); diff --git a/common/space_lua/eval.ts b/common/space_lua/eval.ts index 5d3f1791..11a3363f 100644 --- a/common/space_lua/eval.ts +++ b/common/space_lua/eval.ts @@ -334,11 +334,11 @@ function evalPrefixExpression( if (prefixValue instanceof Promise) { return prefixValue.then(async (resolvedPrefix) => { const args = await resolveVarargs(); - return luaCall(resolvedPrefix, args, e.ctx, sf); + return luaCall(resolvedPrefix, args, e.ctx, sf.withCtx(e.ctx)); }); } else { return resolveVarargs().then((args) => - luaCall(prefixValue, args, e.ctx, sf) + luaCall(prefixValue, args, e.ctx, sf.withCtx(e.ctx)) ); } } @@ -409,7 +409,7 @@ const operatorsMetaMethods: Record a / b }, "//": { metaMethod: "__idiv", - nativeImplementation: (a, b, ctx, sf) => Math.floor(a / b), + nativeImplementation: (a, b) => Math.floor(a / b), }, "%": { metaMethod: "__mod", nativeImplementation: (a, b) => a % b }, "^": { metaMethod: "__pow", nativeImplementation: (a, b) => a ** b }, diff --git a/common/space_lua/runtime.ts b/common/space_lua/runtime.ts index fabc0b92..33f174af 100644 --- a/common/space_lua/runtime.ts +++ b/common/space_lua/runtime.ts @@ -150,6 +150,7 @@ export class LuaFunction implements ILuaFunction { if (!sf) { console.trace(sf); } + // Set _CTX to the thread local environment from the stack frame env.setLocal("_CTX", sf.threadLocal); // Assign the passed arguments to the parameters @@ -271,6 +272,7 @@ export class LuaBuiltinFunction implements ILuaFunction { } call(sf: LuaStackFrame, ...args: LuaValue[]): Promise | LuaValue { + // _CTX is already available via the stack frame return this.fn(sf, ...args); } @@ -608,21 +610,35 @@ export class LuaRuntimeError extends Error { // Find the line and column let line = 1; let column = 0; + let lastNewline = -1; for (let i = 0; i < ctx.from; i++) { if (code[i] === "\n") { line++; + lastNewline = i; column = 0; } else { column++; } } - traceStr += `* ${ - ctx.ref || "(unknown source)" - } @ ${line}:${column}:\n ${code.substring(ctx.from, ctx.to)}\n`; + + // Get the full line of code for context + const lineStart = lastNewline + 1; + const lineEnd = code.indexOf("\n", ctx.from); + const codeLine = code.substring( + lineStart, + lineEnd === -1 ? undefined : lineEnd, + ); + + // Add position indicator + const pointer = " ".repeat(column) + "^"; + + traceStr += `* ${ctx.ref || "(unknown source)"} @ ${line}:${column}:\n` + + ` ${codeLine}\n` + + ` ${pointer}\n`; current = current.parent; } - return `LuaRuntimeError: ${this.message} ${traceStr}`; + return `LuaRuntimeError: ${this.message}\nStack trace:\n${traceStr}`; } override toString() { diff --git a/common/space_lua/stdlib.ts b/common/space_lua/stdlib.ts index f039ee9e..29a54d5b 100644 --- a/common/space_lua/stdlib.ts +++ b/common/space_lua/stdlib.ts @@ -72,8 +72,8 @@ const tonumberFunction = new LuaBuiltinFunction((_sf, value: LuaValue) => { return Number(value); }); -const errorFunction = new LuaBuiltinFunction((_sf, message: string) => { - throw new Error(message); +const errorFunction = new LuaBuiltinFunction((sf, message: string) => { + throw new LuaRuntimeError(message, sf); }); const pcallFunction = new LuaBuiltinFunction( @@ -81,6 +81,9 @@ const pcallFunction = new LuaBuiltinFunction( try { return new LuaMultiRes([true, await luaCall(fn, args, sf.astCtx!, sf)]); } catch (e: any) { + if (e instanceof LuaRuntimeError) { + return new LuaMultiRes([false, e.message]); + } return new LuaMultiRes([false, e.message]); } }, @@ -91,9 +94,10 @@ const xpcallFunction = new LuaBuiltinFunction( try { return new LuaMultiRes([true, await fn.call(sf, ...args)]); } catch (e: any) { + const errorMsg = e instanceof LuaRuntimeError ? e.message : e.message; return new LuaMultiRes([ false, - await luaCall(errorHandler, [e.message], sf.astCtx!, sf), + await luaCall(errorHandler, [errorMsg], sf.astCtx!, sf), ]); } }, diff --git a/common/space_lua/stdlib/js.ts b/common/space_lua/stdlib/js.ts index 7093b77f..b40173c3 100644 --- a/common/space_lua/stdlib/js.ts +++ b/common/space_lua/stdlib/js.ts @@ -22,6 +22,7 @@ export const jsApi = new LuaTable({ log: new LuaBuiltinFunction((_sf, ...args) => { console.log(...args); }), + stringify: new LuaBuiltinFunction((_sf, val) => JSON.stringify(val)), // assignGlobal: new LuaBuiltinFunction((name: string, value: any) => { // (globalThis as any)[name] = value; // }), diff --git a/common/space_lua_api.ts b/common/space_lua_api.ts index 4c3ee826..ae949cf0 100644 --- a/common/space_lua_api.ts +++ b/common/space_lua_api.ts @@ -56,7 +56,12 @@ function exposeDefinitions( if (!def.get("name")) { throw new Error("Name is required"); } - console.log("Registering Lua command", def.get("name")); + const fn = def.get(1); + console.log( + `[Lua] Registering command '${ + def.get("name") + }' (source: ${fn.body.ctx.ref})`, + ); scriptEnv.registerCommand( { name: def.get("name"), @@ -67,10 +72,10 @@ function exposeDefinitions( hide: def.get("hide"), } as CommandDef, async (...args: any[]) => { - const tl = new LuaEnv(); + const tl = await buildThreadLocalEnv(system); const sf = new LuaStackFrame(tl, null); try { - return await def.get(1).call(sf, ...args.map(jsToLuaValue)); + return await fn.call(sf, ...args.map(jsToLuaValue)); } catch (e: any) { await handleLuaError(e, system); } @@ -88,13 +93,19 @@ function exposeDefinitions( if (!def.get("event")) { throw new Error("Event is required"); } - console.log("Subscribing to Lua event", def.get("event")); + const fn = def.get(1); + console.log( + `[Lua] Subscribing to event '${ + def.get("event") + }' (source: ${fn.body.ctx.ref})`, + ); scriptEnv.registerEventListener( { name: def.get("event") }, async (...args: any[]) => { - const sf = new LuaStackFrame(new LuaEnv(), null); + const tl = await buildThreadLocalEnv(system); + const sf = new LuaStackFrame(tl, null); try { - return await def.get(1).call(sf, ...args.map(jsToLuaValue)); + return await fn.call(sf, ...args.map(jsToLuaValue)); } catch (e: any) { await handleLuaError(e, system); } @@ -104,6 +115,16 @@ function exposeDefinitions( ); } +async function buildThreadLocalEnv(system: System) { + const tl = new LuaEnv(); + const currentPageMeta = await system.localSyscall( + "editor.getCurrentPageMeta", + [], + ); + tl.setLocal("pageMeta", currentPageMeta); + return tl; +} + async function handleLuaError(e: any, system: System) { console.error( "Lua eval exception", diff --git a/common/template/render.ts b/common/template/render.ts index 79855c75..57457324 100644 --- a/common/template/render.ts +++ b/common/template/render.ts @@ -79,7 +79,10 @@ async function renderExpressionDirective( variables, functionMap, ); + return renderExpressionResult(result); +} +export function renderExpressionResult(result: any): string { if ( Array.isArray(result) && result.length > 0 && typeof result[0] === "object" ) { diff --git a/plug-api/syscalls/editor.ts b/plug-api/syscalls/editor.ts index 348db1c9..57ead963 100644 --- a/plug-api/syscalls/editor.ts +++ b/plug-api/syscalls/editor.ts @@ -1,4 +1,4 @@ -import type { UploadFile } from "../types.ts"; +import type { PageMeta, UploadFile } from "../types.ts"; import { syscall } from "../syscall.ts"; import type { PageRef } from "../lib/page_ref.ts"; import type { FilterOption } from "@silverbulletmd/silverbullet/type/client"; @@ -17,6 +17,14 @@ export function getCurrentPage(): Promise { return syscall("editor.getCurrentPage"); } +/** + * Returns the meta data of the page currently open in the editor. + * @returns the current page meta data + */ +export function getCurrentPageMeta(): Promise { + return syscall("editor.getCurrentPageMeta"); +} + /** * Returns the full text of the currently open page */ diff --git a/web/cm_plugins/lua_directive.ts b/web/cm_plugins/lua_directive.ts index 827f9654..bd52c3ee 100644 --- a/web/cm_plugins/lua_directive.ts +++ b/web/cm_plugins/lua_directive.ts @@ -43,13 +43,13 @@ export function luaDirectivePlugin(client: Client) { } const text = state.sliceDoc(node.from + 2, node.to - 1); - + const currentPageMeta = client.ui.viewState.currentPageMeta; widgets.push( Decoration.widget({ widget: new LuaWidget( node.from, client, - `lua:${text}`, + `lua:${text}:${currentPageMeta?.name}`, text, async (bodyText) => { try { @@ -58,14 +58,22 @@ export function luaDirectivePlugin(client: Client) { (parsedLua.statements[0] as LuaFunctionCallStatement).call .args[0]; - const sf = new LuaStackFrame(new LuaEnv(), expr.ctx); - return luaValueToJS( + const tl = new LuaEnv(); + tl.setLocal("pageMeta", currentPageMeta); + const sf = new LuaStackFrame(tl, expr.ctx); + const threadLocalizedEnv = new LuaEnv( + client.clientSystem.spaceLuaEnv.env, + ); + threadLocalizedEnv.setLocal("_CTX", tl); + const result = luaValueToJS( await evalExpression( expr, - client.clientSystem.spaceLuaEnv.env, + threadLocalizedEnv, sf, ), ); + // console.log("Result:", result); + return result; } catch (e: any) { if (e instanceof LuaRuntimeError) { if (e.sf.astCtx) { diff --git a/web/cm_plugins/lua_widget.ts b/web/cm_plugins/lua_widget.ts index ae7aa138..4b81e275 100644 --- a/web/cm_plugins/lua_widget.ts +++ b/web/cm_plugins/lua_widget.ts @@ -11,6 +11,7 @@ import { extendedMarkdownLanguage } from "$common/markdown_parser/parser.ts"; import { renderToText } from "@silverbulletmd/silverbullet/lib/tree"; import { activeWidgets } from "./markdown_widget.ts"; import { attachWidgetEventHandlers } from "./widget_util.ts"; +import { renderExpressionResult } from "$common/template/render.ts"; export type LuaWidgetCallback = ( bodyText: string, @@ -97,10 +98,15 @@ export class LuaWidget extends WidgetType { this.cacheKey, { height: div.clientHeight, html }, ); - } else if (widgetContent.markdown) { + } else { + // If there is a markdown key, use it, otherwise render the objects as a markdown table + let mdContent = widgetContent.markdown; + if (!mdContent) { + mdContent = renderExpressionResult(widgetContent); + } let mdTree = parse( extendedMarkdownLanguage, - widgetContent.markdown!, + mdContent, ); mdTree = await this.client.clientSystem.localSyscall( "system.invokeFunction", @@ -187,8 +193,8 @@ export class LuaWidget extends WidgetType { override eq(other: WidgetType): boolean { return ( other instanceof LuaWidget && - other.bodyText === this.bodyText && other.cacheKey === this.cacheKey && - this.from === other.from + other.bodyText === this.bodyText && other.cacheKey === this.cacheKey + // && this.from === other.from ); } } diff --git a/web/syscalls/editor.ts b/web/syscalls/editor.ts index 8c5a0b67..818d6f8d 100644 --- a/web/syscalls/editor.ts +++ b/web/syscalls/editor.ts @@ -12,7 +12,7 @@ import { EditorView } from "@codemirror/view"; import { getCM as vimGetCm, Vim } from "@replit/codemirror-vim"; import type { SysCallMapping } from "$lib/plugos/system.ts"; import type { FilterOption } from "@silverbulletmd/silverbullet/type/client"; -import type { UploadFile } from "../../plug-api/types.ts"; +import type { PageMeta, UploadFile } from "../../plug-api/types.ts"; import type { PageRef } from "@silverbulletmd/silverbullet/lib/page_ref"; import { openSearchPanel } from "@codemirror/search"; import { diffAndPrepareChanges } from "../cm_util.ts"; @@ -22,6 +22,9 @@ export function editorSyscalls(client: Client): SysCallMapping { "editor.getCurrentPage": (): string => { return client.currentPage; }, + "editor.getCurrentPageMeta": (): PageMeta | undefined => { + return client.ui.viewState.currentPageMeta; + }, "editor.getText": () => { return client.editorView.state.sliceDoc(); }, diff --git a/website/Space Lua.md b/website/Space Lua.md index bc6443b1..62ba9c0c 100644 --- a/website/Space Lua.md +++ b/website/Space Lua.md @@ -81,7 +81,9 @@ You can listen to events using `define_event_listener`: define_event_listener { event = "my-custom-event"; function(e) - editor.flash_notification("Custom triggered: " .. e.data.name) + editor.flash_notification("Custom triggered: " + .. e.data.name + .. " on page " .. _CTX.pageMeta.name) end } ``` @@ -106,6 +108,11 @@ Template: Here's a greeting: {{greet_me("Pete")}} ``` +# Thread locals +There’s a magic `_CTX` global variable available from which you can access useful context-specific value. Currently the following keys are available: + +* `_CTX.pageMeta` contains a reference to the loaded page metadata (can be `nil` when not yet loaded) + # API Lua APIs, which should be (roughly) implemented according to the Lua standard. * `print`