diff --git a/README.md b/README.md index 436e528e1..e82430635 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,51 @@ Supply a custom path to the prettier module. This path should be to the module f **Disabled on untrusted workspaces** +#### prettier.customExecutable + +Use a custom executable to run Prettier. This is useful for Docker-centric workspaces where Prettier runs inside a container. Specify the full command including any arguments. Prettier CLI arguments (like `--stdin-filepath`, `--parser`, etc.) will be automatically appended to your command. + +**How it works:** +- The extension runs your command with Prettier arguments appended +- Code is sent via stdin and formatted output is read from stdout +- Optionally use `${prettier}` as a placeholder for the Prettier path (defaults to the value in `prettier.prettierPath` or auto-detected path) + +**Example for Docker:** + +```json +{ + "prettier.customExecutable": "docker compose exec -T app node_modules/.bin/prettier" +} +``` + +This will execute commands like: +```bash +docker compose exec -T app node_modules/.bin/prettier --stdin-filepath file.js --parser babel +``` + +**Example with a wrapper script:** + +```json +{ + "prettier.customExecutable": "./scripts/prettier-wrapper.sh" +} +``` + +**Example with placeholder:** + +```json +{ + "prettier.customExecutable": "docker exec my-container ${prettier}", + "prettier.prettierPath": "/app/node_modules/.bin/prettier" +} +``` + +When `prettier.customExecutable` is specified, it takes precedence over `prettier.prettierPath`. If you need to specify where Prettier is located, you can set `prettier.prettierPath` to provide the path for the `${prettier}` placeholder or for auto-detection. + +**Note:** The custom executable must accept Prettier CLI arguments and behave like the Prettier CLI. Input is provided via stdin and output is expected on stdout. + +**Disabled on untrusted workspaces** + #### prettier.resolveGlobalModules (default: `false`) When enabled, this extension will attempt to use global npm or yarn modules if local modules cannot be resolved. diff --git a/package.json b/package.json index b0eeee995..28b0055ad 100644 --- a/package.json +++ b/package.json @@ -132,6 +132,7 @@ "prettier.resolveGlobalModules", "prettier.ignorePath", "prettier.prettierPath", + "prettier.customExecutable", "prettier.configPath", "prettier.useEditorConfig", "prettier.resolveGlobalModules", @@ -214,6 +215,11 @@ "markdownDescription": "%ext.config.prettierPath%", "scope": "resource" }, + "prettier.customExecutable": { + "type": "string", + "markdownDescription": "%ext.config.customExecutable%", + "scope": "resource" + }, "prettier.configPath": { "type": "string", "markdownDescription": "%ext.config.configPath%", diff --git a/package.nls.json b/package.nls.json index 08de4f40b..da724edf3 100644 --- a/package.nls.json +++ b/package.nls.json @@ -21,6 +21,7 @@ "ext.config.parser": "Override the parser. You shouldn't have to change this setting.", "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.customExecutable": "Use a custom executable to run Prettier. Specify the full command including arguments. Prettier CLI arguments will be appended automatically. Optionally use `${prettier}` as a placeholder for the Prettier path. Example: `docker compose exec -T app node_modules/.bin/prettier`. When specified, takes precedence over `prettier.prettierPath`.", "ext.config.printWidth": "Fit code within this line limit.", "ext.config.proseWrap": "(Markdown) wrap prose over multiple lines.", "ext.config.quoteProps": "Change when properties in objects are quoted.\nValid options:\n- `\"as-needed\"` - Only add quotes around object properties where required.\n- `\"consistent\"` - If at least one property in an object requires quotes, quote all properties.\n- `\"preserve\"` - Respect the input use of quotes in object properties.", diff --git a/package.nls.zh-cn.json b/package.nls.zh-cn.json index 6077280c6..f145492e8 100644 --- a/package.nls.zh-cn.json +++ b/package.nls.zh-cn.json @@ -21,6 +21,7 @@ "ext.config.parser": "覆盖解析器。通常不需要更改此设置。", "ext.config.parserDeprecationMessage": "此设置已不再支持。请改用 Prettier 配置文件。", "ext.config.prettierPath": "`prettier` 包路径,如 `./node_modules/prettier`。", + "ext.config.customExecutable": "使用自定义可执行文件运行 Prettier。指定完整命令及其参数。Prettier CLI 参数将自动附加。可选使用 `${prettier}` 作为 Prettier 路径的占位符。例如:`docker compose exec -T app node_modules/.bin/prettier`。当指定时,优先于 `prettier.prettierPath`。", "ext.config.printWidth": "每行代码的长度限制。", "ext.config.proseWrap": "( Markdown ) 文本换行。", "ext.config.quoteProps": "指定对象字面量中的属性名引号添加方式。\n可选项: \n- `as-needed` - 只在需要的情况下加引号。\n- `consistent` - 有一个需要引号就给其他都统一加上。\n - `preserve` - 保留用户输入的引号。", diff --git a/package.nls.zh-tw.json b/package.nls.zh-tw.json index 3e3ddaada..ae33aa6b8 100644 --- a/package.nls.zh-tw.json +++ b/package.nls.zh-tw.json @@ -21,6 +21,7 @@ "ext.config.parser": "覆寫解析器。你不應變更這個設定。", "ext.config.parserDeprecationMessage": "這個設定已不再支援。請改用 prettier 組態檔。", "ext.config.prettierPath": "`prettier` 模組的路徑,如 `./node_modules/prettier`。", + "ext.config.customExecutable": "使用自訂可執行檔來執行 Prettier。指定完整命令及其參數。Prettier CLI 參數將自動附加。可選使用 `${prettier}` 作為 Prettier 路徑的佔位符。例如:`docker compose exec -T app node_modules/.bin/prettier`。當指定時,優先於 `prettier.prettierPath`。", "ext.config.printWidth": "讓程式碼的每一列符合這個寬度限制。", "ext.config.proseWrap": "( Markdown ) 把文句換行成多列。", "ext.config.quoteProps": "變更物件屬性何時加上引號。\n可用的選項:\n- `\"as-needed\"` - 只在需要時加上引號。\n- `\"consistent\"` - 如果至少有一個屬性需要引號,則對所有屬性加上引號。\n- `\"preserve\"` - 保留輸入中屬性引號的使用方式。", diff --git a/src/ModuleResolverNode.ts b/src/ModuleResolverNode.ts index 9df178750..1d033aa78 100644 --- a/src/ModuleResolverNode.ts +++ b/src/ModuleResolverNode.ts @@ -31,6 +31,7 @@ import { getWorkspaceRelativePath, } from "./utils/workspace.js"; import { PrettierDynamicInstance } from "./PrettierDynamicInstance.js"; +import { PrettierExecutableInstance } from "./PrettierExecutableInstance.js"; const minPrettierVersion = "1.13.0"; @@ -100,9 +101,17 @@ export class ModuleResolver implements ModuleResolverInterface { return getBundledPrettier(); } - const { prettierPath, resolveGlobalModules } = getWorkspaceConfig( - Uri.file(fileName), - ); + const { prettierPath, customExecutable, resolveGlobalModules } = + getWorkspaceConfig(Uri.file(fileName)); + + // If custom executable is specified, use it with highest priority + if (customExecutable) { + return this.getCustomExecutableInstance( + fileName, + customExecutable, + prettierPath, + ); + } // Look for local module let modulePath: string | undefined; @@ -160,6 +169,67 @@ export class ModuleResolver implements ModuleResolverInterface { return prettierInstance; } + private async getCustomExecutableInstance( + fileName: string, + customExecutable: string, + prettierPath: string | undefined, + ): Promise { + // Determine the prettier path for the custom executable + let resolvedPrettierPath: string; + + if (prettierPath) { + // Use the explicitly provided prettierPath + const absolutePath = path.isAbsolute(prettierPath) + ? prettierPath + : path.join( + workspace.getWorkspaceFolder(Uri.file(fileName))?.uri.fsPath ?? "", + prettierPath, + ); + resolvedPrettierPath = absolutePath; + } else { + // Try to find prettier module automatically + const foundPath = await this.findPrettierModule(fileName); + if (foundPath) { + resolvedPrettierPath = foundPath; + } else { + // Fall back to just "prettier" and let the custom executable resolve it + resolvedPrettierPath = "prettier"; + } + } + + const cacheKey = `custom:${customExecutable}:${resolvedPrettierPath}`; + + // Check cache + let prettierInstance = this.path2Module.get(cacheKey); + if (prettierInstance) { + return prettierInstance; + } + + // Create new instance using PrettierExecutableInstance + prettierInstance = new PrettierExecutableInstance( + customExecutable, + resolvedPrettierPath, + ); + + // Import/validate the executable + try { + await prettierInstance.import(); + this.loggingService.logInfo( + `Using custom executable: ${customExecutable} with prettier at ${resolvedPrettierPath}`, + ); + } catch (error) { + this.loggingService.logError( + `Failed to initialize custom executable: ${customExecutable}`, + error, + ); + return undefined; + } + + this.path2Module.set(cacheKey, prettierInstance); + + return prettierInstance; + } + private async getModuleFromPrettierPath( fileName: string, prettierPath: string, diff --git a/src/PrettierExecutableInstance.ts b/src/PrettierExecutableInstance.ts new file mode 100644 index 000000000..72b114dcc --- /dev/null +++ b/src/PrettierExecutableInstance.ts @@ -0,0 +1,368 @@ +import { spawn } from "child_process"; +import type { FileInfoOptions, Options, ResolveConfigOptions } from "prettier"; +import type { + PrettierFileInfoResult, + PrettierPlugin, + PrettierSupportLanguage, + PrettierInstance, +} from "./types.js"; + +/** + * Prettier instance that executes Prettier through a custom executable command. + * This is useful for Docker-centric workspaces where Prettier runs inside a container. + */ +export class PrettierExecutableInstance implements PrettierInstance { + public version: string | null = null; + + constructor( + private customExecutable: string, + private prettierPath: string, + ) {} + + public async import(): Promise { + // Get Prettier version by executing it with --version flag + const versionCommand = this.buildCommand("--version", ""); + try { + const stdout = await this.executeCommand(versionCommand); + this.version = stdout.trim(); + return this.version; + } catch (error) { + throw new Error( + `Failed to get Prettier version using custom executable: ${error}`, + ); + } + } + + public async format( + source: string, + options?: Options | undefined, + ): Promise { + if (!this.version) { + await this.import(); + } + + // Build the command with options + const args = this.buildOptionsArgs(options); + const command = this.buildCommand(args, ""); + + try { + return await this.executeCommand(command, source); + } catch (error) { + throw new Error(`Prettier formatting failed: ${error}`); + } + } + + public async getFileInfo( + filePath: string, + fileInfoOptions?: FileInfoOptions | undefined, + ): Promise { + if (!this.version) { + await this.import(); + } + + const args = ["--file-info", this.escapeArg(filePath)]; + + if (fileInfoOptions?.ignorePath) { + const ignorePath = + typeof fileInfoOptions.ignorePath === "string" + ? fileInfoOptions.ignorePath + : fileInfoOptions.ignorePath.toString(); + args.push("--ignore-path", this.escapeArg(ignorePath)); + } + + if (fileInfoOptions?.withNodeModules) { + args.push("--with-node-modules"); + } + + if (fileInfoOptions?.plugins) { + for (const plugin of fileInfoOptions.plugins) { + if (typeof plugin === "string") { + args.push("--plugin", this.escapeArg(plugin)); + } + } + } + + const command = this.buildCommand(args.join(" "), ""); + + try { + const stdout = await this.executeCommand(command); + return JSON.parse(stdout) as PrettierFileInfoResult; + } catch (error) { + throw new Error(`Failed to get file info: ${error}`); + } + } + + public async getSupportInfo({ + plugins, + }: { + plugins: (string | PrettierPlugin)[]; + }): Promise<{ + languages: PrettierSupportLanguage[]; + }> { + if (!this.version) { + await this.import(); + } + + const args = ["--support-info"]; + + for (const plugin of plugins) { + if (typeof plugin === "string") { + args.push("--plugin", this.escapeArg(plugin)); + } + } + + const command = this.buildCommand(args.join(" "), ""); + + try { + const stdout = await this.executeCommand(command); + return JSON.parse(stdout) as { languages: PrettierSupportLanguage[] }; + } catch (error) { + throw new Error(`Failed to get support info: ${error}`); + } + } + + public async clearConfigCache(): Promise { + // Custom executables don't maintain a config cache in the extension + // The cache would be on the Prettier side, which we can't directly clear + return; + } + + public async resolveConfigFile( + filePath?: string | undefined, + ): Promise { + if (!this.version) { + await this.import(); + } + + const args = ["--find-config-path"]; + if (filePath) { + args.push(this.escapeArg(filePath)); + } + + const command = this.buildCommand(args.join(" "), ""); + + try { + const stdout = await this.executeCommand(command); + const result = stdout.trim(); + return result === "" || result === "null" ? null : result; + } catch { + return null; + } + } + + public async resolveConfig( + fileName: string, + options?: ResolveConfigOptions | undefined, + ): Promise { + if (!this.version) { + await this.import(); + } + + const args = ["--resolve-config", this.escapeArg(fileName)]; + + if (options?.config) { + const config = + typeof options.config === "string" + ? options.config + : options.config.toString(); + args.unshift("--config", this.escapeArg(config)); + } + + if (options?.editorconfig === false) { + args.push("--no-editorconfig"); + } + + const command = this.buildCommand(args.join(" "), ""); + + try { + const stdout = await this.executeCommand(command); + const result = stdout.trim(); + return result === "" || result === "null" + ? null + : (JSON.parse(result) as Options); + } catch { + return null; + } + } + + private executeCommand( + command: string, + stdin?: string, + ): Promise { + return new Promise((resolve, reject) => { + // Execute the full command through shell + // Note: Using shell:true is intentional to support shell features + // The customExecutable is from user VS Code settings (trusted configuration) + // All dynamic arguments (file paths, options) are escaped + const child = spawn(command, { + shell: true, + }); + + let stdout = ""; + let stderr = ""; + + child.stdout.on("data", (data) => { + stdout += data.toString(); + }); + + child.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + child.on("error", (error) => { + reject(new Error(`Failed to execute command: ${error.message}`)); + }); + + child.on("close", (code) => { + if (code !== 0) { + reject( + new Error( + `Command exited with code ${code}. stderr: ${stderr}`, + ), + ); + } else { + resolve(stdout); + } + }); + + // Write stdin if provided + if (stdin) { + child.stdin.write(stdin); + } + child.stdin.end(); + }); + } + + private buildCommand(args: string, _filePath: string): string { + // Replace placeholders in the custom executable command + // ${prettier} = path to prettier (optional placeholder) + let command = this.customExecutable; + + // Escape prettierPath to prevent command injection + const escapedPrettierPath = this.escapeArg(this.prettierPath); + + // If no ${prettier} placeholder exists, append it to the command + if (!command.includes("${prettier}")) { + command = `${command} ${escapedPrettierPath}`; + } else { + command = command.replace(/\$\{prettier\}/g, escapedPrettierPath); + } + + // Add arguments (these are already escaped in buildOptionsArgs or other methods) + if (args) { + command = `${command} ${args}`; + } + + return command; + } + + private buildOptionsArgs(options?: Options): string { + if (!options) { + return "--stdin-filepath dummy.js"; + } + + const args: string[] = []; + + // Required for stdin formatting + if (options.filepath) { + args.push("--stdin-filepath", this.escapeArg(options.filepath)); + } else { + args.push("--stdin-filepath", "dummy.js"); + } + + if (options.parser) { + args.push("--parser", this.escapeArg(options.parser as string)); + } + + if (options.plugins && Array.isArray(options.plugins)) { + for (const plugin of options.plugins) { + if (typeof plugin === "string") { + args.push("--plugin", this.escapeArg(plugin)); + } + } + } + + // Add common formatting options + if (options.printWidth !== undefined) { + args.push("--print-width", String(options.printWidth)); + } + if (options.tabWidth !== undefined) { + args.push("--tab-width", String(options.tabWidth)); + } + if (options.useTabs !== undefined) { + args.push(options.useTabs ? "--use-tabs" : "--no-use-tabs"); + } + if (options.semi !== undefined) { + args.push(options.semi ? "--semi" : "--no-semi"); + } + if (options.singleQuote !== undefined) { + args.push( + options.singleQuote ? "--single-quote" : "--no-single-quote", + ); + } + if (options.quoteProps) { + args.push("--quote-props", this.escapeArg(options.quoteProps)); + } + if (options.jsxSingleQuote !== undefined) { + args.push( + options.jsxSingleQuote ? "--jsx-single-quote" : "--no-jsx-single-quote", + ); + } + if (options.trailingComma) { + args.push("--trailing-comma", this.escapeArg(options.trailingComma)); + } + if (options.bracketSpacing !== undefined) { + args.push( + options.bracketSpacing ? "--bracket-spacing" : "--no-bracket-spacing", + ); + } + if (options.bracketSameLine !== undefined) { + args.push( + options.bracketSameLine + ? "--bracket-same-line" + : "--no-bracket-same-line", + ); + } + if (options.arrowParens) { + args.push("--arrow-parens", this.escapeArg(options.arrowParens)); + } + if (options.proseWrap) { + args.push("--prose-wrap", this.escapeArg(options.proseWrap)); + } + if (options.htmlWhitespaceSensitivity) { + args.push( + "--html-whitespace-sensitivity", + this.escapeArg(options.htmlWhitespaceSensitivity), + ); + } + if (options.endOfLine) { + args.push("--end-of-line", this.escapeArg(options.endOfLine)); + } + if (options.embeddedLanguageFormatting) { + args.push( + "--embedded-language-formatting", + this.escapeArg(options.embeddedLanguageFormatting), + ); + } + + // Range formatting + if (options.rangeStart !== undefined) { + args.push("--range-start", String(options.rangeStart)); + } + if (options.rangeEnd !== undefined) { + args.push("--range-end", String(options.rangeEnd)); + } + + return args.join(" "); + } + + private escapeArg(arg: string): string { + // Escape arguments for shell execution to prevent command injection + // Use single quotes for maximum safety, and escape any single quotes in the arg + // This prevents shell interpretation of special characters + // Replace single quotes with '\'' (end quote, escaped quote, start quote) + // Note: This approach works well on Unix-like systems (Linux, macOS) + // Windows support is best-effort - users should test their custom executables + return `'${arg.replace(/'/g, "'\\''")}'`; + } +} diff --git a/src/types.ts b/src/types.ts index 1bef2a894..d497cffec 100644 --- a/src/types.ts +++ b/src/types.ts @@ -97,6 +97,10 @@ export interface IExtensionConfig { * Path to prettier module. */ prettierPath: string | undefined; + /** + * Custom executable command to run Prettier. + */ + customExecutable: string | undefined; /** * Path to prettier configuration file. */ diff --git a/test-fixtures/custom-executable/.prettierrc b/test-fixtures/custom-executable/.prettierrc new file mode 100644 index 000000000..63c660cd2 --- /dev/null +++ b/test-fixtures/custom-executable/.prettierrc @@ -0,0 +1,5 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "es5" +} diff --git a/test-fixtures/custom-executable/.vscode/settings.json b/test-fixtures/custom-executable/.vscode/settings.json new file mode 100644 index 000000000..6f9fbd6ce --- /dev/null +++ b/test-fixtures/custom-executable/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "prettier.customExecutable": "./wrapper.sh" +} diff --git a/test-fixtures/custom-executable/package.json b/test-fixtures/custom-executable/package.json new file mode 100644 index 000000000..e34936262 --- /dev/null +++ b/test-fixtures/custom-executable/package.json @@ -0,0 +1,8 @@ +{ + "name": "test-custom-executable", + "version": "1.0.0", + "private": true, + "devDependencies": { + "prettier": "3.4.2" + } +} diff --git a/test-fixtures/custom-executable/ugly.js b/test-fixtures/custom-executable/ugly.js new file mode 100644 index 000000000..4a300d5cd --- /dev/null +++ b/test-fixtures/custom-executable/ugly.js @@ -0,0 +1,2 @@ +const x={a:1,b:2,c:3}; +function foo(a,b,c){return a+b+c} diff --git a/test-fixtures/custom-executable/wrapper.sh b/test-fixtures/custom-executable/wrapper.sh new file mode 100755 index 000000000..95aecd61d --- /dev/null +++ b/test-fixtures/custom-executable/wrapper.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# Simple wrapper script for testing custom executable functionality +# This simulates running prettier through an external command (like docker) +exec "$@" diff --git a/test-fixtures/test.code-workspace b/test-fixtures/test.code-workspace index b7ea59655..0ac4fc9f1 100644 --- a/test-fixtures/test.code-workspace +++ b/test-fixtures/test.code-workspace @@ -62,6 +62,9 @@ }, { "path": "monorepo-subfolder" + }, + { + "path": "custom-executable" } ], "settings": {