From c73d27614d26c96af2f55daa678cae6fc3750c30 Mon Sep 17 00:00:00 2001 From: Brendon Smith Date: Tue, 17 Mar 2026 19:57:19 -0400 Subject: [PATCH] Add `prettier.parser` setting The Prettier CLI has a `--parser` option for manually specifying the parser if needed, but the Prettier VSCode extension does not have a corresponding setting for specifying a parser in the VSCode `settings.json`. It also is not able to use custom language modes that have been set in VSCode (i.e., if a YAML file has been set as a `helm-template`). A parser setting would also be helpful for "untrusted" VSCode workspaces (workspaces running in "Restricted Mode"). Starting with version 12.4.0, untrusted workspaces now no longer load Prettier configuration files. However, they can still load `settings.json`, so Prettier settings can be added to `settings.json` for those workspaces. This PR will add a `prettier.parser` setting. The setting can be used in conjunction with language-specific settings to override the parser for specific VSCode languages. Alternatively, setting `prettier.parser: "vscode"` will derive the parser from the active VSCode language mode. Tests will be updated for the new setting and behavior. There are some small updates to config file parsing needed to properly respect the `.do-not-use-prettier-vscode-root` marker files. Signed-off-by: Brendon Smith --- CHANGELOG.md | 2 + README.md | 11 ++ package.json | 5 + package.nls.json | 2 +- package.nls.zh-cn.json | 2 +- package.nls.zh-tw.json | 2 +- src/ModuleResolverNode.ts | 109 ++++++++++++++++-- src/PrettierEditService.ts | 97 +++++++++++++--- src/test/suite/format.test.ts | 61 +++++++++- src/test/suite/formatTestUtils.ts | 43 +++++++ .../project/formatTest/markdown-in-txt.txt | 4 + .../project/formatTest/override-parser.jsonc | 5 + 12 files changed, 313 insertions(+), 30 deletions(-) create mode 100644 test-fixtures/project/formatTest/markdown-in-txt.txt create mode 100644 test-fixtures/project/formatTest/override-parser.jsonc diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d72e8bdd..908394108 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to the "prettier-vscode" extension will be documented in thi ## [Unreleased] +- Added [`parser`](https://prettier.io/docs/options.html#parser) setting. The setting can be used in conjunction with language-specific settings to override the parser for specific VSCode languages. Alternatively, setting `prettier.parser: "vscode"` will derive the parser from the active VSCode language mode. + ## [12.4.0] - **Security**: Fixed config resolution in untrusted workspaces to prevent JavaScript config files (`.prettierrc.js`, `prettier.config.js`, etc.) from being executed. Previously, even when workspace trust was enforced for module resolution, Prettier's config resolution could still `require()`/`import()` JS config files, allowing arbitrary code execution. Reported by Hector Ruiz Ruiz. diff --git a/README.md b/README.md index 55a18b82c..e2d4f05b2 100644 --- a/README.md +++ b/README.md @@ -280,6 +280,7 @@ prettier.bracketSameLine prettier.jsxBracketSameLine prettier.jsxSingleQuote prettier.printWidth +prettier.parser prettier.proseWrap prettier.quoteProps prettier.requirePragma @@ -320,6 +321,16 @@ To configure language-specific settings, use the `[language]` syntax in your VS This feature is particularly useful when working in multi-language projects or when different languages have different formatting conventions. Language-specific settings will override the global Prettier settings when formatting files of that language type. +You can also set `prettier.parser` in language-specific overrides to force a parser, or use `prettier.parser: "vscode"` to derive the parser from the active VS Code language mode. For example: + +```json +{ + "[git-commit]": { + "prettier.parser": "markdown" + } +} +``` + ### Extension Settings These settings are specific to VS Code and need to be set in the VS Code settings file. See the [documentation](https://code.visualstudio.com/docs/getstarted/settings) for how to do that. diff --git a/package.json b/package.json index 681b45dda..a7ab51a33 100644 --- a/package.json +++ b/package.json @@ -314,6 +314,11 @@ "markdownDescription": "%ext.config.proseWrap%", "scope": "language-overridable" }, + "prettier.parser": { + "type": "string", + "markdownDescription": "%ext.config.parser%", + "scope": "language-overridable" + }, "prettier.arrowParens": { "type": "string", "enum": [ diff --git a/package.nls.json b/package.nls.json index 08de4f40b..38ef8fe2c 100644 --- a/package.nls.json +++ b/package.nls.json @@ -18,7 +18,7 @@ "ext.config.jsxSingleQuote": "Use single quotes instead of double quotes in JSX.", "ext.config.packageManager": "The package manager you use to install node modules.", "ext.config.packageManagerDeprecation": "Package manager is now automatically detected by VS Code. This setting is no longer used.", - "ext.config.parser": "Override the parser. You shouldn't have to change this setting.", + "ext.config.parser": "Explicitly specify the Prettier parser to use. Set to `vscode` to derive the parser from the active VS Code language mode.", "ext.config.parserDeprecationMessage": "This setting is no longer supported. Use a prettier configuration file instead.", "ext.config.prettierPath": "Path to the `prettier` module, eg: `./node_modules/prettier`.", "ext.config.printWidth": "Fit code within this line limit.", diff --git a/package.nls.zh-cn.json b/package.nls.zh-cn.json index 6077280c6..53b92bda2 100644 --- a/package.nls.zh-cn.json +++ b/package.nls.zh-cn.json @@ -18,7 +18,7 @@ "ext.config.jsxSingleQuote": "JSX 中使用单引号而不是双引号。", "ext.config.packageManager": "用于安装 node modules 的包管理器。", "ext.config.packageManagerDeprecation": "包管理器现在由 VS Code 自动检测。此设置已不再使用。", - "ext.config.parser": "覆盖解析器。通常不需要更改此设置。", + "ext.config.parser": "显式指定要使用的 Prettier 解析器。设置为 `vscode` 以根据当前 VS Code 语言模式确定解析器。", "ext.config.parserDeprecationMessage": "此设置已不再支持。请改用 Prettier 配置文件。", "ext.config.prettierPath": "`prettier` 包路径,如 `./node_modules/prettier`。", "ext.config.printWidth": "每行代码的长度限制。", diff --git a/package.nls.zh-tw.json b/package.nls.zh-tw.json index 3e3ddaada..df14071b9 100644 --- a/package.nls.zh-tw.json +++ b/package.nls.zh-tw.json @@ -18,7 +18,7 @@ "ext.config.jsxSingleQuote": "在 JSX 中使用單引號而不是雙引號。", "ext.config.packageManager": "你用於安裝 node modules 的套件管理器。", "ext.config.packageManagerDeprecation": "套件管理器目前會經由 VS Code 進行偵測。這個設定已不再使用。", - "ext.config.parser": "覆寫解析器。你不應變更這個設定。", + "ext.config.parser": "明確指定要使用的 Prettier 解析器。設定為 `vscode` 以根據目前 VS Code 語言模式決定解析器。", "ext.config.parserDeprecationMessage": "這個設定已不再支援。請改用 prettier 組態檔。", "ext.config.prettierPath": "`prettier` 模組的路徑,如 `./node_modules/prettier`。", "ext.config.printWidth": "讓程式碼的每一列符合這個寬度限制。", diff --git a/src/ModuleResolverNode.ts b/src/ModuleResolverNode.ts index fd832800d..5f0d12b6e 100644 --- a/src/ModuleResolverNode.ts +++ b/src/ModuleResolverNode.ts @@ -1,4 +1,5 @@ import * as fs from "fs"; +import * as os from "os"; import * as path from "path"; import type * as PrettierTypes from "prettier"; import * as semver from "semver"; @@ -34,6 +35,19 @@ import { import { PrettierDynamicInstance } from "./PrettierDynamicInstance.js"; const minPrettierVersion = "1.13.0"; +let emptyConfigPath: string | undefined; + +async function getEmptyConfigPath(): Promise { + if (emptyConfigPath) { + return emptyConfigPath; + } + const tempDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "prettier-vscode-"), + ); + emptyConfigPath = path.join(tempDir, "empty.prettierrc.json"); + await fs.promises.writeFile(emptyConfigPath, "{}", { encoding: "utf8" }); + return emptyConfigPath; +} export type PrettierNodeModule = typeof PrettierTypes; @@ -415,6 +429,31 @@ export class ModuleResolver implements ModuleResolverInterface { return null; } + const configSearchRoot = await findUp( + async (dir: string) => { + if ( + await pathExists( + path.join(dir, ".do-not-use-prettier-vscode-root"), + ) + ) { + return dir; + } + return undefined; + }, + { cwd: path.dirname(fileName), type: "directory" }, + ); + + const isWithinSearchRoot = (candidatePath: string) => { + if (!configSearchRoot) { + return true; + } + const relative = path.relative(configSearchRoot, candidatePath); + return ( + relative === "" || + (!relative.startsWith("..") && !path.isAbsolute(relative)) + ); + }; + let configPath: string | undefined; try { configPath = @@ -445,6 +484,19 @@ export class ModuleResolver implements ModuleResolverInterface { ? getWorkspaceRelativePath(fileName, vscodeConfig.configPath) : undefined; + let limitConfigSearch = false; + if (!customConfigPath && configSearchRoot) { + if (!configPath) { + limitConfigSearch = true; + } else if (!isWithinSearchRoot(configPath)) { + this.loggingService.logInfo( + `Ignoring config file outside search root: ${configPath}`, + ); + configPath = undefined; + limitConfigSearch = true; + } + } + // Log if a custom config path is specified in VS Code settings if (customConfigPath) { this.loggingService.logInfo( @@ -452,14 +504,55 @@ export class ModuleResolver implements ModuleResolverInterface { ); } - const resolveConfigOptions: PrettierResolveConfigOptions = { - config: customConfigPath ?? configPath, - editorconfig: vscodeConfig.useEditorConfig, - }; - resolvedConfig = await prettierInstance.resolveConfig( - fileName, - resolveConfigOptions, - ); + if (customConfigPath || configPath) { + const resolveConfigOptions: PrettierResolveConfigOptions = { + config: customConfigPath ?? configPath, + editorconfig: vscodeConfig.useEditorConfig, + }; + resolvedConfig = await prettierInstance.resolveConfig( + fileName, + resolveConfigOptions, + ); + } else if (limitConfigSearch && vscodeConfig.useEditorConfig) { + const editorConfigPath = await findUp( + async (dir: string) => { + const editorConfigCandidate = path.join(dir, ".editorconfig"); + if (await pathExists(editorConfigCandidate)) { + return editorConfigCandidate; + } + if ( + await pathExists( + path.join(dir, ".do-not-use-prettier-vscode-root"), + ) + ) { + return FIND_UP_STOP; + } + return undefined; + }, + { cwd: path.dirname(fileName) }, + ); + + if (editorConfigPath) { + const resolveConfigOptions: PrettierResolveConfigOptions = { + config: await getEmptyConfigPath(), + editorconfig: true, + }; + resolvedConfig = await prettierInstance.resolveConfig( + fileName, + resolveConfigOptions, + ); + } else { + resolvedConfig = null; + } + } else { + const resolveConfigOptions: PrettierResolveConfigOptions = { + editorconfig: vscodeConfig.useEditorConfig, + }; + resolvedConfig = await prettierInstance.resolveConfig( + fileName, + resolveConfigOptions, + ); + } } catch (error) { this.loggingService.logError(INVALID_PRETTIER_CONFIG, error); return "error"; diff --git a/src/PrettierEditService.ts b/src/PrettierEditService.ts index c38274ef3..bf3917b9d 100644 --- a/src/PrettierEditService.ts +++ b/src/PrettierEditService.ts @@ -599,24 +599,67 @@ export default class PrettierEditService implements Disposable { return; } + const parserSetting = + typeof vscodeConfig.parser === "string" + ? vscodeConfig.parser.trim() + : undefined; + const parserSettingIsVSCode = + parserSetting?.toLowerCase() === "vscode"; + + let parserSettingWasExplicit = false; let parser: PrettierBuiltInParserName | string | undefined; - if (fileInfo && fileInfo.inferredParser) { - parser = fileInfo.inferredParser; - } else if (resolvedConfig && resolvedConfig.parser) { - // Parser specified in config (e.g., via overrides for custom extensions) - parser = resolvedConfig.parser as string; - } else if (languageId !== "plaintext") { - // Don't attempt VS Code language for plaintext because we never have - // a formatter for plaintext and most likely the reason for this is - // somebody has registered a custom file extension without properly - // configuring the parser in their prettier config. - this.loggingService.logWarning( - `Parser not inferred, trying VS Code language.`, + + let supportInfoLanguages: + | Awaited>["languages"] + | undefined; + const getSupportInfoLanguages = async () => { + if (!supportInfoLanguages) { + const { languages } = await prettierInstance.getSupportInfo({ + plugins: resolvedPlugins, + }); + supportInfoLanguages = languages; + } + return supportInfoLanguages; + }; + + if (parserSetting) { + parserSettingWasExplicit = true; + if (parserSettingIsVSCode) { + if (languageId !== "plaintext") { + const languages = await getSupportInfoLanguages(); + parser = getParserFromLanguageId(languages, uri, languageId); + } else { + this.loggingService.logWarning( + "Parser set to 'vscode' but language is plaintext; no parser available.", + ); + } + } else { + parser = parserSetting; + } + + this.loggingService.logInfo( + "Using parser from VS Code settings:", + parser ?? parserSetting, ); - const { languages } = await prettierInstance.getSupportInfo({ - plugins: resolvedPlugins, - }); - parser = getParserFromLanguageId(languages, uri, languageId); + } + + if (!parser && !parserSettingWasExplicit) { + if (fileInfo && fileInfo.inferredParser) { + parser = fileInfo.inferredParser; + } else if (resolvedConfig && resolvedConfig.parser) { + // Parser specified in config (e.g., via overrides for custom extensions) + parser = resolvedConfig.parser as string; + } else if (languageId !== "plaintext") { + // Don't attempt VS Code language for plaintext because we never have + // a formatter for plaintext and most likely the reason for this is + // somebody has registered a custom file extension without properly + // configuring the parser in their prettier config. + this.loggingService.logWarning( + `Parser not inferred, trying VS Code language.`, + ); + const languages = await getSupportInfoLanguages(); + parser = getParserFromLanguageId(languages, uri, languageId); + } } if (!parser) { @@ -627,13 +670,22 @@ export default class PrettierEditService implements Disposable { return; } + const resolvedConfigForFormatting = + parserSettingWasExplicit && resolvedConfig + ? (() => { + const { parser: _parser, ...rest } = resolvedConfig; + return rest; + })() + : resolvedConfig; + const prettierOptions = this.getPrettierOptions( fileName, parser as PrettierBuiltInParserName, vscodeConfig, - resolvedConfig, + resolvedConfigForFormatting, options, resolvedPlugins, + parserSettingWasExplicit, ); this.loggingService.logInfo("Prettier Options:", prettierOptions); @@ -661,6 +713,7 @@ export default class PrettierEditService implements Disposable { configOptions: PrettierOptions | null, extensionFormattingOptions: ExtensionFormattingOptions, resolvedPlugins: (string | PrettierPlugin)[], + parserSettingWasExplicit: boolean, ): Partial { const fallbackToVSCodeConfig = configOptions === null; @@ -710,6 +763,14 @@ export default class PrettierEditService implements Disposable { }; } + const configOptionsToApply = + parserSettingWasExplicit && configOptions + ? (() => { + const { parser: _parser, ...rest } = configOptions; + return rest; + })() + : configOptions; + const options: PrettierOptions = { ...(fallbackToVSCodeConfig ? vsOpts : {}), ...{ @@ -718,7 +779,7 @@ export default class PrettierEditService implements Disposable { parser: parser as PrettierBuiltInParserName, }, ...(rangeFormattingOptions || {}), - ...(configOptions || {}), + ...(configOptionsToApply || {}), // Pass resolved plugin paths for Prettier to import ...(resolvedPlugins.length > 0 ? { plugins: resolvedPlugins } : {}), }; diff --git a/src/test/suite/format.test.ts b/src/test/suite/format.test.ts index 956c37306..5b555b6d1 100644 --- a/src/test/suite/format.test.ts +++ b/src/test/suite/format.test.ts @@ -1,7 +1,8 @@ import * as assert from "assert"; import * as prettier from "prettier"; +import * as vscode from "vscode"; import { ensureExtensionActivated } from "./testUtils.js"; -import { format } from "./formatTestUtils.js"; +import { format, formatWithLanguage } from "./formatTestUtils.js"; /** * Compare prettier's output (default settings) @@ -29,6 +30,33 @@ describe("Test format Document", () => { await ensureExtensionActivated(); }); + const withParserSetting = async ( + languageId: string, + value: string, + run: () => Promise, + ) => { + const config = vscode.workspace.getConfiguration("prettier", { + languageId, + }); + const previous = config.inspect("parser")?.workspaceLanguageValue; + await config.update( + "parser", + value, + vscode.ConfigurationTarget.Workspace, + true, + ); + try { + await run(); + } finally { + await config.update( + "parser", + previous, + vscode.ConfigurationTarget.Workspace, + true, + ); + } + }; + it("formats JavaScript", async () => { const { actual, source } = await format("project", "formatTest/ugly.js"); const prettierFormatted = await prettier.format(source, { @@ -99,4 +127,35 @@ describe("Test format Document", () => { "Formatting via extension should be idempotent", ); }); + + it("respects prettier.parser for language overrides", async () => { + await withParserSetting("jsonc", "json", async () => { + const { actual, source } = await format( + "project", + "formatTest/override-parser.jsonc", + ); + const jsonFormatted = await prettier.format(source, { parser: "json" }); + const jsoncFormatted = await prettier.format(source, { parser: "jsonc" }); + assert.equal(actual, jsonFormatted); + assert.notEqual( + actual, + jsoncFormatted, + "Parser override should change the output compared to jsonc", + ); + }); + }); + + it("supports prettier.parser: vscode to use active language mode", async () => { + await withParserSetting("markdown", "vscode", async () => { + const { actual, source } = await formatWithLanguage( + "project", + "formatTest/markdown-in-txt.txt", + "markdown", + ); + const prettierFormatted = await prettier.format(source, { + parser: "markdown", + }); + assert.equal(actual, prettierFormatted); + }); + }); }); diff --git a/src/test/suite/formatTestUtils.ts b/src/test/suite/formatTestUtils.ts index 66d6e9225..5940bde7c 100644 --- a/src/test/suite/formatTestUtils.ts +++ b/src/test/suite/formatTestUtils.ts @@ -107,3 +107,46 @@ export async function format(workspaceFolderName: string, testFile: string) { return { actual, source: text }; } + +export async function formatWithLanguage( + workspaceFolderName: string, + testFile: string, + languageId: string, +) { + const base = getWorkspaceFolderUri(workspaceFolderName); + const absPath = path.join(base.fsPath, testFile); + let doc = await vscode.workspace.openTextDocument(absPath); + doc = await vscode.languages.setTextDocumentLanguage(doc, languageId); + const text = doc.getText(); + try { + await vscode.window.showTextDocument(doc); + } catch (error) { + console.log(error); + throw error; + } + console.time(`${testFile}:${languageId}`); + + const maxRetries = 5; + const retryDelay = 500; + let actual = text; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + await vscode.commands.executeCommand("editor.action.formatDocument"); + actual = doc.getText(); + + if (actual !== text) { + break; + } + + if (attempt < maxRetries) { + console.log( + `Format attempt ${attempt} did not change document, retrying in ${retryDelay}ms...`, + ); + await delay(retryDelay); + } + } + + console.timeEnd(`${testFile}:${languageId}`); + + return { actual, source: text }; +} diff --git a/test-fixtures/project/formatTest/markdown-in-txt.txt b/test-fixtures/project/formatTest/markdown-in-txt.txt new file mode 100644 index 000000000..b5767941a --- /dev/null +++ b/test-fixtures/project/formatTest/markdown-in-txt.txt @@ -0,0 +1,4 @@ +#Title + +- [ ]todo +- [x]done diff --git a/test-fixtures/project/formatTest/override-parser.jsonc b/test-fixtures/project/formatTest/override-parser.jsonc new file mode 100644 index 000000000..507b700b2 --- /dev/null +++ b/test-fixtures/project/formatTest/override-parser.jsonc @@ -0,0 +1,5 @@ +{ + // comment + "a":1, + "b":[1,2] +}