@fgv/ts-json 5.0.0-2 → 5.0.0-21

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.
Files changed (94) hide show
  1. package/.vscode/launch.json +16 -0
  2. package/.vscode/settings.json +32 -0
  3. package/config/api-extractor.json +343 -0
  4. package/config/rig.json +16 -0
  5. package/dist/ts-json.d.ts +719 -28
  6. package/dist/tsdoc-metadata.json +1 -1
  7. package/lib/index.d.ts +2 -0
  8. package/lib/index.js +25 -0
  9. package/lib/packlets/diff/detailedDiff.d.ts +375 -0
  10. package/lib/packlets/diff/detailedDiff.js +342 -0
  11. package/lib/packlets/diff/index.d.ts +3 -0
  12. package/lib/packlets/diff/index.js +40 -0
  13. package/lib/packlets/diff/threeWayDiff.d.ts +263 -0
  14. package/lib/packlets/diff/threeWayDiff.js +261 -0
  15. package/lib/packlets/diff/utils.d.ts +6 -0
  16. package/lib/packlets/diff/utils.js +62 -0
  17. package/lib/packlets/editor/common.d.ts +6 -0
  18. package/lib/packlets/editor/jsonEditor.d.ts +57 -28
  19. package/lib/packlets/editor/jsonEditor.js +101 -32
  20. package/lib/test/legacy/jsonConverter.conditional.test.d.ts +2 -0
  21. package/lib/test/legacy/jsonEditor/rules/conditional.test.d.ts +2 -0
  22. package/lib/test/legacy/jsonEditor/rules/multivalue.test.d.ts +2 -0
  23. package/lib/test/legacy/jsonEditor/rules/references.test.d.ts +2 -0
  24. package/lib/test/legacy/jsonEditor/rules/templates.test.d.ts +2 -0
  25. package/lib/test/unit/contextHelpers.test.d.ts +2 -0
  26. package/lib/test/unit/converters.test.d.ts +2 -0
  27. package/lib/test/unit/diff/jsonDiff.test.d.ts +2 -0
  28. package/lib/test/unit/jsonConverter.test.d.ts +2 -0
  29. package/lib/test/unit/jsonEditor/jsonEditor.test.d.ts +2 -0
  30. package/lib/test/unit/jsonEditor/templateContext.test.d.ts +2 -0
  31. package/lib/test/unit/jsonReferenceMap.test.d.ts +2 -0
  32. package/package.json +11 -11
  33. package/src/index.ts +29 -0
  34. package/src/packlets/context/compositeJsonMap.ts +120 -0
  35. package/src/packlets/context/contextHelpers.ts +221 -0
  36. package/src/packlets/context/index.ts +33 -0
  37. package/src/packlets/context/jsonContext.ts +133 -0
  38. package/src/packlets/converters/converters.ts +117 -0
  39. package/src/packlets/converters/index.ts +26 -0
  40. package/src/packlets/converters/jsonConverter.ts +476 -0
  41. package/src/packlets/diff/detailedDiff.ts +585 -0
  42. package/src/packlets/diff/index.ts +24 -0
  43. package/src/packlets/diff/threeWayDiff.ts +420 -0
  44. package/src/packlets/diff/utils.ts +66 -0
  45. package/src/packlets/editor/common.ts +125 -0
  46. package/src/packlets/editor/index.ts +36 -0
  47. package/src/packlets/editor/jsonEditor.ts +523 -0
  48. package/src/packlets/editor/jsonEditorRule.ts +117 -0
  49. package/src/packlets/editor/jsonEditorState.ts +225 -0
  50. package/src/packlets/editor/jsonReferenceMap.ts +516 -0
  51. package/src/packlets/editor/rules/conditional.ts +222 -0
  52. package/src/packlets/editor/rules/index.ts +25 -0
  53. package/src/packlets/editor/rules/multivalue.ts +206 -0
  54. package/src/packlets/editor/rules/references.ts +177 -0
  55. package/src/packlets/editor/rules/templates.ts +159 -0
  56. package/CHANGELOG.md +0 -115
  57. package/lib/index.d.ts.map +0 -1
  58. package/lib/index.js.map +0 -1
  59. package/lib/packlets/context/compositeJsonMap.d.ts.map +0 -1
  60. package/lib/packlets/context/compositeJsonMap.js.map +0 -1
  61. package/lib/packlets/context/contextHelpers.d.ts.map +0 -1
  62. package/lib/packlets/context/contextHelpers.js.map +0 -1
  63. package/lib/packlets/context/index.d.ts.map +0 -1
  64. package/lib/packlets/context/index.js.map +0 -1
  65. package/lib/packlets/context/jsonContext.d.ts.map +0 -1
  66. package/lib/packlets/context/jsonContext.js.map +0 -1
  67. package/lib/packlets/converters/converters.d.ts.map +0 -1
  68. package/lib/packlets/converters/converters.js.map +0 -1
  69. package/lib/packlets/converters/index.d.ts.map +0 -1
  70. package/lib/packlets/converters/index.js.map +0 -1
  71. package/lib/packlets/converters/jsonConverter.d.ts.map +0 -1
  72. package/lib/packlets/converters/jsonConverter.js.map +0 -1
  73. package/lib/packlets/editor/common.d.ts.map +0 -1
  74. package/lib/packlets/editor/common.js.map +0 -1
  75. package/lib/packlets/editor/index.d.ts.map +0 -1
  76. package/lib/packlets/editor/index.js.map +0 -1
  77. package/lib/packlets/editor/jsonEditor.d.ts.map +0 -1
  78. package/lib/packlets/editor/jsonEditor.js.map +0 -1
  79. package/lib/packlets/editor/jsonEditorRule.d.ts.map +0 -1
  80. package/lib/packlets/editor/jsonEditorRule.js.map +0 -1
  81. package/lib/packlets/editor/jsonEditorState.d.ts.map +0 -1
  82. package/lib/packlets/editor/jsonEditorState.js.map +0 -1
  83. package/lib/packlets/editor/jsonReferenceMap.d.ts.map +0 -1
  84. package/lib/packlets/editor/jsonReferenceMap.js.map +0 -1
  85. package/lib/packlets/editor/rules/conditional.d.ts.map +0 -1
  86. package/lib/packlets/editor/rules/conditional.js.map +0 -1
  87. package/lib/packlets/editor/rules/index.d.ts.map +0 -1
  88. package/lib/packlets/editor/rules/index.js.map +0 -1
  89. package/lib/packlets/editor/rules/multivalue.d.ts.map +0 -1
  90. package/lib/packlets/editor/rules/multivalue.js.map +0 -1
  91. package/lib/packlets/editor/rules/references.d.ts.map +0 -1
  92. package/lib/packlets/editor/rules/references.js.map +0 -1
  93. package/lib/packlets/editor/rules/templates.d.ts.map +0 -1
  94. package/lib/packlets/editor/rules/templates.js.map +0 -1
