1import * as Cause from "effect/Cause";
2import * as Effect from "effect/Effect";
3import * as Exit from "effect/Exit";
4import * as Result from "effect/Result";
5import * as Schema from "effect/Schema";
6import * as SchemaGetter from "effect/SchemaGetter";
7import * as SchemaIssue from "effect/SchemaIssue";
8import * as SchemaTransformation from "effect/SchemaTransformation";
9
10const MAX_SCHEMA_DIAGNOSTIC_ISSUES = 8;
11const MAX_SCHEMA_DIAGNOSTIC_PATH_SEGMENTS = 16;
12const MAX_SCHEMA_DIAGNOSTIC_PATH_SEGMENT_LENGTH = 64;
13const MAX_SCHEMA_DIAGNOSTIC_LENGTH = 2_048;
14
15interface SchemaDiagnosticIssue {
16 readonly message: string;
17 readonly path: ReadonlyArray<PropertyKey>;
18}
19
20// Schema's default formatter includes actual values. These diagnostics cross
21// process and UI boundaries, so retain only issue kinds and bounded paths.
22
23function truncateDiagnostic(value: string, maxLength: number): string {
24 return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`;
25}
26
27function formatDiagnosticPathSegment(key: PropertyKey): string {
28 if (typeof key === "number") {
29 return `[${key}]`;
30 }
31 const value = truncateDiagnostic(
32 typeof key === "symbol" ? String(key) : key,
33 MAX_SCHEMA_DIAGNOSTIC_PATH_SEGMENT_LENGTH,
34 );
35 return `[${JSON.stringify(value)}]`;
36}
37
38function formatDiagnosticIssue(issue: SchemaDiagnosticIssue): string {
39 if (issue.path.length === 0) {
40 return issue.message;
41 }
42 const path = issue.path
43 .slice(0, MAX_SCHEMA_DIAGNOSTIC_PATH_SEGMENTS)
44 .map(formatDiagnosticPathSegment)
45 .join("");
46 const suffix = issue.path.length > MAX_SCHEMA_DIAGNOSTIC_PATH_SEGMENTS ? "[...]" : "";
47 return `${issue.message}\n at ${path}${suffix}`;
48}
49
50function schemaDiagnosticMessage(issue: SchemaIssue.Issue): string {
51 switch (issue._tag) {
52 case "InvalidType":
53 return "Invalid type";
54 case "InvalidValue":
55 case "Filter":
56 case "AnyOf":
57 case "Encoding":
58 case "Pointer":
59 case "Composite":
60 return "Invalid value";
61 case "MissingKey":
62 return "Missing key";
63 case "UnexpectedKey":
64 return "Unexpected key";
65 case "Forbidden":
66 return "Forbidden operation";
67 case "OneOf":
68 return "Expected exactly one schema member to match";
69 }
70}
71
72function collectSchemaDiagnosticIssues(
73 issue: SchemaIssue.Issue,
74 path: ReadonlyArray<PropertyKey>,
75 diagnostics: Array<SchemaDiagnosticIssue>,
76): number {
77 switch (issue._tag) {
78 case "Encoding":
79 return collectSchemaDiagnosticIssues(issue.issue, path, diagnostics);
80 case "Filter":
81 if (issue.issue._tag !== "InvalidValue") {
82 return collectSchemaDiagnosticIssues(issue.issue, path, diagnostics);
83 }
84 break;
85 case "Pointer":
86 return collectSchemaDiagnosticIssues(issue.issue, [...path, ...issue.path], diagnostics);
87 case "Composite":
88 return issue.issues.reduce(
89 (count, issue) => count + collectSchemaDiagnosticIssues(issue, path, diagnostics),
90 0,
91 );
92 case "AnyOf":
93 if (issue.issues.length > 0) {
94 return issue.issues.reduce(
95 (count, issue) => count + collectSchemaDiagnosticIssues(issue, path, diagnostics),
96 0,
97 );
98 }
99 break;
100 }
101
102 if (diagnostics.length < MAX_SCHEMA_DIAGNOSTIC_ISSUES) {
103 diagnostics.push({ message: schemaDiagnosticMessage(issue), path });
104 }
105 return 1;
106}
107
108export const decodeJsonResult = <S extends Schema.Codec<unknown, unknown, never, never>>(
109 schema: S,
110) => {
111 const decode = Schema.decodeExit(Schema.fromJsonString(schema));
112 return (input: string) => {
113 const result = decode(input);
114 if (Exit.isFailure(result)) {
115 return Result.fail(result.cause);
116 }
117 return Result.succeed(result.value);
118 };
119};
120
121export const formatSchemaError = (cause: Cause.Cause<Schema.SchemaError>) => {
122 const issues: Array<SchemaDiagnosticIssue> = [];
123 let issueCount = 0;
124 let failureCount = 0;
125 let defectCount = 0;
126 let interruptionCount = 0;
127
128 for (const reason of cause.reasons) {
129 switch (reason._tag) {
130 case "Fail":
131 failureCount += 1;
132 if (Schema.isSchemaError(reason.error)) {
133 issueCount += collectSchemaDiagnosticIssues(reason.error.issue, [], issues);
134 }
135 break;
136 case "Die":
137 defectCount += 1;
138 break;
139 case "Interrupt":
140 interruptionCount += 1;
141 break;
142 }
143 }
144
145 if (issues.length === 0) {
146 return `Schema validation failed (failureCount=${failureCount}, defectCount=${defectCount}, interruptionCount=${interruptionCount}).`;
147 }
148
149 const omittedIssueCount = issueCount - issues.length;
150 const formatted = issues.map(formatDiagnosticIssue).join("\n");
151 if (omittedIssueCount === 0) {
152 return truncateDiagnostic(formatted, MAX_SCHEMA_DIAGNOSTIC_LENGTH);
153 }
154 const suffix = `\n... and ${omittedIssueCount} more issue(s)`;
155 return truncateDiagnostic(formatted, MAX_SCHEMA_DIAGNOSTIC_LENGTH - suffix.length) + suffix;
156};
157
158/**
159 * A `Getter` that parses a lenient JSON string (tolerating trailing commas
160 * and JS-style comments) into an unknown value.
161 *
162 * Mirrors `SchemaGetter.parseJson()` but strips JSONC syntax before parsing.
163 */
164const decodeJsonString = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown));
165
166const parseLenientJsonGetter = SchemaGetter.transformEffect((input: string) => {
167 // Strip single-line comments - alternation preserves quoted strings.
168 let stripped = input.replace(
169 /("(?:[^"\\]|\\.)*")|\/\/[^\n]*/g,
170 (match, stringLiteral: string | undefined) => (stringLiteral ? match : ""),
171 );
172
173 // Strip multi-line comments.
174 stripped = stripped.replace(
175 /("(?:[^"\\]|\\.)*")|\/\*[\s\S]*?\*\//g,
176 (match, stringLiteral: string | undefined) => (stringLiteral ? match : ""),
177 );
178
179 // Strip trailing commas before `}` or `]`. The alternation preserves quoted
180 // strings so a comma inside a string value (e.g. `{"note":"a,]"}`) is not
181 // mistaken for a trailing comma and removed.
182 stripped = stripped.replace(
183 /("(?:[^"\\]|\\.)*")|,(\s*[}\]])/g,
184 (match, stringLiteral: string | undefined, bracket: string | undefined) =>
185 stringLiteral ? match : (bracket ?? ""),
186 );
187
188 return decodeJsonString(stripped).pipe(Effect.mapError((error) => error.issue));
189});
190
191/**
192 * Schema transformation: lenient JSONC string ↔ unknown.
193 *
194 * Same API as `SchemaTransformation.fromJsonString`, but the decode side
195 * strips trailing commas and JS-style comments before parsing.
196 * Encoding produces strict JSON via `JSON.stringify`.
197 */
198const fromLenientJsonString = new SchemaTransformation.Transformation(
199 parseLenientJsonGetter,
200 SchemaGetter.stringifyJson(),
201);
202
203const prettyJsonString = SchemaGetter.parseJson<string>().pipe(
204 SchemaGetter.compose(SchemaGetter.stringifyJson({ space: 2 })),
205);
206
207/**
208 * Build a schema that decodes a lenient JSON string into `A`.
209 *
210 * Drop-in replacement for `Schema.fromJsonString(schema)` that tolerates
211 * trailing commas and comments in the input.
212 */
213export const fromLenientJson = <S extends Schema.Top>(schema: S) =>
214 Schema.String.pipe(Schema.decodeTo(schema, fromLenientJsonString));
215
216export function extractJsonObject(raw: string): string {
217 const trimmed = raw.trim();
218 if (trimmed.length === 0) {
219 return trimmed;
220 }
221
222 const start = trimmed.indexOf("{");
223 if (start < 0) {
224 return trimmed;
225 }
226
227 let depth = 0;
228 let inString = false;
229 let escaping = false;
230 for (let index = start; index < trimmed.length; index += 1) {
231 const char = trimmed[index];
232 if (inString) {
233 if (escaping) {
234 escaping = false;
235 } else if (char === "\\") {
236 escaping = true;
237 } else if (char === '"') {
238 inString = false;
239 }
240 continue;
241 }
242
243 if (char === '"') {
244 inString = true;
245 continue;
246 }
247
248 if (char === "{") {
249 depth += 1;
250 continue;
251 }
252
253 if (char === "}") {
254 depth -= 1;
255 if (depth === 0) {
256 return trimmed.slice(start, index + 1);
257 }
258 }
259 }
260
261 return trimmed.slice(start);
262}
263
264/**
265 * Build a JSON string schema that encodes with stable 2-space formatting.
266 *
267 * Decode behavior matches `Schema.fromJsonString(schema)`. Encode behavior
268 * keeps the transformation schema-based while preserving human-readable JSON.
269 */
270export const fromJsonStringPretty = <S extends Schema.Top>(schema: S) =>
271 Schema.fromJsonString(schema).pipe(
272 Schema.encode({
273 decode: prettyJsonString,
274 encode: prettyJsonString,
275 }),
276 );