Files
linuxcncWebWASM/web/workspace/ini-parser.mjs

134 lines
5.9 KiB
JavaScript

import { fnv1a64 } from "../worker/replay.mjs";
import { normalizeWorkspacePath } from "./runtime.mjs";
export const INI_SCHEMA = "ini-resource-graph.v1";
export const INI_ERRORS = Object.freeze({
missing: "ini_source_missing",
cycle: "ini_include_cycle",
depth: "ini_include_depth",
section: "ini_section_invalid",
key: "ini_key_invalid",
assignment: "ini_assignment_invalid",
variable: "ini_variable_missing",
path: "ini_path_invalid",
});
function clone(value) { return structuredClone(value); }
function error(code, path, line, message = code, extra = {}) {
return Object.assign(new Error(message), { code, source: path, line, column: 1, ...extra });
}
function stripComment(line) {
let quote = null;
for (let index = 0; index < line.length; index += 1) {
const char = line[index];
if ((char === "\"" || char === "'") && (!quote || quote === char)) quote = quote ? null : char;
if (!quote && (char === ";" || char === "#")) return line.slice(0, index);
}
return line;
}
function resolveWorkspaceRelative(basePath, value) {
const parts = basePath.split("/");
parts.pop();
for (const part of value.replaceAll("\\", "/").split("/")) {
if (!part || part === ".") continue;
if (part === "..") {
if (!parts.length) throw error(INI_ERRORS.path, basePath, 0, "ini_include_path_escape");
parts.pop();
continue;
}
parts.push(part);
}
return normalizeWorkspacePath(`/workspace/${parts.join("/")}`);
}
function resolveInclude(basePath, includePath) {
if (includePath.startsWith("/") || includePath.includes("\\")) throw error(INI_ERRORS.path, basePath, 0, "ini_include_path_invalid");
if (includePath.startsWith("linuxcnc/")) return normalizeWorkspacePath(`/workspace/${includePath}`);
return resolveWorkspaceRelative(basePath, includePath);
}
function expand(value, variables, path, line) {
return value.replace(/\$\{([^}]+)\}|\$\(([^)]+)\)|\$([A-Za-z_][A-Za-z0-9_]*)/g, (match, brace, paren, plain) => {
const name = brace ?? paren ?? plain;
if (!(name in variables)) throw error(INI_ERRORS.variable, path, line, "ini_variable_missing", { variable: name });
return String(variables[name]);
});
}
export function parseIni({ workspace, entrypoint, variables = {}, maxDepth = 32 } = {}) {
if (!workspace || typeof workspace.read !== "function") throw new Error("ini_workspace_required");
const root = normalizeWorkspacePath(entrypoint);
const sections = {};
const entries = [];
const includes = [];
const diagnostics = [];
const graph = [];
const active = [];
const vars = { ...variables };
function parseFile(path, depth) {
if (depth > maxDepth) throw error(INI_ERRORS.depth, path, 0, "ini_include_depth", { maxDepth });
if (active.includes(path)) throw error(INI_ERRORS.cycle, path, 0, "ini_include_cycle", { cycle: [...active, path] });
active.push(path);
let source;
try { source = workspace.read(`/workspace/${path}`); } catch (cause) {
active.pop();
throw error(INI_ERRORS.missing, path, 0, "ini_source_missing", { cause: String(cause?.message ?? cause) });
}
graph.push(path);
let section = "";
const lines = source.split(/\r?\n/);
lines.forEach((rawLine, offset) => {
const lineNumber = offset + 1;
const hashInclude = rawLine.trim().match(/^#INCLUDE\s+(.+)$/i);
if (hashInclude) {
const rawInclude = hashInclude[1].trim().replace(/^['"]|['"]$/g, "");
let child;
try { child = resolveInclude(path, expand(rawInclude, vars, path, lineNumber)); } catch (cause) { throw cause.code ? cause : error(INI_ERRORS.path, path, lineNumber, "ini_include_path_invalid"); }
includes.push({ source: path, line: lineNumber, path: child });
parseFile(child, depth + 1);
return;
}
const clean = stripComment(rawLine).trim();
if (!clean) return;
const includeMatch = clean.match(/^(?:include|INCLUDE)\s*(?:=\s*)?(.+)$/);
if (includeMatch) {
const rawInclude = includeMatch[1].trim().replace(/^['"]|['"]$/g, "");
let child;
try { child = resolveInclude(path, expand(rawInclude, vars, path, lineNumber)); } catch (cause) { throw cause.code ? cause : error(INI_ERRORS.path, path, lineNumber, "ini_include_path_invalid"); }
includes.push({ source: path, line: lineNumber, path: child });
parseFile(child, depth + 1);
return;
}
const sectionMatch = clean.match(/^\[([^\]]+)\]$/);
if (sectionMatch) {
section = sectionMatch[1].trim();
if (!section || /[\0\r\n]/.test(section)) throw error(INI_ERRORS.section, path, lineNumber, "ini_section_invalid");
if (!sections[section]) sections[section] = {};
return;
}
const assignment = clean.match(/^([^=:#]+?)\s*(?:=|:)\s*(.*)$/);
if (!assignment || !section) throw error(!section ? INI_ERRORS.section : INI_ERRORS.assignment, path, lineNumber, !section ? "ini_section_invalid" : "ini_assignment_invalid");
const key = assignment[1].trim();
if (!/^[A-Za-z_][A-Za-z0-9_.-]*$/.test(key)) throw error(INI_ERRORS.key, path, lineNumber, "ini_key_invalid");
const value = expand(assignment[2].trim(), vars, path, lineNumber);
sections[section][key] = value;
if (section.toUpperCase() === "VAR" || section.toUpperCase() === "VARIABLES") vars[key] = value;
entries.push({ source: path, line: lineNumber, section, key, value });
});
active.pop();
}
parseFile(root, 0);
const normalizedSections = Object.fromEntries(Object.keys(sections).sort().map((name) => [name, Object.fromEntries(Object.keys(sections[name]).sort().map((key) => [key, sections[name][key]]))]));
const result = {
schema: INI_SCHEMA,
entrypoint: root,
sections: normalizedSections,
entries,
includes,
graph: [...new Set(graph)],
variables: Object.fromEntries(Object.keys(vars).sort().map((key) => [key, vars[key]])),
diagnostics,
};
return { ...result, state_hash: fnv1a64(result) };
}