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) }; }