diff --git a/packages/rstack/src/fmt/cli.ts b/packages/rstack/src/fmt/cli.ts index 0dc1347..7cada94 100644 --- a/packages/rstack/src/fmt/cli.ts +++ b/packages/rstack/src/fmt/cli.ts @@ -14,6 +14,7 @@ interface ParsedFmtCLIArgs { ignorePaths: string[]; ignoreUnknown: boolean; noErrorOnUnmatchedPattern: boolean; + withNodeModules: boolean; maxWorkers?: number; help: boolean; /** Path the stdin content is formatted as; it need not exist on disk. */ @@ -34,6 +35,7 @@ ${color.cyan('Options')}: --ignore-path Path to an additional ignore file (repeatable) -u, --ignore-unknown Ignore unknown files --no-error-on-unmatched-pattern Do not error when no files match + --with-node-modules Process files inside node_modules --parallel-workers Number of parallel workers --stdin-filepath Format stdin as if it were saved at -h, --help Display this help message`; @@ -61,6 +63,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { 'ignore-path': { type: 'string', multiple: true }, 'ignore-unknown': { type: 'boolean', short: 'u' }, 'no-error-on-unmatched-pattern': { type: 'boolean' }, + 'with-node-modules': { type: 'boolean' }, 'parallel-workers': { type: 'string' }, 'stdin-filepath': { type: 'string' }, help: { type: 'boolean', short: 'h' }, @@ -81,6 +84,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { const ignorePaths = values.ignorePath ?? []; const ignoreUnknown = values.ignoreUnknown ?? false; const noErrorOnUnmatchedPattern = values.noErrorOnUnmatchedPattern ?? false; + const withNodeModules = values.withNodeModules ?? false; const parallelWorkers = values.parallelWorkers; const maxWorkers = parseMaxWorkers(parallelWorkers); const help = values.help ?? false; @@ -104,6 +108,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { ignorePaths, ignoreUnknown, noErrorOnUnmatchedPattern, + withNodeModules, maxWorkers, help, stdinFilepath, @@ -239,6 +244,7 @@ const runFmtCLI = async (args: string[]): Promise => { noErrorOnUnmatchedPattern, patterns, stdinFilepath, + withNodeModules, } = parseFmtCLIArgs(args); if (help) { logger.log(fmtHelpMessage); @@ -266,6 +272,7 @@ const runFmtCLI = async (args: string[]): Promise => { patterns, config, ignorePaths, + withNodeModules, }); if (files.length === 0) { diff --git a/packages/rstack/src/fmt/discoverPaths.ts b/packages/rstack/src/fmt/discoverPaths.ts index f0e16f4..acc013d 100644 --- a/packages/rstack/src/fmt/discoverPaths.ts +++ b/packages/rstack/src/fmt/discoverPaths.ts @@ -5,12 +5,14 @@ import isBinaryPath from 'is-binary-path'; import micromatch from 'micromatch'; import readdir, { type Dirent } from 'tiny-readdir'; -const alwaysIgnoredNames = new Set(['.git', '.sl', '.svn', '.hg', '.jj', 'node_modules']); +const defaultIgnoredDirNames = new Set(['.git', '.sl', '.svn', '.hg', '.jj', 'node_modules']); interface DiscoverFmtPathsOptions { /** Absolute directory used to resolve input paths. */ cwd: string; patterns?: string[]; + /** Whether files inside node_modules may be discovered. */ + withNodeModules?: boolean; /** Returns whether a scanned directory can be pruned before traversal. */ isDirectoryIgnored?: (directoryPath: string) => boolean; } @@ -47,11 +49,15 @@ const getDirentParentPath = (dirent: Dirent): string => const getDirentPath = (dirent: Dirent, parentPath: string): string => `${parentPath}${parentPath === path.sep ? '' : path.sep}${dirent.name}`; -const hasAlwaysIgnoredSegment = (cwd: string, filePath: string): boolean => +const hasBuiltInIgnoredSegment = ( + cwd: string, + filePath: string, + ignoredDirNames: ReadonlySet, +): boolean => path .relative(cwd, filePath) .split(path.sep) - .some((segment) => alwaysIgnoredNames.has(segment)); + .some((segment) => ignoredDirNames.has(segment)); const findGitRoot = async (cwd: string): Promise => { let directoryPath = cwd; @@ -202,6 +208,7 @@ class GitIgnoreMatcher { const createTraversalOptions = ( gitIgnore: GitIgnoreMatcher, + ignoredDirNames: ReadonlySet, isIncluded?: (filePath: string) => boolean, isDirectoryIgnored?: (directoryPath: string) => boolean, ) => { @@ -212,7 +219,7 @@ const createTraversalOptions = ( followSymlinks: false, ignore: (targetPath: string) => { const isDirectory = directories.delete(targetPath); - if (alwaysIgnoredNames.has(path.basename(targetPath))) { + if (ignoredDirNames.has(path.basename(targetPath))) { return true; } @@ -265,7 +272,11 @@ type ClassifiedPatterns = { negativeGlobs: string[]; }; -const classifyPatterns = async (cwd: string, patterns: string[]): Promise => { +const classifyPatterns = async ( + cwd: string, + patterns: string[], + ignoredDirNames: ReadonlySet, +): Promise => { const entries = await Promise.all( patterns.map(async (pattern): Promise => { if (pattern.startsWith('!')) { @@ -273,7 +284,7 @@ const classifyPatterns = async (cwd: string, patterns: string[]): Promise => { const patterns = inputPatterns?.length ? inputPatterns : ['.']; + const ignoredDirNames = withNodeModules + ? new Set(defaultIgnoredDirNames) + : defaultIgnoredDirNames; + + if (withNodeModules) { + ignoredDirNames.delete('node_modules'); + } + const { files: explicitFiles, directories, globs, negativeGlobs, - } = await classifyPatterns(cwd, patterns); + } = await classifyPatterns(cwd, patterns, ignoredDirNames); const directoryRoots = getOutermostPaths(directories); const globMatchers = globs.map((pattern) => micromatch.matcher(pattern, { dot: true })); const candidates = new Set(explicitFiles); @@ -389,7 +409,10 @@ const discoverFmtPaths = async ({ }; return ( - await readdir(rootPath, createTraversalOptions(gitIgnore, isIncluded, isDirectoryIgnored)) + await readdir( + rootPath, + createTraversalOptions(gitIgnore, ignoredDirNames, isIncluded, isDirectoryIgnored), + ) ).files; }), ); diff --git a/packages/rstack/src/fmt/discovery.ts b/packages/rstack/src/fmt/discovery.ts index 47dc157..532859b 100644 --- a/packages/rstack/src/fmt/discovery.ts +++ b/packages/rstack/src/fmt/discovery.ts @@ -13,12 +13,14 @@ const discoverFmtFiles = async ({ cwd, patterns, ignorePaths, + withNodeModules, config, }: DiscoverFmtFilesOptions): Promise => { const isIgnored = await createIgnoreMatcher({ config, cwd, ignorePaths }); const candidates = await discoverFmtPaths({ cwd, patterns, + withNodeModules, isDirectoryIgnored: (directoryPath) => isIgnored(directoryPath, true), }); if (candidates.length === 0) { diff --git a/packages/rstack/src/fmt/types.ts b/packages/rstack/src/fmt/types.ts index db9b2d6..e93a4bd 100644 --- a/packages/rstack/src/fmt/types.ts +++ b/packages/rstack/src/fmt/types.ts @@ -58,6 +58,8 @@ interface DiscoverFmtFilesOptions { patterns?: string[]; /** Ignore files resolved from `cwd`; each file's patterns are relative to its own directory. */ ignorePaths?: string[]; + /** Whether files inside node_modules may be discovered. */ + withNodeModules?: boolean; /** Resolved project config applied to discovered files. */ config: ResolvedFmtConfig; } diff --git a/packages/rstack/tests/cli/fmt/index.test.ts b/packages/rstack/tests/cli/fmt/index.test.ts index 7f568af..cdd41b7 100644 --- a/packages/rstack/tests/cli/fmt/index.test.ts +++ b/packages/rstack/tests/cli/fmt/index.test.ts @@ -128,6 +128,21 @@ test('formats the current directory with Prettier defaults', () => { expect(readProjectFile('index.ts')).toBe('const message = "hello";\n'); }); +test('formats files in node_modules with --with-node-modules', () => { + const source = 'const message="hello"'; + writeProjectFile('node_modules/example/index.ts', source); + + const skipped = runFmt(['node_modules/example']); + expect(skipped.status).toBe(2); + expect(readProjectFile('node_modules/example/index.ts')).toBe(source); + + const result = runFmt(['--with-node-modules', 'node_modules/example']); + expect(result.status).toBe(0); + expectWriteSummary(result.stdout, 1, 1); + expect(result.stderr).toBe(''); + expect(readProjectFile('node_modules/example/index.ts')).toBe('const message = "hello";\n'); +}); + test('summarizes write mode when no files change', () => { writeProjectFile('index.ts', 'const message = "hello";\n'); diff --git a/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap b/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap index 2df10bd..9d69d08 100644 --- a/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap +++ b/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap @@ -13,6 +13,7 @@ Options: --ignore-path Path to an additional ignore file (repeatable) -u, --ignore-unknown Ignore unknown files --no-error-on-unmatched-pattern Do not error when no files match + --with-node-modules Process files inside node_modules --parallel-workers Number of parallel workers --stdin-filepath Format stdin as if it were saved at -h, --help Display this help message" diff --git a/packages/rstack/tests/fmt/cli.test.ts b/packages/rstack/tests/fmt/cli.test.ts index 9521ccb..e687031 100644 --- a/packages/rstack/tests/fmt/cli.test.ts +++ b/packages/rstack/tests/fmt/cli.test.ts @@ -24,6 +24,7 @@ test('uses write mode by default', () => { ignorePaths: [], ignoreUnknown: false, noErrorOnUnmatchedPattern: false, + withNodeModules: false, maxWorkers: undefined, help: false, }); @@ -40,6 +41,7 @@ test.each([ ignorePaths: [], ignoreUnknown: false, noErrorOnUnmatchedPattern: false, + withNodeModules: false, maxWorkers: undefined, help: false, }); @@ -52,6 +54,7 @@ test('configures parallel worker count', () => { ignorePaths: [], ignoreUnknown: false, noErrorOnUnmatchedPattern: false, + withNodeModules: false, maxWorkers: 3, help: false, }); @@ -75,6 +78,7 @@ test('preserves file paths and globs', () => { ignorePaths: [], ignoreUnknown: false, noErrorOnUnmatchedPattern: false, + withNodeModules: false, maxWorkers: undefined, help: false, }); @@ -87,6 +91,7 @@ test('treats arguments after the terminator as paths', () => { ignorePaths: [], ignoreUnknown: false, noErrorOnUnmatchedPattern: false, + withNodeModules: false, maxWorkers: undefined, help: false, }); @@ -111,6 +116,10 @@ test.each(['-u', '--ignore-unknown', '--ignoreUnknown'])('parses %s', (option) = expect(parseFmtCLIArgs([option]).ignoreUnknown).toBe(true); }); +test('parses --with-node-modules', () => { + expect(parseFmtCLIArgs(['--with-node-modules']).withNodeModules).toBe(true); +}); + test('parses --stdin-filepath', () => { expect(parseFmtCLIArgs(['--stdin-filepath', 'src/index.ts'])).toEqual({ mode: 'write', @@ -118,6 +127,7 @@ test('parses --stdin-filepath', () => { ignorePaths: [], ignoreUnknown: false, noErrorOnUnmatchedPattern: false, + withNodeModules: false, maxWorkers: undefined, help: false, stdinFilepath: 'src/index.ts', @@ -131,6 +141,7 @@ test('accepts a worker count with --stdin-filepath', () => { ignorePaths: [], ignoreUnknown: false, noErrorOnUnmatchedPattern: false, + withNodeModules: false, maxWorkers: 2, help: false, stdinFilepath: 'index.ts', diff --git a/packages/rstack/tests/fmt/discoverPaths.test.ts b/packages/rstack/tests/fmt/discoverPaths.test.ts index a712f61..02bfbd2 100644 --- a/packages/rstack/tests/fmt/discoverPaths.test.ts +++ b/packages/rstack/tests/fmt/discoverPaths.test.ts @@ -19,6 +19,7 @@ test('discovers non-binary files in stable order and skips hard-ignored paths', writeProjectFile(rootPath, '.jj/internal.js'); const files = await discoverFmtPaths({ cwd: rootPath }); + const filesWithNodeModules = await discoverFmtPaths({ cwd: rootPath, withNodeModules: true }); expect(relativePaths(rootPath, files)).toEqual([ 'a.js', @@ -26,9 +27,35 @@ test('discovers non-binary files in stable order and skips hard-ignored paths', path.join('folder with spaces', 'c.ts'), 'unknown.extension', ]); + expect(relativePaths(rootPath, filesWithNodeModules)).toEqual([ + 'a.js', + 'b.ts', + path.join('folder with spaces', 'c.ts'), + path.join('node_modules', 'package', 'index.js'), + 'unknown.extension', + ]); await expect( discoverFmtPaths({ cwd: rootPath, patterns: ['node_modules/package/index.js'] }), ).resolves.toEqual([]); + await expect( + discoverFmtPaths({ + cwd: rootPath, + patterns: ['node_modules/package/index.js'], + withNodeModules: true, + }), + ).resolves.toEqual([path.join(rootPath, 'node_modules/package/index.js')]); + }); +}); + +test('keeps node_modules excluded by gitignore when built-in exclusion is disabled', async () => { + await withTempProject(async (rootPath) => { + writeProjectFile(rootPath, '.gitignore', 'node_modules/\n'); + writeProjectFile(rootPath, 'node_modules/package/index.js'); + writeProjectFile(rootPath, 'index.js'); + + const files = await discoverFmtPaths({ cwd: rootPath, withNodeModules: true }); + + expect(relativePaths(rootPath, files)).toEqual(['.gitignore', 'index.js']); }); }); diff --git a/website/docs/en/guide/cli/fmt.mdx b/website/docs/en/guide/cli/fmt.mdx index b92ac26..9e4f09e 100644 --- a/website/docs/en/guide/cli/fmt.mdx +++ b/website/docs/en/guide/cli/fmt.mdx @@ -149,6 +149,16 @@ Formatted output is written to stdout and diagnostics to stderr. If the input pa > `--stdin-filepath` cannot be combined with file arguments or with `--write`, `--check`, or `--list-different`. +### `--with-node-modules` + +Process files inside `node_modules`, which `rs fmt` excludes by default: + +```bash +rs fmt --with-node-modules node_modules/example/index.js +``` + +This option only disables the built-in `node_modules` exclusion. Directory and glob scans still follow `.gitignore`, while `ignorePatterns` and `--ignore-path` continue to apply to every input. + ### `--write` Write formatted files in place. This is the default mode, so specifying `--write` is optional: diff --git a/website/docs/zh/guide/cli/fmt.mdx b/website/docs/zh/guide/cli/fmt.mdx index 78fd64f..a425863 100644 --- a/website/docs/zh/guide/cli/fmt.mdx +++ b/website/docs/zh/guide/cli/fmt.mdx @@ -149,6 +149,16 @@ cat src/index.ts | rs fmt --stdin-filepath src/index.ts > `--stdin-filepath` 不能与文件参数或 `--write`、`--check`、`--list-different` 同时使用。 +### `--with-node-modules` + +处理 `node_modules` 中的文件。默认情况下,`rs fmt` 会排除这些文件: + +```bash +rs fmt --with-node-modules node_modules/example/index.js +``` + +此选项只会关闭内置的 `node_modules` 排除规则。目录和 glob 扫描仍然遵循 `.gitignore`,`ignorePatterns` 和 `--ignore-path` 也会继续作用于所有输入。 + ### `--write` 将格式化结果写回文件。这是默认模式,因此可以省略 `--write`: