Repository navigation
feat(@angular/cli): support Oxfmt for automatic file formatting #34460
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -13,7 +13,6 @@ import { dirname, join } from 'node:path'; | |||||||||||||||||
| import { promisify } from 'node:util'; | ||||||||||||||||||
|
|
||||||||||||||||||
| const execFileAsync = promisify(execFile); | ||||||||||||||||||
| let prettierCliPath: string | null | undefined; | ||||||||||||||||||
|
|
||||||||||||||||||
| /** | ||||||||||||||||||
| * Conservative upper bound for the length of a single spawned command line. | ||||||||||||||||||
|
|
@@ -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. | ||||||||||||||||||
| */ | ||||||||||||||||||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Improve robustness and implement caching when resolving the formatter:
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;
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Only pass
Suggested change
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oxfmt 0.74.0 supports |
||||||||||||||||||
| // `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)) { | ||||||||||||||||||
|
|
||||||||||||||||||
| 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); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To avoid redundant disk I/O and package resolution when
formatFilesis 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.There was a problem hiding this comment.
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