Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
10 changes: 8 additions & 2 deletions docs/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand Down
9 changes: 8 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
20 changes: 12 additions & 8 deletions src/config/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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]!;

Expand Down
65 changes: 65 additions & 0 deletions src/config/parse-jsonc.test.ts
Original file line number Diff line number Diff line change
@@ -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("<html>", PATH)).toThrow(
"Invalid JSON in /project/.tuor/config.json:1:1: InvalidSymbol",
);
});
});
44 changes: 44 additions & 0 deletions src/config/parse-jsonc.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}