From 9043f724233f499e9536da676d3eded7b1d8ca57 Mon Sep 17 00:00:00 2001 From: Iain Lane Date: Thu, 7 May 2026 22:25:28 +0100 Subject: [PATCH] fix: handle parser re-entry from chaining plugins 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 --- prettier-plugin-fake-chaining/index.js | 64 +++++++++++++++++ prettier-plugin-fake-chaining/package.json | 9 +++ src/index.ts | 71 +++++++++++++------ src/parser.ts | 11 ++- .../compatibleWithPlugins.test.ts.snap | 39 ++++++++++ tests/compatibleWithPlugins.test.ts | 46 ++++++++++++ 6 files changed, 218 insertions(+), 22 deletions(-) create mode 100644 prettier-plugin-fake-chaining/index.js create mode 100644 prettier-plugin-fake-chaining/package.json diff --git a/prettier-plugin-fake-chaining/index.js b/prettier-plugin-fake-chaining/index.js new file mode 100644 index 0000000..df867c3 --- /dev/null +++ b/prettier-plugin-fake-chaining/index.js @@ -0,0 +1,64 @@ +import { parsers as typescriptParsers } from "prettier/plugins/typescript"; + +// Mimics how prettier-plugin-tailwindcss composes: on every preprocess/parse +// call it re-resolves the underlying parser by walking `options.plugins` and +// `Object.assign`-ing each plugin's parser for this name on top of its own. The +// resulting object's preprocess/parse therefore points at whichever plugin sits +// later in the chain. The important detail is that this lookup can re-enter +// another plugin's parser during the same format call. +const PLUGIN_NAME = "prettier-plugin-fake-chaining"; +const baseParser = typescriptParsers.typescript; + +function waitForNextTask() { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +function resolveUnderlyingParser(parserName, options) { + const merged = { ...baseParser }; + + if (!options || !options.plugins) { + return merged; + } + + for (const plugin of options.plugins) { + if ( + typeof plugin !== "object" || + plugin === null || + plugin.name === PLUGIN_NAME || + !plugin.parsers || + !(parserName in plugin.parsers) + ) { + continue; + } + + Object.assign(merged, plugin.parsers[parserName]); + } + + return merged; +} + +export const name = PLUGIN_NAME; + +export const parsers = { + typescript: { + ...baseParser, + + preprocess(text, options) { + const underlying = resolveUnderlyingParser("typescript", options); + + return underlying.preprocess + ? underlying.preprocess(text, options) + : text; + }, + + async parse(text, options) { + await waitForNextTask(); + + const underlying = resolveUnderlyingParser("typescript", options); + + return underlying.parse(text, options); + }, + }, +}; diff --git a/prettier-plugin-fake-chaining/package.json b/prettier-plugin-fake-chaining/package.json new file mode 100644 index 0000000..a1429c1 --- /dev/null +++ b/prettier-plugin-fake-chaining/package.json @@ -0,0 +1,9 @@ +{ + "name": "prettier-plugin-fake-chaining", + "version": "1.0.0", + "private": true, + "type": "module", + "files": [ + "index.js" + ] +} diff --git a/src/index.ts b/src/index.ts index f13d699..77bae12 100755 --- a/src/index.ts +++ b/src/index.ts @@ -228,53 +228,82 @@ const defaultOptions: JsdocOptions = { jsdocBracketSpacing: options.jsdocBracketSpacing.default, }; +const parserCache = new Map(); + +function getMergedParser( + originalParser: prettier.Parser, + parserName: string, +): prettier.Parser { + let cached = parserCache.get(parserName); + if (!cached) { + cached = mergeParsers(originalParser, parserName); + parserCache.set(parserName, cached); + } + + return cached; +} + const parsers = { // JS - Babel get babel() { - const parser = parserBabel.parsers.babel; - return mergeParsers(parser, "babel"); + return getMergedParser(parserBabel.parsers.babel, "babel"); }, get "babel-flow"() { - const parser = parserBabel.parsers["babel-flow"]; - return mergeParsers(parser, "babel-flow"); + return getMergedParser(parserBabel.parsers["babel-flow"], "babel-flow"); }, get "babel-ts"() { - const parser = parserBabel.parsers["babel-ts"]; - return mergeParsers(parser, "babel-ts"); + return getMergedParser(parserBabel.parsers["babel-ts"], "babel-ts"); }, // JS - Flow get flow() { - const parser = parserFlow.parsers.flow; - return mergeParsers(parser, "flow"); + return getMergedParser(parserFlow.parsers.flow, "flow"); }, // JS - TypeScript get typescript(): prettier.Parser { - const parser = parserTypescript.parsers.typescript; - - return mergeParsers(parser, "typescript"); - // require("./parser-typescript").parsers.typescript; + return getMergedParser(parserTypescript.parsers.typescript, "typescript"); }, get "jsdoc-parser"() { // Backward compatible, don't use this in new version since 1.0.0 - const parser = parserBabel.parsers["babel-ts"]; - - return mergeParsers(parser, "babel-ts"); + return getMergedParser(parserBabel.parsers["babel-ts"], "babel-ts"); }, }; function mergeParsers(originalParser: prettier.Parser, parserName: string) { - const jsDocParse = getParser(originalParser.parse, parserName) as any; - let hasPreprocessed = false; + // Chaining plugins can re-resolve parsers and re-enter this parser during the + // same format call. Recursive entries fall back to Prettier's raw parser so + // the chain can unwind, while separate options objects still format normally. + const activePreprocesses = new WeakSet(); + const activeParses = new WeakSet(); + + const innerParse = getParser(originalParser.parse, parserName); + const jsDocParse = async ( + text: string, + parsersOrOptions: any, + maybeOptions?: any, + ) => { + const options = (maybeOptions ?? + parsersOrOptions) as prettier.ParserOptions; + + if (activeParses.has(options)) { + return originalParser.parse(text, options); + } + + activeParses.add(options); + try { + return await innerParse(text, parsersOrOptions, maybeOptions); + } finally { + activeParses.delete(options); + } + }; const jsDocPreprocess = (text: string, options: prettier.ParserOptions) => { normalizeOptions(options as any); - // Prevent infinite recursion by checking if we've already preprocessed - if (hasPreprocessed) { + if (activePreprocesses.has(options)) { return text; } - hasPreprocessed = true; + activePreprocesses.add(options); try { const tsPluginParser = findPluginByParser(parserName, options); @@ -288,7 +317,7 @@ function mergeParsers(originalParser: prettier.Parser, parserName: string) { tsPluginParser?.preprocess || originalParser.preprocess; return preprocess ? preprocess(text, options) : text; } finally { - hasPreprocessed = false; + activePreprocesses.delete(options); } }; diff --git a/src/parser.ts b/src/parser.ts index cf4e40d..0fb5295 100755 --- a/src/parser.ts +++ b/src/parser.ts @@ -30,8 +30,17 @@ const { description: descriptionTokenizer, } = tokenizers; +type JsdocParser = ( + text: string, + parsersOrOptions: Parameters[1], + maybeOptions?: AllOptions, +) => ReturnType; + /** @link https://prettier.io/docs/en/api.html#custom-parser-api} */ -export const getParser = (originalParse: Parser["parse"], parserName: string) => +export const getParser = ( + originalParse: Parser["parse"], + parserName: string, +): JsdocParser => async function jsdocParser( text: string, parsersOrOptions: Parameters[1], diff --git a/tests/__snapshots__/compatibleWithPlugins.test.ts.snap b/tests/__snapshots__/compatibleWithPlugins.test.ts.snap index 1947ad8..ca385c8 100644 --- a/tests/__snapshots__/compatibleWithPlugins.test.ts.snap +++ b/tests/__snapshots__/compatibleWithPlugins.test.ts.snap @@ -181,3 +181,42 @@ import a from "a"; const testFunction = (text, defaultValue, optionalNumber) => true; " `; + +exports[`combined with a plugin that chains Should format concurrent calls consistently 1`] = ` +[ + "/** + * @param {String | Number} text - Some text description + * @param {String} [defaultValue="defaultTest"] TODO. Default is \`"defaultTest"\` + * @returns {Boolean} Description for returns + */ +const testFunction = (text, defaultValue) => true; +", + "/** + * @param {String | Number} text - Some text description + * @param {String} [defaultValue="defaultTest"] TODO. Default is \`"defaultTest"\` + * @returns {Boolean} Description for returns + */ +const testFunction = (text, defaultValue) => true; +", +] +`; + +exports[`combined with a plugin that chains Should format without infinite recursion (chaining plugin first) 1`] = ` +"/** + * @param {String | Number} text - Some text description + * @param {String} [defaultValue="defaultTest"] TODO. Default is \`"defaultTest"\` + * @returns {Boolean} Description for returns + */ +const testFunction = (text, defaultValue) => true; +" +`; + +exports[`combined with a plugin that chains Should format without infinite recursion (chaining plugin last) 1`] = ` +"/** + * @param {String | Number} text - Some text description + * @param {String} [defaultValue="defaultTest"] TODO. Default is \`"defaultTest"\` + * @returns {Boolean} Description for returns + */ +const testFunction = (text, defaultValue) => true; +" +`; diff --git a/tests/compatibleWithPlugins.test.ts b/tests/compatibleWithPlugins.test.ts index 074b020..f96bf54 100755 --- a/tests/compatibleWithPlugins.test.ts +++ b/tests/compatibleWithPlugins.test.ts @@ -235,3 +235,49 @@ async function example(name: string): Promise {} }); }); }); + +describe("combined with a plugin that chains", () => { + function format(code: string, plugins: string[]) { + return prettier.format(code, { + parser: "typescript", + plugins, + } as AllOptions); + } + + const code = `/** +* @param {String|Number} text - some text description +* @param {String} [defaultValue="defaultTest"] TODO +* @returns {Boolean} Description for returns +*/ +const testFunction = (text, defaultValue) => true; +`; + + test("Should format without infinite recursion (chaining plugin first)", async () => { + const result = await format(code, [ + "./prettier-plugin-fake-chaining/index.js", + "prettier-plugin-jsdoc", + ]); + expect(result).toMatchSnapshot(); + }); + + test("Should format without infinite recursion (chaining plugin last)", async () => { + const result = await format(code, [ + "prettier-plugin-jsdoc", + "./prettier-plugin-fake-chaining/index.js", + ]); + expect(result).toMatchSnapshot(); + }); + + test("Should format concurrent calls consistently", async () => { + const plugins = [ + "./prettier-plugin-fake-chaining/index.js", + "prettier-plugin-jsdoc", + ]; + const results = await Promise.all([ + format(code, plugins), + format(code, plugins), + ]); + + expect(results).toMatchSnapshot(); + }); +});