Files
plainleaf/client/codemirror/lua_directive.ts
T
2026-03-12 13:40:20 +01:00

165 lines
5.5 KiB
TypeScript

import type { EditorState, Range } from "@codemirror/state";
import { syntaxTree } from "@codemirror/language";
import { Decoration } from "@codemirror/view";
import {
decoratorStateField,
invisibleDecoration,
isCursorInRange,
} from "./util.ts";
import type { Client } from "../client.ts";
import { parse as parseLua } from "../space_lua/parse.ts";
import type { LuaBlock, LuaFunctionCallStatement } from "../space_lua/ast.ts";
import { evalExpression } from "../space_lua/eval.ts";
import {
LuaEnv,
LuaRuntimeError,
LuaStackFrame,
LuaTable,
luaValueToJS,
singleResult,
} from "../space_lua/runtime.ts";
import { isTaggedFloat } from "../space_lua/numeric.ts";
import {
encodeRef,
getNameFromPath,
} from "@silverbulletmd/silverbullet/lib/ref";
import { resolveASTReference } from "../space_lua.ts";
import { LuaWidget } from "./lua_widget.ts";
export function luaDirectivePlugin(client: Client) {
return decoratorStateField((state: EditorState) => {
const widgets: Range<Decoration>[] = [];
let shouldRender = true;
// Don't render Lua directives of federated pages (security)
if (!client.clientSystem.scriptsLoaded) {
return Decoration.none;
}
syntaxTree(state).iterate({
enter: (node) => {
// Disable rendering of Lua directives in #meta/template pages
// Either in frontmatter
if (node.name === "FrontMatterCode") {
const text = state.sliceDoc(node.from, node.to);
try {
// Very ad-hoc regex to detect if meta/template appears in the tag list
if (/tags:.*meta\/template/s.exec(text)) {
shouldRender = false;
return;
}
} catch {
// Ignore
}
}
// Or with a hash tag
if (node.name === "Hashtag") {
const text = state.sliceDoc(node.from, node.to);
if (text.startsWith("#meta/template")) {
shouldRender = false;
return;
}
}
if (node.name !== "LuaDirective") {
return;
}
if (isCursorInRange(state, [node.from, node.to])) {
return;
}
const codeText = state.sliceDoc(node.from, node.to);
const expressionText = codeText.slice(2, -1);
const currentPageMeta = client.currentPageMeta();
widgets.push(
Decoration.widget({
widget: new LuaWidget({
client,
cacheKey: `lua:${expressionText}:${currentPageMeta?.name}`,
expressionText,
codeText,
callback: async (bodyText) => {
if (bodyText.trim().length === 0) {
return "**Error:** Empty Lua expression";
}
try {
const parsedLua = parseLua(`_(${bodyText})`) as LuaBlock;
const expr = (
parsedLua.statements[0] as LuaFunctionCallStatement
).call.args[0];
const tl = new LuaEnv();
tl.setLocal(
"currentPage",
currentPageMeta ||
(client.ui.viewState.current
? {
name: getNameFromPath(
client.ui.viewState.current.path,
),
}
: undefined),
);
const sf = LuaStackFrame.createWithGlobalEnv(
client.clientSystem.spaceLuaEnv.env,
expr.ctx,
);
const threadLocalizedEnv = new LuaEnv(
client.clientSystem.spaceLuaEnv.env,
);
threadLocalizedEnv.setLocal("_CTX", tl);
const rawResult = singleResult(
await evalExpression(expr, threadLocalizedEnv, sf),
);
// keep tagged floats as-is for proper formatting
if (
isTaggedFloat(rawResult) ||
typeof rawResult === "number"
) {
return rawResult;
}
if (rawResult instanceof LuaTable) {
if (rawResult.rawGet("_isWidget")) {
return luaValueToJS(rawResult, sf);
}
return rawResult;
}
// everything else needs luaValueToJS for widget support
return luaValueToJS(rawResult, sf);
} catch (e: any) {
if (e instanceof LuaRuntimeError) {
if (e.sf?.astCtx) {
const source = resolveASTReference(e.sf.astCtx);
if (source) {
// We know the origin node of the error, let's reference it
return `**Lua error:** ${e.message} (Origin: [[${encodeRef(
source,
)}]])`;
}
}
}
return `**Lua error:** ${e.message}`;
}
},
renderEmpty: true,
inPage: true,
}),
}).range(node.to),
);
if (!client.ui.viewState.uiOptions.markdownSyntaxRendering) {
widgets.push(invisibleDecoration.range(node.from, node.to));
}
},
});
if (!shouldRender) {
return Decoration.set([]);
}
return Decoration.set(widgets, true);
});
}