Skip to content

Commit

Permalink
Merge pull request #129 from augustocdias/issue_123
Browse files Browse the repository at this point in the history
Add prompt variable resolver
  • Loading branch information
MarcelRobitaille authored Jan 3, 2025
2 parents 016b96a + 60f2e8a commit a898b76
Show file tree
Hide file tree
Showing 10 changed files with 132 additions and 52 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || '';
Expand Down
21 changes: 11 additions & 10 deletions src/lib/CommandHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
});
Expand Down
32 changes: 8 additions & 24 deletions src/lib/CommandHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends unknown[], R>(fn: (...args: [...T, (err: Error | null, stdout: string, stderr: string) => void]) => R) {
return (...args: [...T]) =>
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 8 additions & 1 deletion src/lib/VariableResolver.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as path from "path";

import * as vscode from "vscode";
import { expect, test } from 'vitest';

import { VariableResolver } from './VariableResolver';
Expand All @@ -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,
Expand Down
87 changes: 73 additions & 14 deletions src/lib/VariableResolver.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -17,6 +20,25 @@ export type Input = {
env: Record<string, string>;
}

type PromptOptions = {
rememberPrevious: boolean;
prompt?: string;
}

function zip<A, B>(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;
Expand All @@ -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<string | undefined> {
const promises: Promise<string | undefined>[] = [];
// 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<string | undefined> => {
const value = match[1];

if (this.workspaceIndexedRegex.test(value)) {
return this.bindIndexedFolder(value);
}
Expand All @@ -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<string> {
Expand All @@ -93,6 +133,25 @@ export class VariableResolver {
return result as string;
}

protected async bindPrompt(
promptOptions: PromptOptions,
match: RegExpExecArray,
): Promise<string> {
const taskId = this.input.args.taskId ?? this.input.id;
const promptId = `prompt/${taskId}_${match.index}`;
const prevValue = this.context.workspaceState.get<string>(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,
Expand Down
19 changes: 19 additions & 0 deletions src/lib/options.ts
Original file line number Diff line number Diff line change
@@ -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;
}

0 comments on commit a898b76

Please sign in to comment.