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(); + }); +});