Skip to content

Commit a8b84fe

Browse files
authored
fix: handle parser re-entry from chaining plugins (#262)
Some Prettier plugins, such as `prettier-plugin-tailwindcss`, compose parsers by resolving the active parser chain again during preprocess or parse. When that chain points back at this plugin, the old module-level recursion guard could either recurse indefinitely or make unrelated concurrent format calls skip JSDoc processing. Track active preprocess and parse calls per Prettier options object instead. Recursive entries for the same format call fall back to the raw Prettier parser, while separate format calls continue to run through the JSDoc parser normally. Add a fake chaining plugin fixture to cover both plugin orders and concurrent format calls. Closes: #254
1 parent 7699758 commit a8b84fe

6 files changed

Lines changed: 218 additions & 22 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { parsers as typescriptParsers } from "prettier/plugins/typescript";
2+
3+
// Mimics how prettier-plugin-tailwindcss composes: on every preprocess/parse
4+
// call it re-resolves the underlying parser by walking `options.plugins` and
5+
// `Object.assign`-ing each plugin's parser for this name on top of its own. The
6+
// resulting object's preprocess/parse therefore points at whichever plugin sits
7+
// later in the chain. The important detail is that this lookup can re-enter
8+
// another plugin's parser during the same format call.
9+
const PLUGIN_NAME = "prettier-plugin-fake-chaining";
10+
const baseParser = typescriptParsers.typescript;
11+
12+
function waitForNextTask() {
13+
return new Promise((resolve) => {
14+
setTimeout(resolve, 0);
15+
});
16+
}
17+
18+
function resolveUnderlyingParser(parserName, options) {
19+
const merged = { ...baseParser };
20+
21+
if (!options || !options.plugins) {
22+
return merged;
23+
}
24+
25+
for (const plugin of options.plugins) {
26+
if (
27+
typeof plugin !== "object" ||
28+
plugin === null ||
29+
plugin.name === PLUGIN_NAME ||
30+
!plugin.parsers ||
31+
!(parserName in plugin.parsers)
32+
) {
33+
continue;
34+
}
35+
36+
Object.assign(merged, plugin.parsers[parserName]);
37+
}
38+
39+
return merged;
40+
}
41+
42+
export const name = PLUGIN_NAME;
43+
44+
export const parsers = {
45+
typescript: {
46+
...baseParser,
47+
48+
preprocess(text, options) {
49+
const underlying = resolveUnderlyingParser("typescript", options);
50+
51+
return underlying.preprocess
52+
? underlying.preprocess(text, options)
53+
: text;
54+
},
55+
56+
async parse(text, options) {
57+
await waitForNextTask();
58+
59+
const underlying = resolveUnderlyingParser("typescript", options);
60+
61+
return underlying.parse(text, options);
62+
},
63+
},
64+
};
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"name": "prettier-plugin-fake-chaining",
3+
"version": "1.0.0",
4+
"private": true,
5+
"type": "module",
6+
"files": [
7+
"index.js"
8+
]
9+
}

src/index.ts

Lines changed: 50 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -228,53 +228,82 @@ const defaultOptions: JsdocOptions = {
228228
jsdocBracketSpacing: options.jsdocBracketSpacing.default,
229229
};
230230

