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
27 changes: 26 additions & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ UPDATED=""
# for plain .json when node is unavailable (jq cannot parse JSONC comments).
if command -v node >/dev/null 2>&1; then
# shellcheck disable=SC2016 # single-quoted block is JS source, not shell
# NOTE: scan() and removeTrailingCommas() below are plain-JS copies of the
# functions in src/jsonc.ts. Keep the two in sync when making changes.
UPDATED="$(node -e '
const fs = require("fs");
const [p, spec, name] = [process.argv[1], process.argv[2], process.argv[3]];
Expand Down Expand Up @@ -200,9 +202,32 @@ if command -v node >/dev/null 2>&1; then
return ind;
}

// Remove trailing commas from comment-stripped JSONC so JSON.parse accepts it.
// Tracks string context to avoid touching commas inside string values.
function removeTrailingCommas(s) {
let result = "", inStr = false, i = 0;
while (i < s.length) {
const ch = s[i];
if (inStr) {
result += ch;
if (ch === "\\") { result += (s[i + 1] || ""); i += 2; continue; }
if (ch === "\"") inStr = false;
i++; continue;
}
if (ch === "\"") { inStr = true; result += ch; i++; continue; }
if (ch === ",") {
let j = i + 1;
while (j < s.length && (s[j] === " " || s[j] === "\t" || s[j] === "\n" || s[j] === "\r")) j++;
if (j < s.length && (s[j] === "}" || s[j] === "]")) { i++; continue; }
}
result += ch; i++;
}
return result;
}

const { out: stripped, map } = scan(raw);
let parsed;
try { parsed = JSON.parse(stripped); }
try { parsed = JSON.parse(removeTrailingCommas(stripped)); }
catch (e) { console.error("Failed to parse " + p + ": " + e.message); process.exit(1); }
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
console.error("Expected a JSON object in " + p); process.exit(1);
Expand Down
117 changes: 117 additions & 0 deletions src/jsonc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* JSONC (JSON with Comments) helpers.
*
* These functions are also inlined verbatim (as plain JS) into install.sh so
* that the installer remains a self-contained bash script with no runtime
* dependencies beyond Node. Keep the two copies in sync when changing this
* file.
*
* Tests live in test/jsonc.test.ts.
*/

/** Result of scanning a JSONC string: comment-stripped text plus index map. */
export interface ScanResult {
/** Comment-stripped output (string literals preserved, comments removed). */
out: string;
/** map[i] is the index in the original raw string that produced out[i]. */
map: number[];
}

/**
* Strip line comments and block comments from a JSONC string while preserving
* string literals verbatim. Returns the stripped text and a map from
* stripped-index back to raw-index so callers can locate and edit the original
* text in place.
*/
export function scan(s: string): ScanResult {
let out = "",
map: number[] = [],
inStr = false,
i = 0;
while (i < s.length) {
const ch = s[i],
nx = s[i + 1];
if (inStr) {
out += ch;
map.push(i);
if (ch === "\\") {
out += s[i + 1] || "";
map.push(i + 1);
i += 2;
continue;
}
if (ch === '"') inStr = false;
i++;
continue;
}
if (ch === '"') {
inStr = true;
out += ch;
map.push(i);
i++;
continue;
}
if (ch === "/" && nx === "/") {
while (i < s.length && s[i] !== "\n") i++;
continue;
}
if (ch === "/" && nx === "*") {
i += 2;
while (i < s.length && !(s[i] === "*" && s[i + 1] === "/")) i++;
i += 2;
continue;
}
out += ch;
map.push(i);
i++;
}
return { out, map };
}

