Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ import { isPackageNameSafeForAnalytics } from '../analytics/analytics';
import { EventCustomDimension } from '../analytics/analytics-parameters';
import { getProjectByCwd, getSchematicDefaults } from '../utilities/config';
import { assertIsError } from '../utilities/error';
import { formatFiles } from '../utilities/formatting';
import { memoize } from '../utilities/memoize';
import { formatFiles } from '../utilities/prettier';
import { isTTY } from '../utilities/tty';
import {
CommandModule,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ import * as semver from 'semver';
import { subscribeToWorkflow } from '../../../command-builder/utilities/schematic-workflow';
import { colors } from '../../../utilities/color';
import { assertIsError } from '../../../utilities/error';
import { formatFiles } from '../../../utilities/formatting';
import { writeErrorToLogFile } from '../../../utilities/log-file';
import { formatFiles } from '../../../utilities/prettier';
import { askChoices } from '../../../utilities/prompt';
import { isTTY } from '../../../utilities/tty';
import { coerceVersionNumber } from './cli-version';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { dirname, join } from 'node:path';
import { promisify } from 'node:util';

const execFileAsync = promisify(execFile);
let prettierCliPath: string | null | undefined;

Comment on lines 15 to 16

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To avoid redundant disk I/O and package resolution when formatFiles is called multiple times (e.g., during multiple migrations in a loop), we should cache the resolved formatter path and name. We can define a cache Map at the module level.

Suggested change
const execFileAsync = promisify(execFile);
let prettierCliPath: string | null | undefined;
const execFileAsync = promisify(execFile);
const formatterCache = new Map<string, { cliPath: string | null; formatter?: string }>();

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me know if that should be done

/**
* Conservative upper bound for the length of a single spawned command line.
Expand Down Expand Up @@ -60,7 +59,7 @@ export function batchFilesByArgumentLength(
}

/**
* Formats files using Prettier.
* Formats files using the workspace's Oxfmt or Prettier installation.
* @param cwd The current working directory.
* @param files The files to format.
*/
Expand All @@ -69,40 +68,44 @@ export async function formatFiles(cwd: string, files: Set<string>): Promise<void
return;
}