@@ -0,0 +1,523 @@
1
+ /*
2
+ * Copyright (c) 2020 Erik Fortune
3
+ *
4
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ * of this software and associated documentation files (the "Software"), to deal
6
+ * in the Software without restriction, including without limitation the rights
7
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ * copies of the Software, and to permit persons to whom the Software is
9
+ * furnished to do so, subject to the following conditions:
10
+ *
11
+ * The above copyright notice and this permission notice shall be included in all
12
+ * copies or substantial portions of the Software.
13
+ *
14
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ * SOFTWARE.
21
+ */
22
+
23
+ import {
24
+ DetailedResult,
25
+ Result,
26
+ captureResult,
27
+ fail,
28
+ failWithDetail,
29
+ mapDetailedResults,
30
+ mapResults,
31
+ succeed,
32
+ succeedWithDetail
33
+ } from '@fgv/ts-utils';
34
+ import {
35
+ ConditionalJsonEditorRule,
36
+ MultiValueJsonEditorRule,
37
+ ReferenceJsonEditorRule,
38
+ TemplatedJsonEditorRule
39
+ } from './rules';
40
+
41
+ import {
42
+ JsonArray,
43
+ JsonObject,
44
+ JsonValue,
45
+ isJsonArray,
46
+ isJsonObject,
47
+ isJsonPrimitive
48
+ } from '@fgv/ts-json-base';
49
+ import { IJsonContext } from '../context';
50
+ import {
51
+ IJsonCloneEditor,
52
+ IJsonEditorMergeOptions,
53
+ IJsonEditorOptions,
54
+ IJsonEditorValidationOptions,
55
+ JsonEditFailureReason,
56
+ JsonPropertyEditFailureReason
57
+ } from './common';
58
+
59
+ import { IJsonEditorRule } from './jsonEditorRule';
60
+ import { JsonEditorState } from './jsonEditorState';
61
+
62
+ /**
63
+ * A {@link JsonEditor | JsonEditor} can be used to edit JSON objects in place or to
64
+ * clone any JSON value, applying a default context and optional set of editor rules that
65
+ * were supplied at initialization.
66
+ * @public
67
+ */
68
+ export class JsonEditor implements IJsonCloneEditor {
69
+ /**
70
+ * Default singleton {@link JsonEditor | JsonEditor}.
71
+ * @internal
72
+ */
73
+ protected static _default?: JsonEditor;
74
+
75
+ /**
76
+ * Full set of {@link IJsonEditorOptions | editor options} in effect for this editor.
77
+ */
78
+ public options: IJsonEditorOptions;
79
+
80
+ /**
81
+ * The set of {@link IJsonEditorRule | editor rules} applied by this editor.
82
+ * @internal
83
+ */
84
+ protected _rules: IJsonEditorRule[];
85
+
86
+ /**
87
+ * Protected constructor for {@link JsonEditor | JsonEditor} and derived classes.
88
+ * External consumers should instantiate via the {@link JsonEditor.create | create static method}.
89
+ * @param options - Optional partial {@link IJsonEditorOptions | editor options} for the
90
+ * constructed editor.
91
+ * @param rules - Any {@link IJsonEditorRule | editor rules} to be applied by the editor.
92
+ * @internal
93
+ */
94
+ protected constructor(options?: Partial<IJsonEditorOptions>, rules?: IJsonEditorRule[]) {
95
+ this.options = JsonEditor._getDefaultOptions(options).orThrow();
96
+ this._rules = rules || JsonEditor.getDefaultRules(this.options).orThrow();
97
+ }
98
+
99
+ /**
100
+ * Default singleton {@link JsonEditor | JsonEditor} for simple use. Applies all rules
101
+ * but with no default context.
102
+ */
103
+ public static get default(): JsonEditor {
104
+ if (!JsonEditor._default) {
105
+ const rules = this.getDefaultRules().orDefault();
106
+ JsonEditor._default = new JsonEditor(undefined, rules);
107
+ }
108
+ return JsonEditor._default;
109
+ }
110
+
111
+ /**
112
+ * Constructs a new {@link JsonEditor | JsonEditor}.
113
+ * @param options - Optional partial {@link IJsonEditorOptions | editor options} for the
114
+ * constructed editor.
115
+ * @param rules - Optional set of {@link IJsonEditorRule | editor rules} to be applied by the editor.
116
+ * @readonly A new {@link JsonEditor | JsonEditor}.
117
+ */
118
+ public static create(options?: Partial<IJsonEditorOptions>, rules?: IJsonEditorRule[]): Result<JsonEditor> {
119
+ return captureResult(() => new JsonEditor(options, rules));
120
+ }
121
+
122
+ /**
123
+ * Gets the default set of rules to be applied for a given set of options.
124
+ * By default, all available rules (templates, conditionals, multi-value and references)
125
+ * are applied.
126
+ * @param options - Optional partial {@link IJsonEditorOptions | editor options} for
127
+ * all rules.
128
+ * @returns Default {@link IJsonEditorRule | editor rules} with any supplied options
129
+ * applied.
130
+ */
131
+ public static getDefaultRules(options?: IJsonEditorOptions): Result<IJsonEditorRule[]> {
132
+ return mapResults<IJsonEditorRule>([
133
+ TemplatedJsonEditorRule.create(options),
134
+ ConditionalJsonEditorRule.create(options),
135
+ MultiValueJsonEditorRule.create(options),
136
+ ReferenceJsonEditorRule.create(options)
137
+ ]);
138
+ }
139
+
140
+ /**
141
+ * Creates a complete IJsonEditorOptions object from partial options, filling in
142
+ * default values for any missing properties. This ensures all editor instances
143
+ * have consistent, complete configuration including validation rules and merge behavior.
144
+ * @param options - Optional partial editor options to merge with defaults
145
+ * @returns Success with complete editor options, or Failure if validation fails
146
+ * @internal
147
+ */
148
+ protected static _getDefaultOptions(options?: Partial<IJsonEditorOptions>): Result<IJsonEditorOptions> {
149
+ const context: IJsonContext | undefined = options?.context;
150
+ let validation: IJsonEditorValidationOptions | undefined = options?.validation;
151
+ if (validation === undefined) {
152
+ validation = {
153
+ onInvalidPropertyName: 'error',
154
+ onInvalidPropertyValue: 'error',
155
+ onUndefinedPropertyValue: 'ignore'
156
+ };
157
+ }
158
+ let merge: IJsonEditorMergeOptions | undefined = options?.merge;
159
+ if (merge === undefined) {
160
+ merge = {
161
+ arrayMergeBehavior: 'append',
162
+ nullAsDelete: false
163
+ };
164
+ } else {
165
+ merge = {
166
+ arrayMergeBehavior: merge.arrayMergeBehavior,
167
+ nullAsDelete: merge.nullAsDelete ?? false
168
+ };
169
+ }
170
+ return succeed({ context, validation, merge });
171
+ }
172
+
173
+ /**
174
+ * Merges a supplied source object into a supplied target, updating the target object.
175
+ * @param target - The target `JsonObject` to be updated
176
+ * @param src - The source `JsonObject` to be merged
177
+ * @param runtimeContext - An optional {@link IJsonContext | IJsonContext} supplying variables
178
+ * and references.
179
+ * @returns `Success` with the original source `JsonObject` if merge was successful.
180
+ * Returns `Failure` with details if an error occurs.
181
+ */
182
+ public mergeObjectInPlace(
183
+ target: JsonObject,
184
+ src: JsonObject,
185
+ runtimeContext?: IJsonContext
186
+ ): Result<JsonObject> {
187
+ const state = new JsonEditorState(this, this.options, runtimeContext);
188
+ return this._mergeObjectInPlace(target, src, state).onSuccess((merged) => {
189
+ return this._finalizeAndMerge(merged, state);
190
+ });
191
+ }
192
+
193
+ /**
194
+ * Merges multiple supplied source objects into a supplied target, updating the target
195
+ * object and using the default context supplied at creation time.
196
+ * @param target - The target `JsonObject` to be updated
197
+ * @param srcObjects - `JsonObject`s to be merged into the target object, in the order
198
+ * supplied.
199
+ * @returns `Success` with the original source `JsonObject` if merge was successful.
200
+ * Returns `Failure` with details if an error occurs.
201
+ */
202
+ public mergeObjectsInPlace(target: JsonObject, srcObjects: JsonObject[]): Result<JsonObject> {
203
+ return this.mergeObjectsInPlaceWithContext(this.options.context, target, srcObjects);
204
+ }
205
+
206
+ /**
207
+ * Merges multiple supplied source objects into a supplied target, updating the target
208
+ * object and using an optional {@link IJsonContext | context} supplied in the call.
209
+ * @param context - An optional {@link IJsonContext | IJsonContext} supplying variables and
210
+ * references.
211
+ * @param base - The base `JsonObject` to be updated
212
+ * @param srcObjects - Objects to be merged into the target object, in the order supplied.
213
+ * @returns `Success` with the original source `JsonObject` if merge was successful.
214
+ * Returns `Failure` with details if an error occurs.
215
+ */
216
+ public mergeObjectsInPlaceWithContext(
217
+ context: IJsonContext | undefined,
218
+ base: JsonObject,
219
+ srcObjects: JsonObject[]
220
+ ): Result<JsonObject> {
221
+ for (const src of srcObjects) {
222
+ const mergeResult = this.mergeObjectInPlace(base, src, context);
223
+ if (mergeResult.isFailure()) {
224
+ return mergeResult.withFailureDetail('error');
225
+ }
226
+ }
227
+ return succeedWithDetail(base);
228
+ }
229
+
230
+ /**
231
+ * Deep clones a supplied `JsonValue`, applying all editor rules and a default
232
+ * or optionally supplied context
233
+ * @param src - The `JsonValue` to be cloned.
234
+ * @param context - An optional {@link IJsonContext | JSON context} supplying variables and references.
235
+ */
236
+ public clone(src: JsonValue, context?: IJsonContext): DetailedResult<JsonValue, JsonEditFailureReason> {
237
+ const state = new JsonEditorState(this, this.options, context);
238
+ let value = src;
239
+ let valueResult = this._editValue(src, state);
240
+
241
+ while (valueResult.isSuccess()) {
242
+ value = valueResult.value;
243
+ valueResult = this._editValue(value, state);
244
+ }
245
+
246
+ if (valueResult.detail === 'error' || valueResult.detail === 'ignore') {
247
+ return valueResult;
248
+ }
249
+
250
+ if (isJsonPrimitive(value) || value === null) {
251
+ return succeedWithDetail(value, 'edited');
252
+ } else if (isJsonObject(value)) {
253
+ return this._cloneObjectWithoutNullAsDelete({}, value, state);
254
+ } else if (isJsonArray(value)) {
255
+ return this._cloneArray(value, state.context);
256
+ } else if (value === undefined) {
257
+ return state.failValidation('undefinedPropertyValue');
258
+ }
259
+ return state.failValidation(
260
+ 'invalidPropertyValue',
261
+ `Cannot convert invalid JSON: "${JSON.stringify(value)}"`
262
+ );
263
+ }
264
+
265
+ /**
266
+ * Merges properties from a source object into a target object, applying editor rules and
267
+ * null-as-delete logic. This is the core merge implementation that handles property-by-property
268
+ * merging with rule processing and deferred property handling.
269
+ * @param target - The target object to merge properties into
270
+ * @param src - The source object containing properties to merge
271
+ * @param state - The editor state containing options and context
272
+ * @returns Success with the modified target object, or Failure with error details
273
+ * @internal
274
+ */
275
+ protected _mergeObjectInPlace(
276
+ target: JsonObject,
277
+ src: JsonObject,
278
+ state: JsonEditorState
279
+ ): Result<JsonObject> {
280
+ for (const key in src) {
281
+ if (src.hasOwnProperty(key)) {
282
+ // Skip dangerous property names to prevent prototype pollution
283
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
284
+ continue;
285
+ }
286
+ const propResult = this._editProperty(key, src[key], state);
287
+ if (propResult.isSuccess()) {
288
+ if (propResult.detail === 'deferred') {
289
+ state.defer(propResult.value);
290
+ } else {
291
+ const mergeResult = this._mergeObjectInPlace(target, propResult.value, state);
292
+ if (mergeResult.isFailure()) {
293
+ return mergeResult;
294
+ }
295
+ }
296
+ } else if (propResult.detail === 'inapplicable') {
297
+ const valueResult = this.clone(src[key], state.context).onSuccess((cloned) => {
298
+ return this._mergeClonedProperty(target, key, cloned, state);
299
+ });
300
+
301
+ if (valueResult.isFailure() && valueResult.detail === 'error') {
302
+ return fail(`${key}: ${valueResult.message}`);
303
+ }
304
+ } else if (propResult.detail !== 'ignore') {
305
+ return fail(`${key}: ${propResult.message}`);
306
+ }
307
+ } else {
308
+ return fail(`${key}: Cannot merge inherited properties`);
309
+ }
310
+ }
311
+ return succeed(target);
312
+ }
313
+
314
+ /**
315
+ * Creates a deep clone of a JSON array by recursively cloning each element.
316
+ * Each array element is cloned using the main clone method, preserving the
317
+ * editor's rules and validation settings.
318
+ * @param src - The source JSON array to clone
319
+ * @param context - Optional JSON context for cloning operations
320
+ * @returns Success with the cloned array, or Failure with error details
321
+ * @internal
322
+ */
323
+ protected _cloneArray(
324
+ src: JsonArray,
325
+ context?: IJsonContext
326
+ ): DetailedResult<JsonArray, JsonEditFailureReason> {
327
+ const results = src.map((v) => {
328
+ return this.clone(v, context);
329
+ });
330
+
331
+ return mapDetailedResults<JsonValue, JsonEditFailureReason>(results, ['ignore'])
332
+ .onSuccess((converted) => {
333
+ return succeed(converted);
334
+ })
335
+ .withFailureDetail('error');
336
+ }
337
+
338
+ /**
339
+ * Merges a single cloned property value into a target object. This method handles
340
+ * the core merge logic including null-as-delete behavior, array merging, and
341
+ * recursive object merging. The null-as-delete check occurs before primitive
342
+ * handling to ensure null values can signal property deletion.
343
+ * @param target - The target object to merge the property into
344
+ * @param key - The property key being merged
345
+ * @param newValue - The cloned value to merge (from source object)
346
+ * @param state - The editor state containing merge options and context
347
+ * @returns Success with the merged value, or Failure with error details
348
+ * @internal
349
+ */
350
+ protected _mergeClonedProperty(
351
+ target: JsonObject,
352
+ key: string,
353
+ newValue: JsonValue,
354
+ state: JsonEditorState
355
+ ): DetailedResult<JsonValue, JsonEditFailureReason> {
356
+ // Skip dangerous property names to prevent prototype pollution
357
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
358
+ return succeedWithDetail(newValue, 'edited');
359
+ }
360
+
361
+ const existing = target[key];
362
+
363
+ // Handle null-as-delete behavior before primitive check
364
+ /* c8 ignore next 1 - ? is defense in depth */
365
+ if (newValue === null && state.options.merge?.nullAsDelete === true) {
366
+ delete target[key];
367
+ return succeedWithDetail(null, 'edited');
368
+ }
369
+
370
+ // merge is called right after clone so this should never happen
371
+ // since clone itself will have failed
372
+
373
+ if (isJsonPrimitive(newValue)) {
374
+ target[key] = newValue;
375
+ return succeedWithDetail(newValue, 'edited');
376
+ }
377
+
378
+ if (isJsonObject(newValue)) {
379
+ if (isJsonObject(existing)) {
380
+ return this.mergeObjectInPlace(existing, newValue, state.context).withFailureDetail('error');
381
+ }
382
+ target[key] = newValue;
383
+ return succeedWithDetail(newValue, 'edited');
384
+ }
385
+
386
+ /* c8 ignore else */
387
+ if (isJsonArray(newValue)) {
388
+ if (isJsonArray(existing)) {
389
+ /* c8 ignore next 1 - ?? is defense in depth */
390
+ const arrayMergeBehavior = state.options.merge?.arrayMergeBehavior ?? 'append';
391
+ switch (arrayMergeBehavior) {
392
+ case 'append':
393
+ target[key] = existing.concat(...newValue);
394
+ break;
395
+ case 'replace':
396
+ target[key] = newValue;
397
+ break;
398
+ /* c8 ignore next 2 - exhaustive switch for ArrayMergeBehavior type */
399
+ default:
400
+ return failWithDetail(`Invalid array merge behavior: ${arrayMergeBehavior as string}`, 'error');
401
+ }
402
+ return succeedWithDetail(target[key], 'edited');
403
+ }
404
+ target[key] = newValue;
405
+ return succeedWithDetail(newValue, 'edited');
406
+ }
407
+ /* c8 ignore start */
408
+ return failWithDetail(`Invalid JSON: ${JSON.stringify(newValue)}`, 'error');
409
+ } /* c8 ignore stop */
410
+
411
+ /**
412
+ * Applies editor rules to a single property during merge operations. This method
413
+ * iterates through all configured editor rules to process the property, handling
414
+ * templates, conditionals, multi-value properties, and references.
415
+ * @param key - The property key to edit
416
+ * @param value - The property value to edit
417
+ * @param state - The editor state containing rules and context
418
+ * @returns Success with transformed property object, or Failure if rules cannot process
419
+ * @internal
420
+ */
421
+ protected _editProperty(
422
+ key: string,
423
+ value: JsonValue,
424
+ state: JsonEditorState
425
+ ): DetailedResult<JsonObject, JsonPropertyEditFailureReason> {
426
+ for (const rule of this._rules) {
427
+ const ruleResult = rule.editProperty(key, value, state);
428
+ if (ruleResult.isSuccess() || ruleResult.detail !== 'inapplicable') {
429
+ return ruleResult;
430
+ }
431
+ }
432
+ return failWithDetail('inapplicable', 'inapplicable');
433
+ }
434
+
435
+ /**
436
+ * Applies editor rules to a single JSON value during clone operations. This method
437
+ * iterates through all configured editor rules to process the value, handling
438
+ * templates, conditionals, multi-value expressions, and references.
439
+ * @param value - The JSON value to edit and transform
440
+ * @param state - The editor state containing rules and context
441
+ * @returns Success with transformed value, or Failure if rules cannot process
442
+ * @internal
443
+ */
444
+ protected _editValue(
445
+ value: JsonValue,
446
+ state: JsonEditorState
447
+ ): DetailedResult<JsonValue, JsonEditFailureReason> {
448
+ for (const rule of this._rules) {
449
+ const ruleResult = rule.editValue(value, state);
450
+ if (ruleResult.isSuccess() || ruleResult.detail !== 'inapplicable') {
451
+ return ruleResult;
452
+ }
453
+ }
454
+ return failWithDetail('inapplicable', 'inapplicable');
455
+ }
456
+
457
+ /**
458
+ * Clone an object without applying null-as-delete behavior.
459
+ * This preserves null values during cloning so they can be used for deletion signaling during merge.
460
+ * @param target - The target object to clone into
461
+ * @param src - The source object to clone
462
+ * @param state - The editor state
463
+ * @returns The cloned object
464
+ * @internal
465
+ */
466
+ protected _cloneObjectWithoutNullAsDelete(
467
+ target: JsonObject,
468
+ src: JsonObject,
469
+ state: JsonEditorState
470
+ ): DetailedResult<JsonObject, JsonEditFailureReason> {
471
+ // Temporarily disable null-as-delete during cloning
472
+ const modifiedOptions: IJsonEditorOptions = {
473
+ context: state.options.context,
474
+ validation: state.options.validation,
475
+ merge: {
476
+ /* c8 ignore next 1 - ? is defense in depth */
477
+ arrayMergeBehavior: state.options.merge?.arrayMergeBehavior ?? 'append',
478
+ nullAsDelete: false
479
+ }
480
+ };
481
+ const modifiedState = new JsonEditorState(state.editor, modifiedOptions, state.context);
482
+
483
+ return this._mergeObjectInPlace(target, src, modifiedState)
484
+ .onSuccess((merged) => {
485
+ return this._finalizeAndMerge(merged, modifiedState);
486
+ })
487
+ .withFailureDetail('error');
488
+ }
489
+
490
+ /**
491
+ * Finalizes the merge operation by processing any deferred properties and merging
492
+ * them into the target object. Deferred properties are those that require special
493
+ * processing after the initial merge phase, such as references that depend on
494
+ * other properties being resolved first.
495
+ * @param target - The target object that has been merged
496
+ * @param state - The editor state containing deferred properties and rules
497
+ * @returns Success with the finalized target object, or Failure with error details
498
+ * @internal
499
+ */
500
+ protected _finalizeAndMerge(
501
+ target: JsonObject,
502
+ state: JsonEditorState
503
+ ): DetailedResult<JsonObject, JsonEditFailureReason> {
504
+ const deferred = state.deferred;
505
+ if (deferred.length > 0) {
506
+ for (const rule of this._rules) {
507
+ const ruleResult = rule.finalizeProperties(deferred, state);
508
+ if (ruleResult.isSuccess()) {
509
+ return this.mergeObjectsInPlaceWithContext(
510
+ state.context,
511
+ target,
512
+ ruleResult.value
513
+ ).withFailureDetail('error');
514
+ } else if (ruleResult.detail === 'ignore') {
515
+ succeedWithDetail(target, 'edited');
516
+ } else if (ruleResult.detail !== 'inapplicable') {
517
+ return failWithDetail(ruleResult.message, ruleResult.detail);
518
+ }
519
+ }
520
+ }
521
+ return succeedWithDetail(target, 'edited');
522
+ }
523
+ }
@@ -0,0 +1,117 @@
1
+ /*
2
+ * Copyright (c) 2020 Erik Fortune
3
+ *
4
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ * of this software and associated documentation files (the "Software"), to deal
6
+ * in the Software without restriction, including without limitation the rights
7
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ * copies of the Software, and to permit persons to whom the Software is
9
+ * furnished to do so, subject to the following conditions:
10
+ *
11
+ * The above copyright notice and this permission notice shall be included in all
12
+ * copies or substantial portions of the Software.
13
+ *
14
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ * SOFTWARE.
21
+ */
22
+
23
+ import { JsonObject, JsonValue } from '@fgv/ts-json-base';
24
+ import { DetailedResult, failWithDetail } from '@fgv/ts-utils';
25
+ import { JsonEditFailureReason, JsonPropertyEditFailureReason } from './common';
26
+
27
+ import { JsonEditorState } from './jsonEditorState';
28
+
29
+ /**
30
+ * An {@link IJsonEditorRule | IJsonEditorRule} represents a single configurable
31
+ * rule to be applied by a {@link JsonEditor | JsonEditor}.
32
+ * @public
33
+ */
34
+ export interface IJsonEditorRule {
35
+ /**
36
+ * Called by a {@link JsonEditor | JsonEditor} to possibly edit one of the properties being
37
+ * merged into a target object.
38
+ * @param key - The key of the property to be edited.
39
+ * @param value - The `JsonValue` of the property to be edited.
40
+ * @param state - {@link JsonEditorState | Editor state} which applies to the edit.
41
+ * @returns If the property was edited, returns `Success` with a `JsonObject` containing
42
+ * the edited results and with detail `'edited'`. If this property should be deferred for later consideration
43
+ * or merge, `Success` with detail `'deferred'` and a `JsonObject` to be finalized. If
44
+ * the rule does not affect this property, returns `Failure` with detail `'inapplicable'`. If an error occurred
45
+ * while processing the error, returns `Failure` with detail `'error'`.
46
+ */
47
+ editProperty(
48
+ key: string,
49
+ value: JsonValue,
50
+ state: JsonEditorState
51
+ ): DetailedResult<JsonObject, JsonPropertyEditFailureReason>;
52
+
53
+ /**
54
+ * Called by a {@link JsonEditor | JsonEditor} to possibly edit a property value or array element.
55
+ * @param value - The `JsonValue` of the property to be edited.
56
+ * @param state - {@link JsonEditorState | Editor state} which applies to the edit.
57
+ * @returns Returns `Success` with the `JsonValue` to be inserted, with detail `'edited'` if
58
+ * the value was edited. Returns `Failure` with `'inapplicable'` if the rule does not affect this value.
59
+ * Fails with detail `'ignore'` if the value is to be ignored, or with `'error'` if an error occurs.
60
+ */
61
+ editValue(value: JsonValue, state: JsonEditorState): DetailedResult<JsonValue, JsonEditFailureReason>;
62
+
63
+ /**
64
+ * Called for each rule after all properties have been merged. Any properties that were deferred
65
+ * during the initial edit pass are supplied as input.
66
+ * @param deferred - Any JSON objects that were deferred during the first edit pass.
67
+ * @param state - {@link JsonEditorState | Editor state} which applies to the edit.
68
+ * @returns On `Success` return, any returned objects are merged in order and finalization
69
+ * is stopped. Finalization is also stopped on `Failure` with detail `'ignore'`. On `Failure`
70
+ * with detail `'inapplicable'`, finalization continues with the next rule. Fails with an
71
+ * error detail `'error'` and an informative message if an error occurs.
72
+ */
73
+ finalizeProperties(
74
+ deferred: JsonObject[],
75
+ state: JsonEditorState
76
+ ): DetailedResult<JsonObject[], JsonEditFailureReason>;
77
+ }
78
+
79
+ /**
80
+ * Default base implementation of {@link IJsonEditorRule | IJsonEditorRule} returns inapplicable for all operations so that
81
+ * derived classes need only implement the operations they actually support.
82
+ * @public
83
+ */
84
+ export class JsonEditorRuleBase implements IJsonEditorRule {
85
+ /**
86
+ * {@inheritdoc IJsonEditorRule.editProperty}
87
+ */
88
+ /* c8 ignore start */
89
+ public editProperty(
90
+ __key: string,
91
+ __value: JsonValue,
92
+ __state: JsonEditorState
93
+ ): DetailedResult<JsonObject, JsonPropertyEditFailureReason> {
94
+ return failWithDetail('inapplicable', 'inapplicable');
95
+ }
96
+ /* c8 ignore stop */
97
+
98
+ /**
99
+ * {@inheritdoc IJsonEditorRule.editValue}
100
+ */
101
+ public editValue(
102
+ __value: JsonValue,
103
+ __state: JsonEditorState
104
+ ): DetailedResult<JsonValue, JsonEditFailureReason> {
105
+ return failWithDetail('inapplicable', 'inapplicable');
106
+ }
107
+
108
+ /**
109
+ * {@inheritdoc IJsonEditorRule.finalizeProperties}
110
+ */
111
+ public finalizeProperties(
112
+ __deferred: JsonObject[],
113
+ __state: JsonEditorState
114
+ ): DetailedResult<JsonObject[], JsonEditFailureReason> {
115
+ return failWithDetail('inapplicable', 'inapplicable');
116
+ }
117
+ }