feat: Add JSONC support for oh-my-opencode config files (#275)
Uses Microsoft's jsonc-parser package for reliable JSONC parsing: - oh-my-opencode.jsonc (preferred) or oh-my-opencode.json - Supports line comments (//), block comments (/* */), and trailing commas - Better error reporting with line/column positions Core changes: - Added jsonc-parser dependency (Microsoft's VS Code parser) - Shared JSONC utilities (parseJsonc, parseJsoncSafe, readJsoncFile, detectConfigFile) - Main plugin config loader uses detectConfigFile for .jsonc priority - CLI config manager supports JSONC parsing Comprehensive test suite with 18 tests for JSONC parsing. Fixes #265 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
This commit is contained in:
30
README.md
30
README.md
@@ -696,6 +696,36 @@ Schema autocomplete supported:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### JSONC Support
|
||||||
|
|
||||||
|
The `oh-my-opencode` configuration file supports JSONC (JSON with Comments):
|
||||||
|
- Line comments: `// comment`
|
||||||
|
- Block comments: `/* comment */`
|
||||||
|
- Trailing commas: `{ "key": "value", }`
|
||||||
|
|
||||||
|
When both `oh-my-opencode.jsonc` and `oh-my-opencode.json` files exist, `.jsonc` takes priority.
|
||||||
|
|
||||||
|
**Example with comments:**
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||||
|
|
||||||
|
// Enable Google Gemini via Antigravity OAuth
|
||||||
|
"google_auth": false,
|
||||||
|
|
||||||
|
/* Agent overrides - customize models for specific tasks */
|
||||||
|
"agents": {
|
||||||
|
"oracle": {
|
||||||
|
"model": "openai/gpt-5.2" // GPT for strategic reasoning
|
||||||
|
},
|
||||||
|
"explore": {
|
||||||
|
"model": "opencode/grok-code" // Free & fast for exploration
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Google Auth
|
### Google Auth
|
||||||
|
|
||||||
**Recommended**: Use the external [`opencode-antigravity-auth`](https://github.com/NoeFabris/opencode-antigravity-auth) plugin. It provides multi-account load balancing, more models (including Claude via Antigravity), and active maintenance. See [Installation > Google Gemini](#google-gemini-antigravity-oauth).
|
**Recommended**: Use the external [`opencode-antigravity-auth`](https://github.com/NoeFabris/opencode-antigravity-auth) plugin. It provides multi-account load balancing, more models (including Claude via Antigravity), and active maintenance. See [Installation > Google Gemini](#google-gemini-antigravity-oauth).
|
||||||
|
|||||||
3
bun.lock
3
bun.lock
@@ -14,6 +14,7 @@
|
|||||||
"@opencode-ai/sdk": "^1.0.162",
|
"@opencode-ai/sdk": "^1.0.162",
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.2",
|
||||||
"hono": "^4.10.4",
|
"hono": "^4.10.4",
|
||||||
|
"jsonc-parser": "^3.3.1",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"picomatch": "^4.0.2",
|
"picomatch": "^4.0.2",
|
||||||
"xdg-basedir": "^5.1.0",
|
"xdg-basedir": "^5.1.0",
|
||||||
@@ -110,6 +111,8 @@
|
|||||||
|
|
||||||
"jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
"jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||||
|
|
||||||
|
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
|
||||||
|
|
||||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
"@opencode-ai/sdk": "^1.0.162",
|
"@opencode-ai/sdk": "^1.0.162",
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.2",
|
||||||
"hono": "^4.10.4",
|
"hono": "^4.10.4",
|
||||||
|
"jsonc-parser": "^3.3.1",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"picomatch": "^4.0.2",
|
"picomatch": "^4.0.2",
|
||||||
"xdg-basedir": "^5.1.0",
|
"xdg-basedir": "^5.1.0",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||||
import { homedir } from "node:os"
|
import { homedir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
|
import { parseJsonc } from "../shared"
|
||||||
import type { ConfigMergeResult, DetectedConfig, InstallConfig } from "./types"
|
import type { ConfigMergeResult, DetectedConfig, InstallConfig } from "./types"
|
||||||
|
|
||||||
const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode")
|
const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode")
|
||||||
@@ -39,80 +40,10 @@ export function detectConfigFormat(): { format: ConfigFormat; path: string } {
|
|||||||
return { format: "none", path: OPENCODE_JSON }
|
return { format: "none", path: OPENCODE_JSON }
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripJsoncComments(content: string): string {
|
|
||||||
let result = ""
|
|
||||||
let i = 0
|
|
||||||
let inString = false
|
|
||||||
let escape = false
|
|
||||||
|
|
||||||
while (i < content.length) {
|
|
||||||
const char = content[i]
|
|
||||||
|
|
||||||
if (escape) {
|
|
||||||
result += char
|
|
||||||
escape = false
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (char === "\\") {
|
|
||||||
result += char
|
|
||||||
escape = true
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (char === '"' && !inString) {
|
|
||||||
inString = true
|
|
||||||
result += char
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (char === '"' && inString) {
|
|
||||||
inString = false
|
|
||||||
result += char
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inString) {
|
|
||||||
result += char
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Outside string - check for comments
|
|
||||||
if (char === "/" && content[i + 1] === "/") {
|
|
||||||
// Line comment - skip to end of line
|
|
||||||
while (i < content.length && content[i] !== "\n") {
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (char === "/" && content[i + 1] === "*") {
|
|
||||||
// Block comment - skip to */
|
|
||||||
i += 2
|
|
||||||
while (i < content.length - 1 && !(content[i] === "*" && content[i + 1] === "/")) {
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
i += 2
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
result += char
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.replace(/,(\s*[}\]])/g, "$1")
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseConfig(path: string, isJsonc: boolean): OpenCodeConfig | null {
|
function parseConfig(path: string, isJsonc: boolean): OpenCodeConfig | null {
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(path, "utf-8")
|
const content = readFileSync(path, "utf-8")
|
||||||
const cleaned = isJsonc ? stripJsoncComments(content) : content
|
return parseJsonc<OpenCodeConfig>(content)
|
||||||
return JSON.parse(cleaned) as OpenCodeConfig
|
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -252,8 +183,7 @@ export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult
|
|||||||
|
|
||||||
if (existsSync(OMO_CONFIG)) {
|
if (existsSync(OMO_CONFIG)) {
|
||||||
const content = readFileSync(OMO_CONFIG, "utf-8")
|
const content = readFileSync(OMO_CONFIG, "utf-8")
|
||||||
const cleaned = stripJsoncComments(content)
|
const existing = parseJsonc<Record<string, unknown>>(content)
|
||||||
const existing = JSON.parse(cleaned) as Record<string, unknown>
|
|
||||||
delete existing.agents
|
delete existing.agents
|
||||||
const merged = deepMerge(existing, newConfig)
|
const merged = deepMerge(existing, newConfig)
|
||||||
writeFileSync(OMO_CONFIG, JSON.stringify(merged, null, 2) + "\n")
|
writeFileSync(OMO_CONFIG, JSON.stringify(merged, null, 2) + "\n")
|
||||||
@@ -484,7 +414,7 @@ export function detectCurrentConfig(): DetectedConfig {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(OMO_CONFIG, "utf-8")
|
const content = readFileSync(OMO_CONFIG, "utf-8")
|
||||||
const omoConfig = JSON.parse(stripJsoncComments(content)) as OmoConfigData
|
const omoConfig = parseJsonc<OmoConfigData>(content)
|
||||||
|
|
||||||
const agents = omoConfig.agents ?? {}
|
const agents = omoConfig.agents ?? {}
|
||||||
|
|
||||||
|
|||||||
24
src/index.ts
24
src/index.ts
@@ -47,7 +47,7 @@ import { builtinTools, createCallOmoAgent, createBackgroundTools, createLookAt,
|
|||||||
import { BackgroundManager } from "./features/background-agent";
|
import { BackgroundManager } from "./features/background-agent";
|
||||||
import { createBuiltinMcps } from "./mcp";
|
import { createBuiltinMcps } from "./mcp";
|
||||||
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig, type HookName } from "./config";
|
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig, type HookName } from "./config";
|
||||||
import { log, deepMerge, getUserConfigDir, addConfigLoadError } from "./shared";
|
import { log, deepMerge, getUserConfigDir, addConfigLoadError, parseJsonc, detectConfigFile } from "./shared";
|
||||||
import { PLAN_SYSTEM_PROMPT, PLAN_PERMISSION } from "./agents/plan-prompt";
|
import { PLAN_SYSTEM_PROMPT, PLAN_PERMISSION } from "./agents/plan-prompt";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
@@ -119,7 +119,7 @@ function loadConfigFromPath(configPath: string, ctx: any): OhMyOpenCodeConfig |
|
|||||||
try {
|
try {
|
||||||
if (fs.existsSync(configPath)) {
|
if (fs.existsSync(configPath)) {
|
||||||
const content = fs.readFileSync(configPath, "utf-8");
|
const content = fs.readFileSync(configPath, "utf-8");
|
||||||
const rawConfig = JSON.parse(content);
|
const rawConfig = parseJsonc<Record<string, unknown>>(content);
|
||||||
|
|
||||||
migrateConfigFile(configPath, rawConfig);
|
migrateConfigFile(configPath, rawConfig);
|
||||||
|
|
||||||
@@ -201,19 +201,15 @@ function mergeConfigs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadPluginConfig(directory: string, ctx: any): OhMyOpenCodeConfig {
|
function loadPluginConfig(directory: string, ctx: any): OhMyOpenCodeConfig {
|
||||||
// User-level config path (OS-specific)
|
// User-level config path (OS-specific) - prefer .jsonc over .json
|
||||||
const userConfigPath = path.join(
|
const userBasePath = path.join(getUserConfigDir(), "opencode", "oh-my-opencode");
|
||||||
getUserConfigDir(),
|
const userDetected = detectConfigFile(userBasePath);
|
||||||
"opencode",
|
const userConfigPath = userDetected.format !== "none" ? userDetected.path : userBasePath + ".json";
|
||||||
"oh-my-opencode.json"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Project-level config path
|
// Project-level config path - prefer .jsonc over .json
|
||||||
const projectConfigPath = path.join(
|
const projectBasePath = path.join(directory, ".opencode", "oh-my-opencode");
|
||||||
directory,
|
const projectDetected = detectConfigFile(projectBasePath);
|
||||||
".opencode",
|
const projectConfigPath = projectDetected.format !== "none" ? projectDetected.path : projectBasePath + ".json";
|
||||||
"oh-my-opencode.json"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Load user config first (base)
|
// Load user config first (base)
|
||||||
let config: OhMyOpenCodeConfig = loadConfigFromPath(userConfigPath, ctx) ?? {};
|
let config: OhMyOpenCodeConfig = loadConfigFromPath(userConfigPath, ctx) ?? {};
|
||||||
|
|||||||
@@ -14,3 +14,4 @@ export * from "./config-path"
|
|||||||
export * from "./data-path"
|
export * from "./data-path"
|
||||||
export * from "./config-errors"
|
export * from "./config-errors"
|
||||||
export * from "./claude-config-dir"
|
export * from "./claude-config-dir"
|
||||||
|
export * from "./jsonc-parser"
|
||||||
|
|||||||
266
src/shared/jsonc-parser.test.ts
Normal file
266
src/shared/jsonc-parser.test.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { detectConfigFile, parseJsonc, parseJsoncSafe, readJsoncFile } from "./jsonc-parser"
|
||||||
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||||
|
import { join } from "node:path"
|
||||||
|
|
||||||
|
describe("parseJsonc", () => {
|
||||||
|
test("parses plain JSON", () => {
|
||||||
|
//#given
|
||||||
|
const json = `{"key": "value"}`
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = parseJsonc<{ key: string }>(json)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.key).toBe("value")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parses JSONC with line comments", () => {
|
||||||
|
//#given
|
||||||
|
const jsonc = `{
|
||||||
|
// This is a comment
|
||||||
|
"key": "value"
|
||||||
|
}`
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = parseJsonc<{ key: string }>(jsonc)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.key).toBe("value")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parses JSONC with block comments", () => {
|
||||||
|
//#given
|
||||||
|
const jsonc = `{
|
||||||
|
/* Block comment */
|
||||||
|
"key": "value"
|
||||||
|
}`
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = parseJsonc<{ key: string }>(jsonc)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.key).toBe("value")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parses JSONC with multi-line block comments", () => {
|
||||||
|
//#given
|
||||||
|
const jsonc = `{
|
||||||
|
/* Multi-line
|
||||||
|
comment
|
||||||
|
here */
|
||||||
|
"key": "value"
|
||||||
|
}`
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = parseJsonc<{ key: string }>(jsonc)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.key).toBe("value")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parses JSONC with trailing commas", () => {
|
||||||
|
//#given
|
||||||
|
const jsonc = `{
|
||||||
|
"key1": "value1",
|
||||||
|
"key2": "value2",
|
||||||
|
}`
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = parseJsonc<{ key1: string; key2: string }>(jsonc)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.key1).toBe("value1")
|
||||||
|
expect(result.key2).toBe("value2")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parses JSONC with trailing comma in array", () => {
|
||||||
|
//#given
|
||||||
|
const jsonc = `{
|
||||||
|
"arr": [1, 2, 3,]
|
||||||
|
}`
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = parseJsonc<{ arr: number[] }>(jsonc)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.arr).toEqual([1, 2, 3])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves URLs with // in strings", () => {
|
||||||
|
//#given
|
||||||
|
const jsonc = `{
|
||||||
|
"url": "https://example.com"
|
||||||
|
}`
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = parseJsonc<{ url: string }>(jsonc)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.url).toBe("https://example.com")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parses complex JSONC config", () => {
|
||||||
|
//#given
|
||||||
|
const jsonc = `{
|
||||||
|
// This is an example config
|
||||||
|
"agents": {
|
||||||
|
"oracle": { "model": "openai/gpt-5.2" }, // GPT for strategic reasoning
|
||||||
|
},
|
||||||
|
/* Agent overrides */
|
||||||
|
"disabled_agents": [],
|
||||||
|
}`
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = parseJsonc<{
|
||||||
|
agents: { oracle: { model: string } }
|
||||||
|
disabled_agents: string[]
|
||||||
|
}>(jsonc)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.agents.oracle.model).toBe("openai/gpt-5.2")
|
||||||
|
expect(result.disabled_agents).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("throws on invalid JSON", () => {
|
||||||
|
//#given
|
||||||
|
const invalid = `{ "key": invalid }`
|
||||||
|
|
||||||
|
//#when
|
||||||
|
//#then
|
||||||
|
expect(() => parseJsonc(invalid)).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("throws on unclosed string", () => {
|
||||||
|
//#given
|
||||||
|
const invalid = `{ "key": "unclosed }`
|
||||||
|
|
||||||
|
//#when
|
||||||
|
//#then
|
||||||
|
expect(() => parseJsonc(invalid)).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("parseJsoncSafe", () => {
|
||||||
|
test("returns data on valid JSONC", () => {
|
||||||
|
//#given
|
||||||
|
const jsonc = `{ "key": "value" }`
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = parseJsoncSafe<{ key: string }>(jsonc)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.data).not.toBeNull()
|
||||||
|
expect(result.data?.key).toBe("value")
|
||||||
|
expect(result.errors).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns errors on invalid JSONC", () => {
|
||||||
|
//#given
|
||||||
|
const invalid = `{ "key": invalid }`
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = parseJsoncSafe(invalid)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.data).toBeNull()
|
||||||
|
expect(result.errors.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("readJsoncFile", () => {
|
||||||
|
const testDir = join(__dirname, ".test-jsonc")
|
||||||
|
const testFile = join(testDir, "config.jsonc")
|
||||||
|
|
||||||
|
test("reads and parses valid JSONC file", () => {
|
||||||
|
//#given
|
||||||
|
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
|
||||||
|
const content = `{
|
||||||
|
// Comment
|
||||||
|
"test": "value"
|
||||||
|
}`
|
||||||
|
writeFileSync(testFile, content)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = readJsoncFile<{ test: string }>(testFile)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).not.toBeNull()
|
||||||
|
expect(result?.test).toBe("value")
|
||||||
|
|
||||||
|
rmSync(testDir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns null for non-existent file", () => {
|
||||||
|
//#given
|
||||||
|
const nonExistent = join(testDir, "does-not-exist.jsonc")
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = readJsoncFile(nonExistent)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns null for malformed JSON", () => {
|
||||||
|
//#given
|
||||||
|
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
|
||||||
|
writeFileSync(testFile, "{ invalid }")
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = readJsoncFile(testFile)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBeNull()
|
||||||
|
|
||||||
|
rmSync(testDir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("detectConfigFile", () => {
|
||||||
|
const testDir = join(__dirname, ".test-detect")
|
||||||
|
|
||||||
|
test("prefers .jsonc over .json", () => {
|
||||||
|
//#given
|
||||||
|
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
|
||||||
|
const basePath = join(testDir, "config")
|
||||||
|
writeFileSync(`${basePath}.json`, "{}")
|
||||||
|
writeFileSync(`${basePath}.jsonc`, "{}")
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = detectConfigFile(basePath)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.format).toBe("jsonc")
|
||||||
|
expect(result.path).toBe(`${basePath}.jsonc`)
|
||||||
|
|
||||||
|
rmSync(testDir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("detects .json when .jsonc doesn't exist", () => {
|
||||||
|
//#given
|
||||||
|
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
|
||||||
|
const basePath = join(testDir, "config")
|
||||||
|
writeFileSync(`${basePath}.json`, "{}")
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = detectConfigFile(basePath)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.format).toBe("json")
|
||||||
|
expect(result.path).toBe(`${basePath}.json`)
|
||||||
|
|
||||||
|
rmSync(testDir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns none when neither exists", () => {
|
||||||
|
//#given
|
||||||
|
const basePath = join(testDir, "nonexistent")
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = detectConfigFile(basePath)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.format).toBe("none")
|
||||||
|
})
|
||||||
|
})
|
||||||
66
src/shared/jsonc-parser.ts
Normal file
66
src/shared/jsonc-parser.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { existsSync, readFileSync } from "node:fs"
|
||||||
|
import { parse, ParseError, printParseErrorCode } from "jsonc-parser"
|
||||||
|
|
||||||
|
export interface JsoncParseResult<T> {
|
||||||
|
data: T | null
|
||||||
|
errors: Array<{ message: string; offset: number; length: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseJsonc<T = unknown>(content: string): T {
|
||||||
|
const errors: ParseError[] = []
|
||||||
|
const result = parse(content, errors, {
|
||||||
|
allowTrailingComma: true,
|
||||||
|
disallowComments: false,
|
||||||
|
}) as T
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
const errorMessages = errors
|
||||||
|
.map((e) => `${printParseErrorCode(e.error)} at offset ${e.offset}`)
|
||||||
|
.join(", ")
|
||||||
|
throw new SyntaxError(`JSONC parse error: ${errorMessages}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseJsoncSafe<T = unknown>(content: string): JsoncParseResult<T> {
|
||||||
|
const errors: ParseError[] = []
|
||||||
|
const data = parse(content, errors, {
|
||||||
|
allowTrailingComma: true,
|
||||||
|
disallowComments: false,
|
||||||
|
}) as T | null
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: errors.length > 0 ? null : data,
|
||||||
|
errors: errors.map((e) => ({
|
||||||
|
message: printParseErrorCode(e.error),
|
||||||
|
offset: e.offset,
|
||||||
|
length: e.length,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readJsoncFile<T = unknown>(filePath: string): T | null {
|
||||||
|
try {
|
||||||
|
const content = readFileSync(filePath, "utf-8")
|
||||||
|
return parseJsonc<T>(content)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectConfigFile(basePath: string): {
|
||||||
|
format: "json" | "jsonc" | "none"
|
||||||
|
path: string
|
||||||
|
} {
|
||||||
|
const jsoncPath = `${basePath}.jsonc`
|
||||||
|
const jsonPath = `${basePath}.json`
|
||||||
|
|
||||||
|
if (existsSync(jsoncPath)) {
|
||||||
|
return { format: "jsonc", path: jsoncPath }
|
||||||
|
}
|
||||||
|
if (existsSync(jsonPath)) {
|
||||||
|
return { format: "json", path: jsonPath }
|
||||||
|
}
|
||||||
|
return { format: "none", path: jsonPath }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user