blob: 08becebcba17342269d00a7ca4ca47f38be8bd6c [file] [log] [blame]
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);
}
}