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/format.js ADDED
@@ -0,0 +1,453 @@
1
+ import { JsoncModificationError } from "./errors.js";
2
+ import { createScanner } from "./scanner.js";
3
+ import { Effect, Function } from "effect";
4
+
5
+ //#region src/format.ts
6
+ /**
7
+ * JSONC formatting, modification, and edit application.
8
+ *
9
+ * All functions compute edits (not mutations) — a natural fit
10
+ * for Effect's functional style.
11
+ *
12
+ * @packageDocumentation
13
+ */
14
+ /**
15
+ * Compute formatting edits for a JSONC document.
16
+ *
17
+ * Returns an array of {@link JsoncEdit} objects describing text replacements
18
+ * that, when applied, produce a well-formatted document. The input text is
19
+ * never mutated — apply the returned edits with {@link applyEdits}.
20
+ *
21
+ * @param text - The JSONC source text to format.
22
+ * @param range - Optional sub-range to format. When provided, only edits
23
+ * within the range are returned.
24
+ * @param options - Partial {@link JsoncFormattingOptions} controlling indent
25
+ * size, tabs vs. spaces, EOL style, and final-newline insertion.
26
+ * @returns An `Effect` that succeeds with a read-only array of
27
+ * {@link JsoncEdit} objects.
28
+ *
29
+ * @remarks
30
+ * This function is non-mutating by design. It computes a minimal set of
31
+ * whitespace edits; structural changes (inserting or removing properties)
32
+ * are handled by {@link modify}. The `range` parameter allows formatting a
33
+ * subset of the document without touching surrounding text.
34
+ *
35
+ * @see {@link applyEdits} to apply the returned edits to the source text.
36
+ * @see {@link formatAndApply} for a one-step format-and-apply convenience.
37
+ * @see {@link JsoncFormattingOptions} for available formatting controls.
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * import { Effect } from "effect";
42
+ * import type { JsoncEdit } from "jsonc-effect";
43
+ * import { format, applyEdits } from "jsonc-effect";
44
+ *
45
+ * const input = '{"a":1, "b" :2}';
46
+ * const edits: ReadonlyArray<JsoncEdit> = Effect.runSync(format(input));
47
+ * const formatted: string = Effect.runSync(applyEdits(input, edits));
48
+ * ```
49
+ *
50
+ * @privateRemarks
51
+ * Uses {@link createScanner} internally to tokenize the input and compute
52
+ * whitespace adjustments between consecutive non-trivia tokens.
53
+ *
54
+ * @public
55
+ */
56
+ const format = (text, range, options) => Effect.sync(() => formatImpl(text, range, options));
57
+ /**
58
+ * Apply an array of text edits to JSONC source text.
59
+ *
60
+ * This is a {@link https://effect.website/docs/function-dual | Function.dual}
61
+ * that supports both data-first and data-last (pipeline) usage.
62
+ *
63
+ * @param text - The original JSONC source text.
64
+ * @param edits - A read-only array of {@link JsoncEdit} objects, typically
65
+ * produced by {@link format} or {@link modify}.
66
+ * @returns An `Effect` that succeeds with the edited string.
67
+ *
68
+ * @remarks
69
+ * Edits are sorted in reverse offset order before application so that
70
+ * earlier edits do not shift the offsets of later ones. The original `edits`
71
+ * array is not mutated.
72
+ *
73
+ * @see {@link format} to compute formatting edits.
74
+ * @see {@link modify} to compute structural edits (insert, replace, remove).
75
+ *
76
+ * @example Data-first usage
77
+ * ```ts
78
+ * import { Effect } from "effect";
79
+ * import { format, applyEdits } from "jsonc-effect";
80
+ *
81
+ * const input = '{"a":1}';
82
+ * const edits = Effect.runSync(format(input));
83
+ * const result: string = Effect.runSync(applyEdits(input, edits));
84
+ * ```
85
+ *
86
+ * @example Pipeline with modify
87
+ * ```ts
88
+ * import { Effect, pipe } from "effect";
89
+ * import { modify, applyEdits } from "jsonc-effect";
90
+ *
91
+ * const input = '{ "a": 1 }';
92
+ * const result = pipe(
93
+ * input,
94
+ * modify(["a"], 42),
95
+ * Effect.flatMap((edits) => applyEdits(input, edits)),
96
+ * Effect.runSync,
97
+ * );
98
+ * ```
99
+ *
100
+ * @public
101
+ */
102
+ const applyEdits = Function.dual(2, (text, edits) => Effect.sync(() => {
103
+ const sorted = [...edits].sort((a, b) => b.offset - a.offset);
104
+ let result = text;
105
+ for (const edit of sorted) result = result.substring(0, edit.offset) + edit.content + result.substring(edit.offset + edit.length);
106
+ return result;
107
+ }));
108
+ /**
109
+ * Format a JSONC document in one step by computing edits and immediately
110
+ * applying them.
111
+ *
112
+ * This is a convenience that combines {@link format} and {@link applyEdits}
113
+ * into a single call.
114
+ *
115
+ * @param text - The JSONC source text to format.
116
+ * @param range - Optional sub-range to restrict formatting to.
117
+ * @param options - Partial {@link JsoncFormattingOptions} controlling indent
118
+ * size, tabs vs. spaces, EOL style, and final-newline insertion.
119
+ * @returns An `Effect` that succeeds with the fully formatted string.
120
+ *
121
+ * @see {@link format} to obtain the edits without applying them.
122
+ * @see {@link applyEdits} to apply edits separately.
123
+ *
124
+ * @example
125
+ * ```ts
126
+ * import { Effect } from "effect";
127
+ * import { formatAndApply } from "jsonc-effect";
128
+ *
129
+ * const input = '{"a":1, "b" :2}';
130
+ * const formatted: string = Effect.runSync(formatAndApply(input));
131
+ * ```
132
+ *
133
+ * @public
134
+ */
135
+ const formatAndApply = (text, range, options) => format(text, range, options).pipe(Effect.flatMap((edits) => applyEdits(text, edits)));
136
+ /**
137
+ * Compute edits to insert, replace, or remove a value at a JSON path.
138
+ *
139
+ * This is a {@link https://effect.website/docs/function-dual | Function.dual}
140
+ * that supports both data-first and data-last (pipeline) usage.
141
+ *
142
+ * @param text - The JSONC source text to modify.
143
+ * @param path - A {@link (JsoncPath:type)} (array of string keys and numeric indices)
144
+ * identifying the target location in the JSON structure.
145
+ * @param value - The value to set. Pass `undefined` to remove the
146
+ * property or array element at the given path.
147
+ * @param options - Optional object with `formattingOptions` controlling
148
+ * indent size, tabs vs. spaces, and EOL style for generated text.
149
+ * @returns An `Effect` that succeeds with a read-only array of
150
+ * {@link JsoncEdit} objects, or fails with a
151
+ * {@link JsoncModificationError} if the path cannot be navigated.
152
+ *
153
+ * @remarks
154
+ * Setting `value` to `undefined` removes the targeted property or element,
155
+ * including its surrounding comma. When inserting a new property into an
156
+ * object, it is appended after the last existing property.
157
+ *
158
+ * @see {@link applyEdits} to apply the returned edits to the source text.
159
+ * @see {@link JsoncModificationError} for the error type on navigation failure.
160
+ *
161
+ * @example Update an existing property
162
+ * ```ts
163
+ * import { Effect } from "effect";
164
+ * import { modify, applyEdits } from "jsonc-effect";
165
+ *
166
+ * const input = '{ "a": 1 }';
167
+ * const edits = Effect.runSync(modify(input, ["a"], 2));
168
+ * const result: string = Effect.runSync(applyEdits(input, edits));
169
+ * ```
170
+ *
171
+ * @example Insert a new property
172
+ * ```ts
173
+ * import { Effect } from "effect";
174
+ * import { modify, applyEdits } from "jsonc-effect";
175
+ *
176
+ * const input = '{ "a": 1 }';
177
+ * const edits = Effect.runSync(modify(input, ["b"], "hello"));
178
+ * const result: string = Effect.runSync(applyEdits(input, edits));
179
+ * ```
180
+ *
181
+ * @example Remove a property
182
+ * ```ts
183
+ * import { Effect } from "effect";
184
+ * import { modify, applyEdits } from "jsonc-effect";
185
+ *
186
+ * const input = '{ "a": 1, "b": 2 }';
187
+ * const edits = Effect.runSync(modify(input, ["a"], undefined));
188
+ * const result: string = Effect.runSync(applyEdits(input, edits));
189
+ * ```
190
+ *
191
+ * @example Pipeline (data-last) usage
192
+ * ```ts
193
+ * import { Effect, pipe } from "effect";
194
+ * import { modify, applyEdits } from "jsonc-effect";
195
+ *
196
+ * const input = '{ "a": 1 }';
197
+ * const result = pipe(
198
+ * input,
199
+ * modify(["a"], 42),
200
+ * Effect.flatMap((edits) => applyEdits(input, edits)),
201
+ * Effect.runSync,
202
+ * );
203
+ * ```
204
+ *
205
+ * @privateRemarks
206
+ * Uses its own scanner-based navigation to locate the target path rather
207
+ * than building a full AST via `parseTree`. This keeps the implementation
208
+ * lightweight and avoids an intermediate allocation.
209
+ *
210
+ * @public
211
+ */
212
+ const modify = Function.dual((args) => typeof args[0] === "string" && Array.isArray(args[1]), (text, path, value, options) => Effect.try({
213
+ try: () => modifyImpl(text, path, value, options?.formattingOptions),
214
+ catch: (e) => new JsoncModificationError({
215
+ path,
216
+ reason: String(e)
217
+ })
218
+ }));
219
+ function formatImpl(text, range, options) {
220
+ const opts = {
221
+ tabSize: options?.tabSize ?? 2,
222
+ insertSpaces: options?.insertSpaces ?? true,
223
+ eol: options?.eol ?? "\n",
224
+ insertFinalNewline: options?.insertFinalNewline ?? false,
225
+ keepLines: options?.keepLines ?? false
226
+ };
227
+ const indentUnit = opts.insertSpaces ? " ".repeat(opts.tabSize) : " ";
228
+ const edits = [];
229
+ const scanner = createScanner(text, false);
230
+ const rangeStart = range?.offset ?? 0;
231
+ const rangeEnd = range ? range.offset + range.length : text.length;
232
+ let depth = 0;
233
+ let prevTokenEnd = -1;
234
+ let prevToken = "Unknown";
235
+ let firstToken = true;
236
+ function makeIndent(d) {
237
+ return indentUnit.repeat(d);
238
+ }
239
+ function addEdit(offset, length, content) {
240
+ if (offset >= rangeStart && offset + length <= rangeEnd) {
241
+ if (text.substring(offset, offset + length) !== content) edits.push({
242
+ offset,
243
+ length,
244
+ content
245
+ });
246
+ }
247
+ }
248
+ let kind = scanner.scan();
249
+ while (kind !== "EOF") {
250
+ const tokenOffset = scanner.getTokenOffset();
251
+ const tokenLength = scanner.getTokenLength();
252
+ if (kind !== "Trivia" && kind !== "LineBreak") {
253
+ if (!firstToken && prevTokenEnd >= 0) {
254
+ const gap = text.substring(prevTokenEnd, tokenOffset);
255
+ let expectedGap;
256
+ if (kind === "CloseBrace" || kind === "CloseBracket") {
257
+ depth--;
258
+ expectedGap = opts.eol + makeIndent(depth);
259
+ } else if (prevToken === "OpenBrace" || prevToken === "OpenBracket") expectedGap = opts.eol + makeIndent(depth);
260
+ else if (prevToken === "Comma") expectedGap = opts.eol + makeIndent(depth);
261
+ else if (prevToken === "Colon") expectedGap = " ";
262
+ else if (kind === "LineComment" || kind === "BlockComment") if (gap.includes("\n")) expectedGap = opts.eol + makeIndent(depth);
263
+ else expectedGap = " ";
264
+ else if (prevToken === "LineComment") expectedGap = opts.eol + makeIndent(depth);
265
+ else if (prevToken === "BlockComment") if (gap.includes("\n")) expectedGap = opts.eol + makeIndent(depth);
266
+ else expectedGap = " ";
267
+ else expectedGap = gap;
268
+ if (opts.keepLines && gap.includes("\n")) expectedGap = gap;
269
+ addEdit(prevTokenEnd, tokenOffset - prevTokenEnd, expectedGap);
270
+ }
271
+ if (kind === "OpenBrace" || kind === "OpenBracket") depth++;
272
+ prevToken = kind;
273
+ prevTokenEnd = tokenOffset + tokenLength;
274
+ firstToken = false;
275
+ }
276
+ kind = scanner.scan();
277
+ }
278
+ if (opts.insertFinalNewline && prevTokenEnd >= 0) {
279
+ const trailing = text.substring(prevTokenEnd);
280
+ if (!trailing.endsWith(opts.eol)) edits.push({
281
+ offset: prevTokenEnd,
282
+ length: trailing.length,
283
+ content: opts.eol
284
+ });
285
+ }
286
+ return edits;
287
+ }
288
+ function modifyImpl(text, path, value, formattingOptions) {
289
+ const opts = {
290
+ tabSize: formattingOptions?.tabSize ?? 2,
291
+ insertSpaces: formattingOptions?.insertSpaces ?? true,
292
+ eol: formattingOptions?.eol ?? "\n"
293
+ };
294
+ const indentUnit = opts.insertSpaces ? " ".repeat(opts.tabSize) : " ";
295
+ if (path.length === 0) {
296
+ const content = value === void 0 ? "" : JSON.stringify(value, null, opts.tabSize);
297
+ return [{
298
+ offset: 0,
299
+ length: text.length,
300
+ content
301
+ }];
302
+ }
303
+ const scanner = createScanner(text, true);
304
+ let currentToken = scanner.scan();
305
+ function skipValue() {
306
+ switch (currentToken) {
307
+ case "OpenBrace": {
308
+ currentToken = scanner.scan();
309
+ let first = true;
310
+ while (currentToken !== "CloseBrace" && currentToken !== "EOF") {
311
+ if (!first && currentToken === "Comma") currentToken = scanner.scan();
312
+ if (currentToken === "String") {
313
+ currentToken = scanner.scan();
314
+ if (currentToken === "Colon") {
315
+ currentToken = scanner.scan();
316
+ skipValue();
317
+ }
318
+ } else currentToken = scanner.scan();
319
+ first = false;
320
+ }
321
+ if (currentToken === "CloseBrace") currentToken = scanner.scan();
322
+ break;
323
+ }
324
+ case "OpenBracket": {
325
+ currentToken = scanner.scan();
326
+ let first = true;
327
+ while (currentToken !== "CloseBracket" && currentToken !== "EOF") {
328
+ if (!first && currentToken === "Comma") currentToken = scanner.scan();
329
+ skipValue();
330
+ first = false;
331
+ }
332
+ if (currentToken === "CloseBracket") currentToken = scanner.scan();
333
+ break;
334
+ }
335
+ default:
336
+ currentToken = scanner.scan();
337
+ break;
338
+ }
339
+ }
340
+ let depth = 0;
341
+ for (const segment of path) {
342
+ depth++;
343
+ if (typeof segment === "string") {
344
+ if (currentToken !== "OpenBrace") throw new Error(`Expected object at depth ${depth}`);
345
+ currentToken = scanner.scan();
346
+ let found = false;
347
+ let lastValueEnd = scanner.getTokenOffset();
348
+ let isFirst = true;
349
+ while (currentToken !== "CloseBrace" && currentToken !== "EOF") {
350
+ if (!isFirst && currentToken === "Comma") currentToken = scanner.scan();
351
+ if (currentToken === "String") {
352
+ const key = scanner.getTokenValue();
353
+ currentToken = scanner.scan();
354
+ if (currentToken === "Colon") currentToken = scanner.scan();
355
+ if (key === segment) {
356
+ found = true;
357
+ if (depth === path.length) {
358
+ const valueStart = scanner.getTokenOffset();
359
+ const prevEnd = valueStart;
360
+ skipValue();
361
+ const valueEnd = scanner.getTokenOffset();
362
+ if (value === void 0) {
363
+ let removeStart = valueStart;
364
+ let removeEnd = valueEnd;
365
+ const keyStart = text.substring(0, valueStart).lastIndexOf(`"${segment}"`);
366
+ if (keyStart >= 0) removeStart = keyStart;
367
+ const commaPosBefore = text.substring(0, removeStart).trimEnd().lastIndexOf(",");
368
+ if (commaPosBefore >= 0) removeStart = commaPosBefore;
369
+ else {
370
+ const trimmedAfter = text.substring(removeEnd).match(/^(\s*,)/);
371
+ if (trimmedAfter) removeEnd += trimmedAfter[1].length;
372
+ }
373
+ return [{
374
+ offset: removeStart,
375
+ length: removeEnd - removeStart,
376
+ content: ""
377
+ }];
378
+ }
379
+ const serialized = JSON.stringify(value, null, opts.tabSize);
380
+ return [{
381
+ offset: prevEnd,
382
+ length: valueEnd - prevEnd,
383
+ content: serialized
384
+ }];
385
+ }
386
+ break;
387
+ }
388
+ skipValue();
389
+ } else currentToken = scanner.scan();
390
+ lastValueEnd = scanner.getTokenOffset();
391
+ isFirst = false;
392
+ }
393
+ if (!found && depth === path.length && value !== void 0) {
394
+ const indent = indentUnit.repeat(depth);
395
+ const serialized = JSON.stringify(value, null, opts.tabSize);
396
+ const insertText = isFirst ? `${opts.eol}${indent}"${segment}": ${serialized}${opts.eol}${indentUnit.repeat(depth - 1)}` : `,${opts.eol}${indent}"${segment}": ${serialized}`;
397
+ return [{
398
+ offset: lastValueEnd,
399
+ length: 0,
400
+ content: insertText
401
+ }];
402
+ }
403
+ } else {
404
+ if (currentToken !== "OpenBracket") throw new Error(`Expected array at depth ${depth}`);
405
+ currentToken = scanner.scan();
406
+ let idx = 0;
407
+ let lastEnd = scanner.getTokenOffset();
408
+ while (currentToken !== "CloseBracket" && currentToken !== "EOF") {
409
+ if (idx > 0 && currentToken === "Comma") currentToken = scanner.scan();
410
+ if (idx === segment) {
411
+ if (depth === path.length) {
412
+ const valueStart = scanner.getTokenOffset();
413
+ skipValue();
414
+ const valueEnd = scanner.getTokenOffset();
415
+ if (value === void 0) {
416
+ let removeEnd = valueEnd;
417
+ if (text.substring(removeEnd).trimStart().startsWith(",")) removeEnd = text.indexOf(",", removeEnd) + 1;
418
+ return [{
419
+ offset: valueStart,
420
+ length: removeEnd - valueStart,
421
+ content: ""
422
+ }];
423
+ }
424
+ const serialized = JSON.stringify(value, null, opts.tabSize);
425
+ return [{
426
+ offset: valueStart,
427
+ length: valueEnd - valueStart,
428
+ content: serialized
429
+ }];
430
+ }
431
+ break;
432
+ }
433
+ skipValue();
434
+ lastEnd = scanner.getTokenOffset();
435
+ idx++;
436
+ }
437
+ if (idx <= segment && depth === path.length && value !== void 0) {
438
+ const indent = indentUnit.repeat(depth);
439
+ const serialized = JSON.stringify(value, null, opts.tabSize);
440
+ const insertText = idx === 0 ? `${opts.eol}${indent}${serialized}${opts.eol}${indentUnit.repeat(depth - 1)}` : `,${opts.eol}${indent}${serialized}`;
441
+ return [{
442
+ offset: lastEnd,
443
+ length: 0,
444
+ content: insertText
445
+ }];
446
+ }
447
+ }
448
+ }
449
+ return [];
450
+ }
451
+
452
+ //#endregion
453
+ export { applyEdits, format, formatAndApply, modify };