Skip to content

Commit 33f9f3d

Browse files
committed
feat(@angular-devkit/schematics): support reading JSON content directly from a Tree
The schematics `Tree` now contains an additional `readJSON` method that supports directly reading and parsing the content of a file as UTF-8 JSON with comment support. This avoids the need to manually decode a Buffer, parse and handle JSON comments within a schematic when JSON content is needed. If a file path does not exist, an exception will be thrown. While this differs from the semantics of `read`, it helps reduce the amount of code needed for common schematic use cases. JSON parse errors will also result in an exception being thrown with a message detailing the error.
1 parent 01297f4 commit 33f9f3d

File tree

10 files changed

+116
-2
lines changed

10 files changed

+116
-2
lines changed

goldens/public-api/angular_devkit/schematics/index.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
/// <reference types="node" />
88

99
import { BaseException } from '@angular-devkit/core';
10+
import { JsonValue } from '@angular-devkit/core';
1011
import { logging } from '@angular-devkit/core';
1112
import { Observable } from 'rxjs';
1213
import { Path } from '@angular-devkit/core';
@@ -235,6 +236,8 @@ export class DelegateTree implements Tree_2 {
235236
// (undocumented)
236237
read(path: string): Buffer | null;
237238
// (undocumented)
239+
readJson(path: string): JsonValue;
240+
// (undocumented)
238241
readText(path: string): string;
239242
// (undocumented)
240243
rename(from: string, to: string): void;
@@ -549,6 +552,8 @@ export class HostTree implements Tree_2 {
549552
// (undocumented)
550553
read(path: string): Buffer | null;
551554
// (undocumented)
555+
readJson(path: string): JsonValue;
556+
// (undocumented)
552557
readText(path: string): string;
553558
// (undocumented)
554559
rename(from: string, to: string): void;

goldens/public-api/angular_devkit/schematics/testing/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
/// <reference types="node" />
88

9+
import { JsonValue } from '@angular-devkit/core';
910
import { logging } from '@angular-devkit/core';
1011
import { Observable } from 'rxjs';
1112
import { Path } from '@angular-devkit/core';

goldens/public-api/angular_devkit/schematics/tools/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import { BaseException } from '@angular-devkit/core';
1010
import { JsonObject } from '@angular-devkit/core';
11+
import { JsonValue } from '@angular-devkit/core';
1112
import { logging } from '@angular-devkit/core';
1213
import { Observable } from 'rxjs';
1314
import { Path } from '@angular-devkit/core';

packages/angular_devkit/schematics/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ ts_library(
3737
"//packages/angular_devkit/core",
3838
"//packages/angular_devkit/core/node", # TODO: get rid of this for 6.0
3939
"@npm//@types/node",
40+
"@npm//jsonc-parser",
4041
"@npm//magic-string",
4142
"@npm//rxjs",
4243
],

packages/angular_devkit/schematics/src/tree/delegate.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
import { JsonValue } from '@angular-devkit/core';
910
import { Action } from './action';
1011
import {
1112
DirEntry,
@@ -38,6 +39,9 @@ export class DelegateTree implements Tree {
3839
readText(path: string): string {
3940
return this._other.readText(path);
4041
}
42+
readJson(path: string): JsonValue {
43+
return this._other.readJson(path);
44+
}
4145
exists(path: string): boolean {
4246
return this._other.exists(path);
4347
}

packages/angular_devkit/schematics/src/tree/host-tree.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import {
10+
JsonValue,
1011
Path,
1112
PathFragment,
1213
PathIsDirectoryException,
@@ -16,6 +17,7 @@ import {
1617
normalize,
1718
virtualFs,
1819
} from '@angular-devkit/core';
20+
import { ParseError, parse as jsoncParse, printParseErrorCode } from 'jsonc-parser';
1921
import { EMPTY, Observable } from 'rxjs';
2022
import { concatMap, map, mergeMap } from 'rxjs/operators';
2123
import { TextDecoder } from 'util';
@@ -310,6 +312,22 @@ export class HostTree implements Tree {
310312
}
311313
}
312314

315+
readJson(path: string): JsonValue {
316+
const content = this.readText(path);
317+
const errors: ParseError[] = [];
318+
const result = jsoncParse(content, errors, { allowTrailingComma: true });
319+
320+
// If there is a parse error throw with the error information
321+
if (errors[0]) {
322+
const { error, offset } = errors[0];
323+
throw new Error(
324+
`Failed to parse "${path}" as JSON. ${printParseErrorCode(error)} at offset: ${offset}.`,
325+
);
326+
}
327+
328+
return result;
329+
}
330+
313331
exists(path: string): boolean {
314332
return this._recordSync.isFile(this._normalizePath(path));
315333
}

packages/angular_devkit/schematics/src/tree/host-tree_spec.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,61 @@ describe('HostTree', () => {
3434
});
3535
});
3636

37+
describe('readJson', () => {
38+
it('returns a JSON value when reading a file that exists', () => {
39+
const tree = new HostTree();
40+
tree.create('/textfile1', '{ "a": true, "b": "xyz" }');
41+
tree.create('/textfile2', '123');
42+
tree.create('/textfile3', 'null');
43+
expect(tree.readJson('/textfile1')).toEqual({ a: true, b: 'xyz' });
44+
expect(tree.readJson('/textfile2')).toEqual(123);
45+
expect(tree.readJson('/textfile3')).toBeNull();
46+
});
47+
48+
it('returns a JSON value when reading a file with comments', () => {
49+
const tree = new HostTree();
50+
tree.create(
51+
'/textfile1',
52+
'{ "a": true, /* inner object\nmultiline comment\n */ "b": "xyz" }',
53+
);
54+
tree.create('/textfile2', '123 // number value');
55+
tree.create('/textfile3', 'null // null value');
56+
expect(tree.readJson('/textfile1')).toEqual({ a: true, b: 'xyz' });
57+
expect(tree.readJson('/textfile2')).toEqual(123);
58+
expect(tree.readJson('/textfile3')).toBeNull();
59+
});
60+
61+
it('returns a JSON value when reading a file with trailing commas', () => {
62+
const tree = new HostTree();
63+
tree.create('/textfile1', '{ "a": true, "b": "xyz", }');
64+
tree.create('/textfile2', '[5, 4, 3, 2, 1, ]');
65+
expect(tree.readJson('/textfile1')).toEqual({ a: true, b: 'xyz' });
66+
expect(tree.readJson('/textfile2')).toEqual([5, 4, 3, 2, 1]);
67+
});
68+
69+
it('throws an error when a file does not exist', () => {
70+
const tree = new HostTree();
71+
const path = '/textfile1';
72+
expect(() => tree.readJson(path)).toThrowError(`Path "${path}" does not exist.`);
73+
});
74+
75+
it('throws an error if the JSON is malformed', () => {
76+
const tree = new HostTree();
77+
const path = '/textfile1';
78+
tree.create(path, '{ "a": true;;;;; "b": "xyz" }');
79+
expect(() => tree.readJson(path)).toThrowError(
80+
`Failed to parse "${path}" as JSON. InvalidSymbol at offset: 7.`,
81+
);
82+
});
83+
84+
it('throws an error when invalid UTF-8 characters are present', () => {
85+
const tree = new HostTree();
86+
const path = '/textfile1';
87+
tree.create(path, Buffer.from([0xff, 0xff, 0xff, 0xff]));
88+
expect(() => tree.readJson(path)).toThrowError(`Failed to decode "${path}" as UTF-8 text.`);
89+
});
90+
});
91+
3792
describe('merge', () => {
3893
it('should create files from each tree', () => {
3994
const tree = new HostTree();

packages/angular_devkit/schematics/src/tree/interface.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import { Path, PathFragment } from '@angular-devkit/core';
9+
import { JsonValue, Path, PathFragment } from '@angular-devkit/core';
1010
import { Action } from './action';
1111

1212
export enum MergeStrategy {
@@ -94,6 +94,20 @@ export interface Tree {
9494
*/
9595
readText(path: string): string;
9696

97+
/**
98+
* Reads and parses a file from the Tree as a UTF-8 encoded JSON file.
99+
* Supports parsing JSON (RFC 8259) with the following extensions:
100+
* * Single-line and multi-line JavaScript comments
101+
* * Trailing commas within objects and arrays
102+
*
103+
* @param path The path of the file to read.
104+
* @returns A JsonValue containing the parsed contents of the file.
105+
* @throws {@link FileDoesNotExistException} if the file is not found.
106+
* @throws An error if the file contains invalid UTF-8 characters.
107+
* @throws An error if the file contains malformed JSON.
108+
*/
109+
readJson(path: string): JsonValue;
110+
97111
exists(path: string): boolean;
98112
get(path: string): FileEntry | null;
99113
getDir(path: string): DirEntry;

packages/angular_devkit/schematics/src/tree/null.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,15 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import { BaseException, Path, PathFragment, dirname, join, normalize } from '@angular-devkit/core';
9+
import {
10+
BaseException,
11+
JsonValue,
12+
Path,
13+
PathFragment,
14+
dirname,
15+
join,
16+
normalize,
17+
} from '@angular-devkit/core';
1018
import { FileDoesNotExistException } from '../exception/exception';
1119
import { Action } from './action';
1220
import { DirEntry, MergeStrategy, Tree, TreeSymbol, UpdateRecorder } from './interface';
@@ -60,6 +68,9 @@ export class NullTree implements Tree {
6068
readText(path: string): string {
6169
throw new FileDoesNotExistException(path);
6270
}
71+
readJson(path: string): JsonValue {
72+
throw new FileDoesNotExistException(path);
73+
}
6374
get(_path: string) {
6475
return null;
6576
}

packages/angular_devkit/schematics/src/tree/scoped.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import {
10+
JsonValue,
1011
NormalizedRoot,
1112
Path,
1213
PathFragment,
@@ -116,6 +117,9 @@ export class ScopedTree implements Tree {
116117
readText(path: string): string {
117118
return this._base.readText(this._fullPath(path));
118119
}
120+
readJson(path: string): JsonValue {
121+
return this._base.readJson(this._fullPath(path));
122+
}
119123
exists(path: string): boolean {
120124
return this._base.exists(this._fullPath(path));
121125
}

0 commit comments

Comments
 (0)