import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs"; import { basename, dirname } from "node:path"; import { isDeepStrictEqual } from "node:util"; import { applyEdits, modify, parse as parseJsonc, printParseErrorCode, type ParseError, } from "jsonc-parser"; import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; import type { ConfigFormat } from "./types.ts"; export class ConfigFileError extends Error { constructor(message: string, options?: { cause?: unknown }) { super(message, options); this.name = "ConfigFileError"; } } export class ConfigMutationConflict extends Error { constructor(message: string) { super(message); this.name = "ConfigMutationConflict"; } } export interface ParsedConfigDocument { path: string; format: ConfigFormat; text: string; value: unknown; } export type ValueMutation = | { path: string; format: ConfigFormat; keyPath: string[]; operation: "delete"; expected: unknown; } | { path: string; format: ConfigFormat; keyPath: string[]; operation: "set"; value: unknown; }; export interface PreparedFileChange { path: string; beforeText: string | undefined; afterText: string; mode: number; } interface PathLookup { exists: boolean; value: unknown; } let tempCounter = 0; function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } function stripBom(text: string): { bom: string; body: string } { return text.startsWith("\uFEFF") ? { bom: "\uFEFF", body: text.slice(1) } : { bom: "", body: text }; } function parseJsoncText(text: string, filePath: string): unknown { const errors: ParseError[] = []; const value = parseJsonc(stripBom(text).body, errors, { allowTrailingComma: true, disallowComments: false, allowEmptyContent: false, }); if (errors.length > 0) { const summary = errors .slice(0, 3) .map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`) .join(", "); throw new ConfigFileError(`Failed to parse JSONC config ${filePath}: ${summary}`); } return value; } function parseTomlText(text: string, filePath: string): unknown { try { return parseToml(stripBom(text).body); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new ConfigFileError(`Failed to parse TOML config ${filePath}: ${message}`, { cause: error }); } } export function parseConfigText(text: string, format: ConfigFormat, filePath = ""): unknown { return format === "toml" ? parseTomlText(text, filePath) : parseJsoncText(text, filePath); } export function readConfigDocument(filePath: string, format: ConfigFormat): ParsedConfigDocument | null { if (!existsSync(filePath)) return null; let text: string; try { text = readFileSync(filePath, "utf8"); } catch (error) { throw new ConfigFileError(`Failed to read config ${filePath}`, { cause: error }); } return { path: filePath, format, text, value: parseConfigText(text, format, filePath), }; } export function getPathValue(root: unknown, keyPath: readonly string[]): PathLookup { let current = root; for (const key of keyPath) { if (!isRecord(current) || !Object.prototype.hasOwnProperty.call(current, key)) { return { exists: false, value: undefined }; } current = current[key]; } return { exists: true, value: current }; } function deleteObjectPath(root: unknown, keyPath: readonly string[]): void { if (keyPath.length === 0) throw new ConfigFileError("Cannot delete a document root"); let parent = root; for (const key of keyPath.slice(0, -1)) { if (!isRecord(parent) || !Object.prototype.hasOwnProperty.call(parent, key)) { throw new ConfigFileError(`Cannot traverse missing or non-object path ${keyPath.join(".")}`); } parent = parent[key]; } if (!isRecord(parent)) throw new ConfigFileError(`Cannot delete from non-object path ${keyPath.join(".")}`); delete parent[keyPath[keyPath.length - 1]!]; } function defineOwnValue(target: Record, key: string, value: unknown): void { Object.defineProperty(target, key, { value, writable: true, enumerable: true, configurable: true, }); } function setObjectPath(root: unknown, keyPath: readonly string[], value: unknown): void { if (keyPath.length === 0) throw new ConfigFileError("Cannot replace a document root"); if (!isRecord(root)) throw new ConfigFileError(`Cannot create path ${keyPath.join(".")} in a non-object document`); let parent = root; for (const key of keyPath.slice(0, -1)) { const hasOwn = Object.prototype.hasOwnProperty.call(parent, key); const next = hasOwn ? parent[key] : undefined; if (!hasOwn) { const created: Record = {}; defineOwnValue(parent, key, created); parent = created; continue; } if (!isRecord(next)) throw new ConfigFileError(`Cannot traverse non-object path ${keyPath.join(".")}`); parent = next; } defineOwnValue(parent, keyPath[keyPath.length - 1]!, value); } function formattingOptions(text: string): { insertSpaces: boolean; tabSize: number; eol: string; } { const body = stripBom(text).body; const indent = body.match(/^(\s+)["A-Za-z_][^\r\n]*[:=]/m)?.[1] ?? " "; const insertSpaces = !indent.includes("\t"); return { insertSpaces, tabSize: insertSpaces ? Math.max(1, indent.length) : 1, eol: body.includes("\r\n") ? "\r\n" : "\n", }; } function applyJsonMutation(text: string, mutation: ValueMutation): string { const { bom, body } = stripBom(text); const edits = modify( body, mutation.keyPath, mutation.operation === "set" ? mutation.value : undefined, { formattingOptions: formattingOptions(body) }, ); return bom + applyEdits(body, edits); } function formatTomlDocument(value: unknown): string { if (!isRecord(value)) throw new ConfigFileError("TOML root must be a table"); const text = stringifyToml(value as Parameters[0]); return text.endsWith("\n") || text.length === 0 ? text : `${text}\n`; } function groupMutations(mutations: readonly ValueMutation[]): Map { const grouped = new Map(); for (const mutation of mutations) { const current = grouped.get(mutation.path) ?? []; const existingFormat = current[0]?.format; if (existingFormat && existingFormat !== mutation.format) { throw new ConfigFileError(`Conflicting formats for ${mutation.path}`); } current.push(mutation); grouped.set(mutation.path, current); } return grouped; } export function prepareFileChanges(mutations: readonly ValueMutation[]): PreparedFileChange[] { const prepared: PreparedFileChange[] = []; for (const [filePath, fileMutations] of groupMutations(mutations)) { const format = fileMutations[0]!.format; const existed = existsSync(filePath); const beforeText = existed ? readFileSync(filePath, "utf8") : undefined; let currentText = beforeText ?? (format === "jsonc" ? "{}\n" : ""); let root = parseConfigText(currentText, format, filePath); if (!isRecord(root)) { throw new ConfigFileError(`Config root must be an object in ${filePath}`); } for (const mutation of fileMutations) { const current = getPathValue(root, mutation.keyPath); if (mutation.operation === "delete") { if (!current.exists) { throw new ConfigMutationConflict(`Definition disappeared from ${filePath} at ${mutation.keyPath.join(".")}`); } if (!isDeepStrictEqual(current.value, mutation.expected)) { throw new ConfigMutationConflict(`Definition changed in ${filePath} at ${mutation.keyPath.join(".")}`); } } else if (current.exists) { throw new ConfigMutationConflict(`Definition already exists in ${filePath} at ${mutation.keyPath.join(".")}`); } if (format === "jsonc") { currentText = applyJsonMutation(currentText, mutation); root = parseConfigText(currentText, format, filePath); } else if (mutation.operation === "delete") { deleteObjectPath(root, mutation.keyPath); } else { setObjectPath(root, mutation.keyPath, structuredClone(mutation.value)); } } if (format === "toml") currentText = formatTomlDocument(root); if (currentText === beforeText) continue; prepared.push({ path: filePath, beforeText, afterText: currentText, mode: existed ? statSync(filePath).mode & 0o777 : 0o600, }); } return prepared; } export function atomicWriteText(filePath: string, text: string, mode = 0o600): void { mkdirSync(dirname(filePath), { recursive: true }); const tempPath = `${filePath}.${process.pid}.${++tempCounter}.${basename(filePath)}.tmp`; try { writeFileSync(tempPath, text, { encoding: "utf8", mode }); try { chmodSync(tempPath, mode); } catch { // chmod is best-effort on filesystems without POSIX modes. } renameSync(tempPath, filePath); try { chmodSync(filePath, mode); } catch { // chmod is best-effort on filesystems without POSIX modes. } } catch (error) { try { rmSync(tempPath, { force: true }); } catch { // Preserve the original write error. } throw new ConfigFileError(`Failed to atomically write ${filePath}`, { cause: error }); } } function rollbackChanges(changes: readonly PreparedFileChange[]): void { const errors: unknown[] = []; for (const change of [...changes].reverse()) { try { if (change.beforeText === undefined) { rmSync(change.path, { force: true }); } else { atomicWriteText(change.path, change.beforeText, change.mode); } } catch (error) { errors.push(error); } } if (errors.length > 0) throw new AggregateError(errors, "Failed to roll back one or more config files"); } export function commitPreparedChanges(changes: readonly PreparedFileChange[]): () => void { const committed: PreparedFileChange[] = []; try { for (const change of changes) { atomicWriteText(change.path, change.afterText, change.mode); committed.push(change); } } catch (error) { try { rollbackChanges(committed); } catch (rollbackError) { throw new AggregateError([error, rollbackError], "Config write failed and rollback was incomplete"); } throw error; } let rolledBack = false; return () => { if (rolledBack) return; rolledBack = true; rollbackChanges(committed); }; }