@valbuild/server 0.12.0 → 0.13.3

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.
@@ -0,0 +1,803 @@
1
+ import ts from "typescript";
2
+ import { result, array, pipe } from "@valbuild/core/fp";
3
+ import {
4
+ validateInitializers,
5
+ evaluateExpression,
6
+ findObjectPropertyAssignment,
7
+ ValSyntaxErrorTree,
8
+ shallowValidateExpression,
9
+ isValFileMethodCall,
10
+ findValFileNodeArg,
11
+ findValFileMetadataArg,
12
+ } from "./syntax";
13
+ import {
14
+ deepEqual,
15
+ isNotRoot,
16
+ Ops,
17
+ PatchError,
18
+ JSONValue,
19
+ parseAndValidateArrayIndex,
20
+ } from "@valbuild/core/patch";
21
+ import { FILE_REF_PROP, FileSource } from "@valbuild/core";
22
+ import { JsonPrimitive } from "@valbuild/core/src/Json";
23
+
24
+ type TSOpsResult<T> = result.Result<T, PatchError | ValSyntaxErrorTree>;
25
+
26
+ declare module "typescript" {
27
+ interface PrinterOptions {
28
+ /**
29
+ * Internal option that stops printing unnecessary ASCII escape sequences
30
+ * in strings, though it might have unintended effects. Might be useful?
31
+ */
32
+ neverAsciiEscape?: boolean;
33
+ }
34
+ }
35
+
36
+ function isValidIdentifier(text: string): boolean {
37
+ if (text.length === 0) {
38
+ return false;
39
+ }
40
+
41
+ if (!ts.isIdentifierStart(text.charCodeAt(0), ts.ScriptTarget.ES2020)) {
42
+ return false;
43
+ }
44
+
45
+ for (let i = 1; i < text.length; ++i) {
46
+ if (!ts.isIdentifierPart(text.charCodeAt(i), ts.ScriptTarget.ES2020)) {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ return true;
52
+ }
53
+
54
+ function createPropertyAssignment(key: string, value: JSONValue) {
55
+ return ts.factory.createPropertyAssignment(
56
+ isValidIdentifier(key)
57
+ ? ts.factory.createIdentifier(key)
58
+ : ts.factory.createStringLiteral(key),
59
+ toExpression(value)
60
+ );
61
+ }
62
+
63
+ function createValFileReference(ref: string) {
64
+ return ts.factory.createCallExpression(
65
+ ts.factory.createPropertyAccessExpression(
66
+ ts.factory.createIdentifier("val"),
67
+ ts.factory.createIdentifier("file")
68
+ ),
69
+ undefined,
70
+ [ts.factory.createStringLiteral(ref)]
71
+ );
72
+ }
73
+
74
+ function toExpression(value: JSONValue): ts.Expression {
75
+ if (typeof value === "string") {
76
+ // TODO: Use configuration/heuristics to determine use of single quote or double quote
77
+ return ts.factory.createStringLiteral(value);
78
+ } else if (typeof value === "number") {
79
+ return ts.factory.createNumericLiteral(value);
80
+ } else if (typeof value === "boolean") {
81
+ return value ? ts.factory.createTrue() : ts.factory.createFalse();
82
+ } else if (value === null) {
83
+ return ts.factory.createNull();
84
+ } else if (Array.isArray(value)) {
85
+ return ts.factory.createArrayLiteralExpression(value.map(toExpression));
86
+ } else if (typeof value === "object") {
87
+ if (isValFileValue(value)) {
88
+ return createValFileReference(value[FILE_REF_PROP]);
89
+ }
90
+ return ts.factory.createObjectLiteralExpression(
91
+ Object.entries(value).map(([key, value]) =>
92
+ createPropertyAssignment(key, value)
93
+ )
94
+ );
95
+ } else {
96
+ return ts.factory.createStringLiteral(value);
97
+ }
98
+ }
99
+
100
+ // TODO: Choose newline based on project settings/heuristics/system default?
101
+ const newLine = ts.NewLineKind.LineFeed;
102
+ // TODO: Handle indentation of printed code
103
+ const printer = ts.createPrinter({
104
+ newLine: newLine,
105
+ // neverAsciiEscape: true,
106
+ });
107
+
108
+ function replaceNodeValue<T extends ts.Node>(
109
+ document: ts.SourceFile,
110
+ node: T,
111
+ value: JSONValue
112
+ ): [document: ts.SourceFile, replaced: T] {
113
+ const replacementText = printer.printNode(
114
+ ts.EmitHint.Unspecified,
115
+ toExpression(value),
116
+ document
117
+ );
118
+ const span = ts.createTextSpanFromBounds(
119
+ node.getStart(document, false),
120
+ node.end
121
+ );
122
+ const newText = `${document.text.substring(
123
+ 0,
124
+ span.start
125
+ )}${replacementText}${document.text.substring(ts.textSpanEnd(span))}`;
126
+
127
+ return [
128
+ document.update(
129
+ newText,
130
+ ts.createTextChangeRange(span, replacementText.length)
131
+ ),
132
+ node,
133
+ ];
134
+ }
135
+
136
+ function isIndentation(s: string): boolean {
137
+ for (let i = 0; i < s.length; ++i) {
138
+ const c = s.charAt(i);
139
+ if (c !== " " && c !== "\t") {
140
+ return false;
141
+ }
142
+ }
143
+ return true;
144
+ }
145
+
146
+ function newLineStr(kind: ts.NewLineKind) {
147
+ if (kind === ts.NewLineKind.CarriageReturnLineFeed) {
148
+ return "\r\n";
149
+ } else {
150
+ return "\n";
151
+ }
152
+ }
153
+
154
+ function getSeparator(document: ts.SourceFile, neighbor: ts.Node): string {
155
+ const startPos = neighbor.getStart(document, true);
156
+ const basis = document.getLineAndCharacterOfPosition(startPos);
157
+ const lineStartPos = document.getPositionOfLineAndCharacter(basis.line, 0);
158
+ const maybeIndentation = document.getText().substring(lineStartPos, startPos);
159
+
160
+ if (isIndentation(maybeIndentation)) {
161
+ return `,${newLineStr(newLine)}${maybeIndentation}`;
162
+ } else {
163
+ return `, `;
164
+ }
165
+ }
166
+
167
+ function insertAt<T extends ts.Node>(
168
+ document: ts.SourceFile,
169
+ nodes: ts.NodeArray<T>,
170
+ index: number,
171
+ node: T
172
+ ): ts.SourceFile {
173
+ let span: ts.TextSpan;
174
+ let replacementText: string;
175
+ if (nodes.length === 0) {
176
+ // Replace entire range of nodes
177
+ replacementText = printer.printNode(
178
+ ts.EmitHint.Unspecified,
179
+ node,
180
+ document
181
+ );
182
+ span = ts.createTextSpanFromBounds(nodes.pos, nodes.end);
183
+ } else if (index === nodes.length) {
184
+ // Insert after last node
185
+ const neighbor = nodes[nodes.length - 1];
186
+ replacementText = `${getSeparator(document, neighbor)}${printer.printNode(
187
+ ts.EmitHint.Unspecified,
188
+ node,
189
+ document
190
+ )}`;
191
+ span = ts.createTextSpan(neighbor.end, 0);
192
+ } else {
193
+ // Insert before node
194
+ const neighbor = nodes[index];
195
+ replacementText = `${printer.printNode(
196
+ ts.EmitHint.Unspecified,
197
+ node,
198
+ document
199
+ )}${getSeparator(document, neighbor)}`;
200
+ span = ts.createTextSpan(neighbor.getStart(document, true), 0);
201
+ }
202
+
203
+ const newText = `${document.text.substring(
204
+ 0,
205
+ span.start
206
+ )}${replacementText}${document.text.substring(ts.textSpanEnd(span))}`;
207
+
208
+ return document.update(
209
+ newText,
210
+ ts.createTextChangeRange(span, replacementText.length)
211
+ );
212
+ }
213
+
214
+ function removeAt<T extends ts.Node>(
215
+ document: ts.SourceFile,
216
+ nodes: ts.NodeArray<T>,
217
+ index: number
218
+ ): [document: ts.SourceFile, removed: T] {
219
+ const node = nodes[index];
220
+ let span: ts.TextSpan;
221
+
222
+ if (nodes.length === 1) {
223
+ span = ts.createTextSpanFromBounds(nodes.pos, nodes.end);
224
+ } else if (index === nodes.length - 1) {
225
+ // Remove until previous node
226
+ const neighbor = nodes[index - 1];
227
+ span = ts.createTextSpanFromBounds(neighbor.end, node.end);
228
+ } else {
229
+ // Remove before next node
230
+ const neighbor = nodes[index + 1];
231
+ span = ts.createTextSpanFromBounds(
232
+ node.getStart(document, true),
233
+ neighbor.getStart(document, true)
234
+ );
235
+ }
236
+ const newText = `${document.text.substring(
237
+ 0,
238
+ span.start
239
+ )}${document.text.substring(ts.textSpanEnd(span))}`;
240
+
241
+ return [document.update(newText, ts.createTextChangeRange(span, 0)), node];
242
+ }
243
+
244
+ function parseAndValidateArrayInsertIndex(
245
+ key: string,
246
+ nodes: ReadonlyArray<ts.Expression>
247
+ ): TSOpsResult<number> {
248
+ if (key === "-") {
249
+ // For insertion, all nodes up until the insertion index must be valid
250
+ // initializers
251
+ const err = validateInitializers(nodes);
252
+ if (err) {
253
+ return result.err(err);
254
+ }
255
+ return result.ok(nodes.length);
256
+ }
257
+
258
+ return pipe(
259
+ parseAndValidateArrayIndex(key),
260
+ result.flatMap((index: number): TSOpsResult<number> => {
261
+ // For insertion, all nodes up until the insertion index must be valid
262
+ // initializers
263
+ const err = validateInitializers(nodes.slice(0, index));
264
+ if (err) {
265
+ return result.err(err);
266
+ }
267
+ if (index > nodes.length) {
268
+ return result.err(new PatchError("Array index out of bounds"));
269
+ } else {
270
+ return result.ok(index);
271
+ }
272
+ })
273
+ );
274
+ }
275
+
276
+ function parseAndValidateArrayInboundsIndex(
277
+ key: string,
278
+ nodes: ReadonlyArray<ts.Expression>
279
+ ): TSOpsResult<number> {
280
+ return pipe(
281
+ parseAndValidateArrayIndex(key),
282
+ result.flatMap((index: number): TSOpsResult<number> => {
283
+ // For in-bounds operations, all nodes up until and including the index
284
+ // must be valid initializers
285
+ const err = validateInitializers(nodes.slice(0, index + 1));
286
+ if (err) {
287
+ return result.err(err);
288
+ }
289
+ if (index >= nodes.length) {
290
+ return result.err(new PatchError("Array index out of bounds"));
291
+ } else {
292
+ return result.ok(index);
293
+ }
294
+ })
295
+ );
296
+ }
297
+
298
+ function replaceInNode(
299
+ document: ts.SourceFile,
300
+ node: ts.Expression,
301
+ key: string,
302
+ value: JSONValue
303
+ ): TSOpsResult<[document: ts.SourceFile, replaced: ts.Expression]> {
304
+ if (ts.isArrayLiteralExpression(node)) {
305
+ return pipe(
306
+ parseAndValidateArrayInboundsIndex(key, node.elements),
307
+ result.map((index: number) =>
308
+ replaceNodeValue(document, node.elements[index], value)
309
+ )
310
+ );
311
+ } else if (ts.isObjectLiteralExpression(node)) {
312
+ return pipe(
313
+ findObjectPropertyAssignment(node, key),
314
+ result.flatMap((assignment): TSOpsResult<ts.PropertyAssignment> => {
315
+ if (!assignment) {
316
+ return result.err(
317
+ new PatchError("Cannot replace object element which does not exist")
318
+ );
319
+ }
320
+ return result.ok(assignment);
321
+ }),
322
+ result.map((assignment: ts.PropertyAssignment) =>
323
+ replaceNodeValue(document, assignment.initializer, value)
324
+ )
325
+ );
326
+ } else if (isValFileMethodCall(node)) {
327
+ if (key === FILE_REF_PROP) {
328
+ if (typeof value !== "string") {
329
+ return result.err(
330
+ new PatchError(
331
+ "Cannot replace val.file reference with non-string value"
332
+ )
333
+ );
334
+ }
335
+ return pipe(
336
+ findValFileNodeArg(node),
337
+ result.map((refNode) => replaceNodeValue(document, refNode, value))
338
+ );
339
+ } else {
340
+ return pipe(
341
+ findValFileMetadataArg(node),
342
+ result.flatMap((metadataArgNode) => {
343
+ if (!metadataArgNode) {
344
+ return result.err(
345
+ new PatchError(
346
+ "Cannot replace in val.file metadata when it does not exist"
347
+ )
348
+ );
349
+ }
350
+ if (key !== "metadata") {
351
+ return result.err(
352
+ new PatchError(
353
+ `Cannot replace val.file metadata key ${key} when it does not exist`
354
+ )
355
+ );
356
+ }
357
+ return replaceInNode(
358
+ document,
359
+ // TODO: creating a fake object here might not be right - seems to work though
360
+ ts.factory.createObjectLiteralExpression([
361
+ ts.factory.createPropertyAssignment(key, metadataArgNode),
362
+ ]),
363
+ key,
364
+ value
365
+ );
366
+ })
367
+ );
368
+ }
369
+ } else {
370
+ return result.err(
371
+ shallowValidateExpression(node) ??
372
+ new PatchError("Cannot replace in non-object/array")
373
+ );
374
+ }
375
+ }
376
+
377
+ function replaceAtPath(
378
+ document: ts.SourceFile,
379
+ rootNode: ts.Expression,
380
+ path: string[],
381
+ value: JSONValue
382
+ ): TSOpsResult<[document: ts.SourceFile, replaced: ts.Expression]> {
383
+ if (isNotRoot(path)) {
384
+ return pipe(
385
+ getPointerFromPath(rootNode, path),
386
+ result.flatMap(([node, key]: Pointer) =>
387
+ replaceInNode(document, node, key, value)
388
+ )
389
+ );
390
+ } else {
391
+ return result.ok(replaceNodeValue(document, rootNode, value));
392
+ }
393
+ }
394
+
395
+ export function getFromNode(
396
+ node: ts.Expression,
397
+ key: string
398
+ ): TSOpsResult<ts.Expression | undefined> {
399
+ if (ts.isArrayLiteralExpression(node)) {
400
+ return pipe(
401
+ parseAndValidateArrayInboundsIndex(key, node.elements),
402
+ result.map((index: number) => node.elements[index])
403
+ );
404
+ } else if (ts.isObjectLiteralExpression(node)) {
405
+ return pipe(
406
+ findObjectPropertyAssignment(node, key),
407
+ result.map(
408
+ (assignment: ts.PropertyAssignment | undefined) =>
409
+ assignment?.initializer
410
+ )
411
+ );
412
+ } else if (isValFileMethodCall(node)) {
413
+ if (key === FILE_REF_PROP) {
414
+ return findValFileNodeArg(node);
415
+ }
416
+ return findValFileMetadataArg(node);
417
+ } else {
418
+ return result.err(
419
+ shallowValidateExpression(node) ??
420
+ new PatchError("Cannot access non-object/array")
421
+ );
422
+ }
423
+ }
424
+
425
+ type Pointer = [node: ts.Expression, key: string];
426
+ function getPointerFromPath(
427
+ node: ts.Expression,
428
+ path: array.NonEmptyArray<string>
429
+ ): TSOpsResult<Pointer> {
430
+ let targetNode: ts.Expression = node;
431
+ let key: string = path[0];
432
+ for (let i = 0; i < path.length - 1; ++i, key = path[i]) {
433
+ const childNode = getFromNode(targetNode, key);
434
+ if (result.isErr(childNode)) {
435
+ return childNode;
436
+ }
437
+ if (childNode.value === undefined) {
438
+ return result.err(
439
+ new PatchError("Path refers to non-existing object/array")
440
+ );
441
+ }
442
+ targetNode = childNode.value;
443
+ }
444
+
445
+ return result.ok([targetNode, key]);
446
+ }
447
+
448
+ function getAtPath(
449
+ rootNode: ts.Expression,
450
+ path: string[]
451
+ ): TSOpsResult<ts.Expression> {
452
+ return pipe(
453
+ path,
454
+ result.flatMapReduce(
455
+ (node: ts.Expression, key: string) =>
456
+ pipe(
457
+ getFromNode(node, key),
458
+ result.filterOrElse(
459
+ (
460
+ childNode: ts.Expression | undefined
461
+ ): childNode is ts.Expression => childNode !== undefined,
462
+ (): PatchError | ValSyntaxErrorTree =>
463
+ new PatchError("Path refers to non-existing object/array")
464
+ )
465
+ ),
466
+ rootNode
467
+ )
468
+ );
469
+ }
470
+
471
+ function removeFromNode(
472
+ document: ts.SourceFile,
473
+ node: ts.Expression,
474
+ key: string
475
+ ): TSOpsResult<[document: ts.SourceFile, removed: ts.Expression]> {
476
+ if (ts.isArrayLiteralExpression(node)) {
477
+ return pipe(
478
+ parseAndValidateArrayInboundsIndex(key, node.elements),
479
+ result.map((index: number) => removeAt(document, node.elements, index))
480
+ );
481
+ } else if (ts.isObjectLiteralExpression(node)) {
482
+ return pipe(
483
+ findObjectPropertyAssignment(node, key),
484
+ result.flatMap(
485
+ (
486
+ assignment: ts.PropertyAssignment | undefined
487
+ ): TSOpsResult<ts.PropertyAssignment> => {
488
+ if (!assignment) {
489
+ return result.err(
490
+ new PatchError(
491
+ "Cannot replace object element which does not exist"
492
+ )
493
+ );
494
+ }
495
+ return result.ok(assignment);
496
+ }
497
+ ),
498
+ result.map((assignment: ts.PropertyAssignment) => [
499
+ removeAt(
500
+ document,
501
+ node.properties,
502
+ node.properties.indexOf(assignment)
503
+ )[0],
504
+ assignment.initializer,
505
+ ])
506
+ );
507
+ } else if (isValFileMethodCall(node)) {
508
+ if (key === FILE_REF_PROP) {
509
+ return result.err(new PatchError("Cannot remove a ref from val.file"));
510
+ } else {
511
+ return pipe(
512
+ findValFileMetadataArg(node),
513
+ result.flatMap((metadataArgNode) => {
514
+ if (!metadataArgNode) {
515
+ return result.err(
516
+ new PatchError(
517
+ "Cannot remove from val.file metadata when it does not exist"
518
+ )
519
+ );
520
+ }
521
+ return removeFromNode(document, metadataArgNode, key);
522
+ })
523
+ );
524
+ }
525
+ } else {
526
+ return result.err(
527
+ shallowValidateExpression(node) ??
528
+ new PatchError("Cannot remove from non-object/array")
529
+ );
530
+ }
531
+ }
532
+
533
+ function removeAtPath(
534
+ document: ts.SourceFile,
535
+ rootNode: ts.Expression,
536
+ path: array.NonEmptyArray<string>
537
+ ): TSOpsResult<[document: ts.SourceFile, removed: ts.Expression]> {
538
+ return pipe(
539
+ getPointerFromPath(rootNode, path),
540
+ result.flatMap(([node, key]: Pointer) =>
541
+ removeFromNode(document, node, key)
542
+ )
543
+ );
544
+ }
545
+
546
+ function isValFileValue(value: JSONValue): value is FileSource<{
547
+ [key: string]: JsonPrimitive;
548
+ }> {
549
+ return !!(
550
+ typeof value === "object" &&
551
+ value &&
552
+ FILE_REF_PROP in value &&
553
+ typeof value[FILE_REF_PROP] === "string"
554
+ );
555
+ }
556
+
557
+ function addToNode(
558
+ document: ts.SourceFile,
559
+ node: ts.Expression,
560
+ key: string,
561
+ value: JSONValue
562
+ ): TSOpsResult<[document: ts.SourceFile, replaced?: ts.Expression]> {
563
+ if (ts.isArrayLiteralExpression(node)) {
564
+ return pipe(
565
+ parseAndValidateArrayInsertIndex(key, node.elements),
566
+ result.map((index: number): [document: ts.SourceFile] => [
567
+ insertAt(document, node.elements, index, toExpression(value)),
568
+ ])
569
+ );
570
+ } else if (ts.isObjectLiteralExpression(node)) {
571
+ if (key === FILE_REF_PROP) {
572
+ return result.err(new PatchError("Cannot add a key ref to object"));
573
+ }
574
+ return pipe(
575
+ findObjectPropertyAssignment(node, key),
576
+ result.map(
577
+ (
578
+ assignment: ts.PropertyAssignment | undefined
579
+ ): [document: ts.SourceFile, replaced?: ts.Expression] => {
580
+ if (!assignment) {
581
+ return [
582
+ insertAt(
583
+ document,
584
+ node.properties,
585
+ node.properties.length,
586
+ createPropertyAssignment(key, value)
587
+ ),
588
+ ];
589
+ } else {
590
+ return replaceNodeValue(document, assignment.initializer, value);
591
+ }
592
+ }
593
+ )
594
+ );
595
+ } else if (isValFileMethodCall(node)) {
596
+ if (key === FILE_REF_PROP) {
597
+ if (typeof value !== "string") {
598
+ return result.err(
599
+ new PatchError(
600
+ `Cannot add ${FILE_REF_PROP} key to val.file with non-string value`
601
+ )
602
+ );
603
+ }
604
+ return pipe(
605
+ findValFileNodeArg(node),
606
+ result.map((arg: ts.Expression) =>
607
+ replaceNodeValue(document, arg, value)
608
+ )
609
+ );
610
+ } else {
611
+ return pipe(
612
+ findValFileMetadataArg(node),
613
+ result.flatMap((metadataArgNode) => {
614
+ if (metadataArgNode) {
615
+ return result.err(
616
+ new PatchError(
617
+ "Cannot add metadata to val.file when it already exists"
618
+ )
619
+ );
620
+ }
621
+ if (key !== "metadata") {
622
+ return result.err(
623
+ new PatchError(
624
+ `Cannot add ${key} key to val.file: only metadata is allowed`
625
+ )
626
+ );
627
+ }
628
+ return result.ok([
629
+ insertAt(
630
+ document,
631
+ node.arguments,
632
+ node.arguments.length,
633
+ toExpression(value)
634
+ ),
635
+ ]);
636
+ })
637
+ );
638
+ }
639
+ } else {
640
+ return result.err(
641
+ shallowValidateExpression(node) ??
642
+ new PatchError("Cannot add to non-object/array")
643
+ );
644
+ }
645
+ }
646
+
647
+ function addAtPath(
648
+ document: ts.SourceFile,
649
+ rootNode: ts.Expression,
650
+ path: string[],
651
+ value: JSONValue
652
+ ): TSOpsResult<[document: ts.SourceFile, replaced?: ts.Expression]> {
653
+ if (isNotRoot(path)) {
654
+ return pipe(
655
+ getPointerFromPath(rootNode, path),
656
+ result.flatMap(([node, key]: Pointer) =>
657
+ addToNode(document, node, key, value)
658
+ )
659
+ );
660
+ } else {
661
+ return result.ok(replaceNodeValue(document, rootNode, value));
662
+ }
663
+ }
664
+
665
+ function pickDocument<
666
+ T extends readonly [document: ts.SourceFile, ...rest: unknown[]]
667
+ >([document]: T) {
668
+ return document;
669
+ }
670
+
671
+ export class TSOps implements Ops<ts.SourceFile, ValSyntaxErrorTree> {
672
+ constructor(
673
+ private findRoot: (
674
+ document: ts.SourceFile
675
+ ) => result.Result<ts.Expression, ValSyntaxErrorTree>
676
+ ) {}
677
+ get(document: ts.SourceFile, path: string[]): TSOpsResult<JSONValue> {
678
+ return pipe(
679
+ document,
680
+ this.findRoot,
681
+ result.flatMap((rootNode: ts.Expression) => getAtPath(rootNode, path)),
682
+ result.flatMap(evaluateExpression)
683
+ );
684
+ }
685
+ add(
686
+ document: ts.SourceFile,
687
+ path: string[],
688
+ value: JSONValue
689
+ ): TSOpsResult<ts.SourceFile> {
690
+ return pipe(
691
+ document,
692
+ this.findRoot,
693
+ result.flatMap((rootNode: ts.Expression) =>
694
+ addAtPath(document, rootNode, path, value)
695
+ ),
696
+ result.map(pickDocument)
697
+ );
698
+ }
699
+ remove(
700
+ document: ts.SourceFile,
701
+ path: array.NonEmptyArray<string>
702
+ ): TSOpsResult<ts.SourceFile> {
703
+ return pipe(
704
+ document,
705
+ this.findRoot,
706
+ result.flatMap((rootNode: ts.Expression) =>
707
+ removeAtPath(document, rootNode, path)
708
+ ),
709
+ result.map(pickDocument)
710
+ );
711
+ }
712
+ replace(
713
+ document: ts.SourceFile,
714
+ path: string[],
715
+ value: JSONValue
716
+ ): TSOpsResult<ts.SourceFile> {
717
+ return pipe(
718
+ document,
719
+ this.findRoot,
720
+ result.flatMap((rootNode: ts.Expression) =>
721
+ replaceAtPath(document, rootNode, path, value)
722
+ ),
723
+ result.map(pickDocument)
724
+ );
725
+ }
726
+ move(
727
+ document: ts.SourceFile,
728
+ from: array.NonEmptyArray<string>,
729
+ path: string[]
730
+ ): TSOpsResult<ts.SourceFile> {
731
+ return pipe(
732
+ document,
733
+ this.findRoot,
734
+ result.flatMap((rootNode: ts.Expression) =>
735
+ removeAtPath(document, rootNode, from)
736
+ ),
737
+ result.flatMap(
738
+ ([document, removedNode]: [
739
+ doc: ts.SourceFile,
740
+ removedNode: ts.Expression
741
+ ]) =>
742
+ pipe(
743
+ evaluateExpression(removedNode),
744
+ result.map(
745
+ (
746
+ removedValue: JSONValue
747
+ ): [doc: ts.SourceFile, removedValue: JSONValue] => [
748
+ document,
749
+ removedValue,
750
+ ]
751
+ )
752
+ )
753
+ ),
754
+ result.flatMap(
755
+ ([document, removedValue]: [
756
+ document: ts.SourceFile,
757
+ removedValue: JSONValue
758
+ ]) =>
759
+ pipe(
760
+ document,
761
+ this.findRoot,
762
+ result.flatMap((root: ts.Expression) =>
763
+ addAtPath(document, root, path, removedValue)
764
+ )
765
+ )
766
+ ),
767
+ result.map(pickDocument)
768
+ );
769
+ }
770
+ copy(
771
+ document: ts.SourceFile,
772
+ from: string[],
773
+ path: string[]
774
+ ): TSOpsResult<ts.SourceFile> {
775
+ return pipe(
776
+ document,
777
+ this.findRoot,
778
+ result.flatMap((rootNode: ts.Expression) =>
779
+ pipe(
780
+ getAtPath(rootNode, from),
781
+ result.flatMap(evaluateExpression),
782
+ result.flatMap((value: JSONValue) =>
783
+ addAtPath(document, rootNode, path, value)
784
+ )
785
+ )
786
+ ),
787
+ result.map(pickDocument)
788
+ );
789
+ }
790
+ test(
791
+ document: ts.SourceFile,
792
+ path: string[],
793
+ value: JSONValue
794
+ ): TSOpsResult<boolean> {
795
+ return pipe(
796
+ document,
797
+ this.findRoot,
798
+ result.flatMap((rootNode: ts.Expression) => getAtPath(rootNode, path)),
799
+ result.flatMap(evaluateExpression),
800
+ result.map((documentValue: JSONValue) => deepEqual(value, documentValue))
801
+ );
802
+ }
803
+ }