diff --git a/Extension/package.json b/Extension/package.json index 30e23b7293..fd99aa4f64 100644 --- a/Extension/package.json +++ b/Extension/package.json @@ -313,12 +313,78 @@ "description": "%c_cpp.taskDefinitions.name.description%" }, "command": { - "type": "string", - "description": "%c_cpp.taskDefinitions.command.description%" + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "required": [ + "value", + "quoting" + ], + "properties": { + "value": { + "type": "string", + "description": "%c_cpp.taskDefinitions.args.value.description%" + }, + "quoting": { + "type": "string", + "enum": [ + "escape", + "strong", + "weak" + ], + "enumDescriptions": [ + "%c_cpp.taskDefinitions.args.quoting.escape.description%", + "%c_cpp.taskDefinitions.args.quoting.strong.description%", + "%c_cpp.taskDefinitions.args.quoting.weak.description%" + ], + "default": "strong", + "description": "%c_cpp.taskDefinitions.args.quoting.description%" + } + } + } + ] }, "args": { "type": "array", - "description": "%c_cpp.taskDefinitions.args.description%" + "description": "%c_cpp.taskDefinitions.args.description%", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "required": [ + "value", + "quoting" + ], + "properties": { + "value": { + "type": "string", + "description": "%c_cpp.taskDefinitions.args.value.description%" + }, + "quoting": { + "type": "string", + "enum": [ + "escape", + "strong", + "weak" + ], + "enumDescriptions": [ + "%c_cpp.taskDefinitions.args.quoting.escape.description%", + "%c_cpp.taskDefinitions.args.quoting.strong.description%", + "%c_cpp.taskDefinitions.args.quoting.weak.description%" + ], + "default": "strong", + "description": "%c_cpp.taskDefinitions.args.quoting.description%" + } + } + } + ] + } }, "options": { "type": "object", diff --git a/Extension/package.nls.json b/Extension/package.nls.json index 839ac2e1dd..67e1d9e218 100644 --- a/Extension/package.nls.json +++ b/Extension/package.nls.json @@ -928,6 +928,11 @@ "c_cpp.taskDefinitions.name.description": "The name of the task.", "c_cpp.taskDefinitions.command.description": "The path to either a compiler or script that performs compilation.", "c_cpp.taskDefinitions.args.description": "Additional arguments to pass to the compiler or compilation script.", + "c_cpp.taskDefinitions.args.value.description": "The actual argument value.", + "c_cpp.taskDefinitions.args.quoting.description": "How the argument value should be quoted.", + "c_cpp.taskDefinitions.args.quoting.escape.description": "Escapes characters using the shell's escape character (e.g. \\ under bash).", + "c_cpp.taskDefinitions.args.quoting.strong.description": "Quotes the argument using the shell's strong quote character (e.g. ' under bash).", + "c_cpp.taskDefinitions.args.quoting.weak.description": "Quotes the argument using the shell's weak quote character (e.g. \" under bash).", "c_cpp.taskDefinitions.options.description": "Additional command options.", "c_cpp.taskDefinitions.options.cwd.description": "The current working directory of the executed program or script. If omitted Code's current workspace root is used.", "c_cpp.taskDefinitions.detail.description": "Additional details of the task.", diff --git a/Extension/src/Debugger/configurationProvider.ts b/Extension/src/Debugger/configurationProvider.ts index 21f719db91..35f7c7c7c1 100644 --- a/Extension/src/Debugger/configurationProvider.ts +++ b/Extension/src/Debugger/configurationProvider.ts @@ -413,7 +413,7 @@ export class DebugConfigurationProvider implements vscode.DebugConfigurationProv if (buildTasks.length !== 0) { configs = (await Promise.all(buildTasks.map>(async task => { const definition: CppBuildTaskDefinition = task.definition as CppBuildTaskDefinition; - const compilerPath: string = definition.command; + const compilerPath: string = util.isString(definition.command) ? definition.command : definition.command.value; // Filter out the tasks that has an invalid compiler path. const compilerPathExists: boolean = path.isAbsolute(compilerPath) ? // Absolute path, just check if it exists diff --git a/Extension/src/LanguageServer/cppBuildTaskProvider.ts b/Extension/src/LanguageServer/cppBuildTaskProvider.ts index 482e2af58c..d7afd71d2a 100644 --- a/Extension/src/LanguageServer/cppBuildTaskProvider.ts +++ b/Extension/src/LanguageServer/cppBuildTaskProvider.ts @@ -21,8 +21,8 @@ const localize: nls.LocalizeFunc = nls.loadMessageBundle(); export interface CppBuildTaskDefinition extends TaskDefinition { type: string; label: string; // The label appears in tasks.json file. - command: string; - args: string[]; + command: string | util.IQuotedString; + args: (string | util.IQuotedString)[]; options: cp.ExecOptions | cp.SpawnOptions | undefined; } @@ -166,20 +166,24 @@ export class CppBuildTaskProvider implements TaskProvider { return result; } - private getTask: (compilerPath: string, appendSourceToName: boolean, compilerArgs?: string[], definition?: CppBuildTaskDefinition, detail?: string) => Task = (compilerPath: string, appendSourceToName: boolean, compilerArgs?: string[], definition?: CppBuildTaskDefinition, detail?: string) => { - const compilerPathBase: string = path.basename(compilerPath); + private getTask: (compilerPath: string | util.IQuotedString, appendSourceToName: boolean, compilerArgs?: (string | util.IQuotedString)[], definition?: CppBuildTaskDefinition, detail?: string) => Task = (compilerPath: string | util.IQuotedString, appendSourceToName: boolean, compilerArgs?: (string | util.IQuotedString)[], definition?: CppBuildTaskDefinition, detail?: string) => { + const compilerPathString: string = util.isString(compilerPath) ? compilerPath : compilerPath.value; + const compilerPathBase: string = path.basename(compilerPathString); const isCl: boolean = compilerPathBase.toLowerCase() === "cl.exe"; const isClang: boolean = !isCl && compilerPathBase.toLowerCase().includes("clang"); // Double-quote the command if needed. - let resolvedcompilerPath: string = isCl ? compilerPathBase : compilerPath; - resolvedcompilerPath = util.quoteArgument(resolvedcompilerPath); + const resolvedCompilerPathString: string = isCl ? compilerPathBase : compilerPathString; + let resolvedCompilerPath: string | util.IQuotedString = compilerPath; + if (isCl) { + resolvedCompilerPath = compilerPathBase; + } if (!definition) { const isWindows: boolean = os.platform() === 'win32'; const taskLabel: string = ((appendSourceToName && !compilerPathBase.startsWith(ext.configPrefix)) ? ext.configPrefix : "") + compilerPathBase + " " + localize("build.active.file", "build active file"); const programName: string = util.defaultExePath(); - let args: string[] = isCl ? + let args: (string | util.IQuotedString)[] = isCl ? ['/Zi', '/EHsc', '/nologo', `/Fe${programName}`, '${file}'] : isClang ? ['-fcolor-diagnostics', '-fansi-escape-codes', '-g', '${file}', '-o', programName] : @@ -188,30 +192,38 @@ export class CppBuildTaskProvider implements TaskProvider { if (compilerArgs && compilerArgs.length > 0) { args = args.concat(compilerArgs); } - const cwd: string = isWindows && !isCl && !process.env.PATH?.includes(path.dirname(compilerPath)) ? path.dirname(compilerPath) : "${fileDirname}"; + const cwd: string = isWindows && !isCl && !process.env.PATH?.includes(path.dirname(compilerPathString)) ? path.dirname(compilerPathString) : "${fileDirname}"; const options: cp.ExecOptions | cp.SpawnOptions | undefined = { cwd: cwd }; definition = { type: CppBuildTaskProvider.CppBuildScriptType, label: taskLabel, - command: isCl ? compilerPathBase : compilerPath, + command: compilerPath, args: args, options: options }; + if (isCl) { + definition.command = compilerPathBase; + } } const editor: TextEditor | undefined = window.activeTextEditor; const folder: WorkspaceFolder | undefined = editor ? workspace.getWorkspaceFolder(editor.document.uri) : undefined; - const taskUsesActiveFile: boolean = definition.args.some(arg => arg.indexOf('${file}') >= 0); // Need to check this before ${file} is resolved + const taskUsesActiveFile: boolean = definition.args.some(arg => { + if (util.isString(arg)) { + return arg.indexOf('${file}') >= 0; + } + return arg.value.indexOf('${file}') >= 0; + }); // Need to check this before ${file} is resolved const scope: WorkspaceFolder | TaskScope = folder ? folder : TaskScope.Workspace; const task: CppBuildTask = new Task(definition, scope, definition.label, ext.CppSourceStr, new CustomExecution(async (resolvedDefinition: TaskDefinition): Promise => // When the task is executed, this callback will run. Here, we setup for running the task. - new CustomBuildTaskTerminal(resolvedcompilerPath, resolvedDefinition.args, resolvedDefinition.options, { taskUsesActiveFile, insertStd: isClang && os.platform() === 'darwin' }) + new CustomBuildTaskTerminal(resolvedCompilerPath, resolvedDefinition.args, resolvedDefinition.options, { taskUsesActiveFile, insertStd: isClang && os.platform() === 'darwin' }) ), isCl ? '$msCompile' : '$gcc'); task.group = TaskGroup.Build; - task.detail = detail ? detail : localize("compiler.details", "compiler:") + " " + resolvedcompilerPath; + task.detail = detail ? detail : localize("compiler.details", "compiler:") + " " + resolvedCompilerPathString; return task; }; @@ -354,7 +366,7 @@ class CustomBuildTaskTerminal implements Pseudoterminal { public get onDidClose(): Event { return this.closeEmitter.event; } private endOfLine: string = "\r\n"; - constructor(private command: string, private args: string[], private options: cp.ExecOptions | cp.SpawnOptions | undefined, private buildOptions: BuildOptions) { + constructor(private command: string | util.IQuotedString, private args: (string | util.IQuotedString)[], private options: cp.ExecOptions | undefined, private buildOptions: BuildOptions) { } async open(_initialDimensions: TerminalDimensions | undefined): Promise { @@ -380,22 +392,29 @@ class CustomBuildTaskTerminal implements Pseudoterminal { private async doBuild(): Promise { // Do build. - let command: string = util.resolveVariables(this.command); - let activeCommand: string = command; + let resolvedCommand: string | util.IQuotedString | undefined; + if (util.isString(this.command)) { + resolvedCommand = util.resolveVariables(this.command); + } else { + resolvedCommand = { + value: util.resolveVariables(this.command.value), + quoting: this.command.quoting + }; + } // Create the exe folder path if it doesn't exist. const exePath: string | undefined = util.resolveVariables(util.findExePathInArgs(this.args)); util.createDirIfNotExistsSync(exePath); this.args.forEach((value, index) => { - value = util.quoteArgument(util.resolveVariables(value)); - activeCommand = activeCommand + " " + value; - this.args[index] = value; + if (util.isString(value)) { + this.args[index] = util.resolveVariables(value); + } else { + value.value = util.resolveVariables(value.value); + } }); - if (this.options) { - this.options.shell = true; - } else { - this.options = { "shell": true }; + if (this.options === undefined) { + this.options = { }; } if (this.options.cwd) { this.options.cwd = util.resolveVariables(this.options.cwd.toString()); @@ -425,15 +444,12 @@ class CustomBuildTaskTerminal implements Pseudoterminal { } }; - if (os.platform() === 'win32') { - command = `cmd /c chcp 65001>nul && ${command}`; - } - + const activeCommand: string = util.buildShellCommandLine(resolvedCommand, this.command, this.args); this.writeEmitter.fire(activeCommand + this.endOfLine); let child: cp.ChildProcess | undefined; try { - child = cp.spawn(command, this.args, this.options ? this.options : {}); + child = cp.exec(activeCommand, this.options); let error: string = ""; let stdout: string = ""; let stderr: string = ""; diff --git a/Extension/src/common.ts b/Extension/src/common.ts index 8a997c0de7..9c7d948fd7 100644 --- a/Extension/src/common.ts +++ b/Extension/src/common.ts @@ -355,17 +355,6 @@ export function defaultExePath(): string { return isWindows ? exePath + '.exe' : exePath; } -export function findExePathInArgs(args: string[]): string | undefined { - const exePath: string | undefined = args.find((arg: string, index: number) => arg.includes(".exe") || (index > 0 && args[index - 1] === "-o")); - if (exePath?.startsWith("/Fe")) { - return exePath.substring(3); - } - if (exePath?.toLowerCase().startsWith("/out:")) { - return exePath.substring(5); - } - return exePath; -} - // Pass in 'arrayResults' if a string[] result is possible and a delimited string result is undesirable. // The string[] result will be copied into 'arrayResults'. export function resolveVariables(input: string | undefined, additionalEnvironment?: Record, arrayResults?: string[]): string { @@ -1632,3 +1621,181 @@ export function mergeOverlappingRanges(ranges: Range[]): Range[] { mergedRanges.length = lastMergedIndex; return mergedRanges; } + +// Arg quoting utility functions, copied from VS Code with minor changes. + +export interface IShellQuotingOptions { + /** + * The character used to do character escaping. + */ + escape?: string | { + escapeChar: string; + charsToEscape: string; + }; + + /** + * The character used for string quoting. + */ + strong?: string; + + /** + * The character used for weak quoting. + */ + weak?: string; +} + +export interface IQuotedString { + value: string; + quoting: 'escape' | 'strong' | 'weak'; +} + +export type CommandString = string | IQuotedString; + +export function buildShellCommandLine(originalCommand: CommandString, command: CommandString, args: CommandString[]): string { + + let shellQuoteOptions: IShellQuotingOptions; + const isWindows: boolean = os.platform() === 'win32'; + if (isWindows) { + shellQuoteOptions = { + strong: '"' + }; + } else { + shellQuoteOptions = { + escape: { + escapeChar: '\\', + charsToEscape: ' "\'' + }, + strong: '\'', + weak: '"' + }; + } + + // TODO: Support launching with PowerShell + // For PowerShell: + // { + // escape: { + // escapeChar: '`', + // charsToEscape: ' "\'()' + // }, + // strong: '\'', + // weak: '"' + // }, + + function needsQuotes(value: string): boolean { + if (value.length >= 2) { + const first = value[0] === shellQuoteOptions.strong ? shellQuoteOptions.strong : value[0] === shellQuoteOptions.weak ? shellQuoteOptions.weak : undefined; + if (first === value[value.length - 1]) { + return false; + } + } + let quote: string | undefined; + for (let i = 0; i < value.length; i++) { + // We found the end quote. + const ch = value[i]; + if (ch === quote) { + quote = undefined; + } else if (quote !== undefined) { + // skip the character. We are quoted. + continue; + } else if (ch === shellQuoteOptions.escape) { + // Skip the next character + i++; + } else if (ch === shellQuoteOptions.strong || ch === shellQuoteOptions.weak) { + quote = ch; + } else if (ch === ' ') { + return true; + } + } + return false; + } + + function quote(value: string, kind: 'escape' | 'strong' | 'weak'): [string, boolean] { + if (kind === "strong" && shellQuoteOptions.strong) { + return [shellQuoteOptions.strong + value + shellQuoteOptions.strong, true]; + } else if (kind === "weak" && shellQuoteOptions.weak) { + return [shellQuoteOptions.weak + value + shellQuoteOptions.weak, true]; + } else if (kind === "escape" && shellQuoteOptions.escape) { + if (isString(shellQuoteOptions.escape)) { + return [value.replace(/ /g, shellQuoteOptions.escape + ' '), true]; + } else { + const buffer: string[] = []; + for (const ch of shellQuoteOptions.escape.charsToEscape) { + buffer.push(`\\${ch}`); + } + const regexp: RegExp = new RegExp('[' + buffer.join(',') + ']', 'g'); + const escapeChar = shellQuoteOptions.escape.escapeChar; + return [value.replace(regexp, (match) => escapeChar + match), true]; + } + } + return [value, false]; + } + + function quoteIfNecessary(value: CommandString): [string, boolean] { + if (isString(value)) { + if (needsQuotes(value)) { + return quote(value, "strong"); + } else { + return [value, false]; + } + } else { + return quote(value.value, value.quoting); + } + } + + // If we have no args and the command is a string then use the command to stay backwards compatible with the old command line + // model. To allow variable resolving with spaces we do continue if the resolved value is different than the original one + // and the resolved one needs quoting. + if ((!args || args.length === 0) && isString(command) && (command === originalCommand as string || needsQuotes(originalCommand as string))) { + return command; + } + + const result: string[] = []; + let commandQuoted = false; + let argQuoted = false; + let value: string; + let quoted: boolean; + [value, quoted] = quoteIfNecessary(command); + result.push(value); + commandQuoted = quoted; + for (const arg of args) { + [value, quoted] = quoteIfNecessary(arg); + result.push(value); + argQuoted = argQuoted || quoted; + } + + let commandLine = result.join(' '); + // There are special rules quoted command line in cmd.exe + if (isWindows) + { + commandLine = `chcp 65001>nul && ${commandLine}`; + if (commandQuoted && argQuoted) { + commandLine = '"' + commandLine + '"'; + } + commandLine = `cmd /c ${commandLine}`; + } + + return commandLine; +} + +export function findExePathInArgs(args: CommandString[]): string | undefined { + const isWindows: boolean = os.platform() === 'win32'; + let previousArg: string | undefined; + + for (const arg of args) { + const argValue = isString(arg) ? arg : arg.value; + if (previousArg === '-o') { + return argValue; + } + if (isWindows && argValue.includes('.exe')) { + if (argValue.startsWith('/Fe')) { + return argValue.substring(3); + } else if (argValue.toLowerCase().startsWith('/out:')) { + return argValue.substring(5); + } + } + + previousArg = argValue; + } + + return undefined; +}