diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ae8c0e..eb437f6 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Change Log +## [1.15.0] 2025-01-02 + +- Add variable resolver for ${prompt} (#123) + ## [1.14.1] 2024-12-24 - Fix bug with multiselect active vs selected (#127) diff --git a/README.md b/README.md index a748a13..0c76a11 100755 --- a/README.md +++ b/README.md @@ -115,6 +115,10 @@ VSCode renders it like this: As of today, the extension supports variable substitution for: * a subset of predefined variables like `file`, `fileDirName`, `fileBasenameNoExtension`, `fileBasename`, `lineNumber`, `extension`, `workspaceFolder` and `workspaceFolderBasename`, pattern: `${variable}` +* an arbitrary value via `prompt` (combine options using `&` like URL query strings): + * `${prompt}` to show an input box for an arbitrary value + * `${prompt:rememberPrevious=false}` to disable the default action of initializing the input box with the previous value + * `${prompt:prompt=Custom prompt text}` to configure the label of the input box to show `Custom prompt text` * the remembered value (the default value when `rememberPrevious` is true), available as `${rememberedValue}` * all config variables, pattern: `${config:variable}` * all environment variables (`tasks.json` `options.env` with fallback to parent process), pattern: `${env:variable}` diff --git a/package-lock.json b/package-lock.json index ce369ec..7382b2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tasks-shell-input", - "version": "1.14.1", + "version": "1.15.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "tasks-shell-input", - "version": "1.14.1", + "version": "1.15.0", "devDependencies": { "@types/glob": "8.1.0", "@types/node": "20.11.30", diff --git a/package.json b/package.json index 178d309..dd2b0ee 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "displayName": "Tasks Shell Input", "description": "Use shell commands as input for your tasks", "icon": "icon.png", - "version": "1.14.1", + "version": "1.15.0", "publisher": "augustocdias", "repository": { "url": "https://github.com/augustocdias/vscode-shell-command" diff --git a/src/extension.ts b/src/extension.ts index 92fed69..5ea3ebe 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -31,6 +31,8 @@ export function activate(this: any, context: vscode.ExtensionContext) { // Reimplementation of promptString that can be used from inputs. const handlePromptString = async () => { + vscode.window.showWarningMessage( + 'shellCommand.promptString is deprecated. Please use `${prompt}`.'); const inputValue = await vscode.window.showInputBox(); return inputValue || ''; diff --git a/src/lib/CommandHandler.test.ts b/src/lib/CommandHandler.test.ts index f35840d..e88f11b 100644 --- a/src/lib/CommandHandler.test.ts +++ b/src/lib/CommandHandler.test.ts @@ -7,6 +7,7 @@ import { describe, expect, test, vi, beforeEach } from 'vitest'; import { ShellCommandOptions } from "./ShellCommandOptions"; import { CommandHandler } from './CommandHandler'; import { UserInputContext } from './UserInputContext'; +import { parseBoolean } from './options'; import * as mockVscode from '../mocks/vscode'; const mockExtensionContext = { @@ -349,21 +350,21 @@ describe("Argument parsing", () => { }); test("parseBoolean", () => { - expect(CommandHandler.parseBoolean(undefined, true)).toBe(true); - expect(CommandHandler.parseBoolean(undefined, false)).toBe(false); + expect(parseBoolean(undefined, true)).toBe(true); + expect(parseBoolean(undefined, false)).toBe(false); - expect(CommandHandler.parseBoolean(false, true)).toBe(false); - expect(CommandHandler.parseBoolean(true, false)).toBe(true); + expect(parseBoolean(false, true)).toBe(false); + expect(parseBoolean(true, false)).toBe(true); - expect(CommandHandler.parseBoolean("false", true)).toBe(false); - expect(CommandHandler.parseBoolean("fALse", true)).toBe(false); - expect(CommandHandler.parseBoolean("true", false)).toBe(true); - expect(CommandHandler.parseBoolean("tRUe", false)).toBe(true); + expect(parseBoolean("false", true)).toBe(false); + expect(parseBoolean("fALse", true)).toBe(false); + expect(parseBoolean("true", false)).toBe(true); + expect(parseBoolean("tRUe", false)).toBe(true); expect(mockVscode.window.getShowWarningMessageCalls().length).toBe(0); - expect(CommandHandler.parseBoolean(42, true)).toBe(true); - expect(CommandHandler.parseBoolean(42, false)).toBe(false); + expect(parseBoolean(42, true)).toBe(true); + expect(parseBoolean(42, false)).toBe(false); expect(mockVscode.window.getShowWarningMessageCalls().length).toBe(2); }); diff --git a/src/lib/CommandHandler.ts b/src/lib/CommandHandler.ts index aa3a514..2113c83 100755 --- a/src/lib/CommandHandler.ts +++ b/src/lib/CommandHandler.ts @@ -5,6 +5,7 @@ import { VariableResolver, Input } from "./VariableResolver"; import { ShellCommandException } from "../util/exceptions"; import { UserInputContext } from "./UserInputContext"; import { QuickPickItem } from "./QuickPickItem"; +import { parseBoolean } from "./options"; function promisify(fn: (...args: [...T, (err: Error | null, stdout: string, stderr: string) => void]) => R) { return (...args: [...T]) => @@ -67,40 +68,23 @@ export class CommandHandler { static resolveArgs(args: { [key: string]: unknown }): ShellCommandOptions { return { - useFirstResult: CommandHandler.parseBoolean(args.useFirstResult, false), - useSingleResult: CommandHandler.parseBoolean(args.useSingleResult, false), - rememberPrevious: CommandHandler.parseBoolean(args.rememberPrevious, false), - allowCustomValues: CommandHandler.parseBoolean(args.allowCustomValues, false), - warnOnStderr: CommandHandler.parseBoolean(args.warnOnStderr, true), - multiselect: CommandHandler.parseBoolean(args.multiselect, false), + useFirstResult: parseBoolean(args.useFirstResult, false), + useSingleResult: parseBoolean(args.useSingleResult, false), + rememberPrevious: parseBoolean(args.rememberPrevious, false), + allowCustomValues: parseBoolean(args.allowCustomValues, false), + warnOnStderr: parseBoolean(args.warnOnStderr, true), + multiselect: parseBoolean(args.multiselect, false), multiselectSeparator: args.multiselectSeparator ?? " ", stdio: ["stdout", "stderr", "both"].includes(args.stdio as string) ? args.stdio : "stdout", ...args, } as ShellCommandOptions; } - static parseBoolean(value: unknown, defaultValue: boolean): boolean { - if (value === undefined) { - return defaultValue; - } - if (typeof value === 'boolean') { - return value; - } - if (typeof value === 'string') { - if (value.toLowerCase() === 'true') { - return true; - } else if (value.toLowerCase() === 'false') { - return false; - } - } - vscode.window.showWarningMessage(`Cannot parse the boolean value: ${value}, use the default: ${defaultValue}`); - return defaultValue; - } - protected async resolveArgs() { const resolver = new VariableResolver( this.input, this.userInputContext, + this.context, this.getDefault().join(this.args.multiselectSeparator)); const command = await resolver.resolve(this.command); diff --git a/src/lib/VariableResolver.test.ts b/src/lib/VariableResolver.test.ts index a43decd..a569598 100644 --- a/src/lib/VariableResolver.test.ts +++ b/src/lib/VariableResolver.test.ts @@ -1,5 +1,6 @@ import * as path from "path"; +import * as vscode from "vscode"; import { expect, test } from 'vitest'; import { VariableResolver } from './VariableResolver'; @@ -15,7 +16,13 @@ test('variable types', async () => { const input = {...tasksJson.inputs[0], workspaceIndex: 0}; const rememberedValue = "Back in my day"; const userInputContext = new UserInputContext(); - const resolver = new VariableResolver(input, userInputContext, rememberedValue); + const context = {} as unknown as vscode.ExtensionContext; + const resolver = new VariableResolver( + input, + userInputContext, + context, + rememberedValue, + ); for (const [key, value] of Object.entries({ "workspaceFolder": workspaceFolder, diff --git a/src/lib/VariableResolver.ts b/src/lib/VariableResolver.ts index 2016c37..8528394 100644 --- a/src/lib/VariableResolver.ts +++ b/src/lib/VariableResolver.ts @@ -1,6 +1,9 @@ import * as vscode from 'vscode'; import * as path from 'path'; +import * as querystring from 'querystring'; + import { UserInputContext } from './UserInputContext'; +import { parseBoolean } from './options'; export type Input = { command: "shellCommand.execute"; @@ -17,6 +20,25 @@ export type Input = { env: Record; } +type PromptOptions = { + rememberPrevious: boolean; + prompt?: string; +} + +function zip(a: A[], b: B[]): [A, B][] { + return a.map(function(e, i) { + return [e, b[i]]; + }); +} + +function resolvePromptOptions(queryString: string): PromptOptions { + const parsed = querystring.decode(queryString); + return { + prompt: (typeof parsed.prompt === "string") ? parsed.prompt : undefined, + rememberPrevious: parseBoolean(parsed.rememberPrevious, true), + }; +} + export class VariableResolver { protected expressionRegex = /\$\{(.*?)\}/gm; protected workspaceIndexedRegex = /workspaceFolder\[(\d+)\]/gm; @@ -26,24 +48,33 @@ export class VariableResolver { protected inputVarRegex = /input:(.+)/m; protected taskIdVarRegex = /taskId:(.+)/m; protected commandVarRegex = /command:(.+)/m; + protected promptVarRegex = /prompt(?::(.*)|)/m; protected rememberedValue?: string; + protected context: vscode.ExtensionContext; protected userInputContext: UserInputContext; protected input: Input; constructor(input: Input, userInputContext: UserInputContext, + context: vscode.ExtensionContext, rememberedValue?: string) { this.userInputContext = userInputContext; this.rememberedValue = rememberedValue; this.input = input; + this.context = context; } async resolve(str: string): Promise { - const promises: Promise[] = []; + // Sort by index (descending) + // The indices will change once we start substituting the replacements, + // which are not necessarily the same length + const matches = [...str.matchAll(this.expressionRegex)] + .sort((a, b) => b.index - a.index); // Process the synchronous string interpolations - let result = str.replace( - this.expressionRegex, - (_: string, value: string): string => { + const data = await Promise.all(matches.map( + (match: RegExpExecArray): string | Thenable => { + const value = match[1]; + if (this.workspaceIndexedRegex.test(value)) { return this.bindIndexedFolder(value); } @@ -59,27 +90,36 @@ export class VariableResolver { const inputVar = this.inputVarRegex.exec(value); if (inputVar) { - return this.userInputContext.lookupInputValueByInputId(inputVar[1]) ?? ''; + return this.userInputContext + .lookupInputValueByInputId(inputVar[1]) ?? ''; } const taskIdVar = this.taskIdVarRegex.exec(value); if (taskIdVar) { - return this.userInputContext.lookupInputValueByTaskId(taskIdVar[1]) ?? ''; + return this.userInputContext + .lookupInputValueByTaskId(taskIdVar[1]) ?? ''; } if (this.commandVarRegex.test(value)) { - // We don't replace these yet, they have to be done asynchronously - promises.push(this.bindCommandVariable(value)); - return _; + return this.bindCommandVariable(value); + } + + const promptVar = this.promptVarRegex.exec(value); + if (promptVar) { + return this.bindPrompt( + resolvePromptOptions(promptVar[1]), match); } + return this.bindConfiguration(value); }, - ); + )); - // Process the async string interpolations - const data = await Promise.all(promises) as string[]; - result = result.replace(this.expressionRegex, () => data.shift() ?? ''); - return result === '' ? undefined : result; + const result = zip(matches, data).reduce((str, [match, replacement]) => { + return str.slice(0, match.index) + replacement + + str.slice(match.index + match[0].length); + }, str); + + return result; } protected async bindCommandVariable(value: string): Promise { @@ -93,6 +133,25 @@ export class VariableResolver { return result as string; } + protected async bindPrompt( + promptOptions: PromptOptions, + match: RegExpExecArray, + ): Promise { + const taskId = this.input.args.taskId ?? this.input.id; + const promptId = `prompt/${taskId}_${match.index}`; + const prevValue = this.context.workspaceState.get(promptId, ''); + const initialValue = promptOptions.rememberPrevious ? prevValue : ''; + + const result = (await vscode.window.showInputBox({ + value: initialValue, + prompt: promptOptions.prompt, + })) ?? ''; + + this.context.workspaceState.update(promptId, result); + + return result; + } + protected bindIndexedFolder(value: string): string { return value.replace( this.workspaceIndexedRegex, diff --git a/src/lib/options.ts b/src/lib/options.ts new file mode 100644 index 0000000..2e133ce --- /dev/null +++ b/src/lib/options.ts @@ -0,0 +1,19 @@ +import * as vscode from "vscode"; + +export function parseBoolean(value: unknown, defaultValue: boolean): boolean { + if (value === undefined) { + return defaultValue; + } + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'string') { + if (value.toLowerCase() === 'true') { + return true; + } else if (value.toLowerCase() === 'false') { + return false; + } + } + vscode.window.showWarningMessage(`Cannot parse the boolean value: ${value}, use the default: ${defaultValue}`); + return defaultValue; +}