| 1 | import * as Cause from "effect/Cause"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Exit from "effect/Exit"; |
| 4 | import * as Result from "effect/Result"; |
| 5 | import * as Schema from "effect/Schema"; |
| 6 | import * as SchemaGetter from "effect/SchemaGetter"; |
| 7 | import * as SchemaIssue from "effect/SchemaIssue"; |
| 8 | import * as SchemaTransformation from "effect/SchemaTransformation"; |
| 9 | |
| 10 | const MAX_SCHEMA_DIAGNOSTIC_ISSUES = 8; |
| 11 | const MAX_SCHEMA_DIAGNOSTIC_PATH_SEGMENTS = 16; |
| 12 | const MAX_SCHEMA_DIAGNOSTIC_PATH_SEGMENT_LENGTH = 64; |
| 13 | const MAX_SCHEMA_DIAGNOSTIC_LENGTH = 2_048; |
| 14 | |
| 15 | interface 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 | |
| 23 | function truncateDiagnostic(value: string, maxLength: number): string { |
| 24 | return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`; |
| 25 | } |
| 26 | |
| 27 | function 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 | |
| 38 | function 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 | |
| 50 | function 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 | |
| 72 | function 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 | |
| 108 | export 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 | |
| 121 | export 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 | */ |
| 164 | const decodeJsonString = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); |
| 165 | |
| 166 | const 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 | */ |
| 198 | const fromLenientJsonString = new SchemaTransformation.Transformation( |
| 199 | parseLenientJsonGetter, |
| 200 | SchemaGetter.stringifyJson(), |
| 201 | ); |
| 202 | |
| 203 | const 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 | */ |
| 213 | export const fromLenientJson = <S extends Schema.Top>(schema: S) => |
| 214 | Schema.String.pipe(Schema.decodeTo(schema, fromLenientJsonString)); |
| 215 | |
| 216 | export 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 | */ |
| 270 | export const fromJsonStringPretty = <S extends Schema.Top>(schema: S) => |
| 271 | Schema.fromJsonString(schema).pipe( |
| 272 | Schema.encode({ |
| 273 | decode: prettyJsonString, |
| 274 | encode: prettyJsonString, |
| 275 | }), |
| 276 | ); |