Skip to content

Commit a898b76

Browse files
Merge pull request #129 from augustocdias/issue_123
Add prompt variable resolver
2 parents 016b96a + 60f2e8a commit a898b76

10 files changed

Lines changed: 132 additions & 52 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Change Log
22

3+
## [1.15.0] 2025-01-02
4+
5+
- Add variable resolver for ${prompt} (#123)
6+
37
## [1.14.1] 2024-12-24
48

59
- Fix bug with multiselect active vs selected (#127)

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@ VSCode renders it like this:
115115
As of today, the extension supports variable substitution for:
116116

117117
* a subset of predefined variables like `file`, `fileDirName`, `fileBasenameNoExtension`, `fileBasename`, `lineNumber`, `extension`, `workspaceFolder` and `workspaceFolderBasename`, pattern: `${variable}`
118+
* an arbitrary value via `prompt` (combine options using `&` like URL query strings):
119+
* `${prompt}` to show an input box for an arbitrary value
120+
* `${prompt:rememberPrevious=false}` to disable the default action of initializing the input box with the previous value
121+
* `${prompt:prompt=Custom prompt text}` to configure the label of the input box to show `Custom prompt text`
118122
* the remembered value (the default value when `rememberPrevious` is true), available as `${rememberedValue}`
119123
* all config variables, pattern: `${config:variable}`
120124
* all environment variables (`tasks.json` `options.env` with fallback to parent process), pattern: `${env:variable}`

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"displayName": "Tasks Shell Input",
44
"description": "Use shell commands as input for your tasks",
55
"icon": "icon.png",
6-
"version": "1.14.1",
6+
"version": "1.15.0",
77
"publisher": "augustocdias",
88
"repository": {
99
"url": "https://github.com/augustocdias/vscode-shell-command"

src/extension.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export function activate(this: any, context: vscode.ExtensionContext) {
3131

3232
// Reimplementation of promptString that can be used from inputs.
3333
const handlePromptString = async () => {
34+
vscode.window.showWarningMessage(
35+
'shellCommand.promptString is deprecated. Please use `${prompt}`.');
3436
const inputValue = await vscode.window.showInputBox();
3537

3638
return inputValue || '';

src/lib/CommandHandler.test.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { describe, expect, test, vi, beforeEach } from 'vitest';
77
import { ShellCommandOptions } from "./ShellCommandOptions";
88
import { CommandHandler } from './CommandHandler';
99
import { UserInputContext } from './UserInputContext';
10+
import { parseBoolean } from './options';
1011
import * as mockVscode from '../mocks/vscode';
1112

1213
const mockExtensionContext = {
@@ -349,21 +350,21 @@ describe("Argument parsing", () => {
349350
});
350351

351352
test("parseBoolean", () => {
352-
expect(CommandHandler.parseBoolean(undefined, true)).toBe(true);
353-
expect(CommandHandler.parseBoolean(undefined, false)).toBe(false);
353+
expect(parseBoolean(undefined, true)).toBe(true);
354+
expect(parseBoolean(undefined, false)).toBe(false);
354355

355-
expect(CommandHandler.parseBoolean(false, true)).toBe(false);
356-
expect(CommandHandler.parseBoolean(true, false)).toBe(true);
356+
expect(parseBoolean(false, true)).toBe(false);
357+
expect(parseBoolean(true, false)).toBe(true);
357358

358-
expect(CommandHandler.parseBoolean("false", true)).toBe(false);
359-
expect(CommandHandler.parseBoolean("fALse", true)).toBe(false);
360-
expect(CommandHandler.parseBoolean("true", false)).toBe(true);
361-
expect(CommandHandler.parseBoolean("tRUe", false)).toBe(true);
359+
expect(parseBoolean("false", true)).toBe(false);
360+
expect(parseBoolean("fALse", true)).toBe(false);
361+
expect(parseBoolean("true", false)).toBe(true);
362+
expect(parseBoolean("tRUe", false)).toBe(true);
362363

363364
expect(mockVscode.window.getShowWarningMessageCalls().length).toBe(0);
364365

365-
expect(CommandHandler.parseBoolean(42, true)).toBe(true);
366-
expect(CommandHandler.parseBoolean(42, false)).toBe(false);
366+
expect(parseBoolean(42, true)).toBe(true);
367+
expect(parseBoolean(42, false)).toBe(false);
367368

368369
expect(mockVscode.window.getShowWarningMessageCalls().length).toBe(2);
369370
});

src/lib/CommandHandler.ts

Lines changed: 8 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { VariableResolver, Input } from "./VariableResolver";
55
import { ShellCommandException } from "../util/exceptions";
66
import { UserInputContext } from "./UserInputContext";
77
import { QuickPickItem } from "./QuickPickItem";
8+
import { parseBoolean } from "./options";
89

910
function promisify<T extends unknown[], R>(fn: (...args: [...T, (err: Error | null, stdout: string, stderr: string) => void]) => R) {
1011
return (...args: [...T]) =>
@@ -67,40 +68,23 @@ export class CommandHandler {
6768

6869
static resolveArgs(args: { [key: string]: unknown }): ShellCommandOptions {
6970
return {
70-
useFirstResult: CommandHandler.parseBoolean(args.useFirstResult, false),
71-
useSingleResult: CommandHandler.parseBoolean(args.useSingleResult, false),
72-
rememberPrevious: CommandHandler.parseBoolean(args.rememberPrevious, false),
73-
allowCustomValues: CommandHandler.parseBoolean(args.allowCustomValues, false),
74-
warnOnStderr: CommandHandler.parseBoolean(args.warnOnStderr, true),
75-
multiselect: CommandHandler.parseBoolean(args.multiselect, false),
71+
useFirstResult: parseBoolean(args.useFirstResult, false),
72+
useSingleResult: parseBoolean(args.useSingleResult, false),
73+
rememberPrevious: parseBoolean(args.rememberPrevious, false),
74+
allowCustomValues: parseBoolean(args.allowCustomValues, false),
75+
warnOnStderr: parseBoolean(args.warnOnStderr, true),
76+
multiselect: parseBoolean(args.multiselect, false),
7677
multiselectSeparator: args.multiselectSeparator ?? " ",
7778
stdio: ["stdout", "stderr", "both"].includes(args.stdio as string) ? args.stdio : "stdout",
7879
...args,
7980
} as ShellCommandOptions;
8081
}
8182

82-
static parseBoolean(value: unknown, defaultValue: boolean): boolean {
83-
if (value === undefined) {
84-
return defaultValue;
85-
}
86-
if (typeof value === 'boolean') {
87-
return value;
88-
}
89-
if (typeof value === 'string') {
90-
if (value.toLowerCase() === 'true') {
91-
return true;
92-
} else if (value.toLowerCase() === 'false') {
93-
return false;
94-
}
95-
}
96-
vscode.window.showWarningMessage(`Cannot parse the boolean value: ${value}, use the default: ${defaultValue}`);
97-
return defaultValue;
98-
}
99-
10083
protected async resolveArgs() {
10184
const resolver = new VariableResolver(
10285
this.input,
10386
this.userInputContext,
87+
this.context,
10488
this.getDefault().join(this.args.multiselectSeparator));
10589

10690
const command = await resolver.resolve(this.command);

src/lib/VariableResolver.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as path from "path";
22

3+
import * as vscode from "vscode";
34
import { expect, test } from 'vitest';
45

56
import { VariableResolver } from './VariableResolver';
@@ -15,7 +16,13 @@ test('variable types', async () => {
1516
const input = {...tasksJson.inputs[0], workspaceIndex: 0};
1617
const rememberedValue = "Back in my day";
1718
const userInputContext = new UserInputContext();
18-
const resolver = new VariableResolver(input, userInputContext, rememberedValue);
19+
const context = {} as unknown as vscode.ExtensionContext;
20+
const resolver = new VariableResolver(
21+
input,
22+
userInputContext,
23+
context,
24+
rememberedValue,
25+
);
1926

2027
for (const [key, value] of Object.entries({
2128
"workspaceFolder": workspaceFolder,

src/lib/VariableResolver.ts

Lines changed: 73 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import * as vscode from 'vscode';
22
import * as path from 'path';
3+
import * as querystring from 'querystring';
4+
35
import { UserInputContext } from './UserInputContext';
6+
import { parseBoolean } from './options';
47

58
export type Input = {
69
command: "shellCommand.execute";
@@ -17,6 +20,25 @@ export type Input = {
1720
env: Record<string, string>;
1821
}
1922

23+
type PromptOptions = {
24+
rememberPrevious: boolean;
25+
prompt?: string;
26+
}
27+
28+
function zip<A, B>(a: A[], b: B[]): [A, B][] {
29+
return a.map(function(e, i) {
30+
return [e, b[i]];
31+
});
32+
}
33+
34+
function resolvePromptOptions(queryString: string): PromptOptions {
35+
const parsed = querystring.decode(queryString);
36+
return {
37+
prompt: (typeof parsed.prompt === "string") ? parsed.prompt : undefined,
38+
rememberPrevious: parseBoolean(parsed.rememberPrevious, true),
39+
};
40+
}
41+
2042
export class VariableResolver {
2143
protected expressionRegex = /\$\{(.*?)\}/gm;
2244
protected workspaceIndexedRegex = /workspaceFolder\[(\d+)\]/gm;
@@ -26,24 +48,33 @@ export class VariableResolver {
2648
protected inputVarRegex = /input:(.+)/m;
2749
protected taskIdVarRegex = /taskId:(.+)/m;
2850
protected commandVarRegex = /command:(.+)/m;
51+
protected promptVarRegex = /prompt(?::(.*)|)/m;
2952
protected rememberedValue?: string;
53+
protected context: vscode.ExtensionContext;
3054
protected userInputContext: UserInputContext;
3155
protected input: Input;
3256

3357
constructor(input: Input, userInputContext: UserInputContext,
58+
context: vscode.ExtensionContext,
3459
rememberedValue?: string) {
3560
this.userInputContext = userInputContext;
3661
this.rememberedValue = rememberedValue;
3762
this.input = input;
63+
this.context = context;
3864
}
3965

4066
async resolve(str: string): Promise<string | undefined> {
41-
const promises: Promise<string | undefined>[] = [];
67+
// Sort by index (descending)
68+
// The indices will change once we start substituting the replacements,
69+
// which are not necessarily the same length
70+
const matches = [...str.matchAll(this.expressionRegex)]
71+
.sort((a, b) => b.index - a.index);
4272

4373
// Process the synchronous string interpolations
44-
let result = str.replace(
45-
this.expressionRegex,
46-
(_: string, value: string): string => {
74+
const data = await Promise.all(matches.map(
75+
(match: RegExpExecArray): string | Thenable<string | undefined> => {
76+
const value = match[1];
77+
4778
if (this.workspaceIndexedRegex.test(value)) {
4879
return this.bindIndexedFolder(value);
4980
}
@@ -59,27 +90,36 @@ export class VariableResolver {
5990

6091
const inputVar = this.inputVarRegex.exec(value);
6192
if (inputVar) {
62-
return this.userInputContext.lookupInputValueByInputId(inputVar[1]) ?? '';
93+
return this.userInputContext
94+
.lookupInputValueByInputId(inputVar[1]) ?? '';
6395
}
6496

6597
const taskIdVar = this.taskIdVarRegex.exec(value);
6698
if (taskIdVar) {
67-
return this.userInputContext.lookupInputValueByTaskId(taskIdVar[1]) ?? '';
99+
return this.userInputContext
100+
.lookupInputValueByTaskId(taskIdVar[1]) ?? '';
68101
}
69102

70103
if (this.commandVarRegex.test(value)) {
71-
// We don't replace these yet, they have to be done asynchronously
72-
promises.push(this.bindCommandVariable(value));
73-
return _;
104+
return this.bindCommandVariable(value);
105+
}
106+
107+
const promptVar = this.promptVarRegex.exec(value);
108+
if (promptVar) {
109+
return this.bindPrompt(
110+
resolvePromptOptions(promptVar[1]), match);
74111
}
112+
75113
return this.bindConfiguration(value);
76114
},
77-
);
115+
));
78116

79-
// Process the async string interpolations
80-
const data = await Promise.all(promises) as string[];
81-
result = result.replace(this.expressionRegex, () => data.shift() ?? '');
82-
return result === '' ? undefined : result;
117+
const result = zip(matches, data).reduce((str, [match, replacement]) => {
118+
return str.slice(0, match.index) + replacement +
119+
str.slice(match.index + match[0].length);
120+
}, str);
121+
122+
return result;
83123
}
84124

85125
protected async bindCommandVariable(value: string): Promise<string> {
@@ -93,6 +133,25 @@ export class VariableResolver {
93133
return result as string;
94134
}
95135

136+
protected async bindPrompt(
137+
promptOptions: PromptOptions,
138+
match: RegExpExecArray,
139+
): Promise<string> {
140+
const taskId = this.input.args.taskId ?? this.input.id;
141+
const promptId = `prompt/${taskId}_${match.index}`;
142+
const prevValue = this.context.workspaceState.get<string>(promptId, '');
143+
const initialValue = promptOptions.rememberPrevious ? prevValue : '';
144+
145+
const result = (await vscode.window.showInputBox({
146+
value: initialValue,
147+
prompt: promptOptions.prompt,
148+
})) ?? '';
149+
150+
this.context.workspaceState.update(promptId, result);
151+
152+
return result;
153+
}
154+
96155
protected bindIndexedFolder(value: string): string {
97156
return value.replace(
98157
this.workspaceIndexedRegex,

src/lib/options.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import * as vscode from "vscode";
2+
3+
export function parseBoolean(value: unknown, defaultValue: boolean): boolean {
4+
if (value === undefined) {
5+
return defaultValue;
6+
}
7+
if (typeof value === 'boolean') {
8+
return value;
9+
}
10+
if (typeof value === 'string') {
11+
if (value.toLowerCase() === 'true') {
12+
return true;
13+
} else if (value.toLowerCase() === 'false') {
14+
return false;
15+
}
16+
}
17+
vscode.window.showWarningMessage(`Cannot parse the boolean value: ${value}, use the default: ${defaultValue}`);
18+
return defaultValue;
19+
}

0 commit comments

Comments
 (0)