| use std::collections::BTreeMap; | |
| use std::fs; | |
| use std::process::Command; | |
| use anyhow::{bail, Context, Result}; | |
| use camino::{Utf8Path, Utf8PathBuf}; | |
| use log::debug; | |
| use serde::Serialize; | |
| use serde_json::Value; | |
| pub struct BazelInfo { | |
| pub output_base: String, | |
| pub workspace: String, | |
| } | |
| fn bazel_command(bazel: &Utf8Path, workspace_root: &Utf8Path) -> Command { | |
| let mut cmd = Command::new(bazel); | |
| cmd.current_dir(workspace_root); | |
| cmd | |
| } | |
| impl BazelInfo { | |
| pub fn new(output_base: String, workspace: String) -> Self { | |
| Self { | |
| output_base, | |
| workspace, | |
| } | |
| } | |
| pub fn try_new(bazel: &Utf8Path, workspace_root: &Utf8Path) -> anyhow::Result<Self> { | |
| let output = bazel_command(bazel, workspace_root) | |
| .arg("info") | |
| .output() | |
| .context("Failed to execute 'bazel info'")?; | |
| if !output.status.success() { | |
| let stderr = String::from_utf8_lossy(&output.stderr); | |
| bail!("bazel info failed: {}", stderr); | |
| } | |
| let info_map: BTreeMap<String, String> = String::from_utf8(output.stdout)? | |
| .trim() | |
| .lines() | |
| .filter_map(|line| line.split_once(':')) | |
| .map(|(k, v)| (k.to_owned(), v.trim().to_owned())) | |
| .collect(); | |
| Ok(Self { | |
| output_base: info_map | |
| .get("output_base") | |
| .context("Failed to query `bazel info output_base`")? | |
| .clone(), | |
| workspace: info_map | |
| .get("workspace") | |
| .context("Failed to query `bazel info workspace`")? | |
| .clone(), | |
| }) | |
| } | |
| } | |
| /// Information about a Bazel target for debugging. | |
| #[derive(Debug, Clone)] | |
| pub struct TargetInfo { | |
| pub label: String, | |
| pub binary_path: Utf8PathBuf, | |
| pub is_test: bool, | |
| pub target_kind: String, | |
| } | |
| /// VSCode launch configuration for debugging. | |
| #[derive(Debug, Serialize)] | |
| pub struct LaunchConfig { | |
| pub name: String, | |
| pub r#type: String, | |
| pub request: String, | |
| #[serde(skip_serializing_if = "Option::is_none")] | |
| pub program: Option<String>, | |
| #[serde(skip_serializing_if = "Vec::is_empty")] | |
| pub args: Vec<String>, | |
| pub cwd: String, | |
| #[serde(rename = "sourceLanguages")] | |
| pub source_languages: Vec<String>, | |
| #[serde(skip_serializing_if = "Option::is_none")] | |
| pub env: Option<BTreeMap<String, String>>, | |
| #[serde( | |
| rename = "targetCreateCommands", | |
| skip_serializing_if = "Option::is_none" | |
| )] | |
| pub target_create_commands: Option<Vec<String>>, | |
| } | |
| /// VSCode task configuration for building. | |
| #[derive(Debug, Serialize)] | |
| pub struct TaskConfig { | |
| pub label: String, | |
| pub r#type: String, | |
| pub command: String, | |
| pub args: Vec<String>, | |
| pub group: String, | |
| pub presentation: TaskPresentation, | |
| #[serde(rename = "problemMatcher")] | |
| pub problem_matcher: Vec<String>, | |
| } | |
| #[derive(Debug, Serialize)] | |
| pub struct TaskPresentation { | |
| pub reveal: String, | |
| pub panel: String, | |
| #[serde(rename = "showReuseMessage")] | |
| pub show_reuse_message: bool, | |
| pub clear: bool, | |
| } | |
| /// Generator for VSCode launch configurations. | |
| pub struct LaunchConfigGenerator { | |
| workspace_root: Utf8PathBuf, | |
| bazel_binary: Utf8PathBuf, | |
| bazel_info: BazelInfo, | |
| } | |
| impl LaunchConfigGenerator { | |
| pub fn new(workspace_root: Utf8PathBuf, bazel_info: BazelInfo) -> Self { | |
| Self { | |
| workspace_root, | |
| bazel_binary: "bazel".into(), | |
| bazel_info, | |
| } | |
| } | |
| pub fn with_bazel_binary(mut self, bazel: Utf8PathBuf) -> Self { | |
| self.bazel_binary = bazel; | |
| self | |
| } | |
| /// Query information about multiple targets at once. | |
| pub fn query_targets_batch(&mut self, targets: &[String]) -> Result<Vec<TargetInfo>> { | |
| if targets.is_empty() { | |
| return Ok(vec![]); | |
| } | |
| // Get all target kinds in one query | |
| let target_kinds = self.batch_query_target_kinds(targets)?; | |
| let mut results = Vec::new(); | |
| for target in targets { | |
| if let Some(target_kind) = target_kinds.get(target) { | |
| let is_test = target_kind.contains("rust_test"); | |
| // We don't need to resolve binary paths now - that will happen at debug time | |
| // Just create a placeholder path that will be resolved by the pre-launch task | |
| results.push(TargetInfo { | |
| label: target.to_string(), | |
| binary_path: Utf8PathBuf::from(""), // Placeholder - resolved at debug time | |
| is_test, | |
| target_kind: target_kind.clone(), | |
| }); | |
| } | |
| } | |
| Ok(results) | |
| } | |
| /// Batch query target kinds for multiple targets at once. | |
| fn batch_query_target_kinds(&self, targets: &[String]) -> Result<BTreeMap<String, String>> { | |
| let target_pattern = targets.join(" + "); | |
| let output = bazel_command(&self.bazel_binary, &self.workspace_root) | |
| .arg("query") | |
| .arg("--output=label_kind") | |
| .arg(&target_pattern) | |
| .output() | |
| .context("Failed to execute 'bazel query'")?; | |
| if !output.status.success() { | |
| let stderr = String::from_utf8_lossy(&output.stderr); | |
| bail!("bazel query failed for targets: {}", stderr); | |
| } | |
| let stdout = String::from_utf8(output.stdout)?; | |
| let mut result = BTreeMap::new(); | |
| for line in stdout.trim().lines() { | |
| // Format: "rust_test rule //path/to:target" | |
| let parts: Vec<&str> = line.split_whitespace().collect(); | |
| if parts.len() >= 3 { | |
| let kind = parts[0]; | |
| let label = parts[2]; | |
| if !kind.starts_with("rust_") { | |
| // Skip non-Rust targets | |
| continue; | |
| } | |
| result.insert(label.to_string(), kind.to_string()); | |
| } | |
| } | |
| Ok(result) | |
| } | |
| /// Execute a Bazel query and return the results. | |
| fn execute_query(&self, query_expr: &str) -> Result<Vec<String>> { | |
| debug!("Executing Bazel query: {}", query_expr); | |
| let output = bazel_command(&self.bazel_binary, &self.workspace_root) | |
| .arg("query") | |
| .arg(query_expr) | |
| .output() | |
| .context("Failed to execute 'bazel query'")?; | |
| if !output.status.success() { | |
| let stderr = String::from_utf8_lossy(&output.stderr); | |
| bail!("bazel query failed for '{}': {}", query_expr, stderr); | |
| } | |
| let targets = String::from_utf8(output.stdout)? | |
| .lines() | |
| .map(|line| line.trim().to_string()) | |
| .filter(|line| !line.is_empty()) | |
| .collect::<Vec<_>>(); | |
| debug!("Query returned {} targets", targets.len()); | |
| Ok(targets) | |
| } | |
| /// Find Rust targets using query patterns (similar to gen_rust_project approach). | |
| /// Returns TargetInfo for all rust_binary and rust_test targets found. | |
| pub fn find_rust_targets(&self, patterns: &[String]) -> Result<Vec<TargetInfo>> { | |
| if patterns.is_empty() { | |
| return Ok(vec![]); | |
| } | |
| let target_pattern = patterns.join(" + "); | |
| let mut results = Vec::new(); | |
| // Query rust_binary targets | |
| let binary_query = format!("kind('rust_binary', {})", target_pattern); | |
| let binary_targets = self.execute_query(&binary_query)?; | |
| for label in binary_targets { | |
| results.push(TargetInfo { | |
| label, | |
| binary_path: Utf8PathBuf::from(""), // Placeholder - resolved at debug time | |
| is_test: false, | |
| target_kind: "rust_binary".to_string(), | |
| }); | |
| } | |
| // Query rust_test targets | |
| let test_query = format!("kind('rust_test', {})", target_pattern); | |
| let test_targets = self.execute_query(&test_query)?; | |
| for label in test_targets { | |
| results.push(TargetInfo { | |
| label, | |
| binary_path: Utf8PathBuf::from(""), // Placeholder - resolved at debug time | |
| is_test: true, | |
| target_kind: "rust_test".to_string(), | |
| }); | |
| } | |
| Ok(results) | |
| } | |
| /// Generate a launch configuration for a target. | |
| pub fn generate_launch_config(&self, target_info: &TargetInfo) -> Result<LaunchConfig> { | |
| let name = format!("Debug {}", target_info.label); | |
| // Use CodeLLDB's "custom" request with Python scripting to build and get the binary path | |
| // This consolidates everything into targetCreateCommands, no separate task needed | |
| let target_create_commands = vec![ | |
| // Multi-line Python script that: | |
| // 1. Runs bazel with --run_under to get the binary path | |
| // 2. Parses stderr for the bazel-out path | |
| // 3. Creates the debug target | |
| "script ".to_owned() + &[ | |
| "import subprocess, os, sys".to_owned(), | |
| format!("result = subprocess.run(['bazel', 'run', '--compilation_mode=dbg', '--strip=never', '--run_under=@rules_rust//tools/vscode:get_binary_path', '{}'], stdout=subprocess.PIPE, text=True, cwd='${{workspaceFolder}}')", target_info.label), | |
| "binary_path = result.stdout.strip().splitlines()[-1]".to_owned(), | |
| format!("assert binary_path, 'No binary path output for {}'", target_info.label), | |
| "abs_path = os.path.join('${workspaceFolder}', binary_path)".to_owned(), | |
| "lldb.debugger.CreateTarget(abs_path)".to_owned(), | |
| ].join("; ") | |
| ]; | |
| let mut config = LaunchConfig { | |
| name, | |
| r#type: "lldb".to_string(), | |
| request: "custom".to_string(), | |
| program: None, | |
| args: vec![], | |
| cwd: self.workspace_root.to_string(), | |
| source_languages: vec!["rust".to_string()], | |
| env: None, | |
| target_create_commands: Some(target_create_commands), | |
| }; | |
| // Add test environment if this is a test target | |
| if target_info.is_test { | |
| let test_env = self.generate_test_environment(&target_info.label); | |
| config.env = Some(test_env); | |
| } | |
| Ok(config) | |
| } | |
| /// Sanitize target name for use in filenames. | |
| fn sanitize_target_name(&self, target: &str) -> String { | |
| target.replace("//", "").replace([':', '/'], "_") | |
| } | |
| /// Generate test environment variables based on Bazel test encyclopedia. | |
| fn generate_test_environment(&self, target: &str) -> BTreeMap<String, String> { | |
| let workspace_name = &self.bazel_info.workspace; | |
| let mut env = BTreeMap::new(); | |
| let sanitized_name = self.sanitize_target_name(target); | |
| let vscode_dir = self.workspace_root.join(".vscode"); | |
| // Core test environment variables from Bazel test encyclopedia | |
| // https://bazel.build/reference/test-encyclopedia | |
| env.insert("BAZEL_TEST".to_string(), "1".to_string()); | |
| env.insert("TEST_TARGET".to_string(), target.to_string()); | |
| env.insert("TEST_WORKSPACE".to_string(), workspace_name.clone()); | |
| // Test output directories (use .vscode subdirectories) | |
| env.insert( | |
| "TEST_TMPDIR".to_string(), | |
| vscode_dir | |
| .join(format!("bazel-test-tmp-{}", sanitized_name)) | |
| .to_string(), | |
| ); | |
| env.insert( | |
| "TEST_UNDECLARED_OUTPUTS_DIR".to_string(), | |
| vscode_dir | |
| .join(format!("bazel-test-outputs-{}", sanitized_name)) | |
| .to_string(), | |
| ); | |
| env.insert( | |
| "TEST_UNDECLARED_OUTPUTS_ANNOTATIONS_DIR".to_string(), | |
| vscode_dir | |
| .join(format!("bazel-test-annotations-{}", sanitized_name)) | |
| .to_string(), | |
| ); | |
| // Rust-specific | |
| env.insert("RUST_BACKTRACE".to_string(), "all".to_string()); | |
| env | |
| } | |
| /// Read existing launch.json file if it exists. | |
| pub fn read_existing_launch_config(&self, path: &Utf8Path) -> Result<Option<Value>> { | |
| if !path.exists() { | |
| return Ok(None); | |
| } | |
| let content = fs::read_to_string(path) | |
| .with_context(|| format!("Failed to read existing launch config from {}", path))?; | |
| let config: Value = serde_json::from_str(&content) | |
| .with_context(|| format!("Failed to parse existing launch config from {}", path))?; | |
| Ok(Some(config)) | |
| } | |
| /// Read existing tasks.json file if it exists. | |
| pub fn read_existing_tasks_config(&self, path: &Utf8Path) -> Result<Option<Value>> { | |
| if !path.exists() { | |
| return Ok(None); | |
| } | |
| let content = fs::read_to_string(path) | |
| .with_context(|| format!("Failed to read existing tasks config from {}", path))?; | |
| let config: Value = serde_json::from_str(&content) | |
| .with_context(|| format!("Failed to parse existing tasks config from {}", path))?; | |
| Ok(Some(config)) | |
| } | |
| /// Check if a configuration name matches our "Debug {label}" pattern and extract the label. | |
| fn extract_debug_label(name: &str) -> Option<&str> { | |
| name.strip_prefix("Debug ") | |
| } | |
| /// Check if a task name matches our "bazel-debug: build {label}" pattern and extract the label. | |
| fn extract_build_task_label(name: &str) -> Option<&str> { | |
| name.strip_prefix("bazel-debug: build ") | |
| } | |
| /// Filter out existing configurations that match our generated patterns. | |
| fn filter_existing_configurations( | |
| existing: &mut Value, | |
| generated_targets: &[String], | |
| ) -> Result<()> { | |
| if let Some(configurations) = existing.get_mut("configurations") { | |
| if let Some(configs_array) = configurations.as_array_mut() { | |
| configs_array.retain(|config| { | |
| if let Some(name) = config.get("name").and_then(|v| v.as_str()) { | |
| if let Some(label) = Self::extract_debug_label(name) { | |
| // Check if this is a valid Bazel label that we're generating | |
| if label::analyze(label).is_ok() { | |
| // If it's in our list of targets to generate, remove it | |
| return !generated_targets.contains(&label.to_string()); | |
| } | |
| } | |
| } | |
| // Keep configurations that don't match our pattern | |
| true | |
| }); | |
| } | |
| } | |
| Ok(()) | |
| } | |
| /// Filter out existing tasks that match our generated patterns. | |
| fn filter_existing_tasks(existing: &mut Value, generated_targets: &[String]) -> Result<()> { | |
| if let Some(tasks) = existing.get_mut("tasks") { | |
| if let Some(tasks_array) = tasks.as_array_mut() { | |
| tasks_array.retain(|task| { | |
| if let Some(label) = task.get("label").and_then(|v| v.as_str()) { | |
| if let Some(target_label) = Self::extract_build_task_label(label) { | |
| // Check if this is a valid Bazel label that we're generating | |
| if label::analyze(target_label).is_ok() { | |
| // If it's in our list of targets to generate, remove it | |
| return !generated_targets.contains(&target_label.to_string()); | |
| } | |
| } | |
| } | |
| // Keep tasks that don't match our pattern | |
| true | |
| }); | |
| } | |
| } | |
| Ok(()) | |
| } | |
| /// Merge new configurations with existing launch.json. | |
| pub fn merge_launch_configs( | |
| &self, | |
| new_configs: &[LaunchConfig], | |
| existing_path: &Utf8Path, | |
| ) -> Result<Value> { | |
| let generated_targets: Vec<String> = new_configs | |
| .iter() | |
| .filter_map(|config| Self::extract_debug_label(&config.name)) | |
| .map(|s| s.to_string()) | |
| .collect(); | |
| let mut result = | |
| if let Some(mut existing) = self.read_existing_launch_config(existing_path)? { | |
| // Remove existing configurations for targets we're regenerating | |
| Self::filter_existing_configurations(&mut existing, &generated_targets)?; | |
| existing | |
| } else { | |
| // Create new launch.json structure | |
| serde_json::json!({ | |
| "version": "0.2.0", | |
| "configurations": [] | |
| }) | |
| }; | |
| // Add new configurations | |
| if let Some(configurations) = result.get_mut("configurations") { | |
| if let Some(configs_array) = configurations.as_array_mut() { | |
| for config in new_configs { | |
| configs_array.push(serde_json::to_value(config)?); | |
| } | |
| } | |
| } | |
| Ok(result) | |
| } | |
| /// Merge new tasks with existing tasks.json. | |
| pub fn merge_tasks_configs( | |
| &self, | |
| new_tasks: &[TaskConfig], | |
| existing_path: &Utf8Path, | |
| ) -> Result<Value> { | |
| let generated_targets: Vec<String> = new_tasks | |
| .iter() | |
| .filter_map(|task| Self::extract_build_task_label(&task.label)) | |
| .map(|s| s.to_string()) | |
| .collect(); | |
| let mut result = | |
| if let Some(mut existing) = self.read_existing_tasks_config(existing_path)? { | |
| // Remove existing tasks for targets we're regenerating | |
| Self::filter_existing_tasks(&mut existing, &generated_targets)?; | |
| existing | |
| } else { | |
| // Create new tasks.json structure | |
| serde_json::json!({ | |
| "version": "2.0.0", | |
| "tasks": [] | |
| }) | |
| }; | |
| // Add new tasks | |
| if let Some(tasks) = result.get_mut("tasks") { | |
| if let Some(tasks_array) = tasks.as_array_mut() { | |
| for task in new_tasks { | |
| tasks_array.push(serde_json::to_value(task)?); | |
| } | |
| } | |
| } | |
| Ok(result) | |
| } | |
| } | |
| #[cfg(test)] | |
| mod tests { | |
| use super::*; | |
| #[test] | |
| fn test_target_info_creation() { | |
| let target_info = TargetInfo { | |
| label: "//test:my_test".to_string(), | |
| binary_path: "/path/to/binary".into(), | |
| is_test: true, | |
| target_kind: "rust_test rule".to_string(), | |
| }; | |
| assert_eq!(target_info.label, "//test:my_test"); | |
| assert!(target_info.is_test); | |
| } | |
| } |