History

Use jsonc-parser for local config

Rhys Sullivan committed 8 months ago

Commit 4ecb3081 parent 16327d74 files · +131 −64Browse files

Unified · Split

4 files
  1. bun.lock +5 −4
  2. packages/control-plane/package.json +1 −0
  3. packages/control-plane/src/runtime/local-config.test.ts +96 −0
  4. packages/control-plane/src/runtime/local-config.ts +29 −60
bun.lock+5−4
@@ -10,6 +10,9 @@ · 9 unmodified lines
1010 "typescript": "^5.9.3",
1111 },
1212 },
13+ "apps/docs": {
14+ "name": "@executor/docs",
15+ },
1316 "apps/executor": {
1417 "name": "executor",
1518 "version": "1.1.9",
@@ -79,9 +82,6 @@ · 63 unmodified lines
7982 "vite": "^7.3.1",
8083 },
8184 },
82− "docs": {
83− "name": "@executor/docs",
84− },
8585 "packages/ai-sdk-adapter": {
8686 "name": "@executor/ai-sdk-adapter",
8787 "dependencies": {
@@ -171,6 +171,7 @@ · 83 unmodified lines
171171 "drizzle-orm": "1.0.0-beta.15-859cf75",
172172 "effect": "catalog:",
173173 "graphql": "^16.12.0",
174+ "jsonc-parser": "3.3.1",
174175 "postgres": "^3.4.7",
175176 "prettier": "^3.8.1",
176177 },
@@ -496,7 +497,7 @@ · 319 unmodified lines
496497
497498 "@executor/control-plane": ["@executor/control-plane@workspace:packages/control-plane"],
498499
499− "@executor/docs": ["@executor/docs@workspace:docs"],
500+ "@executor/docs": ["@executor/docs@workspace:apps/docs"],
500501
501502 "@executor/executor-mcp": ["@executor/executor-mcp@workspace:packages/executor-mcp"],
502503
packages/control-plane/package.json+1−0
@@ -34,6 +34,7 @@ · 33 unmodified lines
3434 "drizzle-orm": "1.0.0-beta.15-859cf75",
3535 "effect": "catalog:",
3636 "graphql": "^16.12.0",
37+ "jsonc-parser": "3.3.1",
3738 "postgres": "^3.4.7",
3839 "prettier": "^3.8.1"
3940 },
packages/control-plane/src/runtime/local-config.test.ts+96−0
@@ -0,0 +1,96 @@
1+import { mkdtempSync } from "node:fs";
2+import { mkdir, writeFile } from "node:fs/promises";
3+import { tmpdir } from "node:os";
4+import { join } from "node:path";
5+import { describe, expect, it } from "@effect/vitest";
6+import * as Effect from "effect/Effect";
7+
8+import {
9+ loadLocalExecutorConfig,
10+ resolveLocalWorkspaceContext,
11+} from "./local-config";
12+
13+const makeWorkspaceRoot = () =>
14+ mkdtempSync(join(tmpdir(), "executor-local-config-"));
15+
16+describe("local-config", () => {
17+ it.effect("parses jsonc project config with comments and trailing commas", () =>
18+ Effect.gen(function* () {
19+ const workspaceRoot = makeWorkspaceRoot();
20+ const configDirectory = join(workspaceRoot, ".executor");
21+ yield* Effect.promise(() => mkdir(configDirectory, { recursive: true }));
22+ yield* Effect.promise(() =>
23+ writeFile(
24+ join(configDirectory, "executor.jsonc"),
25+ `{
26+ // local workspace config
27+ "sources": {
28+ "github": {
29+ "kind": "openapi",
30+ "connection": {
31+ "endpoint": "https://api.github.com",
32+ },
33+ "binding": {
34+ "specUrl": "https://example.com/openapi.json",
35+ },
36+ },
37+ },
38+}
39+`,
40+ "utf8",
41+ ),
42+ );
43+
44+ const context = yield* Effect.promise(() =>
45+ resolveLocalWorkspaceContext({ workspaceRoot }),
46+ );
47+ const loaded = yield* Effect.promise(() =>
48+ loadLocalExecutorConfig(context),
49+ );
50+
51+ expect(loaded.config?.sources?.github?.kind).toBe("openapi");
52+ expect(loaded.config?.sources?.github?.connection.endpoint).toBe(
53+ "https://api.github.com",
54+ );
55+ }),
56+ );
57+
58+ it.effect("reports jsonc syntax errors with line and column details", () =>
59+ Effect.gen(function* () {
60+ const workspaceRoot = makeWorkspaceRoot();
61+ const configDirectory = join(workspaceRoot, ".executor");
62+ yield* Effect.promise(() => mkdir(configDirectory, { recursive: true }));
63+ yield* Effect.promise(() =>
64+ writeFile(
65+ join(configDirectory, "executor.jsonc"),
66+ `{
67+ "sources": {
68+ "github": {
69+ "kind": "openapi"
70+ "connection": {
71+ "endpoint": "https://api.github.com"
72+ }
73+ }
74+ }
75+}
76+`,
77+ "utf8",
78+ ),
79+ );
80+
81+ const context = yield* Effect.promise(() =>
82+ resolveLocalWorkspaceContext({ workspaceRoot }),
83+ );
84+ const failure = yield* Effect.flip(
85+ Effect.tryPromise({
86+ try: () => loadLocalExecutorConfig(context),
87+ catch: (cause) =>
88+ cause instanceof Error ? cause : new Error(String(cause)),
89+ }),
90+ );
91+
92+ expect(failure.message).toContain("Invalid executor config");
93+ expect(failure.message).toContain("line 5, column 7");
94+ }),
95+ );
96+});
packages/control-plane/src/runtime/local-config.ts+29−60
@@ -2,6 +2,11 @@ import { createHash } from "node:crypto"; · 1 unmodified line
22 import { homedir } from "node:os";
33 import { basename, dirname, isAbsolute, join, resolve } from "node:path";
44 import { promises as fs } from "node:fs";
5+import {
6+ type ParseError as JsoncParseError,
7+ parse as parseJsoncDocument,
8+ printParseErrorCode,
9+} from "jsonc-parser";
510
611 import {
712 LocalExecutorConfigSchema,
@@ -49,74 +54,38 @@ const trimOrUndefined = (value: string | undefined | null): string | undefined = · 41 unmodified lines
4954 return trimmed && trimmed.length > 0 ? trimmed : undefined;
5055 };
5156
52−const stripJsonComments = (input: string): string => {
53− let output = "";
54− let inString = false;
55− let stringChar = '"';
56− let escapeNext = false;
57− let inLineComment = false;
58− let inBlockComment = false;
59−
60− for (let index = 0; index < input.length; index += 1) {
61− const char = input[index]!;
62− const next = input[index + 1];
63−
64− if (inLineComment) {
65− if (char === "\n") {
66− inLineComment = false;
67− output += char;
68− }
69− continue;
70− }
57+const formatJsoncParseErrors = (content: string, errors: readonly JsoncParseError[]): string => {
58+ const lines = content.split("\n");
7159
72− if (inBlockComment) {
73− if (char === "*" && next === "/") {
74− inBlockComment = false;
75− index += 1;
76− }
77− continue;
78− }
60+ return errors
61+ .map((error) => {
62+ const beforeOffset = content.slice(0, error.offset).split("\n");
63+ const line = beforeOffset.length;
64+ const column = beforeOffset[beforeOffset.length - 1]?.length ?? 0;
65+ const lineText = lines[line - 1];
66+ const location = `line ${line}, column ${column + 1}`;
67+ const detail = printParseErrorCode(error.error);
7968
80− if (inString) {
81− output += char;
82− if (escapeNext) {
83− escapeNext = false;
84− } else if (char === "\\") {
85− escapeNext = true;
86− } else if (char === stringChar) {
87− inString = false;
69+ if (!lineText) {
70+ return `${detail} at ${location}`;
8871 }
89− continue;
90− }
91−
92− if ((char === '"' || char === "'") && !inString) {
93− inString = true;
94− stringChar = char;
95− output += char;
96− continue;
97− }
9872
99− if (char === "/" && next === "/") {
100− inLineComment = true;
101− index += 1;
102− continue;
103− }
104−
105− if (char === "/" && next === "*") {
106− inBlockComment = true;
107− index += 1;
108− continue;
109− }
110−
111− output += char;
112− }
113−
114− return output;
73+ return `${detail} at ${location}\n${lineText}`;
74+ })
75+ .join("\n");
11576 };
11677
11778 const parseJsonc = (input: { path: string; content: string }): LocalExecutorConfig => {
79+ const errors: JsoncParseError[] = [];
80+
11881 try {
119− const parsed = JSON.parse(stripJsonComments(input.content)) as unknown;
82+ const parsed = parseJsoncDocument(input.content, errors, {
83+ allowTrailingComma: true,
84+ });
85+ if (errors.length > 0) {
86+ throw new Error(formatJsoncParseErrors(input.content, errors));
87+ }
88+
12089 return decodeLocalExecutorConfig(parsed);
12190 } catch (cause) {
12291 const message = cause instanceof Error ? cause.message : String(cause);