/**
* Remove trailing commas from a comment-stripped JSONC string so that
* `JSON.parse` accepts it. Tracks string context to avoid removing commas
* that are part of a string value.
*
* A trailing comma is a `,` whose next non-whitespace character is `}` or `]`.
*/
export function removeTrailingCommas(s: string): string {
let result = "",
inStr = false,
i = 0;
while (i < s.length) {
const ch = s[i];
if (inStr) {
result += ch;
if (ch === "\\") {
result += s[i + 1] || "";
i += 2;
continue;
}
if (ch === '"') inStr = false;
i++;
continue;
}
if (ch === '"') {
inStr = true;
result += ch;
i++;
continue;
}
if (ch === ",") {
let j = i + 1;
while (
j < s.length &&
(s[j] === " " || s[j] === "\t" || s[j] === "\n" || s[j] === "\r")
)
j++;
if (j < s.length && (s[j] === "}" || s[j] === "]")) {
i++;
continue;
}
}
result += ch;
i++;
}
return result;
}
232 changes: 232 additions & 0 deletions test/jsonc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import { describe, expect, it } from "vitest";
import { removeTrailingCommas, scan } from "../src/jsonc.js";

// ---------------------------------------------------------------------------
// removeTrailingCommas
// ---------------------------------------------------------------------------

describe("removeTrailingCommas", () => {
// --- basic cases -----------------------------------------------------------

it("removes a trailing comma from a flat array", () => {
const result = removeTrailingCommas('["a", "b", "c",]');
expect(JSON.parse(result)).toEqual(["a", "b", "c"]);
});

it("removes a trailing comma from a flat object", () => {
const result = removeTrailingCommas('{"x": 1, "y": 2,}');
expect(JSON.parse(result)).toEqual({ x: 1, y: 2 });
});

it("removes trailing commas from both arrays and objects in one pass", () => {
const input = '{"arr": [1, 2, 3,], "obj": {"a": true,},}';
const result = removeTrailingCommas(input);
expect(JSON.parse(result)).toEqual({ arr: [1, 2, 3], obj: { a: true } });
});

it("handles trailing comma with spaces before the closing bracket", () => {
expect(JSON.parse(removeTrailingCommas('["a", "b", ]'))).toEqual([
"a",
"b",
]);
});

it("handles trailing comma with a newline before the closing bracket", () => {
const input = `{
"key": "value",
}`;
expect(JSON.parse(removeTrailingCommas(input))).toEqual({ key: "value" });
});

it("handles trailing comma with mixed whitespace before the closing bracket", () => {
const input = '["x",\t\n ]';
expect(JSON.parse(removeTrailingCommas(input))).toEqual(["x"]);
});

// --- already-valid JSON must pass through unchanged -----------------------

it("leaves already-valid JSON untouched", () => {
const valid = '{"plugin": ["@stablekernel/opencode-cursor@latest"]}';
expect(removeTrailingCommas(valid)).toBe(valid);
});

it("leaves an empty array untouched", () => {
expect(removeTrailingCommas("[]")).toBe("[]");
});

it("leaves an empty object untouched", () => {
expect(removeTrailingCommas("{}")).toBe("{}");
});

it("leaves a nested structure with no trailing commas untouched", () => {
const valid = '{"a": [1, 2], "b": {"c": null}}';
expect(removeTrailingCommas(valid)).toBe(valid);
});

// --- string values that contain comma + bracket must NOT be changed -------

it("does not remove a comma that is inside a string value", () => {
// The comma here is part of the string literal, not a trailing comma.
const input = '{"key": "trailing,"}';
expect(JSON.parse(removeTrailingCommas(input))).toEqual({
key: "trailing,",
});
// The raw text must be identical — nothing was removed.
expect(removeTrailingCommas(input)).toBe(input);
});

it("does not remove a comma followed by ] that is inside a string", () => {
// ",]" appears inside the string — must be preserved.
const input = '{"key": ",]"}';
expect(JSON.parse(removeTrailingCommas(input))).toEqual({ key: ",]" });
expect(removeTrailingCommas(input)).toBe(input);
});

it("does not remove a comma followed by } that is inside a string", () => {
const input = '{"key": ",}"}';
expect(JSON.parse(removeTrailingCommas(input))).toEqual({ key: ",}" });
expect(removeTrailingCommas(input)).toBe(input);
});

it("handles an escaped quote inside a string without confusing string-context tracking", () => {
// The \" inside the string must not end string context prematurely.
const input = '{"msg": "say \\"hi,\\"",}';
const result = removeTrailingCommas(input);
expect(JSON.parse(result)).toEqual({ msg: 'say "hi,"' });
});

// --- deeply nested --------------------------------------------------------

it("removes trailing commas at every nesting level", () => {
const input = `{
"models": [
"cursor/gpt-4o",
"cursor/claude-3-5-sonnet",
],
"settings": {
"theme": "dark",
"fontSize": 14,
},
}`;
const parsed = JSON.parse(removeTrailingCommas(input));
expect(parsed).toEqual({
models: ["cursor/gpt-4o", "cursor/claude-3-5-sonnet"],
settings: { theme: "dark", fontSize: 14 },
});
});
});

