diff --git a/src/bazel/bazel_quickpick.ts b/src/bazel/bazel_quickpick.ts index cf8d3acc..90ddc359 100644 --- a/src/bazel/bazel_quickpick.ts +++ b/src/bazel/bazel_quickpick.ts @@ -292,3 +292,64 @@ export function showDynamicQuickPick({ }); }); } + +/** Maximum length for target display names before truncation */ +const MAX_TARGET_DISPLAY_LENGTH = 80; + +/** + * Creates a formatted display name for a target with proper truncation + */ +function formatTargetDisplayName( + target: string, + maxLabelLength: number = MAX_TARGET_DISPLAY_LENGTH, +): string { + const shortName = target.includes(":") ? target.split(":")[1] : target; + // Truncate from the beginning if the name is too long (keep the end visible) + return shortName.length > maxLabelLength + ? "..." + shortName.slice(-(maxLabelLength - 3)) + : shortName; +} + +/** + * Creates QuickPick items for targets with consistent formatting + */ +export function createTargetQuickPickItems(targets: string[]): { + label: string; + description: string; + target: string; +}[] { + return targets.map((target) => ({ + label: formatTargetDisplayName(target), + description: target, // Full target path as description + target, + })); +} + +/** + * Shows a QuickPick for multiple targets and returns the selected target + * @param targets Array of target strings to choose from + * @param commandName Name of the command for display purposes + * @returns Promise that resolves to the selected target string, or undefined if cancelled + */ +export async function showTargetQuickPick( + targets: string[], + commandName: string, +): Promise { + if (targets.length === 0) { + return undefined; + } + + if (targets.length === 1) { + return targets[0]; + } + + // Show QuickPick for multiple targets + const quickPickItems = createTargetQuickPickItems(targets); + + const selectedItem = await vscode.window.showQuickPick(quickPickItems, { + placeHolder: `Select target to ${commandName.toLowerCase()}`, + canPickMany: false, + }); + + return selectedItem?.target; +} diff --git a/src/codelens/bazel_build_code_lens_provider.ts b/src/codelens/bazel_build_code_lens_provider.ts index c05622b2..9ae67265 100644 --- a/src/codelens/bazel_build_code_lens_provider.ts +++ b/src/codelens/bazel_build_code_lens_provider.ts @@ -20,26 +20,24 @@ import { getDefaultBazelExecutablePath } from "../extension/configuration"; import { blaze_query } from "../protos"; import { CodeLensCommandAdapter } from "./code_lens_command_adapter"; -/** Computes the shortened name of a Bazel target. +/** + * Groups of Bazel targets organized by the actions they support. + * Used by the CodeLens provider to determine which actions to display for each target. * - * For example, if the target name starts with `//foo/bar/baz:fizbuzz`, - * the target's short name will be `fizzbuzz`. - * - * This allows our code lens suggestions to avoid filling users' screen with - * redundant path information. - * - * @param targetName The unshortened name of the target. - * @returns The shortened name of the target. + * @interface ActionGroups + * @property {string[]} copy - Targets that support copying their label to clipboard (all target types) + * @property {string[]} build - Targets that support build operations (libraries, binaries, tests) + * @property {string[]} test - Targets that support test execution (test rules only) + * @property {string[]} run - Targets that support run operations (executable binaries only) */ -function getTargetShortName(targetName: string): string { - const colonFragments = targetName.split(":"); - if (colonFragments.length !== 2) { - return targetName; - } - return colonFragments[1]; +interface ActionGroups { + copy: string[]; + build: string[]; + test: string[]; + run: string[]; } -/** Provids CodeLenses for targets in Bazel BUILD files. */ +/** Provides CodeLenses for targets in Bazel BUILD files. */ export class BazelBuildCodeLensProvider implements vscode.CodeLensProvider { public onDidChangeCodeLenses: vscode.Event; @@ -117,8 +115,9 @@ export class BazelBuildCodeLensProvider implements vscode.CodeLensProvider { * Takes the result of a Bazel query for targets defined in a package and * returns a list of CodeLens for the BUILD file in that package. * - * @param bazelWorkspaceDirectory The Bazel workspace directory. - * @param queryResult The result of the bazel query. + * @param bazelWorkspaceInfo The Bazel workspace information containing workspace path and context + * @param queryResult The result of the bazel query containing target definitions + * @returns A new array of CodeLens objects for the BUILD file */ private computeCodeLenses( bazelWorkspaceInfo: BazelWorkspaceInfo, @@ -126,50 +125,108 @@ export class BazelBuildCodeLensProvider implements vscode.CodeLensProvider { ): vscode.CodeLens[] { const result: vscode.CodeLens[] = []; - interface LensCommand { - commandString: string; - name: string; - } - - const useTargetMap = queryResult.target - .map((t) => new QueryLocation(t.rule.location).line) - .reduce((countMap, line) => { - countMap.set(line, countMap.has(line)); - return countMap; - }, new Map()); - // Sort targets by length first, then alphabetically - // This ensures shorter names (often main targets) appear first, with consistent ordering within each length group + // Sort targets alphabetically const sortedTargets = [...queryResult.target].sort((a, b) => { - const lengthDiff = a.rule.name.length - b.rule.name.length; - return lengthDiff !== 0 - ? lengthDiff - : a.rule.name.localeCompare(b.rule.name); + return a.rule.name.localeCompare(b.rule.name); }); + // Group targets by line number to handle multiple targets on same line + const targetsByLine = new Map(); for (const target of sortedTargets) { const location = new QueryLocation(target.rule.location); + const line = location.line; + if (!targetsByLine.has(line)) { + targetsByLine.set(line, []); + } + targetsByLine.get(line)?.push(target); + } + + // Process each line's targets + for (const [, targets] of targetsByLine) { + this.createCodeLensesForTargetsOnSameLine( + targets, + bazelWorkspaceInfo, + result, + ); + } + + return result; + } + + /** + * Creates CodeLens objects for targets on the same line. + * + * @param targets Array of Bazel targets found on the same line in the BUILD file + * @param bazelWorkspaceInfo Workspace context information for command creation + * @param result Output array that will be modified in-place to include new CodeLens objects + */ + private createCodeLensesForTargetsOnSameLine( + targets: blaze_query.ITarget[], + bazelWorkspaceInfo: BazelWorkspaceInfo, + result: vscode.CodeLens[], + ): void { + const location = new QueryLocation(targets[0].rule.location); + + const actionGroups = this.groupTargetsByAction(targets); + + this.createCodeLens( + "Copy", + "bazel.copyLabelToClipboard", + actionGroups.copy, + location, + bazelWorkspaceInfo, + result, + ); + this.createCodeLens( + "Build", + "bazel.buildTarget", + actionGroups.build, + location, + bazelWorkspaceInfo, + result, + ); + this.createCodeLens( + "Test", + "bazel.testTarget", + actionGroups.test, + location, + bazelWorkspaceInfo, + result, + ); + this.createCodeLens( + "Run", + "bazel.runTarget", + actionGroups.run, + location, + bazelWorkspaceInfo, + result, + ); + } + + /** + * Groups targets by the actions they support based on Bazel rule types. + * Uses rule naming conventions to determine which actions are available. + * + * @param targets Array of Bazel targets to classify by supported actions + * @returns ActionGroups object with targets organized by action type + */ + private groupTargetsByAction(targets: blaze_query.ITarget[]): ActionGroups { + const copyTargets: string[] = []; + const buildTargets: string[] = []; + const testTargets: string[] = []; + const runTargets: string[] = []; + + for (const target of targets) { const targetName = target.rule.name; const ruleClass = target.rule.ruleClass; - const targetShortName = getTargetShortName(targetName); - const commands: LensCommand[] = []; - - // All targets support target copying and building. - commands.push({ - commandString: "bazel.copyLabelToClipboard", - name: "Copy", - }); - commands.push({ - commandString: "bazel.buildTarget", - name: "Build", - }); + // All targets support copying and building + copyTargets.push(targetName); + buildTargets.push(targetName); // Only test targets support testing. if (ruleClass.endsWith("_test") || ruleClass === "test_suite") { - commands.push({ - commandString: "bazel.testTarget", - name: "Test", - }); + testTargets.push(targetName); } // Targets which are not libraries may support running. @@ -180,28 +237,51 @@ export class BazelBuildCodeLensProvider implements vscode.CodeLensProvider { // first running the `analysis` phase, so we use a heuristic instead. const ruleIsLibrary = ruleClass.endsWith("_library"); if (!ruleIsLibrary) { - commands.push({ - commandString: "bazel.runTarget", - name: "Run", - }); + runTargets.push(targetName); } + } - for (const command of commands) { - const tooltip = `${command.name} ${targetShortName}`; - const title = useTargetMap.get(location.line) ? tooltip : command.name; - result.push( - new vscode.CodeLens(location.range, { - arguments: [ - new CodeLensCommandAdapter(bazelWorkspaceInfo, [targetName]), - ], - command: command.commandString, - title, - tooltip, - }), - ); - } + return { + copy: copyTargets, + build: buildTargets, + test: testTargets, + run: runTargets, + }; + } + + /** + * Creates a CodeLens for a specific action type if targets are available. + * Title shows action name with count for multiple targets. + * + * @param actionName Display name for the action (e.g., "Build", "Test", "Run", "Copy") + * @param command VS Code command identifier to execute when CodeLens is clicked + * @param targets Array of target names that support this action + * @param location Source location information for CodeLens positioning + * @param bazelWorkspaceInfo Workspace context for command adapter creation + * @param result Output array that will be modified in-place to include the new CodeLens + */ + private createCodeLens( + actionName: string, + command: string, + targets: string[], + location: QueryLocation, + bazelWorkspaceInfo: BazelWorkspaceInfo, + result: vscode.CodeLens[], + ): void { + if (targets.length === 0) { + return; } - return result; + const title = + targets.length === 1 ? actionName : `${actionName} (${targets.length})`; + + result.push( + new vscode.CodeLens(location.range, { + arguments: [new CodeLensCommandAdapter(bazelWorkspaceInfo, targets)], + command, + title, + tooltip: `${actionName} target - ${targets.length} targets available`, + }), + ); } } diff --git a/src/extension/bazel_wrapper_commands.ts b/src/extension/bazel_wrapper_commands.ts index d5e7c40f..a1701cfe 100644 --- a/src/extension/bazel_wrapper_commands.ts +++ b/src/extension/bazel_wrapper_commands.ts @@ -27,35 +27,81 @@ import { queryQuickPickTargets, queryQuickPickPackage, showDynamicQuickPick, + showTargetQuickPick, } from "../bazel/bazel_quickpick"; import { createBazelTask } from "../bazel/tasks"; import { blaze_query } from "../protos"; +import { CodeLensCommandAdapter } from "../codelens/code_lens_command_adapter"; /** - * Builds a Bazel target and streams output to the terminal. + * Unified target selection logic that handles all 3 use cases: + * 1. Command palette without target (adapter undefined) → prompt user + * 2. Internal command with single target (adapter defined, one target) → no querying + * 3. CodeLens with multiple targets (adapter defined, multiple targets) → prompt from given targets * - * @param adapter An object that implements {@link IBazelCommandAdapter} from - * which the command's arguments will be determined. + * @param adapter The command adapter, undefined for command palette usage + * @param quickPickQuery Query string for command palette target selection + * @param commandName Display name for the command (e.g., "Build target") + * @returns Promise that resolves to IBazelCommandAdapter with single target, or undefined if cancelled */ -async function bazelBuildTarget(adapter: IBazelCommandAdapter | undefined) { +async function selectSingleTarget( + adapter: IBazelCommandAdapter | undefined, + quickPickQuery: string, + commandName: string, +): Promise { + // Use Case 1: Command palette without target (adapter undefined) → prompt user if (adapter === undefined) { - // If the command adapter was unspecified, it means this command is being - // invoked via the command palatte. Provide quickpick build targets for - // the user to choose from. const quickPick = await showDynamicQuickPick({ initialPattern: "//...", - queryBuilder: (pattern) => `kind('.* rule', ${pattern})`, + queryBuilder: (pattern) => quickPickQuery.replace("...", pattern), queryFunctor: queryQuickPickTargets, workspaceInfo: await BazelWorkspaceInfo.fromWorkspaceFolders(), }); - // If the result was undefined, the user cancelled the quick pick, so don't - // try again. - if (quickPick) { - await bazelBuildTarget(quickPick); - } - return; + // If the result was undefined, the user cancelled the quick pick + return quickPick; } + const commandOptions = adapter.getBazelCommandOptions(); + + // Single target - use as-is + if (commandOptions.targets.length <= 1) { + return adapter; + } + + // Multiple targets - let user choose + const selectedTarget = await showTargetQuickPick( + commandOptions.targets, + commandName, + ); + + if (!selectedTarget) { + return undefined; + } + + // Create adapter with selected target + return new CodeLensCommandAdapter(commandOptions.workspaceInfo, [ + selectedTarget, + ]); +} + +/** + * Builds a Bazel target and streams output to the terminal. + * + * @param adapter An object that implements {@link IBazelCommandAdapter} from + * which the command's arguments will be determined. + */ +async function bazelBuildTarget(adapter: IBazelCommandAdapter | undefined) { + const selectedAdapter = await selectSingleTarget( + adapter, + "kind('.* rule', ...)", + "Build target", + ); + + if (!selectedAdapter) { + return; // User cancelled + } + + const commandOptions = selectedAdapter.getBazelCommandOptions(); const task = createBazelTask("build", commandOptions); // eslint-disable-next-line @typescript-eslint/no-floating-promises vscode.tasks.executeTask(task); @@ -166,6 +212,22 @@ async function buildPackage( vscode.tasks.executeTask(task); } +/** + * Creates and executes a Bazel task. + * + * @param adapter The command adapter with target + * @param commandType The Bazel command type (build, test, run) + */ +function executeBazelTask( + adapter: IBazelCommandAdapter, + commandType: "build" | "test" | "run", +): void { + const commandOptions = adapter.getBazelCommandOptions(); + const task = createBazelTask(commandType, commandOptions); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + vscode.tasks.executeTask(task); +} + /** * Runs a Bazel target and streams output to the terminal. * @@ -173,27 +235,17 @@ async function buildPackage( * which the command's arguments will be determined. */ async function bazelRunTarget(adapter: IBazelCommandAdapter | undefined) { - if (adapter === undefined) { - // If the command adapter was unspecified, it means this command is being - // invoked via the command palatte. Provide quickpick test targets for - // the user to choose from. - const quickPick = await showDynamicQuickPick({ - initialPattern: "//...", - queryBuilder: (pattern) => `kind('.* rule', ${pattern})`, - queryFunctor: queryQuickPickTargets, - workspaceInfo: await BazelWorkspaceInfo.fromWorkspaceFolders(), - }); - // If the result was undefined, the user cancelled the quick pick, so don't - // try again. - if (quickPick) { - await bazelRunTarget(quickPick); - } - return; + const selectedAdapter = await selectSingleTarget( + adapter, + "kind('.* rule', ...)", + "Run target", + ); + + if (!selectedAdapter) { + return; // User cancelled } - const commandOptions = adapter.getBazelCommandOptions(); - const task = createBazelTask("run", commandOptions); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - vscode.tasks.executeTask(task); + + executeBazelTask(selectedAdapter, "run"); } /** @@ -203,27 +255,17 @@ async function bazelRunTarget(adapter: IBazelCommandAdapter | undefined) { * which the command's arguments will be determined. */ async function bazelTestTarget(adapter: IBazelCommandAdapter | undefined) { - if (adapter === undefined) { - // If the command adapter was unspecified, it means this command is being - // invoked via the command palatte. Provide quickpick test targets for - // the user to choose from. - const quickPick = await showDynamicQuickPick({ - initialPattern: "//...", - queryBuilder: (pattern) => `kind('.* rule', ${pattern})`, - queryFunctor: queryQuickPickTargets, - workspaceInfo: await BazelWorkspaceInfo.fromWorkspaceFolders(), - }); - // If the result was undefined, the user cancelled the quick pick, so don't - // try again. - if (quickPick) { - await bazelTestTarget(quickPick); - } - return; + const selectedAdapter = await selectSingleTarget( + adapter, + "kind('.*_test rule', ...)", + "Test target", + ); + + if (!selectedAdapter) { + return; // User cancelled } - const commandOptions = adapter.getBazelCommandOptions(); - const task = createBazelTask("test", commandOptions); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - vscode.tasks.executeTask(task); + + executeBazelTask(selectedAdapter, "test"); } /** @@ -377,77 +419,106 @@ async function bazelGoToLabel(target_info?: blaze_query.ITarget | undefined) { } /** - * Copies the Bazel label to the clipboard. - * - * If no adapter is provided, it will find the label under the cursor in the - * active editor, validate it, and copy it to the clipboard. If the label is a - * short form (missing the target name), it will be expanded to the full label. + * Copies a label to clipboard and shows confirmation message. */ -function bazelCopyLabelToClipboard(adapter: IBazelCommandAdapter | undefined) { - let label: string; - - if (adapter !== undefined) { - // Called from a command adapter, so we can assume there is only one target. - label = adapter.getBazelCommandOptions().targets[0]; - } else { - // Called from command palette - const editor = vscode.window.activeTextEditor; - if (!editor) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - vscode.window.showInformationMessage( - "Please open a file to copy a label from.", - ); - return; - } +function copyLabelToClipboard(label: string): void { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + vscode.env.clipboard.writeText(label); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + vscode.window.showInformationMessage(`Copied to clipboard: ${label}`); +} - const document = editor.document; - const position = editor.selection.active; - const wordRange = document.getWordRangeAtPosition( - position, - /(? { + // Use Case 1: Command palette without target (adapter undefined) → extract from cursor + if (adapter === undefined) { + const cursorLabel = extractLabelFromCursor(); + if (cursorLabel) { + copyLabelToClipboard(cursorLabel); } + return; } - // eslint-disable-next-line @typescript-eslint/no-floating-promises - vscode.env.clipboard.writeText(label); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - vscode.window.showInformationMessage(`Copied to clipboard: ${label}`); + + // Use Case 2 & 3: Handle adapter with single or multiple targets + const selectedAdapter = await selectSingleTarget( + adapter, + "kind('.* rule', ...)", + "Copy label", + ); + + if (!selectedAdapter) { + return; // User cancelled + } + + const commandOptions = selectedAdapter.getBazelCommandOptions(); + const targetLabel = commandOptions.targets[0]; + copyLabelToClipboard(targetLabel); } /** diff --git a/test/code_lens_provider.test.ts b/test/code_lens_provider.test.ts index 453ecebc..a2be04ab 100644 --- a/test/code_lens_provider.test.ts +++ b/test/code_lens_provider.test.ts @@ -1,7 +1,7 @@ import * as vscode from "vscode"; import * as assert from "assert"; import { BazelBuildCodeLensProvider } from "../src/codelens/bazel_build_code_lens_provider"; -import { BazelWorkspaceInfo } from "../src/bazel"; +import { BazelWorkspaceInfo, IBazelCommandAdapter } from "../src/bazel"; import { blaze_query } from "../src/protos"; // Group lenses by target line number for easier assertions @@ -35,10 +35,19 @@ function groupLensesByLine(lenses: vscode.CodeLens[]): Map { lensesByLine.set(line, []); } if (lens.command?.title) { - const targetArg = lens.command.arguments?.[0] as - | { targets: string[] } + const commandAdapter = lens.command.arguments?.[0] as + | IBazelCommandAdapter | undefined; - const target = targetArg?.targets?.[0] || ""; + let target = ""; + + // Extract target from CodeLensCommandAdapter + if ( + commandAdapter && + typeof commandAdapter.getBazelCommandOptions === "function" + ) { + const options = commandAdapter.getBazelCommandOptions(); + target = options.targets?.[0] || ""; + } lensesByLine.get(line)?.push({ title: lens.command.title, @@ -52,28 +61,6 @@ function groupLensesByLine(lenses: vscode.CodeLens[]): Map { return lensesByLine; } -function groupCommandsByType(lenses: LensInfo[]): Record { - const commandsByType: Record = {}; - - lenses.forEach((lens) => { - const command = lens.command; - if (!command || !command.arguments?.[0]) return; - - const title = command.title || ""; - const target: string = - (command.arguments[0] as { targets: string[] }).targets?.[0] || ""; - - // Extract just the command name (first word) from the title - const commandName = title.split(" ")[0]; - if (!commandsByType[commandName]) { - commandsByType[commandName] = []; - } - commandsByType[commandName].push(target); - }); - - return commandsByType; -} - describe("BazelBuildCodeLensProvider", () => { let provider: BazelBuildCodeLensProvider; const mockContext = { @@ -186,41 +173,94 @@ describe("BazelBuildCodeLensProvider", () => { }); }); - // 4. Verify multiple targets on the same line (line 25) - // - Shorter target name ("test") should come before longer one ("abc_helper") - // - Targets with same length should be ordered alphabetically ("abc_helper" should come before "helper_abc") + // 4. Verify multiple targets on the same line (line 25) - now uses grouped approach + // - Should have 4 grouped code lenses (Copy, Build, Run, Test) + // - Each grouped lens should contain multiple targets ordered by length then alphabetically const sameLineLenses = lensesByLine.get(25) || []; assert.strictEqual( sameLineLenses.length, - 8, - "Should have 8 code lenses for both targets on line 25", - ); - // Process each lens to group commands by type - const commandsByType = groupCommandsByType(sameLineLenses); - // Verify commands are ordered by target name length (shorter first) - assert.deepStrictEqual( - commandsByType.Copy, - ["//foo:test", "//foo:abc_helper", "//foo:helper_abc"], - "Copy commands should be ordered by target name length", - ); - assert.deepStrictEqual( - commandsByType.Build, - ["//foo:test", "//foo:abc_helper", "//foo:helper_abc"], - "Build commands should be ordered by target name length", + 4, + "Should have 4 grouped code lenses for multiple targets on line 25", ); - // Test target should have Run and Test commands + // Verify we have the expected command types + const lenseTitles = sameLineLenses.map((l) => l.title).sort(); assert.deepStrictEqual( - commandsByType.Run, - ["//foo:test"], - "Run command should only be for the test target", - ); - assert.deepStrictEqual( - commandsByType.Test, - ["//foo:test"], - "Test command should only be for the test target", + lenseTitles, + ["Build (3)", "Copy (3)", "Run", "Test"], + "Should have grouped commands with correct counts", ); + // Verify each grouped command contains the correct targets in the right order + sameLineLenses.forEach((lens) => { + const command = lens.command; + assert.ok(command, "Each lens should have a command"); + assert.ok(command.arguments?.[0], "Command should have arguments"); + + if (lens.title.startsWith("Copy") || lens.title.startsWith("Build")) { + // All commands now use CodeLensCommandAdapter format + const commandAdapter = command.arguments[0] as + | IBazelCommandAdapter + | undefined; + assert.ok( + commandAdapter && + typeof commandAdapter.getBazelCommandOptions === "function", + "Command should use CodeLensCommandAdapter format", + ); + + const options = commandAdapter.getBazelCommandOptions(); + assert.ok( + Array.isArray(options.targets), + "Command should have targets array", + ); + + if (lens.title.includes("(")) { + // Multiple target format: Copy (3), Build (3) + assert.deepStrictEqual( + options.targets, + ["//foo:abc_helper", "//foo:helper_abc", "//foo:test"], + `${lens.title} should contain all targets ordered alphabetically`, + ); + } else { + // Single target format: Copy, Build + assert.strictEqual( + options.targets.length, + 1, + "Single target should have exactly one target", + ); + assert.ok( + options.targets[0].startsWith("//foo:"), + "Target should be from foo package", + ); + } + } else if ( + lens.title.startsWith("Run") || + lens.title.startsWith("Test") + ) { + // Run and Test commands now also use CodeLensCommandAdapter format + assert.strictEqual( + command.arguments.length, + 1, + "Single target commands should have 1 argument", + ); + const commandAdapter = command.arguments[0] as + | IBazelCommandAdapter + | undefined; + assert.ok( + commandAdapter && + typeof commandAdapter.getBazelCommandOptions === "function", + "Command should use CodeLensCommandAdapter format", + ); + + const options = commandAdapter.getBazelCommandOptions(); + assert.deepStrictEqual( + options.targets, + ["//foo:test"], + `${lens.title} should be for the test target`, + ); + } + }); + // Verify all commands are on the correct line (0-based line 24 = line 25 in editor) sameLineLenses.forEach((lens) => { assert.strictEqual( diff --git a/test/multiple_target_picker.test.ts b/test/multiple_target_picker.test.ts new file mode 100644 index 00000000..047cdc0e --- /dev/null +++ b/test/multiple_target_picker.test.ts @@ -0,0 +1,217 @@ +// Copyright 2024 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as assert from "assert"; +import { BazelWorkspaceInfo } from "../src/bazel/bazel_workspace_info"; +import { CodeLensCommandAdapter } from "../src/codelens/code_lens_command_adapter"; + +describe("Multiple Target Picker Functionality", () => { + let mockWorkspaceInfo: BazelWorkspaceInfo; + + beforeEach(() => { + // Create mock workspace info + mockWorkspaceInfo = { + bazelWorkspacePath: "/test/workspace", + } as BazelWorkspaceInfo; + }); + + describe("Target Grouping", () => { + it("should detect multiple targets for picker display", () => { + // GIVEN: Multiple targets in adapter + const targets = ["//foo:target1", "//foo:target2"]; + const adapter = new CodeLensCommandAdapter(mockWorkspaceInfo, targets); + + // WHEN: Checking for multiple targets + const commandOptions = adapter.getBazelCommandOptions(); + const hasMultipleTargets = commandOptions.targets.length > 1; + + // THEN: Should detect multiple targets + assert.strictEqual( + hasMultipleTargets, + true, + "Should detect multiple targets when targets.length > 1", + ); + assert.strictEqual( + commandOptions.targets.length, + 2, + "Should preserve both targets for picker", + ); + }); + + it("should bypass picker for single targets", () => { + // GIVEN: Single target in adapter + const targets = ["//foo:single"]; + const adapter = new CodeLensCommandAdapter(mockWorkspaceInfo, targets); + + // WHEN: Checking for multiple targets + const commandOptions = adapter.getBazelCommandOptions(); + const hasMultipleTargets = commandOptions.targets.length > 1; + + // THEN: Should not trigger picker logic + assert.strictEqual( + hasMultipleTargets, + false, + "Single targets should bypass picker logic", + ); + assert.strictEqual( + commandOptions.targets[0], + "//foo:single", + "Single target should be preserved", + ); + }); + + it("should preserve target order for picker display", () => { + // GIVEN: Targets in specific order + const targets = ["//foo:zzz_last", "//foo:aaa_first"]; + const adapter = new CodeLensCommandAdapter(mockWorkspaceInfo, targets); + + // WHEN: Getting targets from adapter + const commandOptions = adapter.getBazelCommandOptions(); + + // THEN: Order should be preserved for consistent picker display + assert.strictEqual( + commandOptions.targets[0], + "//foo:zzz_last", + "Target order should be preserved for picker", + ); + assert.strictEqual( + commandOptions.targets[1], + "//foo:aaa_first", + "Target order should be preserved for picker", + ); + }); + }); + + describe("Picker Display", () => { + it("should show picker for multiple target commands", () => { + // GIVEN: Multiple targets that support all operations + const targets = ["//foo:binary", "//foo:test"]; + const adapter = new CodeLensCommandAdapter(mockWorkspaceInfo, targets); + + // WHEN: Checking if commands would trigger picker logic + const commandOptions = adapter.getBazelCommandOptions(); + const shouldShowPicker = commandOptions.targets.length > 1; + + // THEN: All operations should show picker + assert.strictEqual( + shouldShowPicker, + true, + "Build command should show picker for multiple targets", + ); + assert.ok( + commandOptions.workspaceInfo, + "Workspace info should be available for picker", + ); + assert.ok( + Array.isArray(commandOptions.targets), + "Targets should be available as array for picker", + ); + }); + + it("should handle target name extraction for picker display", () => { + // GIVEN: Various target patterns for picker display + const testCases = [ + { + target: "//foo:bar", + expectedShortName: "bar", + description: "Simple target should extract name for picker", + }, + { + target: "//very/long/path:target", + expectedShortName: "target", + description: "Long path should extract target name for picker", + }, + { + target: "//:root", + expectedShortName: "root", + description: "Root target should extract name for picker", + }, + ]; + + testCases.forEach((testCase) => { + // WHEN: Extracting short name for picker display + const colonIndex = testCase.target.lastIndexOf(":"); + const shortName = + colonIndex !== -1 + ? testCase.target.substring(colonIndex + 1) + : testCase.target; + + // THEN: Short name should be extracted correctly for picker + assert.strictEqual( + shortName, + testCase.expectedShortName, + testCase.description, + ); + }); + }); + }); + + describe("Copy with Clipboard", () => { + it("should enable copy functionality for multiple targets", () => { + // GIVEN: Multiple targets for copy operation + const targets = ["//foo:lib1", "//foo:lib2"]; + const adapter = new CodeLensCommandAdapter(mockWorkspaceInfo, targets); + + // WHEN: Getting command options for copy operation + const commandOptions = adapter.getBazelCommandOptions(); + const shouldShowPicker = commandOptions.targets.length > 1; + + // THEN: Copy command should show picker for target selection + assert.strictEqual( + shouldShowPicker, + true, + "Copy command should show picker for multiple targets", + ); + assert.strictEqual( + commandOptions.targets.length, + 2, + "Both targets should be available for copy selection", + ); + + // Verify targets are available for clipboard operation + assert.strictEqual( + commandOptions.targets[0], + "//foo:lib1", + "First target should be available for copy", + ); + assert.strictEqual( + commandOptions.targets[1], + "//foo:lib2", + "Second target should be available for copy", + ); + }); + + it("should handle direct copy for single targets", () => { + // GIVEN: Single target for copy operation + const targets = ["//foo:single_lib"]; + const adapter = new CodeLensCommandAdapter(mockWorkspaceInfo, targets); + + // WHEN: Getting command options for copy operation + const commandOptions = adapter.getBazelCommandOptions(); + const shouldShowPicker = commandOptions.targets.length > 1; + + // THEN: Copy should work directly without picker + assert.strictEqual( + shouldShowPicker, + false, + "Single target copy should bypass picker", + ); + assert.strictEqual( + commandOptions.targets[0], + "//foo:single_lib", + "Single target should be available for direct copy", + ); + }); + }); +});