diff --git a/packages/rstack/src/fmt/plugins.ts b/packages/rstack/src/fmt/plugins.ts index 17ebcc24..fea4734b 100644 --- a/packages/rstack/src/fmt/plugins.ts +++ b/packages/rstack/src/fmt/plugins.ts @@ -1,11 +1,14 @@ -import { isAbsolute, join, resolve as resolvePath } from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { readFile, realpath } from 'node:fs/promises'; +import { isAbsolute, join, relative, resolve as resolvePath, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { moduleResolve } from 'import-meta-resolve'; import type { Options as PrettierOptions } from 'prettier'; +import { toPosixPath } from './pathHelpers.ts'; import type { FmtPluginSpecifier, ResolvedFmtOptions } from './types.ts'; type FmtPlugin = NonNullable[number]; type FmtPluginResolver = (options: ResolvedFmtOptions) => ResolvedFmtOptions; +type FingerprintResolver = (plugin: FmtPluginSpecifier) => Promise; const resolveModuleUrl = (specifier: string, parentUrl: URL): string => moduleResolve(specifier, parentUrl).href; @@ -13,6 +16,81 @@ const resolveModuleUrl = (specifier: string, parentUrl: URL): string => const isFmtPluginSpecifier = (plugin: FmtPlugin): plugin is FmtPluginSpecifier => typeof plugin === 'string' || plugin instanceof URL; +const getPackageRoot = (entryPath: string): string | undefined => { + const marker = `${sep}node_modules${sep}`; + const index = entryPath.lastIndexOf(marker); + if (index === -1) { + return undefined; + } + const start = index + marker.length; + + let end = entryPath.indexOf(sep, start); + if (end === -1) { + return undefined; + } + if (entryPath[start] === '@') { + end = entryPath.indexOf(sep, end + 1); + if (end === -1) { + return undefined; + } + } + + return entryPath.slice(0, end); +}; + +const fingerprintPlugin = async (pluginUrl: string): Promise => { + try { + const url = new URL(pluginUrl); + if (url.protocol !== 'file:') { + return undefined; + } + + // Resolving symlinks keeps workspace and other local plugins out of the + // package-version fast path when their implementation can change in place. + const entryPath = await realpath(fileURLToPath(url)); + const packageRoot = getPackageRoot(entryPath); + if (packageRoot === undefined) { + return undefined; + } + + const pkg: unknown = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')); + if ( + typeof pkg !== 'object' || + pkg === null || + !('name' in pkg) || + typeof pkg.name !== 'string' || + !('version' in pkg) || + typeof pkg.version !== 'string' + ) { + return undefined; + } + + return JSON.stringify([ + 'package', + pkg.name, + pkg.version, + toPosixPath(relative(packageRoot, entryPath)), + ]); + } catch { + return undefined; + } +}; + +/** Creates a memoized identity resolver for installed package plugins. */ +const createFingerprintResolver = (): FingerprintResolver => { + const cache = new Map>(); + + return (plugin) => { + const pluginUrl = plugin instanceof URL ? plugin.href : plugin; + let fingerprint = cache.get(pluginUrl); + if (fingerprint === undefined) { + fingerprint = fingerprintPlugin(pluginUrl); + cache.set(pluginUrl, fingerprint); + } + return fingerprint; + }; +}; + /** Creates a project-root resolver for plugins in final per-file options. */ const createFmtPluginResolver = (rootPath: string): FmtPluginResolver => { const parentUrl = pathToFileURL(join(rootPath, 'index.js')); @@ -65,5 +143,5 @@ const createFmtPluginResolver = (rootPath: string): FmtPluginResolver => { }; }; -export { createFmtPluginResolver }; -export type { FmtPluginResolver }; +export { createFingerprintResolver, createFmtPluginResolver }; +export type { FingerprintResolver, FmtPluginResolver }; diff --git a/packages/rstack/tests/fmt/plugins.test.ts b/packages/rstack/tests/fmt/plugins.test.ts index cac6da5b..7d2a34f7 100644 --- a/packages/rstack/tests/fmt/plugins.test.ts +++ b/packages/rstack/tests/fmt/plugins.test.ts @@ -1,6 +1,6 @@ import { pathToFileURL } from 'node:url'; import { expect, test } from 'rstack/test'; -import { createFmtPluginResolver } from '../../src/fmt/plugins.ts'; +import { createFingerprintResolver, createFmtPluginResolver } from '../../src/fmt/plugins.ts'; import { withTempProject, writeProjectFile } from './helpers.ts'; test('resolves plugin specifiers from the config root', async () => { @@ -60,3 +60,57 @@ test('rejects imported plugin objects', () => { 'Prettier plugin objects are not supported. Use a package name, path, or URL instead.', ); }); + +test('fingerprints installed package plugins once', async () => { + await withTempProject(async (rootPath) => { + const entry = writeProjectFile(rootPath, 'node_modules/prettier-plugin-fixture/dist/index.mjs'); + const packageJsonPath = 'node_modules/prettier-plugin-fixture/package.json'; + writeProjectFile( + rootPath, + packageJsonPath, + JSON.stringify({ name: 'prettier-plugin-fixture', version: '1.2.3' }), + ); + + const resolveFingerprint = createFingerprintResolver(); + const pluginUrl = pathToFileURL(entry); + const fingerprint = JSON.stringify([ + 'package', + 'prettier-plugin-fixture', + '1.2.3', + 'dist/index.mjs', + ]); + + await expect(resolveFingerprint(pluginUrl)).resolves.toBe(fingerprint); + + writeProjectFile( + rootPath, + packageJsonPath, + JSON.stringify({ name: 'prettier-plugin-fixture', version: '2.0.0' }), + ); + await expect(resolveFingerprint(pluginUrl)).resolves.toBe(fingerprint); + }); +}); + +test('skips plugins without stable package metadata', async () => { + await withTempProject(async (rootPath) => { + const localPlugin = writeProjectFile(rootPath, 'plugins/local.mjs'); + const unversionedPlugin = writeProjectFile( + rootPath, + 'node_modules/prettier-plugin-fixture/index.mjs', + ); + writeProjectFile( + rootPath, + 'node_modules/prettier-plugin-fixture/package.json', + JSON.stringify({ name: 'prettier-plugin-fixture' }), + ); + + const resolveFingerprint = createFingerprintResolver(); + await expect( + Promise.all([ + resolveFingerprint(pathToFileURL(localPlugin)), + resolveFingerprint(pathToFileURL(unversionedPlugin)), + resolveFingerprint('data:text/javascript,export default {}'), + ]), + ).resolves.toEqual([undefined, undefined, undefined]); + }); +});