231+
const parserCache = new Map<string, prettier.Parser>();
232+
233+
function getMergedParser(
234+
originalParser: prettier.Parser,
235+
parserName: string,
236+
): prettier.Parser {
237+
let cached = parserCache.get(parserName);
238+
if (!cached) {
239+
cached = mergeParsers(originalParser, parserName);
240+
parserCache.set(parserName, cached);
241+
}
242+
243+
return cached;
244+
}
245+
231246
const parsers = {
232247
// JS - Babel
233248
get babel() {
234-
const parser = parserBabel.parsers.babel;
235-
return mergeParsers(parser, "babel");
249+
return getMergedParser(parserBabel.parsers.babel, "babel");
236250
},
237251
get "babel-flow"() {
238-
const parser = parserBabel.parsers["babel-flow"];
239-
return mergeParsers(parser, "babel-flow");
252+
return getMergedParser(parserBabel.parsers["babel-flow"], "babel-flow");
240253
},
241254
get "babel-ts"() {
242-
const parser = parserBabel.parsers["babel-ts"];
243-
return mergeParsers(parser, "babel-ts");
255+
return getMergedParser(parserBabel.parsers["babel-ts"], "babel-ts");
244256
},
245257
// JS - Flow
246258
get flow() {
247-
const parser = parserFlow.parsers.flow;
248-
return mergeParsers(parser, "flow");
259+
return getMergedParser(parserFlow.parsers.flow, "flow");
249260
},
250261
// JS - TypeScript
251262
get typescript(): prettier.Parser {
252-
const parser = parserTypescript.parsers.typescript;
253-
254-
return mergeParsers(parser, "typescript");
255-
// require("./parser-typescript").parsers.typescript;
263+
return getMergedParser(parserTypescript.parsers.typescript, "typescript");
256264
},
257265
get "jsdoc-parser"() {
258266
// Backward compatible, don't use this in new version since 1.0.0
259-
const parser = parserBabel.parsers["babel-ts"];
260-
261-
return mergeParsers(parser, "babel-ts");
267+
return getMergedParser(parserBabel.parsers["babel-ts"], "babel-ts");
262268
},
263269
};
264270

