diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index bf4bc91ba..f8254c193 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -558,6 +558,35 @@ pub struct GoSettings { pub goroot: String, /// Empty = `go env GOPATH`. pub gopath: String, + pub build_tags: GoBuildTags, + pub modules: GoModules, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct GoModules { + pub env: BTreeMap, + pub auto_vendor: bool, +} + +impl Default for GoModules { + fn default() -> Self { + Self { env: BTreeMap::new(), auto_vendor: true } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct GoBuildTags { + /// Empty values inherit the selected toolchain's defaults. + pub os: String, + pub arch: String, + pub version: String, + pub compiler: String, + /// Empty = automatic, "1" = enabled, "0" = disabled. + pub cgo: String, + pub custom: String, + pub experiments: String, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] @@ -766,3 +795,19 @@ mod gradle_settings_tests { } } } + +#[cfg(test)] +mod go_module_tests { + use super::*; + #[test] + fn old_go_settings_inherit_module_defaults() { + let old: GoSettings = serde_json::from_str(r#"{"goroot":"/go"}"#).unwrap(); + assert!(old.modules.auto_vendor); + assert!(old.modules.env.is_empty()); + let configured: GoSettings = + serde_json::from_str(r#"{"modules":{"autoVendor":false,"env":{"GOPRIVATE":"example.com/private"}}}"#) + .unwrap(); + assert!(!configured.modules.auto_vendor); + assert_eq!(configured.modules.env["GOPRIVATE"], "example.com/private"); + } +} diff --git a/crates/ide-dap/src/adapter.rs b/crates/ide-dap/src/adapter.rs index ade3d7c00..f1f16bf44 100644 --- a/crates/ide-dap/src/adapter.rs +++ b/crates/ide-dap/src/adapter.rs @@ -348,8 +348,17 @@ pub struct AdapterIo { pub port: Option, /// Recent stderr lines of the adapter process, for error messages. pub stderr: Arc>>, + /// (category, line) printed by the debuggee when it shares the adapter's stdio. + pub program_output: Option, } +pub type ProgramOutput = tokio::sync::mpsc::UnboundedReceiver<(&'static str, String)>; +type ProgramSink = tokio::sync::mpsc::UnboundedSender<(&'static str, String)>; + +/// Delve's debuggee inherits its stdio; no DAP output event carries what the program prints. +const SHARES_STDIO: &str = "delve"; +const DELVE_BANNER: &str = "DAP server listening at:"; + pub type LaunchFuture = Pin> + Send>>; /// Starts adapters. Tests substitute in-memory adapters. @@ -385,6 +394,7 @@ impl AdapterLauncher for ProcessLauncher { child: None, port: Some(port), stderr, + program_output: None, }); } let embedded = match spec.connect.as_deref().filter(|_| spec.transport == Transport::Tcp) { @@ -407,6 +417,7 @@ impl AdapterLauncher for ProcessLauncher { child: None, port: Some(*port), stderr, + program_output: None, }); } if spec.adapter == DEBUGPY_TOOL { @@ -445,8 +456,14 @@ impl AdapterLauncher for ProcessLauncher { .kill_on_drop(true); let mut child = cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?; + let (program_sink, program_output) = if spec.adapter == SHARES_STDIO { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + (Some(tx), Some(rx)) + } else { + (None, None) + }; if let Some(err) = child.stderr.take() { - pump_lines(err, spec.adapter.clone(), stderr.clone()); + pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr"))); } match port { None => { @@ -458,11 +475,12 @@ impl AdapterLauncher for ProcessLauncher { child: Some(child), port: None, stderr, + program_output, }) } Some(port) => { if let Some(out) = child.stdout.take() { - pump_lines(out, spec.adapter.clone(), stderr.clone()); + pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout"))); } let timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT }; let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), timeout).await { @@ -484,6 +502,7 @@ impl AdapterLauncher for ProcessLauncher { child: Some(child), port: Some(port), stderr, + program_output, }) } } @@ -495,9 +514,15 @@ fn pump_lines( stream: R, name: String, sink: Arc>>, + program: Option<(ProgramSink, &'static str)>, ) { tokio::spawn(ide_jsonrpc::output::read_lines(stream, move |line| { tracing::debug!(target: "ide_dap::adapter", "[{name}] {line}"); + if let Some((tx, category)) = &program + && !line.starts_with(DELVE_BANNER) + { + let _ = tx.send((category, format!("{line}\n"))); + } push_line(&sink, line); })); } @@ -551,6 +576,18 @@ mod tests { /// Managed tools win over `PATH`, and debugpy falls back to the private copy. #[cfg(unix)] + #[tokio::test] + async fn program_output_is_forwarded_without_the_adapter_banner() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let sink = Arc::default(); + let text: &[u8] = b"DAP server listening at: 127.0.0.1:1\n[hello two words]\nno newline"; + pump_lines(text, "delve".into(), Arc::clone(&sink), Some((tx, "stdout"))); + assert_eq!(rx.recv().await, Some(("stdout", "[hello two words]\n".into()))); + assert_eq!(rx.recv().await, Some(("stdout", "no newline\n".into()))); + assert_eq!(rx.recv().await, None); + assert_eq!(sink.lock().len(), 3); + } + #[tokio::test] async fn managed_tools_and_private_debugpy() { use std::os::unix::fs::PermissionsExt; diff --git a/crates/ide-dap/src/lib.rs b/crates/ide-dap/src/lib.rs index 550f85cfa..638ec8d38 100644 --- a/crates/ide-dap/src/lib.rs +++ b/crates/ide-dap/src/lib.rs @@ -697,6 +697,14 @@ impl DebugManager { }; let session = Arc::new(Session::new(id, owner, name, adapter_name, parent, request, conn, spec, io.port, io.child)); + if let Some(mut output) = io.program_output { + let session = session.clone(); + tokio::spawn(async move { + while let Some((category, output)) = output.recv().await { + session.emit(ide_core::event::DebugEvent::Output { category: category.into(), output }); + } + }); + } self.sessions.lock().insert(id, session.clone()); self.order.lock().push(id); // Announced before the event loop runs, so it precedes every adapter event. diff --git a/crates/ide-dap/src/tests.rs b/crates/ide-dap/src/tests.rs index 16381f157..6bcd57b65 100644 --- a/crates/ide-dap/src/tests.rs +++ b/crates/ide-dap/src/tests.rs @@ -32,7 +32,14 @@ impl AdapterLauncher for FakeLauncher { tokio::spawn(fake_adapter(server, log, script)); let (r, w) = tokio::io::split(client); Box::pin(async move { - Ok(AdapterIo { reader: Box::new(r), writer: Box::new(w), child: None, port: None, stderr: Arc::default() }) + Ok(AdapterIo { + reader: Box::new(r), + writer: Box::new(w), + child: None, + port: None, + stderr: Arc::default(), + program_output: None, + }) }) } } diff --git a/crates/ide-host/src/ops/debug.rs b/crates/ide-host/src/ops/debug.rs index 242960e4f..fcba1485a 100644 --- a/crates/ide-host/src/ops/debug.rs +++ b/crates/ide-host/src/ops/debug.rs @@ -22,6 +22,21 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide Ok(project) => project.settings.effective(), Err(_) => host.global_settings.effective(), }; + if config.adapter == "delve" && config.request == "launch" { + let root = host.project(window).map(|p| p.root.clone()).unwrap_or_default(); + let settings = settings.clone(); + config = host + .blocking(move || { + ide_sdk::go::debug_environment(&root, &settings, &mut config.env); + ide_sdk::go::apply_to_debug( + &mut config.env, + &mut config.extra_json, + &settings.languages.go.build_tags, + )?; + Ok(config) + }) + .await?; + } if config.adapter == ide_dap::kotlin::ADAPTER { let data_dir = host.data_dir.clone(); let runtime_root = config.cwd.clone().map(PathBuf::from).unwrap_or_default(); diff --git a/crates/ide-host/src/ops/run.rs b/crates/ide-host/src/ops/run.rs index 8188b9ce1..9f1f4d5f5 100644 --- a/crates/ide-host/src/ops/run.rs +++ b/crates/ide-host/src/ops/run.rs @@ -97,6 +97,10 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit } else { ide_sdk::apply::resolve_run_command(&mut config, root, &settings); } + if config.kind == "go" { + ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings); + ide_sdk::go::apply_to_run(&mut config, &settings.languages.go.build_tags); + } if config.command == ide_sdk::godot::COMMAND { return Err(IdeError::unavailable(ide_sdk::godot::NOT_FOUND)); } @@ -112,12 +116,32 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit .await .map_err(|e| IdeError::internal(e.to_string()))??; } + if config.kind == "go" && config.extras.params.get("scope").is_some_and(|s| s == "file") { + let root = project.root.clone(); + config = host + .blocking(move || { + ide_run::go::prepare_test_file(&root, &mut config)?; + Ok(config) + }) + .await?; + } let encoding = settings.console.encoding; let options = StartOptions { encoding: Some(encoding).filter(|e| !e.trim().is_empty()), python_paths: if member.is_some() { vec![root.clone()] } else { super::python::paths(&project) }, }; - host.processes.start(&project.root, &config, &options, project.sink.clone(), Box::new(move |c| on_output(c))) + let mut sink = project.sink.clone(); + if config.kind == "go" && matches!(config.args.first().map(String::as_str), Some("mod" | "work")) { + let previous = sink; + let root = project.root.clone(); + sink = std::sync::Arc::new(move |event: ide_core::IdeEvent| { + if matches!(&event, ide_core::IdeEvent::RunExited { .. }) { + ide_sdk::libraries::invalidate_go(&root); + } + previous.emit(event); + }); + } + host.processes.start(&project.root, &config, &options, sink, Box::new(move |c| on_output(c))) } /// Stops a run (interrupt, then kill after Settings › Run's termination timeout). diff --git a/crates/ide-run/src/config.rs b/crates/ide-run/src/config.rs index 7855f074f..63c6a35f3 100644 --- a/crates/ide-run/src/config.rs +++ b/crates/ide-run/src/config.rs @@ -340,7 +340,11 @@ fn write_array(path: &Path, key: &str, configs: &[&RunConfig], suppressed: Optio /// Keeps the workspace-local file out of version control (`.openidea/.gitignore`). fn ignore_local(root: &Path) -> IdeResult<()> { - let entry = format!("/{LOCAL_FILE}"); + ignore_private(root, &format!("/{LOCAL_FILE}")) +} + +/// Adds `entry` to `.openidea/.gitignore` unless it is already listed. +pub(crate) fn ignore_private(root: &Path, entry: &str) -> IdeResult<()> { let file = project_dir::path(root, ".gitignore"); let current = fs::read_to_string(&file).unwrap_or_default(); if current.lines().any(|l| l.trim() == entry) { @@ -352,7 +356,7 @@ fn ignore_local(root: &Path) -> IdeResult<()> { } else if !text.ends_with('\n') { text.push('\n'); } - text.push_str(&entry); + text.push_str(entry); text.push('\n'); if let Some(dir) = file.parent() { fs::create_dir_all(dir)?; diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index a56fec5d6..8c1dd4ce5 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -680,6 +680,19 @@ fn is_go_main_package(dir: &Path) -> bool { } fn detect_go(root: &Path, out: &mut Vec) { + if let Some(work) = read(&root.join("go.work")) { + let mut seen = HashSet::new(); + for member in crate::go::work_modules(&work) { + let dir = root.join(member); + let dir = dir.canonicalize().unwrap_or(dir); + if dir.join("go.mod").is_file() && seen.insert(dir.clone()) { + let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); + let rel = dir.strip_prefix(&canonical_root).unwrap_or(&dir).to_path_buf(); + detect_go_module(&dir, (!rel.as_os_str().is_empty()).then_some(rel), out); + } + } + return; + } if root.join("go.mod").is_file() { detect_go_module(root, None, out); return; diff --git a/crates/ide-run/src/go.rs b/crates/ide-run/src/go.rs new file mode 100644 index 000000000..96169a682 --- /dev/null +++ b/crates/ide-run/src/go.rs @@ -0,0 +1,292 @@ +//! Typed Go configurations use the process service for both build and execution. +use crate::RunConfig; +use ide_core::{IdeError, IdeResult}; +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +pub struct Plan { + pub build: RunConfig, + pub run: Option, +} + +pub fn plan(root: &Path, config: &RunConfig) -> IdeResult> { + if config.kind != "go" || !config.extras.params.contains_key("goKind") { + return Ok(None); + } + let p = &config.extras.params; + let get = |key: &str| p.get(key).map(String::as_str).unwrap_or(""); + let module = root.join(if get("module").is_empty() { "." } else { get("module") }); + let mut build = config.clone(); + build.cwd = Some(module.clone().into()); + let test = get("goKind") == "test"; + if get("scope") == "directory" { + let target = module.join(get("target")); + build.cwd = Some(target.into()); + if let Some(arg) = build.args.iter_mut().skip(1).find(|a| a.as_str() == get("target")) { + *arg = ".".into(); + } + } + if test { + return Ok(Some(Plan { build, run: None })); + } + let output = if get("outputDir").is_empty() { + crate::config::ignore_private(root, "/bin/")?; + ide_core::project_dir::path(root, "bin") + } else { + root.join(get("outputDir")) + }; + std::fs::create_dir_all(&output)?; + let mut hash = std::collections::hash_map::DefaultHasher::new(); + config.id.hash(&mut hash); + let mut binary = output.join(format!("go-app-{:x}{}", hash.finish(), std::env::consts::EXE_SUFFIX)); + // An explicit Go -o selects the executable just as it does on the command line. + let mut i = 1; + while i < build.args.len() { + if build.args[i] == "-o" { + if let Some(path) = build.args.get(i + 1) { + binary = module.join(path); + } + build.args.drain(i..(i + 2).min(build.args.len())); + } else if let Some(path) = build.args[i].strip_prefix("-o=") { + binary = module.join(path); + build.args.remove(i); + } else { + i += 1; + } + } + if binary.is_dir() || binary.to_string_lossy().ends_with(['/', '\\']) { + let target = Path::new(get("target").split('|').next().unwrap_or(".")); + let name = target.file_stem().or_else(|| module.file_name()).unwrap_or_default().to_string_lossy(); + binary = binary.join(format!("{name}{}", std::env::consts::EXE_SUFFIX)); + } + if let Some(dir) = binary.parent() { + std::fs::create_dir_all(dir)?; + } + build.args.splice(1..1, ["-o".into(), binary.to_string_lossy().into_owned()]); + let run = if get("runAfterBuild") == "true" { + let mut run = config.clone(); + run.command = binary.to_string_lossy().into_owned(); + // Program args are stored separately as an argv vector by the form. + run.args = p + .get("programArgv") + .map(|s| serde_json::from_str(s)) + .transpose() + .map_err(|e| IdeError::invalid(e.to_string()))? + .unwrap_or_default(); + run.extras.params.clear(); + Some(run) + } else { + None + }; + Ok(Some(Plan { build, run })) +} + +/// Uses Go's own build-file selection, including platform suffixes, cgo and build tags. +/// `-find` avoids dependency traversal and package downloads for this local directory. +pub fn package_sources(mut command: Command, dir: &Path, flags: &[String]) -> IdeResult> { + command.args(["list", "-find", "-e", "-json"]); + let mut it = flags.iter(); + while let Some(arg) = it.next() { + let name = arg.split('=').next().unwrap_or(arg); + if ["-tags", "-compiler", "-mod", "-modfile", "-overlay"].contains(&name) { + command.arg(arg); + if name == arg + && let Some(value) = it.next() + { + command.arg(value); + } + } else if ["-race", "-msan", "-asan"].contains(&name) { + command.arg(arg); + } + } + command.arg(".").current_dir(dir).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut child = command.spawn()?; + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let reader = std::thread::spawn(move || { + use std::io::Read; + let mut text = String::new(); + if let Some(mut stdout) = stdout { + let _ = stdout.read_to_string(&mut text); + } + text + }); + let errors = std::thread::spawn(move || { + use std::io::Read; + let mut text = String::new(); + if let Some(mut stderr) = stderr { + let _ = stderr.read_to_string(&mut text); + } + text + }); + let deadline = Instant::now() + Duration::from_secs(10); + let status = loop { + match child.try_wait()? { + Some(status) => break status, + None if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(10)), + None => { + let _ = child.kill(); + let _ = child.wait(); + return Err(IdeError::unavailable("Go package discovery timed out")); + } + } + }; + let text = reader.join().unwrap_or_default(); + let errors = errors.join().unwrap_or_default(); + if !status.success() { + return Err(IdeError::invalid(format!("Go package discovery failed: {errors}"))); + } + let package: serde_json::Value = serde_json::from_str(&text).map_err(|e| IdeError::invalid(e.to_string()))?; + Ok(["GoFiles", "CgoFiles"] + .iter() + .flat_map(|key| package[key].as_array().into_iter().flatten()) + .filter_map(|p| p.as_str()) + .map(|p| dir.join(p)) + .collect()) +} + +pub fn prepare_test_file(root: &Path, config: &mut RunConfig) -> IdeResult<()> { + let p = &config.extras.params; + if config.kind != "go" || p.get("goKind").is_none_or(|s| s != "test") || p.get("scope").is_none_or(|s| s != "file") + { + return Ok(()); + } + let target = p.get("target").ok_or_else(|| IdeError::invalid("Go test file target is missing"))?; + let file = root.join(p.get("module").map(String::as_str).unwrap_or(".")).join(target); + let dir = file.parent().ok_or_else(|| IdeError::invalid("Go test file has no directory"))?; + let mut command = Command::new(&config.command); + crate::process::configure_environment(&mut command, root, config)?; + let mut files = package_sources(command, dir, &config.args)?; + files.push(file); + let at = config + .args + .iter() + .position(|a| a == target) + .ok_or_else(|| IdeError::invalid("Go test file target is missing"))?; + config.args.splice(at..=at, files.iter().map(|p| p.to_string_lossy().into_owned())); + Ok(()) +} + +/// Module directories from a go.work file. Quoted paths and comments follow Go's file syntax. +pub fn work_modules(text: &str) -> Vec { + let mut tokens = Vec::new(); + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + match ch { + '\n' | '(' | ')' => tokens.push(ch.to_string()), + ch if ch.is_whitespace() => {} + '/' if chars.peek() == Some(&'/') => { + for c in chars.by_ref() { + if c == '\n' { + tokens.push("\n".into()); + break; + } + } + } + '"' | '`' => { + let Some(token) = quoted_work_path(&mut chars, ch) else { return Vec::new() }; + tokens.push(token); + } + _ => { + let mut token = ch.to_string(); + while chars.peek().is_some_and(|c| !c.is_whitespace() && *c != '(' && *c != ')') { + token.push(chars.next().unwrap()); + } + tokens.push(token); + } + } + } + let mut out = Vec::new(); + let mut start = true; + let mut tokens = tokens.into_iter(); + while let Some(token) = tokens.next() { + if token == "\n" { + start = true; + continue; + } + if start + && token == "use" + && let Some(path) = tokens.next() + { + if path == "(" { + let mut paths = Vec::new(); + let mut closed = false; + for path in tokens.by_ref() { + if path == ")" { + closed = true; + break; + } + if path != "\n" && !path.is_empty() { + paths.push(PathBuf::from(path)); + } + } + if closed { + out.extend(paths); + } + } else if path != "\n" && !path.is_empty() { + out.push(PathBuf::from(path)); + } + } + start = false; + } + let mut seen = std::collections::HashSet::new(); + out.retain(|path| seen.insert(path.clone())); + out +} + +fn quoted_work_path(chars: &mut std::iter::Peekable>, quote: char) -> Option { + let mut bytes = Vec::new(); + while let Some(ch) = chars.next() { + if ch == quote { + return String::from_utf8(bytes).ok(); + } + if ch == '\\' && quote == '"' { + let escaped = chars.next()?; + let byte = match escaped { + 'a' => 7, + 'b' => 8, + 'f' => 12, + 'n' => 10, + 'r' => 13, + 't' => 9, + 'v' => 11, + '\\' => b'\\', + '"' => b'"', + 'x' => { + let digits: String = chars.by_ref().take(2).collect(); + if digits.len() != 2 { + return None; + } + u8::from_str_radix(&digits, 16).ok()? + } + 'u' | 'U' => { + let width = if escaped == 'u' { 4 } else { 8 }; + let digits: String = chars.by_ref().take(width).collect(); + if digits.len() != width { + return None; + } + let ch = char::from_u32(u32::from_str_radix(&digits, 16).ok()?)?; + bytes.extend_from_slice(ch.to_string().as_bytes()); + continue; + } + '0'..='7' => { + let digits = format!("{escaped}{}", chars.by_ref().take(2).collect::()); + if digits.len() != 3 { + return None; + } + u8::from_str_radix(&digits, 8).ok()? + } + _ => return None, + }; + bytes.push(byte); + } else { + if ch == '\n' && quote == '"' { + return None; + } + bytes.extend_from_slice(ch.to_string().as_bytes()); + } + } + None +} diff --git a/crates/ide-run/src/lib.rs b/crates/ide-run/src/lib.rs index 0c6e746ae..db94a4586 100644 --- a/crates/ide-run/src/lib.rs +++ b/crates/ide-run/src/lib.rs @@ -3,6 +3,7 @@ mod config; mod detect; pub mod envfile; +pub mod go; pub mod gradle_jvm; pub mod java_cmdline; pub mod junit; diff --git a/crates/ide-run/src/process.rs b/crates/ide-run/src/process.rs index 7a1f6a869..fe9a69f62 100644 --- a/crates/ide-run/src/process.rs +++ b/crates/ide-run/src/process.rs @@ -49,7 +49,7 @@ struct Entry { id: RunId, config_id: String, name: String, - pid: Option, + pid: Mutex>, stdin: Mutex>>, /// The PTY master of a terminal-emulated run (resize; dropped on exit). pty: Option>>>, @@ -65,7 +65,7 @@ impl Entry { id: self.id, config_id: self.config_id.clone(), name: self.name.clone(), - pid: self.pid, + pid: *self.pid.lock(), running: !self.exited.load(Ordering::Acquire), pty: self.pty.is_some(), } @@ -299,6 +299,12 @@ impl ProcessManager { if config.command.trim().is_empty() { return Err(IdeError::invalid("run configuration has no command")); } + let plan = crate::go::plan(root, config)?; + let followup = plan.as_ref().and_then(|p| p.run.as_ref()).map(|run| { + let cwd = resolve_cwd(root, run.cwd.as_deref()); + (run.clone(), cwd) + }); + let config = plan.as_ref().map_or(config, |p| &p.build); let cwd = resolve_cwd(root, config.cwd.as_deref()); if !cwd.is_dir() { return Err(IdeError::not_found(format!("working directory does not exist: {}", cwd.display()))); @@ -326,6 +332,9 @@ impl ProcessManager { let notice = if extras.emulate_terminal { None } else { match_console_encoding(&mut inv, &mut env, config, options) }; let temp = shorten_java(&mut inv, config, &cwd)?; + if extras.emulate_terminal && followup.is_some() { + return Err(IdeError::invalid("Go build and run requires the output console")); + } if extras.emulate_terminal { return self.start_pty(config, &inv, &cwd, &env, stop, options, sink, on_output, temp); } @@ -377,7 +386,7 @@ impl ProcessManager { id, config_id: config.id.clone(), name: config.name.clone(), - pid: Some(child.id()), + pid: Mutex::new(Some(child.id())), stdin: Mutex::new(stdin), pty: None, stop, @@ -399,7 +408,7 @@ impl ProcessManager { if let Some(err) = stderr { spawn_reader(id, Stream::Stderr, err, tx.clone())?; } - spawn_pump(rx, on_output, entry.clone(), sink, options.encoding.clone(), notice)?; + spawn_pump(rx, on_output.clone(), entry.clone(), sink, options.encoding.clone(), notice)?; Ok(()) })(); if let Err(e) = result { @@ -410,10 +419,17 @@ impl ProcessManager { } // The waiter owns the child; it must exist for the pump to ever finish. let waiter_tx = tx; + let waiter_entry = entry.clone(); + let run_root = root.to_path_buf(); std::thread::Builder::new() .name(format!("openidea-run-wait-{}", id.0)) .spawn(move || { - let status = child.wait(); + let mut status = child.wait(); + if status.as_ref().is_ok_and(ExitStatus::success) + && let Some((run, cwd)) = followup + { + status = run_followup(&run_root, &run, &cwd, &waiter_entry, &waiter_tx); + } drop(temp); let _ = waiter_tx.send(Msg::Exited(Exit::Pipes(status))); }) @@ -479,7 +495,7 @@ impl ProcessManager { id, config_id: config.id.clone(), name: config.name.clone(), - pid: child.process_id(), + pid: Mutex::new(child.process_id()), stdin: Mutex::new(Some(writer)), pty: Some(Mutex::new(Some(pair.master))), stop, @@ -578,8 +594,9 @@ impl ProcessManager { if entry.exited.load(Ordering::Acquire) { return Ok(()); } - let Some(pid) = entry.pid else { return Ok(()) }; - if entry.stopping.swap(true, Ordering::AcqRel) { + let already_stopping = entry.stopping.swap(true, Ordering::AcqRel); + let Some(pid) = *entry.pid.lock() else { return Ok(()) }; + if already_stopping { // Second stop request: don't wait out the grace period. kill_tree(pid, true); return Ok(()); @@ -624,8 +641,9 @@ impl ProcessManager { /// Kills every running process tree at once (shutting down). pub fn kill_all(&self) { for entry in self.runs.lock().values() { + entry.stopping.store(true, Ordering::Release); if !entry.exited.load(Ordering::Acquire) - && let Some(pid) = entry.pid + && let Some(pid) = *entry.pid.lock() { kill_tree(pid, true); } @@ -641,6 +659,67 @@ impl Drop for ProcessManager { /// The environment a configuration's process gets on top of the inherited one: `.env` /// files, then the configured variables (later wins). +fn run_followup( + root: &Path, + config: &RunConfig, + cwd: &Path, + entry: &Entry, + tx: &Sender, +) -> std::io::Result { + let mut pid = entry.pid.lock(); + if entry.stopping.load(Ordering::Acquire) { + return Err(std::io::Error::other("run stopped after build")); + } + let inv = invocation(config, cwd); + let mut cmd = Command::new(&inv.program); + cmd.args(&inv.args).current_dir(cwd).stdout(Stdio::piped()).stderr(Stdio::piped()); + match config.extras.stdin_file.as_deref() { + Some(file) => { + cmd.stdin(std::fs::File::open(resolve_cwd(root, Some(Path::new(file))))?); + } + None => { + cmd.stdin(Stdio::piped()); + } + } + let env = launch_env(root, config).map_err(|e| std::io::Error::other(e.to_string()))?; + apply_env(&mut cmd, &env); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + cmd.process_group(0); + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x0000_0200 | ide_core::process::CREATE_NO_WINDOW); + } + (entry.output)(OutputChunk::System { text: format!("{}\n", inv.display) }); + let mut child = cmd.spawn()?; + *pid = Some(child.id()); + *entry.stdin.lock() = child.stdin.take().map(|p| Box::new(p) as Box); + drop(pid); + let readers = (|| { + if let Some(out) = child.stdout.take() { + spawn_reader(entry.id, Stream::Stdout, out, tx.clone())?; + } + if let Some(err) = child.stderr.take() { + spawn_reader(entry.id, Stream::Stderr, err, tx.clone())?; + } + Ok::<_, std::io::Error>(()) + })(); + if let Err(e) = readers { + let _ = child.kill(); + let _ = child.wait(); + return Err(e); + } + child.wait() +} + +pub(crate) fn configure_environment(command: &mut Command, root: &Path, config: &RunConfig) -> IdeResult<()> { + apply_env(command, &launch_env(root, config)?); + Ok(()) +} + fn launch_env(root: &Path, config: &RunConfig) -> IdeResult { let extras = &config.extras; let mut vars = if extras.env_files.is_empty() { diff --git a/crates/ide-run/tests/detect.rs b/crates/ide-run/tests/detect.rs index 31a672a3f..aa5cfb4a8 100644 --- a/crates/ide-run/tests/detect.rs +++ b/crates/ide-run/tests/detect.rs @@ -550,3 +550,52 @@ fn root_package_disables_nested_scan() { assert!(ids(&c).contains(&"go:test")); assert!(!ids(&c).contains(&"go:test:svc")); } + +#[test] +fn go_work_members_define_configs_including_deep_and_quoted_directories() { + let dir = project(&[ + ( + "go.work", + "go 1.22\n// use ./ignored\nuse (\n ./apps/deep/server // member\n \"./module with spaces\"\n)\nuse ./apps/deep/server\n", + ), + ("apps/deep/server/go.mod", "module example.com/server\ngo 1.22\n"), + ("apps/deep/server/main.go", "package main\nfunc main() {}"), + ("module with spaces/go.mod", "module example.com/lib\ngo 1.22\n"), + ("ignored/go.mod", "module example.com/ignored\ngo 1.22\n"), + ]); + let configs = detect(dir.path()); + assert_eq!(configs.iter().filter(|c| c.kind == "go" && c.is_test).count(), 2); + assert_eq!(find(&configs, "go:run:apps/deep/server").cwd.as_deref(), Some(Path::new("apps/deep/server"))); + assert!(configs.iter().all(|c| !c.id.contains("ignored"))); + assert!(configs.iter().any(|c| c.id == "go:test:module with spaces")); +} + +#[test] +fn go_work_parser_ignores_other_directives_comments_and_malformed_groups() { + use ide_run::go::work_modules; + assert_eq!( + work_modules("replace use => ./not-a-member\n// use ./also-not\nuse ./real\n"), + vec![std::path::PathBuf::from("./real")] + ); + assert!(work_modules("use (\n./missing-close\n").is_empty()); + assert!(work_modules("use \"unterminated").is_empty()); +} + +#[test] +fn go_work_paths_decode_go_string_escapes() { + use ide_run::go::work_modules; + for path in [r#""./\u0061pp""#, r#""./\x61pp""#, r#""./\141pp""#, "`./app`"] { + assert_eq!(work_modules(&format!("use {path}\n")), vec![std::path::PathBuf::from("./app")]); + } + assert!(work_modules(r#"use "./\qinvalid""#).is_empty()); +} + +#[test] +fn go_default_build_output_is_ignored_by_version_control() { + let dir = project(&[("go.mod", "module x\ngo 1.22\n"), ("main.go", "package main\nfunc main() {}")]); + let mut config = detect(dir.path()).into_iter().find(|c| c.kind == "go" && !c.is_test).unwrap(); + config.extras.params.insert("goKind".into(), "package".into()); + assert!(ide_run::go::plan(dir.path(), &config).unwrap().is_some()); + let ignore = fs::read_to_string(dir.path().join(".openidea/.gitignore")).unwrap(); + assert!(ignore.lines().any(|l| l == "/bin/"), "{ignore}"); +} diff --git a/crates/ide-run/tests/process.rs b/crates/ide-run/tests/process.rs index 316a411f0..47d67386c 100644 --- a/crates/ide-run/tests/process.rs +++ b/crates/ide-run/tests/process.rs @@ -473,3 +473,76 @@ fn python_scripts_import_sibling_packages_and_preserve_env_file_paths() { assert_eq!(text(&chunks, stdout), "import OK vendor OK\n"); } } + +#[test] +fn go_build_runs_with_separate_arguments_environment_and_working_directory() { + let h = harness(); + let go = + ide_run::which::find_executable("go", &std::env::var_os("PATH").unwrap_or_default()).expect("Go toolchain"); + let module = h.root.path().join("module"); + std::fs::create_dir_all(&module).unwrap(); + std::fs::write(module.join("go.mod"), "module example.com/demo\ngo 1.22\n").unwrap(); + std::fs::write(module.join("main.go"), "//go:build demo\n\npackage main\nimport (\"fmt\"; \"os\")\nfunc main(){cwd,_:=os.Getwd();fmt.Printf(\"%q %s %s\\n\",os.Args[1:],os.Getenv(\"GO_RUN_VALUE\"),cwd)}\n").unwrap(); + let mut c = cfg("go-config", &go.to_string_lossy(), &["build", "-tags", "demo", "."]); + c.kind = "go".into(); + c.env.insert("GO_RUN_VALUE".into(), "configured".into()); + c.extras.params = BTreeMap::from([ + ("goKind".into(), "build".into()), + ("scope".into(), "package".into()), + ("target".into(), ".".into()), + ("module".into(), "module".into()), + ("outputDir".into(), "bin with spaces".into()), + ("runAfterBuild".into(), "true".into()), + ("programArgv".into(), r#"["-tags","two words"]"#.into()), + ]); + let (id, out) = start(&h, &c); + assert_eq!(wait_exit(&h, id, Duration::from_secs(60)), Some(0), "{}", text(&out, stderr)); + let output = text(&out, stdout); + assert!(output.contains(r#"["-tags" "two words"] configured"#), "{output}"); + assert!(output.contains(&h.root.path().display().to_string()), "{output}"); + assert!(std::fs::read_dir(h.root.path().join("bin with spaces")).unwrap().next().is_some()); + + c.extras.params.insert("runAfterBuild".into(), "false".into()); + let (id, out) = start(&h, &c); + assert_eq!(wait_exit(&h, id, Duration::from_secs(60)), Some(0)); + assert!(text(&out, stdout).is_empty()); + + c.extras.params.insert("runAfterBuild".into(), "true".into()); + c.args = vec!["build".into(), "missing.go".into()]; + let (id, out) = start(&h, &c); + assert_ne!(wait_exit(&h, id, Duration::from_secs(60)), Some(0)); + assert!(text(&out, stdout).is_empty(), "a failed build must never run an older binary"); +} + +#[test] +fn go_file_tests_use_build_selected_sources_and_only_the_selected_test_file() { + let h = harness(); + let go = + ide_run::which::find_executable("go", &std::env::var_os("PATH").unwrap_or_default()).expect("Go toolchain"); + std::fs::write(h.root.path().join("go.mod"), "module example.com/filetests\ngo 1.22\n").unwrap(); + std::fs::write(h.root.path().join("main.go"), "package filetests\nfunc value() int { return 2 }\n").unwrap(); + std::fs::write(h.root.path().join("generate.go"), "//go:build ignore\n\npackage main\nfunc main() {}\n").unwrap(); + std::fs::write( + h.root.path().join("demo_test.go"), + "package filetests\nimport \"testing\"\nfunc TestChosen(t *testing.T) { if value()!=2 { t.Fatal(\"bad\") } }\n", + ) + .unwrap(); + std::fs::write( + h.root.path().join("other_test.go"), + "package filetests\nimport \"testing\"\nfunc TestOther(t *testing.T) { t.Fatal(\"must not run\") }\n", + ) + .unwrap(); + let mut c = cfg("go-file", &go.to_string_lossy(), &["test", "-json", "demo_test.go"]); + c.kind = "go".into(); + c.extras.params = BTreeMap::from([ + ("goKind".into(), "test".into()), + ("scope".into(), "file".into()), + ("target".into(), "demo_test.go".into()), + ]); + ide_run::go::prepare_test_file(h.root.path(), &mut c).unwrap(); + assert!(!c.args.iter().any(|a| a.ends_with("generate.go") || a.ends_with("other_test.go"))); + let (id, out) = start(&h, &c); + assert_eq!(wait_exit(&h, id, Duration::from_secs(60)), Some(0), "{}", text(&out, stderr)); + assert!(text(&out, stdout).contains("TestChosen")); + assert!(!text(&out, stdout).contains("TestOther")); +} diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 297f14f75..ea0f406ef 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -358,6 +358,22 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root } if name == "gopls" { + let mut build = crate::go::build_settings(&s.languages.go.build_tags); + let module_env = crate::go::module_environment(server_root, &s.languages.go.modules); + for (key, value) in module_env { + build["env"][key] = json!(value); + } + let mut settings = cfg.settings.take().unwrap_or_else(|| json!({})); + // gopls retains omitted keys on reconfiguration. Explicit empty values + // restore defaults when the last project constraint is cleared. + let init = cfg.initialization_options.as_ref().and_then(|v| serde_json::to_value(v).ok()); + for (key, default) in [("buildFlags", json!([])), ("env", json!({}))] { + if settings.get(key).is_none() { + settings[key] = init.as_ref().and_then(|v| v.get(key)).cloned().unwrap_or(default); + } + } + merge(&mut settings, build); + cfg.settings = Some(settings); if let Some(goroot) = goroot(root, s) { path_dirs.push(goroot.join("bin")); cfg.env.insert("GOROOT".into(), goroot.to_string_lossy().into_owned()); @@ -625,6 +641,9 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings } } "go" => { + for (key, value) in crate::go::module_environment(root, &s.languages.go.modules) { + c.env.entry(key).or_insert(value); + } if let Some(goroot) = &go { let bin = goroot.join("bin"); if c.command == "go" && bin.join(exe("go")).is_file() { diff --git a/crates/ide-sdk/src/go.rs b/crates/ide-sdk/src/go.rs new file mode 100644 index 000000000..3a30f772c --- /dev/null +++ b/crates/ide-sdk/src/go.rs @@ -0,0 +1,329 @@ +//! Go build constraints shared by language services and command execution. +use ide_core::settings::GoBuildTags; +use serde_json::{Value, json}; + +pub fn custom_tags(tags: &str) -> Vec<&str> { + tags.split(|c: char| c.is_whitespace() || c == ',').filter(|s| !s.is_empty()).collect() +} + +/// The build category of gopls settings uses flat protocol keys (`buildFlags`, `env`). +/// Keep these in protocol settings so changes do not restart the server process. +pub fn build_settings(tags: &GoBuildTags) -> Value { + let mut out = json!({}); + let mut flags = Vec::new(); + let mut selected = custom_tags(&tags.custom); + if !tags.version.trim().is_empty() { + selected.push(tags.version.trim()); + } + if !selected.is_empty() { + flags.push(format!("-tags={}", selected.join(","))); + } + if !tags.compiler.trim().is_empty() { + flags.push(format!("-compiler={}", tags.compiler.trim())); + } + if !flags.is_empty() { + out["buildFlags"] = json!(flags); + } + for (key, value) in [("GOOS", &tags.os), ("GOARCH", &tags.arch), ("CGO_ENABLED", &tags.cgo)] { + if !value.trim().is_empty() { + out["env"][key] = json!(value.trim()); + } + } + let experiments = tags.experiments.split(',').map(str::trim).filter(|s| !s.is_empty()).collect::>(); + if !experiments.is_empty() { + out["env"]["GOEXPERIMENT"] = json!(experiments.join(",")); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_do_not_override_toolchain_or_existing_server_settings() { + assert_eq!(build_settings(&GoBuildTags::default()), json!({})); + assert!(custom_tags(" , \n ").is_empty()); + } + + #[test] + fn constraints_map_to_protocol_build_settings() { + let tags = GoBuildTags { + os: "linux".into(), + arch: "arm64".into(), + version: "go1.22".into(), + compiler: "gc".into(), + cgo: "0".into(), + custom: "integration debug,feature".into(), + experiments: "loopvar, rangefunc ".into(), + }; + assert_eq!( + build_settings(&tags), + json!({ + "buildFlags": ["-tags=integration,debug,feature,go1.22", "-compiler=gc"], + "env": {"GOOS":"linux", "GOARCH":"arm64", "CGO_ENABLED":"0", "GOEXPERIMENT":"loopvar,rangefunc"} + }) + ); + } +} + +/// Project build constraints for Go command launches. Explicit run environment wins. +pub fn apply_to_run(config: &mut ide_run::RunConfig, tags: &GoBuildTags) { + let settings = build_settings(tags); + if let Some(env) = settings["env"].as_object() { + for (key, value) in env { + if let Some(value) = value.as_str() { + config.env.entry(key.clone()).or_insert_with(|| value.into()); + } + } + } + if config.extras.params.get("useTags").is_some_and(|v| v == "true") { + let extra = settings["buildFlags"] + .as_array() + .into_iter() + .flatten() + .filter_map(|v| v.as_str()) + .map(str::to_owned) + .collect::>(); + let mut args = config.args.split_off(usize::from(!config.args.is_empty())); + merge_flags(&mut args, &extra); + config.args.extend(args); + } +} + +fn merge_flags(args: &mut Vec, extra: &[String]) { + let boundary = args.iter().position(|a| a == "-args" || a == "--").unwrap_or(args.len()); + let tail = args.split_off(boundary); + let mut tags = Vec::new(); + let mut merged = Vec::new(); + let mut all = extra.iter().chain(args.iter()); + while let Some(arg) = all.next() { + if arg == "-tags" { + if let Some(value) = all.next() { + tags.push(value.as_str()); + } + } else if let Some(value) = arg.strip_prefix("-tags=") { + tags.push(value); + } else { + merged.push(arg.clone()); + } + } + if !tags.is_empty() { + merged.insert(0, format!("-tags={}", tags.join(","))); + } + merged.extend(tail); + *args = merged; +} + +fn delve_build_flags(args: &[String], files: bool) -> Value { + if !files { + return json!(args); + } + json!( + args.iter() + .map(|arg| format!("'{}'", arg.replace('\\', "\\\\").replace('\'', "\\'"))) + .collect::>() + .join(" ") + ) +} + +pub fn apply_to_debug( + env: &mut std::collections::BTreeMap, + extra_json: &mut Option, + tags: &GoBuildTags, +) -> ide_core::IdeResult<()> { + let mut extra: Value = extra_json + .as_deref() + .map(serde_json::from_str) + .transpose() + .map_err(|e| ide_core::IdeError::invalid(e.to_string()))? + .unwrap_or_else(|| json!({})); + let settings = build_settings(tags); + if let Some(values) = settings["env"].as_object() { + for (key, value) in values { + if let Some(value) = value.as_str() { + env.entry(key.clone()).or_insert_with(|| value.into()); + } + } + } + if extra.get("__goToolArgs").is_some() { + let mut args: Vec = extra + .get("__goToolArgs") + .cloned() + .map(serde_json::from_value) + .transpose() + .map_err(|e| ide_core::IdeError::invalid(e.to_string()))? + .unwrap_or_default(); + if extra.get("__goUseTags").and_then(Value::as_bool) == Some(true) { + let flags = settings["buildFlags"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect::>(); + merge_flags(&mut args, &flags); + } + if let Some(files) = extra.get("__goFiles").and_then(Value::as_array) { + if extra["mode"] == "test" { + if let Some(dir) = files.first().and_then(Value::as_str).and_then(|f| std::path::Path::new(f).parent()) + { + let mut command = std::process::Command::new("go"); + ide_core::process::host_env(&mut command); + command.envs(env.iter()); + let sources = ide_run::go::package_sources(command, dir, &args)?; + args.extend(sources.iter().map(|p| p.to_string_lossy().into_owned())); + } + } else { + args.extend(files.iter().skip(1).filter_map(Value::as_str).map(str::to_owned)); + } + } + // Delve appends its -o after array build flags; positional source files + // require the string form so all compiler flags precede the file list. + extra["buildFlags"] = delve_build_flags(&args, extra.get("__goFiles").is_some()); + } + if let Some(output) = extra.get("output").and_then(Value::as_str).and_then(|p| std::path::Path::new(p).parent()) { + std::fs::create_dir_all(output)?; + } + if let Some(map) = extra.as_object_mut() { + map.remove("__goUseTags"); + map.remove("__goToolArgs"); + map.remove("__goFiles"); + } + *extra_json = Some(extra.to_string()); + Ok(()) +} + +#[cfg(test)] +mod command_tests { + use super::*; + use std::collections::BTreeMap; + + #[test] + fn merges_custom_tags_without_touching_program_arguments() { + let mut flags = vec!["-tags".into(), "explicit".into(), ".".into(), "-args".into(), "-tags=program".into()]; + merge_flags(&mut flags, &["-tags=project,debug".into()]); + assert_eq!(flags, ["-tags=project,debug,explicit", ".", "-args", "-tags=program"]); + } + + #[test] + fn file_debug_flags_keep_delves_output_flags_before_source_files() { + let flags = vec!["-tags=demo".into(), "C:\\a b\\main.go".into(), "quote'file.go".into()]; + assert_eq!(delve_build_flags(&flags, false), json!(flags)); + assert_eq!(delve_build_flags(&flags, true), json!("'-tags=demo' 'C:\\\\a b\\\\main.go' 'quote\\'file.go'")); + } + + #[test] + fn debug_env_and_flags_use_the_same_constraints_and_preserve_overrides() { + let tags = GoBuildTags { custom: "project".into(), os: "linux".into(), ..Default::default() }; + let mut env = BTreeMap::from([("GOOS".into(), "darwin".into())]); + let mut extra = + Some(json!({"mode":"test", "__goUseTags":true, "__goToolArgs":["-tags", "explicit"]}).to_string()); + apply_to_debug(&mut env, &mut extra, &tags).unwrap(); + let extra: Value = serde_json::from_str(extra.as_deref().unwrap()).unwrap(); + assert_eq!(extra["buildFlags"], json!(["-tags=project,explicit"])); + assert!(extra.get("__goUseTags").is_none()); + assert_eq!(env["GOOS"], "darwin"); + assert!(apply_to_debug(&mut env, &mut Some("{".into()), &tags).is_err()); + } +} + +pub fn debug_environment( + root: &std::path::Path, + settings: &ide_core::settings::Settings, + env: &mut std::collections::BTreeMap, +) { + for (key, value) in module_environment(root, &settings.languages.go.modules) { + env.entry(key).or_insert(value); + } + if let Some(goroot) = crate::apply::goroot(root, settings) { + env.entry("GOROOT".into()).or_insert_with(|| goroot.to_string_lossy().into_owned()); + env.entry("PATH".into()).or_insert_with(|| crate::exec::path_with(&[&goroot.join("bin")])); + } + if let Some(gopath) = crate::apply::setting_path(root, &settings.languages.go.gopath) { + env.entry("GOPATH".into()).or_insert_with(|| gopath.to_string_lossy().into_owned()); + } +} + +pub fn module_environment( + root: &std::path::Path, + modules: &ide_core::settings::GoModules, +) -> std::collections::BTreeMap { + let mut env: std::collections::BTreeMap = modules + .env + .iter() + .filter(|(key, value)| { + !key.is_empty() + && !key.contains(['=', '\0']) + && !key.contains(char::is_whitespace) + && !value.trim().is_empty() + && !value.contains('\0') + }) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + for key in ["GOWORK", "GOMODCACHE"] { + if let Some(value) = env.get_mut(key) + && value != "off" + && value != "auto" + { + *value = root.join(value.as_str()).to_string_lossy().into_owned(); + } + } + if !modules.auto_vendor { + let flags = env + .get("GOFLAGS") + .cloned() + .or_else(|| ide_core::process::host_var_os("GOFLAGS").map(|v| v.to_string_lossy().into_owned())) + .unwrap_or_default(); + if !flags.split_whitespace().any(|flag| flag.starts_with("-mod=")) { + let workspace = match env.get("GOWORK").map(String::as_str) { + Some("off") => false, + Some(path) if path != "auto" => !path.is_empty(), + _ => root.ancestors().any(|dir| dir.join("go.work").is_file()), + }; + env.insert( + "GOFLAGS".into(), + format!("{} -mod={}", flags.trim(), if workspace { "readonly" } else { "mod" }).trim().into(), + ); + } + } + env +} + +#[cfg(test)] +mod module_tests { + use super::*; + use ide_core::settings::GoModules; + use std::collections::BTreeMap; + + #[test] + fn defaults_and_invalid_entries_leave_the_environment_unchanged() { + let dir = tempfile::tempdir().unwrap(); + assert!(module_environment(dir.path(), &GoModules::default()).is_empty()); + let modules = GoModules { + env: BTreeMap::from([ + ("BAD KEY".into(), "x".into()), + ("GOPROXY".into(), "".into()), + ("GOPRIVATE".into(), "example.com/private".into()), + ]), + ..Default::default() + }; + assert_eq!( + module_environment(dir.path(), &modules), + BTreeMap::from([("GOPRIVATE".into(), "example.com/private".into())]) + ); + } + + #[test] + fn disabling_automatic_vendor_uses_workspace_legal_flags_and_keeps_explicit_flags() { + let dir = tempfile::tempdir().unwrap(); + let mut modules = GoModules { auto_vendor: false, env: BTreeMap::from([("GOFLAGS".into(), "-race".into())]) }; + assert_eq!(module_environment(dir.path(), &modules)["GOFLAGS"], "-race -mod=mod"); + std::fs::write(dir.path().join("go.work"), "go 1.22\n").unwrap(); + assert_eq!(module_environment(dir.path(), &modules)["GOFLAGS"], "-race -mod=readonly"); + modules.env.insert("GOWORK".into(), "off".into()); + assert_eq!(module_environment(dir.path(), &modules)["GOFLAGS"], "-race -mod=mod"); + modules.env.insert("GOFLAGS".into(), "-mod=vendor".into()); + assert_eq!(module_environment(dir.path(), &modules)["GOFLAGS"], "-mod=vendor"); + } +} diff --git a/crates/ide-sdk/src/lib.rs b/crates/ide-sdk/src/lib.rs index 3d178c62c..ac98b5716 100644 --- a/crates/ide-sdk/src/lib.rs +++ b/crates/ide-sdk/src/lib.rs @@ -9,6 +9,7 @@ mod angular; pub mod apply; mod discover; mod exec; +pub mod go; pub mod godot; pub mod java; pub mod jdtls; diff --git a/crates/ide-sdk/src/libraries.rs b/crates/ide-sdk/src/libraries.rs index 08b1161bb..4c50b2f55 100644 --- a/crates/ide-sdk/src/libraries.rs +++ b/crates/ide-sdk/src/libraries.rs @@ -92,6 +92,10 @@ struct Cached { static CACHE: LazyLock>> = LazyLock::new(Default::default); +pub fn invalidate_go(root: &Path) { + CACHE.lock().unwrap_or_else(|e| e.into_inner()).remove(&(root.to_path_buf(), "go")); +} + fn stamp(files: &[PathBuf]) -> Stamp { files .iter() @@ -410,18 +414,27 @@ pub(crate) fn parse_cargo_metadata(json: &str) -> Vec<(String, PathBuf)> { // ------------------------------------------------------------------ go fn go_groups(root: &Path, s: &Settings) -> Vec { - let Some(files) = manifests(root, GO_MANIFESTS) else { return Vec::new() }; + let Some(mut files) = manifests(root, GO_MANIFESTS) else { return Vec::new() }; if !root.join("go.mod").is_file() && !root.join("go.work").is_file() { return Vec::new(); } + if let Ok(work) = std::fs::read_to_string(root.join("go.work")) { + for module in ide_run::go::work_modules(&work) { + files.extend([root.join(&module).join("go.mod"), root.join(&module).join("go.sum")]); + } + } + files.extend([root.join("go.work.sum"), root.join("vendor/modules.txt")]); let g = &s.languages.go; - cached(root, "go", format!("{}\n{}", g.goroot, g.gopath), &files, || { + cached(root, "go", serde_json::to_string(g).unwrap_or_default(), &files, || { let goroot = crate::apply::goroot(root, s); let Some(go) = tool(goroot.as_ref().map(|r| r.join("bin")), "go") else { return Vec::new() }; let goroot_s = goroot.as_ref().map(|p| p.to_string_lossy().into_owned()); let gopath = crate::apply::setting_path(root, &g.gopath).map(|p| p.to_string_lossy().into_owned()); // Never download modules or toolchains just to list libraries. - let mut env = vec![("GOPROXY", "off"), ("GOTOOLCHAIN", "local")]; + let module_env = crate::go::module_environment(root, &g.modules); + let mut env: Vec<(&str, &str)> = module_env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); + // Listing libraries must stay offline even when module commands use a proxy. + env.extend([("GOPROXY", "off"), ("GOTOOLCHAIN", "local")]); env.extend(goroot_s.as_deref().map(|v| ("GOROOT", v))); env.extend(gopath.as_deref().map(|v| ("GOPATH", v))); let mut out = Vec::new(); @@ -436,13 +449,38 @@ fn go_groups(root: &Path, s: &Settings) -> Vec { out.extend(group(name, "go", library_roots(vec![Path::new(dir).join("src")]))); } } - if let Some(text) = query_in(&go, &["list", "-m", "-e", "-json", "all"], Some(root), &env, GRAPH_TIMEOUT) { + let flags = module_env.get("GOFLAGS").map(String::as_str).unwrap_or(""); + let vendor = root.join("vendor"); + let use_vendor = vendor.is_dir() + && (flags.split_whitespace().any(|f| f == "-mod=vendor") + || (g.modules.auto_vendor && !flags.split_whitespace().any(|f| f.starts_with("-mod=")))); + if use_vendor { + if let Ok(text) = std::fs::read_to_string(vendor.join("modules.txt")) { + let modules = vendor_roots(&vendor, &text); + out.extend(group("Go Modules", "goModules", named_roots(modules))); + } + } else if let Some(text) = query_in(&go, &["list", "-m", "-e", "-json", "all"], Some(root), &env, GRAPH_TIMEOUT) + { out.extend(group("Go Modules", "goModules", named_roots(parse_go_modules(&text)))); } out }) } +fn vendor_roots(vendor: &Path, text: &str) -> Vec<(String, PathBuf)> { + let mut seen = std::collections::HashSet::new(); + text.lines() + .filter_map(|line| { + let module = line.strip_prefix("# ")?.split_whitespace().next()?; + if module == "=>" || !seen.insert(module) { + return None; + } + let path = vendor.join(module); + path.is_dir().then(|| (module.to_string(), path)) + }) + .collect() +} + /// `(path@version, dir)` of the downloaded non-main modules in `go list -m -json` output. pub(crate) fn parse_go_modules(text: &str) -> Vec<(String, PathBuf)> { #[derive(Deserialize)] @@ -467,3 +505,34 @@ pub(crate) fn parse_go_modules(text: &str) -> Vec<(String, PathBuf)> { }) .collect() } + +#[cfg(test)] +mod go_cache_tests { + use super::*; + #[test] + fn vendor_roots_include_local_replacements_once() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("example.com/local")).unwrap(); + let text = "# example.com/local v0.0.0 => ../local\n## explicit; go 1.22\nexample.com/local\n# example.com/local => ../local\n"; + assert_eq!( + vendor_roots(dir.path(), text), + vec![("example.com/local".into(), dir.path().join("example.com/local"))] + ); + } + + #[test] + fn go_commands_invalidate_the_graph_even_when_manifests_are_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let count = std::cell::Cell::new(0); + let detect = || { + count.set(count.get() + 1); + Vec::new() + }; + cached(dir.path(), "go", "same".into(), &[], detect); + cached(dir.path(), "go", "same".into(), &[], detect); + assert_eq!(count.get(), 1); + invalidate_go(dir.path()); + cached(dir.path(), "go", "same".into(), &[], detect); + assert_eq!(count.get(), 2); + } +} diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 59adefe02..1dcfee226 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -967,3 +967,70 @@ fn node_on_the_search_path_keeps_its_version_manager_as_source() { assert_eq!(source("/home/me/.local/bin/node"), "path"); assert_eq!(source("/usr/bin/node"), "system"); } + +#[test] +fn go_constraints_change_only_protocol_settings() { + use serde_json::json; + let tmp = tempfile::tempdir().unwrap(); + let mut settings = Settings::default(); + let mut original = server("gopls"); + original.settings = Some(json!({"analyses":{"unusedparams":true}})); + let mut defaults = original.clone(); + configure_server(&mut defaults, tmp.path(), tmp.path(), &settings); + assert_eq!(defaults.settings, Some(json!({"analyses":{"unusedparams":true}, "buildFlags":[], "env":{}}))); + defaults.settings = original.settings.clone(); + assert_eq!(defaults, original); + settings.languages.go.build_tags.custom = "integration".into(); + settings.languages.go.build_tags.os = "linux".into(); + let mut configured = original.clone(); + configure_server(&mut configured, tmp.path(), tmp.path(), &settings); + assert_eq!( + configured.settings.take(), + Some(json!({ + "analyses":{"unusedparams":true}, "buildFlags":["-tags=integration"], "env":{"GOOS":"linux"} + })) + ); + configured.settings = original.settings.clone(); + assert_eq!(configured, original, "launch configuration stays unchanged for in-place reconfiguration"); + let mut other = server("rust-analyzer"); + configure_server(&mut other, tmp.path(), tmp.path(), &settings); + assert_eq!(other.settings, None); +} + +#[test] +fn go_empty_constraints_preserve_explicit_server_defaults() { + use serde_json::json; + let tmp = tempfile::tempdir().unwrap(); + let mut cfg = server("gopls"); + cfg.initialization_options = + Some(toml::Value::try_from(json!({"buildFlags":["-tags=legacy"], "env":{"GOPROXY":"direct"}})).unwrap()); + configure_server(&mut cfg, tmp.path(), tmp.path(), &Settings::default()); + assert_eq!(cfg.settings, Some(json!({"buildFlags":["-tags=legacy"], "env":{"GOPROXY":"direct"}}))); +} + +#[test] +fn go_modules_configure_gopls_in_place_and_commands_with_explicit_run_overrides() { + use serde_json::json; + let tmp = tempfile::tempdir().unwrap(); + let mut settings = Settings::default(); + settings.languages.go.modules.env = BTreeMap::from([ + ("GOPROXY".into(), "https://proxy.example".into()), + ("GOPRIVATE".into(), "example.com/private".into()), + ("GOFLAGS".into(), "-race".into()), + ]); + settings.languages.go.modules.auto_vendor = false; + let mut cfg = server("gopls"); + configure_server(&mut cfg, tmp.path(), tmp.path(), &settings); + let protocol = cfg.settings.take().unwrap(); + assert_eq!(protocol["env"]["GOFLAGS"], "-race -mod=mod"); + assert_eq!(protocol["env"]["GOPRIVATE"], "example.com/private"); + assert!(cfg.env.is_empty(), "module environment changes must not restart gopls"); + let mut run = run("go", "go", &["mod", "tidy"]); + run.env.insert("GOPROXY".into(), "off".into()); + apply_to_run_configs(std::slice::from_mut(&mut run), tmp.path(), &settings); + assert_eq!(run.env["GOPROXY"], "off"); + assert_eq!(run.env["GOPRIVATE"], "example.com/private"); + let mut cleared = server("gopls"); + configure_server(&mut cleared, tmp.path(), tmp.path(), &Settings::default()); + assert_eq!(cleared.settings.unwrap()["env"], json!({})); +} diff --git a/src/bindings.ts b/src/bindings.ts index 22c09faed..ad7223a83 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -3234,11 +3234,30 @@ export type GitSettings = { combineStashAndShelf?: boolean, }; +export type GoBuildTags = { + /** Empty values inherit the selected toolchain's defaults. */ + os?: string, + arch?: string, + version?: string, + compiler?: string, + /** Empty = automatic, "1" = enabled, "0" = disabled. */ + cgo?: string, + custom?: string, + experiments?: string, +}; + +export type GoModules = { + env?: { [key in string]: string }, + autoVendor?: boolean, +}; + export type GoSettings = { /** Empty = the `go` on `PATH`. */ goroot?: string, /** Empty = `go env GOPATH`. */ gopath?: string, + buildTags?: GoBuildTags, + modules?: GoModules, }; export type GotoKind = "definition" | "declaration" | "typeDefinition" | "implementation"; diff --git a/src/features/code-insight/goConstraints.test.ts b/src/features/code-insight/goConstraints.test.ts new file mode 100644 index 000000000..33f80459d --- /dev/null +++ b/src/features/code-insight/goConstraints.test.ts @@ -0,0 +1,14 @@ +import { expect, it } from "vitest"; +import { goConstraintExcluded } from "./goConstraints"; + +it("recognizes build exclusion without confusing unrelated package errors", () => { + expect( + goConstraintExcluded("No packages found for open file x.go.\nThis file may be excluded due to its build tags"), + ).toBe(true); + expect(goConstraintExcluded("This file may be excluded due to its GOOS/GOARCH, or other build constraints.")).toBe( + true, + ); + expect(goConstraintExcluded("No packages found for open file x.go.")).toBe(false); + expect(goConstraintExcluded("undefined: missingSymbol")).toBe(false); + expect(goConstraintExcluded("invalid build tags syntax")).toBe(false); +}); diff --git a/src/features/code-insight/goConstraints.ts b/src/features/code-insight/goConstraints.ts new file mode 100644 index 000000000..dacc80d86 --- /dev/null +++ b/src/features/code-insight/goConstraints.ts @@ -0,0 +1,4 @@ +/** Only gopls diagnostics that explicitly identify build exclusion qualify. */ +export function goConstraintExcluded(message: string): boolean { + return /excluded[^\n]*(?:build|GOOS\/GOARCH)/i.test(message); +} diff --git a/src/features/code-insight/index.ts b/src/features/code-insight/index.ts index cca63ee9a..1ad1f06e4 100644 --- a/src/features/code-insight/index.ts +++ b/src/features/code-insight/index.ts @@ -5,7 +5,12 @@ */ import { registerActions } from "@/platform/actions"; import { useProject } from "@/platform/project"; -import { registerOverlay, registerStatusItem, registerToolWindow } from "@/workbench/contributions"; +import { + registerEditorBanner, + registerOverlay, + registerStatusItem, + registerToolWindow, +} from "@/workbench/contributions"; import { clearProblems, installProblemsEvents, useProblems } from "./problemsStore"; import { installProblemDecorations } from "./decorations"; import { ProblemsHeaderActions, ProblemsTabs, ProblemsView, useProblemsHeaderMenu } from "./ProblemsView"; @@ -15,6 +20,7 @@ import { findUsages, rerunActiveUsages, resetUsages, showUsageResults, showUsage import { HierarchyTabs, HierarchyView } from "./HierarchyView"; import { resetHierarchy, showHierarchy } from "./hierarchy"; import { installServerEvents } from "./servers"; +import { goConstraintExcluded } from "./goConstraints"; import { ServerStatus } from "./ServerStatus"; const EDITOR_WHEN = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)"; @@ -41,6 +47,16 @@ export function activate(): () => void { }); const disposers = [ unsubProject, + registerEditorBanner({ + id: "go.buildConstraints", + useBanner: (path) => { + const excluded = useProblems( + (s) => + path.endsWith(".go") && (s.map.get(path)?.get("gopls") ?? []).some((d) => goConstraintExcluded(d.message)), + ); + return excluded ? { text: "This Go file is excluded by the current build constraints." } : null; + }, + }), installProblemsEvents(), installProblemDecorations(), installServerEvents(), diff --git a/src/features/debugger/__tests__/launch.test.ts b/src/features/debugger/__tests__/launch.test.ts index 247916360..56a71e5f3 100644 --- a/src/features/debugger/__tests__/launch.test.ts +++ b/src/features/debugger/__tests__/launch.test.ts @@ -259,3 +259,51 @@ describe("run config → launch config", () => { expect(extra(relative)).toEqual({ project: "/p/game", scene: "res://menu.tscn" }); }); }); + +describe("Go build configuration fields", () => { + it("separates split build flags from program args for legacy run and test configs", () => { + for (const sub of ["run", "test"]) { + const launch = launchConfigFor( + rc({ + kind: "go", + command: "go", + args: [sub, "-tags", "demo", "-gcflags", "all=-N -l", "./cmd/app", "--", "hello"], + debugAdapter: "delve", + }), + "/p", + )!; + expect(launch.program).toBe("/p/cmd/app"); + expect(launch.args).toEqual(["hello"]); + expect(JSON.parse(launch.extraJson!).buildFlags).toBe("-tags demo -gcflags 'all=-N -l'"); + } + }); + it("uses the selected module for building and the working directory for execution", () => { + const launch = launchConfigFor( + rc({ + kind: "go", + command: "go", + args: ["build", "."], + cwd: "runtime", + debugAdapter: "delve", + params: { + goKind: "build", + scope: "directory", + target: "cmd/app", + module: "backend", + toolArgs: "-tags demo", + programArgs: 'hello "two words"', + useTags: "true", + }, + }), + "/p", + )!; + expect(launch.program).toBe("/p/backend/cmd/app"); + expect(launch.cwd).toBe("/p/runtime"); + expect(launch.args).toEqual(["hello", "two words"]); + expect(JSON.parse(launch.extraJson!)).toMatchObject({ + mode: "debug", + __goToolArgs: ["-tags", "demo"], + __goUseTags: true, + }); + }); +}); diff --git a/src/features/debugger/launch.ts b/src/features/debugger/launch.ts index 36477017b..4bd480b9c 100644 --- a/src/features/debugger/launch.ts +++ b/src/features/debugger/launch.ts @@ -1,3 +1,4 @@ +import { parseArgs, formatArgs } from "@/platform/commandLine"; /** Run configuration → DAP launch configuration, per adapter. */ import { workflowSettings } from "@/platform/workflowSettings"; import type { LaunchConfig, RunConfig } from "@/platform/ipc"; @@ -169,34 +170,98 @@ function jsDebugLaunch(rc: RunConfig, cwd: string): LaunchConfig { return out; } -function delveLaunch(rc: RunConfig, cwd: string): LaunchConfig { +function delveLaunch(rc: RunConfig, cwd: string, root: string): LaunchConfig { const out = base(rc, "delve", cwd); - const [sub, ...rest] = rc.args; - const [goArgs, programArgs] = splitAtDashDash(rest); - if (sub === "test") { - // `go test` selection flags become the test binary's (`-run ^TestX$` → `-test.run ^TestX$`). - const testFlags: string[] = []; - let pkg: string | undefined; - for (let i = 0; i < goArgs.length; i++) { - const a = goArgs[i]!; - const flag = /^--?(run|skip)(?:=(.*))?$/.exec(a); - if (flag) { - const value = flag[2] ?? goArgs[++i]; - if (value !== undefined) testFlags.push(`-test.${flag[1]}`, value); - } else if (!a.startsWith("-")) pkg ??= a; - } - pkg ??= "."; - out.program = pkg.endsWith("/...") ? cwd : join(cwd, pkg); - out.extraJson = JSON.stringify({ mode: "test" }); - out.args = [...testFlags, ...programArgs]; - } else { - const flags = goArgs.filter((a) => a.startsWith("-")); - const positional = goArgs.filter((a) => !a.startsWith("-")); - const target = positional[0] ?? "."; - out.program = join(cwd, target); - out.args = [...positional.slice(1), ...programArgs]; - out.extraJson = JSON.stringify(flags.length ? { mode: "debug", buildFlags: flags.join(" ") } : { mode: "debug" }); + const p = rc.params; + if (p?.goKind) { + const module = join(root, p.module || "."); + const target = p.target || "."; + out.program = + p.scope === "package" && !target.startsWith(".") && !isAbsolute(target) + ? target + : join(module, target.split("|")[0]!.trim()); + const test = p.goKind === "test"; + const parsed = delveLaunch( + { + ...rc, + params: undefined, + args: [ + test ? "test" : "build", + ...parseArgs(p.toolArgs ?? ""), + ...(test && p.pattern ? ["-run", p.pattern] : []), + target, + ...(test ? ["-args", ...parseArgs(p.programArgs ?? "")] : []), + ], + }, + module, + root, + ); + const toolArgs = parseArgs(JSON.parse(parsed.extraJson!).buildFlags ?? ""); + out.args = test ? parsed.args : parseArgs(p.programArgs ?? ""); + out.extraJson = JSON.stringify({ + mode: p.goKind === "test" ? "test" : "debug", + buildFlags: toolArgs, + dlvCwd: module, + ...(p.scope === "file" ? { __goFiles: target.split("|").map((f) => join(module, f.trim())) } : {}), + __goToolArgs: toolArgs, + __goUseTags: p.useTags === "true", + ...(p.outputDir ? { output: join(root, `${p.outputDir}/go-debug`) } : {}), + }); + return out; } + const [sub, ...rest] = rc.args; + const test = sub === "test"; + const flags: string[] = []; + const testFlags: string[] = []; + const targets: string[] = []; + let programArgs: string[] = []; + const boolFlags = new Set([ + "-a", + "-n", + "-race", + "-msan", + "-asan", + "-cover", + "-linkshared", + "-modcacherw", + "-buildvcs", + "-trimpath", + "-v", + "-work", + "-x", + "-json", + "-c", + ]); + for (let i = 0; i < rest.length; i++) { + const a = rest[i]!; + if (a === "--" || (test && a === "-args")) { + programArgs = rest.slice(i + 1); + break; + } + const selection = + test && + /^-(run|skip|bench|benchtime|count|timeout|parallel|cpu|list|fuzz|fuzztime|fuzzminimizetime)(?:=(.*))?$/.exec(a); + if (test && /^-(short|failfast|benchmem|fullpath|v)(?:=|$)/.test(a)) { + testFlags.push(a.replace(/^-/, "-test.")); + } else if (selection) { + const value = selection[2] ?? rest[++i]; + if (value !== undefined) testFlags.push(`-test.${selection[1]}`, value); + } else if (a.startsWith("-")) { + if (test && (a === "-json" || a === "-c")) continue; + flags.push(a); + if (!a.includes("=") && !boolFlags.has(a) && rest[i + 1] !== undefined) flags.push(rest[++i]!); + } else if (!test && targets.length) { + programArgs = rest.slice(i); + break; + } else targets.push(a); + } + const target = targets[0] ?? "."; + out.program = target.endsWith("/...") ? cwd : join(cwd, target); + out.args = [...testFlags, ...programArgs]; + out.extraJson = JSON.stringify({ + mode: test ? "test" : "debug", + ...(flags.length ? { buildFlags: formatArgs(flags) } : {}), + }); return out; } @@ -240,7 +305,7 @@ export function launchConfigFor(rc: RunConfig, root: string): LaunchConfig | nul } if (adapter === "debugpy") return debugpyLaunch(rc, cwd); if (adapter === "js-debug") return jsDebugLaunch(rc, cwd); - if (adapter === "delve") return delveLaunch(rc, cwd); + if (adapter === "delve") return delveLaunch(rc, cwd, root); if (adapter === "godot") return godotLaunch(rc, cwd); // Adapters without a template (hidden in Edit Configurations): generic arguments. return { diff --git a/src/features/libraries/index.ts b/src/features/libraries/index.ts index cee6b3623..acc0f6257 100644 --- a/src/features/libraries/index.ts +++ b/src/features/libraries/index.ts @@ -7,6 +7,7 @@ * are browsed through the backend's `!/` paths. */ import { commands, type LibraryGroup } from "@/bindings"; +import { registerActions } from "@/platform/actions"; import { setLibraryRoots } from "@/editor/api"; import { call, isTauri, onIdeEvent } from "@/platform/ipc"; import { useProject } from "@/platform/project"; @@ -96,6 +97,7 @@ const sdkKey = (s: ReturnType) => { l?.rust?.stdlib, l?.go?.goroot, l?.go?.gopath, + l?.go?.modules, ]); }; @@ -133,6 +135,9 @@ export function activate(): () => void { .catch((e: unknown) => console.warn("external libraries: cannot list SDK roots", e)); }; refresh(); + const disposeActions = registerActions([ + { id: "libraries.reload", title: "Reload External Libraries", category: "Go", hidden: true, run: refresh }, + ]); const unsub = useProject.subscribe((s, prev) => { if (sdkKey(s) !== sdkKey(prev)) refresh(); }); @@ -147,6 +152,7 @@ export function activate(): () => void { clearTimeout(timer); unsub(); unsubFs(); + disposeActions(); clear(); }; } diff --git a/src/features/run/configs.ts b/src/features/run/configs.ts index b8d38ca98..9561520cd 100644 --- a/src/features/run/configs.ts +++ b/src/features/run/configs.ts @@ -1,3 +1,4 @@ +import { formatArgs } from "@/platform/commandLine"; /** Run configuration helpers: grouping, overriding, validation and argument parsing. */ import type { RunConfig } from "@/platform/ipc"; import { useRunConfigTypes } from "@/workbench/contributions"; @@ -237,49 +238,7 @@ export function validateConfig(config: RunConfig, all: readonly RunConfig[] = [] export const hasErrors = (e: ConfigErrors) => Object.keys(e).length > 0; -/** - * Splits a command line into arguments: whitespace, '…' and "…" quoting, backslash escapes. - * `windowsPaths`: a backslash only escapes a quote, so `C:\dir\app.jar` stays as typed. - */ -export function parseArgs(input: string, windowsPaths = false): string[] { - const out: string[] = []; - let cur = ""; - let has = false; - let quote: "'" | '"' | null = null; - for (let i = 0; i < input.length; i++) { - const ch = input[i]!; - if (quote) { - if (ch === quote) quote = null; - else if ( - ch === "\\" && - quote === '"' && - i + 1 < input.length && - (windowsPaths ? '"' : '"\\$`').includes(input[i + 1]!) - ) - cur += input[++i]; - else cur += ch; - } else if (ch === "'" || ch === '"') { - quote = ch; - has = true; - } else if (ch === "\\" && i + 1 < input.length && (!windowsPaths || "\"'".includes(input[i + 1]!))) { - cur += input[++i]; - has = true; - } else if (/\s/.test(ch)) { - if (has || cur) out.push(cur); - cur = ""; - has = false; - } else { - cur += ch; - } - } - if (has || cur) out.push(cur); - return out; -} - -/** Inverse of `parseArgs` (quotes only when needed). */ -export function formatArgs(args: readonly string[]): string { - return args.map((a) => (a === "" ? "''" : /[\s'"\\$`]/.test(a) ? `'${a.replace(/'/g, `'\\''`)}'` : a)).join(" "); -} +export { parseArgs, formatArgs } from "@/platform/commandLine"; /** Display command line, e.g. `pnpm run dev`. */ export function commandLine(config: Pick): string { diff --git a/src/features/run/context.ts b/src/features/run/context.ts index 82f849cc2..acab57def 100644 --- a/src/features/run/context.ts +++ b/src/features/run/context.ts @@ -4,6 +4,7 @@ * direct interpreter invocation for main files. Best effort, IDEA-style. */ import type { RunConfig } from "@/platform/ipc"; +import { goTestParts } from "./goArguments"; import { isWindows } from "./jvmTypes"; export type FileRole = "test" | "main" | "script" | null; @@ -44,7 +45,9 @@ function testKinds(relPath: string): string[] { /** Configs whose working directory most specifically contains `relPath` (ties in their original order). */ function allClosest(configs: RunConfig[], relPath: string): RunConfig[] { const score = (c: RunConfig) => { - const cwd = (c.cwd ?? "").replace(/^\.\/?/, "").replace(/\/+$/, ""); + const cwd = (c.kind === "go" ? (c.params?.module ?? c.cwd ?? "") : (c.cwd ?? "")) + .replace(/^\.\/?/, "") + .replace(/\/+$/, ""); if (!cwd) return 0; return relPath === cwd || relPath.startsWith(cwd + "/") ? cwd.length : -1; }; @@ -59,7 +62,9 @@ function closest(configs: RunConfig[], relPath: string): RunConfig | undefined { } function relToCwd(relPath: string, config: RunConfig): string { - const cwd = (config.cwd ?? "").replace(/^\.\/?/, "").replace(/\/+$/, ""); + const cwd = (config.kind === "go" ? (config.params?.module ?? config.cwd ?? "") : (config.cwd ?? "")) + .replace(/^\.\/?/, "") + .replace(/\/+$/, ""); return cwd && relPath.startsWith(cwd + "/") ? relPath.slice(cwd.length + 1) : relPath; } @@ -123,7 +128,8 @@ function implicitPytest(relPath: string, configs: RunConfig[]): RunConfig | null */ export function testBasesFor(relPath: string, configs: RunConfig[]): RunConfig[] { const kinds = testKinds(relPath); - const candidates = configs.filter((c) => c.isTest && kinds.includes(c.kind)); + // Configurations produced from a test or file are results of narrowing, never bases for it. + const candidates = configs.filter((c) => c.isTest && kinds.includes(c.kind) && !c.id.startsWith("context:")); const best = allClosest(candidates, relPath); if (best.length) return best; const fallback = candidates[0] ?? implicitPytest(relPath, configs); @@ -152,8 +158,14 @@ export function testConfigFor(relPath: string, configs: RunConfig[], base?: RunC return derived(base, relPath, { args: [...base.args, file] }); case "go": { const dir = dirOf(file); - const args = base.args.map((a) => (a === "./..." ? (dir ? `./${dir}` : ".") : a)); - return derived(base, relPath, { args, name: `go test ${dir ? `./${dir}` : "."}` }); + const target = dir ? `./${dir}` : "."; + const parts = goTestParts(base.args); + const args = ["test", ...parts.flags, target, ...parts.program]; + return derived(base, relPath, { + args, + name: `go test ${target}`, + ...(base.params?.goKind ? { params: { ...base.params, scope: "directory", target } } : {}), + }); } case "cargo": { const m = /(?:^|\/)tests\/([^/]+)\.rs$/.exec(relPath); diff --git a/src/features/run/goActions.test.ts b/src/features/run/goActions.test.ts new file mode 100644 index 000000000..f914ef0e2 --- /dev/null +++ b/src/features/run/goActions.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { goModuleCommand, goModuleMenu } from "./goActions"; + +describe("Go module commands", () => { + it("runs module operations from the selected manifest directory", () => { + for (const operation of ["tidy", "download"] as const) { + expect(goModuleCommand(operation, "/work/app/go.mod")).toMatchObject({ + kind: "go", + command: "go", + args: ["mod", operation], + cwd: "/work/app", + isTest: false, + }); + } + expect(goModuleCommand("sync", "/work/go.work")?.args).toEqual(["work", "sync"]); + expect(goModuleCommand("use", "/work/go.work", "./module with spaces")?.args).toEqual([ + "work", + "use", + "--", + "./module with spaces", + ]); + }); + it("does not reinterpret invalid manifests, missing directories or NULs", () => { + expect(goModuleCommand("tidy", "/work/go.work")).toBeNull(); + expect(goModuleCommand("sync", "/work/go.mod")).toBeNull(); + expect(goModuleCommand("use", "/work/go.work")).toBeNull(); + expect(goModuleCommand("use", "/work/go.work", "x\0y")).toBeNull(); + }); + it("contributes stable action ids only to applicable manifest menus", () => { + expect(goModuleMenu({ paths: ["/p/go.mod"] }).map((m) => m.action)).toEqual(["go.mod.tidy", "go.mod.download"]); + expect(goModuleMenu({ paths: ["/p/go.work"] }).map((m) => m.action)).toEqual(["go.work.sync", "go.work.use"]); + expect(goModuleMenu({ paths: ["/p/go.work", "/p/go.mod"] })).toEqual([]); + expect(goModuleMenu({ paths: ["/p/main.go"] })).toEqual([]); + }); +}); diff --git a/src/features/run/goActions.ts b/src/features/run/goActions.ts new file mode 100644 index 000000000..950bfb40d --- /dev/null +++ b/src/features/run/goActions.ts @@ -0,0 +1,112 @@ +import { executeAction, getAction, registerActions } from "@/platform/actions"; +import { useProject } from "@/platform/project"; +import type { RunConfig } from "@/platform/ipc"; +import { registerMenuGroup, maybeEditorService, type MenuGroupContext } from "@/workbench/contributions"; +import { promptInput } from "@/workbench/inputDialogs"; +import { goGenerateConfig, goGenerateMenu } from "./goGenerate"; +import { invalidateGoModules, loadGoModules } from "./goModules"; +import { loadConfigs, startRun, useRun } from "./store"; + +export type GoModuleOperation = "tidy" | "download" | "sync" | "use"; +const ACTIONS = { + tidy: { id: "go.mod.tidy", title: "Go Mod Tidy" }, + download: { id: "go.mod.download", title: "Download Go Modules" }, + sync: { id: "go.work.sync", title: "Sync Go Workspace" }, + use: { id: "go.work.use", title: "Add Directory to Go Workspace…" }, +} as const; + +export function goModuleCommand(operation: GoModuleOperation, path: string, directory?: string): RunConfig | null { + const module = operation === "tidy" || operation === "download"; + if (!path.endsWith(module ? "/go.mod" : "/go.work") || path.includes("\0")) return null; + if (operation === "use" && (!directory?.trim() || directory.includes("\0"))) return null; + const args = [module ? "mod" : "work", operation, ...(operation === "use" ? ["--", directory!] : [])]; + return { + id: `go:${operation}:${path}`, + name: `go ${args.slice(0, 2).join(" ")}`, + kind: "go", + command: "go", + args, + cwd: path.slice(0, path.lastIndexOf("/")), + env: {}, + detected: true, + isTest: false, + debugAdapter: null, + }; +} + +export function goModuleMenu(ctx: MenuGroupContext) { + const path = ctx.paths.length === 1 ? ctx.paths[0]! : ""; + const operations: GoModuleOperation[] = path.endsWith("/go.mod") + ? ["tidy", "download"] + : path.endsWith("/go.work") + ? ["sync", "use"] + : []; + return operations.map((op) => ({ kind: "action" as const, action: ACTIONS[op].id, arg: { path } })); +} + +export function registerGoActions(): () => void { + const actions = registerActions([ + ...Object.entries(ACTIONS).map(([operation, spec]) => ({ + ...spec, + category: "Go", + when: "projectOpen", + run: async ({ arg }: { arg?: unknown }) => { + const params = arg && typeof arg === "object" ? (arg as { path?: string; directory?: string }) : {}; + const path = + typeof arg === "string" + ? arg + : typeof params.path === "string" + ? params.path + : (maybeEditorService()?.activePath() ?? ""); + let directory = typeof params.directory === "string" ? params.directory : undefined; + if (operation === "use" && !directory) { + directory = + (await promptInput({ + title: "Add Directory to Go Workspace", + label: "Module directory", + initial: "./", + hint: "Path relative to go.work, or an absolute directory containing go.mod", + })) ?? undefined; + if (!directory) return; + } + const config = goModuleCommand(operation as GoModuleOperation, path, directory); + if (!config) return; + const root = useProject.getState().project?.root; + await startRun(config, undefined, undefined, { + onExit: () => { + if (useProject.getState().project?.root !== root) return; + invalidateGoModules(); + void loadGoModules(root ?? null, useRun.getState().configs); + void loadConfigs(); + if (getAction("libraries.reload")) void executeAction("libraries.reload"); + }, + }); + }, + })), + ...(["file", "package"] as const).map((scope) => ({ + id: `go.generate.${scope}`, + title: scope === "file" ? "Go Generate File" : "Go Generate Package", + category: "Go", + when: "projectOpen", + run: async ({ arg }: { arg?: unknown }) => { + const path = + typeof arg === "string" + ? arg + : arg && typeof arg === "object" && "path" in arg && typeof arg.path === "string" + ? arg.path + : (maybeEditorService()?.activePath() ?? ""); + const config = goGenerateConfig(path, scope); + if (config) await startRun(config); + }, + })), + ]); + const menu = registerMenuGroup({ + id: "go.modules", + menus: ["projectView", "editorTab", "editor"], + items: (_menu, ctx) => [...goModuleMenu(ctx), ...goGenerateMenu(ctx)], + }); + return () => { + menu(); + actions(); + }; +} diff --git a/src/features/run/goArguments.ts b/src/features/run/goArguments.ts new file mode 100644 index 000000000..3d1c82d79 --- /dev/null +++ b/src/features/run/goArguments.ts @@ -0,0 +1,54 @@ +/** Go test flags can occur before or after package targets; binary arguments start at -args. */ +const BOOLEAN_FLAGS = new Set([ + "-a", + "-n", + "-race", + "-msan", + "-asan", + "-cover", + "-linkshared", + "-modcacherw", + "-trimpath", + "-v", + "-work", + "-x", + "-json", + "-c", + "-i", + "-short", + "-failfast", + "-benchmem", + "-fullpath", + "-buildvcs", +]); + +export function goTestParts(args: readonly string[]) { + const flags: string[] = []; + const targets: string[] = []; + let program: string[] = []; + for (let i = 1; i < args.length; i++) { + const arg = args[i]!; + if (arg === "-args" || arg === "--") { + program = args.slice(i); + break; + } + if (arg.startsWith("-")) { + flags.push(arg); + if (!arg.includes("=") && !BOOLEAN_FLAGS.has(arg) && args[i + 1] !== undefined) flags.push(args[++i]!); + } else targets.push(arg); + } + return { flags, targets, program }; +} + +export function withoutGoSelection(flags: readonly string[]): string[] { + const out: string[] = []; + for (let i = 0; i < flags.length; i++) { + if (["-run", "-skip", "-bench", "-fuzz"].includes(flags[i]!)) { + i++; + continue; + } + if (/^-(run|skip|bench|fuzz)=/.test(flags[i]!)) continue; + out.push(flags[i]!); + } + return out; +} diff --git a/src/features/run/goGenerate.test.ts b/src/features/run/goGenerate.test.ts new file mode 100644 index 000000000..a83fa25c6 --- /dev/null +++ b/src/features/run/goGenerate.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { goGenerateConfig, goGenerateMenu } from "./goGenerate"; +import { detectRunTargets } from "./targets"; +import { OPTIMISTIC, produceContext, produceContextChoices } from "./producers"; +import { formParams } from "./types"; +import { goBuildType } from "./goTypes"; + +const path = "/work/app/tools/generate.go"; + +describe("Go generate", () => { + it("runs files and packages in their own directory without shell interpretation", () => { + const file = goGenerateConfig(path, "file")!; + expect(file).toMatchObject({ + command: "go", + args: ["generate", "generate.go"], + cwd: "/work/app/tools", + isTest: false, + debugAdapter: null, + }); + expect(goGenerateConfig(path, "package")?.args).toEqual(["generate", "."]); + expect(goGenerateConfig("/work/app/-name.go", "file")?.args).toEqual(["generate", "./-name.go"]); + expect(formParams(file, goBuildType)).toBeNull(); + expect(goGenerateConfig("/work/app/go.mod", "file")).toBeNull(); + }); + it("recognizes real directives in non-main packages and narrows the command to their literal line", () => { + const content = + "package tools\n//go:generate go run gen.go -out=$GOFILE\nfunc helper() {}\n// go:generate ignored\n//go:generate \n"; + const targets = detectRunTargets(content, "tools/generate.go"); + expect(targets).toHaveLength(1); + expect(targets[0]).toMatchObject({ kind: "generate", line: 1 }); + const input = { path, root: "/work/app", configs: [], content, line: 1 }; + const result = produceContext(input, OPTIMISTIC)!; + expect(result.config?.args).toEqual([ + "generate", + "-run", + "^//go:generate go run gen\\.go -out=\\$GOFILE$", + "generate.go", + ]); + expect(result.config?.isTest).toBe(false); + expect(produceContext({ ...input, line: 2 }, OPTIMISTIC)).toBeNull(); + const testBase = { ...goGenerateConfig(path, "file")!, id: "go:test", isTest: true, args: ["test", "./..."] }; + expect( + produceContextChoices({ ...input, configs: [testBase, { ...testBase, id: "go:other" }] }, OPTIMISTIC), + ).toHaveLength(1); + }); + it("adds file and package actions only for a single Go source file", () => { + expect(goGenerateMenu({ paths: [path] }).map((m) => m.action)).toEqual(["go.generate.file", "go.generate.package"]); + expect(goGenerateMenu({ paths: [path, "/work/other.go"] })).toEqual([]); + expect(goGenerateMenu({ paths: ["/work/app/go.mod"] })).toEqual([]); + }); +}); diff --git a/src/features/run/goGenerate.ts b/src/features/run/goGenerate.ts new file mode 100644 index 000000000..5fe735bb5 --- /dev/null +++ b/src/features/run/goGenerate.ts @@ -0,0 +1,32 @@ +import type { RunConfig } from "@/platform/ipc"; +import type { MenuGroupContext } from "@/workbench/contributions"; + +export function goGenerateConfig(path: string, scope: "file" | "package", directive?: string): RunConfig | null { + if (!path.endsWith(".go") || path.includes("\0")) return null; + const slash = path.lastIndexOf("/"); + const file = path.slice(slash + 1); + const pattern = directive ? `^${directive.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$` : null; + const target = scope === "package" ? "." : file.startsWith("-") ? `./${file}` : file; + return { + id: `go:generate:${scope}:${path}:${pattern ?? ""}`, + name: `go generate ${target}`, + kind: "go", + command: "go", + args: ["generate", ...(pattern ? ["-run", pattern] : []), target], + cwd: slash < 0 ? null : path.slice(0, slash) || "/", + env: {}, + detected: true, + isTest: false, + debugAdapter: null, + params: { useTags: "true" }, + }; +} + +export function goGenerateMenu(ctx: MenuGroupContext) { + const path = ctx.paths.length === 1 ? ctx.paths[0]! : ""; + if (!path.endsWith(".go") || ctx.dirs?.[0]) return []; + return [ + { kind: "action" as const, action: "go.generate.file", arg: { path } }, + { kind: "action" as const, action: "go.generate.package", arg: { path } }, + ]; +} diff --git a/src/features/run/goModules.ts b/src/features/run/goModules.ts index 5b55a99f1..d316e439d 100644 --- a/src/features/run/goModules.ts +++ b/src/features/run/goModules.ts @@ -8,6 +8,10 @@ import { goModuleDir } from "./producers"; const known = new Map(); +export function invalidateGoModules() { + known.clear(); +} + export function parseGoModule(gomod: string): string | null { const m = /^\s*module\s+("?)([^\s"]+)\1/m.exec(gomod); return m?.[2] ?? null; @@ -21,7 +25,7 @@ export function goModulePath(dir: string): string | null | undefined { /** Reads the `go.mod` of every Go configuration's module not read yet. */ export async function loadGoModules(root: string | null, configs: readonly RunConfig[]): Promise { if (!root || !isTauri()) return; - const dirs = new Set(configs.filter((c) => c.kind === "go").map((c) => goModuleDir(root, c.cwd))); + const dirs = new Set(configs.filter((c) => c.kind === "go").map((c) => goModuleDir(root, c.params?.module ?? c.cwd))); await Promise.all( [...dirs] .filter((d) => !known.has(d)) diff --git a/src/features/run/goTypes.test.ts b/src/features/run/goTypes.test.ts new file mode 100644 index 000000000..243849330 --- /dev/null +++ b/src/features/run/goTypes.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { goBuildType, goTestType } from "./goTypes"; +import { formParams, newConfigOfType, withParams } from "./types"; + +describe("Go configurations", () => { + it("keeps build arguments and program arguments separate and persists the argv", () => { + const c = newConfigOfType(goBuildType, []); + const edited = withParams( + c, + { + ...c.params, + target: "./cmd/app", + toolArgs: '-tags "demo other" -race', + programArgs: 'hello "two words"', + module: "app", + }, + goBuildType, + ); + expect(edited.args).toEqual(["build", "-tags", "demo other", "-race", "./cmd/app"]); + expect(JSON.parse(edited.params!.programArgv!)).toEqual(["hello", "two words"]); + expect(edited.isTest).toBe(false); + }); + it("test configs use the existing Go kind and put test-binary flags after -args", () => { + const c = newConfigOfType(goTestType, []); + const test = withParams( + c, + { ...c.params, pattern: "^TestOne$", toolArgs: "-race", programArgs: "-test.short" }, + goBuildType, + ); + expect(test.kind).toBe("go"); + expect(test.isTest).toBe(true); + expect(test.args).toEqual(["test", "-race", "-run", "^TestOne$", ".", "-args", "-test.short"]); + }); + it("offers existing detected configs the form without rewriting old saved command lines", () => { + const c = { ...newConfigOfType(goBuildType, []), params: {}, args: ["run", "."] }; + expect(formParams(c, goBuildType)).toMatchObject({ goKind: "build", runAfterBuild: "true", target: "." }); + expect(c.args).toEqual(["run", "."]); + expect(formParams({ ...c, args: ["run", "-tags", "old", "."] }, goBuildType)).toBeNull(); + }); +}); diff --git a/src/features/run/goTypes.ts b/src/features/run/goTypes.ts new file mode 100644 index 000000000..5f035f8da --- /dev/null +++ b/src/features/run/goTypes.ts @@ -0,0 +1,99 @@ +import type { RunConfig } from "@/platform/ipc"; +import type { RunConfigTypeContribution } from "@/workbench/contributions"; +import { parseArgs } from "./configs"; + +const scopes = ["Directory", "File", "Package"].map((label) => ({ value: label.toLowerCase(), label })); +const fields: RunConfigTypeContribution["fields"] = [ + { + key: "goKind", + label: "Go configuration", + control: "select", + options: [ + { value: "build", label: "Go Build" }, + { value: "test", label: "Go Test" }, + ], + }, + { key: "scope", label: "Run kind", control: "select", options: scopes }, + { + key: "target", + label: "Target", + mono: true, + required: true, + validate: (value, p) => + p.goKind === "test" && p.scope === "file" && value.includes("|") ? "Select one Go test file" : null, + }, + { key: "outputDir", label: "Output directory", mono: true, visible: (p) => p.goKind !== "test" }, + { key: "runAfterBuild", label: "Run after build", control: "checkbox", visible: (p) => p.goKind !== "test" }, + { key: "pattern", label: "Pattern", mono: true, visible: (p) => p.goKind === "test" }, + { key: "toolArgs", label: "Go tool arguments", mono: true }, + { key: "useTags", label: "Use all custom build tags", control: "checkbox" }, + { key: "programArgs", label: "Program arguments", mono: true }, + { + key: "module", + label: "Module", + mono: true, + placeholder: "Project root", + suggestions: (configs) => [ + ...new Set(configs.filter((c) => c.kind === "go").map((c) => c.params?.module ?? c.cwd ?? "")), + ], + }, +]; + +function commandLine(p: Record) { + const test = p.goKind === "test"; + const tool = parseArgs(p.toolArgs ?? ""); + const target = p.target?.trim() || "."; + const targets = + p.scope === "file" + ? target + .split("|") + .map((f) => f.trim()) + .filter(Boolean) + : [target]; + return { + command: p.executable || "go", + args: test + ? [ + "test", + ...tool, + ...(p.pattern ? ["-run", p.pattern] : []), + ...targets, + ...(p.programArgs?.trim() ? ["-args", ...parseArgs(p.programArgs)] : []), + ] + : ["build", ...tool, ...targets], + }; +} + +function paramsFrom(c: RunConfig): Record | null { + const [op, ...args] = c.args; + // Old arbitrary command lines remain available in the shared command form. + if (!["run", "build", "test"].includes(op ?? "") || args.length !== 1 || args[0]!.startsWith("-")) return null; + return { + goKind: op === "test" ? "test" : "build", + scope: "package", + target: args[0]!, + runAfterBuild: op === "run" ? "true" : "false", + module: c.cwd ?? "", + executable: c.command, + }; +} + +export const goBuildType: RunConfigTypeContribution = { + id: "go", + label: "Go Build", + icon: "file-go", + description: "Build and run a Go application", + defaults: { goKind: "build", scope: "package", target: ".", runAfterBuild: "true", useTags: "true" }, + debugAdapter: "delve", + terminal: false, + fields, + commandLine, + paramsFrom, +}; +export const goTestType: RunConfigTypeContribution = { + ...goBuildType, + id: "go-test", + label: "Go Test", + description: "Run Go tests", + defaults: { goKind: "test", scope: "package", target: ".", useTags: "true" }, +}; diff --git a/src/features/run/index.ts b/src/features/run/index.ts index 1a5cd1ac6..720346763 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -35,6 +35,8 @@ import { debugWithAgent, jdwpLaunch } from "./javaDebug"; import { RunDialogsOverlay } from "./dialogs"; import { registerExternalTools } from "./externalTools"; import { EditConfigsOverlay } from "./EditConfigsDialog"; +import { registerGoActions } from "./goActions"; +import { invalidateGoModules } from "./goModules"; import { RunWidget } from "./RunWidget"; import { runConfigsRunAnything } from "./runAnything"; import { runServiceProvider } from "./services"; @@ -245,7 +247,7 @@ function useRunningCount(): number | null { } const CONFIG_FILES = - /(?:^|\/)(?:\.openidea\/run\.toml|package\.json|deno\.jsonc?|Cargo\.toml|build\.gradle(?:\.kts)?|pom\.xml|go\.mod|pyproject\.toml|setup\.cfg|pytest\.ini|manage\.py|Makefile|justfile)$/; + /(?:^|\/)(?:\.openidea\/run\.toml|package\.json|deno\.jsonc?|Cargo\.toml|build\.gradle(?:\.kts)?|pom\.xml|go\.(?:mod|work)|pyproject\.toml|setup\.cfg|pytest\.ini|manage\.py|Makefile|justfile)$/; let reloadTimer: ReturnType | undefined; function scheduleReload() { @@ -269,6 +271,7 @@ export function activate(): () => void { registerTitleItem({ id: "run.widget", title: "Run Widget", align: "right", priority: 0, component: RunWidget }), registerRunAnythingProvider(runConfigsRunAnything), registerBuiltinTypes(), + registerGoActions(), registerOverlay({ id: "run.chooser", component: ConfigChooserOverlay }), registerOverlay({ id: "run.editConfigs", component: EditConfigsOverlay }), registerOverlay({ id: "run.dialogs", component: RunDialogsOverlay }), @@ -527,6 +530,7 @@ export function activate(): () => void { disposers.push( onIdeEvent("fsChanged", (e) => { const paths = [...e.created, ...e.modified, ...e.removed]; + if (paths.some((p) => /(?:^|\/)go\.(?:mod|work)$/.test(p))) invalidateGoModules(); if (paths.some((p) => CONFIG_FILES.test(p))) scheduleReload(); }), // Detected configs carry the selected toolchains (interpreter, JDK, Maven home and options). diff --git a/src/features/run/producers.test.ts b/src/features/run/producers.test.ts index d9ee794d1..8fd04d491 100644 --- a/src/features/run/producers.test.ts +++ b/src/features/run/producers.test.ts @@ -455,6 +455,20 @@ describe("project producers", () => { expect(at(7)).toMatchObject({ name: "m.test.ts", scope: "file" }); }); + it("does not offer configurations produced by an earlier run as choices", () => { + const earlier = cfg({ + id: "context:target:npm:test:src/m.test.ts:other", + name: "other", + kind: "npm", + command: "npm", + args: ["run", "test", "--", "src/m.test.ts"], + isTest: true, + }); + const src = "describe('math', () => {\n it('adds', () => {\n });\n});\n"; + const input = { path: `${ROOT}/src/m.test.ts`, root: ROOT, configs: [...configs, earlier], content: src, line: 1 }; + expect(produceContextChoices(input, ALL).map((c) => c.name)).toEqual(["math > adds"]); + }); + it("several equally close test configurations: one choice each, the default first", () => { const withUnit = [ ...configs, diff --git a/src/features/run/producers.ts b/src/features/run/producers.ts index 184cd6729..59456391f 100644 --- a/src/features/run/producers.ts +++ b/src/features/run/producers.ts @@ -14,6 +14,8 @@ import { isTestPath } from "@/platform/languageSettings"; import { currentRoots } from "@/platform/projectRoots"; import { applicationConfig, jvmModuleDir } from "./application"; import { formatArgs } from "./configs"; +import { goGenerateConfig } from "./goGenerate"; +import { goTestParts } from "./goArguments"; import { contextConfig, fileRole, projectPython, testBasesFor, testConfigFor } from "./context"; import { isWindows } from "./jvmTypes"; import { detectRunTargets, targetConfig, targetLanguage, type RunTarget } from "./targets"; @@ -136,11 +138,12 @@ export function goTestNamed( test?: string, ): RunConfig { if (config.kind !== "go" || config.args[0] !== "test") return config; - const pkgArg = - config.args.find((a, i) => i > 0 && !a.startsWith("-") && !/^-(run|skip|bench)$/.test(config.args[i - 1]!)) ?? "."; + const pkgArg = goTestParts(config.args).targets[0] ?? "."; const recursive = /(^|\/)\.\.\.$/.test(pkgArg); const dir = pkgArg.replace(/\/?\.\.\.$/, "").replace(/^\.(?:\/|$)/, ""); - const module = input.root ? input.goModule?.(goModuleDir(input.root, config.cwd)) : undefined; + const module = input.root + ? input.goModule?.(goModuleDir(input.root, config.params?.module ?? config.cwd)) + : undefined; const clause = /^\s*package\s+(\w+)/m.exec(input.content ?? "")?.[1]; const base = module ? (dir ? `${module}/${dir}` : module) : (clause ?? (dir || ".")); const pkg = recursive ? `${base}/...` : base; @@ -769,7 +772,7 @@ export function innermostTarget( let best: RunTarget | null = null; for (const t of targets) { if (t.line > line) continue; - if (line > targetEnd(lines, t, python)) continue; + if (t.kind === "generate" ? line !== t.line : line > targetEnd(lines, t, python)) continue; if (!best || t.line >= best.line) best = t; } return best; @@ -813,6 +816,10 @@ export function fileTargets(input: Pick /\.(py|[cm]?[jt]sx?)$/.test(path); /** Last test result of a gutter target (IDEA: green / red run icons after a test run). */ export function gutterTestState(target: RunTarget, path: string): TestState | null { - if (target.kind === "main") return null; + if (target.kind === "main" || target.kind === "generate") return null; return testState({ path: target.path, file: fileRootedResults(path) ? path : undefined }); } diff --git a/src/features/run/store.ts b/src/features/run/store.ts index ea4011ca9..597ab30ca 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -41,6 +41,7 @@ import { type TestEvent, type TestOutputParser, } from "./testing"; +import { GoTestOutput } from "./testing/go"; import { recordTestStates } from "./testing/states"; import { failedTestPaths, leafTestPaths, rerunFailedConfig, testsConfig } from "./testing/rerun"; import { compoundChildren } from "./configs"; @@ -82,6 +83,7 @@ interface SessionRuntime { console: ConsoleModel; tests: TestResults | null; parser: TestOutputParser | null; + goOutput: GoTestOutput | null; queue: OutputChunk[]; /** rAF id while the page is visible, timeout id while hidden (rAF pauses in background tabs). */ flushHandle: { raf: number } | { timeout: number } | null; @@ -361,7 +363,10 @@ function flush(sessionId: string) { cancelFlush(rt); if (rt.queue.length === 0) return; const chunks = rt.queue.splice(0); - for (const c of chunks) rt.console.append(c); + for (const c of chunks) { + const text = c.kind === "stdout" && rt.goOutput ? rt.goOutput.feed(c.text) : c.text; + if (text) rt.console.append({ ...c, text }); + } bump(sessionId); } @@ -426,13 +431,18 @@ export function applyConsoleSettings() { function newRuntime(config: RunConfig): SessionRuntime { const tests = config.isTest ? new TestResults() : null; const parser = config.isTest ? createTestParser(config) : null; + const goOutput = + tests && parser && frameworkFor(config) === "go" + ? new GoTestOutput((line) => tests.apply(parser.feed(line))) + : null; const runtime: SessionRuntime = { + goOutput, tests, parser, console: new ConsoleModel({ maxChars: consoleCharLimit(), onLine: (text, kind) => { - if (parser && tests && kind !== "system") tests.apply(parser.feed(text)); + if (parser && tests && kind !== "system" && !(kind === "stdout" && goOutput)) tests.apply(parser.feed(text)); if (kind !== "system") for (const l of runtime.lineListeners) l(text); }, }), @@ -770,6 +780,10 @@ function finishSession(sessionId: string, code: number | null) { void (async () => { if (runtimes.get(sessionId) !== rt) return; flush(sessionId); + if (rt.goOutput) { + const text = rt.goOutput.end(); + if (text) rt.console.append({ kind: "stdout", text }); + } rt.console.flush(); if (rt.tests && rt.parser) { rt.tests.apply(rt.parser.end?.() ?? []); diff --git a/src/features/run/targets.test.ts b/src/features/run/targets.test.ts index 5685a96b1..e0053b2e2 100644 --- a/src/features/run/targets.test.ts +++ b/src/features/run/targets.test.ts @@ -179,3 +179,61 @@ describe("targetConfig", () => { expect(c.id).toBe("context:target:cargo:test:src/lib.rs:tests/adds"); }); }); + +describe("Go test targets", () => { + it("detects benchmark, fuzz and example functions and runs a benchmark without tests", () => { + const text = + "package main\nfunc main() {}\nfunc BenchmarkWork(b *testing.B) {}\nfunc FuzzInput(f *testing.F) {}\nfunc Example() {}\n// func TestComment(t *testing.T) {}"; + const targets = detectRunTargets(text, "work_test.go"); + expect(targets.map((t) => t.name)).toEqual(["main", "BenchmarkWork", "FuzzInput", "Example"]); + const base = cfg({ id: "go:test", kind: "go", args: ["test", "-tags", "demo", "./...", "-args", "-test.short"] }); + const config = targetConfig(targets[1]!, "work_test.go", [base], text)!; + expect(config.args).toEqual([ + "test", + "-tags", + "demo", + ".", + "-run", + "^$", + "-bench", + "^BenchmarkWork$", + "-args", + "-test.short", + ]); + }); + it("uses the module and overrides a saved filter before binary arguments", () => { + const base = cfg({ + id: "go:typed", + kind: "go", + cwd: "runtime", + args: ["test", "-tags", "demo", "-run", "Old", ".", "-args", "-test.short"], + params: { + goKind: "test", + module: "app", + scope: "package", + target: ".", + pattern: "Old", + programArgs: "-test.short", + }, + }); + const text = 'package calc\nfunc TestNew(t *testing.T) {\n t.Run("child case", func(t *testing.T) {})\n}'; + const targets = detectRunTargets(text, "app/calc_test.go"); + const config = targetConfig(targets[1]!, "app/calc_test.go", [base], text, base)!; + expect(config.args).toEqual([ + "test", + "-tags", + "demo", + ".", + "-run", + "^TestNew$/^child_case$", + "-args", + "-test.short", + ]); + expect(config.params).toMatchObject({ + module: "app", + scope: "directory", + target: ".", + pattern: "^TestNew$/^child_case$", + }); + }); +}); diff --git a/src/features/run/targets.ts b/src/features/run/targets.ts index a951c6d41..2d4efff4f 100644 --- a/src/features/run/targets.ts +++ b/src/features/run/targets.ts @@ -5,13 +5,15 @@ * Line-based regex detection (cheap, no parser), plus the run configuration for * one target derived from the project's detected test configurations. */ +import { formatArgs } from "./configs"; import type { RunConfig } from "@/platform/ipc"; import { applicationConfig, jvmTestConfig } from "./application"; +import { goTestParts, withoutGoSelection } from "./goArguments"; import { contextConfig, testConfigFor } from "./context"; import { scanJvmTargets, simpleClassName } from "./jvmTargets"; import { escapeRegExp } from "./testing/rerun"; -export type TargetKind = "main" | "test" | "suite"; +export type TargetKind = "main" | "test" | "suite" | "generate"; export interface RunTarget { /** 0-based line of the declaration. */ @@ -173,10 +175,19 @@ function goTargets(lines: string[]): RunTarget[] { const isMain = lines.some((l) => /^package\s+main\b/.test(l)); for (let i = 0; i < lines.length; i++) { const line = lines[i]!; - const t = /^func\s+(Test\w*|Example\w*|Fuzz\w*)\s*\(\s*(?:\w+\s+\*testing\.[TF])?\s*\)?/.exec(line); - if (t && (/^Example/.test(t[1]!) || /\*testing\.[TF]/.test(line))) { + if (/^\/\/go:generate[ \t]+\S/.test(line)) { + out.push({ + line: i, + kind: "generate", + name: line.replace(/^\/\/go:generate[ \t]+/, "").trim(), + path: [String(i)], + }); + continue; + } + const t = /^func\s+(Test\w*|Benchmark\w*|Example\w*|Fuzz\w*)\s*\(\s*(?:\w+\s+\*testing\.[TFB])?\s*\)?/.exec(line); + if (t && (/^Example/.test(t[1]!) || /\*testing\.[TFB]/.test(line))) { out.push({ line: i, kind: "test", name: t[1]!, path: [t[1]!] }); - if (/\*testing\.T\b/.test(line)) out.push(...goSubtests(lines, i, t[1]!)); + if (/\*testing\.[TB]\b/.test(line)) out.push(...goSubtests(lines, i, t[1]!)); continue; } if (isMain && /^func\s+main\s*\(\s*\)/.test(line)) out.push({ line: i, kind: "main", name: "main", path: [] }); @@ -301,7 +312,7 @@ export function detectRunTargets(text: string, relPath: string): RunTarget[] { (t) => t.kind === "main" || /(^|\/)(test_[^/]*|[^/]*_test)\.py$|(^|\/)tests?\//.test(relPath), ); case "go": - return goTargets(lines).filter((t) => t.kind === "main" || /_test\.go$/.test(relPath)); + return goTargets(lines).filter((t) => t.kind === "main" || t.kind === "generate" || /_test\.go$/.test(relPath)); case "jvm": return /\.(java|kts?)$/.test(relPath) ? javaKotlinTargets(text, relPath) : jvmTargets(lines, relPath); default: @@ -363,7 +374,23 @@ export function targetConfig( return named(withLast(file.args, (f) => `${f}::${target.path.join("::")}`)); case "go": { const pattern = `^${target.path.map(escapeRegExp).join("$/^")}$`; - return named([...file.args, "-run", pattern]); + const benchmark = target.path[0]?.startsWith("Benchmark"); + const parts = goTestParts(file.args); + const selection = benchmark ? ["-run", "^$", "-bench", pattern] : ["-run", pattern]; + const result = named([ + "test", + ...withoutGoSelection(parts.flags), + ...parts.targets, + ...selection, + ...parts.program, + ]); + if (file.params?.goKind) + result.params = { + ...file.params, + pattern: benchmark ? "^$" : pattern, + toolArgs: formatArgs([...withoutGoSelection(parts.flags), ...(benchmark ? ["-bench", pattern] : [])]), + }; + return result; } case "cargo": { const path = target.path.join("::"); diff --git a/src/features/run/testing/go.test.ts b/src/features/run/testing/go.test.ts new file mode 100644 index 000000000..d5d36157e --- /dev/null +++ b/src/features/run/testing/go.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { GoTestOutput, goJsonEvent, goParser } from "./go"; +import { TestResults } from "./results"; +import { withTestReporting } from "./index"; +import { findOutputLinks } from "../links"; + +const record = (Package: string, Action: string, Test?: string, rest = {}) => + JSON.stringify({ Package, Action, ...(Test ? { Test } : {}), ...rest }); + +describe("Go JSON tests", () => { + it("streams interleaved packages and subtests with exact counts and locations", () => { + const parser = goParser(); + const results = new TestResults(); + const lines = [ + record("one", "run", "TestPass"), + record("two", "run", "TestFail"), + record("one", "run", "TestPass/child"), + record("one", "pass", "TestPass/child", { Elapsed: 0.002 }), + record("two", "output", "TestFail", { Output: " nested/main_test.go:12:7: fixture failure\n" }), + record("two", "fail", "TestFail", { Elapsed: 0.01 }), + record("two", "fail"), + record("one", "pass", "TestPass"), + record("one", "skip", "TestSkip"), + record("one", "pass"), + ]; + lines.forEach((line) => results.apply(parser.feed(line))); + results.finish(1); + expect(results.counts()).toMatchObject({ passed: 1, failed: 1, skipped: 1, running: 0, total: 3 }); + expect(results.roots.map((n) => n.name)).toEqual(["one", "two"]); + expect(results.get("two\0TestFail")?.location).toEqual({ + path: "nested/main_test.go", + line: 12, + col: 7, + resolve: "name", + }); + expect(results.get("one\0TestPass\0child")?.durationMs).toBe(2); + expect(parser.feed(record("two", "fail", "TestFail"))).toEqual([]); + }); + + it("reports compiler failures and packages without tests", () => { + const parser = goParser(); + const results = new TestResults(); + results.apply( + parser.feed( + JSON.stringify({ ImportPath: "broken", Action: "build-output", Output: "./main.go:3:1: undefined: missing\n" }), + ), + ); + results.apply(parser.feed(JSON.stringify({ ImportPath: "broken", Action: "build-fail" }))); + results.apply(parser.feed(record("empty", "skip"))); + expect(results.counts()).toMatchObject({ failed: 1, skipped: 1 }); + expect(results.roots[0]?.output).toContain("./main.go:3:1: undefined: missing"); + }); + + it("ignores malformed metadata and negative durations without throwing", () => { + for (const line of [ + "{", + "null", + "[]", + '{"Action":"pass","Package":5}', + '{"Action":"output","Package":"p","Output":{}}', + '{"Action":"unknown","Package":"p"}', + ]) { + expect(goJsonEvent(line)).toBeNull(); + expect(goParser().feed(line)).toEqual([]); + } + expect(goParser().feed(record("p", "pass", "TestOne", { Elapsed: -1 }))[0]).toMatchObject({ + durationMs: undefined, + }); + }); + + it("decodes chunked and long JSON output into clickable console text", () => { + const parser = goParser(); + const results = new TestResults(); + const output = new GoTestOutput((line) => results.apply(parser.feed(line))); + const line = record("p", "output", "TestFail", { + Output: " main_test.go:6: failure " + "x".repeat(9000) + "\n", + }); + expect(output.feed(line.slice(0, 22))).toBe(""); + const text = output.feed(line.slice(22) + "\n" + record("p", "fail", "TestFail")); + expect(text).toContain("main_test.go:6: failure"); + expect(text).not.toContain('"Action"'); + expect(findOutputLinks(text.slice(0, 100)).some((link) => link.kind === "file")).toBe(true); + expect(output.end()).toBe(""); + expect(results.counts().failed).toBe(1); + }); + + it("forces JSON before program arguments and keeps the binary arguments untouched", () => { + const config = { + kind: "go", + command: "go", + args: ["test", "-json=false", "-tags", "demo", ".", "-args", "-json=false"], + }; + expect(withTestReporting(config).args).toEqual(["test", "-json", "-tags", "demo", ".", "-args", "-json=false"]); + }); +}); diff --git a/src/features/run/testing/go.ts b/src/features/run/testing/go.ts index 88cf6f674..acf8117d1 100644 --- a/src/features/run/testing/go.ts +++ b/src/features/run/testing/go.ts @@ -10,7 +10,7 @@ const RESULT_RE = /^\s*--- (PASS|FAIL|SKIP): (\S+) \(([\d.]+s)\)/; const PKG_RE = /^(ok|FAIL|---)\s+(\S+)\s+(?:\(cached\)|([\d.]+s)|\[[^\]]+\])/; const LOCATION_RE = /^\s+([\w.-]+\.go):(\d+):/; -export function goParser(): TestOutputParser { +function plainGoParser(): TestOutputParser { let buffer: TestEvent[] = []; let current: string[] | null = null; let totals = { passed: 0, failed: 0, skipped: 0 }; @@ -76,3 +76,112 @@ export function goParser(): TestOutputParser { }, }; } + +interface GoEvent { + Action: string; + Package: string; + Test?: string; + Output?: string; + Elapsed?: number; +} + +export function goJsonEvent(line: string): GoEvent | null { + if (!line.startsWith("{")) return null; + try { + const event = JSON.parse(line); + if (!event || typeof event !== "object" || typeof event.Action !== "string") return null; + const pkg = event.Package ?? event.ImportPath; + if ( + typeof pkg !== "string" || + !pkg || + !["start", "run", "pause", "cont", "output", "pass", "fail", "skip", "build-output", "build-fail"].includes( + event.Action, + ) + ) + return null; + if (event.Test !== undefined && typeof event.Test !== "string") return null; + if (event.Output !== undefined && typeof event.Output !== "string") return null; + return { ...event, Package: pkg }; + } catch { + return null; + } +} + +export function goParser(): TestOutputParser { + const plain = plainGoParser(); + const located = new Set(); + const finished = new Set(); + const packages = new Map(); + const totals = { passed: 0, failed: 0, skipped: 0 }; + return { + feed(line) { + const e = goJsonEvent(line); + if (!e) return plain.feed(line); + const pkg = packages.get(e.Package) ?? { tests: false, failed: false }; + packages.set(e.Package, pkg); + const path = [e.Package, ...(e.Test ? e.Test.split("/") : [])]; + if (e.Test) pkg.tests = true; + if (e.Action === "run") return [{ type: "start", path }]; + if (e.Action === "output" || e.Action === "build-output") { + if (!e.Output) return []; + const lines = e.Output.replace(/\n$/, "").split("\n"); + const events: TestEvent[] = lines.map((line) => ({ type: "output", path, line })); + const key = path.join("\0"); + const loc = lines.map((line) => /^\s*(.+\.go):(\d+)(?::(\d+))?:/.exec(line)).find(Boolean); + if (loc && !located.has(key)) { + located.add(key); + events.push({ + type: "location", + path, + location: { + path: loc[1]!, + line: Number(loc[2]), + ...(loc[3] ? { col: Number(loc[3]) } : {}), + resolve: loc[1]!.startsWith("/") ? "path" : "name", + }, + }); + } + return events; + } + if (!["pass", "fail", "skip", "build-fail"].includes(e.Action)) return []; + const key = path.join("\0"); + if (finished.has(key)) return []; + finished.add(key); + const status = e.Action === "pass" ? "passed" : e.Action === "skip" ? "skipped" : "failed"; + if (e.Test && path.length === 2) { + totals[status]++; + if (status === "failed") pkg.failed = true; + } else if (!e.Test && (!pkg.tests || (status === "failed" && !pkg.failed))) totals[status]++; + const durationMs = + typeof e.Elapsed === "number" && Number.isFinite(e.Elapsed) && e.Elapsed >= 0 ? e.Elapsed * 1000 : undefined; + return [ + { type: "result", path, status, durationMs }, + { type: "summary", ...totals }, + ]; + }, + end: () => plain.end?.() ?? [], + }; +} + +/** Decode stdout for the console while feeding complete, unsplit JSON records to the test parser. */ +export class GoTestOutput { + private pending = ""; + constructor(private readonly feedLine: (line: string) => void) {} + feed(text: string): string { + this.pending += text; + const lines = this.pending.split("\n"); + this.pending = lines.pop()!; + return lines.map((line) => this.line(line)).join(""); + } + end(): string { + const pending = this.pending; + this.pending = ""; + return pending ? this.line(pending) : ""; + } + private line(raw: string): string { + const line = raw.replace(/\r$/, ""); + this.feedLine(line); + const event = goJsonEvent(line); + return event ? (event.Output ?? "") : line + "\n"; + } +} diff --git a/src/features/run/testing/index.ts b/src/features/run/testing/index.ts index 0409ea1cd..93579630e 100644 --- a/src/features/run/testing/index.ts +++ b/src/features/run/testing/index.ts @@ -55,6 +55,15 @@ export function frameworkFor(config: Pick>(config: T): T { + if (frameworkFor(config) === "go" && config.args[0] === "test") { + const boundary = config.args.findIndex((a) => a === "-args" || a === "--"); + const tool = boundary < 0 ? config.args : config.args.slice(0, boundary); + const args = tool.filter((a) => a !== "-json" && !a.startsWith("-json=")); + return { + ...config, + args: ["test", "-json", ...args.slice(1), ...(boundary < 0 ? [] : config.args.slice(boundary))], + }; + } if (frameworkFor(config) !== "pytest") return config; const cmd = [config.command, ...config.args]; const pytestAt = cmd.findIndex((a) => /(^|[/\\])pytest(\.exe)?$/.test(a)); diff --git a/src/features/run/testing/rerun.test.ts b/src/features/run/testing/rerun.test.ts index 93a9f85c3..ea426180f 100644 --- a/src/features/run/testing/rerun.test.ts +++ b/src/features/run/testing/rerun.test.ts @@ -157,3 +157,34 @@ describe("withTestReporting", () => { expect(withTestReporting(cfg("cargo", "cargo", ["test"])).args).toEqual(["test"]); }); }); + +describe("Go rerun argument boundaries", () => { + it("replaces import-path targets without losing flag values or binary arguments", () => { + const config = cfg("go", "go", ["test", "-tags", "demo", "-run", "old", "example.com/old", "-args", "-test.short"]); + const rerun = rerunFailedConfig(config, [["example.com/new", "TestChild", "case"]])!; + expect(rerun.args).toEqual([ + "test", + "-tags", + "demo", + "-run", + "^TestChild$/^case$", + "example.com/new", + "-args", + "-test.short", + ]); + }); + it("retains preparation of file-scoped configurations", () => { + const config = { + ...cfg("go", "go", ["test", "main_test.go"]), + params: { goKind: "test", scope: "file", target: "main_test.go", module: "app", pattern: "", useTags: "true" }, + }; + const rerun = rerunFailedConfig(config, [["command-line-arguments", "TestFail"]])!; + expect(rerun.params).toMatchObject({ + goKind: "test", + scope: "file", + target: "main_test.go", + pattern: "^(TestFail)$", + }); + expect(rerun.args).toEqual(["test", "-run", "^(TestFail)$", "main_test.go"]); + }); +}); diff --git a/src/features/run/testing/rerun.ts b/src/features/run/testing/rerun.ts index 93de07035..2d0bc69a4 100644 --- a/src/features/run/testing/rerun.ts +++ b/src/features/run/testing/rerun.ts @@ -3,6 +3,8 @@ * tests of a previous run, using each framework's own filter syntax. */ import type { RunConfig } from "@/platform/ipc"; +import { formatArgs } from "../configs"; +import { goTestParts, withoutGoSelection } from "../goArguments"; import { frameworkFor } from "./index"; import { nodeStatus, type TestNode, type TestResults } from "./results"; @@ -82,16 +84,23 @@ function pytestArgs(base: string[], paths: string[][]): string[] | null { return [...kept, ...ids]; } -const isGoPkgPattern = (a: string) => a === "." || a === "./..." || a.startsWith("./"); - function goArgs(base: string[], paths: string[][]): string[] | null { const pkgs = unique(paths.map((p) => p[0]!).filter(Boolean)); if (!pkgs.length) return null; const tests = paths.filter((p) => p.length > 1); - const args = dropFlag(base, ["-run"]).filter((a) => !isGoPkgPattern(a)); + const parts = goTestParts(base); const top = unique(tests.map((p) => p[1]!)); - const run = top.length ? ["-run", `^(${top.map(escapeRegExp).join("|")})$`] : []; - return [...args, ...run, ...pkgs]; + const pattern = + tests.length === 1 && tests[0]!.length > 2 + ? tests[0]! + .slice(1) + .map((name) => `^${escapeRegExp(name)}$`) + .join("/") + : `^(${top.map(escapeRegExp).join("|")})$`; + const run = top.length ? ["-run", pattern] : []; + // A file-scoped run is reported under this synthetic import path; keep its file targets. + const targets = pkgs.includes("command-line-arguments") ? parts.targets : pkgs; + return ["test", ...withoutGoSelection(parts.flags), ...run, ...targets, ...parts.program]; } function jvmArgs(config: RunConfig, paths: string[][]): string[] | null { @@ -158,5 +167,24 @@ export function testsConfig(config: RunConfig, paths: string[][], name: string): args = null; } if (!args) return null; - return { ...config, args, name, detected: true, env: { ...config.env } }; + return { + ...config, + args, + name, + detected: true, + env: { ...config.env }, + ...(frameworkFor(config) === "go" && config.params?.goKind + ? { + cwd: config.params.module || config.cwd, + params: + config.params.scope === "file" + ? { + ...config.params, + pattern: args[args.indexOf("-run") + 1] ?? "", + toolArgs: formatArgs(withoutGoSelection(goTestParts(args).flags)), + } + : { module: config.params.module ?? "", useTags: config.params.useTags ?? "false" }, + } + : {}), + }; } diff --git a/src/features/run/types.ts b/src/features/run/types.ts index c531a11ea..b0345cd25 100644 --- a/src/features/run/types.ts +++ b/src/features/run/types.ts @@ -20,6 +20,7 @@ import { tx } from "@/platform/i18n"; import { useProject } from "@/platform/project"; import { tomcatType } from "./tomcat"; +import { goBuildType, goTestType } from "./goTypes"; export const COMPOUND = "compound"; @@ -395,6 +396,8 @@ export const BUILTIN_TYPES: readonly RunConfigTypeContribution[] = [ maven, godot, pytest, + goBuildType, + goTestType, applicationType, springBootType, tomcatType, @@ -419,13 +422,15 @@ export const isDebugOnly = (c: Pick, type = typeFor(c.kind)) */ export function formParams(config: RunConfig, type = typeFor(config.kind)): Record | null { if (!type) return null; - if (config.params && Object.keys(config.params).length) return { ...config.params }; + if (config.params && Object.keys(config.params).length && (type.id !== "go" || config.params.goKind)) + return { ...config.params }; if (type.id === COMPOUND) return {}; const derived = type.paramsFrom?.(config); if (!derived) return null; // Only when the form reproduces exactly what runs today. const line = type.commandLine(derived); if (!line) return null; + if (type.id === "go") return derived; const sameCwd = !type.ownsWorkingDirectory || (line.cwd ?? null) === (config.cwd ?? null); return line.command === config.command && formatArgs(line.args) === formatArgs(config.args) && sameCwd ? derived @@ -439,7 +444,9 @@ export function withParams(config: RunConfig, params: Record, ty if (!line) return { ...rest, params, command: "", args: [] }; return { ...rest, - params, + params: params.goKind ? { ...params, programArgv: JSON.stringify(parseArgs(params.programArgs ?? "")) } : params, + ...(config.kind === "go-test" && !config.id.startsWith("template:") ? { kind: "go" } : {}), + ...(params.goKind ? { isTest: params.goKind === "test" } : {}), command: line.command, args: line.args, ...(isJvmApplication(config) && parseJavaArgs(config.args).mainClass !== parseJavaArgs(line.args).mainClass diff --git a/src/features/settings/__tests__/goBuildTags.test.ts b/src/features/settings/__tests__/goBuildTags.test.ts new file mode 100644 index 000000000..8741f9f8e --- /dev/null +++ b/src/features/settings/__tests__/goBuildTags.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_LANGUAGES, validateLanguages } from "@/platform/languageSettings"; +import { LANGUAGE_PAGES } from "../pages/languages"; + +describe("Go build tags", () => { + it("keeps old project settings unchanged and ignores malformed values", () => { + expect(validateLanguages({ go: { goroot: "/go" } }).go).toEqual({ ...DEFAULT_LANGUAGES.go, goroot: "/go" }); + expect(validateLanguages({ go: { buildTags: { os: false, arch: [], custom: 42 } } }).go.buildTags).toEqual( + DEFAULT_LANGUAGES.go.buildTags, + ); + }); + it("preserves independent project constraints", () => { + expect( + validateLanguages({ + go: { + buildTags: { + os: " linux ", + arch: "arm64", + cgo: "0", + version: "go1.22", + compiler: "gc", + custom: "integration debug", + experiments: "loopvar", + }, + }, + }).go.buildTags, + ).toEqual({ + os: "linux", + arch: "arm64", + cgo: "0", + version: "go1.22", + compiler: "gc", + custom: "integration debug", + experiments: "loopvar", + }); + const page = LANGUAGE_PAGES.find((p) => p.id === "languages.go.buildTags")!; + expect(page.defaultScope).toBe("project"); + expect(page.ownScopeSwitch).toBe(true); + }); +}); diff --git a/src/features/settings/__tests__/languages.test.ts b/src/features/settings/__tests__/languages.test.ts index 9468486b0..0fd9ac555 100644 --- a/src/features/settings/__tests__/languages.test.ts +++ b/src/features/settings/__tests__/languages.test.ts @@ -77,3 +77,32 @@ describe("language pages", () => { expect(tildify("C:/Program Files/Java/jdk-21")).toBe("C:/Program Files/Java/jdk-21"); }); }); + +describe("Go module settings", () => { + it("keeps automatic vendoring by default and normalizes malformed environment entries", () => { + expect(validateLanguages({ go: {} }).go.modules).toEqual({ env: {}, autoVendor: true }); + const modules = validateLanguages({ + go: { + modules: { + autoVendor: false, + env: { + GOPROXY: "https://proxy.example", + GOPRIVATE: "example.com/private", + GOFLAGS: "-race", + INVALID: 3, + "BAD KEY": "value", + NULL: "x\0y", + }, + }, + }, + }).go.modules; + expect(modules).toEqual({ + autoVendor: false, + env: { GOPROXY: "https://proxy.example", GOPRIVATE: "example.com/private", GOFLAGS: "-race" }, + }); + expect(validateLanguages({ go: { modules: { autoVendor: "no", env: [] } } }).go.modules).toEqual({ + env: {}, + autoVendor: true, + }); + }); +}); diff --git a/src/features/settings/components/Form.tsx b/src/features/settings/components/Form.tsx index e8ac1c825..5bc75c2f7 100644 --- a/src/features/settings/components/Form.tsx +++ b/src/features/settings/components/Form.tsx @@ -475,7 +475,7 @@ export function SettingText({ set(e.target.value)} /> diff --git a/src/features/settings/languages/GoBuildTags.tsx b/src/features/settings/languages/GoBuildTags.tsx new file mode 100644 index 000000000..f9072c01d --- /dev/null +++ b/src/features/settings/languages/GoBuildTags.tsx @@ -0,0 +1,88 @@ +import { useProject } from "@/platform/project"; +import { T } from "@/ui"; +import { useSettingsDialog } from "../model/staging"; +import { PageContext } from "../components/context"; +import { OptionsPage } from "../pages/OptionsPage"; +import type { SectionSpec } from "../pages/spec"; + +const choices = (values: string[]) => [ + { value: "", label: "Automatic" }, + ...values.map((value) => ({ value, label: value })), +]; +const key = (name: string) => `languages.go.buildTags.${name}`; +export const GO_BUILD_TAG_SECTIONS: SectionSpec[] = [ + { + options: [ + { + kind: "select", + key: key("os"), + label: "Operating System", + options: choices([ + "aix", + "android", + "darwin", + "dragonfly", + "freebsd", + "illumos", + "ios", + "js", + "linux", + "netbsd", + "openbsd", + "plan9", + "solaris", + "wasip1", + "windows", + ]), + }, + { + kind: "select", + key: key("arch"), + label: "Architecture", + options: choices([ + "386", + "amd64", + "arm", + "arm64", + "loong64", + "mips", + "mipsle", + "mips64", + "mips64le", + "ppc64", + "ppc64le", + "riscv64", + "s390x", + "wasm", + ]), + }, + { kind: "text", key: key("version"), label: "Go Version", placeholder: "go1.22" }, + { kind: "select", key: key("compiler"), label: "Compiler", options: choices(["gc", "gccgo"]) }, + { + kind: "select", + key: key("cgo"), + label: "CGO", + options: [ + { value: "", label: "Automatic" }, + { value: "1", label: "Enabled" }, + { value: "0", label: "Disabled" }, + ], + }, + { kind: "text", key: key("custom"), label: "Custom Tags", placeholder: "integration debug" }, + { kind: "text", key: key("experiments"), label: "Experiments", placeholder: "loopvar,rangefunc" }, + ], + }, +]; + +export function GoBuildTagsPage() { + const hasProject = useProject((s) => !!s.project); + const newProjects = useSettingsDialog((s) => s.newProjects); + if (!hasProject || newProjects) return Open a project to configure Go build tags.; + return ( + + + + ); +} diff --git a/src/features/settings/languages/GoModules.tsx b/src/features/settings/languages/GoModules.tsx new file mode 100644 index 000000000..38093ec38 --- /dev/null +++ b/src/features/settings/languages/GoModules.tsx @@ -0,0 +1,41 @@ +import { useProject } from "@/platform/project"; +import { T } from "@/ui"; +import { useSettingsDialog } from "../model/staging"; +import { PageContext } from "../components/context"; +import { OptionsPage } from "../pages/OptionsPage"; +import type { SectionSpec } from "../pages/spec"; + +export const GO_MODULE_SECTIONS: SectionSpec[] = [ + { + options: [ + { kind: "toggle", key: "languages.go.modules.autoVendor", label: "Enable vendoring support automatically" }, + ...[ + "GOPROXY", + "GOPRIVATE", + "GOFLAGS", + "GONOPROXY", + "GONOSUMDB", + "GOSUMDB", + "GOMODCACHE", + "GOTOOLCHAIN", + "GOWORK", + ].map((name) => ({ + kind: "text" as const, + key: `languages.go.modules.env.${name}`, + label: name, + placeholder: "Inherit from environment", + })), + ], + }, +]; + +export function GoModulesPage() { + const hasProject = useProject((s) => !!s.project); + const newProjects = useSettingsDialog((s) => s.newProjects); + if (!hasProject || newProjects) return Open a project to configure Go modules.; + return ( + + + + ); +} diff --git a/src/features/settings/pages/languages.tsx b/src/features/settings/pages/languages.tsx index 2f2ff3e10..b8c320c46 100644 --- a/src/features/settings/pages/languages.tsx +++ b/src/features/settings/pages/languages.tsx @@ -6,6 +6,8 @@ import { GoPage, JavaPage, NodePage, RustPage, TypeScriptPage } from "../languages/LanguagePages"; import { ProjectStructurePage } from "../languages/ProjectStructurePage"; import { PythonInterpreterPage } from "../languages/PythonInterpreterPage"; +import { GoBuildTagsPage, GO_BUILD_TAG_SECTIONS } from "../languages/GoBuildTags"; +import { GoModulesPage, GO_MODULE_SECTIONS } from "../languages/GoModules"; import type { PageSpec } from "./spec"; export const LANGUAGES_GROUP = "Languages & Frameworks"; @@ -51,6 +53,28 @@ export const LANGUAGE_PAGES: PageSpec[] = [ keywords: ["goroot", "gopath", "go sdk", "gopls"], component: GoPage, }, + { + id: "languages.go.buildTags", + title: "Build Tags", + group: LANGUAGES_GROUP, + order: 391, + projectScope: true, + defaultScope: "project", + ownScopeSwitch: true, + sections: GO_BUILD_TAG_SECTIONS, + component: GoBuildTagsPage, + }, + { + id: "languages.go.modules", + title: "Go Modules", + group: LANGUAGES_GROUP, + order: 392, + projectScope: true, + defaultScope: "project", + ownScopeSwitch: true, + sections: GO_MODULE_SECTIONS, + component: GoModulesPage, + }, { id: "languages.java", projectScope: true, diff --git a/src/platform/commandLine.ts b/src/platform/commandLine.ts index 1d8361f5b..0719afa20 100644 --- a/src/platform/commandLine.ts +++ b/src/platform/commandLine.ts @@ -27,3 +27,47 @@ export function splitCommandLine(line: string): string[] { if (has || cur) out.push(cur); return out; } + +/** + * Splits a command line into arguments: whitespace, '…' and "…" quoting, backslash escapes. + * `windowsPaths`: a backslash only escapes a quote, so `C:\dir\app.jar` stays as typed. + */ +export function parseArgs(input: string, windowsPaths = false): string[] { + const out: string[] = []; + let cur = ""; + let has = false; + let quote: "'" | '"' | null = null; + for (let i = 0; i < input.length; i++) { + const ch = input[i]!; + if (quote) { + if (ch === quote) quote = null; + else if ( + ch === "\\" && + quote === '"' && + i + 1 < input.length && + (windowsPaths ? '"' : '"\\$`').includes(input[i + 1]!) + ) + cur += input[++i]; + else cur += ch; + } else if (ch === "'" || ch === '"') { + quote = ch; + has = true; + } else if (ch === "\\" && i + 1 < input.length && (!windowsPaths || "\"'".includes(input[i + 1]!))) { + cur += input[++i]; + has = true; + } else if (/\s/.test(ch)) { + if (has || cur) out.push(cur); + cur = ""; + has = false; + } else { + cur += ch; + } + } + if (has || cur) out.push(cur); + return out; +} + +/** Inverse of `parseArgs` (quotes only when needed). */ +export function formatArgs(args: readonly string[]): string { + return args.map((a) => (a === "" ? "''" : /[\s'"\\$`]/.test(a) ? `'${a.replace(/'/g, `'\\''`)}'` : a)).join(" "); +} diff --git a/src/platform/languageSettings.ts b/src/platform/languageSettings.ts index 741a1c3e0..ca4aa81eb 100644 --- a/src/platform/languageSettings.ts +++ b/src/platform/languageSettings.ts @@ -5,6 +5,26 @@ import { useMemo } from "react"; import { useProject } from "./project"; +export interface GoBuildTags { + os: string; + arch: string; + version: string; + compiler: string; + cgo: string; + custom: string; + experiments: string; +} + +const DEFAULT_GO_BUILD_TAGS: GoBuildTags = { + os: "", + arch: "", + version: "", + compiler: "", + cgo: "", + custom: "", + experiments: "", +}; + export interface LanguagesValid { python: { interpreter: string; activateVirtualenv: boolean }; node: { interpreter: string; packageManager: "" | "npm" | "pnpm" | "yarn" | "bun" }; @@ -15,7 +35,12 @@ export interface LanguagesValid { checkCommand: "check" | "clippy"; rustfmtArgs: string; }; - go: { goroot: string; gopath: string }; + go: { + goroot: string; + gopath: string; + buildTags: GoBuildTags; + modules: { env: Record; autoVendor: boolean }; + }; typescript: { version: string }; java: { jdk: string; @@ -53,7 +78,7 @@ export const DEFAULT_LANGUAGES: LanguagesValid = { python: { interpreter: "", activateVirtualenv: true }, node: { interpreter: "", packageManager: "" }, rust: { toolchain: "", stdlib: "", checkOnSave: true, checkCommand: "check", rustfmtArgs: "" }, - go: { goroot: "", gopath: "" }, + go: { goroot: "", gopath: "", buildTags: DEFAULT_GO_BUILD_TAGS, modules: { env: {}, autoVendor: true } }, typescript: { version: "bundled" }, java: { jdk: "", @@ -114,6 +139,8 @@ export function validateLanguages(raw: unknown): LanguagesValid { const node = obj(l.node); const rust = obj(l.rust); const go = obj(l.go); + const tags = obj(go.buildTags); + const modules = obj(go.modules); const ts = obj(l.typescript); const java = obj(l.java); const maven = obj(java.maven); @@ -134,7 +161,21 @@ export function validateLanguages(raw: unknown): LanguagesValid { checkCommand: oneOf(rust.checkCommand, ["check", "clippy"] as const, d.rust.checkCommand), rustfmtArgs: typeof rust.rustfmtArgs === "string" ? rust.rustfmtArgs : d.rust.rustfmtArgs, }, - go: { goroot: text(go.goroot, d.go.goroot), gopath: text(go.gopath, d.go.gopath) }, + go: { + goroot: text(go.goroot, d.go.goroot), + gopath: text(go.gopath, d.go.gopath), + buildTags: Object.fromEntries( + Object.entries(DEFAULT_GO_BUILD_TAGS).map(([key, value]) => [key, text(tags[key], value)]), + ) as unknown as GoBuildTags, + modules: { + autoVendor: bool(modules.autoVendor, true), + env: Object.fromEntries( + Object.entries(obj(modules.env)).filter( + ([key, value]) => !!key && !/[=\s\0]/.test(key) && typeof value === "string" && !value.includes("\0"), + ), + ) as Record, + }, + }, typescript: { version: text(ts.version, d.typescript.version) || d.typescript.version }, java: { jdk: text(java.jdk, d.java.jdk), diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 2d36c0780..ef01ed415 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5265,5 +5265,58 @@ "Refresh Shelf": "刷新搁置", "Show Shelved Diff": "显示搁置差异", "Compare Shelved Changes with Local": "比较搁置更改与本地版本", - "Create Patch from Shelved Changes…": "从搁置更改创建补丁…" + "Create Patch from Shelved Changes…": "从搁置更改创建补丁…", + "Build Tags": "构建标签", + "Go Version": "Go 版本", + "CGO": "CGO", + "Custom Tags": "自定义标签", + "Experiments": "实验功能", + "Open a project to configure Go build tags.": "打开项目以配置 Go 构建标签。", + "This Go file is excluded by the current build constraints.": "当前构建约束排除了此 Go 文件。", + "Select one Go test file": "请选择一个 Go 测试文件", + "Go configuration": "Go 配置", + "Go Build": "Go 构建", + "Go Test": "Go 测试", + "Run kind": "运行类型", + "Output directory": "输出目录", + "Run after build": "构建后运行", + "Go tool arguments": "Go 工具参数", + "Use all custom build tags": "使用所有自定义构建标签", + "Build and run a Go application": "构建并运行 Go 应用程序", + "Run Go tests": "运行 Go 测试", + "Go test file has no directory": "Go 测试文件没有所属目录", + "Go package discovery timed out": "Go 包发现超时", + "Go package discovery failed: {value}": "Go 包发现失败:{value}", + "Go test file target is missing": "缺少 Go 测试目标文件", + "Go build and run requires the output console": "Go 构建并运行需要使用输出控制台", + "run stopped after build": "构建后运行已停止", + "Go Modules": "Go 模块", + "Enable vendoring support automatically": "自动启用 vendor 支持", + "Inherit from environment": "继承环境变量", + "Open a project to configure Go modules.": "打开项目以配置 Go 模块。", + "Go Mod Tidy": "整理 Go 模块依赖", + "Download Go Modules": "下载 Go 模块", + "Sync Go Workspace": "同步 Go 工作区", + "Add Directory to Go Workspace…": "添加目录到 Go 工作区…", + "Add Directory to Go Workspace": "添加目录到 Go 工作区", + "Module directory": "模块目录", + "Path relative to go.work, or an absolute directory containing go.mod": "相对于 go.work 的路径,或包含 go.mod 的目录的绝对路径", + "Reload External Libraries": "重新加载外部库", + "GOPROXY": "GOPROXY", + "GOPRIVATE": "GOPRIVATE", + "GOFLAGS": "GOFLAGS", + "GONOPROXY": "GONOPROXY", + "GONOSUMDB": "GONOSUMDB", + "GOSUMDB": "GOSUMDB", + "GOMODCACHE": "GOMODCACHE", + "GOTOOLCHAIN": "GOTOOLCHAIN", + "GOWORK": "GOWORK", + "Run Go Generate": "运行 Go Generate", + "Go Generate File": "为文件运行 Go Generate", + "Go Generate Package": "为包运行 Go Generate", + "Architecture": "架构", + "Compiler": "编译器", + "go1.22": "go1.22", + "integration debug": "integration debug", + "loopvar,rangefunc": "loopvar,rangefunc" }