From 422a4a55f18fcc5dac11c06381b3330702fb6d6e Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 1 Aug 2025 16:17:55 -0400 Subject: [PATCH] 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. --- packages/angular/cli/src/utilities/config.ts | 5 +- packages/angular/cli/src/utilities/eol.ts | 12 ++ packages/angular/cli/src/utilities/error.ts | 35 +++- .../angular/cli/src/utilities/json-file.ts | 149 ++++++++++++++---- 4 files changed, 164 insertions(+), 37 deletions(-) diff --git a/packages/angular/cli/src/utilities/config.ts b/packages/angular/cli/src/utilities/config.ts index caa1e2a593f2..25f8dfb2f896 100644 --- a/packages/angular/cli/src/utilities/config.ts +++ b/packages/angular/cli/src/utilities/config.ts @@ -242,12 +242,15 @@ export async function getWorkspaceRaw( export async function validateWorkspace(data: json.JsonObject, isGlobal: boolean): Promise { const schema = readAndParseJson(workspaceSchemaPath); + if (!isJsonObject(schema)) { + throw new Error('Workspace schema is not a JSON object.'); + } // We should eventually have a dedicated global config schema and use that to validate. const schemaToValidate: json.schema.JsonSchema = isGlobal ? { '$ref': '#/definitions/global', - definitions: schema['definitions'], + definitions: schema['definitions'] as json.JsonObject, } : schema; diff --git a/packages/angular/cli/src/utilities/eol.ts b/packages/angular/cli/src/utilities/eol.ts index 02e837649144..c5abe003535e 100644 --- a/packages/angular/cli/src/utilities/eol.ts +++ b/packages/angular/cli/src/utilities/eol.ts @@ -11,6 +11,18 @@ import { EOL } from 'node:os'; const CRLF = '\r\n'; const LF = '\n'; +/** + * Gets the end-of-line sequence from a string. + * + * This function analyzes the given string to determine the most frequent end-of-line (EOL) + * sequence. It counts the occurrences of carriage return line feed (`\r\n`) and + * line feed (`\n`). + * + * @param content The string to process. + * @returns The most frequent EOL sequence. If `\r\n` is more frequent, it returns `\r\n`. + * Otherwise (including ties), it returns `\n`. If no newlines are found, it falls back + * to the operating system's default EOL sequence. + */ export function getEOL(content: string): string { const newlines = content.match(/(?:\r?\n)/g); diff --git a/packages/angular/cli/src/utilities/error.ts b/packages/angular/cli/src/utilities/error.ts index 0ca77c331d2d..7eb356bcf1ea 100644 --- a/packages/angular/cli/src/utilities/error.ts +++ b/packages/angular/cli/src/utilities/error.ts @@ -7,11 +7,36 @@ */ import assert from 'node:assert'; +import { inspect } from 'node:util'; -export function assertIsError(value: unknown): asserts value is Error & { code?: string } { - const isError = +/** + * Checks if a given value is an Error-like object. + * + * This type guard checks if the value is an instance of `Error` or if it's an object + * with `name` and `message` properties. This is useful for identifying error-like + * objects that may not be direct instances of `Error` (e.g., from RxJs). + * + * @param value The value to check. + * @returns `true` if the value is an Error-like object, `false` otherwise. + */ +function isError(value: unknown): value is Error { + return ( value instanceof Error || - // The following is needing to identify errors coming from RxJs. - (typeof value === 'object' && value && 'name' in value && 'message' in value); - assert(isError, 'catch clause variable is not an Error instance'); + (typeof value === 'object' && value !== null && 'name' in value && 'message' in value) + ); +} + +/** + * Asserts that a given value is an Error-like object. + * + * If the value is not an `Error` or an object with `name` and `message` properties, + * this function will throw an `AssertionError` with a descriptive message. + * + * @param value The value to check. + */ +export function assertIsError(value: unknown): asserts value is Error & { code?: string } { + assert( + isError(value), + `Expected a value to be an Error-like object, but received: ${inspect(value)}`, + ); } diff --git a/packages/angular/cli/src/utilities/json-file.ts b/packages/angular/cli/src/utilities/json-file.ts index c0f5fab919e2..9ed52f8f52a8 100644 --- a/packages/angular/cli/src/utilities/json-file.ts +++ b/packages/angular/cli/src/utilities/json-file.ts @@ -20,41 +20,89 @@ import { } from 'jsonc-parser'; import { readFileSync, writeFileSync } from 'node:fs'; import { getEOL } from './eol'; +import { assertIsError } from './error'; +/** A function that returns an index to insert a new property in a JSON object. */ export type InsertionIndex = (properties: string[]) => number; + +/** A JSON path. */ export type JSONPath = (string | number)[]; -/** @internal */ +/** + * Represents a JSON file, allowing for reading, modifying, and saving. + * This class uses `jsonc-parser` to preserve comments and formatting, including + * indentation and end-of-line sequences. + * @internal + */ export class JSONFile { - content: string; - private eol: string; - - constructor(private readonly path: string) { - const buffer = readFileSync(this.path); - if (buffer) { - this.content = buffer.toString(); - } else { - throw new Error(`Could not read '${path}'.`); + /** The raw content of the JSON file. */ + #content: string; + + /** The end-of-line sequence used in the file. */ + #eol: string; + + /** Whether the file uses spaces for indentation. */ + #insertSpaces = true; + + /** The number of spaces or tabs used for indentation. */ + #tabSize = 2; + + /** The path to the JSON file. */ + #path: string; + + /** The parsed JSON abstract syntax tree. */ + #jsonAst: Node | undefined; + + /** The raw content of the JSON file. */ + public get content(): string { + return this.#content; + } + + /** + * Creates an instance of JSONFile. + * @param path The path to the JSON file. + */ + constructor(path: string) { + this.#path = path; + try { + this.#content = readFileSync(this.#path, 'utf-8'); + } catch (e) { + assertIsError(e); + // We don't have to worry about ENOENT, since we'll be creating the file. + if (e.code !== 'ENOENT') { + throw e; + } + + this.#content = ''; } - this.eol = getEOL(this.content); + this.#eol = getEOL(this.#content); + this.#detectIndentation(); } - private _jsonAst: Node | undefined; + /** + * Gets the parsed JSON abstract syntax tree. + * The AST is lazily parsed and cached. + */ private get JsonAst(): Node | undefined { - if (this._jsonAst) { - return this._jsonAst; + if (this.#jsonAst) { + return this.#jsonAst; } const errors: ParseError[] = []; - this._jsonAst = parseTree(this.content, errors, { allowTrailingComma: true }); + this.#jsonAst = parseTree(this.#content, errors, { allowTrailingComma: true }); if (errors.length) { - formatError(this.path, errors); + formatError(this.#path, errors); } - return this._jsonAst; + return this.#jsonAst; } + /** + * Gets a value from the JSON file at a specific path. + * @param jsonPath The path to the value. + * @returns The value at the given path, or `undefined` if not found. + */ get(jsonPath: JSONPath): unknown { const jsonAstNode = this.JsonAst; if (!jsonAstNode) { @@ -70,6 +118,13 @@ export class JSONFile { return node === undefined ? undefined : getNodeValue(node); } + /** + * Modifies a value in the JSON file. + * @param jsonPath The path to the value to modify. + * @param value The new value to insert. + * @param insertInOrder A function to determine the insertion index, or `false` to insert at the end. + * @returns `true` if the modification was successful, `false` otherwise. + */ modify( jsonPath: JSONPath, value: JsonValue | undefined, @@ -89,13 +144,12 @@ export class JSONFile { getInsertionIndex = insertInOrder; } - const edits = modify(this.content, jsonPath, value, { + const edits = modify(this.#content, jsonPath, value, { getInsertionIndex, - // TODO: use indentation from original file. formattingOptions: { - insertSpaces: true, - tabSize: 2, - eol: this.eol, + insertSpaces: this.#insertSpaces, + tabSize: this.#tabSize, + eol: this.#eol, }, }); @@ -103,21 +157,45 @@ export class JSONFile { return false; } - this.content = applyEdits(this.content, edits); - this._jsonAst = undefined; + this.#content = applyEdits(this.#content, edits); + this.#jsonAst = undefined; return true; } + /** + * Deletes a value from the JSON file at a specific path. + * @param jsonPath The path to the value to delete. + * @returns `true` if the deletion was successful, `false` otherwise. + */ + delete(jsonPath: JSONPath): boolean { + return this.modify(jsonPath, undefined); + } + + /** Saves the modified content back to the file. */ save(): void { - writeFileSync(this.path, this.content); + writeFileSync(this.#path, this.#content); + } + + /** Detects the indentation of the file. */ + #detectIndentation(): void { + // Find the first line that has indentation. + const match = this.#content.match(/^(?:( )+|\t+)\S/m); + if (match) { + this.#insertSpaces = !!match[1]; + this.#tabSize = match[0].length - 1; + } } } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function readAndParseJson(path: string): any { +/** + * Reads and parses a JSON file, supporting comments and trailing commas. + * @param path The path to the JSON file. + * @returns The parsed JSON object. + */ +export function readAndParseJson(path: string): T { const errors: ParseError[] = []; - const content = parse(readFileSync(path, 'utf-8'), errors, { allowTrailingComma: true }); + const content = parse(readFileSync(path, 'utf-8'), errors, { allowTrailingComma: true }) as T; if (errors.length) { formatError(path, errors); } @@ -125,6 +203,11 @@ export function readAndParseJson(path: string): any { return content; } +/** + * Formats a JSON parsing error and throws an exception. + * @param path The path to the file that failed to parse. + * @param errors The list of parsing errors. + */ function formatError(path: string, errors: ParseError[]): never { const { error, offset } = errors[0]; throw new Error( @@ -134,7 +217,11 @@ function formatError(path: string, errors: ParseError[]): never { ); } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function parseJson(content: string): any { - return parse(content, undefined, { allowTrailingComma: true }); +/** + * Parses a JSON string, supporting comments and trailing commas. + * @param content The JSON string to parse. + * @returns The parsed JSON object. + */ +export function parseJson(content: string): T { + return parse(content, undefined, { allowTrailingComma: true }) as T; }