From 50522a614bfd3bac052dc1294f757afbfdb2b96d Mon Sep 17 00:00:00 2001 From: neverland Date: Fri, 7 Aug 2026 15:50:16 +0800 Subject: [PATCH] feat(fmt): cache files using package plugins --- packages/rstack/src/fmt/cacheIdentity.ts | 22 ++++++-- packages/rstack/src/fmt/runner.ts | 46 ++++++++++++++++- .../rstack/tests/fmt/cacheIdentity.test.ts | 10 ++++ packages/rstack/tests/fmt/runnerCache.test.ts | 51 ++++++++++++++++++- website/docs/en/guide/formatting.mdx | 2 +- website/docs/zh/guide/formatting.mdx | 2 +- 6 files changed, 124 insertions(+), 9 deletions(-) diff --git a/packages/rstack/src/fmt/cacheIdentity.ts b/packages/rstack/src/fmt/cacheIdentity.ts index 824c0cf9..189eb7e4 100644 --- a/packages/rstack/src/fmt/cacheIdentity.ts +++ b/packages/rstack/src/fmt/cacheIdentity.ts @@ -10,6 +10,7 @@ declare const RSTACK_VERSION: string; type CacheKeyResolver = (filePath: string) => string | undefined; type OptionsHasher = (options: ResolvedFmtOptions) => string | undefined; +type PluginFingerprints = ReadonlyMap; const sha256 = (content: string | Uint8Array): string => createHash('sha256').update(content).digest('hex'); @@ -28,7 +29,7 @@ const createCacheKeyResolver = (rootPath: string): CacheKeyResolver => { }; /** Hashes final per-file options and memoizes option objects shared by many files. */ -const createOptionsHasher = (): OptionsHasher => { +const createOptionsHasher = (pluginFingerprints?: PluginFingerprints): OptionsHasher => { const hashes = new WeakMap(); return (options) => { @@ -39,10 +40,23 @@ const createOptionsHasher = (): OptionsHasher => { let hash: string | undefined; try { - // A resolved plugin path does not identify the plugin implementation. - if (!options.plugins?.length) { - hash = sha256(stableStringify(options)); + const { plugins } = options; + let value = options; + if (plugins?.length) { + const fingerprints: string[] = []; + for (const plugin of plugins) { + const key = + plugin instanceof URL ? plugin.href : typeof plugin === 'string' ? plugin : undefined; + const fingerprint = key === undefined ? undefined : pluginFingerprints?.get(key); + if (fingerprint === undefined) { + hashes.set(options, null); + return undefined; + } + fingerprints.push(fingerprint); + } + value = { ...options, plugins: fingerprints }; } + hash = sha256(stableStringify(value)); } catch { // Circular or unreadable options cannot be cached. } diff --git a/packages/rstack/src/fmt/runner.ts b/packages/rstack/src/fmt/runner.ts index 230aa228..cde24b5e 100644 --- a/packages/rstack/src/fmt/runner.ts +++ b/packages/rstack/src/fmt/runner.ts @@ -6,6 +6,7 @@ import type { FmtExitCode, FmtFileRequest, FmtFileResult, + FmtPluginSpecifier, FmtRunResult, RunFmtFilesOptions, } from './types.ts'; @@ -43,6 +44,43 @@ const minPriorityWorkers = 8; const isMarkdown = (file: FmtFileRequest): boolean => file.path.endsWith('.md') || file.path.endsWith('.mdx'); +/** Resolves each distinct plugin once before the synchronous per-file cache path. */ +const loadPluginFingerprints = async ( + files: FmtFileRequest[], +): Promise | undefined> => { + const plugins = new Map(); + for (const file of files) { + try { + for (const plugin of file.options.plugins ?? []) { + if (typeof plugin === 'string' || plugin instanceof URL) { + plugins.set(plugin instanceof URL ? plugin.href : plugin, plugin); + } + } + } catch { + // Unreadable options cannot be cached by the options hasher. + } + } + if (plugins.size === 0) { + return undefined; + } + + const { createFingerprintResolver } = await import( + /* rspackChunkName: 'fmtPlugins' */ + './plugins.ts' + ); + const resolveFingerprint = createFingerprintResolver(); + const entries = await Promise.all( + Array.from(plugins, async ([key, plugin]) => [key, await resolveFingerprint(plugin)] as const), + ); + const fingerprints = new Map(); + for (const [key, fingerprint] of entries) { + if (fingerprint !== undefined) { + fingerprints.set(key, fingerprint); + } + } + return fingerprints; +}; + /** Converts a formatter outcome into the shared per-file result. */ const runFmtFile = async ( file: FmtFileRequest, @@ -184,10 +222,14 @@ const runFmtFiles = async ({ const shouldWrite = mode === 'write'; let runCache: RunCache | undefined; if (files.length > 0 && cache) { + const [store, fingerprints] = await Promise.all([ + loadFmtCacheStore(cache.filePath, cacheNamespace), + loadPluginFingerprints(files), + ]); runCache = { - store: await loadFmtCacheStore(cache.filePath, cacheNamespace), + store, resolveKey: createCacheKeyResolver(cache.rootPath), - hashOptions: createOptionsHasher(), + hashOptions: createOptionsHasher(fingerprints), }; } diff --git a/packages/rstack/tests/fmt/cacheIdentity.test.ts b/packages/rstack/tests/fmt/cacheIdentity.test.ts index b1b13f6d..e0d48789 100644 --- a/packages/rstack/tests/fmt/cacheIdentity.test.ts +++ b/packages/rstack/tests/fmt/cacheIdentity.test.ts @@ -47,6 +47,16 @@ test('invalidates hashes when final formatter options change', () => { expect(new Set(hashes).size).toBe(hashes.length); }); +test('includes plugin fingerprints in option hashes', () => { + const plugin = pathToFileURL(path.resolve('plugin.mjs')).href; + const first = createOptionsHasher(new Map([[plugin, 'plugin@1']])); + const second = createOptionsHasher(new Map([[plugin, 'plugin@2']])); + + expect(first({ plugins: [plugin] })).toHaveLength(64); + expect(first({ plugins: [new URL(plugin)] })).toBe(first({ plugins: [plugin] })); + expect(first({ plugins: [plugin] })).not.toBe(second({ plugins: [plugin] })); +}); + test('bypasses user plugins and unserializable options', () => { const hashOptions = createOptionsHasher(); const cyclic: Record = {}; diff --git a/packages/rstack/tests/fmt/runnerCache.test.ts b/packages/rstack/tests/fmt/runnerCache.test.ts index 781c35e1..05c9aee3 100644 --- a/packages/rstack/tests/fmt/runnerCache.test.ts +++ b/packages/rstack/tests/fmt/runnerCache.test.ts @@ -1,5 +1,6 @@ import { readFileSync, statSync, utimesSync, writeFileSync } from 'node:fs'; import path from 'node:path'; +import { pathToFileURL } from 'node:url'; import { expect, test } from 'rstack/test'; import { cacheNamespace, createOptionsHasher, sha256 } from '../../src/fmt/cacheIdentity.ts'; import { loadFmtCacheStore } from '../../src/fmt/cacheStore.ts'; @@ -10,7 +11,7 @@ import type { FmtMode, ResolvedFmtOptions, } from '../../src/fmt/types.ts'; -import { withTempProject } from './helpers.ts'; +import { withTempProject, writeProjectFile } from './helpers.ts'; const createRequest = ( filePath: string, @@ -120,6 +121,54 @@ test('invalidates entries when final options change', async () => { }); }); +test('caches only plugins with stable fingerprints', async () => { + await withTempProject(async (rootPath) => { + const filePath = writeProjectFile(rootPath, 'data.fixture', '{"value":true}'); + const pluginEntry = writeProjectFile( + rootPath, + 'node_modules/prettier-plugin-fixture/index.mjs', + `export default { + languages: [{ name: 'Fixture JSON', parsers: ['json'], extensions: ['.fixture'] }], +}; +`, + ); + const packageJsonPath = 'node_modules/prettier-plugin-fixture/package.json'; + const writePackageJson = (version?: string) => + writeProjectFile( + rootPath, + packageJsonPath, + JSON.stringify({ + name: 'prettier-plugin-fixture', + exports: './index.mjs', + ...(version ? { version } : {}), + }), + ); + const cache = createCache(rootPath); + const file = createRequest(filePath, { plugins: [pathToFileURL(pluginEntry).href] }); + + writePackageJson(); + await run([file], 'check', cache); + expect((await loadFmtCacheStore(cache.filePath, cacheNamespace)).get('data.fixture')).toBe( + undefined, + ); + + writePackageJson('1.0.0'); + await run([file], 'check', cache); + const firstHash = (await loadFmtCacheStore(cache.filePath, cacheNamespace)).get( + 'data.fixture', + )?.[1]; + expect(firstHash).toHaveLength(64); + + writePackageJson('2.0.0'); + await run([file], 'check', cache); + const secondHash = (await loadFmtCacheStore(cache.filePath, cacheNamespace)).get( + 'data.fixture', + )?.[1]; + expect(secondHash).toHaveLength(64); + expect(secondHash).not.toBe(firstHash); + }); +}); + test('preserves entries outside the formatted subset', async () => { await withTempProject(async (rootPath) => { const firstPath = path.join(rootPath, 'first.ts'); diff --git a/website/docs/en/guide/formatting.mdx b/website/docs/en/guide/formatting.mdx index 2710f6af..c8519104 100644 --- a/website/docs/en/guide/formatting.mdx +++ b/website/docs/en/guide/formatting.mdx @@ -176,7 +176,7 @@ define.fmt({ ## Cache -`rs fmt` uses a persistent cache by default for file-based `--write`, `--check`, and `--list-different` runs. Cache entries use file content and final formatting options, so changing either causes the file to be formatted again. Files that use custom Prettier plugins currently bypass the cache. +`rs fmt` uses a persistent cache by default for file-based `--write`, `--check`, and `--list-different` runs. Cache entries use file content and final formatting options, so changing either causes the file to be formatted again. Installed Prettier plugins are identified by their package name, version, and entry point; local, linked, or unversioned plugins bypass the cache. 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. diff --git a/website/docs/zh/guide/formatting.mdx b/website/docs/zh/guide/formatting.mdx index 61d96b34..cd33ca50 100644 --- a/website/docs/zh/guide/formatting.mdx +++ b/website/docs/zh/guide/formatting.mdx @@ -176,7 +176,7 @@ define.fmt({ ## 缓存 \{#cache} -`rs fmt` 默认会在基于文件的 `--write`、`--check` 和 `--list-different` 调用中使用持久化缓存。缓存条目基于文件内容和最终格式化选项;任意一项发生变化时,文件都会重新格式化。使用自定义 Prettier 插件的文件目前会绕过缓存。 +`rs fmt` 默认会在基于文件的 `--write`、`--check` 和 `--list-different` 调用中使用持久化缓存。缓存条目基于文件内容和最终格式化选项;任意一项发生变化时,文件都会重新格式化。已安装的 Prettier 插件通过包名、版本和入口进行识别;本地插件、链接插件或缺少版本信息的插件会绕过缓存。 默认缓存目录位于 Rstack 配置根目录下的 `.rstack/cache/fmt`。从子目录运行命令时,仍会使用解析到的 `rstack.config.*` 文件旁的缓存。stdin 格式化不会使用该缓存。