Skip to content

Commit 659e5bd

Browse files
clydinalan-agius4
authored andcommitted
refactor(@angular/cli): enhance JSON file handling and utility functions
This commit introduces a number of significant improvements to the core utility functions, with a primary focus on enhancing the robustness and developer experience of `JSONFile`. The `JSONFile` class now automatically detects and preserves file indentation, uses native private fields for better encapsulation, and includes a new `delete()` method for a more intuitive API. Additionally, the `readAndParseJson` and `parseJson` functions are now generic for improved type-safety. The `assertIsError` utility is more robust, and a type-related bug in workspace configuration validation has been fixed. All related utilities have been updated with comprehensive JSDoc comments.
1 parent 4f3b9ba commit 659e5bd

File tree

4 files changed

+164
-37
lines changed

4 files changed

+164
-37
lines changed

packages/angular/cli/src/utilities/config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,12 +242,15 @@ export async function getWorkspaceRaw(
242242

243243
export async function validateWorkspace(data: json.JsonObject, isGlobal: boolean): Promise<void> {
244244
const schema = readAndParseJson(workspaceSchemaPath);
245+
if (!isJsonObject(schema)) {
246+
throw new Error('Workspace schema is not a JSON object.');
247+
}
245248

246249
// We should eventually have a dedicated global config schema and use that to validate.
247250
const schemaToValidate: json.schema.JsonSchema = isGlobal
248251
? {
249252
'$ref': '#/definitions/global',
250-
definitions: schema['definitions'],
253+
definitions: schema['definitions'] as json.JsonObject,
251254
}
252255
: schema;
253256

packages/angular/cli/src/utilities/eol.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,18 @@ import { EOL } from 'node:os';
1111
const CRLF = '\r\n';
1212
const LF = '\n';
1313

14+
/**
15+
* Gets the end-of-line sequence from a string.
16+
*
17+
* This function analyzes the given string to determine the most frequent end-of-line (EOL)
18+
* sequence. It counts the occurrences of carriage return line feed (`\r\n`) and
19+
* line feed (`\n`).
20+
*
21+
* @param content The string to process.
22+
* @returns The most frequent EOL sequence. If `\r\n` is more frequent, it returns `\r\n`.
23+
* Otherwise (including ties), it returns `\n`. If no newlines are found, it falls back
24+
* to the operating system's default EOL sequence.
25+
*/
1426
export function getEOL(content: string): string {
1527
const newlines = content.match(/(?:\r?\n)/g);
1628

packages/angular/cli/src/utilities/error.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,36 @@
77
*/
88

99
import assert from 'node:assert';
10+
import { inspect } from 'node:util';
1011

11-
export function assertIsError(value: unknown): asserts value is Error & { code?: string } {
12-
const isError =
12+
/**
13+
* Checks if a given value is an Error-like object.
14+
*
15+
* This type guard checks if the value is an instance of `Error` or if it's an object
16+
* with `name` and `message` properties. This is useful for identifying error-like
17+
* objects that may not be direct instances of `Error` (e.g., from RxJs).
18+
*
19+
* @param value The value to check.
20+
* @returns `true` if the value is an Error-like object, `false` otherwise.
21+
*/
22+
function isError(value: unknown): value is Error {
23+
return (
1324
value instanceof Error ||
14-
// The following is needing to identify errors coming from RxJs.
15-
(typeof value === 'object' && value && 'name' in value && 'message' in value);
16-
assert(isError, 'catch clause variable is not an Error instance');
25+
(typeof value === 'object' && value !== null && 'name' in value && 'message' in value)
26+
);
27+
}
28+
29+
/**
30+
* Asserts that a given value is an Error-like object.
31+
*
32+
* If the value is not an `Error` or an object with `name` and `message` properties,
33+
* this function will throw an `AssertionError` with a descriptive message.
34+
*
35+
* @param value The value to check.
36+
*/
37+
export function assertIsError(value: unknown): asserts value is Error & { code?: string } {
38+
assert(
39+
isError(value),
40+
`Expected a value to be an Error-like object, but received: ${inspect(value)}`,
41+
);
1742
}

packages/angular/cli/src/utilities/json-file.ts

Lines changed: 118 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -20,41 +20,89 @@ import {
2020
} from 'jsonc-parser';
2121
import { readFileSync, writeFileSync } from 'node:fs';
2222
import { getEOL } from './eol';
23+
import { assertIsError } from './error';
2324

25+
/** A function that returns an index to insert a new property in a JSON object. */
2426
export type InsertionIndex = (properties: string[]) => number;
27+
28+
/** A JSON path. */
2529
export type JSONPath = (string | number)[];
2630

27-
/** @internal */
31+
/**
32+
* Represents a JSON file, allowing for reading, modifying, and saving.
33+
* This class uses `jsonc-parser` to preserve comments and formatting, including
34+
* indentation and end-of-line sequences.
35+
* @internal
36+
*/
2837
export class JSONFile {
29-
content: string;
30-
private eol: string;
31-
32-
constructor(private readonly path: string) {
33-
const buffer = readFileSync(this.path);
34-
if (buffer) {
35-
this.content = buffer.toString();
36-
} else {
37-
throw new Error(`Could not read '${path}'.`);
38+
/** The raw content of the JSON file. */
39+
#content: string;
40+
41+
/** The end-of-line sequence used in the file. */
42+
#eol: string;
43+
44+
/** Whether the file uses spaces for indentation. */
45+
#insertSpaces = true;
46+
47+
/** The number of spaces or tabs used for indentation. */
48+
#tabSize = 2;
49+
50+
/** The path to the JSON file. */
51+
#path: string;
52+
53+
/** The parsed JSON abstract syntax tree. */
54+
#jsonAst: Node | undefined;
55+
56+
/** The raw content of the JSON file. */
57+
public get content(): string {
58+
return this.#content;
59+
}
60+
61+
/**
62+
* Creates an instance of JSONFile.
63+
* @param path The path to the JSON file.
64+
*/
65+
constructor(path: string) {
66+
this.#path = path;
67+
try {
68+
this.#content = readFileSync(this.#path, 'utf-8');
69+
} catch (e) {
70+
assertIsError(e);
71+
// We don't have to worry about ENOENT, since we'll be creating the file.
72+
if (e.code !== 'ENOENT') {
73+
throw e;
74+
}
75+
76+
this.#content = '';
3877
}
3978

40-
this.eol = getEOL(this.content);
79+
this.#eol = getEOL(this.#content);
80+
this.#detectIndentation();
4181
}
4282

43-
private _jsonAst: Node | undefined;
83+
/**
84+
* Gets the parsed JSON abstract syntax tree.
85+
* The AST is lazily parsed and cached.
86+
*/
4487
private get JsonAst(): Node | undefined {
45-
if (this._jsonAst) {
46-
return this._jsonAst;
88+
if (this.#jsonAst) {
89+
return this.#jsonAst;
4790
}
4891

4992
const errors: ParseError[] = [];
50-
this._jsonAst = parseTree(this.content, errors, { allowTrailingComma: true });
93+
this.#jsonAst = parseTree(this.#content, errors, { allowTrailingComma: true });
5194
if (errors.length) {
52-
formatError(this.path, errors);
95+
formatError(this.#path, errors);
5396
}
5497

55-
return this._jsonAst;
98+
return this.#jsonAst;
5699
}
57100

101+
/**
102+
* Gets a value from the JSON file at a specific path.
103+
* @param jsonPath The path to the value.
104+
* @returns The value at the given path, or `undefined` if not found.
105+
*/
58106
get(jsonPath: JSONPath): unknown {
59107
const jsonAstNode = this.JsonAst;
60108
if (!jsonAstNode) {
@@ -70,6 +118,13 @@ export class JSONFile {
70118
return node === undefined ? undefined : getNodeValue(node);
71119
}
72120

121+
/**
122+
* Modifies a value in the JSON file.
123+
* @param jsonPath The path to the value to modify.
124+
* @param value The new value to insert.
125+
* @param insertInOrder A function to determine the insertion index, or `false` to insert at the end.
126+
* @returns `true` if the modification was successful, `false` otherwise.
127+
*/
73128
modify(
74129
jsonPath: JSONPath,
75130
value: JsonValue | undefined,
@@ -89,42 +144,70 @@ export class JSONFile {
89144
getInsertionIndex = insertInOrder;
90145
}
91146

92-
const edits = modify(this.content, jsonPath, value, {
147+
const edits = modify(this.#content, jsonPath, value, {
93148
getInsertionIndex,
94-
// TODO: use indentation from original file.
95149
formattingOptions: {
96-
insertSpaces: true,
97-
tabSize: 2,
98-
eol: this.eol,
150+
insertSpaces: this.#insertSpaces,
151+
tabSize: this.#tabSize,
152+
eol: this.#eol,
99153
},
100154
});
101155

102156
if (edits.length === 0) {
103157
return false;
104158
}
105159

106-
this.content = applyEdits(this.content, edits);
107-
this._jsonAst = undefined;
160+
this.#content = applyEdits(this.#content, edits);
161+
this.#jsonAst = undefined;
108162

109163
return true;
110164
}
111165

166+
/**
167+
* Deletes a value from the JSON file at a specific path.
168+
* @param jsonPath The path to the value to delete.
169+
* @returns `true` if the deletion was successful, `false` otherwise.
170+
*/
171+
delete(jsonPath: JSONPath): boolean {
172+
return this.modify(jsonPath, undefined);
173+
}
174+
175+
/** Saves the modified content back to the file. */
112176
save(): void {
113-
writeFileSync(this.path, this.content);
177+
writeFileSync(this.#path, this.#content);
178+
}
179+
180+
/** Detects the indentation of the file. */
181+
#detectIndentation(): void {
182+
// Find the first line that has indentation.
183+
const match = this.#content.match(/^(?:( )+|\t+)\S/m);
184+
if (match) {
185+
this.#insertSpaces = !!match[1];
186+
this.#tabSize = match[0].length - 1;
187+
}
114188
}
115189
}
116190

117-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
118-
export function readAndParseJson(path: string): any {
191+
/**
192+
* Reads and parses a JSON file, supporting comments and trailing commas.
193+
* @param path The path to the JSON file.
194+
* @returns The parsed JSON object.
195+
*/
196+
export function readAndParseJson<T extends JsonValue>(path: string): T {
119197
const errors: ParseError[] = [];
120-
const content = parse(readFileSync(path, 'utf-8'), errors, { allowTrailingComma: true });
198+
const content = parse(readFileSync(path, 'utf-8'), errors, { allowTrailingComma: true }) as T;
121199
if (errors.length) {
122200
formatError(path, errors);
123201
}
124202

125203
return content;
126204
}
127205

206+
/**
207+
* Formats a JSON parsing error and throws an exception.
208+
* @param path The path to the file that failed to parse.
209+
* @param errors The list of parsing errors.
210+
*/
128211
function formatError(path: string, errors: ParseError[]): never {
129212
const { error, offset } = errors[0];
130213
throw new Error(
@@ -134,7 +217,11 @@ function formatError(path: string, errors: ParseError[]): never {
134217
);
135218
}
136219

137-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
138-
export function parseJson(content: string): any {
139-
return parse(content, undefined, { allowTrailingComma: true });
220+
/**
221+
* Parses a JSON string, supporting comments and trailing commas.
222+
* @param content The JSON string to parse.
223+
* @returns The parsed JSON object.
224+
*/
225+
export function parseJson<T extends JsonValue>(content: string): T {
226+
return parse(content, undefined, { allowTrailingComma: true }) as T;
140227
}

0 commit comments

Comments
 (0)