Skip to content

Commit 01bb54a

Browse files
authored
refactor: split config parsing steps (#22996)
1 parent f592c38 commit 01bb54a

5 files changed

Lines changed: 106 additions & 131 deletions

File tree

packages/opencode/src/cli/cmd/tui/config/tui.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { ConfigKeybinds } from "@/config/keybinds"
1818
import { InstallationLocal, InstallationVersion } from "@/installation/version"
1919
import { makeRuntime } from "@/cli/effect/runtime"
2020
import { Filesystem, Log } from "@/util"
21+
import { ConfigVariable } from "@/config/variable"
2122

2223
const log = Log.create({ service: "tui.config" })
2324

@@ -197,18 +198,15 @@ async function loadFile(filepath: string): Promise<Info> {
197198
}
198199

199200
async function load(text: string, configFilepath: string): Promise<Info> {
200-
return ConfigParse.load(Info, text, {
201-
type: "path",
202-
path: configFilepath,
203-
missing: "empty",
204-
normalize: (data) => {
201+
return ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" })
202+
.then((expanded) => ConfigParse.jsonc(expanded, configFilepath))
203+
.then((data) => {
205204
if (!isRecord(data)) return {}
206205

207206
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
208207
// (mirroring the old opencode.json shape) still get their settings applied.
209-
return normalize(data)
210-
},
211-
})
208+
return ConfigParse.schema(Info, normalize(data), configFilepath)
209+
})
212210
.then((data) => resolvePlugins(data, configFilepath))
213211
.catch((error) => {
214212
log.warn("invalid tui config", { path: configFilepath, error })

packages/opencode/src/config/config.ts

Lines changed: 14 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { ConfigSkills } from "./skills"
3838
import { ConfigPaths } from "./paths"
3939
import { ConfigFormatter } from "./formatter"
4040
import { ConfigLSP } from "./lsp"
41+
import { ConfigVariable } from "./variable"
4142

4243
const log = Log.create({ service: "config" })
4344

@@ -327,24 +328,16 @@ export const layer = Layer.effect(
327328
text: string,
328329
options: { path: string } | { dir: string; source: string },
329330
) {
330-
if (!("path" in options)) {
331-
return yield* Effect.promise(() =>
332-
ConfigParse.load(Info, text, {
333-
type: "virtual",
334-
dir: options.dir,
335-
source: options.source,
336-
normalize: normalizeLoadedConfig,
337-
}),
338-
)
339-
}
340-
341-
const data = yield* Effect.promise(() =>
342-
ConfigParse.load(Info, text, {
343-
type: "path",
344-
path: options.path,
345-
normalize: normalizeLoadedConfig,
346-
}),
331+
const source = "path" in options ? options.path : options.source
332+
const expanded = yield* Effect.promise(() =>
333+
ConfigVariable.substitute(
334+
"path" in options ? { text, type: "path", path: options.path } : { text, type: "virtual", ...options },
335+
),
347336
)
337+
const parsed = ConfigParse.jsonc(expanded, source)
338+
const data = ConfigParse.schema(Info, normalizeLoadedConfig(parsed, source), source)
339+
if (!("path" in options)) return data
340+
348341
yield* Effect.promise(() => resolveLoadedPlugins(data, options.path))
349342
if (!data.$schema) {
350343
data.$schema = "https://opencode.ai/config.json"
@@ -725,17 +718,16 @@ export const layer = Layer.effect(
725718
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
726719
const file = globalConfigFile()
727720
const before = (yield* readConfigFile(file)) ?? "{}"
728-
const input = writable(config)
729721

730722
let next: Info
731723
if (!file.endsWith(".jsonc")) {
732-
const existing = ConfigParse.parse(Info, before, file)
733-
const merged = mergeDeep(writable(existing), input)
724+
const existing = ConfigParse.schema(Info, ConfigParse.jsonc(before, file), file)
725+
const merged = mergeDeep(writable(existing), writable(config))
734726
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
735727
next = merged
736728
} else {
737-
const updated = patchJsonc(before, input)
738-
next = ConfigParse.parse(Info, updated, file)
729+
const updated = patchJsonc(before, writable(config))
730+
next = ConfigParse.schema(Info, ConfigParse.jsonc(updated, file), file)
739731
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
740732
}
741733

Lines changed: 23 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,44 @@
11
export * as ConfigParse from "./parse"
22

3-
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
3+
import { type ParseError as JsoncParseError, parse as parseJsoncImpl, printParseErrorCode } from "jsonc-parser"
44
import z from "zod"
5-
import { ConfigVariable } from "./variable"
65
import { InvalidError, JsonError } from "./error"
76

87
type Schema<T> = z.ZodType<T>
9-
type VariableMode = "error" | "empty"
108

11-
export type LoadOptions =
12-
| {
13-
type: "path"
14-
path: string
15-
missing?: VariableMode
16-
normalize?: (data: unknown, source: string) => unknown
17-
}
18-
| {
19-
type: "virtual"
20-
dir: string
21-
source: string
22-
missing?: VariableMode
23-
normalize?: (data: unknown, source: string) => unknown
24-
}
25-
26-
function issues(text: string, errors: JsoncParseError[]) {
27-
const lines = text.split("\n")
28-
return errors
29-
.map((e) => {
30-
const beforeOffset = text.substring(0, e.offset).split("\n")
31-
const line = beforeOffset.length
32-
const column = beforeOffset[beforeOffset.length - 1].length + 1
33-
const problemLine = lines[line - 1]
34-
35-
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
36-
if (!problemLine) return error
37-
38-
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
39-
})
40-
.join("\n")
41-
}
42-
43-
export function parse<T>(schema: Schema<T>, text: string, filepath: string): T {
9+
export function jsonc(text: string, filepath: string): unknown {
4410
const errors: JsoncParseError[] = []
45-
const data = parseJsonc(text, errors, { allowTrailingComma: true })
11+
const data = parseJsoncImpl(text, errors, { allowTrailingComma: true })
4612
if (errors.length) {
13+
const lines = text.split("\n")
14+
const issues = errors
15+
.map((e) => {
16+
const beforeOffset = text.substring(0, e.offset).split("\n")
17+
const line = beforeOffset.length
18+
const column = beforeOffset[beforeOffset.length - 1].length + 1
19+
const problemLine = lines[line - 1]
20+
21+
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
22+
if (!problemLine) return error
23+
24+
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
25+
})
26+
.join("\n")
4727
throw new JsonError({
4828
path: filepath,
49-
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${issues(text, errors)}\n--- End ---`,
29+
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${issues}\n--- End ---`,
5030
})
5131
}
5232

33+
return data
34+
}
35+
36+
export function schema<T>(schema: Schema<T>, data: unknown, source: string): T {
5337
const parsed = schema.safeParse(data)
5438
if (parsed.success) return parsed.data
5539

5640
throw new InvalidError({
57-
path: filepath,
41+
path: source,
5842
issues: parsed.error.issues,
5943
})
6044
}
61-
62-
export async function load<T>(schema: Schema<T>, text: string, options: LoadOptions): Promise<T> {
63-
const source = options.type === "path" ? options.path : options.source
64-
const expanded = await ConfigVariable.substitute(
65-
text,
66-
options.type === "path" ? { type: "path", path: options.path } : options,
67-
options.missing,
68-
)
69-
const data = parse(z.unknown(), expanded, source)
70-
const normalized = options.normalize ? options.normalize(data, source) : data
71-
const parsed = schema.safeParse(normalized)
72-
if (!parsed.success) {
73-
throw new InvalidError({
74-
path: source,
75-
issues: parsed.error.issues,
76-
})
77-
}
78-
79-
return parsed.data
80-
}

packages/opencode/src/config/variable.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ type ParseSource =
1616
dir: string
1717
}
1818

19+
type SubstituteInput = ParseSource & {
20+
text: string
21+
missing?: "error" | "empty"
22+
}
23+
1924
function source(input: ParseSource) {
2025
return input.type === "path" ? input.path : input.source
2126
}
@@ -25,8 +30,9 @@ function dir(input: ParseSource) {
2530
}
2631

2732
/** Apply {env:VAR} and {file:path} substitutions to config text. */
28-
export async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
29-
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
33+
export async function substitute(input: SubstituteInput) {
34+
const missing = input.missing ?? "error"
35+
let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
3036
return process.env[varName] || ""
3137
})
3238

packages/opencode/test/config/config.test.ts

Lines changed: 55 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2213,19 +2213,22 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
22132213
// parseManagedPlist unit tests — pure function, no OS interaction
22142214

22152215
test("parseManagedPlist strips MDM metadata keys", async () => {
2216-
const config = ConfigParse.parse(
2216+
const config = ConfigParse.schema(
22172217
Config.Info,
2218-
await ConfigManaged.parseManagedPlist(
2219-
JSON.stringify({
2220-
PayloadDisplayName: "OpenCode Managed",
2221-
PayloadIdentifier: "ai.opencode.managed.test",
2222-
PayloadType: "ai.opencode.managed",
2223-
PayloadUUID: "AAAA-BBBB-CCCC",
2224-
PayloadVersion: 1,
2225-
_manualProfile: true,
2226-
share: "disabled",
2227-
model: "mdm/model",
2228-
}),
2218+
ConfigParse.jsonc(
2219+
await ConfigManaged.parseManagedPlist(
2220+
JSON.stringify({
2221+
PayloadDisplayName: "OpenCode Managed",
2222+
PayloadIdentifier: "ai.opencode.managed.test",
2223+
PayloadType: "ai.opencode.managed",
2224+
PayloadUUID: "AAAA-BBBB-CCCC",
2225+
PayloadVersion: 1,
2226+
_manualProfile: true,
2227+
share: "disabled",
2228+
model: "mdm/model",
2229+
}),
2230+
),
2231+
"test:mobileconfig",
22292232
),
22302233
"test:mobileconfig",
22312234
)
@@ -2238,14 +2241,17 @@ test("parseManagedPlist strips MDM metadata keys", async () => {
22382241
})
22392242

22402243
test("parseManagedPlist parses server settings", async () => {
2241-
const config = ConfigParse.parse(
2244+
const config = ConfigParse.schema(
22422245
Config.Info,
2243-
await ConfigManaged.parseManagedPlist(
2244-
JSON.stringify({
2245-
$schema: "https://opencode.ai/config.json",
2246-
server: { hostname: "127.0.0.1", mdns: false },
2247-
autoupdate: true,
2248-
}),
2246+
ConfigParse.jsonc(
2247+
await ConfigManaged.parseManagedPlist(
2248+
JSON.stringify({
2249+
$schema: "https://opencode.ai/config.json",
2250+
server: { hostname: "127.0.0.1", mdns: false },
2251+
autoupdate: true,
2252+
}),
2253+
),
2254+
"test:mobileconfig",
22492255
),
22502256
"test:mobileconfig",
22512257
)
@@ -2255,20 +2261,23 @@ test("parseManagedPlist parses server settings", async () => {
22552261
})
22562262

22572263
test("parseManagedPlist parses permission rules", async () => {
2258-
const config = ConfigParse.parse(
2264+
const config = ConfigParse.schema(
22592265
Config.Info,
2260-
await ConfigManaged.parseManagedPlist(
2261-
JSON.stringify({
2262-
$schema: "https://opencode.ai/config.json",
2263-
permission: {
2264-
"*": "ask",
2265-
bash: { "*": "ask", "rm -rf *": "deny", "curl *": "deny" },
2266-
grep: "allow",
2267-
glob: "allow",
2268-
webfetch: "ask",
2269-
"~/.ssh/*": "deny",
2270-
},
2271-
}),
2266+
ConfigParse.jsonc(
2267+
await ConfigManaged.parseManagedPlist(
2268+
JSON.stringify({
2269+
$schema: "https://opencode.ai/config.json",
2270+
permission: {
2271+
"*": "ask",
2272+
bash: { "*": "ask", "rm -rf *": "deny", "curl *": "deny" },
2273+
grep: "allow",
2274+
glob: "allow",
2275+
webfetch: "ask",
2276+
"~/.ssh/*": "deny",
2277+
},
2278+
}),
2279+
),
2280+
"test:mobileconfig",
22722281
),
22732282
"test:mobileconfig",
22742283
)
@@ -2282,23 +2291,29 @@ test("parseManagedPlist parses permission rules", async () => {
22822291
})
22832292

22842293
test("parseManagedPlist parses enabled_providers", async () => {
2285-
const config = ConfigParse.parse(
2294+
const config = ConfigParse.schema(
22862295
Config.Info,
2287-
await ConfigManaged.parseManagedPlist(
2288-
JSON.stringify({
2289-
$schema: "https://opencode.ai/config.json",
2290-
enabled_providers: ["anthropic", "google"],
2291-
}),
2296+
ConfigParse.jsonc(
2297+
await ConfigManaged.parseManagedPlist(
2298+
JSON.stringify({
2299+
$schema: "https://opencode.ai/config.json",
2300+
enabled_providers: ["anthropic", "google"],
2301+
}),
2302+
),
2303+
"test:mobileconfig",
22922304
),
22932305
"test:mobileconfig",
22942306
)
22952307
expect(config.enabled_providers).toEqual(["anthropic", "google"])
22962308
})
22972309

22982310
test("parseManagedPlist handles empty config", async () => {
2299-
const config = ConfigParse.parse(
2311+
const config = ConfigParse.schema(
23002312
Config.Info,
2301-
await ConfigManaged.parseManagedPlist(JSON.stringify({ $schema: "https://opencode.ai/config.json" })),
2313+
ConfigParse.jsonc(
2314+
await ConfigManaged.parseManagedPlist(JSON.stringify({ $schema: "https://opencode.ai/config.json" })),
2315+
"test:mobileconfig",
2316+
),
23022317
"test:mobileconfig",
23032318
)
23042319
expect(config.$schema).toBe("https://opencode.ai/config.json")

0 commit comments

Comments
 (0)