дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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
Цей коміт міститься в:
1 джерело
4418f49c35
коміт
ac360b6143
57 files changed
+2591
-125
No files matched your search
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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!({}));
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 } },
|
||||
];
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
};
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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?.() ?? []);
|
||||
|
||||
@@ -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$",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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("::");
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(" ");
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача