diff --git a/crates/ide-build/src/sync.rs b/crates/ide-build/src/sync.rs index b390b2cca..0a262c624 100644 --- a/crates/ide-build/src/sync.rs +++ b/crates/ide-build/src/sync.rs @@ -95,6 +95,24 @@ fn maven_sync_goals(effective: &Path, offline: bool) -> Vec { "-Dverbose".into(), ] } + +/// The settings' Maven options for the sync runs: the thread count (`-T`, also +/// `--threads`) is left out, because the sync pins its own `-T1` — its console +/// output is parsed as one stream, which a parallel build interleaves. +fn sync_maven_options(options: &[String]) -> Vec { + let mut out = Vec::with_capacity(options.len()); + let mut args = options.iter(); + while let Some(arg) = args.next() { + if arg == "-T" || arg == "--threads" { + args.next(); // the count itself + } else if (arg.starts_with("-T") && arg.len() > 2) || arg.starts_with("--threads=") { + // `--threads=4` / `-T4`: the count is part of the argument. + } else { + out.push(arg.clone()); + } + } + out +} const GRADLE_CACHE: &str = "gradle.json"; const MAVEN_PROFILES: &str = "maven-profiles.json"; @@ -324,7 +342,7 @@ pub fn sync_maven( // `dependency:tree` output of concurrent modules, which the sync parses // as one stream. mvn is serial anyway, so this only pins the daemon. let mut args: Vec = vec!["-B".into(), "-fae".into(), "-Dstyle.color=never".into(), "-T1".into()]; - args.extend(options.maven_options.iter().cloned()); + args.extend(sync_maven_options(&options.maven_options)); if options.offline { args.push("-o".into()); } @@ -831,6 +849,19 @@ mod tests { assert_eq!(error_summary("one\nlast line\n\n"), "last line"); } + #[test] + fn maven_sync_leaves_out_the_settings_thread_count() { + // The sync pins its own `-T1`; the user's `-T` (Settings › Maven) would win + // over it on the mvnd command line and interleave the parsed output. + let options = ["-s".to_owned(), "settings.xml".to_owned(), "-T".to_owned(), "4".to_owned()]; + assert_eq!(sync_maven_options(&options), ["-s", "settings.xml"]); + let options = ["--threads".to_owned(), "2C".to_owned(), "-Dmaven.repo.local=/m2".to_owned()]; + assert_eq!(sync_maven_options(&options), ["-Dmaven.repo.local=/m2"]); + assert_eq!(sync_maven_options(&["-T4".to_owned()]), Vec::::new()); + assert_eq!(sync_maven_options(&["--threads=4".to_owned()]), Vec::::new()); + assert!(sync_maven_options(&[]).is_empty()); + } + #[cfg(unix)] #[test] fn maven_sync_runs_the_daemon_command_serially() { diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 4057c3b86..101178039 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -919,6 +919,24 @@ pub struct MavenSettings { /// Maven Daemon home (or its `mvnd` executable); empty = `$MVND_HOME`, else /// `mvnd` on `PATH` / common install dirs. Only used when `daemon` is on. pub mvnd_home: String, + /// Thread count of Maven builds (`-T`): a number of threads (`4`) or a core + /// multiplier (`2C`, `2.5C`); empty = Maven's default (one thread per module). + pub threads: String, +} + +impl MavenSettings { + /// The `-T` value when it is one Maven accepts: a thread count (`4`) or a + /// core multiplier with an optional decimal fraction (`1C`, `2.5C`). + /// Anything else is dropped: a bad `-T` would make every Maven run fail. + pub fn thread_count(&self) -> Option<&str> { + let value = self.threads.trim(); + let number = value.strip_suffix('C').unwrap_or(value); + let mut decimal = number.split('.'); + let well_formed = number.parse::().is_ok_and(|n| n > 0.0) + && decimal.clone().count() <= 2 + && decimal.all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit())); + well_formed.then_some(value) + } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] @@ -1161,6 +1179,41 @@ mod maven_linkage_tests { assert!(encoded.contains("\"daemon\":true") && encoded.contains("mvndHome")); assert_eq!(serde_json::from_str::(&encoded).unwrap(), settings); } + + #[test] + fn thread_count_takes_counts_and_core_multipliers_only() { + let threads = |t: &str| { + let m = MavenSettings { threads: t.into(), ..Default::default() }; + m.thread_count().map(str::to_owned) + }; + // Empty = Maven's default (one thread per module). + assert_eq!(threads("").as_deref(), None); + assert_eq!(threads(" ").as_deref(), None); + // A thread count, and a core multiplier whose fraction may be decimal. + assert_eq!(threads("4").as_deref(), Some("4")); + assert_eq!(threads(" 2 ").as_deref(), Some("2")); + assert_eq!(threads("1C").as_deref(), Some("1C")); + assert_eq!(threads("2.0C").as_deref(), Some("2.0C")); + assert_eq!(threads("0.5C").as_deref(), Some("0.5C")); + // Everything else a `-T` would make every Maven run reject is dropped. + assert_eq!(threads("abc").as_deref(), None); + assert_eq!(threads("4x").as_deref(), None); + assert_eq!(threads("0").as_deref(), None); + assert_eq!(threads("-4").as_deref(), None); + assert_eq!(threads("1e3").as_deref(), None); + assert_eq!(threads("2..5C").as_deref(), None); + } + + #[test] + fn thread_count_round_trips_and_defaults_to_empty() { + // Old settings files predate the thread count; `serde(default)` keeps them loadable. + let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap(); + assert!(old.threads.is_empty() && old.thread_count().is_none()); + let settings = MavenSettings { threads: "2C".into(), ..old }; + let encoded = serde_json::to_string(&settings).unwrap(); + assert!(encoded.contains("\"threads\":\"2C\"")); + assert_eq!(serde_json::from_str::(&encoded).unwrap(), settings); + } } #[cfg(test)] diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 3133e28ef..a87ffcf5d 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -370,8 +370,8 @@ pub fn gradle_env( env } -/// Maven options from settings, placed before the goals: `-s ` -/// and `-Dmaven.repo.local=`. +/// Maven options from settings, placed before the goals: `-s `, +/// `-Dmaven.repo.local=` and `-T ` (the thread count). pub fn maven_options(root: &Path, s: &Settings) -> Vec { let m = &s.languages.java.maven; let mut out = Vec::new(); @@ -381,6 +381,9 @@ pub fn maven_options(root: &Path, s: &Settings) -> Vec { if let Some(dir) = setting_path(root, &m.local_repository) { out.push(format!("-Dmaven.repo.local={}", dir.to_string_lossy())); } + if let Some(threads) = m.thread_count() { + out.extend(["-T".to_owned(), threads.to_owned()]); + } out } @@ -388,7 +391,7 @@ fn maven_settings_prefixes(args: &[String]) -> Vec { let mut prefixes = vec![0]; let mut i = 0; while let Some(arg) = args.get(i) { - if arg == "-s" && args.get(i + 1).is_some() { + if (arg == "-s" || arg == "-T") && args.get(i + 1).is_some() { i += 2; } else if arg.starts_with("-Dmaven.repo.local=") { i += 1; @@ -1043,22 +1046,28 @@ fn is_python_command(cmd: &str) -> bool { const PY_RUNNERS: [&str; 3] = ["uv", "poetry", "pdm"]; -/// [`maven_options`] as the groups they are passed in (`-s `, `-Dmaven.repo.local=…`). +/// [`maven_options`] as the groups they are passed in (`-s `, `-Dmaven.repo.local=…`, +/// `-T `). fn maven_option_groups(options: &[String]) -> Vec<&[String]> { let mut groups = Vec::new(); let mut i = 0; while i < options.len() { - let n = if options[i] == "-s" { 2.min(options.len() - i) } else { 1 }; + let n = if options[i] == "-s" || options[i] == "-T" { 2.min(options.len() - i) } else { 1 }; groups.push(&options[i..i + n]); i += n; } groups } -/// Whether `args` set what `group` sets: a user settings file, or the local repository. +/// Whether `args` set what `group` sets: a user settings file, the local +/// repository, or the thread count. fn sets_maven_option(args: &[String], group: &[String]) -> bool { match group.first().map(String::as_str) { Some("-s") => args.iter().any(|a| a == "-s" || a == "--settings" || a.starts_with("--settings=")), + // `-T 4`, `--threads 4`, `-T4` and `--threads=4` all set the thread count. + Some("-T") => args.iter().any(|a| { + a == "-T" || a == "--threads" || a.starts_with("--threads=") || (a.starts_with("-T") && a.len() > 2) + }), Some(flag) => { let key = flag.split_once('=').map_or(flag, |(k, _)| k); args.iter().any(|a| a.split_once('=').map_or(a.as_str(), |(k, _)| k) == key) diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 491f3cc71..2d1043a98 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -292,6 +292,41 @@ fn project_jdk_and_maven_settings() { assert_eq!(project_jdk(tmp.path(), &s), None); } +#[test] +fn maven_options_carry_the_thread_count_into_the_command_line() { + let tmp = tempfile::tempdir().unwrap(); + let mut s = Settings::default(); + assert!(maven_options(tmp.path(), &s).is_empty(), "empty settings add nothing"); + + s.languages.java.maven.user_settings = "m2/settings.xml".into(); + s.languages.java.maven.local_repository = "m2/repo".into(); + s.languages.java.maven.threads = "4".into(); + let settings_file = tmp.path().join("m2/settings.xml").to_string_lossy().into_owned(); + let repo = tmp.path().join("m2/repo").to_string_lossy().into_owned(); + assert_eq!( + maven_options(tmp.path(), &s), + ["-s", settings_file.as_str(), &format!("-Dmaven.repo.local={repo}"), "-T", "4"] + ); + + // A core multiplier passes through; anything Maven would reject is dropped + // rather than failing every build. + s.languages.java.maven.threads = "2.5C".into(); + assert!(maven_options(tmp.path(), &s).ends_with(&["-T".to_owned(), "2.5C".to_owned()])); + s.languages.java.maven.threads = "abc".into(); + assert!(!maven_options(tmp.path(), &s).iter().any(|a| a == "-T")); + + // Run configurations get the options ahead of the goals, once per launch. + s.languages.java.maven.threads = "2C".into(); + let mut configs = vec![run("maven", "./mvnw", &["package"]), run("maven", "./mvnw", &["-T", "8", "package"])]; + apply_to_run_configs(&mut configs, tmp.path(), &s); + let args: Vec = configs[0].args.clone(); + assert_eq!(args[args.len() - 3..].join(" "), "-T 2C package", "the settings' thread count leads the goals"); + let args: Vec = configs[1].args.clone(); + assert_eq!(args[args.len() - 3..].join(" "), "-T 8 package", "the configuration's own -T wins"); + apply_to_run_configs(&mut configs, tmp.path(), &s); + assert_eq!(configs[0].args.iter().filter(|a| a.as_str() == "-T").count(), 1, "applied once"); +} + #[test] fn saved_maven_goal_configs_take_the_configured_launcher() { if cfg!(windows) { diff --git a/src/bindings.ts b/src/bindings.ts index 6c85852eb..9fff26172 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -5349,6 +5349,11 @@ export type MavenSettings = { * `mvnd` on `PATH` / common install dirs. Only used when `daemon` is on. */ mvndHome?: string, + /** + * Thread count of Maven builds (`-T`): a number of threads (`4`) or a core + * multiplier (`2C`, `2.5C`); empty = Maven's default (one thread per module). + */ + threads?: string, }; export type MemoryCategory = diff --git a/src/features/settings/__tests__/languages.test.ts b/src/features/settings/__tests__/languages.test.ts index 35268e631..9ce8d8cff 100644 --- a/src/features/settings/__tests__/languages.test.ts +++ b/src/features/settings/__tests__/languages.test.ts @@ -26,7 +26,7 @@ describe("languages settings", () => { jdtlsVmArgs: " -Xmx2g ", jdk: 21, jdtlsJdk: " /opt/jdk-25 ", - maven: { home: " /opt/maven ", daemon: "yes", mvndHome: " /opt/mvnd " }, + maven: { home: " /opt/maven ", daemon: "yes", mvndHome: " /opt/mvnd ", threads: " 2C " }, }, }); expect(v.python).toEqual({ interpreter: "/usr/bin/python3", activateVirtualenv: true }); @@ -45,8 +45,10 @@ describe("languages settings", () => { linkedProjects: [], daemon: false, mvndHome: "/opt/mvnd", + threads: "2C", }); expect(validateSettings({}).languages).toEqual(DEFAULT_LANGUAGES); + expect(validateLanguages({ java: { maven: { threads: 4 } } }).java.maven.threads).toBe(""); }); it("keeps the Ruby interpreter through settings normalization and defaults to Auto", () => { diff --git a/src/features/settings/languages/LanguagePages.tsx b/src/features/settings/languages/LanguagePages.tsx index f516588bf..e72d7b0a6 100644 --- a/src/features/settings/languages/LanguagePages.tsx +++ b/src/features/settings/languages/LanguagePages.tsx @@ -477,6 +477,8 @@ export function GradlePage() { export function MavenPage() { const [daemon, setDaemon] = useSetting("languages.java.maven.daemon"); + const [threads, setThreads] = useSetting("languages.java.maven.threads"); + const id = useId(); return (
@@ -506,6 +508,23 @@ export function MavenPage() { title="Select Local Repository" hint="Passed as -Dmaven.repo.local." /> + + + setThreads(e.target.value.trim())} + aria-label="Thread count" + /> + +