Skip to content

refactor(@angular/cli): enhance JSON file handling and utility functions #30828

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/angular/cli/src/utilities/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,12 +242,15 @@ export async function getWorkspaceRaw(

export async function validateWorkspace(data: json.JsonObject, isGlobal: boolean): Promise<void> {
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;

Expand Down
12 changes: 12 additions & 0 deletions packages/angular/cli/src/utilities/eol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
35 changes: 30 additions & 5 deletions packages/angular/cli/src/utilities/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`,
);
}
149 changes: 118 additions & 31 deletions packages/angular/cli/src/utilities/json-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
Expand All @@ -89,42 +144,70 @@ 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,
},
});

if (edits.length === 0) {
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<T extends JsonValue>(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);
}

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(
Expand All @@ -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<T extends JsonValue>(content: string): T {
return parse(content, undefined, { allowTrailingComma: true }) as T;
}