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] +}