diff --git a/packages/rstack/src/fmt/cli.ts b/packages/rstack/src/fmt/cli.ts index be25327..7b1dd4d 100644 --- a/packages/rstack/src/fmt/cli.ts +++ b/packages/rstack/src/fmt/cli.ts @@ -13,6 +13,7 @@ import type { FmtMode, FmtRunResult, ResolvedFmtConfig } from './types.ts'; interface ParsedFmtCLIArgs { cache: boolean; + cacheLocation?: string; mode: FmtMode; patterns: string[]; ignorePaths: string[]; @@ -39,6 +40,7 @@ ${color.cyan('Options')}: --ignore-path Path to an additional ignore file (repeatable) -u, --ignore-unknown Ignore unknown files --no-cache Disable the formatting cache + --cache-location Path to the formatting cache directory --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 @@ -68,6 +70,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { 'ignore-path': { type: 'string', multiple: true }, 'ignore-unknown': { type: 'boolean', short: 'u' }, 'no-cache': { type: 'boolean' }, + 'cache-location': { type: 'string' }, 'no-error-on-unmatched-pattern': { type: 'boolean' }, 'with-node-modules': { type: 'boolean' }, 'parallel-workers': { type: 'string' }, @@ -88,6 +91,11 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { const mode = check ? 'check' : listDifferent ? 'list-different' : 'write'; const cache = !(values.noCache ?? false); + const cacheLocation = cache ? values.cacheLocation : undefined; + if (cacheLocation === '') { + throw new Error('The --cache-location option requires a path.'); + } + const ignorePaths = values.ignorePath ?? []; const ignoreUnknown = values.ignoreUnknown ?? false; const noErrorOnUnmatchedPattern = values.noErrorOnUnmatchedPattern ?? false; @@ -111,6 +119,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { return { cache, + cacheLocation, mode, patterns: positionals, ignorePaths, @@ -247,6 +256,7 @@ const runFmtCLI = async (args: string[]): Promise => { try { const { cache, + cacheLocation, help, ignorePaths, ignoreUnknown, @@ -277,11 +287,13 @@ const runFmtCLI = async (args: string[]): Promise => { return; } + const cacheDirPath = cacheLocation ? path.resolve(cwd, cacheLocation) : undefined; const config = await loadFmtConfig(cwd); const files = await discoverFmtFiles({ cwd, patterns, config, + excludedDirPath: cacheDirPath, ignorePaths, withNodeModules, }); @@ -297,7 +309,12 @@ const runFmtCLI = async (args: string[]): Promise => { } let cacheContext; - if (cache) { + if (cacheDirPath) { + cacheContext = { + filePath: path.join(cacheDirPath, fmtCacheFileName), + rootPath: config.rootPath, + }; + } else if (cache) { const cacheDir = await ensureProjectCacheDir(config.rootPath); if (cacheDir.status === 'available') { cacheContext = { diff --git a/packages/rstack/src/fmt/discovery.ts b/packages/rstack/src/fmt/discovery.ts index d4e012e..40431b7 100644 --- a/packages/rstack/src/fmt/discovery.ts +++ b/packages/rstack/src/fmt/discovery.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import { createFmtOptionsResolver, type FmtOptionsResolver } from './config.ts'; import { discoverFmtPaths } from './discoverPaths.ts'; import { createIgnoreMatcher } from './ignore.ts'; @@ -11,26 +12,37 @@ const createFileRequest = ( options: resolveOptions(filePath), }); +const createDirMatcher = (dirPath: string): ((filePath: string) => boolean) => { + const prefix = dirPath.endsWith(path.sep) ? dirPath : `${dirPath}${path.sep}`; + return (filePath) => filePath === dirPath || filePath.startsWith(prefix); +}; + /** Discovers worker-ready files without automatically reading Prettier config or ignore files. */ const discoverFmtFiles = async ({ cwd, + excludedDirPath, patterns, ignorePaths, withNodeModules, config, }: DiscoverFmtFilesOptions): Promise => { const isIgnored = await createIgnoreMatcher({ config, cwd, ignorePaths }); + const isExcluded = excludedDirPath ? createDirMatcher(excludedDirPath) : undefined; + const shouldIgnore = isExcluded + ? (filePath: string, isDirectory = false) => + isExcluded(filePath) || isIgnored(filePath, isDirectory) + : isIgnored; const candidates = await discoverFmtPaths({ cwd, patterns, withNodeModules, - isIgnored, + isIgnored: shouldIgnore, }); if (candidates.length === 0) { return []; } - const filePaths = candidates.filter((filePath) => !isIgnored(filePath)); + const filePaths = candidates.filter((filePath) => !shouldIgnore(filePath)); const resolveOptions = createFmtOptionsResolver(config); const files = filePaths.map((filePath) => createFileRequest(filePath, resolveOptions)); if (!files.some((file) => file.options.plugins?.length)) { diff --git a/packages/rstack/src/fmt/types.ts b/packages/rstack/src/fmt/types.ts index cf258e4..23e2dd6 100644 --- a/packages/rstack/src/fmt/types.ts +++ b/packages/rstack/src/fmt/types.ts @@ -55,6 +55,8 @@ interface ResolvedFmtConfig { interface DiscoverFmtFilesOptions { /** Absolute directory used to resolve input paths. */ cwd: string; + /** Absolute directory to exclude from formatting. */ + excludedDirPath?: string; /** Files, directories, and positive or negative globs. Defaults to the current directory. */ patterns?: string[]; /** Ignore files resolved from `cwd`; each file's patterns are relative to its own directory. */ diff --git a/packages/rstack/tests/cli/fmt/index.test.ts b/packages/rstack/tests/cli/fmt/index.test.ts index 23b7e0d..265b14c 100644 --- a/packages/rstack/tests/cli/fmt/index.test.ts +++ b/packages/rstack/tests/cli/fmt/index.test.ts @@ -187,10 +187,18 @@ test.each([ test('--no-cache bypasses cache reads and writes', () => { writeProjectFile('index.ts', 'const value=1'); - - const first = runFmt(['--no-cache', 'index.ts']); + writeProjectFile('custom-cache/v1.json', '{"value":true}'); + + const first = runFmt([ + '--no-cache', + '--cache-location', + 'custom-cache', + 'index.ts', + 'custom-cache/v1.json', + ]); expect(first.status).toBe(0); + expect(readProjectFile('custom-cache/v1.json')).toBe('{ "value": true }\n'); expect(existsSync(path.join(projectPath, '.rstack'))).toBe(false); writeProjectFile('.rstack/cache/fmt-v1.json', 'stale'); @@ -203,6 +211,38 @@ test('--no-cache bypasses cache reads and writes', () => { expect(existsSync(path.join(projectPath, '.rstack/cache/.gitignore'))).toBe(false); }); +test.each(['relative', 'absolute'] as const)('uses a %s custom cache location', (kind) => { + const cacheDir = path.join(projectPath, 'custom-cache'); + const cacheLocation = kind === 'relative' ? path.relative(projectPath, cacheDir) : cacheDir; + const cachePath = path.join(cacheDir, 'v1.json'); + writeProjectFile('index.ts', 'const value = 1;\n'); + + const result = runFmt(['--cache-location', cacheLocation, 'index.ts']); + + expect(result.status).toBe(0); + expect(JSON.parse(readFileSync(cachePath, 'utf8'))).toMatchObject({ + version: 1, + files: { + 'index.ts': [expect.any(String), expect.any(String), 'clean'], + }, + }); + expect(existsSync(path.join(projectPath, 'custom-cache/.gitignore'))).toBe(false); + expect(existsSync(path.join(projectPath, '.rstack'))).toBe(false); +}); + +test('excludes the custom cache directory from formatting', () => { + const cacheLocation = 'custom-cache'; + writeProjectFile('index.ts', 'const value = 1;\n'); + writeProjectFile('custom-cache/nested/ignored.ts', 'const value=2'); + expect(runFmt(['--cache-location', cacheLocation, 'index.ts']).status).toBe(0); + + const result = runFmt(['--cache-location', cacheLocation, '.']); + + expect(result.status).toBe(0); + expectWriteSummary(result.stdout, 2, 0); + expect(readProjectFile('custom-cache/nested/ignored.ts')).toBe('const value=2'); +}); + test('uses an explicit config root cache from a subdirectory', () => { const appPath = path.join(projectPath, 'packages/app'); writeProjectFile('packages/app/index.ts', 'const value=1'); diff --git a/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap b/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap index 3dda6d6..7570874 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-cache Disable the formatting cache + --cache-location Path to the formatting cache directory --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 diff --git a/packages/rstack/tests/fmt/cli.test.ts b/packages/rstack/tests/fmt/cli.test.ts index dd8cf66..5bd45d2 100644 --- a/packages/rstack/tests/fmt/cli.test.ts +++ b/packages/rstack/tests/fmt/cli.test.ts @@ -127,6 +127,23 @@ test('parses --no-cache', () => { expect(parseFmtCLIArgs(['--no-cache']).cache).toBe(false); }); +test('parses --cache-location', () => { + expect(parseFmtCLIArgs(['--cache-location', '.cache/fmt']).cacheLocation).toBe('.cache/fmt'); +}); + +test('--no-cache ignores --cache-location', () => { + expect(parseFmtCLIArgs(['--no-cache', '--cache-location='])).toMatchObject({ + cache: false, + cacheLocation: undefined, + }); +}); + +test('rejects an empty cache location', () => { + expect(() => parseFmtCLIArgs(['--cache-location='])).toThrow( + 'The --cache-location option requires a path.', + ); +}); + test('parses --with-node-modules', () => { expect(parseFmtCLIArgs(['--with-node-modules']).withNodeModules).toBe(true); }); diff --git a/packages/rstack/tests/fmt/discovery.test.ts b/packages/rstack/tests/fmt/discovery.test.ts index 71b902b..171fbaa 100644 --- a/packages/rstack/tests/fmt/discovery.test.ts +++ b/packages/rstack/tests/fmt/discovery.test.ts @@ -60,6 +60,30 @@ test('excludes .rstack from discovery', async () => { }); }); +test('excludes a custom cache directory', async () => { + await withTempProject(async (rootPath) => { + const cacheDir = path.join(rootPath, 'custom-cache'); + const cacheFile = writeProjectFile(rootPath, 'custom-cache/v1.json', '{}'); + writeProjectFile(rootPath, 'custom-cache/nested/ignored.ts'); + writeProjectFile(rootPath, 'index.ts'); + + const discoveredFiles = await discoverFmtFiles({ + cwd: rootPath, + excludedDirPath: cacheDir, + config: normalizeFmtConfig(undefined, rootPath), + }); + const explicitFile = await discoverFmtFiles({ + cwd: rootPath, + excludedDirPath: cacheDir, + patterns: [cacheFile], + config: normalizeFmtConfig(undefined, rootPath), + }); + + expect(relativePaths(rootPath, discoveredFiles)).toEqual(['index.ts']); + expect(explicitFile).toEqual([]); + }); +}); + test('keeps files re-included by a CLI ignore file during directory traversal', async () => { await withTempProject(async (rootPath) => { writeProjectFile(rootPath, '.prettierignore', 'generated/*\n!generated/keep.ts\n'); diff --git a/website/docs/en/guide/cli/fmt.mdx b/website/docs/en/guide/cli/fmt.mdx index 6861b89..e2552f1 100644 --- a/website/docs/en/guide/cli/fmt.mdx +++ b/website/docs/en/guide/cli/fmt.mdx @@ -133,6 +133,18 @@ Without this option, `rs fmt` stores cache data in `.rstack/cache/fmt` under the See [Cache](../formatting#cache) for cache behavior and cleanup guidance. +### `--cache-location ` + +Store the persistent cache in a custom directory: + +```bash +rs fmt --cache-location .cache/rs-fmt +``` + +Relative paths are resolved from the current working directory, while absolute paths are used as-is. The directory is created as needed and excluded from file discovery. Unlike the default cache location, a custom directory does not receive an automatic `.gitignore`; exclude it from version control or manage it through your CI cache configuration. + +When both options are provided, `--no-cache` takes precedence and the custom directory is not excluded from file discovery. + ### `--no-error-on-unmatched-pattern` Exit successfully without diagnostics when no files match the provided paths or globs, including when all matching files are ignored: diff --git a/website/docs/en/guide/formatting.mdx b/website/docs/en/guide/formatting.mdx index 4064d65..2710f6a 100644 --- a/website/docs/en/guide/formatting.mdx +++ b/website/docs/en/guide/formatting.mdx @@ -180,6 +180,8 @@ define.fmt({ The default cache directory is `.rstack/cache/fmt` under the Rstack configuration root. When a command runs from a subdirectory, it continues to use the cache next to the resolved `rstack.config.*` file. Stdin formatting does not use this cache. +Use [`--cache-location `](./cli/fmt#--cache-location-path) to store the cache in a different directory. Relative paths are resolved from the current working directory. Custom directories are excluded from file discovery but are not automatically ignored by Git. + Use [`--no-cache`](./cli/fmt#--no-cache) to run without reading, creating, or updating the cache: ```bash diff --git a/website/docs/zh/guide/cli/fmt.mdx b/website/docs/zh/guide/cli/fmt.mdx index e810c26..adf80c2 100644 --- a/website/docs/zh/guide/cli/fmt.mdx +++ b/website/docs/zh/guide/cli/fmt.mdx @@ -133,6 +133,18 @@ rs fmt --no-cache 缓存行为和清理方式请参考[缓存](../formatting#cache)。 +### `--cache-location ` + +将持久化缓存保存到自定义目录: + +```bash +rs fmt --cache-location .cache/rs-fmt +``` + +相对路径基于当前工作目录解析,绝对路径则原样使用。目录会在需要时自动创建,并从文件发现中排除。与默认缓存位置不同,自定义目录不会自动生成 `.gitignore`;请将其排除在版本控制之外,或通过 CI 缓存配置进行管理。 + +同时使用两个选项时,优先使用 `--no-cache`,且不会从文件发现中排除自定义目录。 + ### `--no-error-on-unmatched-pattern` 如果传入的路径或 glob 没有匹配任何文件(包括所有匹配文件均被忽略的情况),则不输出诊断信息并成功退出: diff --git a/website/docs/zh/guide/formatting.mdx b/website/docs/zh/guide/formatting.mdx index 71f580b..61d96b3 100644 --- a/website/docs/zh/guide/formatting.mdx +++ b/website/docs/zh/guide/formatting.mdx @@ -180,6 +180,8 @@ define.fmt({ 默认缓存目录位于 Rstack 配置根目录下的 `.rstack/cache/fmt`。从子目录运行命令时,仍会使用解析到的 `rstack.config.*` 文件旁的缓存。stdin 格式化不会使用该缓存。 +使用 [`--cache-location `](./cli/fmt#--cache-location-path) 可以将缓存保存到其他目录。相对路径基于当前工作目录解析。自定义目录会从文件发现中排除,但不会被 Git 自动忽略。 + 使用 [`--no-cache`](./cli/fmt#--no-cache) 可以在运行时跳过缓存读取、创建和更新: ```bash