Skip to content

Commit 3c652be

Browse files
authored
feat(fmt): add plugin fingerprint resolver (#231)
1 parent 78f6d7b commit 3c652be

2 files changed

Lines changed: 137 additions & 5 deletions

File tree

‎packages/rstack/src/fmt/plugins.ts‎

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,96 @@
1-
import { isAbsolute, join, resolve as resolvePath } from 'node:path';
2-
import { pathToFileURL } from 'node:url';
1+
import { readFile, realpath } from 'node:fs/promises';
2+
import { isAbsolute, join, relative, resolve as resolvePath, sep } from 'node:path';
3+
import { fileURLToPath, pathToFileURL } from 'node:url';
34
import { moduleResolve } from 'import-meta-resolve';
45
import type { Options as PrettierOptions } from 'prettier';
6+
import { toPosixPath } from './pathHelpers.ts';
57
import type { FmtPluginSpecifier, ResolvedFmtOptions } from './types.ts';
68

79
type FmtPlugin = NonNullable<PrettierOptions['plugins']>[number];
810
type FmtPluginResolver = (options: ResolvedFmtOptions) => ResolvedFmtOptions;
11+
type FingerprintResolver = (plugin: FmtPluginSpecifier) => Promise<string | undefined>;
912

1013
const resolveModuleUrl = (specifier: string, parentUrl: URL): string =>
1114
moduleResolve(specifier, parentUrl).href;
1215

1316
const isFmtPluginSpecifier = (plugin: FmtPlugin): plugin is FmtPluginSpecifier =>
1417
typeof plugin === 'string' || plugin instanceof URL;
1518

19+
const getPackageRoot = (entryPath: string): string | undefined => {
20+
const marker = `${sep}node_modules${sep}`;
21+
const index = entryPath.lastIndexOf(marker);
22+
if (index === -1) {
23+
return undefined;
24+
}
25+
const start = index + marker.length;
26+
27+
let end = entryPath.indexOf(sep, start);
28+
if (end === -1) {
29+
return undefined;
30+
}
31+
if (entryPath[start] === '@') {
32+
end = entryPath.indexOf(sep, end + 1);
33+
if (end === -1) {
34+
return undefined;
35+
}
36+
}
37+
38+
return entryPath.slice(0, end);
39+
};
40+
41+
const fingerprintPlugin = async (pluginUrl: string): Promise<string | undefined> => {
42+
try {
43+
const url = new URL(pluginUrl);
44+
if (url.protocol !== 'file:') {
45+
return undefined;
46+
}
47+
48+
// Resolving symlinks keeps workspace and other local plugins out of the
49+
// package-version fast path when their implementation can change in place.
50+
const entryPath = await realpath(fileURLToPath(url));
51+
const packageRoot = getPackageRoot(entryPath);
52+
if (packageRoot === undefined) {
53+
return undefined;
54+
}
55+
56+
const pkg: unknown = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8'));
57+
if (
58+
typeof pkg !== 'object' ||
59+
pkg === null ||
60+
!('name' in pkg) ||
61+
typeof pkg.name !== 'string' ||
62+
!('version' in pkg) ||
63+
typeof pkg.version !== 'string'
64+
) {
65+
return undefined;
66+
}
67+
68+
return JSON.stringify([
69+
'package',
70+
pkg.name,
71+
pkg.version,
72+
toPosixPath(relative(packageRoot, entryPath)),
73+
]);
74+
} catch {
75+
return undefined;
76+
}
77+
};
78+
79+
/** Creates a memoized identity resolver for installed package plugins. */
80+
const createFingerprintResolver = (): FingerprintResolver => {
81+
const cache = new Map<string, Promise<string | undefined>>();
82+
83+
return (plugin) => {
84+
const pluginUrl = plugin instanceof URL ? plugin.href : plugin;
85+
let fingerprint = cache.get(pluginUrl);
86+
if (fingerprint === undefined) {
87+
fingerprint = fingerprintPlugin(pluginUrl);
88+
cache.set(pluginUrl, fingerprint);
89+
}
90+
return fingerprint;
91+
};
92+
};
93+
1694
/** Creates a project-root resolver for plugins in final per-file options. */
1795
const createFmtPluginResolver = (rootPath: string): FmtPluginResolver => {
1896
const parentUrl = pathToFileURL(join(rootPath, 'index.js'));
@@ -65,5 +143,5 @@ const createFmtPluginResolver = (rootPath: string): FmtPluginResolver => {
65143
};
66144
};
67145

68-
export { createFmtPluginResolver };
69-
export type { FmtPluginResolver };
146+
export { createFingerprintResolver, createFmtPluginResolver };
147+
export type { FingerprintResolver, FmtPluginResolver };

‎packages/rstack/tests/fmt/plugins.test.ts‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { pathToFileURL } from 'node:url';
22
import { expect, test } from 'rstack/test';
3-
import { createFmtPluginResolver } from '../../src/fmt/plugins.ts';
3+
import { createFingerprintResolver, createFmtPluginResolver } from '../../src/fmt/plugins.ts';
44
import { withTempProject, writeProjectFile } from './helpers.ts';
55

66
test('resolves plugin specifiers from the config root', async () => {
@@ -60,3 +60,57 @@ test('rejects imported plugin objects', () => {
6060
'Prettier plugin objects are not supported. Use a package name, path, or URL instead.',
6161
);
6262
});
63+
64+
test('fingerprints installed package plugins once', async () => {
65+
await withTempProject(async (rootPath) => {
66+
const entry = writeProjectFile(rootPath, 'node_modules/prettier-plugin-fixture/dist/index.mjs');
67+
const packageJsonPath = 'node_modules/prettier-plugin-fixture/package.json';
68+
writeProjectFile(
69+
rootPath,
70+
packageJsonPath,
71+
JSON.stringify({ name: 'prettier-plugin-fixture', version: '1.2.3' }),
72+
);
73+
74+
const resolveFingerprint = createFingerprintResolver();
75+
const pluginUrl = pathToFileURL(entry);
76+
const fingerprint = JSON.stringify([
77+
'package',
78+
'prettier-plugin-fixture',
79+
'1.2.3',
80+
'dist/index.mjs',
81+
]);
82+
83+
await expect(resolveFingerprint(pluginUrl)).resolves.toBe(fingerprint);
84+
85+
writeProjectFile(
86+
rootPath,
87+
packageJsonPath,
88+
JSON.stringify({ name: 'prettier-plugin-fixture', version: '2.0.0' }),
89+
);
90+
await expect(resolveFingerprint(pluginUrl)).resolves.toBe(fingerprint);
91+
});
92+
});
93+
94+
test('skips plugins without stable package metadata', async () => {
95+
await withTempProject(async (rootPath) => {
96+
const localPlugin = writeProjectFile(rootPath, 'plugins/local.mjs');
97+
const unversionedPlugin = writeProjectFile(
98+
rootPath,
99+
'node_modules/prettier-plugin-fixture/index.mjs',
100+
);
101+
writeProjectFile(
102+
rootPath,
103+
'node_modules/prettier-plugin-fixture/package.json',
104+
JSON.stringify({ name: 'prettier-plugin-fixture' }),
105+
);
106+
107+
const resolveFingerprint = createFingerprintResolver();
108+
await expect(
109+
Promise.all([
110+
resolveFingerprint(pathToFileURL(localPlugin)),
111+
resolveFingerprint(pathToFileURL(unversionedPlugin)),
112+
resolveFingerprint('data:text/javascript,export default {}'),
113+
]),
114+
).resolves.toEqual([undefined, undefined, undefined]);
115+
});
116+
});

0 commit comments

Comments
 (0)