// ---------------------------------------------------------------------------
// scan
// ---------------------------------------------------------------------------

describe("scan", () => {
it("passes plain JSON through unchanged", () => {
const s = '{"a": 1}';
expect(scan(s).out).toBe(s);
});

it("removes a single-line comment", () => {
const s = '{"a": 1} // comment';
expect(scan(s).out).toBe('{"a": 1} ');
});

it("removes a single-line comment on its own line", () => {
const s = '// top-level comment\n{"a": 1}';
expect(scan(s).out).toBe('\n{"a": 1}');
});

it("removes a block comment", () => {
const s = '{"a": /* inline */ 1}';
expect(scan(s).out).toBe('{"a": 1}');
});

it("preserves // inside a string literal", () => {
const s = '{"url": "https://example.com"}';
expect(scan(s).out).toBe(s);
});

it("preserves /* */ inside a string literal", () => {
const s = '{"k": "/* not a comment */"}';
expect(scan(s).out).toBe(s);
});

it("strips a multi-line block comment spanning multiple lines", () => {
const s = `{
/* this is a
block comment */
"a": 1
}`;
const stripped = scan(s).out;
expect(JSON.parse(stripped)).toEqual({ a: 1 });
});

it("produces a map with the same length as the output", () => {
const s = '{"a": 1} // tail';
const { out, map } = scan(s);
expect(map).toHaveLength(out.length);
});

it("map entries point to the correct raw indices", () => {
// After stripping "// tail", the last non-whitespace output char is '}'.
// In the raw string that's index 7.
const s = '{"a": 1} // tail';
const { out, map } = scan(s);
const closingBrace = out.indexOf("}");
const rawIdx = map[closingBrace]!;
expect(s[rawIdx]).toBe("}");
});
});

// ---------------------------------------------------------------------------
// Full pipeline: scan → removeTrailingCommas → JSON.parse
// This mirrors what install.sh does when processing a real opencode.jsonc.
// ---------------------------------------------------------------------------

describe("scan + removeTrailingCommas pipeline", () => {
it("parses a realistic opencode.jsonc with comments and trailing commas", () => {
const jsonc = `{
// opencode configuration
"$schema": "https://opencode.ai/config.json",
"plugin": [
// cursor provider plugin
"@stablekernel/opencode-cursor@latest",
],
"model": "cursor/claude-3-5-sonnet", /* default model */
}`;
const { out } = scan(jsonc);
const parsed = JSON.parse(removeTrailingCommas(out));
expect(parsed).toEqual({
$schema: "https://opencode.ai/config.json",
plugin: ["@stablekernel/opencode-cursor@latest"],
model: "cursor/claude-3-5-sonnet",
});
});

it("parses JSONC where a string value contains comment-like text", () => {
const jsonc = `{
"note": "see https://example.com/docs /* not a comment */",
"value": 42, // trailing comma here
}`;
const { out } = scan(jsonc);
const parsed = JSON.parse(removeTrailingCommas(out));
expect(parsed).toEqual({
note: "see https://example.com/docs /* not a comment */",
value: 42,
});
});

it("reproduces the exact error case from the bug report: trailing comma after last array element", () => {
// Simulates the auto-generated opencode.jsonc that caused the install failure.
const jsonc = `{
"$schema": "https://opencode.ai/config.json",
"plugin": [
"atest",
],
"agen": {}
}`;
const { out } = scan(jsonc);
const parsed = JSON.parse(removeTrailingCommas(out));
expect(parsed.plugin).toEqual(["atest"]);
});
});