265271
function mergeParsers(originalParser: prettier.Parser, parserName: string) {
266-
const jsDocParse = getParser(originalParser.parse, parserName) as any;
267-
let hasPreprocessed = false;
272+
// Chaining plugins can re-resolve parsers and re-enter this parser during the
273+
// same format call. Recursive entries fall back to Prettier's raw parser so
274+
// the chain can unwind, while separate options objects still format normally.
275+
const activePreprocesses = new WeakSet<prettier.ParserOptions>();
276+
const activeParses = new WeakSet<prettier.ParserOptions>();
277+
278+
const innerParse = getParser(originalParser.parse, parserName);
279+
const jsDocParse = async (
280+
text: string,
281+
parsersOrOptions: any,
282+
maybeOptions?: any,
283+
) => {
284+
const options = (maybeOptions ??
285+
parsersOrOptions) as prettier.ParserOptions;
286+
287+
if (activeParses.has(options)) {
288+
return originalParser.parse(text, options);
289+
}
290+
291+
activeParses.add(options);
292+
try {
293+
return await innerParse(text, parsersOrOptions, maybeOptions);
294+
} finally {
295+
activeParses.delete(options);
296+
}
297+
};
268298

269299
const jsDocPreprocess = (text: string, options: prettier.ParserOptions) => {
270300
normalizeOptions(options as any);
271301

272-
// Prevent infinite recursion by checking if we've already preprocessed
273-
if (hasPreprocessed) {
302+
if (activePreprocesses.has(options)) {
274303
return text;
275304
}
276305

277-
hasPreprocessed = true;
306+
activePreprocesses.add(options);
278307
try {
279308
const tsPluginParser = findPluginByParser(parserName, options);
280309

@@ -288,7 +317,7 @@ function mergeParsers(originalParser: prettier.Parser, parserName: string) {
288317
tsPluginParser?.preprocess || originalParser.preprocess;
289318
return preprocess ? preprocess(text, options) : text;
290319
} finally {
291-
hasPreprocessed = false;
320+
activePreprocesses.delete(options);
292321
}
293322
};
294323

src/parser.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,17 @@ const {
3030
description: descriptionTokenizer,
3131
} = tokenizers;
3232

33+
type JsdocParser = (
34+
text: string,
35+
parsersOrOptions: Parameters<Parser["parse"]>[1],
36+
maybeOptions?: AllOptions,
37+
) => ReturnType<Parser["parse"]>;
38+
3339
/** @link https://prettier.io/docs/en/api.html#custom-parser-api} */
34-
export const getParser = (originalParse: Parser["parse"], parserName: string) =>
40+
export const getParser = (
41+
originalParse: Parser["parse"],
42+
parserName: string,
43+
): JsdocParser =>
3544
async function jsdocParser(
3645
text: string,
3746
parsersOrOptions: Parameters<Parser["parse"]>[1],

tests/__snapshots__/compatibleWithPlugins.test.ts.snap

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,3 +181,42 @@ import a from "a";
181181
const testFunction = (text, defaultValue, optionalNumber) => true;
182182
"
183183
`;
184+
185+
exports[`combined with a plugin that chains Should format concurrent calls consistently 1`] = `
186+
[
187+
"/**
188+
* @param {String | Number} text - Some text description
189+
* @param {String} [defaultValue="defaultTest"] TODO. Default is \`"defaultTest"\`
190+
* @returns {Boolean} Description for returns
191+
*/
192+
const testFunction = (text, defaultValue) => true;
193+
",
194+
"/**
195+
* @param {String | Number} text - Some text description
196+
* @param {String} [defaultValue="defaultTest"] TODO. Default is \`"defaultTest"\`
197+
* @returns {Boolean} Description for returns
198+
*/
199+
const testFunction = (text, defaultValue) => true;
200+
",
201+
]
202+
`;
203+
204+
exports[`combined with a plugin that chains Should format without infinite recursion (chaining plugin first) 1`] = `
205+
"/**
206+
* @param {String | Number} text - Some text description
207+
* @param {String} [defaultValue="defaultTest"] TODO. Default is \`"defaultTest"\`
208+
* @returns {Boolean} Description for returns
209+
*/
210+
const testFunction = (text, defaultValue) => true;
211+
"
212+
`;
213+
214+
exports[`combined with a plugin that chains Should format without infinite recursion (chaining plugin last) 1`] = `
215+
"/**
216+
* @param {String | Number} text - Some text description
217+
* @param {String} [defaultValue="defaultTest"] TODO. Default is \`"defaultTest"\`
218+
* @returns {Boolean} Description for returns
219+
*/
220+
const testFunction = (text, defaultValue) => true;
221+
"
222+
`;

tests/compatibleWithPlugins.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,3 +235,49 @@ async function example(name: string): Promise<void> {}
235235
});
236236
});
237237
});
238+
239+
describe("combined with a plugin that chains", () => {
240+
function format(code: string, plugins: string[]) {
241+
return prettier.format(code, {
242+
parser: "typescript",
243+
plugins,
244+
} as AllOptions);
245+
}
246+
247+
const code = `/**
248+
* @param {String|Number} text - some text description
249+
* @param {String} [defaultValue="defaultTest"] TODO
250+
* @returns {Boolean} Description for returns
251+
*/
252+
const testFunction = (text, defaultValue) => true;
253+
`;
254+
255+
test("Should format without infinite recursion (chaining plugin first)", async () => {
256+
const result = await format(code, [
257+
"./prettier-plugin-fake-chaining/index.js",
258+
"prettier-plugin-jsdoc",
259+
]);
260+
expect(result).toMatchSnapshot();
261+
});
262+
263+
test("Should format without infinite recursion (chaining plugin last)", async () => {
264+
const result = await format(code, [
265+
"prettier-plugin-jsdoc",
266+
"./prettier-plugin-fake-chaining/index.js",
267+
]);
268+
expect(result).toMatchSnapshot();
269+
});
270+
271+
test("Should format concurrent calls consistently", async () => {
272+
const plugins = [
273+
"./prettier-plugin-fake-chaining/index.js",
274+
"prettier-plugin-jsdoc",
275+
];
276+
const results = await Promise.all([
277+
format(code, plugins),
278+
format(code, plugins),
279+
]);
280+
281+
expect(results).toMatchSnapshot();
282+
});
283+
});

0 commit comments

Comments
 (0)