feat(go): add core Go development workflows (#524)

Go settings, execution, debugging and test reporting were disconnected, leaving Go projects without editable build/test fields and workspace maintenance actions.

- Add project build constraints that update gopls in place, and notices on excluded files.
- Add editable Go Build and Go Test configurations with directory, package and file targets, separate tool and program arguments, modules, environment, output selection and build-only execution, and pass the same settings to Delve.
- Decode Go JSON test output into the results view, with subtests, failures, skips and rerun failed. Add gutter targets for benchmarks and generate directives.
- Add module environment and vendoring settings, read go.work members, and offer tidy, download, sync and use from file menus.
- Add file and package generate actions, and format and organize imports through gopls on Save and Reformat.
- Show what a Go program prints in the debug console: Delve's debuggee shares its stdio, which no DAP event carries.
- Keep the default build output out of version control, and keep configurations produced by an earlier run out of the gutter choices.

Fixes #507
Fixes #237
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-31 13:56:06 +08:00
1 джерело 4418f49c35
коміт ac360b6143
57 files changed
+2591 -125

No files matched your search

+45
Переглянути файл
@@ -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<String, String>,
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");
}
}
+39 -2
Переглянути файл
@@ -348,8 +348,17 @@ pub struct AdapterIo {
pub port: Option<u16>,
/// Recent stderr lines of the adapter process, for error messages.
pub stderr: Arc<parking_lot::Mutex<Vec<String>>>,
/// (category, line) printed by the debuggee when it shares the adapter's stdio.
pub program_output: Option<ProgramOutput>,
}
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<Box<dyn Future<Output = IdeResult<AdapterIo>> + 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<R: AsyncRead + Unpin + Send + 'static>(
stream: R,
name: String,
sink: Arc<parking_lot::Mutex<Vec<String>>>,
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;
+8
Переглянути файл
@@ -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.
+8 -1
Переглянути файл
@@ -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,
})
})
}
}
+15
Переглянути файл
@@ -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();
+25 -1
Переглянути файл
@@ -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).
+6 -2
Переглянути файл
@@ -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)?;
+13
Переглянути файл
@@ -680,6 +680,19 @@ fn is_go_main_package(dir: &Path) -> bool {
}
fn detect_go(root: &Path, out: &mut Vec<RunConfig>) {
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;
+292
Переглянути файл
@@ -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<RunConfig>,
}
pub fn plan(root: &Path, config: &RunConfig) -> IdeResult<Option<Plan>> {
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<Vec<PathBuf>> {
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<PathBuf> {
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<std::str::Chars<'_>>, quote: char) -> Option<String> {
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::<String>());
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
}
+1
Переглянути файл
@@ -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;
+88 -9
Переглянути файл
@@ -49,7 +49,7 @@ struct Entry {
id: RunId,
config_id: String,
name: String,
pid: Option<u32>,
pid: Mutex<Option<u32>>,
stdin: Mutex<Option<Box<dyn Write + Send>>>,
/// The PTY master of a terminal-emulated run (resize; dropped on exit).
pty: Option<Mutex<Option<Box<dyn MasterPty + Send>>>>,
@@ -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<Msg>,
) -> std::io::Result<ExitStatus> {
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<dyn Write + Send>);
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<LaunchEnv> {
let extras = &config.extras;
let mut vars = if extras.env_files.is_empty() {
+49
Переглянути файл
@@ -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}");
}
+73
Переглянути файл
@@ -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"));
}
+19
Переглянути файл
@@ -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() {
+329
Переглянути файл
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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<String>, 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::<Vec<_>>()
.join(" ")
)
}
pub fn apply_to_debug(
env: &mut std::collections::BTreeMap<String, String>,
extra_json: &mut Option<String>,
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<String> = 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::<Vec<_>>();
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<String, String>,
) {
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<String, String> {
let mut env: std::collections::BTreeMap<String, String> = 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");
}
}
+1
Переглянути файл
@@ -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;
+73 -4
Переглянути файл
@@ -92,6 +92,10 @@ struct Cached {
static CACHE: LazyLock<Mutex<HashMap<(PathBuf, &'static str), Cached>>> = 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<LibraryGroup> {
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<LibraryGroup> {
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);
}
}
+67
Переглянути файл
@@ -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!({}));
}
+19
Переглянути файл
@@ -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";
+14
Переглянути файл
@@ -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);
});
+4
Переглянути файл
@@ -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);
}
+17 -1
Переглянути файл
@@ -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(),
+48
Переглянути файл
@@ -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,
});
});
});
+92 -27
Переглянути файл
@@ -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 {
+6
Переглянути файл
@@ -7,6 +7,7 @@
* are browsed through the backend's `<archive>!/<entry>` 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<typeof useProject.getState>) => {
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();
};
}
+2 -43
Переглянути файл
@@ -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<RunConfig, "command" | "args">): string {
+17 -5
Переглянути файл
@@ -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);
+35
Переглянути файл
@@ -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([]);
});
});
+112
Переглянути файл
@@ -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();
};
}
+54
Переглянути файл
@@ -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;
}
+51
Переглянути файл
@@ -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([]);
});
});
+32
Переглянути файл
@@ -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 } },
];
}
+5 -1
Переглянути файл
@@ -8,6 +8,10 @@ import { goModuleDir } from "./producers";
const known = new Map<string, string | null>();
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<void> {
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))
+40
Переглянути файл
@@ -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();
});
});
+99
Переглянути файл
@@ -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<string, string>) {
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<string, string> | 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" },
};
+5 -1
Переглянути файл
@@ -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<typeof setTimeout> | 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).
+14
Переглянути файл
@@ -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,
+12 -5
Переглянути файл
@@ -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<ContextInput, "path" | "root" | "configs
function elementProduction(input: ContextInput, env: ToolEnv, target: RunTarget): Produced | null {
const s = site(input);
const rel = s.idPath;
if (target.kind === "generate") {
const config = goGenerateConfig(input.path, "file", input.content?.split("\n")[target.line]);
return config ? { name: config.name, scope: "element", config } : null;
}
if (target.kind === "main") {
// Java / Kotlin main classes in a Maven / Gradle project: an Application configuration.
if (target.className && s.inProject && hasBuildTool(rel, input.configs, JVM_BUILDS)) {
@@ -867,7 +874,7 @@ export function produceContextChoices(input: ContextInput, env: ToolEnv): Produc
if (produced.scope === "element") {
const targets = fileTargets(input, (input.content ?? "").slice(0, 400_000));
element = innermostTarget(input.content ?? "", targets, input.line ?? 0, /\.py$/.test(input.path));
if (!element || element.kind === "main") return [produced];
if (!element || element.kind === "main" || element.kind === "generate") return [produced];
} else if (!produced.config.isTest) {
return [produced];
}
+2 -1
Переглянути файл
@@ -81,6 +81,7 @@ async function launch(target: RunTarget, path: string, debug: boolean, content:
}
export function targetTitle(t: RunTarget): string {
if (t.kind === "generate") return tx`Run Go Generate`;
if (t.kind === "main") return "Run";
if (t.kind === "suite") return "Run tests";
return "Run test";
@@ -133,7 +134,7 @@ const fileRootedResults = (path: string) => /\.(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 });
}
+16 -2
Переглянути файл
@@ -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?.() ?? []);
+58
Переглянути файл
@@ -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$",
});
});
});
+33 -6
Переглянути файл
@@ -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("::");
+95
Переглянути файл
@@ -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"]);
});
});
+110 -1
Переглянути файл
@@ -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<string>();
const finished = new Set<string>();
const packages = new Map<string, { tests: boolean; failed: boolean }>();
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";
}
}
+9
Переглянути файл
@@ -55,6 +55,15 @@ export function frameworkFor(config: Pick<RunConfig, "kind" | "command" | "args"
* chose a verbosity already.
*/
export function withTestReporting<T extends Pick<RunConfig, "kind" | "command" | "args">>(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));
+31
Переглянути файл
@@ -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"]);
});
});
+34 -6
Переглянути файл
@@ -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" },
}
: {}),
};
}
+9 -2
Переглянути файл
@@ -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<RunConfig, "kind">, type = typeFor(c.kind))
*/
export function formParams(config: RunConfig, type = typeFor(config.kind)): Record<string, string> | 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<string, string>, 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
+40
Переглянути файл
@@ -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);
});
});
+29
Переглянути файл
@@ -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,
});
});
});
+1 -1
Переглянути файл
@@ -475,7 +475,7 @@ export function SettingText({
<Input
id={id}
className={styles.text}
value={value}
value={value ?? ""}
placeholder={placeholder}
onChange={(e) => set(e.target.value)}
/>
+88
Переглянути файл
@@ -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 <T>Open a project to configure Go build tags.</T>;
return (
<PageContext.Provider value={{ pageId: "languages.go.buildTags", scope: "project" }}>
<OptionsPage
spec={{ id: "languages.go.buildTags", title: "Build Tags", order: 0, sections: GO_BUILD_TAG_SECTIONS }}
/>
</PageContext.Provider>
);
}
+41
Переглянути файл
@@ -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 <T>Open a project to configure Go modules.</T>;
return (
<PageContext.Provider value={{ pageId: "languages.go.modules", scope: "project" }}>
<OptionsPage spec={{ id: "languages.go.modules", title: "Go Modules", order: 0, sections: GO_MODULE_SECTIONS }} />
</PageContext.Provider>
);
}
+24
Переглянути файл
@@ -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,
+44
Переглянути файл
@@ -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(" ");
}
+44 -3
Переглянути файл
@@ -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<string, string>; 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<string, string>,
},
},
typescript: { version: text(ts.version, d.typescript.version) || d.typescript.version },
java: {
jdk: text(java.jdk, d.java.jdk),
+54 -1
Переглянути файл
@@ -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"
}