diff --git a/Cargo.lock b/Cargo.lock index b7590a3f9..423882bf7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4086,6 +4086,7 @@ dependencies = [ "specta", "tempfile", "tracing", + "zip", ] [[package]] diff --git a/crates/ide-build/Cargo.toml b/crates/ide-build/Cargo.toml index eff2f2485..946863725 100644 --- a/crates/ide-build/Cargo.toml +++ b/crates/ide-build/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true [dependencies] dunce.workspace = true ide-core.workspace = true +zip.workspace = true serde.workspace = true serde_json.workspace = true specta.workspace = true diff --git a/crates/ide-build/src/compile/mod.rs b/crates/ide-build/src/compile/mod.rs index 9cd0ea26f..9cbd6e48e 100644 --- a/crates/ide-build/src/compile/mod.rs +++ b/crates/ide-build/src/compile/mod.rs @@ -185,8 +185,10 @@ pub fn plan(root: &Path, model: &BuildModel, kind: BuildKind, file: Option<&Path None => None, }; let dir = module_dir.clone().unwrap_or_else(|| root.to_path_buf()); + // A folder of sibling projects has no build file itself; its Maven projects are built one by one. let system = detect_dir(&dir, model, root) .or_else(|| if model.gradle.is_some() { Some(BuildSystem::Gradle) } else { None }) + .or_else(|| model.maven.as_ref().filter(|m| !m.projects.is_empty()).map(|_| BuildSystem::Maven)) .ok_or_else(|| IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript) found"))?; let module = module_dir.as_deref().filter(|d| *d != root); let title = match module { @@ -814,6 +816,17 @@ mod tests { ("pnpm", &["run".to_owned(), "build".to_owned()][..]) ); + let siblings = tmp.path().join("siblings"); + write(&siblings, "core/pom.xml", "core"); + write(&siblings, "system/pom.xml", "system"); + let model = crate::load(&siblings, &tmp.path().join("cache3"), None); + let p = plan(&siblings, &model, BuildKind::Build, None).unwrap(); + assert_eq!(p.system, BuildSystem::Maven); + assert_eq!( + p.steps.iter().map(|s| s.cwd.clone()).collect::>(), + [siblings.join("core"), siblings.join("system")] + ); + let none = tmp.path().join("none"); std::fs::create_dir_all(&none).unwrap(); assert!(plan(&none, &BuildModel::default(), BuildKind::Build, None).is_err()); diff --git a/crates/ide-build/src/maven.rs b/crates/ide-build/src/maven.rs index f9b926a9d..b467c95ac 100644 --- a/crates/ide-build/src/maven.rs +++ b/crates/ide-build/src/maven.rs @@ -11,7 +11,9 @@ use crate::xml::{self, Element}; use serde::{Deserialize, Serialize}; use specta::Type; use std::collections::{BTreeMap, HashMap, HashSet}; +use std::io::Read; use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; /// Phases shown under "Lifecycle" (IDEA's default list). pub const LIFECYCLE: &[&str] = @@ -456,13 +458,20 @@ struct Resolver { /// `~/.m2/repository`, or `` from `~/.m2/settings.xml`. pub(crate) fn default_local_repo() -> Option { let m2 = dirs::home_dir()?.join(".m2"); - if let Some(settings) = std::fs::read_to_string(m2.join("settings.xml")).ok().and_then(|t| xml::parse(&t)) - && let Some(repo) = settings.text_of("localRepository") - { - let home = dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default(); - return Some(PathBuf::from(repo.replace("${user.home}", &home))); + local_repository_of(&m2.join("settings.xml")).or(Some(m2.join("repository"))) +} + +/// `` of a settings file, when it names one: the repository a build +/// with `-s ` resolves into, which is not necessarily the user's default. +pub fn local_repository_of(settings: &Path) -> Option { + let text = std::fs::read_to_string(settings).ok()?; + let root = xml::parse(&text)?; + let repo = root.text_of("localRepository")?.trim().to_owned(); + if repo.is_empty() { + return None; } - Some(m2.join("repository")) + let home = dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default(); + Some(PathBuf::from(repo.replace("${user.home}", &home))) } impl Resolver { @@ -688,7 +697,76 @@ pub fn goal_prefix(artifact_id: &str) -> String { artifact_id.strip_suffix("-plugin").unwrap_or(artifact_id).to_owned() } -/// Goals of well-known plugins (their plugin descriptors are not read). +/// Goals of every plugin jar read so far, by the path of that jar. +static DESCRIPTOR: OnceLock>>> = OnceLock::new(); + +/// `` names of the plugin descriptor inside its jar (`META-INF/maven/plugin.xml`), +/// which is where Maven itself takes the list `plugin:help` prints. Empty when the jar +/// is not in the local repository yet. +fn descriptor_goals(repo: &Path, group_id: &str, artifact_id: &str, version: &str) -> Vec { + // Keyed by the jar itself, not by its coordinates: a repository can be replaced + // between runs, and a test's empty repository must not answer for another's. + // One `push` per group segment: the layout is `//…` on + // every platform, Windows' separator included. + let mut dir = repo.to_path_buf(); + dir.extend(group_id.split('.')); + dir.push(artifact_id); + dir.push(version); + let Some(jar) = plugin_jar(&dir, artifact_id, version) else { return Vec::new() }; + let key = jar.to_string_lossy().into_owned(); + let cache = DESCRIPTOR.get_or_init(|| Mutex::new(HashMap::new())); + if let Some(cached) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key) { + return cached.clone(); + } + // A failed read is not remembered: the jar may be half written by Maven right now. + let Some(goals) = read_descriptor(&jar) else { return Vec::new() }; + cache.lock().unwrap_or_else(|e| e.into_inner()).insert(key, goals.clone()); + goals +} + +/// The plugin's jar in its version directory: `-.jar`, or — for a +/// SNAPSHOT installed from a repository, whose file name carries a timestamp — the newest +/// `-*.jar` that is neither sources nor javadoc. +fn plugin_jar(dir: &Path, artifact_id: &str, version: &str) -> Option { + let exact = dir.join(format!("{artifact_id}-{version}.jar")); + if exact.is_file() { + return Some(exact); + } + let prefix = format!("{artifact_id}-"); + let mut best: Option<(std::time::SystemTime, PathBuf)> = None; + for entry in std::fs::read_dir(dir).ok()?.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue }; + if !name.starts_with(&prefix) || !name.ends_with(".jar") || name.ends_with("-sources.jar") { + continue; + } + if name.ends_with("-javadoc.jar") { + continue; + } + let Ok(modified) = entry.metadata().and_then(|m| m.modified()) else { continue }; + if best.as_ref().is_none_or(|(at, _)| modified > *at) { + best = Some((modified, path)); + } + } + best.map(|(_, p)| p) +} + +fn read_descriptor(jar: &Path) -> Option> { + let file = std::fs::File::open(jar).ok()?; + let mut archive = zip::ZipArchive::new(file).ok()?; + let mut entry = archive.by_name("META-INF/maven/plugin.xml").ok()?; + let mut text = String::new(); + entry.read_to_string(&mut text).ok()?; + let root = xml::parse(&text)?; + let mut goals: Vec = + root.list("mojos", "mojo").iter().filter_map(|m| m.text_of("goal")).map(|g| g.trim().to_owned()).collect(); + goals.retain(|g| !g.is_empty()); + goals.sort(); + goals.dedup(); + Some(goals) +} + +/// Goals of well-known plugins, for a jar that is not in the local repository yet. fn known_goals(artifact_id: &str) -> &'static [&'static str] { match artifact_id { "maven-clean-plugin" => &["clean", "help"], @@ -840,9 +918,27 @@ fn lifecycle_plugins(packaging: &str) -> &'static [&'static str] { } } -/// A plugin as shown in the tree: execution goals plus the plugin's known goals, sorted. -pub(crate) fn plugin(group_id: &str, artifact_id: &str, version: Option, goals: &[String]) -> MavenPlugin { +/// A plugin as shown in the tree: the goals its jar declares, the ones known by name for +/// the common plugins, and the ones the project binds in ``, sorted. IDEA lists +/// a plugin's goals the same way, which is why a plugin outside the table (checkstyle, say) +/// still shows every goal it offers instead of only the bound one. +pub(crate) fn plugin( + group_id: &str, + artifact_id: &str, + version: Option, + goals: &[String], + local_repo: Option<&Path>, +) -> MavenPlugin { let mut all: Vec = known_goals(artifact_id).iter().map(|g| (*g).to_owned()).collect(); + let repo = local_repo.map(Path::to_path_buf).or_else(default_local_repo); + // An unresolved `${…}` version names no jar: the table and the bound goals stand in. + if let (Some(repo), Some(version)) = (repo.as_deref(), version.as_deref().filter(|v| !v.contains("${"))) { + for g in descriptor_goals(repo, group_id, artifact_id, version) { + if !all.contains(&g) { + all.push(g); + } + } + } for g in goals { if !all.contains(g) { all.push(g.clone()); @@ -1007,7 +1103,7 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option) -> MavenM .or_else(|| ctx.managed_plugins.iter().find(|m| m.key() == p.key()).and_then(|m| m.version.clone())) .map(|v| i(&v)) .filter(|v| !v.contains("${")); - plugin(&i(&p.group_id), &i(&p.artifact_id), version, &p.goals) + plugin(&i(&p.group_id), &i(&p.artifact_id), version, &p.goals, resolver.local_repo.as_deref()) }) .collect(); @@ -1119,6 +1215,16 @@ pub fn build_files(root: &Path, model: &MavenModel) -> Vec { /// Projects and their plugins / dependencies from `mvn help:effective-pom` output /// (`` or ``), keyed `groupId:artifactId`. pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec, Vec)> { + parse_effective_pom_in(text, None) +} + +/// The same, with the local repository the project actually builds against (a +/// `-Dmaven.repo.local` of the build, say): the plugin goals come from jars there, +/// rather than from the user's default one. +pub fn parse_effective_pom_in( + text: &str, + local_repo: Option<&Path>, +) -> Vec<(String, Vec, Vec)> { let Some(root) = xml::parse(text) else { return Vec::new() }; let projects: Vec<&Element> = if root.name == "projects" { root.children_named("project").collect() } else { vec![&root] }; @@ -1130,7 +1236,7 @@ pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec, Vec Vec<(String, Vec) mod tests { use super::*; + /// A plugin jar in `repo` whose descriptor declares `goals`. + fn write_plugin_jar(repo: &Path, group: &str, artifact: &str, version: &str, goals: &[&str]) { + let dir = repo.join(group.replace('.', "/")).join(artifact).join(version); + std::fs::create_dir_all(&dir).unwrap(); + let mojos: String = goals.iter().map(|g| format!("{g}")).collect(); + let descriptor = format!("{mojos}"); + let file = std::fs::File::create(dir.join(format!("{artifact}-{version}.jar"))).unwrap(); + let mut zip = zip::ZipWriter::new(file); + zip.start_file("META-INF/maven/plugin.xml", zip::write::SimpleFileOptions::default()).unwrap(); + std::io::Write::write_all(&mut zip, descriptor.as_bytes()).unwrap(); + zip.finish().unwrap(); + } + + #[test] + fn a_plugin_lists_the_goals_its_jar_declares() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path(); + write_plugin_jar( + repo, + "org.apache.maven.plugins", + "maven-checkstyle-plugin", + "3.3.1", + &["checkstyle", "checkstyle-aggregate", "help", "check"], + ); + let p = plugin( + "org.apache.maven.plugins", + "maven-checkstyle-plugin", + Some("3.3.1".into()), + &["check".to_owned()], + Some(repo), + ); + assert_eq!(p.goals, ["check", "checkstyle", "checkstyle-aggregate", "help"], "every goal the plugin offers"); + assert_eq!(p.prefix, "checkstyle"); + } + + #[test] + fn a_settings_file_names_the_repository_to_read_goals_from() { + let tmp = tempfile::tempdir().unwrap(); + let settings = tmp.path().join("settings.xml"); + std::fs::write( + &settings, + "${user.home}/repo-from-settings", + ) + .unwrap(); + let repo = local_repository_of(&settings).unwrap(); + assert!(repo.ends_with("repo-from-settings"), "{}", repo.display()); + assert!(!repo.to_string_lossy().contains("${user.home}")); + // A settings file that names none leaves Maven's default alone. + let plain = tmp.path().join("plain.xml"); + std::fs::write(&plain, "true").unwrap(); + assert!(local_repository_of(&plain).is_none()); + assert!(local_repository_of(&tmp.path().join("missing.xml")).is_none()); + } + + #[test] + fn effective_pom_goals_come_from_the_local_repository_it_is_given() { + let tmp = tempfile::tempdir().unwrap(); + write_plugin_jar(tmp.path(), "org.apache.maven.plugins", "maven-demo-plugin", "1.0", &["run", "help"]); + let text = "ga\ + org.apache.maven.pluginsmaven-demo-plugin\ + 1.0run\ + "; + let with_repo = parse_effective_pom_in(text, Some(tmp.path())); + assert_eq!(with_repo[0].1[0].goals, ["help", "run"], "the jar's own goals come first"); + // Without that repository the plugin is unknown: only what the project binds. + assert_eq!(parse_effective_pom(text)[0].1[0].goals, ["run"]); + } + + #[test] + fn a_snapshot_plugin_is_found_by_its_timestamped_jar() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("g/p/maven-demo-plugin/1.0-SNAPSHOT"); + std::fs::create_dir_all(&dir).unwrap(); + let descriptor = "run"; + let file = std::fs::File::create(dir.join("maven-demo-plugin-1.0-20240101.120000-3.jar")).unwrap(); + let mut zip = zip::ZipWriter::new(file); + zip.start_file("META-INF/maven/plugin.xml", zip::write::SimpleFileOptions::default()).unwrap(); + std::io::Write::write_all(&mut zip, descriptor.as_bytes()).unwrap(); + zip.finish().unwrap(); + // A sibling sources jar must not win. + std::fs::File::create(dir.join("maven-demo-plugin-1.0-20240101.120000-3-sources.jar")).unwrap(); + let p = plugin("g.p", "maven-demo-plugin", Some("1.0-SNAPSHOT".into()), &[], Some(tmp.path())); + assert_eq!(p.goals, ["run"]); + } + + #[test] + fn a_plugin_without_its_jar_keeps_the_table_and_the_bound_goals() { + let tmp = tempfile::tempdir().unwrap(); + let p = plugin( + "org.apache.maven.plugins", + "maven-checkstyle-plugin", + Some("3.3.1".into()), + &["check".to_owned()], + Some(tmp.path()), + ); + assert_eq!(p.goals, ["check"], "nothing beyond what the project binds"); + let known = + plugin("org.apache.maven.plugins", "maven-compiler-plugin", Some("3.13.0".into()), &[], Some(tmp.path())); + assert_eq!(known.goals, ["compile", "help", "testCompile"], "the common plugins keep their table"); + } + fn write(dir: &Path, rel: &str, text: &str) { let p = dir.join(rel); std::fs::create_dir_all(p.parent().unwrap()).unwrap(); diff --git a/crates/ide-build/src/sync.rs b/crates/ide-build/src/sync.rs index cbeed656f..815c46313 100644 --- a/crates/ide-build/src/sync.rs +++ b/crates/ide-build/src/sync.rs @@ -48,6 +48,11 @@ struct CachedMavenProject { #[derive(Clone, Debug, Default, Serialize, Deserialize)] struct MavenCache { + /// Bumped whenever what the cache holds stops matching what the model would be + /// built as now (plugin goals read from a descriptor, say): an older cache is + /// dropped instead of shadowing the model. + #[serde(default)] + version: u32, hash: String, at_ms: f64, error: Option, @@ -66,6 +71,7 @@ struct GradleCache { } const MAVEN_CACHE: &str = "maven.json"; +const MAVEN_CACHE_VERSION: u32 = 1; /// `dependency:tree` of a plugin version whose `-Dverbose` lists the occurrences Maven /// omitted (conflicts, duplicates): verbose output was dropped in 3.0 and is back since 3.2. @@ -121,6 +127,9 @@ fn now_ms() -> f64 { /// Merges the cached effective build into `model`. pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel) { let Some(cache) = read_cache::(cache_dir, MAVEN_CACHE) else { return }; + if cache.version != MAVEN_CACHE_VERSION { + return; // built by an older IDE: what it holds no longer matches the model + } model.synced_at = Some(cache.at_ms); model.sync_error = cache.error; let changed = cache.hash != hash_files(&maven::build_files(root, model)); @@ -211,8 +220,31 @@ pub fn sync_maven( let mut model = maven::load(root); maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model); std::fs::create_dir_all(cache_dir)?; - let mut cache = - MavenCache { hash: hash_files(&maven::build_files(root, &model)), verbose: true, ..MavenCache::default() }; + let mut cache = MavenCache { + version: MAVEN_CACHE_VERSION, + hash: hash_files(&maven::build_files(root, &model)), + verbose: true, + ..MavenCache::default() + }; + // Where the build resolves its jars, and therefore where the plugin goals are read + // from: `-Dmaven.repo.local=` first, then `` of the `-s` settings, + // then the user's default repository (which `plugin()` resolves itself). + let local_repo: Option = + options.maven_options.iter().find_map(|a| a.strip_prefix("-Dmaven.repo.local=")).map(PathBuf::from).or_else( + || { + let mut args = options.maven_options.iter(); + while let Some(arg) = args.next() { + let path = match arg.as_str() { + "-s" | "--settings" => args.next().map(String::as_str), + _ => arg.strip_prefix("-s=").or_else(|| arg.strip_prefix("--settings=")), + }; + if let Some(path) = path { + return maven::local_repository_of(Path::new(path)); + } + } + None + }, + ); let mut errors: Vec = Vec::new(); for (n, top) in model.projects.iter().filter(|p| !p.aggregated).enumerate() { let effective = cache_dir.join(format!("effective-pom-{n}.xml")); @@ -241,8 +273,9 @@ pub fn sync_maven( }); } })?; - let effective_projects = - std::fs::read_to_string(&effective).map(|t| maven::parse_effective_pom(&t)).unwrap_or_default(); + let effective_projects = std::fs::read_to_string(&effective) + .map(|t| maven::parse_effective_pom_in(&t, local_repo.as_deref())) + .unwrap_or_default(); let trees = maven::parse_dependency_tree(&out.text); for (key, plugins, deps) in effective_projects { let dependencies = trees.iter().find(|(k, _)| *k == key).map(|(_, t)| t.clone()).unwrap_or(deps); @@ -332,6 +365,7 @@ mod tests { let mut model = maven::load_with_repo(&root, None); assert!(model.synced_at.is_none()); let cache = MavenCache { + version: MAVEN_CACHE_VERSION, hash: hash_files(&maven::build_files(&root, &model)), at_ms: 5.0, error: None, @@ -343,6 +377,7 @@ mod tests { "maven-clean-plugin", Some("3.2.0".into()), &[], + None, )], dependencies: vec![MavenDependency { artifact_id: "dep".into(), ..MavenDependency::default() }], }], @@ -368,6 +403,41 @@ mod tests { assert_eq!(model.projects[0].dependencies[0].artifact_id, "fresh"); } + #[test] + fn a_cache_from_an_older_ide_is_ignored() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("p"); + let cache_dir = tmp.path().join("cache"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("pom.xml"), "ga") + .unwrap(); + let mut model = maven::load_with_repo(&root, None); + let cache = MavenCache { + // What an older build wrote: no plugin descriptors were read back then, so + // its goal lists would shadow the ones the model now knows. + version: 0, + hash: hash_files(&maven::build_files(&root, &model)), + at_ms: 5.0, + error: None, + verbose: true, + projects: vec![CachedMavenProject { + key: "g:a".into(), + plugins: vec![maven::plugin( + "org.apache.maven.plugins", + "maven-clean-plugin", + Some("3.2.0".into()), + &[], + None, + )], + dependencies: vec![MavenDependency { artifact_id: "cached".into(), ..MavenDependency::default() }], + }], + }; + write_cache(&cache_dir, MAVEN_CACHE, &cache).unwrap(); + apply_maven_cache(&root, &cache_dir, &mut model); + assert!(model.synced_at.is_none(), "the cache is dropped, the model stays unsynced"); + assert!(model.projects[0].dependencies.iter().all(|d| d.artifact_id != "cached")); + } + #[test] fn gradle_cache_replaces_tasks_per_project() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-core/src/event.rs b/crates/ide-core/src/event.rs index 5de989786..e2d45fec6 100644 --- a/crates/ide-core/src/event.rs +++ b/crates/ide-core/src/event.rs @@ -82,6 +82,12 @@ pub enum IdeEvent { status: ServerStatus, message: Option, }, + /// A running server lacks a capability; the UI offers the managed tool as a replacement. + LanguageServerWarning { + name: String, + message: String, + install_tool: String, + }, /// A stopped language server left the project's list (disabled in settings, or a /// fallback that a preferred server took over from again). LanguageServerRemoved { diff --git a/crates/ide-core/src/java_runtime.rs b/crates/ide-core/src/java_runtime.rs new file mode 100644 index 000000000..83d1ee217 --- /dev/null +++ b/crates/ide-core/src/java_runtime.rs @@ -0,0 +1,25 @@ +//! Capabilities needed by the JVM hosting Java language-server bundles. + +use std::path::Path; + +/// jdtls' javac bundle, java-debug / hot code replace, and Lombok / Gradle tooling. +const BUNDLE_MODULES: &[&str] = &["jdk.compiler", "jdk.jdi", "jdk.unsupported"]; + +/// Require a full JDK, including the modules used by jdtls and its bundles. +/// Temurin JREs can list the build JDK's modules in `release` even when the runtime +/// image omits them, so module metadata alone does not establish suitability. +pub fn supports_java_bundles(home: &Path) -> bool { + let Ok(release) = std::fs::read_to_string(home.join("release")) else { return false }; + let property = + |key: &str| release.lines().find_map(|line| line.trim().strip_prefix(key)).map(|v| v.trim().trim_matches('"')); + let javac = if cfg!(windows) { "javac.exe" } else { "javac" }; + if property("IMAGE_TYPE=").is_some_and(|image| image.eq_ignore_ascii_case("jre")) + || !home.join("bin").join(javac).is_file() + { + return false; + } + match property("MODULES=") { + Some(modules) => BUNDLE_MODULES.iter().all(|required| modules.split_whitespace().any(|m| m == *required)), + None => BUNDLE_MODULES.iter().all(|m| home.join("jmods").join(format!("{m}.jmod")).is_file()), + } +} diff --git a/crates/ide-core/src/lib.rs b/crates/ide-core/src/lib.rs index fcb39a1f2..023f9d729 100644 --- a/crates/ide-core/src/lib.rs +++ b/crates/ide-core/src/lib.rs @@ -11,6 +11,7 @@ pub mod ids; pub mod ignored; pub mod inspection_settings; pub mod ipc_path; +pub mod java_runtime; pub mod jsonc; pub mod output_encoding; pub mod process; diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 7c2c17a37..453b1433f 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -235,7 +235,7 @@ impl Default for GeneralSettings { fn default() -> Self { Self { reopen_last_project: true, - confirm_exit: false, + confirm_exit: true, open_project_in: "ask".into(), default_project_directory: String::new(), save_on_idle: false, diff --git a/crates/ide-core/src/workflow_settings.rs b/crates/ide-core/src/workflow_settings.rs index 64fb58fbe..3b31a576f 100644 --- a/crates/ide-core/src/workflow_settings.rs +++ b/crates/ide-core/src/workflow_settings.rs @@ -41,6 +41,8 @@ impl Default for VcsSettings { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct GitSettings { + /// Show blame after the current editor line. + pub inline_blame: bool, /// Path to the git executable; empty = auto-detect. pub executable: String, /// Default of Update Project: `merge` | `rebase`. @@ -71,6 +73,7 @@ pub struct GitSettings { impl Default for GitSettings { fn default() -> Self { Self { + inline_blame: false, executable: String::new(), update_method: "merge".into(), auto_update_on_push_rejected: false, diff --git a/crates/ide-dap/src/lib.rs b/crates/ide-dap/src/lib.rs index 638ec8d38..5b8786a14 100644 --- a/crates/ide-dap/src/lib.rs +++ b/crates/ide-dap/src/lib.rs @@ -1389,6 +1389,16 @@ impl DebugManager { }) } + /// Stops every session `key` started: the window that opened the project is closing. + pub async fn stop_key(&self, key: &str) { + let ids: Vec = self.sessions(Some(key)).into_iter().map(|s| s.id).collect(); + for id in ids { + if let Err(e) = self.stop(id).await { + tracing::debug!("debug session {id:?} did not stop: {e}"); + } + } + } + /// Stops a session and its child sessions. pub async fn stop(&self, s: DebugSessionId) -> IdeResult<()> { let session = self.session(s)?; diff --git a/crates/ide-host/src/formatter.rs b/crates/ide-host/src/formatter.rs index 7bc9af9d1..054497b37 100644 --- a/crates/ide-host/src/formatter.rs +++ b/crates/ide-host/src/formatter.rs @@ -7,6 +7,16 @@ use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Range, TextEdit}; use ide_language::FormatterConfig; use ide_lsp::types::FormatOptions; use ide_tools::{Tools, formatter}; +use std::path::Path; + +const OXFMT_CONFIGS: [&str; 4] = [".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts", "oxfmt.config.mts"]; + +/// oxfmt below an oxfmt configuration file, else Prettier. The Prettier +/// settings (languages, preference, on save) apply to both. +fn external_tool(file: &Path) -> &'static str { + let oxfmt = OXFMT_CONFIGS.iter().any(|name| formatter::find_up(file, Path::new(name)).is_some()); + if oxfmt { "oxfmt" } else { "prettier" } +} fn external_selected(config: Option<&FormatterConfig>, language: &str, settings: &PrettierSettings, lsp: bool) -> bool { config.is_some_and(|c| c.tool == "prettier") @@ -44,16 +54,19 @@ pub async fn format( return Err(IdeError::invalid("The file is read-only")); } let config = config.unwrap().clone(); - let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path); + let tool = external_tool(&snap.path); + let name = formatter::display_name(tool); + let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path).filter(|_| tool == "prettier"); let tools = Tools::new(&host.local_data_dir); - let whole_selection = range.is_some() && !config.range; + let whole_selection = range.is_some() && !formatter::supports_range(tool, &config); let edits: IdeResult> = tokio::task::spawn_blocking(move || { let text = ide_text::Document::new(&snap.text); let offsets = range.map(|r| (text.pos_to_offset(r.start), text.pos_to_offset(r.end))); - let mut command = tools.formatter_command(&config.tool, &snap.path, custom.as_deref())?; - command.current_dir(snap.path.parent().ok_or_else(|| IdeError::invalid("Prettier: file has no directory"))?); - command.args(formatter::arguments(&config, &snap.path, options.tab_size, options.insert_spaces, offsets)); - let formatted = formatter::run(command, &snap.text, &Default::default())?; + let mut command = tools.formatter_command(tool, &snap.path, custom.as_deref())?; + let dir = snap.path.parent().ok_or_else(|| IdeError::invalid(format!("{name}: file has no directory")))?; + command.current_dir(dir); + command.args(formatter::arguments(tool, &config, &snap.path, options.tab_size, options.insert_spaces, offsets)); + let formatted = formatter::run(tool, command, &snap.text, &Default::default())?; Ok(text .diff(&formatted) .changes @@ -69,13 +82,13 @@ pub async fn format( if edits.is_ok() && whole_selection { project.sink.emit(IdeEvent::Notify { level: NotifyLevel::Info, - title: "Prettier".into(), - body: Some( - "Prettier does not support selection formatting for this language; formatted the whole file".into(), - ), + title: name.into(), + body: Some(format!( + "{name} does not support selection formatting for this language; formatted the whole file" + )), }); } - edits.map_err(|e| e.with_tool("prettier")) + edits.map_err(|e| e.with_tool(tool)) } #[cfg(test)] @@ -104,4 +117,19 @@ mod tests { assert!(!external_selected(ts.formatter.as_ref(), "typescript", &settings, false)); assert!(!external_selected(None, "rust", &settings, false)); } + + #[test] + fn oxfmt_config_selects_oxfmt_over_prettier() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("web/src")).unwrap(); + let (web, api) = (root.join("web/src/app.ts"), root.join("api/main.ts")); + assert_eq!(external_tool(&web), "prettier"); + for config in OXFMT_CONFIGS { + std::fs::write(root.join("web").join(config), "{}").unwrap(); + assert_eq!(external_tool(&web), "oxfmt", "{config}"); + assert_eq!(external_tool(&api), "prettier", "{config}"); + std::fs::remove_file(root.join("web").join(config)).unwrap(); + } + } } diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index 66e21c9b7..d5fef1e5f 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -176,12 +176,21 @@ impl Host { } project.agents.shutdown(); project.notebooks.stop_admission(); + // What the closing window started goes with it — the builds and searches it + // asked for, its runs, its shells and its debug sessions — and the language + // servers finish shutting down before the window is gone, as IDEA closes a + // project's processes. + for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() { + self.cancel_request(request); + } + self.processes.kill_project(&project.root); + self.terminals.kill_key(self.owner_window(window)); self.shutdown_project_workers_blocking( vec![project.notebooks.clone()], project.kotlin_bridge.iter().cloned().collect(), + Some(project.lsp.clone()), + Some((self.debugger.clone(), window.to_owned())), ); - let lsp = project.lsp.clone(); - self.runtime.spawn(async move { lsp.shutdown_all().await }); } } @@ -191,6 +200,8 @@ impl Host { &self, notebooks: Vec>, bridges: Vec>, + lsp: Option>, + debug: Option<(Arc, String)>, ) { for bridge in &bridges { bridge.stop(); @@ -208,6 +219,12 @@ impl Host { for service in notebooks { service.shutdown().await; } + if let Some(lsp) = lsp { + lsp.shutdown_all().await; + } + if let Some((debug, key)) = debug { + debug.stop_key(&key).await; + } }); }); if let Err(error) = worker.join() { @@ -229,6 +246,8 @@ impl Host { self.shutdown_project_workers_blocking( projects.iter().map(|project| project.notebooks.clone()).collect(), projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(), + None, + None, ); } diff --git a/crates/ide-host/src/ops/term.rs b/crates/ide-host/src/ops/term.rs index c7a284c9f..02a340266 100644 --- a/crates/ide-host/src/ops/term.rs +++ b/crates/ide-host/src/ops/term.rs @@ -40,7 +40,8 @@ pub fn spawn( .ok() .filter(|p| p.is_trusted()) .and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), &shell)); - let info = host.terminals.spawn(options, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?; + let key = host.owner_window(window).to_owned(); + let info = host.terminals.spawn(options, key, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?; if let Some(line) = activation { let _ = host.terminals.write(info.id, &format!(" {line}\r")); } diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index faf28db01..0e00ae8b9 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -513,7 +513,7 @@ impl Host { sink.clone(), ); let project = Arc::new(Project { - trusted: Arc::new(AtomicBool::new(true)), + trusted: Arc::new(AtomicBool::new(false)), sink, languages, settings, diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index 7e8522e10..5fd072bba 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -135,9 +135,7 @@ impl crate::Host { for request in requests { self.cancel_request(request); } - for session in self.debugger.sessions(Some(&window)) { - let _ = self.debugger.stop(session.id).await; - } + self.debugger.stop_key(&window).await; } } project.lsp.reconfigure().await; diff --git a/crates/ide-host/tests/close_project.rs b/crates/ide-host/tests/close_project.rs new file mode 100644 index 000000000..e0b07cd11 --- /dev/null +++ b/crates/ide-host/tests/close_project.rs @@ -0,0 +1,54 @@ +//! Closing a project takes the background processes of its window with it, as IDEA does, +//! and leaves the other windows' processes alone. +#![cfg(unix)] + +use ide_core::NullSink; +use ide_host::{Host, HostConfig, ops}; +use std::sync::Arc; + +fn options(root: &std::path::Path) -> ide_terminal::TerminalOptions { + ide_terminal::TerminalOptions { + cwd: root.to_path_buf().into(), + // A fixed shell: the test must not depend on the login shell of this machine. + shell: Some("/bin/sh".into()), + args: vec![], + env: Default::default(), + cols: 80, + rows: 24, + shell_integration: false, + } +} + +#[test] +fn closing_a_project_closes_its_terminals_and_keeps_other_windows() { + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap().join("project"); + std::fs::create_dir_all(&root).unwrap(); + let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap(); + runtime.block_on(async { + let host = Arc::new(Host::new(HostConfig { + config_dir: tmp.path().join("config"), + data_dir: tmp.path().join("data"), + local_data_dir: tmp.path().join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })); + host.trust.decide(&root, true, false).unwrap(); + host.open_project("main", &root).unwrap(); + let project_shell = + ops::term::spawn(&host, "main", options(&root), Arc::new(|_| {})).expect("project terminal"); + let other_shell = ops::term::spawn(&host, "other", options(&root), Arc::new(|_| {})).expect("other terminal"); + assert_eq!(host.terminals.list().len(), 2); + + host.close_project("main"); + + let left: Vec<_> = host.terminals.list().into_iter().map(|t| t.id).collect(); + assert!(!left.contains(&project_shell.id), "the closing window's shell is gone"); + assert!(left.contains(&other_shell.id), "another window keeps its shell"); + host.terminals.kill_key("other"); + }); +} diff --git a/crates/ide-host/tests/light_edit.rs b/crates/ide-host/tests/light_edit.rs index 1eff9e388..99f2e96c2 100644 --- a/crates/ide-host/tests/light_edit.rs +++ b/crates/ide-host/tests/light_edit.rs @@ -78,3 +78,104 @@ async fn light_windows_ignore_project_scoped_settings() { assert!(!applied.project_scope); assert!(!dir.join(".openidea").exists(), "no project settings next to the file"); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn light_windows_stay_restricted_when_folder_trust_changes() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("loose"); + let other = tmp.path().join("other"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&other).unwrap(); + std::fs::write(root.join("one.py"), "print(1)\n").unwrap(); + std::fs::write(other.join("two.js"), "const value = 1;\n").unwrap(); + let host = host(&tmp.path().join("app")); + host.global_settings + .set(ide_workspace::SettingsScope::Global, "languages.prettier.prefer", serde_json::json!(true)) + .unwrap(); + let light = host.open_light("main", &root.join("one.py")).unwrap(); + assert!(!light.is_trusted()); + assert!(light.require_trust().is_err()); + + host.set_project_trust(&root, true, false).await.unwrap(); + assert!(!light.is_trusted()); + let trusted_folder = host.open_light("second", &root.join("one.py")).unwrap(); + assert!(!trusted_folder.is_trusted()); + let again = host.open_light("main", &other.join("two.js")).unwrap(); + assert!(Arc::ptr_eq(&light, &again)); + assert!(!again.is_trusted()); + host.set_project_trust(&root, true, true).await.unwrap(); + assert!(!light.is_trusted()); + assert!(!trusted_folder.is_trusted()); + for command in ["sdk_detect", "sdk_locate_tools", "notebook_execute", "notebook_restart", "run_start"] { + assert!(host.authorize_command("main", command).is_err(), "{command}"); + } + + let doc = ops::doc::open(&host, "main", other.join("two.js")).await.unwrap(); + let error = ops::lsp::lsp_format( + &host, + "main", + doc.id, + None, + ide_lsp::types::FormatOptions { tab_size: 2, insert_spaces: true, on_save: false }, + ) + .await + .unwrap_err(); + assert_eq!(error.kind, ide_core::error::ErrorKind::Unavailable); + assert!(error.message.contains("Restricted Mode")); + + host.set_project_trust(&root, false, false).await.unwrap(); + assert!(!light.is_trusted()); + assert!(!trusted_folder.is_trusted()); + host.set_project_trust(&root, true, false).await.unwrap(); + let project = host.open_project("main", &root).unwrap(); + assert!(!project.light); + assert!(project.is_trusted()); + assert!(project.require_trust().is_ok()); + host.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn light_notebooks_open_without_interpreter_detection_or_execution() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("loose"); + let bin = root.join(".venv").join(if cfg!(windows) { "Scripts" } else { "bin" }); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(root.join(".venv/pyvenv.cfg"), "home = python\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let python = bin.join("python"); + std::fs::write(&python, "#!/bin/sh\nprintf probed > \"$0.probed\"\nprintf 'Python 3.12.0\\n'\n").unwrap(); + std::fs::set_permissions(&python, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let file = root.join("analysis.ipynb"); + std::fs::write( + &file, + r#"{"cells":[{"id":"cell","cell_type":"code","source":["print(1)"],"metadata":{},"outputs":[],"execution_count":null}],"metadata":{},"nbformat":4,"nbformat_minor":5}"#, + ) + .unwrap(); + let host = host(&tmp.path().join("app")); + host.open_light("main", &file).unwrap(); + let notebook = ops::notebook::open(&host, "main", file.to_string_lossy().into()).await.unwrap(); + assert!(ops::notebook::save(&host, "main", notebook.id.clone()).await.is_ok()); + let error = ops::sdk::sdk_detect(&host, "main", ide_sdk::SdkKind::Python).await.unwrap_err(); + assert!(error.message.contains("Restricted Mode")); + assert!(ops::sdk::sdk_python_effective(&host, "main").is_err()); + assert!(ops::sdk::sdk_locate_tools(&host, "main", vec!["prettier".into()], Some(root)).await.is_err()); + let error = ops::notebook::restart(&host, "main", notebook.id.clone()).await.unwrap_err(); + assert!(error.message.contains("Restricted Mode")); + let error = ops::notebook::execute( + &host, + "main", + notebook.id, + vec![notebook.cells[0].id.clone()], + None, + ide_core::RequestId(849), + ops::Channel::new(|_| {}), + ) + .await + .unwrap_err(); + assert!(error.message.contains("Restricted Mode")); + assert!(!bin.join("python.probed").exists()); + host.shutdown().await; +} diff --git a/crates/ide-host/tests/terminal_images.rs b/crates/ide-host/tests/terminal_images.rs index 13605357a..8bb4a3e11 100644 --- a/crates/ide-host/tests/terminal_images.rs +++ b/crates/ide-host/tests/terminal_images.rs @@ -35,6 +35,7 @@ async fn storage_requires_a_registered_terminal() { rows: 24, shell_integration: false, }, + "main".into(), Arc::new(NullSink), Box::new(|_| {}), ) diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index 5ceaec741..584943590 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -576,7 +576,7 @@ mod tests { }; // After the TypeScript fallback (an available server before it would hide // it); before the HTML server (hover, rename and symbols take the first). - assert_eq!(names("typescript"), ["vtsls", "typescript-language-server", "angular-language-server"]); + assert_eq!(names("typescript"), ["vtsls", "typescript-language-server", "angular-language-server", "oxlint"]); assert_eq!(names("html"), ["angular-language-server", "vscode-html-language-server"]); for lang in ["typescript", "html"] { let cfg = reg.get(&LanguageId::new(lang)).unwrap(); @@ -586,6 +586,24 @@ mod tests { } } + #[test] + fn oxlint_serves_js_files_only_below_its_config() { + let reg = reg(); + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir_all(root.join("apps/web/src")).unwrap(); + std::fs::write(root.join("apps/web/.oxlintrc.json"), "{}").unwrap(); + for lang in ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue", "svelte"] { + let cfg = reg.get(&LanguageId::new(lang)).unwrap(); + let oxlint = cfg.language_servers.iter().find(|s| s.name == "oxlint").unwrap(); + assert_eq!((oxlint.command.as_str(), oxlint.args.as_slice()), ("oxlint", ["--lsp".to_string()].as_slice())); + assert!(oxlint.require_root_marker && !oxlint.fallback, "{lang}"); + let served = |file: &str| crate::marked_root(Some(root), &root.join(file), &oxlint.root_markers); + assert_eq!(served("apps/web/src/main.ts"), Some(root.join("apps/web")), "{lang}"); + assert_eq!(served("apps/api/main.ts"), None, "{lang}"); + } + } + #[test] fn html_of_angular_projects_is_an_angular_template() { let reg = reg(); diff --git a/crates/ide-lsp/src/clang_tidy.rs b/crates/ide-lsp/src/clang_tidy.rs new file mode 100644 index 000000000..351ae473b --- /dev/null +++ b/crates/ide-lsp/src/clang_tidy.rs @@ -0,0 +1,126 @@ +//! Apple clangd accepts `.clang-tidy` projects but ships without clang-tidy checks. + +use crate::{LspManager, Server}; +use ide_core::IdeEvent; +use std::path::Path; + +pub(crate) const MESSAGE: &str = "Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them."; + +fn is_apple_clangd(path: &Path) -> bool { + if path == Path::new("/usr/bin/clangd") { + return true; + } + if !path.ends_with("usr/bin/clangd") { + return false; + } + path.starts_with("/Library/Developer/CommandLineTools") + || path.ancestors().any(|ancestor| { + ancestor.file_name().is_some_and(|name| name == "Toolchains") + && ancestor.parent().is_some_and(|developer| { + developer.ends_with("Contents/Developer") + && developer.parent().and_then(Path::parent).and_then(Path::file_name).is_some_and(|name| { + let name = name.to_string_lossy(); + name.starts_with("Xcode") && name.ends_with(".app") + }) + }) + }) +} + +fn has_config(server_root: &Path, project_root: &Path, file: &Path) -> bool { + server_root.join(".clang-tidy").is_file() + || file + .parent() + .into_iter() + .flat_map(Path::ancestors) + .take_while(|dir| dir.starts_with(project_root)) + .any(|dir| dir.join(".clang-tidy").is_file()) +} + +impl LspManager { + pub(crate) async fn check_clang_tidy(&self, server: &Server) { + if server.name != "clangd" + || !cfg!(target_os = "macos") + || self.shown_messages.lock().contains(&("clangd".into(), MESSAGE.into())) + { + return; + } + let binary = server.resolved_binary.read().clone(); + let Some(binary) = binary else { return }; + let files: Vec<_> = self + .docs + .lock() + .values() + .filter(|doc| { + matches!(doc.language.as_str(), "c" | "cpp" | "objective-c" | "objective-cpp") + && doc.servers.iter().any(|attached| attached.server.id == server.id) + }) + .map(|doc| doc.path.clone()) + .collect(); + let root = server.root.clone(); + let Some(me) = self.me.upgrade() else { return }; + let _ = tokio::task::spawn_blocking(move || { + if !is_apple_clangd(&binary) && !std::fs::canonicalize(&binary).is_ok_and(|path| is_apple_clangd(&path)) { + return; + } + if !me.shutting_down.load(std::sync::atomic::Ordering::Acquire) + && files.iter().any(|file| has_config(&root, &me.project_root_for(file), file)) + && me.first_message("clangd", MESSAGE) + { + me.sink.emit(IdeEvent::LanguageServerWarning { + name: "clangd".into(), + message: MESSAGE.into(), + install_tool: "clangd".into(), + }); + } + }) + .await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn apple_paths() { + for path in [ + "/usr/bin/clangd", + "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clangd", + "/Applications/Xcode-beta.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clangd", + "/Library/Developer/CommandLineTools/usr/bin/clangd", + ] { + assert!(is_apple_clangd(Path::new(path)), "{path}"); + } + for path in [ + "/Users/me/Library/Application Support/openidea/tools/clangd/bin/clangd", + "/opt/homebrew/opt/llvm/bin/clangd", + "/usr/local/bin/clangd", + "/usr/bin/clangd-custom", + "/Library/Developer/CommandLineTools-custom/usr/bin/clangd", + "/Applications/Other.app/Contents/Developer/Toolchains/LLVM.xctoolchain/usr/bin/clangd", + ] { + assert!(!is_apple_clangd(Path::new(path)), "{path}"); + } + } + + #[test] + fn config_in_server_root_or_file_ancestors_stays_inside_project() { + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("project"); + let server = project.join("build"); + let source = project.join("src/nested"); + let file = source.join("main.cpp"); + std::fs::create_dir_all(&source).unwrap(); + std::fs::create_dir_all(&server).unwrap(); + std::fs::write(dir.path().join(".clang-tidy"), "").unwrap(); + assert!(!has_config(&server, &project, &file)); + for root in [&server, &source, &project.join("src"), &project] { + let config = root.join(".clang-tidy"); + std::fs::write(&config, "Checks: '-*,modernize-use-nullptr'").unwrap(); + assert!(has_config(&server, &project, &file), "{}", root.display()); + std::fs::remove_file(config).unwrap(); + } + std::fs::create_dir(source.join(".clang-tidy")).unwrap(); + assert!(!has_config(&server, &project, &file)); + } +} diff --git a/crates/ide-lsp/src/launch.rs b/crates/ide-lsp/src/launch.rs index 36ae556cb..5c7d53c01 100644 --- a/crates/ide-lsp/src/launch.rs +++ b/crates/ide-lsp/src/launch.rs @@ -43,6 +43,18 @@ pub trait Launcher: Send + Sync + 'static { self.launch(config, root, handler, on_stderr) } + /// Starts a server and reports its executable when the launcher runs a local process. + fn launch_resolved( + &self, + config: &LanguageServerConfig, + root: &Path, + handler: Arc, + on_stderr: Box, + cancel: &CancellationToken, + ) -> Result<(Peer, Option), LaunchError> { + self.launch_cancellable(config, root, handler, on_stderr, cancel).map(|peer| (peer, None)) + } + /// Whether `config` could start without a `Missing` error. fn available(&self, _config: &LanguageServerConfig, _root: &Path) -> bool { true @@ -72,6 +84,17 @@ impl Launcher for ProcessLauncher { on_stderr: Box, cancel: &CancellationToken, ) -> Result { + self.launch_resolved(config, root, handler, on_stderr, cancel).map(|(peer, _)| peer) + } + + fn launch_resolved( + &self, + config: &LanguageServerConfig, + root: &Path, + handler: Arc, + on_stderr: Box, + cancel: &CancellationToken, + ) -> Result<(Peer, Option), LaunchError> { let search = server_search_path(config); let binary = ide_core::tool_path::find_command(&config.command, &search, root) .and_then(|b| resolve_rustup_proxy(&config.command, b)); @@ -87,10 +110,12 @@ impl Launcher for ProcessLauncher { handler, on_stderr, cancel, - ); + ) + .map(|peer| (peer, None)); } let binary = binary.ok_or(LaunchError::Missing)?; Peer::spawn(&binary.to_string_lossy(), &config.args, root, &env, handler, on_stderr) + .map(|peer| (peer, Some(binary.clone()))) .map_err(|e| LaunchError::Failed(format!("failed to start {}: {e}", binary.display()))) } diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 521451845..d8ead994b 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -8,6 +8,7 @@ mod angular; mod caps; +mod clang_tidy; mod convert; mod external_project; mod file_ops; @@ -472,13 +473,16 @@ impl LspManager { if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() { return Err(LaunchError::Config("Project is in Restricted Mode".into())); } - me.launcher.launch_cancellable(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line)), &cancel) + me.launcher.launch_resolved(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line)), &cancel) }) .await .unwrap_or_else(|e| Err(LaunchError::Failed(e.to_string()))); let peer = match launched { - Ok(peer) => peer, + Ok((peer, binary)) => { + *server.resolved_binary.write() = binary; + peer + } Err(LaunchError::Missing) => { let cfg = server.effective_config(); let message = @@ -587,6 +591,7 @@ impl LspManager { } } } + self.check_clang_tidy(&server).await; server.refresh_busy_status(&self.sink); server.announce_ready(generation); self.spawn_supervisor(server, generation, peer); @@ -810,6 +815,12 @@ impl LspManager { } for s in &servers { self.ensure_started(s); + if s.running().is_some() + && let Some(me) = self.me.upgrade() + { + let server = s.clone(); + self.runtime.spawn(async move { me.check_clang_tidy(&server).await }); + } } } diff --git a/crates/ide-lsp/src/server.rs b/crates/ide-lsp/src/server.rs index 81c21b021..f15a893e6 100644 --- a/crates/ide-lsp/src/server.rs +++ b/crates/ide-lsp/src/server.rs @@ -79,6 +79,7 @@ pub struct Server { pub config: LanguageServerConfig, /// `config` with the settings hook applied, as the current instance was launched. effective: Mutex>, + pub resolved_binary: RwLock>, languages: Mutex>, state: Mutex, /// Fired when the instance being started is abandoned (stop / restart while starting). @@ -114,6 +115,7 @@ impl Server { root, config, effective: Mutex::new(None), + resolved_binary: RwLock::new(None), languages: Mutex::new(vec![language]), state: Mutex::new(State { generation: 0, conn: Conn::Idle }), start_cancel: Mutex::new(CancellationToken::new()), diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 7bfb45ccd..d2136467e 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -21,6 +21,7 @@ use std::time::Duration; struct MockState { caps: Mutex, missing: std::sync::atomic::AtomicBool, + binary: Mutex>, launches: AtomicUsize, notifications: Mutex>, requests: Mutex>, @@ -148,6 +149,18 @@ impl MockLauncher { } impl Launcher for MockLauncher { + fn launch_resolved( + &self, + config: &LanguageServerConfig, + root: &Path, + handler: Arc, + on_stderr: Box, + cancel: &ide_core::CancellationToken, + ) -> Result<(Peer, Option), LaunchError> { + self.launch_cancellable(config, root, handler, on_stderr, cancel) + .map(|peer| (peer, self.state(&config.name).binary.lock().clone())) + } + fn launch( &self, config: &LanguageServerConfig, @@ -2836,3 +2849,68 @@ async fn formatting_provider_selection_tracks_dynamic_registration() { .unwrap(); assert!(!h.mgr.can_format(doc).await.unwrap()); } + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn apple_clangd_warns_for_tidy_config_once_across_languages_and_restarts() { + for language in ["c", "cpp", "objective-c", "objective-cpp"] { + let h = harness_for(language, "src", &[("clangd", incremental_caps())], &[]); + *h.launcher.state("clangd").binary.lock() = Some(PathBuf::from("/usr/bin/clangd")); + std::fs::write(h.root.join(".clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap(); + h.open("main.src", "int *p = 0;"); + eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. }))).await; + let id = h.mgr.servers()[0].id; + h.mgr.restart(id).await.unwrap(); + h.wait_status("clangd", ServerStatus::Ready).await; + h.open("other.src", "int *p = 0;"); + h.mgr.shutdown_all().await; + let events = h.events.lock(); + let warnings: Vec<_> = events + .iter() + .filter_map(|e| match e { + IdeEvent::LanguageServerWarning { name, message, install_tool } => Some((name, message, install_tool)), + _ => None, + }) + .collect(); + assert_eq!(warnings.len(), 1, "{language}"); + assert_eq!(warnings[0].0, "clangd"); + assert!(warnings[0].1.contains(".clang-tidy")); + assert_eq!(warnings[0].2, "clangd"); + } +} + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn clang_tidy_warning_requires_apple_binary_and_a_config() { + for (binary, config) in [ + ("/usr/bin/clangd", false), + ("/opt/homebrew/opt/llvm/bin/clangd", true), + ("/Users/me/Library/Application Support/openidea/tools/clangd/bin/clangd", true), + ] { + let h = harness_for("cpp", "cpp", &[("clangd", incremental_caps())], &[]); + *h.launcher.state("clangd").binary.lock() = Some(PathBuf::from(binary)); + if config { + std::fs::write(h.root.join(".clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap(); + } + h.open("main.cpp", "int *p = 0;"); + h.wait_status("clangd", ServerStatus::Ready).await; + h.mgr.shutdown_all().await; + assert!(!h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. })), "{binary}"); + } +} + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn clang_tidy_config_for_a_later_opened_file_warns_on_a_running_server() { + let h = harness_for("cpp", "cpp", &[("clangd", incremental_caps())], &[]); + *h.launcher.state("clangd").binary.lock() = Some(PathBuf::from("/usr/bin/clangd")); + h.open("main.cpp", "int *p = 0;"); + h.wait_status("clangd", ServerStatus::Ready).await; + assert!(!h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. }))); + std::fs::create_dir(h.root.join("nested")).unwrap(); + std::fs::write(h.root.join("nested/.clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap(); + h.open("nested/other.cpp", "int *p = 0;"); + eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. }))).await; + assert_eq!(h.mgr.servers().len(), 1); + h.mgr.shutdown_all().await; +} diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 668f38d63..8c484f9fd 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -88,6 +88,9 @@ pub fn project_jdk(root: &Path, s: &Settings) -> Option { pub fn jdtls_jdk(root: &Path, s: &Settings) -> Result, String> { match jdk_setting(root, &s.languages.java.jdtls_jdk) { None => Ok(None), + Some(Ok(jdk)) if !ide_core::java_runtime::supports_java_bundles(&jdk.home) => { + Err(crate::java::server_jdk_incomplete_message(&jdk, jdk.major.max(crate::java::JDTLS_MIN_JAVA))) + } Some(Ok(jdk)) => Ok(Some(jdk)), Some(Err(p)) => { Err(format!("The language server JDK {} (Settings › Languages & Frameworks › Java).", not_a_jdk(&p))) diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index 21b217dc0..56d81ecf7 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -16,6 +16,13 @@ pub struct Jdk { pub home: PathBuf, } +pub(crate) fn server_jdk_incomplete_message(jdk: &Jdk, min_java: u32) -> String { + format!( + "The language server JDK ({}) must be a full JDK with jdk.compiler, jdk.jdi and jdk.unsupported for Java language support and debugging. jdtls needs JDK {min_java} or newer. Download JDK {min_java}, choose a full JDK in {SERVER_JDK_SETTING}, or set it to Auto.", + jdk.home.display() + ) +} + /// `JAVA_VERSION="21.0.3"` (or `"1.8.0_372"`) from a JDK's `release` file. pub fn parse_release(release: &str) -> Option { let line = release.lines().find_map(|l| l.trim().strip_prefix("JAVA_VERSION="))?; @@ -199,7 +206,7 @@ pub const JDTLS_MIN_JAVA: u32 = 21; /// The JDK to run jdtls with: the first found that is new enough (`JAVA_HOME` wins). pub fn jdtls_runtime(jdks: &[Jdk], min_java: u32) -> Option<&Jdk> { - jdks.iter().find(|j| j.major >= min_java) + jdks.iter().find(|j| j.major >= min_java && ide_core::java_runtime::supports_java_bundles(&j.home)) } /// The Java version the jdtls that `cfg` launches needs: the highest any of its @@ -313,21 +320,36 @@ fn text_key(text: &str) -> String { format!("{h:016x}") } +/// Adds `arg` as its own `--jvm-arg`, unless the user already set the property it +/// carries: a value of theirs wins, and a second call adds nothing. +fn set_jvm_property(args: &mut Vec, arg: &str) { + let key = arg.split('=').next().unwrap_or(arg); + let taken = args.iter().enumerate().any(|(i, a)| { + let value = + a.strip_prefix("--jvm-arg=").or_else(|| (i > 0 && args[i - 1] == "--jvm-arg").then_some(a.as_str())); + value.is_some_and(|a| a.split('=').next() == Some(key)) + }); + if !taken { + args.push(format!("--jvm-arg={arg}")); + } +} + fn add_jdtls_proxy_args(args: &mut Vec, proxy: &ide_core::proxy_settings::ProxySettings) { for arg in proxy.jvm_args() { - let key = arg.split('=').next().unwrap_or(arg.as_str()); - if !args.iter().enumerate().any(|(i, a)| { - let value = - a.strip_prefix("--jvm-arg=").or_else(|| (i > 0 && args[i - 1] == "--jvm-arg").then_some(a.as_str())); - value.is_some_and(|a| a.split('=').next() == Some(key)) - }) { - args.push(format!("--jvm-arg={arg}")); - } + set_jvm_property(args, &arg); } } const METADATA_PROPERTY: &str = "java.import.generatesMetadataFilesAtProjectRoot"; +/// JDK 25's `ZipFile` validates the ZIP64 extra field, and jars written by older tools +/// carry a size that check rejects: ECJ reports the dependency as "cannot be read or is +/// not a valid ZIP file", the project using it gets a build path error, and every project +/// depending on that one stops compiling — aspectjweaver 1.8.10 stalls a whole Maven +/// build that way, while the same jar loads fine on IDEA's JBR 21. This is how 25 is told +/// to behave the same. +const ZIP64_LENIENCY: &str = "-Djdk.util.zip.disableZip64ExtraFieldValidation=true"; + /// jdtls: its Eclipse workspace (`-data`) lives under the IDE's data /// directory, one per server root, and no Eclipse metadata is written into /// the project; it runs on a JDK `min_java`+ (via `JAVA_HOME`, which its @@ -384,6 +406,10 @@ pub fn configure_jdtls( // of the `HTTP_PROXY` / `HTTPS_PROXY` it inherits: pass the same proxy as system // properties. One the user set in their own JVM arguments wins. add_jdtls_proxy_args(&mut cfg.args, &ide_core::proxy_settings::proxy()); + // ECJ reads the project's jars with the server JVM's own zip reader, so a jar JDK 25 + // refuses to open takes the whole Maven build with it. IDEA never notices: its JBR 21 + // accepts the same file. + set_jvm_property(&mut cfg.args, ZIP64_LENIENCY); let managed = language_server_runtimes_dir(data_dir); let managed = dunce::canonicalize(&managed).unwrap_or(managed); let project_jdks: Vec<_> = project_jdk @@ -402,6 +428,9 @@ pub fn configure_jdtls( if !cfg.env.contains_key("JAVA_HOME") { let runtime = match server_jdk { Some(jdk) if jdk.major < min_java => Err(server_jdk_too_old_message(jdk, min_java)), + Some(jdk) if !ide_core::java_runtime::supports_java_bundles(&jdk.home) => { + Err(server_jdk_incomplete_message(jdk, min_java)) + } Some(jdk) => Ok(jdk), None => jdtls_runtime(jdks, min_java).ok_or_else(|| missing_runtime_message(jdks, min_java)), }; @@ -411,6 +440,10 @@ pub fn configure_jdtls( } Err(e) => cfg.launch_error = Some(e), } + } else if let Some(jdk) = cfg.env.get("JAVA_HOME").and_then(|home| jdk_at(Path::new(home))) + && !ide_core::java_runtime::supports_java_bundles(&jdk.home) + { + cfg.launch_error = Some(server_jdk_incomplete_message(&jdk, min_java)); } let mut runtimes = runtimes(&project_jdks); if let Some(project) = project_jdk.or_else(|| pick_jdk(&project_jdks, project_java_version(server_root))) { @@ -554,6 +587,19 @@ fn parse_java_version(v: &str) -> Option { mod tests { use super::*; + fn test_jdk(root: &Path, major: u32, name: &str) -> Jdk { + let home = root.join(name.trim_start_matches('/')); + std::fs::create_dir_all(home.join("bin")).unwrap(); + std::fs::write( + home.join("release"), + format!("JAVA_VERSION=\"{major}\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n"), + ) + .unwrap(); + std::fs::write(java_executable(&home), "").unwrap(); + std::fs::write(home.join("bin").join(crate::exe("javac")), "").unwrap(); + Jdk { major, home } + } + #[test] fn unmanaged_source_models_have_separate_jdtls_workspaces() { let data = tempfile::tempdir().unwrap(); @@ -580,8 +626,13 @@ mod tests { let project_root = data.path().join("tools/jdks"); let project_home = project_root.join("temurin-21"); std::fs::create_dir_all(project_home.join("bin")).unwrap(); - std::fs::write(project_home.join("release"), "JAVA_VERSION=\"21.0.1\"\n").unwrap(); + std::fs::write( + project_home.join("release"), + "JAVA_VERSION=\"21.0.1\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n", + ) + .unwrap(); std::fs::write(java_executable(&project_home), "").unwrap(); + std::fs::write(project_home.join("bin").join(crate::exe("javac")), "").unwrap(); let project = jdk_at(&project_home).unwrap(); let discover = || jdk_homes(&project_root).filter_map(|home| jdk_at(&home)).collect::>(); let installed = discover(); @@ -589,8 +640,13 @@ mod tests { assert!(jdtls_runtime(&runtime_candidates(&installed, data.path()), 25).is_none()); let home = language_server_runtimes_dir(data.path()).join("temurin-25"); std::fs::create_dir_all(home.join("bin")).unwrap(); - std::fs::write(home.join("release"), "JAVA_VERSION=\"25.0.1\"\n").unwrap(); + std::fs::write( + home.join("release"), + "JAVA_VERSION=\"25.0.1\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n", + ) + .unwrap(); std::fs::write(java_executable(&home), "").unwrap(); + std::fs::write(home.join("bin").join(crate::exe("javac")), "").unwrap(); let candidates = runtime_candidates(&installed, data.path()); assert_eq!(jdtls_runtime(&candidates, 25).unwrap().major, 25); assert_eq!(jdtls_runtime(&candidates, 21), Some(&project)); @@ -609,11 +665,47 @@ mod tests { ); } + #[test] + fn jdtls_rejects_jre_for_auto_explicit_and_managed_runtimes() { + let tmp = tempfile::tempdir().unwrap(); + let jre = test_jdk(tmp.path(), 25, "jre"); + std::fs::write(jre.home.join("release"), "JAVA_VERSION=\"25\"\nMODULES=\"java.base java.compiler\"\n").unwrap(); + let jdk = test_jdk(tmp.path(), 25, "jdk"); + let installed = [jre.clone(), jdk.clone()]; + assert_eq!(jdtls_runtime(&installed, 25), Some(&jdk)); + assert!(jdtls_runtime(std::slice::from_ref(&jre), 25).is_none()); + let managed = language_server_runtimes_dir(tmp.path()).join("temurin-25"); + std::fs::create_dir_all(managed.parent().unwrap()).unwrap(); + std::fs::rename(&jre.home, &managed).unwrap(); + assert!(jdtls_runtime(&runtime_candidates(&[], tmp.path()), 25).is_none()); + let jre = jdk_at(&managed).unwrap(); + for env_override in [false, true] { + let mut cfg: LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap(); + if env_override { + cfg.env.insert("JAVA_HOME".into(), jre.home.to_string_lossy().into_owned()); + } + configure_jdtls(&mut cfg, tmp.path(), tmp.path(), &installed[1..], None, Some(&jre), 25, &[], None); + let err = cfg.launch_error.unwrap(); + assert!( + err.contains("full JDK with jdk.compiler, jdk.jdi and jdk.unsupported") + && err.contains("Download JDK 25") + && err.contains(SERVER_JDK_SETTING), + "{err}" + ); + } + std::fs::write( + managed.join("release"), + "JAVA_VERSION=\"25\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n", + ) + .unwrap(); + assert!(jdtls_runtime(&runtime_candidates(&[], tmp.path()), 25).is_some()); + } + #[test] fn implicit_compiler_default_matches_project_pick_with_duplicate_versions() { - let installed = - [Jdk { major: 21, home: PathBuf::from("/jdk21a") }, Jdk { major: 21, home: PathBuf::from("/jdk21b") }]; - let server = Jdk { major: 25, home: PathBuf::from("/custom-server-runtime") }; + let fixtures = tempfile::tempdir().unwrap(); + let installed = [test_jdk(fixtures.path(), 21, "/jdk21a"), test_jdk(fixtures.path(), 21, "/jdk21b")]; + let server = test_jdk(fixtures.path(), 25, "/custom-server-runtime"); let mut cfg: LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap(); configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &installed, None, Some(&server), 25, &[], None); let opts = serde_json::to_value(cfg.initialization_options.unwrap()).unwrap(); @@ -623,7 +715,7 @@ mod tests { "name": "JavaSE-21", "path": pick_jdk(&installed, None).unwrap().home, "default": true }]) ); - assert_eq!(cfg.env["JAVA_HOME"], "/custom-server-runtime"); + assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("custom-server-runtime").to_string_lossy()); } #[test] @@ -636,15 +728,16 @@ mod tests { #[test] fn jdtls_workspace_and_runtimes() { + let fixtures = tempfile::tempdir().unwrap(); if cfg!(windows) { return; // Unix paths and shells } let mut cfg: LanguageServerConfig = toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap(); let jdks = [ - Jdk { major: 25, home: PathBuf::from("/jdk25") }, - Jdk { major: 21, home: PathBuf::from("/jdk21a") }, - Jdk { major: 21, home: PathBuf::from("/jdk21b") }, - Jdk { major: 8, home: PathBuf::from("/jdk8") }, + test_jdk(fixtures.path(), 25, "/jdk25"), + test_jdk(fixtures.path(), 21, "/jdk21a"), + test_jdk(fixtures.path(), 21, "/jdk21b"), + test_jdk(fixtures.path(), 8, "/jdk8"), ]; let bundles = [PathBuf::from("/tools/java-debug/bin/com.microsoft.java.debug.plugin.jar")]; configure_jdtls( @@ -664,13 +757,22 @@ mod tests { assert_eq!(cfg.args[n], "-data"); assert!(cfg.args[n + 1].starts_with("/data/lsp/jdtls/"), "{}", cfg.args[n + 1]); assert_eq!(cfg.args[n + 2], "--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=false"); + assert!( + cfg.args.iter().any(|a| a == "--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation=true"), + "jars JDK 25 rejects must still load: {:?}", + cfg.args + ); let opts = serde_json::to_value(cfg.initialization_options.clone().unwrap()).unwrap(); let rt = &opts["settings"]["java"]["configuration"]["runtimes"]; - assert_eq!(rt[0], json!({ "name": "JavaSE-1.8", "path": "/jdk8" })); - assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": "/jdk21a" })); - assert_eq!(rt[2], json!({ "name": "JavaSE-25", "path": "/jdk25", "default": true })); + assert_eq!(rt[0], json!({ "name": "JavaSE-1.8", "path": fixtures.path().join("jdk8") })); + assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": fixtures.path().join("jdk21a") })); + assert_eq!(rt[2], json!({ "name": "JavaSE-25", "path": fixtures.path().join("jdk25"), "default": true })); assert_eq!(opts["bundles"], json!(["/tools/java-debug/bin/com.microsoft.java.debug.plugin.jar"])); - assert_eq!(cfg.env["JAVA_HOME"], "/jdk25", "first JDK new enough to run jdtls"); + assert_eq!( + cfg.env["JAVA_HOME"], + fixtures.path().join("jdk25").to_string_lossy(), + "first JDK new enough to run jdtls" + ); assert_eq!(cfg.launch_error, None); // Deterministic (reconfigure compares configs) and idempotent. @@ -689,7 +791,7 @@ mod tests { assert_eq!(cfg, before); // The project JDK is the default runtime, and runs the server when new enough. - let project = Jdk { major: 21, home: PathBuf::from("/jdk21b") }; + let project = test_jdk(fixtures.path(), 21, "/jdk21b"); let mut cfg: LanguageServerConfig = toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap(); configure_jdtls( &mut cfg, @@ -704,8 +806,8 @@ mod tests { ); let opts = serde_json::to_value(cfg.initialization_options.clone().unwrap()).unwrap(); let rt = &opts["settings"]["java"]["configuration"]["runtimes"]; - assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": "/jdk21b", "default": true })); - assert_eq!(cfg.env["JAVA_HOME"], "/jdk21b"); + assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": fixtures.path().join("jdk21b"), "default": true })); + assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("jdk21b").to_string_lossy()); let mut other: LanguageServerConfig = toml::from_str("name = \"pyright\"\ncommand = \"x\"").unwrap(); configure_jdtls( @@ -751,6 +853,25 @@ mod tests { } } + #[test] + fn a_jar_jdk_25_rejects_loads_and_the_users_value_wins() { + // aspectjweaver 1.8.10 carries a ZIP64 extra field size 25 refuses to read; on the + // JVM that runs jdtls that is a build path error in every project using it. + let mut args = Vec::new(); + set_jvm_property(&mut args, ZIP64_LENIENCY); + assert_eq!(args, ["--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation=true"]); + set_jvm_property(&mut args, ZIP64_LENIENCY); + assert_eq!(args.len(), 1, "added once"); + for user in [ + "--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation=false".to_owned(), + "--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation".to_owned(), + ] { + let mut own = vec![user.clone()]; + set_jvm_property(&mut own, ZIP64_LENIENCY); + assert_eq!(own, [user], "the user decides"); + } + } + #[test] fn jdtls_lombok_agent() { let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() }; @@ -793,8 +914,9 @@ mod tests { #[test] fn jdtls_needs_a_new_enough_jdk_to_run() { + let fixtures = tempfile::tempdir().unwrap(); let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() }; - let old = [Jdk { major: 17, home: PathBuf::from("/jdk17") }, Jdk { major: 8, home: PathBuf::from("/jdk8") }]; + let old = [test_jdk(fixtures.path(), 17, "/jdk17"), test_jdk(fixtures.path(), 8, "/jdk8")]; let mut cfg = jdtls(); configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, None, None, JDTLS_MIN_JAVA, &[], None); assert!(!cfg.env.contains_key("JAVA_HOME")); @@ -813,7 +935,7 @@ mod tests { configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, None, None, JDTLS_MIN_JAVA, &[], None); assert_eq!((cfg.env["JAVA_HOME"].as_str(), cfg.launch_error), ("/custom", None)); - let mixed = [old[0].clone(), Jdk { major: 21, home: PathBuf::from("/jdk21") }]; + let mixed = [old[0].clone(), test_jdk(fixtures.path(), 21, "/jdk21")]; assert_eq!(jdtls_runtime(&mixed, 21).unwrap().major, 21); // A jdtls whose bundles need 25 doesn't start on 21, and says what it needs. @@ -826,11 +948,12 @@ mod tests { #[test] fn issue_49_jdtls_needs_25_with_8_17_21_installed() { + let fixtures = tempfile::tempdir().unwrap(); let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() }; let installed = [ - Jdk { major: 21, home: PathBuf::from("/jdk21") }, - Jdk { major: 17, home: PathBuf::from("/jdk17") }, - Jdk { major: 8, home: PathBuf::from("/jdk8") }, + test_jdk(fixtures.path(), 21, "/jdk21"), + test_jdk(fixtures.path(), 17, "/jdk17"), + test_jdk(fixtures.path(), 8, "/jdk8"), ]; let mut cfg = jdtls(); configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &installed, None, None, 25, &[], None); @@ -839,12 +962,11 @@ mod tests { assert!(err.contains("Download JDK 25"), "{err}"); // Once a JDK 25 has been downloaded, Auto runs jdtls on it. - let downloaded = - [installed.as_slice(), &[Jdk { major: 25, home: PathBuf::from("/tools/jdks/temurin-25") }]].concat(); + let downloaded = [installed.as_slice(), &[test_jdk(fixtures.path(), 25, "/tools/jdks/temurin-25")]].concat(); let mut cfg = jdtls(); configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &downloaded, None, None, 25, &[], None); assert_eq!(cfg.launch_error, None); - assert_eq!(cfg.env["JAVA_HOME"], Path::new("/tools/jdks/temurin-25").to_string_lossy()); + assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("tools/jdks/temurin-25").to_string_lossy()); } #[test] @@ -881,8 +1003,9 @@ mod tests { #[test] fn jdtls_runs_on_the_chosen_server_jdk() { + let fixtures = tempfile::tempdir().unwrap(); let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() }; - let jdk = |major: u32, home: &str| Jdk { major, home: PathBuf::from(home) }; + let jdk = |major: u32, home: &str| test_jdk(fixtures.path(), major, home); let installed = [jdk(25, "/jdk25"), jdk(21, "/jdk21"), jdk(17, "/jdk17")]; let project = jdk(17, "/jdk17"); let run = |server: Option<&Jdk>, min_java: u32| { @@ -903,24 +1026,35 @@ mod tests { // Project on JDK 17, server on the chosen 21: each keeps its own JDK. let cfg = run(Some(&jdk(21, "/jdk21")), JDTLS_MIN_JAVA); - assert_eq!((cfg.env["JAVA_HOME"].as_str(), cfg.launch_error), ("/jdk21", None)); + assert_eq!( + (cfg.env["JAVA_HOME"].clone(), cfg.launch_error), + (fixtures.path().join("jdk21").to_string_lossy().into_owned(), None) + ); let opts = serde_json::to_value(cfg.initialization_options.unwrap()).unwrap(); let rt = &opts["settings"]["java"]["configuration"]["runtimes"]; - assert_eq!(rt[0], json!({ "name": "JavaSE-17", "path": "/jdk17", "default": true }), "still compiles on 17"); + assert_eq!( + rt[0], + json!({ "name": "JavaSE-17", "path": fixtures.path().join("jdk17"), "default": true }), + "still compiles on 17" + ); // Not set: the first installed JDK new enough, as before. - assert_eq!(run(None, JDTLS_MIN_JAVA).env["JAVA_HOME"], "/jdk25"); + assert_eq!(run(None, JDTLS_MIN_JAVA).env["JAVA_HOME"], fixtures.path().join("jdk25").to_string_lossy()); // A chosen JDK outside the scanned locations. let own = jdk(25, "/opt/own-jdk"); let cfg = run(Some(&own), 25); - assert_eq!(cfg.env["JAVA_HOME"], "/opt/own-jdk"); + assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("opt/own-jdk").to_string_lossy()); // Too old for this jdtls: the user's choice is not overridden, it is explained. - let cfg = run(Some(&jdk(21, "/jdk21")), 25); + let old = jdk(21, "/jdk21"); + let cfg = run(Some(&old), 25); assert!(!cfg.env.contains_key("JAVA_HOME")); let err = cfg.launch_error.unwrap(); - assert!(err.contains("/jdk21") && err.contains("JDK 25 or newer"), "{err}"); + // The message embeds jdk.home as written to disk, so match that instead of the + // "/jdk21" literal: its separators are platform-native since test_jdk + // materializes real directories. + assert!(err.contains(old.home.to_string_lossy().as_ref()) && err.contains("JDK 25 or newer"), "{err}"); assert!(err.contains(SERVER_JDK_SETTING), "both messages point at the same setting: {err}"); } diff --git a/crates/ide-sdk/src/jdtls.rs b/crates/ide-sdk/src/jdtls.rs index f9ee72399..75520eed7 100644 --- a/crates/ide-sdk/src/jdtls.rs +++ b/crates/ide-sdk/src/jdtls.rs @@ -249,6 +249,10 @@ pub fn launch_directly(cfg: &mut LanguageServerConfig) { }), }; let Some((java, jdk)) = jdk.filter(|(java, _)| java.is_file()) else { return }; + if !ide_core::java_runtime::supports_java_bundles(&jdk.home) { + cfg.launch_error = Some(crate::java::server_jdk_incomplete_message(&jdk, crate::java::jdtls_min_java(cfg))); + return; + } cfg.args = java_args(&install, jdk.major, &cfg.args); cfg.command = java.to_string_lossy().into_owned(); } @@ -465,7 +469,12 @@ mod tests { std::fs::set_permissions(&script, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap(); let jdk = dir.path().join("jdk-25"); std::fs::create_dir_all(jdk.join("bin")).unwrap(); - std::fs::write(jdk.join("release"), "JAVA_VERSION=\"25.0.1\"\n").unwrap(); + std::fs::write( + jdk.join("release"), + "JAVA_VERSION=\"25.0.1\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n", + ) + .unwrap(); + std::fs::write(jdk.join("bin/javac"), "").unwrap(); std::fs::write(jdk.join("bin/java"), "").unwrap(); let cfg = |command: &str| -> LanguageServerConfig { @@ -486,6 +495,15 @@ mod tests { assert!(direct.args[jar + 1].ends_with("org.eclipse.equinox.launcher_1.7.0.jar")); assert_eq!(direct.args[jar + 2..], strings(&["-data", "/ws"])); + let jre = dir.path().join("jre-25"); + std::fs::create_dir_all(jre.join("bin")).unwrap(); + std::fs::write(jre.join("bin/java"), "").unwrap(); + std::fs::write(jre.join("release"), "JAVA_VERSION=\"25\"\nIMAGE_TYPE=\"JRE\"\n").unwrap(); + let mut overridden = cfg("jdtls"); + overridden.args.push(format!("--java-executable={}", jre.join("bin/java").display())); + launch_directly(&mut overridden); + assert!(overridden.launch_error.unwrap().contains("jdk.jdi")); + // Someone's own wrapper, or no usable JDK: the command is kept. std::fs::write(home.join("bin/my-jdtls"), "#!/bin/sh\n").unwrap(); std::fs::set_permissions(home.join("bin/my-jdtls"), std::os::unix::fs::PermissionsExt::from_mode(0o755)) diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 4abcd6373..dd4628b2e 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -166,7 +166,12 @@ fn run_configs_use_selected_interpreter_and_package_manager() { fn fake_jdk(dir: &Path) { std::fs::create_dir_all(dir.join("bin")).unwrap(); std::fs::write(dir.join("bin").join(exe("java")), "").unwrap(); - std::fs::write(dir.join("release"), "JAVA_VERSION=\"17.0.9\"\n").unwrap(); + std::fs::write( + dir.join("release"), + "JAVA_VERSION=\"17.0.9\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n", + ) + .unwrap(); + std::fs::write(dir.join("bin").join(crate::exe("javac")), "").unwrap(); } #[test] @@ -307,6 +312,12 @@ fn jdtls_jdk_setting() { assert_eq!(jdtls_jdk(tmp.path(), &s), Ok(None)); s.languages.java.jdtls_jdk = "jdk17/bin".into(); assert_eq!(jdtls_jdk(tmp.path(), &s).unwrap().map(|j| j.major), Some(17)); + std::fs::write( + tmp.path().join("jdk17/release"), + "JAVA_VERSION=\"25\"\nIMAGE_TYPE=\"JRE\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n", + ) + .unwrap(); + assert!(jdtls_jdk(tmp.path(), &s).unwrap_err().contains("full JDK")); // A chosen path that is not a JDK is an error, not a silent fallback. std::fs::create_dir(tmp.path().join("not-a-jdk")).unwrap(); s.languages.java.jdtls_jdk = "not-a-jdk".into(); diff --git a/crates/ide-terminal/src/lib.rs b/crates/ide-terminal/src/lib.rs index 82df70cde..21ae833cf 100644 --- a/crates/ide-terminal/src/lib.rs +++ b/crates/ide-terminal/src/lib.rs @@ -53,6 +53,8 @@ pub struct TerminalInfo { } struct Session { + /// The window that opened it: closing that window's project closes its terminals. + key: String, info: TerminalInfo, master: Mutex>, writer: Mutex>, @@ -83,6 +85,7 @@ impl TerminalManager { pub fn spawn( &self, options: TerminalOptions, + key: String, sink: SharedSink, on_output: Box, ) -> IdeResult { @@ -121,6 +124,7 @@ impl TerminalManager { let info = TerminalInfo { id, title: shell_title(&shell), pid: child.process_id() }; let exited = Arc::new(AtomicBool::new(false)); let session = Arc::new(Session { + key, info: info.clone(), master: Mutex::new(pair.master), writer: Mutex::new(writer), @@ -160,6 +164,15 @@ impl TerminalManager { .map_err(|e| IdeError::internal(format!("resize failed: {e}"))) } + /// Closes the terminals `key` opened: the project of that window is closing. + pub fn kill_key(&self, key: &str) { + let ids: Vec = + self.sessions.lock().iter().filter(|(_, s)| s.key == key).map(|(id, _)| *id).collect(); + for id in ids { + let _ = self.kill(id); + } + } + pub fn kill(&self, id: TerminalId) -> IdeResult<()> { let session = self.sessions.lock().remove(&id).ok_or_else(|| not_found(id))?; terminate(&session); @@ -636,6 +649,7 @@ mod tests { .manager .spawn( opts, + "test".into(), h.sink.clone(), Box::new(move |s| { let _ = tx.send(s); @@ -793,7 +807,7 @@ mod tests { let h = harness(); let (other_sink, other_events) = channel_sink(); let (mine, _rx) = spawn_collect(&h, sh("exit 1")); - let theirs = h.manager.spawn(sh("exit 2"), other_sink, Box::new(|_| {})).expect("spawn"); + let theirs = h.manager.spawn(sh("exit 2"), "other".into(), other_sink, Box::new(|_| {})).expect("spawn"); match h.events.recv_timeout(Duration::from_secs(10)).expect("exit event") { IdeEvent::TerminalExited { terminal, code } => assert_eq!((terminal, code), (mine.id, Some(1))), e => panic!("unexpected {e:?}"), diff --git a/crates/ide-tools/src/formatter.rs b/crates/ide-tools/src/formatter.rs index 9b599f0bb..b6a7a692b 100644 --- a/crates/ide-tools/src/formatter.rs +++ b/crates/ide-tools/src/formatter.rs @@ -13,20 +13,30 @@ pub fn find_up(file: &Path, relative: &Path) -> Option { file.parent()?.ancestors().map(|dir| dir.join(relative)).find(|p| p.is_file()) } -fn package_bin(package: &Path) -> IdeResult { +pub fn display_name(tool: &str) -> &str { + crate::registry::find(tool).map_or(tool, |t| t.display) +} + +fn package_bin(package: &Path, tool: &str) -> IdeResult { + let name = display_name(tool); let json: serde_json::Value = serde_json::from_slice(&std::fs::read(package.join("package.json"))?) - .map_err(|e| IdeError::invalid(format!("Prettier: {e}")))?; + .map_err(|e| IdeError::invalid(format!("{name}: {e}")))?; let bin = json["bin"] .as_str() - .or_else(|| json["bin"]["prettier"].as_str()) - .ok_or_else(|| IdeError::invalid("Prettier: package has no CLI entry point"))?; + .or_else(|| json["bin"][tool].as_str()) + .ok_or_else(|| IdeError::invalid(format!("{name}: package has no CLI entry point")))?; let path = package.join(bin); if !path.is_file() { - return Err(IdeError::unavailable(format!("Prettier: CLI does not exist: {}", path.display()))); + return Err(IdeError::unavailable(format!("{name}: CLI does not exist: {}", path.display()))); } Ok(path) } +/// Prettier formats a range for some languages (`config.range`); oxfmt formats whole files only. +pub fn supports_range(tool: &str, config: &FormatterConfig) -> bool { + tool == "prettier" && config.range +} + impl Tools { /// Explicit executable/script, then the nearest project package, then managed tools and PATH. pub fn formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult { @@ -34,22 +44,26 @@ impl Tools { } fn resolve_formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult { - if tool != "prettier" { + if crate::registry::find(tool).is_none_or(|t| t.kind != crate::ToolKind::Formatter) { return Err(IdeError::unavailable(format!("Unknown external formatter: {tool}"))); } + let package = Path::new("node_modules").join(tool); + let manifest = package.join("package.json"); let (path, node) = if let Some(custom) = custom.filter(|p| p.is_dir()) { - (package_bin(custom)?, true) + (package_bin(custom, tool)?, true) } else if let Some(custom) = custom { (custom.to_path_buf(), matches!(custom.extension().and_then(|s| s.to_str()), Some("js" | "cjs" | "mjs"))) - } else if let Some(manifest) = find_up(file, Path::new("node_modules/prettier/package.json")) { - (package_bin(manifest.parent().unwrap())?, true) - } else if self.root.join("node_modules/prettier/package.json").is_file() { - (package_bin(&self.root.join("node_modules/prettier"))?, true) + } else if let Some(manifest) = find_up(file, &manifest) { + (package_bin(manifest.parent().unwrap(), tool)?, true) + } else if self.root.join(&manifest).is_file() { + (package_bin(&self.root.join(&package), tool)?, true) } else { - (self.which("prettier").ok_or_else(|| IdeError::missing_tool("prettier"))?, false) + (self.which(tool).ok_or_else(|| IdeError::missing_tool(tool))?, false) }; if node { - let node = self.which("node").ok_or_else(|| IdeError::unavailable("Prettier: Node.js is required"))?; + let name = display_name(tool); + let node = + self.which("node").ok_or_else(|| IdeError::unavailable(format!("{name}: Node.js is required")))?; let mut cmd = self.command(node); cmd.arg(path); Ok(cmd) @@ -59,34 +73,40 @@ impl Tools { } } -/// Project configuration overrides these defaults; Prettier resolves config and plugins itself. +/// Both tools resolve their configuration and plugins themselves. Prettier's +/// project configuration overrides the editor indentation passed here; oxfmt has +/// no such flags and reads its configuration and `.editorconfig` only. pub fn arguments( + tool: &str, config: &FormatterConfig, file: &Path, tab_size: u32, insert_spaces: bool, range: Option<(u32, u32)>, ) -> Vec { - let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") { - "mdx" - } else { - &config.parser - }; - let mut args: Vec = [ - "--parser", - parser, - "--config-precedence", - "file-override", - "--tab-width", - &tab_size.max(1).to_string(), - "--use-tabs", - if insert_spaces { "false" } else { "true" }, - ] - .into_iter() - .map(Into::into) - .collect(); + let mut args: Vec = Vec::new(); + if tool == "prettier" { + let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") { + "mdx" + } else { + &config.parser + }; + args.extend( + [ + "--parser", + parser, + "--config-precedence", + "file-override", + "--tab-width", + &tab_size.max(1).to_string(), + "--use-tabs", + if insert_spaces { "false" } else { "true" }, + ] + .map(OsString::from), + ); + } args.extend(["--stdin-filepath".into(), file.as_os_str().to_owned()]); - if config.range + if supports_range(tool, config) && let Some((start, end)) = range { args.extend(["--range-start".into(), start.to_string().into(), "--range-end".into(), end.to_string().into()]); @@ -100,8 +120,8 @@ pub fn arguments( args } -pub fn run(mut command: Command, text: &str, cancel: &CancellationToken) -> IdeResult { - crate::process::run_input(&mut command, text, Duration::from_secs(15), cancel).map_err(|e| e.with_tool("prettier")) +pub fn run(tool: &str, mut command: Command, text: &str, cancel: &CancellationToken) -> IdeResult { + crate::process::run_input(&mut command, text, Duration::from_secs(15), cancel).map_err(|e| e.with_tool(tool)) } #[cfg(test)] @@ -118,7 +138,7 @@ mod tests { assert_eq!(missing.kind, ErrorKind::NotFound); assert_eq!(missing.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: true })); let custom = tools.formatter_command("prettier", &file, Some(&dir.path().join("absent-prettier"))).unwrap(); - let failure = run(custom, "text", &Default::default()).unwrap_err(); + let failure = run("prettier", custom, "text", &Default::default()).unwrap_err(); assert_eq!(failure.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: false })); } @@ -127,7 +147,7 @@ mod tests { fn stderr_is_preserved_verbatim_even_when_longer_than_four_thousand_characters() { let mut command = Command::new("sh"); command.args(["-c", "i=0; while [ $i -lt 1000 ]; do printf 'error\\n' >&2; i=$((i+1)); done; exit 1"]); - let failure = run(command, "", &Default::default()).unwrap_err(); + let failure = run("prettier", command, "", &Default::default()).unwrap_err(); assert_eq!(failure.message, "error\n".repeat(1000)); assert_eq!(failure.data, Some(ide_core::error::ErrorData::Tool { tool: "prettier".into(), missing: false })); } @@ -161,7 +181,8 @@ mod tests { ("vue", true), ] { let config = FormatterConfig { tool: "prettier".into(), parser: parser.into(), range: supports_range }; - let args = arguments(&config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9))); + let args = + arguments("prettier", &config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9))); let args: Vec<_> = args.iter().map(|a| a.to_string_lossy()).collect(); assert!(args.windows(2).any(|a| a == ["--config-precedence", "file-override"])); assert!(args.windows(2).any(|a| a == ["--stdin-filepath", "/project with spaces/file.md"])); @@ -171,4 +192,41 @@ mod tests { assert!(!args.contains(&"--write".into())); } } + + #[test] + fn oxfmt_runs_the_nearest_package_without_prettier_flags() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("node_modules/oxfmt/bin")).unwrap(); + std::fs::write(root.join("node_modules/oxfmt/package.json"), r#"{"bin":{"oxfmt":"bin/oxfmt"}}"#).unwrap(); + std::fs::write(root.join("node_modules/oxfmt/bin/oxfmt"), "").unwrap(); + std::fs::write(root.join(".prettierignore"), "dist").unwrap(); + let file = root.join("packages/web/src/app.ts"); + let path = std::env::var_os("PATH").unwrap_or_default(); + let tools = Tools::with_paths(root.join("tools"), path, crate::platform::Platform::current()); + + let command = tools.formatter_command("oxfmt", &file, None).unwrap(); + let program = Path::new(command.get_program()).file_stem().unwrap().to_string_lossy().into_owned(); + assert_eq!(program, "node"); + let script: Vec<_> = command.get_args().collect(); + // Compare components: `package_bin` joins the manifest's `bin` entry + // (`bin/oxfmt`) onto a platform path, so separators differ on Windows. + assert_eq!( + Path::new(&script[0]).components().collect::>(), + root.join("node_modules/oxfmt/bin/oxfmt").components().collect::>() + ); + + let config = FormatterConfig { tool: "prettier".into(), parser: "typescript".into(), range: true }; + let args = arguments("oxfmt", &config, &file, 4, false, Some((3, 9))); + let ignore = root.join(".prettierignore").into_os_string(); + assert_eq!( + args[..4], + ["--stdin-filepath".into(), file.clone().into_os_string(), "--ignore-path".into(), ignore] + ); + assert!(args[4..].chunks(2).all(|pair| pair[0] == "--ignore-path"), "{args:?}"); + assert!(!supports_range("oxfmt", &config)); + + let unknown = tools.formatter_command("rust-analyzer", &file, None).unwrap_err(); + assert!(unknown.message.contains("Unknown external formatter"), "{}", unknown.message); + } } diff --git a/crates/ide-tools/src/jdk.rs b/crates/ide-tools/src/jdk.rs index a96ff2924..b8264ec7f 100644 --- a/crates/ide-tools/src/jdk.rs +++ b/crates/ide-tools/src/jdk.rs @@ -108,26 +108,7 @@ fn adoptium_arch(arch: Arch) -> &'static str { /// The latest Temurin JDK build of Java `feature` for `platform`. pub fn adoptium_assets_url(feature: u32, platform: Platform) -> String { - assets_url(feature, platform, ImageType::Jdk) -} - -#[derive(Clone, Copy)] -enum ImageType { - Jdk, - Jre, -} - -impl ImageType { - fn name(self) -> &'static str { - match self { - Self::Jdk => "jdk", - Self::Jre => "jre", - } - } -} - -fn assets_url(feature: u32, platform: Platform, image: ImageType) -> String { - let image = image.name(); + let image = "jdk"; format!( "{ADOPTIUM_API}/assets/latest/{feature}/hotspot?architecture={}&image_type={image}&os={}&vendor=eclipse", adoptium_arch(platform.arch), @@ -190,16 +171,12 @@ impl AssetVersion { /// The JDK archive (`.tar.gz` on macOS / Linux, `.zip` on Windows) in an Adoptium /// `assets/latest` response; `None` when there is no build for the platform. pub fn adoptium_pick(json: &str, platform: Platform) -> IdeResult> { - pick_image(json, platform, ImageType::Jdk) -} - -fn pick_image(json: &str, platform: Platform, image: ImageType) -> IdeResult> { let assets: Vec = serde_json::from_str(json).map_err(|e| proto(format!("unexpected Adoptium response: {e}")))?; let ext = if platform.os == Os::Windows { ".zip" } else { ".tar.gz" }; let os = adoptium_os(platform.os); let found = assets.into_iter().find(|a| { - a.binary.image_type.as_deref().is_none_or(|t| t == image.name()) + a.binary.image_type.as_deref().is_none_or(|t| t == "jdk") && a.binary.heap_size.as_deref().is_none_or(|h| h == "normal") && a.binary.os.as_deref().is_none_or(|o| o == os) && a.binary.package.name.ends_with(ext) @@ -249,6 +226,10 @@ pub fn find_jdk_home(dir: &Path, platform: Platform) -> Option { subdirs.iter().flat_map(|d| candidates(d)).find(|h| is_jdk_home(h, platform)) } +fn usable_java_home(dir: &Path, platform: Platform, runtime: bool) -> Option { + find_jdk_home(dir, platform).filter(|home| !runtime || ide_core::java_runtime::supports_java_bundles(home)) +} + /// The directory to move into place: the archive's single top-level folder /// holding the JDK, else everything that was unpacked. fn unpacked_root(pkg: &Path, home: &Path) -> PathBuf { @@ -283,8 +264,8 @@ impl Tools { self.download_java(vendor, feature, location, false, cancel, emit) } - /// Downloads a runtime for a language server, preferring a JRE. Only a - /// platform without a JRE build falls back to a full JDK. + /// Downloads a full JDK for a language server and its bundles (java-debug + /// needs `jdk.jdi`). Kept outside project JDK discovery roots. pub fn download_java_runtime( &self, feature: u32, @@ -310,19 +291,8 @@ impl Tools { let http = self.http()?; let location = location.map(Path::to_path_buf).unwrap_or_else(|| self.root.join("jdks")); emit(InstallEvent::Line { text: format!("Resolving the latest Eclipse Temurin {feature}…") }); - let mut picked = if runtime { - pick_image( - &http.get_text_or_empty_on_404(&assets_url(feature, self.platform, ImageType::Jre), cancel)?, - self.platform, - ImageType::Jre, - )? - } else { - None - }; - if picked.is_none() { - picked = - adoptium_pick(&http.get_text(&adoptium_assets_url(feature, self.platform), cancel)?, self.platform)?; - } + let mut picked = + adoptium_pick(&http.get_text(&adoptium_assets_url(feature, self.platform), cancel)?, self.platform)?; if picked.is_none() && matches!(self.platform.os, Os::Mac | Os::Windows) && self.platform.arch == Arch::Arm64 { // Older JDKs may need the OS-provided x64 emulation. let intel = Platform { os: self.platform.os, arch: Arch::X64 }; @@ -336,7 +306,7 @@ impl Tools { })?; let target = location.join(jdk_dir_name(vendor, &dl.version)); let done = |home: PathBuf| DownloadedJdk { home: home.into(), version: dl.version.clone(), major }; - if let Some(home) = find_jdk_home(&target, self.platform) { + if let Some(home) = usable_java_home(&target, self.platform, runtime) { emit(InstallEvent::Line { text: format!("{} is already installed", target.display()) }); return Ok(done(home)); } @@ -351,7 +321,7 @@ impl Tools { emit(InstallEvent::Line { text: "Extracting…".into() }); let pkg = staging.join("pkg"); archive::extract(&archive, archive::ArchiveKind::from_name(&dl.name), &pkg, "java", cancel)?; - let home = find_jdk_home(&pkg, self.platform) + let home = usable_java_home(&pkg, self.platform, runtime) .ok_or_else(|| IdeError::new(ErrorKind::Io, format!("no JDK found in {}", dl.name)))?; // Last point to stop: from here the JDK replaces what is at `target`. if cancel.is_cancelled() { @@ -362,7 +332,7 @@ impl Tools { } std::fs::rename(unpacked_root(&pkg, &home), &target)?; clear_quarantine(&target); - find_jdk_home(&target, self.platform) + usable_java_home(&target, self.platform, runtime) .ok_or_else(|| IdeError::new(ErrorKind::Io, format!("no JDK found in {}", target.display()))) })(); let _ = std::fs::remove_dir_all(&staging); @@ -437,18 +407,56 @@ mod tests { } #[test] - fn runtime_assets_use_jre_without_changing_jdk_downloads() { + fn runtime_assets_require_jdk_images() { for platform in [MAC_ARM, LINUX_X64, WIN_X64] { - assert!(assets_url(25, platform, ImageType::Jre).contains("image_type=jre")); assert!(adoptium_assets_url(25, platform).contains("image_type=jdk")); let ext = if platform.os == Os::Windows { "zip" } else { "tar.gz" }; let jdk = asset(&format!("java.{ext}"), adoptium_os(platform.os), "normal", ""); let jre = jdk.replace("\"image_type\":\"jdk\"", "\"image_type\":\"jre\""); - assert!(pick_image(&format!("[{jre}]"), platform, ImageType::Jre).unwrap().is_some()); + assert!(adoptium_pick(&format!("[{jre},{jdk}]"), platform).unwrap().is_some()); assert!(adoptium_pick(&format!("[{jre}]"), platform).unwrap().is_none()); - assert!(pick_image(&format!("[{jdk}]"), platform, ImageType::Jre).unwrap().is_none()); - assert!(pick_image("[]", platform, ImageType::Jre).unwrap().is_none()); - assert!(pick_image("offline", platform, ImageType::Jre).is_err()); + } + } + + #[test] + fn stale_runtime_is_replaced_even_at_the_same_version() { + for platform in [MAC_ARM, LINUX_X64, WIN_X64] { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join(jdk_dir_name(TEMURIN, "25.0.1")); + let home = if platform.os == Os::Mac { target.join("Contents/Home") } else { target.clone() }; + fake_jdk(&home, platform); + let javac = home.join("bin").join(if cfg!(windows) { "javac.exe" } else { "javac" }); + std::fs::write(&javac, "").unwrap(); + for modules in [None, Some("java.base java.compiler jdk.jdix"), Some("java.base jdk.jdi.agent")] { + let line = modules.map(|m| format!("MODULES=\"{m}\"\n")).unwrap_or_default(); + std::fs::write(home.join("release"), format!("JAVA_VERSION=\"25.0.1\"\n{line}")).unwrap(); + assert!(usable_java_home(&target, platform, true).is_none(), "{modules:?}"); + assert_eq!(usable_java_home(&target, platform, false), Some(home.clone())); + } + std::fs::write( + home.join("release"), + "JAVA_VERSION=\"25.0.1\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n", + ) + .unwrap(); + assert_eq!(usable_java_home(&target, platform, true), Some(home.clone())); + std::fs::write(home.join("release"), "JAVA_VERSION=\"25.0.1\"\n").unwrap(); + std::fs::create_dir_all(home.join("jmods")).unwrap(); + for module in ["jdk.compiler", "jdk.jdi", "jdk.unsupported"] { + std::fs::write(home.join("jmods").join(format!("{module}.jmod")), "").unwrap(); + } + assert_eq!(usable_java_home(&target, platform, true), Some(home.clone())); + std::fs::write( + home.join("release"), + "IMAGE_TYPE=\"JRE\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n", + ) + .unwrap(); + assert!(usable_java_home(&target, platform, true).is_none(), "Temurin JRE release can list absent modules"); + std::fs::write(home.join("release"), "MODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n") + .unwrap(); + std::fs::remove_file(javac).unwrap(); + assert!(usable_java_home(&target, platform, true).is_none(), "custom runtime without compiler"); + std::fs::write(home.join("release"), "MODULES=\"java.base\"\n").unwrap(); + assert!(usable_java_home(&target, platform, true).is_none(), "release describes the runtime image"); } } diff --git a/crates/ide-tools/src/registry.rs b/crates/ide-tools/src/registry.rs index 6dfcd5df7..661a71705 100644 --- a/crates/ide-tools/src/registry.rs +++ b/crates/ide-tools/src/registry.rs @@ -134,6 +134,28 @@ pub static TOOLS: &[ToolSpec] = &[ needs: NODE, homepage: "https://prettier.io", }, + ToolSpec { + name: "oxfmt", + display: "oxfmt", + kind: Formatter, + commands: &["oxfmt"], + method: Method::Npm { packages: &["oxfmt"] }, + fallback: None, + version: ver("oxfmt"), + needs: NODE, + homepage: "https://oxc.rs/docs/guide/usage/formatter", + }, + ToolSpec { + name: "oxlint", + display: "oxlint", + kind: LanguageServer, + commands: &["oxlint"], + method: Method::Npm { packages: &["oxlint"] }, + fallback: None, + version: ver("oxlint"), + needs: NODE, + homepage: "https://oxc.rs/docs/guide/usage/linter", + }, ToolSpec { name: "rust-analyzer", display: "rust-analyzer", @@ -965,12 +987,8 @@ mod tests { known.insert((d.adapter.clone(), d.command.clone())); } } - for l in &langs { - if let Some(f) = &l.formatter { - known.insert((f.tool.clone(), f.tool.clone())); - } - } - for t in TOOLS.iter().filter(|t| t.kind != Plugin) { + // The host selects the formatter of each file (Prettier or oxfmt). + for t in TOOLS.iter().filter(|t| !matches!(t.kind, Plugin | Formatter)) { assert!( known.contains(&(t.name.to_string(), t.commands[0].to_string())), "{} / {} is not referenced by languages/*.toml", diff --git a/crates/ide-tools/tests/prettier.rs b/crates/ide-tools/tests/prettier.rs index 3d622caac..c09f6d59f 100644 --- a/crates/ide-tools/tests/prettier.rs +++ b/crates/ide-tools/tests/prettier.rs @@ -19,8 +19,8 @@ fn format( }; let mut cmd = tools.formatter_command("prettier", path, None)?; cmd.current_dir(path.parent().unwrap()); - cmd.args(formatter::arguments(&config, path, 4, true, range)); - formatter::run(cmd, text, &CancellationToken::new()) + cmd.args(formatter::arguments("prettier", &config, path, 4, true, range)); + formatter::run("prettier", cmd, text, &CancellationToken::new()) } #[test] diff --git a/crates/ide-vcs/src/write.rs b/crates/ide-vcs/src/write.rs index e31701bea..4e4f92832 100644 --- a/crates/ide-vcs/src/write.rs +++ b/crates/ide-vcs/src/write.rs @@ -89,15 +89,28 @@ pub(crate) fn blame( return Ok(uncommitted_lines(line_count)); } // Restricted blame reads committed blobs only. Feeding working-tree bytes to - // Git's --contents path would run configured clean filters. - let out = if crate::execution_allowed() { - run( - r.root(), - &["--literal-pathspecs", "blame", "--porcelain", "--contents", "-", revision, "--", &rel], - Some(&contents), - )? + // Git's --contents path would run configured clean filters, so it blames the + // revision plain. Git learned to combine `--contents` with a revision in 2.41; + // older releases reject the pair (`fatal: cannot use --contents with final + // commit object name`) and take the same plain path — the edited lines are + // marked uncommitted below either way (issue #755). + let contents_args = ["--literal-pathspecs", "blame", "--porcelain", "--contents", "-", revision, "--", &rel]; + let plain_args: &[&str] = if crate::execution_allowed() { + &["--literal-pathspecs", "blame", "--porcelain", revision, "--", &rel] } else { - run(r.root(), &["--literal-pathspecs", "blame", "--no-textconv", "--porcelain", revision, "--", &rel], None)? + &["--literal-pathspecs", "blame", "--no-textconv", "--porcelain", revision, "--", &rel] + }; + let (out, contents_blame) = if crate::execution_allowed() { + let out = cli::run_raw(r.root(), &contents_args, Some(&contents))?; + if out.code == Some(0) { + (out, true) + } else if String::from_utf8_lossy(&out.stderr).contains("cannot use --contents") { + (run(r.root(), plain_args, None)?, false) + } else { + return Err(cli::failure(&contents_args, &out.stderr, &out.stdout, out.code)); + } + } else { + (run(r.root(), plain_args, None)?, false) }; let mut lines = crate::blame::parse_porcelain(&out.stdout_str()); for line in &mut lines { @@ -106,7 +119,7 @@ pub(crate) fn blame( line.summary = String::new(); } } - if !crate::execution_allowed() { + if !contents_blame { let base = r.file_at(path, Some(revision), encoding)?.unwrap_or_default(); for hunk in crate::diff::line_hunks(&base, &text).into_iter().rev() { let start = (hunk.old_start as usize).min(lines.len()); diff --git a/e2e/console-selection.spec.ts b/e2e/console-selection.spec.ts new file mode 100644 index 000000000..cfec2c291 --- /dev/null +++ b/e2e/console-selection.spec.ts @@ -0,0 +1,84 @@ +import { expect, test, type Page } from "@playwright/test"; + +/** Mount the real console with enough output to recycle rows while selecting. */ +async function consoleOutput(page: Page, follow = false) { + await page.goto("/"); + await page.evaluate(async (follow) => { + const reactPath = "/node_modules/.vite/deps/react.js"; + const domPath = "/node_modules/.vite/deps/react-dom_client.js"; + const viewPath = "/src/features/run/ConsoleView.tsx"; + const modelPath = "/src/features/run/console.ts"; + const react = await import(reactPath); + const dom = await import(domPath); + const { createElement, useState } = react.default ?? react; + const { createRoot } = dom.default ?? dom; + const { ConsoleView } = await import(viewPath); + const { ConsoleModel } = await import(modelPath); + const model = new ConsoleModel(); + model.append({ kind: "stdout", text: Array.from({ length: 500 }, (_, i) => `output ${i}`).join("\n") }); + const host = document.createElement("div"); + host.style.cssText = "position:fixed;inset:0;z-index:99999;display:flex;height:300px;background:white"; + document.body.append(host); + function Console() { + const [following, setFollowing] = useState(follow); + return createElement(ConsoleView, { + model, + version: 1, + softWrap: false, + follow: following, + onFollowChange: setFollowing, + cwd: "/", + }); + } + createRoot(host).render(createElement(Console)); + }, follow); + const log = page.getByRole("log", { name: "Console", exact: true }); + await expect(log.locator("[data-index]").first()).toBeVisible(); + return log; +} + +test("keeps a console selection intact when scrolling beyond the virtual window", async ({ page }) => { + const log = await consoleOutput(page); + await log.evaluate((el) => { + el.scrollTop = 200 * 20; + }); + const start = log.locator('[data-index="205"]'); + await expect(start).toBeVisible(); + await start.evaluate((el) => { + const selection = window.getSelection()!; + selection.setBaseAndExtent(el.firstChild!.firstChild!, 2, el.nextElementSibling!.firstChild!.firstChild!, 5); + }); + await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toBe("tput 205\noutpu"); + await log.evaluate((el) => { + el.scrollTop = 350 * 20; + }); + await expect(log.locator('[data-index="350"]')).toBeVisible(); + await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toBe("tput 205\noutpu"); + await page.evaluate(() => window.getSelection()?.removeAllRanges()); + await expect(start).toHaveCount(0); +}); + +test("preserves all lines in a drag selection across virtual windows", async ({ page }) => { + const log = await consoleOutput(page); + await log.evaluate((el) => { + el.scrollTop = 200 * 20; + }); + const start = log.locator('[data-index="205"]'); + await expect(start).toBeVisible(); + const box = (await start.boundingBox())!; + await page.mouse.move(box.x + 20, box.y + 10); + await page.mouse.down(); + await page.mouse.move(box.x + 60, box.y + 30, { steps: 5 }); + await log.evaluate((el) => { + el.scrollTop = 280 * 20; + }); + const end = log.locator('[data-index="285"]'); + await expect(end).toBeVisible(); + const endBox = (await end.boundingBox())!; + await page.mouse.move(endBox.x + 60, endBox.y + 10, { steps: 5 }); + await page.mouse.up(); + const text = await page.evaluate(() => window.getSelection()?.toString()); + expect(text).toContain("output 206\noutput 207"); + expect(text).toContain("output 250\noutput 251"); + expect(text).toContain("output 284"); +}); diff --git a/e2e/overviewLane.spec.ts b/e2e/overviewLane.spec.ts new file mode 100644 index 000000000..6c51df7c8 --- /dev/null +++ b/e2e/overviewLane.spec.ts @@ -0,0 +1,30 @@ +import { expect, openFromTree, test } from "./fixtures"; + +// Headless Chromium hides scrollbars by default; this test drags one. +test.use({ launchOptions: { ignoreDefaultArgs: ["--hide-scrollbars"] } }); + +test("the scrollbar thumb drags even where overview marks cover it (#752)", async ({ workbench: page }) => { + const editor = await openFromTree(page, "build.gradle.kts"); + await editor.locator(".cm-content").click(); + await page.keyboard.press("Meta+a"); + await page.keyboard.insertText(Array.from({ length: 400 }, (_, i) => `val mark${i} = ${i}`).join("\n")); + // A match on every line covers the overview lane, as a new file's change mark does. + await page.keyboard.press("Meta+f"); + await page.getByRole("textbox", { name: "Find" }).fill("mark"); + await expect(editor.locator(".cm-openidea-overviewMark").first()).toBeVisible(); + const scroller = editor.locator(".cm-scroller"); + await scroller.evaluate((s) => (s.scrollTop = 0)); + const box = (await scroller.boundingBox())!; + // Over a mark and inside the thumb, which is at the top of the track. + const x = box.x + box.width - 5; + const y = box.y + 12; + await page.mouse.move(x - 40, y); + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x, y + 150, { steps: 10 }); + await page.mouse.up(); + expect(await scroller.evaluate((s) => s.scrollTop)).toBeGreaterThan(0); + // Away from the thumb the marks take the pointer again. + await page.mouse.move(x, box.y + box.height - 20); + await expect(editor.locator(".cm-openidea-overview")).not.toHaveClass(/cm-openidea-overview-onThumb/); +}); diff --git a/e2e/usage-highlight-contrast.spec.ts b/e2e/usage-highlight-contrast.spec.ts new file mode 100644 index 000000000..c10e212ea --- /dev/null +++ b/e2e/usage-highlight-contrast.spec.ts @@ -0,0 +1,81 @@ +import { expect, test } from "./fixtures"; + +const themes = [ + { theme: "light", scheme: "theme" }, + { theme: "dark", scheme: "theme" }, + { theme: "dark-hc", scheme: "theme" }, + { theme: "light", scheme: "dark" }, + { theme: "dark", scheme: "light" }, +] as const; + +for (const { theme, scheme } of themes) { + test(`usage highlights have readable text with ${theme} theme and ${scheme} scheme`, async ({ + workbench: page, + }, info) => { + await page.evaluate( + async ({ theme, scheme }) => { + const themePath = "/src/workbench/theme.ts"; + const schemePath = "/src/platform/colorScheme.ts"; + const usagesPath = "/src/features/code-insight/usages.ts"; + const { applyThemeAttributes } = await import(themePath); + const { schemeCss } = await import(schemePath); + const { useUsagesPopup } = await import(usagesPath); + applyThemeAttributes(theme); + const style = document.createElement("style"); + style.textContent = schemeCss(scheme); + document.head.append(style); + const symbol = "trainingSessionFetch"; + const texts = [ + `export function ${symbol}() {}`, + `import { ${symbol} } from './api';`, + `// Call ${symbol} here`, + `const name = '${symbol}';`, + ]; + useUsagesPopup.setState({ + open: true, + anchor: { x: 100, y: 100 }, + symbol, + groups: texts.map((text, index) => ({ + path: `/contrast/example-${index}.ts`, + usages: [ + { + path: `/contrast/example-${index}.ts`, + line: 0, + col: text.indexOf(symbol), + endCol: text.indexOf(symbol) + symbol.length, + text, + }, + ], + })), + note: null, + }); + }, + { theme, scheme }, + ); + const popup = page.getByRole("dialog", { name: "Usages", exact: true }); + await expect(popup.locator(".tok-comment").first()).toBeVisible(); + await expect(popup.locator("mark")).toHaveCount(4); + const ratios = await popup.locator("mark").evaluateAll((marks) => { + const luminance = (color: string) => { + const channels = color + .match(/[\d.]+/g)! + .slice(0, 3) + .map(Number) + .map((value) => { + const channel = value / 255; + return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; + }); + return channels[0]! * 0.2126 + channels[1]! * 0.7152 + channels[2]! * 0.0722; + }; + return marks.flatMap((mark) => { + const background = luminance(getComputedStyle(mark).backgroundColor); + return [mark, ...mark.querySelectorAll("*")].map((node) => { + const foreground = luminance(getComputedStyle(node).color); + return (Math.max(background, foreground) + 0.05) / (Math.min(background, foreground) + 0.05); + }); + }); + }); + for (const ratio of ratios) expect(ratio).toBeGreaterThanOrEqual(4.5); + await popup.screenshot({ path: info.outputPath("usage-highlights.png") }); + }); +} diff --git a/languages/javascript.toml b/languages/javascript.toml index b5676537e..5f19df869 100644 --- a/languages/javascript.toml +++ b/languages/javascript.toml @@ -27,6 +27,14 @@ args = ["--stdio"] root-markers = ["jsconfig.json", "tsconfig.json", "package.json"] install-hint = "npm i -g typescript@6 typescript-language-server" +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" + [debugger] adapter = "js-debug" command = "js-debug-adapter" diff --git a/languages/javascriptreact.toml b/languages/javascriptreact.toml index 3c5a17760..be5b5ab90 100644 --- a/languages/javascriptreact.toml +++ b/languages/javascriptreact.toml @@ -26,6 +26,14 @@ args = ["--stdio"] root-markers = ["jsconfig.json", "tsconfig.json", "package.json"] install-hint = "npm i -g typescript@6 typescript-language-server" +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" + [debugger] adapter = "js-debug" command = "js-debug-adapter" diff --git a/languages/svelte.toml b/languages/svelte.toml index 41095ee15..b441d4107 100644 --- a/languages/svelte.toml +++ b/languages/svelte.toml @@ -13,3 +13,11 @@ command = "svelteserver" args = ["--stdio"] root-markers = ["svelte.config.js", "svelte.config.mjs", "svelte.config.ts", "package.json"] install-hint = "npm i -g svelte-language-server" + +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" diff --git a/languages/typescript.toml b/languages/typescript.toml index 41817c5c3..0dedc17ad 100644 --- a/languages/typescript.toml +++ b/languages/typescript.toml @@ -37,6 +37,14 @@ root-markers = ["angular.json", "node_modules/@angular/core"] require-root-marker = true install-hint = "npm i -g @angular/language-server" +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" + [debugger] adapter = "js-debug" command = "js-debug-adapter" diff --git a/languages/typescriptreact.toml b/languages/typescriptreact.toml index ad238c6c9..d7ecf0b8c 100644 --- a/languages/typescriptreact.toml +++ b/languages/typescriptreact.toml @@ -26,6 +26,14 @@ args = ["--stdio"] root-markers = ["tsconfig.json", "jsconfig.json", "package.json"] install-hint = "npm i -g typescript@6 typescript-language-server" +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" + [debugger] adapter = "js-debug" command = "js-debug-adapter" diff --git a/languages/vue.toml b/languages/vue.toml index e260bce72..247f09303 100644 --- a/languages/vue.toml +++ b/languages/vue.toml @@ -22,3 +22,11 @@ command = "vtsls" args = ["--stdio"] root-markers = ["tsconfig.json", "jsconfig.json", "package.json"] install-hint = "npm i -g @vtsls/language-server" + +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" diff --git a/src/bindings.ts b/src/bindings.ts index b3ea339c3..7c4a2d955 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -3350,6 +3350,8 @@ export type GitOperation = { op: "interactiveRebase"; base: string | null; entries: RebaseEntry[] }; export type GitSettings = { + /** Show blame after the current editor line. */ + inlineBlame?: boolean, /** Path to the git executable; empty = auto-detect. */ executable?: string, /** Default of Update Project: `merge` | `rebase`. */ @@ -3619,6 +3621,8 @@ cancellable: boolean } | { type: "indexState"; indexing: boolean; files: number; symbols: number } | /** Git status / branch information should be refreshed. */ { type: "vcsChanged"; root: IpcPath } | { type: "languageServer"; server: ServerId; name: string; status: ServerStatus; message: string | null } | +/** A running server lacks a capability; the UI offers the managed tool as a replacement. */ +{ type: "languageServerWarning"; name: string; message: string; installTool: string } | /** * A stopped language server left the project's list (disabled in settings, or a * fallback that a preferred server took over from again). diff --git a/src/editor/api.ts b/src/editor/api.ts index 9f0251cbf..9d1e81e01 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -32,6 +32,9 @@ import type { LanguageDescription } from "@codemirror/language"; import { create } from "zustand"; import { maybeEditorService } from "@/workbench/contributions"; +/** Documents above this size (UTF-16 units) skip per-line decorations. */ +export const HEAVY_LIMIT = 8_000_000; + export interface EditorDocContext { doc: number; path: string; diff --git a/src/editor/core/cm/ideaParity.test.ts b/src/editor/core/cm/ideaParity.test.ts index 38640f8fd..bad82ca40 100644 --- a/src/editor/core/cm/ideaParity.test.ts +++ b/src/editor/core/cm/ideaParity.test.ts @@ -23,7 +23,7 @@ import { foldSelectionEffects, } from "./folding"; import { columnModeExtension, extendBlock, toggleColumnMode } from "./columnMode"; -import { nearestMark, unfoldAround } from "./lanes"; +import { nearestMark, scrollbarThumb, unfoldAround } from "./lanes"; import { lineRangeSelection } from "./gutterDrag"; import { matchBraceTarget } from "./caretCommands"; import { overwriteField, toggleOverwrite } from "./overwrite"; @@ -291,6 +291,16 @@ describe("error stripe", () => { expect(nearestMark(marks, 21)).toBeNull(); }); + it("finds the scrollbar thumb the marks must let through", () => { + // Nothing to scroll: no thumb. + expect(scrollbarThumb(0, 400, 400)).toBeNull(); + // A quarter of the document visible: a 100px thumb, at the top, then at the bottom. + expect(scrollbarThumb(0, 1600, 400)).toEqual({ top: 0, bottom: 100 }); + expect(scrollbarThumb(1200, 1600, 400)).toEqual({ top: 300, bottom: 400 }); + // A long document keeps the 32px minimum thumb; halfway down it sits mid-track. + expect(scrollbarThumb(49800, 100000, 400)).toEqual({ top: 184, bottom: 216 }); + }); + it("unfolds what hides the target line", () => { const s = EditorState.create({ doc: "f {\n x\n}", extensions: [codeFolding()] }).update({ effects: foldEffect.of({ from: 3, to: 8 }), diff --git a/src/editor/core/cm/lanes.ts b/src/editor/core/cm/lanes.ts index cb876831c..9e6ac7c62 100644 --- a/src/editor/core/cm/lanes.ts +++ b/src/editor/core/cm/lanes.ts @@ -27,6 +27,27 @@ export function nearestMark(marks: re return best; } +/** The smallest scrollbar thumb (`::-webkit-scrollbar-thumb` min-height in global.css). */ +const MIN_THUMB = 32; + +/** + * Where the vertical scrollbar thumb of a scroller is, in px from the top of its track, or null + * when nothing scrolls. The overview lane lies over the scrollbar, so its marks must not take + * the pointer where the thumb is (#752). + */ +export function scrollbarThumb( + scrollTop: number, + scrollHeight: number, + clientHeight: number, + minThumb = MIN_THUMB, +): { top: number; bottom: number } | null { + const max = scrollHeight - clientHeight; + if (max <= 0 || clientHeight <= 0) return null; + const length = Math.min(clientHeight, Math.max(minThumb, (clientHeight * clientHeight) / scrollHeight)); + const top = (Math.min(Math.max(scrollTop, 0), max) / max) * (clientHeight - length); + return { top, bottom: top + length }; +} + /** Unfolds the folds hiding `pos` or the rest of its line (navigating to a mark shows it). */ export function unfoldAround(state: EditorState, pos: number): StateEffect[] { const lineEnd = state.doc.lineAt(pos).to; @@ -41,12 +62,17 @@ class OverviewLane { dom: HTMLElement; private scheduled = false; private lastKey = ""; + /** The last pointer position over the editor, to re-check the thumb when it scrolls. */ + private pointer: { x: number; y: number } | null = null; constructor(private view: EditorView) { this.dom = document.createElement("div"); this.dom.className = "cm-openidea-overview"; this.dom.setAttribute("aria-hidden", "true"); this.dom.addEventListener("mousedown", this.onMouseDown); + view.dom.addEventListener("mousemove", this.onPointerMove); + view.dom.addEventListener("mouseleave", this.onPointerLeave); + view.scrollDOM.addEventListener("scroll", this.onScroll, { passive: true }); view.dom.appendChild(this.dom); this.schedule(); } @@ -132,6 +158,38 @@ class OverviewLane { }); } + /** + * Over the scrollbar thumb the marks let the pointer through, so the thumb can be dragged + * even where a mark covers it (a new file is one mark the height of the lane, #752). + */ + private updateThumbPassThrough() { + const p = this.pointer; + let over = false; + if (p) { + const lane = this.dom.getBoundingClientRect(); + const s = this.view.scrollDOM; + const thumb = scrollbarThumb(s.scrollTop, s.scrollHeight, s.clientHeight); + const y = p.y - s.getBoundingClientRect().top; + over = !!thumb && p.x >= lane.left && p.x <= lane.right && y >= thumb.top && y <= thumb.bottom; + } + this.dom.classList.toggle("cm-openidea-overview-onThumb", over); + } + + private onPointerMove = (e: MouseEvent) => { + this.pointer = { x: e.clientX, y: e.clientY }; + // A thumb drag in progress keeps the marks out of the way until the button is released. + if (e.buttons === 0) this.updateThumbPassThrough(); + }; + + private onPointerLeave = () => { + this.pointer = null; + this.updateThumbPassThrough(); + }; + + private onScroll = () => { + if (this.pointer) this.updateThumbPassThrough(); + }; + private onMouseDown = (e: MouseEvent) => { if (e.button !== 0) return; const marks = [...this.dom.querySelectorAll(".cm-openidea-overviewMark")].map((el) => { @@ -153,6 +211,9 @@ class OverviewLane { destroy() { this.dom.removeEventListener("mousedown", this.onMouseDown); + this.view.dom.removeEventListener("mousemove", this.onPointerMove); + this.view.dom.removeEventListener("mouseleave", this.onPointerLeave); + this.view.scrollDOM.removeEventListener("scroll", this.onScroll); this.dom.remove(); } } diff --git a/src/editor/core/cm/setup.ts b/src/editor/core/cm/setup.ts index bcecc965e..059b6c139 100644 --- a/src/editor/core/cm/setup.ts +++ b/src/editor/core/cm/setup.ts @@ -28,7 +28,7 @@ import type { EditorConfigProps } from "@/bindings"; import type { EditorSettings } from "@/platform/ipc"; import { editorPrefs, lineCommentStyleFor, type EditorExtras } from "@/platform/editorSettings"; import { BUNDLED_MONO_FAMILY, monoFontFamily } from "@/platform/fonts"; -import { editorContributions, type EditorDocContext } from "@/editor/api"; +import { editorContributions, HEAVY_LIMIT, type EditorDocContext } from "@/editor/api"; import { distractionFreeWidth, isDistractionFree } from "@/workbench/distractionFree"; import { openideaHighlightStyle } from "./highlight"; import { openideaTheme } from "./theme"; @@ -78,8 +78,7 @@ export function prefsOf(settings: EditorSettings | undefined): EditorExtras { return editorPrefs({ editor: settings }); } -/** Documents above this size (UTF-16 units) skip per-line decorations. */ -export const HEAVY_LIMIT = 8_000_000; +export { HEAVY_LIMIT } from "@/editor/api"; export interface EffectiveIndent { tabSize: number; diff --git a/src/editor/core/cm/theme.ts b/src/editor/core/cm/theme.ts index 0a95f9177..0390d1f1f 100644 --- a/src/editor/core/cm/theme.ts +++ b/src/editor/core/cm/theme.ts @@ -283,6 +283,8 @@ export const openideaTheme = EditorView.theme({ cursor: "pointer", }, ".cm-openidea-overviewMark-thin": { left: "4px", width: "4px", minHeight: "2px" }, + // The scrollbar thumb wins over the marks under it (cm/lanes.ts, #752). + ".cm-openidea-overview-onThumb .cm-openidea-overviewMark": { pointerEvents: "none" }, ".cm-openidea-overviewCaret": { position: "absolute", left: "2px", diff --git a/src/editor/lsp/format.ts b/src/editor/lsp/format.ts index 061e377c1..5d3031c3c 100644 --- a/src/editor/lsp/format.ts +++ b/src/editor/lsp/format.ts @@ -118,19 +118,22 @@ async function format( try { edits = await lspRequest(() => commands.lspFormat(doc.doc, range, { ...options, onSave: opts.onSave })); } catch (e) { - if (e instanceof IpcError && e.data?.type === "tool" && e.data.tool === "prettier") { + if (e instanceof IpcError && e.data?.type === "tool" && (e.data.tool === "prettier" || e.data.tool === "oxfmt")) { + const tool = e.data.tool; if (e.kind === "notFound" && e.data.missing) { - await executeAction("tools.promptInstall", { name: "prettier", sticky: !opts.onSave }); + await executeAction("tools.promptInstall", { name: tool, sticky: !opts.onSave }); } else if (e.kind !== "cancelled") { notifications().notify({ group: "tools", level: "error", - title: tx("Prettier formatting failed"), + title: tool === "oxfmt" ? tx("oxfmt formatting failed") : tx("Prettier formatting failed"), body: e.kind === "timedOut" - ? tx("Prettier formatting timed out") + ? tool === "oxfmt" + ? tx("oxfmt formatting timed out") + : tx("Prettier formatting timed out") : createElement("span", { className: styles.diagnostics }, e.message), - key: `prettier-format-error:${doc.path}`, + key: `${tool}-format-error:${doc.path}`, sticky: false, }); } diff --git a/src/editor/lsp/formatErrors.test.tsx b/src/editor/lsp/formatErrors.test.tsx index aa21c997f..3107316b9 100644 --- a/src/editor/lsp/formatErrors.test.tsx +++ b/src/editor/lsp/formatErrors.test.tsx @@ -18,7 +18,6 @@ import { import { formatDocument } from "./format"; import * as lsp from "./lsp"; -const tool = { type: "tool", tool: "prettier", missing: false } as const; const views: EditorView[] = []; const viewFor = (path = "/project/file.css") => { const view = new EditorView({ @@ -43,7 +42,9 @@ afterEach(() => { _resetI18nForTests(); }); -describe("external formatter errors", () => { +describe.each(["prettier", "oxfmt"])("%s formatter errors", (name) => { + const tool = { type: "tool", tool: name, missing: false } as const; + const display = name === "prettier" ? "Prettier" : name; it("uses structured missing-tool information to request the shared install prompt", async () => { const prompt = vi.fn(); const dispose = registerAction({ @@ -54,9 +55,9 @@ describe("external formatter errors", () => { try { failWith({ kind: "notFound", message: "unrelated diagnostic", data: { ...tool, missing: true } }); await formatDocument(viewFor()); - expect(prompt).toHaveBeenCalledWith({ name: "prettier", sticky: true }); + expect(prompt).toHaveBeenCalledWith({ name, sticky: true }); await formatDocument(viewFor(), { explicit: false, onSave: true }); - expect(prompt).toHaveBeenLastCalledWith({ name: "prettier", sticky: false }); + expect(prompt).toHaveBeenLastCalledWith({ name, sticky: false }); expect(useNotifications.getState().items).toHaveLength(0); } finally { dispose(); @@ -95,7 +96,7 @@ describe("external formatter errors", () => { for (let i = 0; i < 3; i++) await formatDocument(view, { explicit: false, onSave: true }); const notes = useNotifications.getState().items; expect(notes).toHaveLength(1); - expect(notes[0]).toMatchObject({ sticky: false, title: "Prettier 格式化失败" }); + expect(notes[0]).toMatchObject({ sticky: false, title: `${display} 格式化失败` }); expect(balloonTimeout(notes[0]!)).toBe(10000); render(); expect(screen.getByRole("alert")).toHaveTextContent(stderr); @@ -115,7 +116,7 @@ describe("external formatter errors", () => { const request = failWith({ kind: "timedOut", message: "arbitrary backend text", data: tool }); const view = viewFor(); await formatDocument(view); - expect(useNotifications.getState().items[0]).toMatchObject({ body: "Prettier 格式化超时", sticky: false }); + expect(useNotifications.getState().items[0]).toMatchObject({ body: `${display} 格式化超时`, sticky: false }); clearNotifications(); request.mockRejectedValue(new IpcError({ kind: "cancelled", message: "cancelled", data: tool })); await formatDocument(view); diff --git a/src/features/code-insight/UsagesView.tsx b/src/features/code-insight/UsagesView.tsx index ca7ea44c0..0f7795054 100644 --- a/src/features/code-insight/UsagesView.tsx +++ b/src/features/code-insight/UsagesView.tsx @@ -54,7 +54,7 @@ export function UsagePreview({ usage }: { usage: Usage }) { return ( {tokenSpans(text, 0, from, tokens, "b:")} - {tokenSpans(text, from, to, tokens, "m:")} + {text.slice(from, to)} {tokenSpans(text, to, text.length, tokens, "a:")} ); diff --git a/src/features/code-insight/usagePreview.test.tsx b/src/features/code-insight/usagePreview.test.tsx index 7c276925d..79e3b8c23 100644 --- a/src/features/code-insight/usagePreview.test.tsx +++ b/src/features/code-insight/usagePreview.test.tsx @@ -17,14 +17,17 @@ describe("UsagePreview", () => { ); await waitFor(() => expect(container.querySelector(".tok-keyword")?.textContent).toBe("const")); expect(container.querySelector("mark")?.textContent).toBe("foo"); + expect(container.querySelector("mark")?.childElementCount).toBe(0); expect(container.textContent).toBe("const x = foo();"); }); - it("colours a usage inside a doc comment as a comment", async () => { + it("keeps comment colours outside the match and the search foreground inside it", async () => { const { container } = render( , ); - await waitFor(() => expect(container.querySelector("mark .tok-comment")?.textContent).toBe("foo")); + await waitFor(() => expect(container.querySelector(".tok-comment")?.textContent).toBe("* Uses ")); + expect(container.querySelector("mark")?.textContent).toBe("foo"); + expect(container.querySelector("mark")?.childElementCount).toBe(0); }); }); diff --git a/src/features/project-tree/ProjectView.module.css b/src/features/project-tree/ProjectView.module.css index c23ebaf46..d499db233 100644 --- a/src/features/project-tree/ProjectView.module.css +++ b/src/features/project-tree/ProjectView.module.css @@ -40,6 +40,9 @@ color: var(--fg-strong); font-weight: 600; } +.moduleLabel { + font-weight: 600; +} /* IDEA's "excluded" look: ignored / excluded entries are dimmed. */ .excluded { color: var(--vcs-ignored); diff --git a/src/features/project-tree/ProjectView.tsx b/src/features/project-tree/ProjectView.tsx index f4e140f8d..363912c43 100644 --- a/src/features/project-tree/ProjectView.tsx +++ b/src/features/project-tree/ProjectView.tsx @@ -48,7 +48,12 @@ import { badgeKind } from "@/ui/symbolKind"; import { contextMenuItems, menuGroupContext, newMenuItems } from "./contextMenu"; import { openNewPopup } from "./newPopupModel"; import { presentationIcon, useJvmPresentation } from "@/platform/filePresentation"; -import { moduleFolderIcon, useProjectPresentation, type BuildPresentation } from "@/platform/projectPresentation"; +import { + isModuleFolder, + moduleFolderIcon, + useProjectPresentation, + type BuildPresentation, +} from "@/platform/projectPresentation"; import { relativePath } from "@/platform/project"; import { folderKind } from "./folderKind"; import { useHomeDir } from "./home"; @@ -270,6 +275,8 @@ const Row = memo(function Row({ node.dir || node.placeholder || node.icon ? iconFor(node, roots, root, build, detected) : presentationIcon(node.path, file, roots, root, build.spring); + const moduleFolder = + node.dir && !node.placeholder && !!root && isModuleFolder(relativePath(root, node.path), node.root, build.modules); const isEditing = !!editing && (node.placeholder ? editing.kind === "new" : editing.kind === "rename" && editing.key === node.key); const faded = node.ignored || deco.faded; @@ -310,6 +317,8 @@ const Row = memo(function Row({ node.root && styles.rootName, // Only the project itself is bold (IDEA); contributed roots like Scratches are not. node.root && !node.icon && styles.rootLabel, + // Maven / Gradle module folders are bold too, as in IDEA. + moduleFolder && styles.moduleLabel, faded && !deco.color && styles.excluded, deco.error && styles.error, )} diff --git a/src/features/project-tree/store.test.ts b/src/features/project-tree/store.test.ts index 032924c3e..8d60d4d89 100644 --- a/src/features/project-tree/store.test.ts +++ b/src/features/project-tree/store.test.ts @@ -68,6 +68,14 @@ describe("lazy loading", () => { expect([...useProjectTree.getState().expanded].sort()).toEqual(["/p", "/p/docs"]); }); + it("keeps a collapsed project root collapsed and an expanded node outside it open", async () => { + setExpanded(new Set(["/other/lib"])); + await new Promise((r) => setTimeout(r, 350)); + initProjectTree(null); + initProjectTree(ROOT, "proj"); + expect([...useProjectTree.getState().expanded]).toEqual(["/other/lib"]); + }); + it("drops a folder that vanished and collapses it", async () => { await loadDir(ROOT); setExpanded(new Set([ROOT, "/p/gone"])); diff --git a/src/features/project-tree/store.ts b/src/features/project-tree/store.ts index 3d90bfcb2..cca259ea9 100644 --- a/src/features/project-tree/store.ts +++ b/src/features/project-tree/store.ts @@ -301,11 +301,12 @@ function initialExpanded(root: string | null, mode: ViewMode, members = get().me r.scope === "scratches" && r.path ? [r.path] : [], ), ); - const persisted = mode === "project" ? (readJson(expandedKey(root)) ?? []) : []; - const roots = members ?? [root]; - const expanded = new Set(persisted.filter((p) => roots.some((r) => isSameOrInside(p, r)))); - roots.forEach((r) => expanded.add(r)); - return expanded; + // The tree as the user left it, like IDEA: a collapsed project root stays collapsed, and + // nodes outside it (content roots, contributed roots) stay open. + // Keys of nodes that no longer exist are inert. Only a first open expands the roots. + const persisted = mode === "project" ? readJson(expandedKey(root)) : null; + if (persisted) return new Set(persisted); + return new Set(members ?? [root]); } function persistExpanded() { diff --git a/src/features/run/ConsoleView.tsx b/src/features/run/ConsoleView.tsx index 8997d4196..24cc2287d 100644 --- a/src/features/run/ConsoleView.tsx +++ b/src/features/run/ConsoleView.tsx @@ -21,6 +21,7 @@ import { findOutputLinks, type OutputLink } from "./links"; import { openTarget, openUrl } from "./navigate"; import { segmentLine, type SegmentMark } from "./segments"; import styles from "./Run.module.css"; +import { useConsoleSelection } from "./useConsoleSelection"; import { hostBinding, formatBinding, hasPrimaryModifier, registerKeyClaim } from "@/platform/keymap"; import { tx } from "@/platform/i18n"; import { ConsoleFindBar } from "./FindBar"; @@ -211,7 +212,9 @@ export function ConsoleView({ }); const programmatic = useRef(false); + const { rangeExtractor, selecting } = useConsoleSelection(scrollRef, () => onFollowChange(false)); const virtualizer = useVirtualizer({ + rangeExtractor, count, getScrollElement: () => scrollRef.current, estimateSize: () => ROW, @@ -241,6 +244,7 @@ export function ConsoleView({ const el = scrollRef.current; if (!el || programmatic.current) return; const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < ROW; + if (atBottom && selecting) return; if (atBottom !== follow) onFollowChange(atBottom); }; @@ -537,6 +541,7 @@ export function ConsoleView({ {row.fold && foldButton(row.fold, true)} {inputInline && item.index === rows.length - 1 && pendingInput} + {"\n"} ); })} diff --git a/src/features/run/java.test.ts b/src/features/run/java.test.ts index 3aae4705c..161e1017a 100644 --- a/src/features/run/java.test.ts +++ b/src/features/run/java.test.ts @@ -252,6 +252,21 @@ describe("java command lines", () => { ).toEqual(["--module-path", "/m/x.jar"]); }); + it("colours a Spring Boot console unless the configuration sets the property", () => { + const boot = (args: string[]) => ({ + ...applicationConfig({ className: "a.Main" }, "src/main/java/a/Main.java"), + kind: "spring-boot" as const, + args, + }); + expect(resolveJavaCommand(boot(["-Xmx1g", "a.Main"]), info).args[0]).toBe("-Dspring.output.ansi.enabled=always"); + expect(resolveJavaCommand(boot(["-Dspring.output.ansi.enabled=never", "a.Main"]), info).args).toEqual([ + "-Dspring.output.ansi.enabled=never", + "a.Main", + ]); + const plain = applicationConfig({ className: "a.Main" }, "src/main/java/a/Main.java"); + expect(resolveJavaCommand(plain, info).args).not.toContain("-Dspring.output.ansi.enabled=always"); + }); + it("joins arguments for java-debug and reads JDWP's port", () => { expect(joinJavaArgs(["a", "b c", 'q"'])).toBe('a "b c" "q\\""'); expect(jdwpListeningPort("Listening for transport dt_socket at address: 50123")).toBe(50123); @@ -380,6 +395,25 @@ describe("temporary configurations", () => { expect(useRun.getState().temporary).toEqual([]); }); + it("reuse a configuration of the same main class and module, whatever its id", () => { + const own = (module: string) => + cfg({ + id: `user:${module}`, + kind: "spring-boot", + name: `Mine ${module}`, + args: ["-Xmx1g", "-cp", "${classpath}", "a.Main"], + module, + detected: false, + }); + useRun.setState({ configs: [own("other"), own("app")] }); + expect(adoptTemporary(applicationConfig({ className: "a.Main" }, "app/src/main/java/a/Main.java")).name).toBe( + "Mine app", + ); + expect(useRun.getState().temporary).toEqual([]); + const fresh = adoptTemporary(applicationConfig({ className: "a.Other" }, "app/src/main/java/a/Other.java")); + expect(useRun.getState().temporary).toEqual([fresh.id]); + }); + it("offer Save in the chooser while selected", async () => { const app = adoptTemporary(applicationConfig({ className: "a.Main" }, "src/main/java/a/Main.java")); const { configs, temporary } = useRun.getState(); diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 38a3c1b3c..3d7f35529 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -256,6 +256,13 @@ function persistTemporary() { ); } +/** IDEA's `isConfigurationFromContext` for an Application: the same main class of the same module. */ +function sameApplication(a: RunConfig, b: RunConfig): boolean { + if (!isJvmApplication(a) || !isJvmApplication(b)) return false; + const main = parseJavaArgs(a.args).mainClass; + return main !== null && main === parseJavaArgs(b.args).mainClass && (a.module ?? a.cwd) === (b.module ?? b.cwd); +} + /** * Selects the configuration for a gutter / context run: a saved or detected * configuration with the same id is reused, otherwise `config` becomes a @@ -263,7 +270,10 @@ function persistTemporary() { */ export function adoptTemporary(config: RunConfig): RunConfig { const state = useRun.getState(); - const existing = state.configs.find((c) => c.id === config.id); + // IDEA's `findExistingConfiguration`: a configuration the user keeps for this context wins. + const existing = + state.configs.find((c) => c.id === config.id) ?? + state.configs.find((c) => !state.temporary.includes(c.id) && sameApplication(c, config)); if (existing && !state.temporary.includes(config.id)) { selectConfig(existing.id); return existing; diff --git a/src/features/run/useConsoleSelection.test.tsx b/src/features/run/useConsoleSelection.test.tsx new file mode 100644 index 000000000..9e0058cf9 --- /dev/null +++ b/src/features/run/useConsoleSelection.test.tsx @@ -0,0 +1,94 @@ +import { act, fireEvent, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useConsoleSelection } from "./useConsoleSelection"; + +const viewport = { startIndex: 100, endIndex: 110, overscan: 2, count: 500 }; + +afterEach(() => { + window.getSelection()?.removeAllRanges(); + document.body.replaceChildren(); +}); + +function setup() { + const host = document.createElement("div"); + document.body.append(host); + const rows = [5, 6, 7].map((index) => { + const row = document.createElement("div"); + row.dataset.index = String(index); + row.textContent = `output ${index}`; + host.append(row); + return row; + }); + const ref = { current: host }; + const pause = vi.fn(); + const hook = renderHook(({ pause }) => useConsoleSelection(ref, pause), { initialProps: { pause } }); + const select = (backward = false) => { + act(() => { + const [anchor, focus] = backward ? [rows[2]!, rows[0]!] : [rows[0]!, rows[2]!]; + window.getSelection()!.setBaseAndExtent(anchor.firstChild!, 1, focus.firstChild!, 3); + document.dispatchEvent(new Event("selectionchange")); + }); + }; + return { ...hook, host, rows, pause, select }; +} + +describe("console selection retention", () => { + it.each([false, true])("keeps the full selected interval mounted (backward=%s)", (backward) => { + const { result, select, pause } = setup(); + select(backward); + const indices = result.current.rangeExtractor(viewport); + expect(indices.slice(0, 3)).toEqual([5, 6, 7]); + expect(indices).not.toContain(50); + expect(indices).toContain(100); + expect(pause).toHaveBeenCalled(); + }); + + it("renders the intervening text when dragging beyond the viewport", () => { + const { result, rows, select } = setup(); + fireEvent.mouseDown(rows[0]!, { button: 0 }); + select(); + const indices = result.current.rangeExtractor(viewport); + expect(indices[0]).toBe(5); + expect(indices).toHaveLength(108); + fireEvent.mouseUp(document); + expect(result.current.rangeExtractor(viewport)).not.toContain(50); + expect(result.current.rangeExtractor(viewport)).toContain(5); + }); + + it("releases pinned rows when the selection is cleared or moved outside the console", () => { + const { result, select } = setup(); + select(); + act(() => { + window.getSelection()!.removeAllRanges(); + document.dispatchEvent(new Event("selectionchange")); + }); + expect(result.current.selecting).toBe(false); + expect(result.current.rangeExtractor(viewport)[0]).toBe(98); + select(); + const other = document.createTextNode("other output"); + document.body.append(other); + act(() => { + window.getSelection()!.setBaseAndExtent(other, 0, other, 5); + document.dispatchEvent(new Event("selectionchange")); + }); + expect(result.current.selecting).toBe(false); + expect(result.current.rangeExtractor(viewport)[0]).toBe(98); + }); + + it("keeps a drag active when incoming output rerenders the parent", () => { + const { result, rows, select, rerender } = setup(); + fireEvent.mouseDown(rows[0]!, { button: 0 }); + const pause = vi.fn(); + rerender({ pause }); + select(); + expect(result.current.rangeExtractor(viewport)).toContain(50); + expect(pause).toHaveBeenCalled(); + }); + + it("does not pin rows or pause output for a context click", () => { + const { result, rows, pause } = setup(); + fireEvent.mouseDown(rows[0]!, { button: 2 }); + expect(result.current.selecting).toBe(false); + expect(pause).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/run/useConsoleSelection.ts b/src/features/run/useConsoleSelection.ts new file mode 100644 index 000000000..6fa897240 --- /dev/null +++ b/src/features/run/useConsoleSelection.ts @@ -0,0 +1,73 @@ +import { useCallback, useEffect, useEffectEvent, useState, type RefObject } from "react"; +import { defaultRangeExtractor, type Range } from "@tanstack/react-virtual"; + +type SelectedRows = { start: number; end: number; dragging: boolean }; + +/** Keep the browser's selection endpoints, and the text between them, mounted. */ +export function useConsoleSelection(host: RefObject, pauseFollow: () => void) { + const pause = useEffectEvent(pauseFollow); + const [selected, setSelected] = useState(null); + + useEffect(() => { + const el = host.current; + if (!el) return; + let dragging = false; + const indexOf = (node: Node | null) => { + const element = node instanceof Element ? node : node?.parentElement; + const row = element?.closest("[data-index]"); + return row && el.contains(row) ? Number(row.dataset.index) : null; + }; + const update = () => { + const selection = window.getSelection(); + const anchor = indexOf(selection?.anchorNode ?? null); + const focus = indexOf(selection?.focusNode ?? null); + const next = + selection && (!selection.isCollapsed || dragging) && anchor !== null && focus !== null + ? { start: Math.min(anchor, focus), end: Math.max(anchor, focus), dragging } + : null; + setSelected((prev) => + prev?.start === next?.start && prev?.end === next?.end && prev?.dragging === next?.dragging ? prev : next, + ); + if (next && !selection!.isCollapsed) pause(); + }; + const down = (event: MouseEvent) => { + if (event.button !== 0) return; + const index = indexOf(event.target as Node); + if (index === null) return; + dragging = true; + // Pin the anchor before a scroll can recycle it, even before selectionchange fires. + setSelected({ start: index, end: index, dragging: true }); + pause(); + }; + const up = () => { + dragging = false; + update(); + }; + el.addEventListener("mousedown", down); + document.addEventListener("mouseup", up); + document.addEventListener("selectionchange", update); + return () => { + el.removeEventListener("mousedown", down); + document.removeEventListener("mouseup", up); + document.removeEventListener("selectionchange", update); + }; + }, [host]); + + const rangeExtractor = useCallback( + (range: Range) => { + const visible = defaultRangeExtractor(range); + if (!selected || !visible.length) return visible; + // During a drag, scrolling must also render every line up to the pointer. + const start = Math.max(0, selected.dragging ? Math.min(selected.start, visible[0]!) : selected.start); + const end = Math.min( + range.count - 1, + selected.dragging ? Math.max(selected.end, visible[visible.length - 1]!) : selected.end, + ); + const indices = new Set(visible); + for (let i = start; i <= end; i++) indices.add(i); + return [...indices].sort((a, b) => a - b); + }, + [selected], + ); + return { rangeExtractor, selecting: selected !== null }; +} diff --git a/src/features/settings/model/schema.ts b/src/features/settings/model/schema.ts index d92540d3a..f9400f4c9 100644 --- a/src/features/settings/model/schema.ts +++ b/src/features/settings/model/schema.ts @@ -118,7 +118,7 @@ function validateSpelling(raw: unknown): SpellingValid { export { ACCENT_PRESETS } from "@/workbench/theme"; export const DEFAULT_SETTINGS: ValidSettings = { - general: { reopenLastProject: true, confirmExit: false, ...DEFAULT_GENERAL_EXTRAS }, + general: { reopenLastProject: true, confirmExit: true, ...DEFAULT_GENERAL_EXTRAS }, appearance: { theme: "light", accent: "#3871e1", uiFontSize: 13, locale: "en", ...DEFAULT_APPEARANCE_EXTRAS }, editor: { fontFamily: "JetBrains Mono", diff --git a/src/features/settings/pages/vcs.tsx b/src/features/settings/pages/vcs.tsx index 225095a0e..db0875711 100644 --- a/src/features/settings/pages/vcs.tsx +++ b/src/features/settings/pages/vcs.tsx @@ -492,6 +492,7 @@ export const VCS_PAGES: PageSpec[] = [ keywords: ["Test", "git version"], component: GitExecutableRow, }, + { kind: "toggle", key: "vcs.git.inlineBlame", label: "Show inline blame for the current line" }, ], }, { diff --git a/src/features/tools/__tests__/tools.test.tsx b/src/features/tools/__tests__/tools.test.tsx index 0d4bd57cc..49c64365d 100644 --- a/src/features/tools/__tests__/tools.test.tsx +++ b/src/features/tools/__tests__/tools.test.tsx @@ -7,7 +7,7 @@ import { useTasks } from "@/workbench/tasks"; import { MemoryToolsBackend, sampleTools, setToolsBackend } from "../backend"; import { cancelInstall, installTool, refreshTools, uninstallTool, useTools } from "../store"; import { filterTools, LanguageServersPage } from "../LanguageServersPage"; -import { installMissingServerPrompts, missingKey, promptInstall } from "../missing"; +import { installMissingServerPrompts, installServerWarningPrompts, missingKey, promptInstall } from "../missing"; import { resetTestForm, stagedFor, TestSettingsForm } from "./settingsForm"; type Note = Parameters[0]; @@ -273,3 +273,49 @@ describe("tools.install action", () => { } }); }); + +describe("language server capability warning", () => { + const event = { + type: "languageServerWarning", + name: "clangd", + message: + "Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them.", + installTool: "clangd", + } as const; + + it("offers a managed replacement for system clangd once and restarts it after installation", async () => { + backend.tools.push({ + ...sampleTools()[0]!, + name: "clangd", + displayName: "clangd", + command: "clangd", + status: "onPath", + path: "/usr/bin/clangd", + languages: ["C", "C++", "Objective-C", "Objective-C++"], + }); + const off = installServerWarningPrompts(); + try { + emitLocalIdeEvent(event); + emitLocalIdeEvent(event); + await waitFor(() => expect(notes).toHaveLength(1)); + expect(notes[0]).toMatchObject({ title: "clangd", level: "warning", sticky: true, body: event.message }); + expect(notes[0]!.actions!.map((a) => a.label)).toEqual(["Install", "Language Servers…"]); + notes[0]!.actions![0]!.run(); + await waitFor(() => expect(backend.restarted).toEqual(["clangd"])); + expect(notes.at(-1)).toMatchObject({ title: "clangd installed", key: notes[0]!.key }); + } finally { + off(); + } + }); + + it("keeps the Settings action when the tool cannot be installed", async () => { + const off = installServerWarningPrompts(); + try { + emitLocalIdeEvent(event); + await waitFor(() => expect(notes).toHaveLength(1)); + expect(notes[0]!.actions!.map((a) => a.label)).toEqual(["Language Servers…"]); + } finally { + off(); + } + }); +}); diff --git a/src/features/tools/index.ts b/src/features/tools/index.ts index 76736a609..f9c79d9a0 100644 --- a/src/features/tools/index.ts +++ b/src/features/tools/index.ts @@ -9,7 +9,7 @@ import { lazyComponent } from "@/ui"; import { executeAction, registerActions } from "@/platform/actions"; import { registerSettingsPage } from "@/workbench/contributions"; import { installJdkPrompts, registerJdkActions } from "./jdk"; -import { installMissingServerPrompts, promptInstall } from "./missing"; +import { installMissingServerPrompts, installServerWarningPrompts, promptInstall } from "./missing"; import { installTool, refreshTools, SETTINGS_PAGE_ID, toolByName, useTools } from "./store"; /** The Language Servers page (also used by the Settings window). */ @@ -79,6 +79,7 @@ export function activate(): () => void { }, ]), installMissingServerPrompts(), + installServerWarningPrompts(), registerJdkActions(), installJdkPrompts(), ]; diff --git a/src/features/tools/missing.ts b/src/features/tools/missing.ts index c1c48d1a2..6bd53191e 100644 --- a/src/features/tools/missing.ts +++ b/src/features/tools/missing.ts @@ -56,3 +56,31 @@ export function installMissingServerPrompts(): () => void { void promptInstall(e.name).catch(() => {}); }); } + +/** A system server that lacks a capability can be replaced by the managed tool. */ +export function installServerWarningPrompts(): () => void { + const prompted = new Set(); + return onIdeEvent("languageServerWarning", (e) => { + const key = `lsp-warning-${e.name}-${e.installTool}`; + if (prompted.has(key)) return; + prompted.add(key); + void (async () => { + if (!useTools.getState().loaded) await refreshTools(); + const tool = toolByName(e.installTool); + notifications().notify({ + group: "lsp", + level: "warning", + title: e.name, + body: tx(e.message), + key, + sticky: true, + actions: [ + ...(tool?.canInstall + ? [{ label: "Install", run: () => void installTool(e.installTool, { notificationKey: key }) }] + : []), + { label: "Language Servers…", run: () => void executeAction("file.settings", SETTINGS_PAGE_ID) }, + ], + }); + })(); + }); +} diff --git a/src/features/vcs/MergeEditor.tsx b/src/features/vcs/MergeEditor.tsx index 4c899b98f..b04e51675 100644 --- a/src/features/vcs/MergeEditor.tsx +++ b/src/features/vcs/MergeEditor.tsx @@ -8,13 +8,17 @@ import { keymap } from "@codemirror/view"; import { unifiedMergeView } from "@codemirror/merge"; import type { MergeSides } from "@/bindings"; import { embeddedEditorExtensions } from "@/editor/api"; +import { setContext } from "@/platform/context"; import { tx, useTxTemplate } from "@/platform/i18n"; import { call, commands } from "@/platform/ipc"; +import { shortcutLabel } from "@/platform/keymap"; import { Button, IconButton, showContextMenu, Spinner, T } from "@/ui"; import { editorService, type EditorInput } from "@/workbench/contributions"; import { blameSlot, toggleAnnotations } from "./blame"; import { languageFor, viewerBase } from "./cm"; import { confirmDialog } from "./dialogs"; +import { focusedDiffNavigator, setFocusedDiffNavigator, type DiffNavigator } from "./diffNavigator"; +import { syncMergeScroll } from "./mergeScroll"; import { initialResult, mergeTitles, resolutionText, type Resolution, type ResultConflict } from "./conflicts"; import { baseName } from "./model"; import { errorMessage, readWorkingText, runOp, writeFileText } from "./ops"; @@ -220,28 +224,10 @@ export function MergeEditor({ input }: { input: EditorInput }) { setLeft(result.state.field(conflictField).conflicts.length); oursView.current = ours; theirsView.current = theirs; - // Scroll the three panes together (by scroll ratio), like IDEA's merge view. - const views = [ours, result, theirs]; - let syncing = false; - const onScroll = (from: EditorView) => () => { - if (syncing) return; - syncing = true; - const s = from.scrollDOM; - const ratio = s.scrollTop / Math.max(1, s.scrollHeight - s.clientHeight); - for (const other of views) { - if (other === from) continue; - const o = other.scrollDOM; - o.scrollTop = ratio * Math.max(0, o.scrollHeight - o.clientHeight); - } - syncing = false; - }; - const offs = views.map((v) => { - const h = onScroll(v); - v.scrollDOM.addEventListener("scroll", h, { passive: true }); - return () => v.scrollDOM.removeEventListener("scroll", h); - }); + // Scroll the three panes together, lined up through their changes. + const stopSync = syncMergeScroll({ ours, result, theirs }); return () => { - offs.forEach((off) => off()); + stopSync(); ours.destroy(); theirs.destroy(); result.destroy(); @@ -313,17 +299,31 @@ export function MergeEditor({ input }: { input: EditorInput }) { } }; - // IDEA's merge view: F7 / Shift+F7 jump between conflicts; each side pane can - // annotate its version with Git Blame from the context menu. - // Each side pane blames on the branch it belongs to, so lines that only exist on - // the other branch show their real history instead of "Not Committed Yet". + // Next / Previous Difference (F7 / Shift+F7 by default) jump between conflicts through + // the keymap, as in the diff viewer. + // `goto` reads only refs, so the first render's one stays valid. + const [nav] = useState(() => ({ + next: () => goto(1), + prev: () => goto(-1), + jumpToSource: () => void editorService().openFile(path, { focus: true }), + applyChange: () => false, + file: () => {}, + showSettings: () => {}, + })); + useEffect( + () => () => { + if (focusedDiffNavigator() === nav) { + setFocusedDiffNavigator(null); + setContext("diffEditorFocus", false); + } + }, + [nav], + ); + + // Each side pane can annotate its version with Git Blame from the context menu, on + // the branch it belongs to, so lines that only exist on the other branch show their + // real history instead of "Not Committed Yet". const otherRev = state?.sides.commit ?? "HEAD"; - const onKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "F7") { - e.preventDefault(); - goto(e.shiftKey ? -1 : 1); - } - }; const sideMenu = (e: React.MouseEvent, viewRef: { current: EditorView | null }, text: string, revision?: string) => { const view = viewRef.current; @@ -358,10 +358,34 @@ export function MergeEditor({ input }: { input: EditorInput }) { ); return ( -
+
{ + setFocusedDiffNavigator(nav); + setContext("diffEditorFocus", true); + }} + onBlurCapture={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node | null) && focusedDiffNavigator() === nav) { + setFocusedDiffNavigator(null); + setContext("diffEditorFocus", false); + } + }} + >
- goto(-1)} /> - goto(1)} /> + goto(-1)} + /> + goto(1)} + />