diff --git a/README.md b/README.md index d161211..99935ec 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,8 @@ The configuration has the following syntax: "bluebazel.shellCommands": [ { "name": "myCommand1", - "command": "A shell command" + "command": "A shell command", + "memoized": false } ] ``` @@ -100,15 +101,23 @@ Example: "bluebazel.shellCommands": [ { "name": "myEcho", - "command": "echo this is my custom shell command" + "command": "echo this is my custom shell command", + "memoized": true, }, { "name": "myEchoEcho", "command": "echo " // This will execute the command `myEcho` and echo the result. + }, + { + "name": "myDecoaratedEcho", + "command": "echo \"Echo was: \"" } ] ``` +In the above example, the command `myEcho` will only be evaluated once, then memoized and reused in both +`myEchoMyEcho` and `myDecoratedEcho`. + ### Custom Button configuration Custom buttons configuration allows the user to add sections and buttons to the bazel view container, and link @@ -188,6 +197,7 @@ Here are additional keywords to receive user input at the time of the execution: 1. [Pick(`arg`)]: This shows an item list for the user to choose one from. `arg` must be a command that returns multiline string where each line corresponds to an item. 2. [Input()]: This receives a plain string input from the user. +3. [MultiPick(`arg`)]: Similar to `Pick`, this shows an item list for the user to choose one or more from. Where as `Pick` returns a single selection, `MultiPick` returns all selections, one per line (output is one selection per line, separated by a "\n"). ### A complete example @@ -206,6 +216,18 @@ This example illustrates the `Test` button: "methodName": "bluebazel.test" } ] + }, + { + "title": "Run PyTest", + "buttons": [ + { + "title": "Run Selected Test Cases", + "command": "bazel test --build_tests_only --test_timeout=1500 ", + "description": "Select and run specific test case(s)", + "tooltip": "Select and run specific test case(s) with `bazel test`", + "methodName": "bluebazel.runPyTestCase" + } + ] } ], "bluebazel.shellCommands": [ @@ -216,6 +238,27 @@ This example illustrates the `Test` button: { "name": "testTarget", "command": "bash -c 'source scripts/envsetup.sh > /dev/null && bazel query \"tests()\"" + }, + { + "name": "testTargetAsLabel", + "command": "echo -n ${bluebazel.testTarget} | perl -pe 's|^bazel-bin(/.*?)/([^/]+)$|/$1:$2|'" + }, + { + "name": "confirmedTestTargetAsLabel", + "command": "echo -n \"[Input()]\"", + "memoized": true + }, + { + "name": "testCaseListHelper", + "command": "bazel run --ui_event_filters=-info,-stdout,-stderr --noshow_progress -- --collect-only -qq --no-cov --disable-warnings --color=no | grep -E '(.*?/).+::.+'", + }, + { + "name": "confirmExtraTestArgs", + "command": "echo -n \"[Input(--test_arg=\\\"--no-cov\\\")]\"", + }, + { + "name": "testCaseTestArgList", + "command": "echo -n \"[MultiPick()]\" | awk '{print \"--test_arg=\\\"\"$1\"\\\"\"}' | tr \"\n\" \" \"", } ] ``` @@ -225,9 +268,19 @@ modifies it to return something in the form of `//path/...`. `testTarget` gives this input to `bazel query` to return all available tests in this path. -Finally, the button `Test` uses the output of `testTarget` to display the user the list of tests to choose from, +`testTargetAsLabel` formats the selected test target bazel path as a label. + +`testCaseTestArgList` takes a `MultiSelect` output and formats it as a series of `--test_arg` options +passed to the Bazel test command. + +The button `Test` uses the output of `testTarget` to display the user the list of tests to choose from, and executes the test using the current configs and run arguments. +Finally, the button `Run Selected Test Cases` uses the output of `testCaseTestArgList` to display the user the +list of PyTest-managed Python test cases corresponding to the current `testTarget`. `testTarget` +is assumed to support both the Bazel test and run execution phases and that it conforms to the PyTest +command line program interface. + ## Releases See [Release Notes](ReleaseNotes.md) diff --git a/ReleaseNotes.md b/ReleaseNotes.md index a665bdc..a2cc4f1 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -66,3 +66,8 @@ Clear the workspace state only on major version changes. Add unit tests for the bazel parser and service. Fix query of targets to be more accurate. Fix argument passing when debugging with C/C++ and GDB. + +## 1.1.0 + +Support MultiPick quickpik extension. +Support memoizing shellCommand outputs for repeated use in a button command. diff --git a/package-lock.json b/package-lock.json index 4884e1b..942e1b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bluebazel", - "version": "1.0.5", + "version": "1.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "bluebazel", - "version": "1.0.5", + "version": "1.1.0", "dependencies": { "@types/tmp": "^0.2.0", "atob": "^2.1.2", diff --git a/package.json b/package.json index 476c54f..c2ded56 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "bluebazel", "displayName": "Blue Bazel", "description": "Bazel vscode UI integration to build, debug, and test targets", - "version": "1.0.5", + "version": "1.1.0", "engines": { "vscode": "^1.85.2" }, diff --git a/src/controllers/user-commands-controller.ts b/src/controllers/user-commands-controller.ts index 0d76bbf..82b88b3 100644 --- a/src/controllers/user-commands-controller.ts +++ b/src/controllers/user-commands-controller.ts @@ -22,8 +22,9 @@ // SOFTWARE. //////////////////////////////////////////////////////////////////////////////////// import { BazelTargetManager } from '../models/bazel-target-manager'; +import { WorkspaceStateManager } from '../models/workspace-state-manager'; import { BazelService } from '../services/bazel-service'; -import { ConfigurationManager } from '../services/configuration-manager'; +import { ConfigurationManager, ShellCommand } from '../services/configuration-manager'; import { ShellService } from '../services/shell-service'; import { TaskService } from '../services/task-service'; import { showProgress } from '../ui/progress'; @@ -33,6 +34,11 @@ import * as vscode from 'vscode'; /** * Controller class for executing user custom commands. */ + +type PickStateRecord = Record; + +type PickStateMap = Record; + export class UserCommandsController { private static CONFIG_KEYWORDS = { @@ -55,6 +61,7 @@ export class UserCommandsController { }; private static EXTENSION_COMMANDS = { + multipick: 'MultiPick', pick: 'Pick', input: 'Input' }; @@ -63,18 +70,26 @@ export class UserCommandsController { private readonly configurationManager: ConfigurationManager, private readonly shellService: ShellService, // Inject the services private readonly taskService: TaskService, - private readonly bazelTargetManager: BazelTargetManager - ) { } + private readonly bazelTargetManager: BazelTargetManager, + private readonly workspaceStateManager: WorkspaceStateManager + ) { + } + + private static PICK_STATE_KEY: string = "userCommandsController.pickStateMap"; public async runCustomTask(command: string): Promise { - let completeCommand = this.resolveKeywords(command); + const pickStateMap: PickStateMap = this.workspaceStateManager.get(UserCommandsController.PICK_STATE_KEY, {}); + const resolver = new this.Resolver(this.bazelTargetManager, this.configurationManager, this.shellService, pickStateMap); + let completeCommand = resolver.resolveKeywords(command); return showProgress(`Running ${completeCommand}`, async (cancellationToken) => { try { - completeCommand = await this.resolveExtensionCommands(completeCommand); - completeCommand = await this.resolveCommands(completeCommand); + completeCommand = await resolver.resolveExtensionCommands(completeCommand); + completeCommand = await resolver.resolveCommands(completeCommand); this.taskService.runTask(completeCommand, completeCommand, this.configurationManager.isClearTerminalBeforeAction(), cancellationToken); } catch (error) { vscode.window.showErrorMessage(`Error running custom task: ${error}`); + } finally { + this.workspaceStateManager.update(UserCommandsController.PICK_STATE_KEY, pickStateMap); } }); } @@ -86,139 +101,199 @@ export class UserCommandsController { return result; } - private resolveKeyword(keyword: string): string { - const buildTarget = this.bazelTargetManager.getSelectedTarget('build'); - const runTarget = this.bazelTargetManager.getSelectedTarget('run'); - const testTarget = this.bazelTargetManager.getSelectedTarget('test'); - - - const keywordMap: Map string> = new Map([ - [UserCommandsController.CONFIG_KEYWORDS.runArgs, () => runTarget.getRunArgs().toString()], - [UserCommandsController.CONFIG_KEYWORDS.testArgs, () => UserCommandsController.formatTestArgs(testTarget.getRunArgs().toString())], - [UserCommandsController.CONFIG_KEYWORDS.runTarget, () => { - return BazelService.formatBazelTargetFromPath(runTarget.buildPath); - }], - [UserCommandsController.CONFIG_KEYWORDS.testTarget, () => testTarget.buildPath], - [UserCommandsController.CONFIG_KEYWORDS.buildConfigs, () => buildTarget.getConfigArgs().toString()], - [UserCommandsController.CONFIG_KEYWORDS.runConfigs, () => runTarget.getConfigArgs().toString()], - [UserCommandsController.CONFIG_KEYWORDS.testConfigs, () => testTarget.getConfigArgs().toString()], - [UserCommandsController.CONFIG_KEYWORDS.bazelBuildArgs, () => buildTarget.getBazelArgs().toString()], - [UserCommandsController.CONFIG_KEYWORDS.bazelRunArgs, () => runTarget.getBazelArgs().toString()], - [UserCommandsController.CONFIG_KEYWORDS.bazelTestArgs, () => testTarget.getBazelArgs().toString()], - [UserCommandsController.CONFIG_KEYWORDS.buildEnvVars, () => buildTarget.getEnvVars().toStringArray().join(' ')], - [UserCommandsController.CONFIG_KEYWORDS.runEnvVars, () => runTarget.getEnvVars().toStringArray().join(' ')], - [UserCommandsController.CONFIG_KEYWORDS.testEnvVars, () => buildTarget.getEnvVars().toStringArray().join(' ')], - [UserCommandsController.CONFIG_KEYWORDS.buildTarget, () => testTarget.buildPath], - [UserCommandsController.CONFIG_KEYWORDS.executable, () => this.configurationManager.getExecutableCommand()], - [UserCommandsController.CONFIG_KEYWORDS.formatCommand, () => this.configurationManager.getFormatCommand()], - ]); - - const getValue = keywordMap.get(keyword); - return getValue ? getValue() : `\${${keyword}}`; + private Resolver = class { + public cache: Map = new Map(); - } + constructor( + private bazelTargetManager: BazelTargetManager, + private configurationManager: ConfigurationManager, + private shellService: ShellService, + private pickStateMap: PickStateMap + ) { } + + protected resolveKeyword(keyword: string): string { + const buildTarget = this.bazelTargetManager.getSelectedTarget('build'); + const runTarget = this.bazelTargetManager.getSelectedTarget('run'); + const testTarget = this.bazelTargetManager.getSelectedTarget('test'); - private async extPick(input: string): Promise { - try { + const keywordMap: Map string> = new Map([ + [UserCommandsController.CONFIG_KEYWORDS.runArgs, () => runTarget.getRunArgs().toString()], + [UserCommandsController.CONFIG_KEYWORDS.testArgs, () => UserCommandsController.formatTestArgs(testTarget.getRunArgs().toString())], + [UserCommandsController.CONFIG_KEYWORDS.runTarget, () => { + return BazelService.formatBazelTargetFromPath(runTarget.buildPath); + }], + [UserCommandsController.CONFIG_KEYWORDS.testTarget, () => testTarget.buildPath], + [UserCommandsController.CONFIG_KEYWORDS.buildConfigs, () => buildTarget.getConfigArgs().toString()], + [UserCommandsController.CONFIG_KEYWORDS.runConfigs, () => runTarget.getConfigArgs().toString()], + [UserCommandsController.CONFIG_KEYWORDS.testConfigs, () => testTarget.getConfigArgs().toString()], + [UserCommandsController.CONFIG_KEYWORDS.bazelBuildArgs, () => buildTarget.getBazelArgs().toString()], + [UserCommandsController.CONFIG_KEYWORDS.bazelRunArgs, () => runTarget.getBazelArgs().toString()], + [UserCommandsController.CONFIG_KEYWORDS.bazelTestArgs, () => testTarget.getBazelArgs().toString()], + [UserCommandsController.CONFIG_KEYWORDS.buildEnvVars, () => buildTarget.getEnvVars().toStringArray().join(' ')], + [UserCommandsController.CONFIG_KEYWORDS.runEnvVars, () => runTarget.getEnvVars().toStringArray().join(' ')], + [UserCommandsController.CONFIG_KEYWORDS.testEnvVars, () => buildTarget.getEnvVars().toStringArray().join(' ')], + [UserCommandsController.CONFIG_KEYWORDS.buildTarget, () => testTarget.buildPath], + [UserCommandsController.CONFIG_KEYWORDS.executable, () => this.configurationManager.getExecutableCommand()], + [UserCommandsController.CONFIG_KEYWORDS.formatCommand, () => this.configurationManager.getFormatCommand()], + ]); + + const getValue = keywordMap.get(keyword); + return getValue ? getValue() : `\${${keyword}}`; + } + + private async buildPickList(input: string, picker: (label: string) => boolean): Promise { // Evaluate the inner command of the pick const output = await this.resolveCommands(input); // Make a list of the output const outputList = []; for (const element of output.split('\n')) { - const elementTrimmed = element.trim(); - if (elementTrimmed.length > 0) outputList.push(elementTrimmed); + const label = element.trim(); + if (label.length > 0) { + outputList.push({ 'label': label, 'picked': picker(label) }); + } + } + return outputList; + } + + private async extPick(input: vscode.QuickPickItem[]): Promise { + try { + return vscode.window.showQuickPick( + input, + { 'ignoreFocusOut': true } + ).then((data) => { + return data !== undefined ? data.label : '' + }); + } catch (error) { + return Promise.reject(error); } + } - return vscode.window.showQuickPick(outputList, { 'ignoreFocusOut': true }).then(data => { - return data !== undefined ? data : '' - }); - } catch (error) { - return Promise.reject(error); + private async extPickMany(input: vscode.QuickPickItem[]): Promise { + try { + return vscode.window.showQuickPick( + input, + { 'ignoreFocusOut': true, 'canPickMany': true } + ).then((data) => { + return data !== undefined ? data : [] + }).then((data) => { + return data.map((item) => item.label); + }); + } catch (error) { + return Promise.reject(error); + } + } + + private async extInput(input: string): Promise { + try { + let resolvedInput = await this.resolveCommands(input); + if (resolvedInput !== undefined) { + // we take the first result if this is a multi-line string + resolvedInput = resolvedInput.split('\n')[0]; + } + return vscode.window.showInputBox( + { value: resolvedInput } + ).then((val) => { + return val !== undefined ? val : '' + }); + } catch (error) { + return Promise.reject(error); + } + } + + public async resolveExtensionCommands(input: string): Promise { + // Execute commands + let output = input; + const regexp = /\[([^\s]*)\(([^\s]*)\)\]/g; + let match; + try { + do { + match = regexp.exec(input); + if (match) { + const extCommand = match[1]; + const extArgs = match[2]; + let evalRes = ''; + if (extCommand === UserCommandsController.EXTENSION_COMMANDS.multipick) { + let state = this.pickStateMap[output]; + const input = await this.buildPickList(extArgs, (label) => state[label] ?? false); + const labels = await this.extPickMany(input); + evalRes = labels.join("\n"); + state = {}; + labels.forEach((label) => state[label] = true); + this.pickStateMap[output] = state; + } else if (extCommand === UserCommandsController.EXTENSION_COMMANDS.pick) { + const input = await this.buildPickList(extArgs, (label) => false); + evalRes = await this.extPick(input); + } else if (extCommand === UserCommandsController.EXTENSION_COMMANDS.input) { + evalRes = await this.extInput(extArgs); + } + output = output.replace(match[0], evalRes); + } + } while (match); + } catch (error) { + return Promise.reject(error); + } + return output; } - } - private async resolveExtensionCommands(input: string): Promise { - // Execute commands - let output = input; - const regexp = /\[([^\s]*)\(([^\s]*)\)\]/g; - let match; - try { + public resolveKeywords(input: string): string { + let output = input; + // First replace keywords + const regexp = /\$\{([^\s]*)\}/g; + let match; do { match = regexp.exec(input); if (match) { - const extCommand = match[1]; - const extArgs = match[2]; - let evalRes = ''; - if (extCommand === UserCommandsController.EXTENSION_COMMANDS.pick) { - evalRes = await this.extPick(extArgs); - } else if (extCommand === UserCommandsController.EXTENSION_COMMANDS.input) { - await vscode.window.showInputBox( - { value: extArgs } - ).then((val) => { - if (val !== undefined) { - evalRes = val; - } - }); - } - output = output.replace(match[0], evalRes); + output = output.replace(match[0], this.resolveKeyword(match[1])); } } while (match); - } catch (error) { - return Promise.reject(error); + return output; } - return output; - } - private resolveKeywords(input: string): string { - let output = input; - // First replace keywords - const regexp = /\$\{([^\s]*)\}/g; - let match; - do { - match = regexp.exec(input); - if (match) { - output = output.replace(match[0], this.resolveKeyword(match[1])); - } - } while (match); - return output; - } + private findCommandByKeyword(keyword: string): ShellCommand | undefined { + const commands = this.configurationManager.getShellCommands(); + return commands.find((item) => item.name == keyword); + } - private async resolveCommands(input: string): Promise { - // Execute commands - let output = input; - const regexp = /<([^\s]*)>/g; - let match; - do { - match = regexp.exec(input); - if (match) { - try { - const currentCommand = await this.resolveCommandByKeyword(match[1]); - const evalRes = await this.shellService.runShellCommand(currentCommand); - - output = output.replace(match[0], evalRes.stdout); - } catch (error) { - return Promise.reject(error); - } - } - } while (match); - return output; - } + public async resolveCommands(input: string): Promise { + // Execute commands + let output = input; + const regexp = /<([^\s]*)>/g; + let match; + do { + match = regexp.exec(input); + if (match) { + try { + const cmd = this.findCommandByKeyword(match[1]); + let evalRes = ''; + if (cmd !== undefined) { + if (cmd.memoized && this.cache.has(cmd.name)) { + evalRes = this.cache.get(cmd.name) ?? ''; + } else { + const resolvedCmd = await this.resolveCommand(cmd.command); + const cmdRes = await this.shellService.runShellCommand(resolvedCmd); + evalRes = cmdRes.stdout; + this.cache.set(cmd.name, evalRes); + } + } - private async resolveCommandByKeyword(keyword: string): Promise { - const commands = this.configurationManager.getShellCommands(); - let res = ''; - - for (const element of commands) { - if (keyword === element.name) { - res = this.resolveKeywords(element.command); - try { - res = await this.resolveExtensionCommands(res); - res = await this.resolveCommands(res); - } catch (error) { - return Promise.reject(error); + output = output.replace(match[0], evalRes); + } catch (error) { + return Promise.reject(error); + } } + } while (match); + return output; + } + + private async resolveCommand(command: string): Promise { + try { + let res = this.resolveKeywords(command); + res = await this.resolveExtensionCommands(res); + res = await this.resolveCommands(res); + return res; + } catch (error) { + return Promise.reject(error); } } - return res; } -} \ No newline at end of file +} diff --git a/src/extension.ts b/src/extension.ts index 3204ed1..305e14c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -211,7 +211,7 @@ async function initExtension(context: vscode.ExtensionContext) { attachTreeDataProviderToView(context, bazelController, bazelTargetTreeProvider); // The user commands controller runs user dynamic tasks added through configuration settings. - userCommandsController = new UserCommandsController(configurationManager, shellService, taskService, bazelTargetManager); + userCommandsController = new UserCommandsController(configurationManager, shellService, taskService, bazelTargetManager, workspaceStateManager); // The workspace events controller monitors for workspace events and triggers appropriate logic // when those events fire. diff --git a/src/services/configuration-manager.ts b/src/services/configuration-manager.ts index 401dc7c..0605ce2 100644 --- a/src/services/configuration-manager.ts +++ b/src/services/configuration-manager.ts @@ -85,9 +85,26 @@ export class UserCustomCategory { } } -export interface ShellCommand { - 'name': string, - 'command': string +export class ShellCommand { + name: string; + command: string; + memoized: boolean; + public readonly id: string; + + constructor(data: { 'name': string, 'command': string, 'memoized': boolean }) { + this.name = data.name; + this.command = data.command; + this.memoized = data.memoized ?? false; + this.id = this.name; + } + + toJSON(): { 'name': string, 'command': string, 'memoized': boolean } { + return { + 'name': this.name, + 'command': this.command, + 'memoized': this.memoized + }; + } } export class ConfigurationManager { @@ -121,11 +138,11 @@ export class ConfigurationManager { } public getShellCommands(): Array { - const result = this.getConfig().get>('shellCommands'); - if (result !== undefined) { - return result; + const shellCommands = this.getConfig().get>('shellCommands'); + if (shellCommands === undefined) { + return []; } - return []; + return shellCommands.map(commandData => new ShellCommand(commandData)); } @@ -246,4 +263,4 @@ export class ConfigurationManager { return res; } -} \ No newline at end of file +}