jsonc-effect 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/scanner.js ADDED
@@ -0,0 +1,350 @@
1
+ //#region src/scanner.ts
2
+ const isWhitespace = (ch) => ch === 32 || ch === 9 || ch === 11 || ch === 12 || ch === 160 || ch === 65279;
3
+ const isLineBreak = (ch) => ch === 10 || ch === 13 || ch === 8232 || ch === 8233;
4
+ const isDigit = (ch) => ch >= 48 && ch <= 57;
5
+ /**
6
+ * Create a stateful {@link JsoncScanner} for the given JSONC string.
7
+ *
8
+ * @param text - JSONC string to tokenize
9
+ * @param ignoreTrivia - If `true`, the scanner automatically skips whitespace,
10
+ * line-break, and comment tokens so that only structural tokens are returned
11
+ * (default: `false`).
12
+ * @returns A stateful {@link JsoncScanner} positioned before the first token.
13
+ *
14
+ * @remarks
15
+ * When `ignoreTrivia` is `true` the scanner is suitable for building parsers
16
+ * that only care about structural tokens (`OpenBrace`, `String`, `Number`,
17
+ * etc.). Set it to `false` (the default) when you need to preserve comments
18
+ * or whitespace — for example in a formatter or a comment-stripping pass.
19
+ *
20
+ * @see {@link JsoncScanner} — the interface returned by this factory
21
+ * @see {@link parse} — higher-level API that uses a scanner internally
22
+ *
23
+ * @example
24
+ * Tokenizing a JSONC string and printing each token:
25
+ * ```ts
26
+ * import type { JsoncSyntaxKind } from "jsonc-effect";
27
+ * import { createScanner } from "jsonc-effect";
28
+ *
29
+ * const scanner = createScanner('{ "name": "jsonc" }', true);
30
+ * let kind: JsoncSyntaxKind;
31
+ * do {
32
+ * kind = scanner.scan();
33
+ * console.log(kind, scanner.getTokenValue());
34
+ * } while (kind !== "EOF");
35
+ * ```
36
+ *
37
+ * @privateRemarks
38
+ * Ported from Microsoft's jsonc-parser (MIT), adapted to use string literal
39
+ * token types instead of numeric enums.
40
+ *
41
+ * @public
42
+ */
43
+ const createScanner = (text, ignoreTrivia = false) => {
44
+ const len = text.length;
45
+ let pos = 0;
46
+ let tokenOffset = 0;
47
+ let token = "Unknown";
48
+ let tokenValue = "";
49
+ let tokenError = "None";
50
+ let lineNumber = 0;
51
+ let lineStartOffset = 0;
52
+ let tokenStartLine = 0;
53
+ let tokenStartCharacter = 0;
54
+ const scanHexDigits = (count) => {
55
+ let value = 0;
56
+ for (let i = 0; i < count; i++) {
57
+ if (pos >= len) return -1;
58
+ const ch = text.charCodeAt(pos);
59
+ if (ch >= 48 && ch <= 57) value = value * 16 + (ch - 48);
60
+ else if (ch >= 65 && ch <= 70) value = value * 16 + (ch - 65 + 10);
61
+ else if (ch >= 97 && ch <= 102) value = value * 16 + (ch - 97 + 10);
62
+ else return -1;
63
+ pos++;
64
+ }
65
+ return value;
66
+ };
67
+ const scanString = () => {
68
+ let result = "";
69
+ pos++;
70
+ let start = pos;
71
+ while (pos < len) {
72
+ const ch = text.charCodeAt(pos);
73
+ if (ch === 34) {
74
+ result += text.substring(start, pos);
75
+ pos++;
76
+ return result;
77
+ }
78
+ if (ch === 92) {
79
+ result += text.substring(start, pos);
80
+ pos++;
81
+ if (pos >= len) {
82
+ tokenError = "UnexpectedEndOfString";
83
+ return result;
84
+ }
85
+ const escaped = text.charCodeAt(pos);
86
+ pos++;
87
+ switch (escaped) {
88
+ case 34:
89
+ result += "\"";
90
+ break;
91
+ case 92:
92
+ result += "\\";
93
+ break;
94
+ case 47:
95
+ result += "/";
96
+ break;
97
+ case 98:
98
+ result += "\b";
99
+ break;
100
+ case 102:
101
+ result += "\f";
102
+ break;
103
+ case 110:
104
+ result += "\n";
105
+ break;
106
+ case 114:
107
+ result += "\r";
108
+ break;
109
+ case 116:
110
+ result += " ";
111
+ break;
112
+ case 117: {
113
+ const value = scanHexDigits(4);
114
+ if (value >= 0) result += String.fromCharCode(value);
115
+ else tokenError = "InvalidUnicode";
116
+ break;
117
+ }
118
+ default:
119
+ tokenError = "InvalidEscapeCharacter";
120
+ break;
121
+ }
122
+ start = pos;
123
+ } else if (isLineBreak(ch)) {
124
+ tokenError = "UnexpectedEndOfString";
125
+ return result + text.substring(start, pos);
126
+ } else pos++;
127
+ }
128
+ tokenError = "UnexpectedEndOfString";
129
+ return result + text.substring(start, pos);
130
+ };
131
+ const scanNumber = () => {
132
+ const start = pos;
133
+ if (text.charCodeAt(pos) === 45) pos++;
134
+ if (text.charCodeAt(pos) === 48) pos++;
135
+ else {
136
+ if (!isDigit(text.charCodeAt(pos))) {
137
+ tokenError = "UnexpectedEndOfNumber";
138
+ return text.substring(start, pos);
139
+ }
140
+ pos++;
141
+ while (pos < len && isDigit(text.charCodeAt(pos))) pos++;
142
+ }
143
+ if (pos < len && text.charCodeAt(pos) === 46) {
144
+ pos++;
145
+ if (!isDigit(text.charCodeAt(pos))) {
146
+ tokenError = "UnexpectedEndOfNumber";
147
+ return text.substring(start, pos);
148
+ }
149
+ pos++;
150
+ while (pos < len && isDigit(text.charCodeAt(pos))) pos++;
151
+ }
152
+ if (pos < len && (text.charCodeAt(pos) === 69 || text.charCodeAt(pos) === 101)) {
153
+ pos++;
154
+ if (pos < len && (text.charCodeAt(pos) === 43 || text.charCodeAt(pos) === 45)) pos++;
155
+ if (!isDigit(text.charCodeAt(pos))) {
156
+ tokenError = "UnexpectedEndOfNumber";
157
+ return text.substring(start, pos);
158
+ }
159
+ pos++;
160
+ while (pos < len && isDigit(text.charCodeAt(pos))) pos++;
161
+ }
162
+ return text.substring(start, pos);
163
+ };
164
+ const scan = () => {
165
+ tokenValue = "";
166
+ tokenError = "None";
167
+ if (pos >= len) {
168
+ tokenOffset = len;
169
+ tokenStartLine = lineNumber;
170
+ tokenStartCharacter = pos - lineStartOffset;
171
+ token = "EOF";
172
+ return token;
173
+ }
174
+ let ch = text.charCodeAt(pos);
175
+ if (isWhitespace(ch)) {
176
+ tokenOffset = pos;
177
+ tokenStartLine = lineNumber;
178
+ tokenStartCharacter = pos - lineStartOffset;
179
+ do {
180
+ pos++;
181
+ ch = pos < len ? text.charCodeAt(pos) : 0;
182
+ } while (isWhitespace(ch));
183
+ tokenValue = text.substring(tokenOffset, pos);
184
+ if (ignoreTrivia) return scan();
185
+ token = "Trivia";
186
+ return token;
187
+ }
188
+ if (isLineBreak(ch)) {
189
+ tokenOffset = pos;
190
+ tokenStartLine = lineNumber;
191
+ tokenStartCharacter = pos - lineStartOffset;
192
+ pos++;
193
+ if (ch === 13 && pos < len && text.charCodeAt(pos) === 10) pos++;
194
+ lineNumber++;
195
+ lineStartOffset = pos;
196
+ tokenValue = text.substring(tokenOffset, pos);
197
+ if (ignoreTrivia) return scan();
198
+ token = "LineBreak";
199
+ return token;
200
+ }
201
+ tokenOffset = pos;
202
+ tokenStartLine = lineNumber;
203
+ tokenStartCharacter = pos - lineStartOffset;
204
+ switch (ch) {
205
+ case 123:
206
+ pos++;
207
+ tokenValue = "{";
208
+ token = "OpenBrace";
209
+ return token;
210
+ case 125:
211
+ pos++;
212
+ tokenValue = "}";
213
+ token = "CloseBrace";
214
+ return token;
215
+ case 91:
216
+ pos++;
217
+ tokenValue = "[";
218
+ token = "OpenBracket";
219
+ return token;
220
+ case 93:
221
+ pos++;
222
+ tokenValue = "]";
223
+ token = "CloseBracket";
224
+ return token;
225
+ case 58:
226
+ pos++;
227
+ tokenValue = ":";
228
+ token = "Colon";
229
+ return token;
230
+ case 44:
231
+ pos++;
232
+ tokenValue = ",";
233
+ token = "Comma";
234
+ return token;
235
+ case 34:
236
+ tokenValue = scanString();
237
+ token = "String";
238
+ return token;
239
+ case 47: {
240
+ const nextCh = pos + 1 < len ? text.charCodeAt(pos + 1) : 0;
241
+ if (nextCh === 47) {
242
+ pos += 2;
243
+ while (pos < len && !isLineBreak(text.charCodeAt(pos))) pos++;
244
+ tokenValue = text.substring(tokenOffset, pos);
245
+ if (ignoreTrivia) return scan();
246
+ token = "LineComment";
247
+ return token;
248
+ }
249
+ if (nextCh === 42) {
250
+ pos += 2;
251
+ const safeLen = len - 1;
252
+ let commentClosed = false;
253
+ while (pos < safeLen) {
254
+ const cch = text.charCodeAt(pos);
255
+ if (isLineBreak(cch)) {
256
+ if (cch === 13 && pos + 1 < len && text.charCodeAt(pos + 1) === 10) pos++;
257
+ pos++;
258
+ lineNumber++;
259
+ lineStartOffset = pos;
260
+ } else if (cch === 42 && text.charCodeAt(pos + 1) === 47) {
261
+ pos += 2;
262
+ commentClosed = true;
263
+ break;
264
+ } else pos++;
265
+ }
266
+ if (!commentClosed) {
267
+ pos = len;
268
+ tokenError = "UnexpectedEndOfComment";
269
+ }
270
+ tokenValue = text.substring(tokenOffset, pos);
271
+ if (ignoreTrivia) return scan();
272
+ token = "BlockComment";
273
+ return token;
274
+ }
275
+ pos++;
276
+ tokenValue = text.substring(tokenOffset, pos);
277
+ token = "Unknown";
278
+ tokenError = "InvalidCharacter";
279
+ return token;
280
+ }
281
+ case 45:
282
+ if (pos + 1 < len && isDigit(text.charCodeAt(pos + 1))) {
283
+ tokenValue = scanNumber();
284
+ token = "Number";
285
+ return token;
286
+ }
287
+ pos++;
288
+ tokenValue = "-";
289
+ token = "Unknown";
290
+ tokenError = "InvalidSymbol";
291
+ return token;
292
+ default:
293
+ if (isDigit(ch)) {
294
+ tokenValue = scanNumber();
295
+ token = "Number";
296
+ return token;
297
+ }
298
+ if (ch >= 97 && ch <= 122) {
299
+ const start = pos;
300
+ pos++;
301
+ while (pos < len) {
302
+ const kch = text.charCodeAt(pos);
303
+ if (kch >= 97 && kch <= 122) pos++;
304
+ else break;
305
+ }
306
+ tokenValue = text.substring(start, pos);
307
+ switch (tokenValue) {
308
+ case "true":
309
+ token = "True";
310
+ return token;
311
+ case "false":
312
+ token = "False";
313
+ return token;
314
+ case "null":
315
+ token = "Null";
316
+ return token;
317
+ default:
318
+ token = "Unknown";
319
+ tokenError = "InvalidSymbol";
320
+ return token;
321
+ }
322
+ }
323
+ pos++;
324
+ tokenValue = text.substring(tokenOffset, pos);
325
+ token = "Unknown";
326
+ tokenError = "InvalidCharacter";
327
+ return token;
328
+ }
329
+ };
330
+ return {
331
+ scan,
332
+ getToken: () => token,
333
+ getTokenValue: () => tokenValue,
334
+ getTokenOffset: () => tokenOffset,
335
+ getTokenLength: () => pos - tokenOffset,
336
+ getTokenStartLine: () => tokenStartLine,
337
+ getTokenStartCharacter: () => tokenStartCharacter,
338
+ getTokenError: () => tokenError,
339
+ getPosition: () => pos,
340
+ setPosition: (newPos) => {
341
+ pos = newPos;
342
+ tokenValue = "";
343
+ token = "Unknown";
344
+ tokenError = "None";
345
+ }
346
+ };
347
+ };
348
+
349
+ //#endregion
350
+ export { createScanner };
@@ -0,0 +1,178 @@
1
+ import { parse } from "./parse.js";
2
+ import { Effect, ParseResult, Schema } from "effect";
3
+
4
+ //#region src/schema-integration.ts
5
+ /**
6
+ * Schema-based JSONC parsing pipelines.
7
+ *
8
+ * Transforms JSONC strings directly into typed domain objects
9
+ * using Schema.transformOrFail and Schema.compose.
10
+ *
11
+ * @packageDocumentation
12
+ */
13
+ /**
14
+ * Pre-built `Schema<unknown, string>` that decodes a JSONC string into an
15
+ * unknown JavaScript value using default parse options.
16
+ *
17
+ * This is the first stage of a typical parsing pipeline:
18
+ *
19
+ * ```
20
+ * JSONC string --JsoncFromString--> unknown --YourSchema--> A
21
+ * ```
22
+ *
23
+ * @remarks
24
+ * Default parse options are used (comments allowed, trailing commas
25
+ * allowed). The encode direction uses `JSON.stringify` with 2-space
26
+ * indentation, which means comments present in the original JSONC
27
+ * input are not preserved during a round-trip encode.
28
+ *
29
+ * For custom parse options, use {@link makeJsoncFromString} instead.
30
+ *
31
+ * @see {@link makeJsoncFromString} to create a schema with custom options.
32
+ * @see {@link makeJsoncSchema} to compose JSONC parsing with a domain schema
33
+ * in one step.
34
+ *
35
+ * @example Decode a JSONC string
36
+ * ```ts
37
+ * import { Schema } from "effect";
38
+ * import { JsoncFromString } from "jsonc-effect";
39
+ *
40
+ * const value: unknown = Schema.decodeUnknownSync(JsoncFromString)(
41
+ * '{ "key": 42 // comment\n}',
42
+ * );
43
+ * ```
44
+ *
45
+ * @example Compose with a domain schema
46
+ * ```ts
47
+ * import { Schema } from "effect";
48
+ * import { JsoncFromString } from "jsonc-effect";
49
+ *
50
+ * const MyConfig = Schema.Struct({
51
+ * name: Schema.String,
52
+ * version: Schema.Number,
53
+ * });
54
+ *
55
+ * const MyConfigFromJsonc = Schema.compose(JsoncFromString, MyConfig);
56
+ * const config = Schema.decodeUnknownSync(MyConfigFromJsonc)(
57
+ * '{ "name": "app", "version": 1 }',
58
+ * );
59
+ * ```
60
+ *
61
+ * @public
62
+ */
63
+ const JsoncFromString = makeJsoncFromString();
64
+ /**
65
+ * Create a `Schema<unknown, string>` that decodes JSONC with custom
66
+ * parse options.
67
+ *
68
+ * @param options - Partial {@link JsoncParseOptions} controlling comment
69
+ * handling and trailing-comma tolerance.
70
+ * @returns A `Schema<unknown, string>` configured with the given options.
71
+ *
72
+ * @remarks
73
+ * The encode direction uses `JSON.stringify` with 2-space indentation,
74
+ * which produces standard JSON. Comments present in the original JSONC
75
+ * input are not preserved during a round-trip encode.
76
+ *
77
+ * @see {@link JsoncFromString} for a zero-config default instance.
78
+ * @see {@link makeJsoncSchema} to compose JSONC parsing with a domain
79
+ * schema in one step.
80
+ *
81
+ * @example Strict mode (no comments, no trailing commas)
82
+ * ```ts
83
+ * import { Schema } from "effect";
84
+ * import { makeJsoncFromString } from "jsonc-effect";
85
+ *
86
+ * const StrictJsoncFromString = makeJsoncFromString({
87
+ * disallowComments: true,
88
+ * allowTrailingComma: false,
89
+ * });
90
+ *
91
+ * const value: unknown = Schema.decodeUnknownSync(StrictJsoncFromString)(
92
+ * '{ "key": 42 }',
93
+ * );
94
+ * ```
95
+ *
96
+ * @privateRemarks
97
+ * Internally delegates to `Schema.transformOrFail` to wire up the decode
98
+ * and encode directions.
99
+ *
100
+ * @public
101
+ */
102
+ function makeJsoncFromString(options) {
103
+ return Schema.transformOrFail(Schema.String, Schema.Unknown, {
104
+ strict: true,
105
+ decode: (input, _options, ast) => {
106
+ const program = options ? parse(input, options) : parse(input);
107
+ return Effect.mapError(program, (parseError) => new ParseResult.Type(ast, input, parseError.message));
108
+ },
109
+ encode: (value) => ParseResult.succeed(JSON.stringify(value, null, 2))
110
+ }).annotations({
111
+ title: "JsoncFromString",
112
+ description: "Parse a JSONC string into an unknown JavaScript value"
113
+ });
114
+ }
115
+ /**
116
+ * Create a composed `Schema<A, string>` that parses a JSONC string and
117
+ * validates the result against a target domain schema in one step.
118
+ *
119
+ * @param targetSchema - The domain schema to validate the parsed value
120
+ * against. Its input type `I` must be assignable from `unknown`.
121
+ * @param options - Optional partial {@link JsoncParseOptions} forwarded to
122
+ * the underlying JSONC parser.
123
+ * @returns A `Schema<A, string>` that goes directly from a JSONC string
124
+ * to a fully validated domain value of type `A`.
125
+ *
126
+ * @remarks
127
+ * Internally composes two schema stages:
128
+ *
129
+ * 1. JSONC string to `unknown` (via {@link makeJsoncFromString}).
130
+ * 2. `unknown` to `A` (via the provided `targetSchema`).
131
+ *
132
+ * This avoids the need to manually call `Schema.compose`.
133
+ *
134
+ * @see {@link makeJsoncFromString} for the first stage of the pipeline.
135
+ *
136
+ * @example Typed configuration from JSONC
137
+ * ```ts
138
+ * import { Schema } from "effect";
139
+ * import { makeJsoncSchema } from "jsonc-effect";
140
+ *
141
+ * const MyConfig = Schema.Struct({
142
+ * name: Schema.String,
143
+ * version: Schema.Number,
144
+ * });
145
+ *
146
+ * const MyConfigFromJsonc = makeJsoncSchema(MyConfig);
147
+ * const config = Schema.decodeUnknownSync(MyConfigFromJsonc)(
148
+ * '{ "name": "app", "version": 1 }',
149
+ * );
150
+ * ```
151
+ *
152
+ * @example With custom parse options
153
+ * ```ts
154
+ * import { Schema } from "effect";
155
+ * import { makeJsoncSchema } from "jsonc-effect";
156
+ *
157
+ * const MyConfig = Schema.Struct({ debug: Schema.Boolean });
158
+ *
159
+ * const StrictConfig = makeJsoncSchema(MyConfig, {
160
+ * disallowComments: true,
161
+ * allowTrailingComma: false,
162
+ * });
163
+ *
164
+ * const config = Schema.decodeUnknownSync(StrictConfig)(
165
+ * '{ "debug": true }',
166
+ * );
167
+ * ```
168
+ *
169
+ * @privateRemarks
170
+ * Uses `Schema.compose` internally to chain the JSONC-from-string schema
171
+ * with the provided target schema.
172
+ *
173
+ * @public
174
+ */
175
+ const makeJsoncSchema = (targetSchema, options) => Schema.compose(makeJsoncFromString(options), targetSchema);
176
+
177
+ //#endregion
178
+ export { JsoncFromString, makeJsoncFromString, makeJsoncSchema };