Lua: tweaks and docs

pull/1212/head
Zef Hemel 2025-01-14 20:26:47 +01:00
parent cbf227fa49
commit 5604f6d8c2
20 changed files with 284 additions and 132 deletions

View File

@ -30,33 +30,37 @@ export class SpaceLuaEnvironment {
"index.queryObjects",
["space-lua", {}],
);
this.env = buildLuaEnv(system, scriptEnv);
const tl = new LuaEnv();
for (const script of allScripts) {
try {
console.log("Now evaluating", script.ref);
const ast = parseLua(script.script, { ref: script.ref });
// We create a local scope for each script
const scriptEnv = new LuaEnv(this.env);
const sf = new LuaStackFrame(tl, ast.ctx);
await evalStatement(ast, scriptEnv, sf);
} catch (e: any) {
if (e instanceof LuaRuntimeError) {
const origin = resolveASTReference(e.sf.astCtx!);
if (origin) {
console.error(
`Error evaluating script: ${e.message} at [[${origin.page}@${origin.pos}]]`,
);
continue;
try {
this.env = buildLuaEnv(system, scriptEnv);
const tl = new LuaEnv();
for (const script of allScripts) {
try {
console.log("Now evaluating", script.ref);
const ast = parseLua(script.script, { ref: script.ref });
// We create a local scope for each script
const scriptEnv = new LuaEnv(this.env);
const sf = new LuaStackFrame(tl, ast.ctx);
await evalStatement(ast, scriptEnv, sf);
} catch (e: any) {
if (e instanceof LuaRuntimeError) {
const origin = resolveASTReference(e.sf.astCtx!);
if (origin) {
console.error(
`Error evaluating script: ${e.message} at [[${origin.page}@${origin.pos}]]`,
);
continue;
}
}
console.error(
`Error evaluating script: ${e.message} for script: ${script.script}`,
);
}
console.error(
`Error evaluating script: ${e.message} for script: ${script.script}`,
);
}
}
console.log("[Lua] Loaded", allScripts.length, "scripts");
console.log("[Lua] Loaded", allScripts.length, "scripts");
} catch (e: any) {
console.error("Error reloading Lua scripts:", e.message);
}
}
}

View File

@ -729,9 +729,11 @@ export async function evalStatement(
s.expressions.map((e) => evalExpression(e, env, sf)),
),
).flatten();
const iteratorFunction: ILuaFunction | undefined =
iteratorMultiRes.values[0];
if (!iteratorFunction?.call) {
let iteratorValue: ILuaFunction | any = iteratorMultiRes.values[0];
if (Array.isArray(iteratorValue) || iteratorValue instanceof LuaTable) {
iteratorValue = env.get("each").call(sf, iteratorValue);
}
if (!iteratorValue?.call) {
console.error("Cannot iterate over", iteratorMultiRes.values[0]);
throw new LuaRuntimeError(
`Cannot iterate over ${iteratorMultiRes.values[0]}`,
@ -744,7 +746,7 @@ export async function evalStatement(
while (true) {
const iterResult = new LuaMultiRes(
await luaCall(iteratorFunction, [state, control], s.ctx, sf),
await luaCall(iteratorValue, [state, control], s.ctx, sf),
).flatten();
if (
iterResult.values[0] === null || iterResult.values[0] === undefined

View File

@ -10,9 +10,12 @@ import { assert } from "@std/assert/assert";
import { fileURLToPath } from "node:url";
Deno.test("Lua language tests", async () => {
// Read the Lua file
await runLuaTest("./language_test.lua");
});
async function runLuaTest(luaPath: string) {
const luaFile = await Deno.readTextFile(
fileURLToPath(new URL("./language_test.lua", import.meta.url)),
fileURLToPath(new URL(luaPath, import.meta.url)),
);
const chunk = parse(luaFile, {});
const env = new LuaEnv(luaBuildStandardEnv());
@ -29,4 +32,4 @@ Deno.test("Lua language tests", async () => {
}
assert(false);
}
});
}

View File

@ -269,6 +269,22 @@ for key, value in pairs({ a = "a", b = "b" }) do
assert_equal(key, value)
end
-- for in over tables directly
local cnt = 1
for val in { 1, 2, 3 } do
assert_equal(val, cnt)
cnt = cnt + 1
end
assert_equal(cnt, 4)
local cnt = 1
for val in js.tojs({ 1, 2, 3 }) do
assert_equal(val, cnt)
cnt = cnt + 1
end
assert_equal(cnt, 4)
-- type
assert(type(1) == "number")
assert(type("Hello") == "string")

View File

@ -1,6 +1,6 @@
import type { ASTCtx, LuaFunctionBody } from "./ast.ts";
import { evalStatement } from "$common/space_lua/eval.ts";
import { asyncQuickSort, evalPromiseValues } from "$common/space_lua/util.ts";
import { evalStatement } from "./eval.ts";
import { asyncQuickSort, evalPromiseValues } from "./util.ts";
export type LuaType =
| "nil"
@ -425,7 +425,7 @@ export class LuaTable implements ILuaSettable, ILuaGettable {
}
}
asJSObject(): Record<string, any> {
toJSObject(): Record<string, any> {
const result: Record<string, any> = {};
for (const key of this.keys()) {
result[key] = luaValueToJS(this.get(key));
@ -433,15 +433,15 @@ export class LuaTable implements ILuaSettable, ILuaGettable {
return result;
}
asJSArray(): any[] {
toJSArray(): any[] {
return this.arrayPart.map(luaValueToJS);
}
asJS(): Record<string, any> | any[] {
toJS(): Record<string, any> | any[] {
if (this.length > 0) {
return this.asJSArray();
return this.toJSArray();
} else {
return this.asJSObject();
return this.toJSObject();
}
}
@ -722,22 +722,7 @@ export function luaValueToJS(value: any): any {
return value.then(luaValueToJS);
}
if (value instanceof LuaTable) {
// We'll go a bit on heuristics here
// If the table has a length > 0 we'll assume it's a pure array
// Otherwise we'll assume it's a pure object
if (value.length > 0) {
const result = [];
for (let i = 0; i < value.length; i++) {
result.push(luaValueToJS(value.get(i + 1)));
}
return result;
} else {
const result: Record<string, any> = {};
for (const key of value.keys()) {
result[key] = luaValueToJS(value.get(key));
}
return result;
}
return value.toJS();
} else if (value instanceof LuaNativeJSFunction) {
return (...args: any[]) => {
return jsToLuaValue(value.fn(...args.map(luaValueToJS)));

View File

@ -1,12 +1,13 @@
import {
type ILuaFunction,
jsToLuaValue,
LuaBuiltinFunction,
luaCall,
LuaEnv,
luaGet,
LuaMultiRes,
LuaRuntimeError,
type LuaTable,
LuaTable,
luaToString,
luaTypeOf,
type LuaValue,
@ -15,11 +16,15 @@ import { stringApi } from "$common/space_lua/stdlib/string.ts";
import { tableApi } from "$common/space_lua/stdlib/table.ts";
import { osApi } from "$common/space_lua/stdlib/os.ts";
import { jsApi } from "$common/space_lua/stdlib/js.ts";
import { spaceLuaApi } from "$common/space_lua/stdlib/space_lua.ts";
import {
interpolateLuaString,
spaceLuaApi,
} from "$common/space_lua/stdlib/space_lua.ts";
import type {
LuaCollectionQuery,
LuaQueryCollection,
} from "$common/space_lua/query_collection.ts";
import { templateApi } from "$common/space_lua/stdlib/template.ts";
const printFunction = new LuaBuiltinFunction(async (_sf, ...args) => {
console.log("[Lua]", ...(await Promise.all(args)));
@ -58,6 +63,18 @@ const pairsFunction = new LuaBuiltinFunction((sf, t: LuaTable) => {
};
});
export const eachFunction = new LuaBuiltinFunction((sf, ar: LuaTable) => {
let i = 1;
return async () => {
if (i > ar.length) {
return;
}
const result = await luaGet(ar, i, sf);
i++;
return result;
};
});
const unpackFunction = new LuaBuiltinFunction(async (sf, t: LuaTable) => {
const values: LuaValue[] = [];
for (let i = 1; i <= t.length; i++) {
@ -127,6 +144,7 @@ const getmetatableFunction = new LuaBuiltinFunction((_sf, table: LuaTable) => {
return table.metatable;
});
// Non-standard
const tagFunction = new LuaBuiltinFunction(
(sf, tagName: LuaValue): LuaQueryCollection => {
const global = sf.threadLocal.get("_GLOBAL");
@ -142,12 +160,29 @@ const tagFunction = new LuaBuiltinFunction(
tagName,
],
query,
)).asJSArray();
)).toJSArray();
},
};
},
);
const tplFunction = new LuaBuiltinFunction(
(_sf, template: string): ILuaFunction => {
const lines = template.split("\n").map((line) =>
line.replace(/^\s{4}/, "")
);
const processed = lines.join("\n");
return new LuaBuiltinFunction(
async (sf, env: LuaTable | any) => {
if (!(env instanceof LuaTable)) {
env = jsToLuaValue(env);
}
return await interpolateLuaString(sf, processed, env);
},
);
},
);
export function luaBuildStandardEnv() {
const env = new LuaEnv();
// Top-level builtins
@ -168,12 +203,17 @@ export function luaBuildStandardEnv() {
env.set("error", errorFunction);
env.set("pcall", pcallFunction);
env.set("xpcall", xpcallFunction);
// Non-standard
env.set("tag", tagFunction);
env.set("tpl", tplFunction);
// APIs
env.set("string", stringApi);
env.set("table", tableApi);
env.set("os", osApi);
env.set("js", jsApi);
// Non-standard
env.set("each", eachFunction);
env.set("space_lua", spaceLuaApi);
env.set("template", templateApi);
return env;
}

View File

@ -28,6 +28,7 @@ function createAugmentedEnv(
}
const env = new LuaEnv(globalEnv);
if (envAugmentation) {
env.setLocal("_", envAugmentation);
for (const key of envAugmentation.keys()) {
env.setLocal(key, envAugmentation.rawGet(key));
}
@ -35,6 +36,70 @@ function createAugmentedEnv(
return env;
}
/**
* Interpolates a string with lua expressions and returns the result.
*
* @param sf - The current space_lua state.
* @param template - The template string to interpolate.
* @param envAugmentation - An optional environment to augment the global environment with.
* @returns The interpolated string.
*/
export async function interpolateLuaString(
sf: LuaStackFrame,
template: string,
envAugmentation?: LuaTable,
): Promise<string> {
let result = "";
let currentIndex = 0;
while (true) {
const startIndex = template.indexOf("${", currentIndex);
if (startIndex === -1) {
result += template.slice(currentIndex);
break;
}
result += template.slice(currentIndex, startIndex);
// Find matching closing brace by counting nesting
let nestLevel = 1;
let endIndex = startIndex + 2;
while (nestLevel > 0 && endIndex < template.length) {
if (template[endIndex] === "{") {
nestLevel++;
} else if (template[endIndex] === "}") {
nestLevel--;
}
if (nestLevel > 0) {
endIndex++;
}
}
if (nestLevel > 0) {
throw new LuaRuntimeError("Unclosed interpolation expression", sf);
}
const expr = template.slice(startIndex + 2, endIndex);
try {
const parsedExpr = parseExpressionString(expr);
const env = createAugmentedEnv(sf, envAugmentation);
const luaResult = luaValueToJS(
await evalExpression(parsedExpr, env, sf),
);
result += luaToString(luaResult);
} catch (e: any) {
throw new LuaRuntimeError(
`Error evaluating "${expr}": ${e.message}`,
sf,
);
}
currentIndex = endIndex + 1;
}
return result;
}
export const spaceLuaApi = new LuaTable({
/**
* Parses a lua expression and returns the parsed expression.
@ -64,63 +129,10 @@ export const spaceLuaApi = new LuaTable({
),
/**
* Interpolates a string with lua expressions and returns the result.
*
* @param sf - The current space_lua state.
* @param template - The template string to interpolate.
* @param envAugmentation - An optional environment to augment the global environment with.
* @returns The interpolated string.
*/
interpolate: new LuaBuiltinFunction(
async (sf, template: string, envAugmentation?: LuaTable) => {
let result = "";
let currentIndex = 0;
while (true) {
const startIndex = template.indexOf("${", currentIndex);
if (startIndex === -1) {
result += template.slice(currentIndex);
break;
}
result += template.slice(currentIndex, startIndex);
// Find matching closing brace by counting nesting
let nestLevel = 1;
let endIndex = startIndex + 2;
while (nestLevel > 0 && endIndex < template.length) {
if (template[endIndex] === "{") {
nestLevel++;
} else if (template[endIndex] === "}") {
nestLevel--;
}
if (nestLevel > 0) {
endIndex++;
}
}
if (nestLevel > 0) {
throw new LuaRuntimeError("Unclosed interpolation expression", sf);
}
const expr = template.slice(startIndex + 2, endIndex);
try {
const parsedExpr = parseExpressionString(expr);
const env = createAugmentedEnv(sf, envAugmentation);
const luaResult = luaValueToJS(
await evalExpression(parsedExpr, env, sf),
);
result += luaToString(luaResult);
} catch (e: any) {
throw new LuaRuntimeError(
`Error evaluating "${expr}": ${e.message}`,
sf,
);
}
currentIndex = endIndex + 1;
}
return result;
(sf, template: string, envAugmentation?: LuaTable) => {
return interpolateLuaString(sf, template, envAugmentation);
},
),
});

View File

@ -157,4 +157,12 @@ export const stringApi = new LuaTable({
split: new LuaBuiltinFunction((_sf, s: string, sep: string) => {
return s.split(sep);
}),
// Non-standard
startswith: new LuaBuiltinFunction((_sf, s: string, prefix: string) => {
return s.startsWith(prefix);
}),
endswith: new LuaBuiltinFunction((_sf, s: string, suffix: string) => {
return s.endsWith(suffix);
}),
});

View File

@ -0,0 +1,20 @@
import {
type ILuaFunction,
LuaBuiltinFunction,
LuaTable,
} from "$common/space_lua/runtime.ts";
export const templateApi = new LuaTable({
each: new LuaBuiltinFunction(
async (sf, tbl: LuaTable | any[], fn: ILuaFunction): Promise<string> => {
const result = [];
if (tbl instanceof LuaTable) {
tbl = tbl.toJSArray();
}
for (const item of tbl) {
result.push(await fn.call(sf, item));
}
return result.join("");
},
),
});

View File

@ -25,8 +25,13 @@ export function buildLuaEnv(system: System<any>, scriptEnv: ScriptEnvironment) {
function exposeSyscalls(env: LuaEnv, system: System<any>) {
// Expose all syscalls to Lua
// Except...
const exclude = ["template"];
const nativeFs = new LuaStackFrame(env, null);
for (const syscallName of system.registeredSyscalls.keys()) {
if (exclude.includes(syscallName)) {
continue;
}
const [ns, fn] = syscallName.split(".");
if (!env.has(ns)) {
env.set(ns, new LuaTable(), nativeFs);

View File

@ -85,7 +85,7 @@ async function renderExpressionDirective(
export function renderExpressionResult(result: any): string {
if (result instanceof LuaTable) {
result = result.asJS();
result = result.toJS();
}
if (
Array.isArray(result) && result.length > 0 && typeof result[0] === "object"

View File

@ -1,16 +1,5 @@
The server API is relatively small. The client primarily communicates with the server for file “CRUD” (Create, Read, Update, Delete) style operations.
This describes the APIs available in [[Space Lua]]
All API requests from the client will always set the `X-Sync-Mode` request header set to `true`. The server may use this fact to distinguish between requests coming from the client and regular e.g. `GET` requests from the browser (through navigation) and redirect appropriately (for instance to the UI URL associated with a specific `.md` file).
The API:
* `GET /index.json` will return a full listing of all files in your space including metadata like when the file was last modified, as well as permissions. This is primarily used for sync purposes with the client.
* `GET /*.*`: _Reads_ and returns the content of the file at the given path. This means that if you `GET /index.md` you will receive the content of your `index` page. If the optional `X-Get-Meta` _request header_ is set, the server does not _need to_ return the body of the file (but it can). The `GET` _response_ will have a few additional SB-specific headers:
* (optional) `X-Last-Modified` the last modified time of the file as a UNIX timestamp in ms since the epoch (as coming from `Data.now()`). This timestamp _has_ to match the `lastModified` listed for this file in `/index.json` otherwise syncing issues may occur. When this header is missing, frequent polling-based sync will be disabled for this file.
* (optional) `X-Created` the created time of the file as a UNIX timestamp in ms since the epoch (as coming from `Data.now()`).
* (optional) `X-Permission`: either `rw` or `ro` which will change whether the editor opens in read-only or edit mode. When missing, `ro` is assumed.
* (optional) `X-Content-Length`: which will be the same as `Content-Length` except if the request was sent with a `X-Get-Meta` header and the body is not returned (then `Content-Length` will be `0` and `X-Content-Length` will be the size of the file)
* `PUT /*.*`: The same as `GET` except that it takes the body of the request and _writes_ it to a file.
* `DELETE /*.*`: Again the same, except this will _delete_ the given file.
* `GET /.client/*`: Retrieve files implementing the client
* `GET /*` and `GET /`: Anything else (any path without a file extension) will serve the SilverBullet UI HTML.
${template.each(query[[
from tag("page") where string.startswith(name, "API/")
]], render.page)}

View File

@ -30,4 +30,17 @@ Returns a given [[Objects#Tags]] as a query collection, to be queried using [[Sp
Example:
${query[[from tag("page") limit 1]]}
${query[[from tag("page") limit 1]]}
## tpl(template)
Returns a template function that can be used to render a template. Conventionally, a template string is put between `[==[` and `]==]` as string delimiters.
Example:
```space-lua
examples = examples or {}
examples.say_hello = tpl[==[Hello ${name}!]==]
```
And its use: ${examples.say_hello {name="Pete"}}

21
website/API/space_lua.md Normal file
View File

@ -0,0 +1,21 @@
Space Lua specific functions that are available to all scripts, but are not part of the standard Lua language.
## space_lua.parse_expression(luaExpression)
Parses a lua expression and returns the parsed expression as an AST.
Example:
space_lua.parse_expression("1 + 1")
## space_lua.eval_expression(parsedExpr, envAugmentation?)
Evaluates a parsed Lua expression and returns the result. Optionally accepts an environment table to augment the global environment.
Example:
${space_lua.eval_expression(space_lua.parse_expression("x + y"), {x = 1, y = 2})}
## space_lua.interpolate(template, envAugmentation?)
Interpolates a string with lua expressions and returns the result. Expressions are wrapped in ${...} syntax. Optionally accepts an environment table to augment the global environment.
${space_lua.interpolate("Hello ${name}!", {name="Pete"})}

10
website/API/template.md Normal file
View File

@ -0,0 +1,10 @@
Template functions that use the [[API/global#tpl(template)]] function.
## template.each(collection, template)
Iterates over a collection and renders a template for each item.
Example:
${template.each(query[[from tag "page" limit 3]], tpl[==[
* ${name}
]==])}

View File

@ -2,7 +2,7 @@ Federation enables _browsing_ content from spaces _outside_ the users space,
This enables a few things:
* **Browsing** other publicly hosted SilverBullet spaces (or websites adhering to its [[API]]) within the comfort of your own SilverBullet client. One use case of this is [[Transclusions|transcluding]] the [[Getting Started]] page in the users automatically generated index page when setting up a fresh space.
* **Browsing** other publicly hosted SilverBullet spaces (or websites adhering to its [[HTTP API]]) within the comfort of your own SilverBullet client. One use case of this is [[Transclusions|transcluding]] the [[Getting Started]] page in the users automatically generated index page when setting up a fresh space.
* **Referencing** other spaces for other purposes, which is leveraged in [[Libraries]].
# How it works
@ -17,7 +17,7 @@ For example: `https://raw.githubusercontent.com/silverbulletmd/silverbullet/main
Can be written to federation syntax as follows: `!raw.githubusercontent.com/silverbulletmd/silverbullet/main/README`
And used as a link: [[!raw.githubusercontent.com/silverbulletmd/silverbullet/main/README]]
If the target server supports the SilverBullet [[API]] (specifically its `/index.json` endpoint), page completion will be provided as well.
If the target server supports the SilverBullet [[HTTP API]] (specifically its `/index.json` endpoint), page completion will be provided as well.
Upon fetching of the page content, a best effort attempt will be made to rewrite any local page links in the page to the appropriate federated paths.

16
website/HTTP API.md Normal file
View File

@ -0,0 +1,16 @@
The server API is relatively small. The client primarily communicates with the server for file “CRUD” (Create, Read, Update, Delete) style operations.
All API requests from the client will always set the `X-Sync-Mode` request header set to `true`. The server may use this fact to distinguish between requests coming from the client and regular e.g. `GET` requests from the browser (through navigation) and redirect appropriately (for instance to the UI URL associated with a specific `.md` file).
The API:
* `GET /index.json` will return a full listing of all files in your space including metadata like when the file was last modified, as well as permissions. This is primarily used for sync purposes with the client.
* `GET /*.*`: _Reads_ and returns the content of the file at the given path. This means that if you `GET /index.md` you will receive the content of your `index` page. If the optional `X-Get-Meta` _request header_ is set, the server does not _need to_ return the body of the file (but it can). The `GET` _response_ will have a few additional SB-specific headers:
* (optional) `X-Last-Modified` the last modified time of the file as a UNIX timestamp in ms since the epoch (as coming from `Data.now()`). This timestamp _has_ to match the `lastModified` listed for this file in `/index.json` otherwise syncing issues may occur. When this header is missing, frequent polling-based sync will be disabled for this file.
* (optional) `X-Created` the created time of the file as a UNIX timestamp in ms since the epoch (as coming from `Data.now()`).
* (optional) `X-Permission`: either `rw` or `ro` which will change whether the editor opens in read-only or edit mode. When missing, `ro` is assumed.
* (optional) `X-Content-Length`: which will be the same as `Content-Length` except if the request was sent with a `X-Get-Meta` header and the body is not returned (then `Content-Length` will be `0` and `X-Content-Length` will be the size of the file)
* `PUT /*.*`: The same as `GET` except that it takes the body of the request and _writes_ it to a file.
* `DELETE /*.*`: Again the same, except this will _delete_ the given file.
* `GET /.client/*`: Retrieve files implementing the client
* `GET /*` and `GET /`: Anything else (any path without a file extension) will serve the SilverBullet UI HTML.

View File

@ -10,7 +10,7 @@ Note: these options are primarily useful for [[Install/Deno]] deployments, not s
SilverBullet supports basic authentication for a single user.
* `SB_USER`: Sets single-user credentials, e.g. `SB_USER=pete:1234` allows you to login with username “pete” and password “1234”.
* `SB_AUTH_TOKEN`: Enables `Authorization: Bearer <token>` style authentication on the [[API]] (useful for [[Sync]] and remote HTTP storage backends).
* `SB_AUTH_TOKEN`: Enables `Authorization: Bearer <token>` style authentication on the [[HTTP API]] (useful for [[Sync]] and remote HTTP storage backends).
* `SB_LOCKOUT_LIMIT`: Specifies the number of failed login attempt before locking the user out (for a `SB_LOCKOUT_TIME` specified amount of seconds), defaults to `10`
* `SB_LOCKOUT_TIME`: Specifies the amount of time (in seconds) a client will be blocked until attempting to log back in.

View File

@ -0,0 +1,8 @@
Defines some core useful templates for use in [[Space Lua]]
```space-lua
render = render or {}
render.page = tpl[==[
* [[${name}]]
]==]
```

View File

@ -19,7 +19,7 @@ Unlike [[Query Language]] which operates on [[Objects]] only, LIQ can operate on
For instance, to sort a list of numbers in descending order:
${query[[from n = {1, 2, 3} order by n desc]]}
However, in most cases youll use it in conjunction with [[Space Lua/stdlib#tag(name)]]. Heres an example querying the 3 pages that were last modified:
However, in most cases youll use it in conjunction with [[../API/global#tag(name)]]. Heres an example querying the 3 pages that were last modified:
${query[[
from p = tag "page"