if (prettierCliPath === undefined) {
const workspaceRequire = createRequire(cwd + '/');
let formatterCliPath: string | undefined;
let formatter: string | undefined;
for (const name of ['oxfmt', 'prettier']) {
let packageJsonPath: string;
try {
const prettierPath = createRequire(cwd + '/').resolve('prettier/package.json');
const prettierPackageJson = JSON.parse(await readFile(prettierPath, 'utf-8')) as {
bin: string;
};
prettierCliPath = join(dirname(prettierPath), prettierPackageJson.bin);
packageJsonPath = workspaceRequire.resolve(`${name}/package.json`);
} catch {
// Prettier is not installed.
prettierCliPath = null;
continue;
}

const { bin } = JSON.parse(await readFile(packageJsonPath, 'utf-8')) as {
bin: string | Record<string, string>;
};
formatterCliPath = join(dirname(packageJsonPath), typeof bin === 'string' ? bin : bin[name]);
formatter = name;
break;
}
Comment on lines +71 to 88

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Improve robustness and implement caching when resolving the formatter:

  1. Wrap the entire resolution, file reading, and JSON parsing inside the try-catch block to prevent any unexpected errors (e.g., missing/malformed package.json or missing bin field) from crashing the CLI.
  2. Safely check if bin is a string or an object containing the formatter name before calling path.join to avoid a TypeError if bin or bin[name] is undefined.
  3. Use the module-level formatterCache to cache the resolved formatter details.
  let cacheEntry = formatterCache.get(cwd);
  if (!cacheEntry) {
    const workspaceRequire = createRequire(cwd + '/');
    let formatterCliPath: string | null = null;
    let formatter: string | undefined;

    for (const name of ['oxfmt', 'prettier']) {
      try {
        const packageJsonPath = workspaceRequire.resolve(`${name}/package.json`);
        const { bin } = JSON.parse(await readFile(packageJsonPath, 'utf-8')) as {
          bin?: string | Record<string, string>;
        };
        const binPath = typeof bin === 'string' ? bin : bin?.[name];
        if (binPath) {
          formatterCliPath = join(dirname(packageJsonPath), binPath);
          formatter = name;
          break;
        }
      } catch {
        continue;
      }
    }

    cacheEntry = { cliPath: formatterCliPath, formatter };
    formatterCache.set(cwd, cacheEntry);
  }

  const { cliPath: formatterCliPath, formatter } = cacheEntry;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me know if this should be done


if (!prettierCliPath) {
if (!formatterCliPath) {
return;
}

const baseArgs = [
prettierCliPath,
'--write',
'--no-error-on-unmatched-pattern',
'--ignore-unknown',
];
const baseArgs = [formatterCliPath, '--write', '--no-error-on-unmatched-pattern'];
if (formatter === 'prettier') {
baseArgs.push('--ignore-unknown');
}
Comment on lines +94 to +97

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Only pass --no-error-on-unmatched-pattern when the formatter is prettier. oxfmt (and other oxc-based tools) use strict argument parsing (via Rust's clap library) and will fail with an error if an unknown option like --no-error-on-unmatched-pattern is passed, preventing any files from being formatted.

Suggested change
const baseArgs = [formatterCliPath, '--write', '--no-error-on-unmatched-pattern'];
if (formatter === 'prettier') {
baseArgs.push('--ignore-unknown');
}
const baseArgs = [formatterCliPath, '--write'];
if (formatter === 'prettier') {
baseArgs.push('--no-error-on-unmatched-pattern', '--ignore-unknown');
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oxfmt 0.74.0 supports --no-error-on-unmatched-pattern https://oxc.rs/docs/guide/usage/formatter/cli.html#runtime-options

// `process.execPath` is spawned as the first argument and also counts toward the
// command-line limit; it and `prettierCliPath` are absolute paths that may contain
// command-line limit; it and `formatterCliPath` are absolute paths that may contain
// spaces and therefore be quoted.
const baseLength = [process.execPath, ...baseArgs].reduce(
(total, arg) => total + arg.length + (arg.includes(' ') ? 3 : 1),
0,
);

// Spawn Prettier once per batch so repositories with many changed files do not
// Spawn the formatter once per batch so repositories with many changed files do not
// overflow the OS command-line length limit. A failure in one batch (e.g. a file
// Prettier cannot parse) must not stop the remaining batches, matching the previous
// the formatter cannot parse) must not stop the remaining batches, matching the previous
// single-invocation behavior, so errors are collected and reported together.
const errors: string[] = [];
for (const batch of batchFilesByArgumentLength(files, baseLength, MAX_COMMAND_LINE_LENGTH)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/

import { batchFilesByArgumentLength } from './prettier';
import { batchFilesByArgumentLength } from './formatting';

describe('batchFilesByArgumentLength', () => {
it('returns no batches when there are no files', () => {
Expand Down
32 changes: 32 additions & 0 deletions tests/e2e/tests/generate/formatting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import { expectFileToMatch, readFile, writeFile } from '../../utils/fs';
import { installPackage, uninstallPackage } from '../../utils/packages';
import { ng } from '../../utils/process';

export default async function () {
await uninstallPackage('prettier');
await installPackage('oxfmt');

const untouched = 'export const untouched={value:"unchanged"};';
await writeFile('src/untouched.ts', untouched);
await writeFile('.oxfmtrc.json', JSON.stringify({ singleQuote: false, semi: false }));

await ng('generate', 'service', 'oxfmt-only', '--skip-tests');
await expectFileToMatch('src/app/oxfmt-only.ts', 'from "@angular/core"\n');

// Distinct configurations make the selected formatter observable when both are installed.
await installPackage('prettier');
await writeFile('.prettierrc', JSON.stringify({ singleQuote: true, semi: false }));
await ng('generate', 'service', 'both-formatters', '--skip-tests');
await expectFileToMatch('src/app/both-formatters.ts', 'from "@angular/core"\n');

await uninstallPackage('oxfmt');
await ng('generate', 'service', 'prettier-only', '--skip-tests');
await expectFileToMatch('src/app/prettier-only.ts', "from '@angular/core'\n");

await uninstallPackage('prettier');
await ng('generate', 'service', 'no-formatter', '--skip-tests');
await expectFileToMatch('src/app/no-formatter.ts', "from '@angular/core';\n");

assert.equal(await readFile('src/untouched.ts'), untouched);
}
Loading