From e3da12df05a7591f8ffad82a849d0932c770a254 Mon Sep 17 00:00:00 2001 From: codethief Date: Sat, 22 Aug 2026 23:42:13 +0200 Subject: [PATCH] Parse config files as JSONC --- CHANGELOG.md | 5 +++ docs/Configuration.md | 10 ++++-- package-lock.json | 9 ++++- package.json | 3 +- src/config/load.ts | 20 ++++++----- src/config/parse-jsonc.test.ts | 65 ++++++++++++++++++++++++++++++++++ src/config/parse-jsonc.ts | 44 +++++++++++++++++++++++ 7 files changed, 144 insertions(+), 12 deletions(-) create mode 100644 src/config/parse-jsonc.test.ts create mode 100644 src/config/parse-jsonc.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5126857..d87333a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Unreleased +## Features +- Config: `config.json` files are now parsed as + [JSONC](https://en.wikipedia.org/wiki/JSON#JSONC), i.e. allow `// line` and + `/* block */` comments as well as trailing commas. + # 0.5.1 (2026-08-03) diff --git a/docs/Configuration.md b/docs/Configuration.md index 711126a..1161af7 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -3,6 +3,12 @@ Tuor can be configured by placing an appropriate `config.json` either in `~/.config/tuor` or in a `.tuor` directory in the current working directory or any of its parents. +Config files are read as [JSONC](https://en.wikipedia.org/wiki/JSON#JSONC) (= +regular JSON + `// line` and `/* block */` comments + trailing commas), using +the [same parser](https://github.com/microsoft/node-jsonc-parser) (by Microsoft) +that VSCode uses, too. An informal specification (not by Microsoft) can be found +at https://jsonc.org/. + ## Config inheritance Configs in child directories inherit from configs in parent directories (and so @@ -24,7 +30,7 @@ Any string value in the config (but not keys) may reference host environment variables, resolved on the host right after the config is loaded (and before it is validated): -```javascript +```jsonc { "mounts": [ // $PWD lets you mount wherever you launched Tuor from: @@ -42,7 +48,7 @@ that is not set on the host is an error. ## Example `config.json` -```javascript +```jsonc { "network": { // "open" for unrestricted access, "restricted" for allowlist diff --git a/package-lock.json b/package-lock.json index 3890abf..b697c6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "@earendil-works/gondolin": "^0.12.0", "@stricli/core": "^1.2.7", - "arktype": "^2.2.0" + "arktype": "^2.2.0", + "jsonc-parser": "^3.3.1" }, "bin": { "tuor": "dist/main.js" @@ -918,6 +919,12 @@ "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", diff --git a/package.json b/package.json index 6c0f3e0..c728d35 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "dependencies": { "@earendil-works/gondolin": "^0.12.0", "@stricli/core": "^1.2.7", - "arktype": "^2.2.0" + "arktype": "^2.2.0", + "jsonc-parser": "^3.3.1" } } diff --git a/src/config/load.ts b/src/config/load.ts index a171c50..673451f 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -5,6 +5,7 @@ import type { SessionSpec } from "../core/session.ts"; import { applyConfigDefaults, type DefaultedConfig } from "./defaults.ts"; import { interpolateVars } from "./interpolate-vars.ts"; import { findAllConfigDirs, mergeConfigs } from "./merge.ts"; +import { parseJsonc } from "./parse-jsonc.ts"; import { createSessionSpecFromConfig } from "./resolve.ts"; import { parseConfig } from "./schema.ts"; @@ -42,15 +43,18 @@ export function loadEffectiveConfig(): LoadedEffectiveConfig { // Interpolate $VAR / ${VAR} against the host env per layer (before parsing, // so interpolated values are still schema-validated and every string value // is covered). - const layers = configDirs.map((dir) => ({ - config: parseConfig( - interpolateVars( - JSON.parse(readFileSync(join(dir, "config.json"), "utf-8")), - process.env, + const layers = configDirs.map((dir) => { + const path = join(dir, "config.json"); + return { + config: parseConfig( + interpolateVars( + parseJsonc(readFileSync(path, "utf-8"), path), + process.env, + ), ), - ), - configDir: dir, - })); + configDir: dir, + }; + }); const merged = mergeConfigs(layers); const closestConfigDir = configDirs[configDirs.length - 1]!; diff --git a/src/config/parse-jsonc.test.ts b/src/config/parse-jsonc.test.ts new file mode 100644 index 0000000..463837d --- /dev/null +++ b/src/config/parse-jsonc.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "vitest"; +import { parseJsonc } from "./parse-jsonc.ts"; + +const PATH = "/project/.tuor/config.json"; + +describe("parseJsonc", () => { + test("parses plain JSON", () => { + expect(parseJsonc('{"workdir": "/workspace"}', PATH)).toEqual({ + workdir: "/workspace", + }); + }); + + test("ignores line comments", () => { + const text = [ + "{", + " // the guest working directory", + ' "workdir": "/workspace" // trailing comment', + "}", + ].join("\n"); + expect(parseJsonc(text, PATH)).toEqual({ workdir: "/workspace" }); + }); + + test("ignores block comments", () => { + const text = '{ /* a\n multi-line note */ "workdir": "/workspace" }'; + expect(parseJsonc(text, PATH)).toEqual({ workdir: "/workspace" }); + }); + + test("allows trailing commas in objects and arrays", () => { + const text = '{ "bootCommands": ["apk add ripgrep", ], }'; + expect(parseJsonc(text, PATH)).toEqual({ + bootCommands: ["apk add ripgrep"], + }); + }); + + test("does not treat a // inside a string as a comment", () => { + expect(parseJsonc('{ "url": "https://example.com" }', PATH)).toEqual({ + url: "https://example.com", + }); + }); + + test("throws on malformed input instead of returning a partial tree", () => { + // The underlying parser is error-tolerant and would otherwise hand back + // `{}` here, silently dropping the config. + expect(() => parseJsonc('{ "workdir": }', PATH)).toThrow( + /Invalid JSON in \/project\/\.tuor\/config\.json/, + ); + }); + + test("throws on an empty file", () => { + expect(() => parseJsonc("", PATH)).toThrow(/Invalid JSON/); + }); + + test("error message points at the offending line and column", () => { + const text = ["{", ' "a": 1', ' "b": 2', "}"].join("\n"); + expect(() => parseJsonc(text, PATH)).toThrow( + "Invalid JSON in /project/.tuor/config.json:3:3: CommaExpected", + ); + }); + + test("error message reports the first error on the first line", () => { + expect(() => parseJsonc("", PATH)).toThrow( + "Invalid JSON in /project/.tuor/config.json:1:1: InvalidSymbol", + ); + }); +}); diff --git a/src/config/parse-jsonc.ts b/src/config/parse-jsonc.ts new file mode 100644 index 0000000..a36c3e7 --- /dev/null +++ b/src/config/parse-jsonc.ts @@ -0,0 +1,44 @@ +import { type ParseError, parse, printParseErrorCode } from "jsonc-parser"; + +/** + * Parse `text` as JSONC and return the resulting JSON tree. Throws on malformed + * input. + * + * `sourcePath` is only used to make errors point at the offending file. + */ +export function parseJsonc(text: string, sourcePath: string): unknown { + const errors: ParseError[] = []; + const result = parse(text, errors, { allowTrailingComma: true }); + + // Report the first error only: a single typo typically cascades into a string + // of follow-up errors whose positions are past the actual mistake. + const firstError = errors[0]; + if (firstError !== undefined) { + const { line, column } = offsetToLineColumn(text, firstError.offset); + throw new Error( + `Invalid JSON in ${sourcePath}:${line}:${column}: ` + + `${printParseErrorCode(firstError.error)}`, + ); + } + + return result; +} + +// --- Internals --- + +/** Translate a character offset into 1-based line/column for error messages. */ +function offsetToLineColumn( + text: string, + offset: number, +): { line: number; column: number } { + const LINE_ENDING = "\n"; + // ^Note that for the purposes of counting lines, this covers Windows line + // endings \r\n, too. + + const upToOffset = text.slice(0, offset); + const lastNewline = upToOffset.lastIndexOf(LINE_ENDING); + return { + line: upToOffset.split(LINE_ENDING).length, + column: offset - lastNewline, + }; +}