Files
plainleaf/plugs/configuration-manager/lua.ts
T
2026-04-21 10:16:05 +02:00

183 lines
5.7 KiB
TypeScript

/**
* Note: yes, this duplicates functionality also found in the Space Lua implementation,
* for pragmatic reasons. Pulling in the entiretly of this library into this plug would
* be overkill. We only need a few serialization features in practice.
*/
const LUA_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
/**
* Format a key for use in a Lua table constructor.
* Simple identifiers use bare `key =`, others use `["key"] =`.
*/
export function toLuaKey(key: string): string {
return LUA_IDENTIFIER.test(key) ? key : `["${escapeLuaString(key)}"]`;
}
function escapeLuaString(s: string): string {
return (
s
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, "\\n")
.replace(/\t/g, "\\t")
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally escaping control chars for Lua output
.replace(/[\x00-\x1f]/g, (ch) => {
return `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`;
})
);
}
/**
* Serialize a JavaScript value to Lua source code.
*/
export function toLua(value: unknown, indent = 0): string {
if (value === null || value === undefined) {
return "nil";
}
if (typeof value === "boolean") {
return value ? "true" : "false";
}
if (typeof value === "number") {
return String(value);
}
if (typeof value === "string") {
return `"${escapeLuaString(value)}"`;
}
if (Array.isArray(value)) {
if (value.length === 0) return "{}";
const pad = " ".repeat(indent + 1);
const items = value.map((v) => `${pad}${toLua(v, indent + 1)},`);
return `{\n${items.join("\n")}\n${" ".repeat(indent)}}`;
}
if (typeof value === "object") {
const entries = Object.entries(value).filter(
([_, v]) => v !== null && v !== undefined,
);
if (entries.length === 0) return "{}";
const pad = " ".repeat(indent + 1);
const items = entries.map(
([k, v]) => `${pad}${toLuaKey(k)} = ${toLua(v, indent + 1)},`,
);
return `{\n${items.join("\n")}\n${" ".repeat(indent)}}`;
}
return "nil";
}
/**
* Parse a single Lua literal as emitted by toLua().
* Only handles the scalar literals (boolean, number, simple string, nil).
* Complex values (tables) are not supported and return undefined.
*/
export function parseLuaLiteral(s: string): any {
s = s.trim();
if (s === "true") return true;
if (s === "false") return false;
if (s === "nil") return undefined;
if (/^-?\d+(\.\d+)?$/.test(s)) return Number(s);
const strMatch = s.match(/^"([^"]*)"$/);
if (strMatch) return strMatch[1];
return undefined;
}
export type CommandOverride = {
key?: string | string[];
mac?: string | string[];
};
/**
* Parse a `key = "..."` or `key = { "a", "b" }` field out of a command.update
* body. Returns undefined if the field isn't present.
*/
function parseStringOrList(
body: string,
field: string,
): string | string[] | undefined {
const scalarRe = new RegExp(`\\b${field}\\s*=\\s*"([^"]*)"`);
const scalar = body.match(scalarRe);
if (scalar) return scalar[1];
const listRe = new RegExp(`\\b${field}\\s*=\\s*\\{([^}]*)\\}`);
const list = body.match(listRe);
if (!list) return undefined;
const items: string[] = [];
const itemRe = /"([^"]*)"/g;
let im: RegExpExecArray | null;
while ((im = itemRe.exec(list[1])) !== null) items.push(im[1]);
return items;
}
/**
* Parse the body of the configuration-manager managed block back into the
* structured form the UI consumes. Only recognizes the exact shapes emitted by
* the manager: `config.set("path", literal)` and
* `command.update { name = "...", key = ..., mac = ... }`, where key/mac are
* either string literals or `{ "a", "b" }` string lists.
*
* Strings with embedded quotes/braces/parens are not supported — matches the
* constraints toLua() respects when emitting these blocks.
*/
export function parseManagedBlock(blockContent: string): {
configOverrides: Record<string, any>;
commandOverrides: Record<string, CommandOverride>;
} {
const configOverrides: Record<string, any> = {};
const commandOverrides: Record<string, CommandOverride> = {};
const configRe = /config\.set\("([^"]+)",\s*(.+)\)/g;
let m: RegExpExecArray | null;
while ((m = configRe.exec(blockContent)) !== null) {
configOverrides[m[1]] = parseLuaLiteral(m[2]);
}
// Scan for `command.update {...}` with brace-depth tracking so nested
// string lists like `key = { "a", "b" }` don't truncate the match early.
const needle = "command.update";
let idx = 0;
while (true) {
const start = blockContent.indexOf(needle, idx);
if (start === -1) break;
let i = start + needle.length;
while (i < blockContent.length && /\s/.test(blockContent[i])) i++;
if (blockContent[i] !== "{") {
idx = start + needle.length;
continue;
}
let depth = 0;
const bodyStart = i + 1;
let bodyEnd = -1;
for (; i < blockContent.length; i++) {
const ch = blockContent[i];
if (ch === '"') {
i++;
while (i < blockContent.length && blockContent[i] !== '"') {
if (blockContent[i] === "\\") i++;
i++;
}
continue;
}
if (ch === "{") depth++;
else if (ch === "}") {
depth--;
if (depth === 0) {
bodyEnd = i;
break;
}
}
}
if (bodyEnd === -1) break;
const body = blockContent.slice(bodyStart, bodyEnd);
idx = bodyEnd + 1;
const nameMatch = body.match(/\bname\s*=\s*"([^"]*)"/);
if (!nameMatch) continue;
const entry: CommandOverride = {};
const key = parseStringOrList(body, "key");
const mac = parseStringOrList(body, "mac");
if (key !== undefined) entry.key = key;
if (mac !== undefined) entry.mac = mac;
commandOverrides[nameMatch[1]] = entry;
}
return { configOverrides, commandOverrides };
}