diff --git a/install.sh b/install.sh index 9703823..3fb4ed4 100755 --- a/install.sh +++ b/install.sh @@ -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]]; @@ -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); diff --git a/src/jsonc.ts b/src/jsonc.ts new file mode 100644 index 0000000..5c83d21 --- /dev/null +++ b/src/jsonc.ts @@ -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; +} diff --git a/test/jsonc.test.ts b/test/jsonc.test.ts new file mode 100644 index 0000000..b6ea4ad --- /dev/null +++ b/test/jsonc.test.ts @@ -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"]); + }); +});