diff --git a/crates/ide-build/src/maven.rs b/crates/ide-build/src/maven.rs index a22a46853..c6ccbff6f 100644 --- a/crates/ide-build/src/maven.rs +++ b/crates/ide-build/src/maven.rs @@ -8,6 +8,7 @@ //! later replaces plugins and dependencies with the effective build. use crate::xml::{self, Element}; +use ide_core::{Pos, Range}; use serde::{Deserialize, Serialize}; use specta::Type; use std::collections::{BTreeMap, HashMap, HashSet}; @@ -467,7 +468,7 @@ struct Resolver { } /// `~/.m2/repository`, or `` from `~/.m2/settings.xml`. -pub(crate) fn default_local_repo() -> Option { +pub fn default_local_repo() -> Option { let m2 = dirs::home_dir()?.join(".m2"); local_repository_of(&m2.join("settings.xml")).or(Some(m2.join("repository"))) } @@ -1492,10 +1493,296 @@ pub fn parse_dependency_tree(output: &str) -> Vec<(String, Vec) out } +// ---------------------------------------------------------------- goto declaration + +/// What a go-to-declaration in a POM found, and where. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct PomGoto { + /// Absolute path of the file with the declaration: the POM itself, or a parent + /// POM of the local repository when the definition is inherited from one. + pub path: PathBuf, + pub range: Range, + /// What was found. + pub kind: PomGotoKind, + /// The declaration as named for the user (`example.version`, `com.example:example-lib`). + pub label: String, +} + +/// The kind of declaration a POM go-to-declaration found. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum PomGotoKind { + /// A `` entry of the POM or one of its parents. + Property, + /// A `` entry providing the dependency's version. + ManagedDependency, + /// A `` entry providing the plugin's version. + ManagedPlugin, +} + +/// Byte offset of `pos` (0-based line / UTF-16 column), clamped to `text`. +fn offset_at(text: &str, pos: Pos) -> usize { + let (mut line_seen, mut at) = (0usize, 0usize); + if pos.line > 0 { + for (i, b) in text.bytes().enumerate() { + if b == b'\n' { + line_seen += 1; + if line_seen == pos.line as usize { + at = i + 1; + break; + } + } + } + if line_seen < pos.line as usize { + return text.len(); + } + } + let line_end = text[at..].find('\n').map_or(text.len(), |i| at + i); + let (mut col, mut byte) = (0u32, at); + for c in text[at..line_end].chars() { + let width = c.len_utf16() as u32; + if col + width > pos.col { + break; + } + col += width; + byte += c.len_utf8(); + } + byte +} + +/// 0-based line / UTF-16 column of byte offset `at`. +fn pos_at(text: &str, at: usize) -> Pos { + let before = &text[..at]; + let line_start = before.rfind('\n').map_or(0, |i| i + 1); + Pos::new(before.matches('\n').count() as u32, before[line_start..].encode_utf16().count() as u32) +} + +/// The `${property}` reference covering byte offset `at`: its name and the span of +/// the whole reference. `None` when `at` is outside one (a closing `${…}` before it +/// ends the search: references do not nest). +fn property_ref_at(text: &str, at: usize) -> Option<(&str, usize, usize)> { + // `at` may sit on the `$` itself. + let open = text[..at].rfind("${").or_else(|| text[at..].starts_with("${").then_some(at))?; + let close = open + 2 + text[open + 2..].find('}')?; + (close >= at).then(|| (text[open + 2..close].trim(), open, close + 1)) +} + +/// The elements containing `at`, from the root down to the innermost one. +fn element_path(root: &Element, at: usize) -> Vec<&Element> { + let mut path = vec![root]; + loop { + let Some(next) = path.last().unwrap().children.iter().find(|c| c.start <= at && at < c.end) else { + return path; + }; + path.push(next); + } +} + +/// The `` / `` of a dependency or plugin element, with the +/// plugin default (`org.apache.maven.plugins`) applied. +fn coordinates(e: &Element, props: &HashMap) -> Option<(String, String)> { + let group = if e.name == "plugin" && e.text_of("groupId").is_none() { + "org.apache.maven.plugins".to_owned() + } else { + interpolate(&e.text_of("groupId")?, props) + }; + Some((group, interpolate(&e.text_of("artifactId")?, props))) +} + +/// The go-to-declaration target at `pos` (0-based line / UTF-16 column) of the POM +/// `text` stored at `path` (the current buffer text when the file is open): a +/// `${property}` reference to the `` entry that defines it along the +/// parent chain, or a dependency / plugin to the `` / +/// `` entry that provides its version (a parent POM, followed into +/// the local repository when it is not in the project). `None` when there is no +/// resolvable declaration here. +pub fn goto_declaration(text: &str, path: &Path, pos: Pos, local_repo: Option) -> Option { + let root = xml::parse(text).filter(|e| e.name == "project")?; + let at = offset_at(text, pos); + if let Some((name, _, _)) = property_ref_at(text, at) + && !name.is_empty() + { + return property_target(text, path, name, local_repo); + } + // A `` / `` in a declaration position: its version may come + // from a management entry of this POM or an ancestor's. + let elements = element_path(&root, at); + let e = elements.iter().rev().find(|e| e.name == "dependency" || e.name == "plugin")?; + if elements.iter().any(|a| a.name == "dependencyManagement" || a.name == "pluginManagement") { + return None; // Management entries are declarations; there is nothing to go to. + } + let kind = match e.name.as_str() { + "dependency" => PomGotoKind::ManagedDependency, + _ => PomGotoKind::ManagedPlugin, + }; + managed_target(text, path, e, kind, local_repo) +} + +/// The parent POMs of the POM in `text` at `path`, child-first (its `` refs, +/// via `relativePath` in the project or the local repository), with their texts. +fn parent_chain(path: &Path, text: &str, local_repo: Option) -> Vec<(PathBuf, String)> { + let mut resolver = Resolver::new(local_repo, &[]); + let path = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let mut chain = vec![(path.clone(), text.to_owned())]; + let mut dir = path.parent().unwrap_or(Path::new("")).to_path_buf(); + let mut pom = parse_pom(text); + for _ in 0..MAX_PARENT_DEPTH { + let Some(parent) = pom.and_then(|p| p.parent) else { return chain }; + let Some(pp) = resolver.parent_path(&dir, &parent) else { return chain }; + let pp = dunce::canonicalize(&pp).unwrap_or(pp); + let Ok(next) = std::fs::read_to_string(&pp) else { return chain }; + pom = resolver.pom(&pp); + dir = pp.parent().unwrap_or(Path::new("")).to_path_buf(); + chain.push((pp, next)); + } + chain +} + +/// The properties visible in the POM at `chain[i]`: inherited (its parents' and +/// ancestors' own entries) plus its own, and the `project.*` built-ins. +fn properties_at(resolver: &mut Resolver, chain: &[(PathBuf, String)], i: usize) -> HashMap { + let (p, text) = &chain[i]; + let Some(pom) = parse_pom(text) else { return HashMap::new() }; + let dir = p.parent().unwrap_or(Path::new("")).to_path_buf(); + let ctx = resolver.context(p, 0); + resolver.properties_for(&pom, &ctx, &dir) +} + +/// Where the property `name` is declared: the `project.*` built-ins go to their +/// coordinate elements, the rest to the first `` entry along the parent +/// chain declaring it (a child's definition wins over a parent's; an active +/// profile's counts as the POM's own). The winner's path, element range and name. +fn property_target(text: &str, path: &Path, name: &str, local_repo: Option) -> Option { + let name = name.strip_prefix("pom.").map(|n| format!("project.{n}")).unwrap_or_else(|| name.to_owned()); + let resolver = Resolver::new(local_repo.clone(), &[]); + let chain = parent_chain(path, text, local_repo); + // `project.parent.*` (and the legacy `parent.*` spellings) are declared by the + // clicked POM's own `` section. + let parent_key = match name.as_str() { + "project.parent.groupId" + | "project.parent.artifactId" + | "project.parent.version" + | "parent.groupId" + | "parent.version" => { + let key = name.rsplit('.').next().unwrap(); + Some(&name[name.len() - key.len()..]) + } + _ => None, + }; + if let Some(key) = parent_key { + if !matches!(key, "groupId" | "artifactId" | "version") { + return None; // No other `project.parent.*` property has a declaration. + } + let root = xml::parse(&chain[0].1).filter(|e| e.name == "project")?; + let e = root.child("parent")?.child(key)?; + return Some(PomGoto { + path: chain[0].0.clone(), + range: range_of(&chain[0].1, e), + kind: PomGotoKind::Property, + label: name.clone(), + }); + } + // Built-in coordinates: the POM's own element, else the first ancestor declaring one. + if let Some(key) = name.strip_prefix("project.") { + if !matches!(key, "groupId" | "artifactId" | "version" | "packaging" | "name") { + return None; // `project.basedir`, `project.build.directory`… have no element. + } + for (p, text) in &chain { + let Some(root) = xml::parse(text).filter(|e| e.name == "project") else { continue }; + let Some(e) = root.child(key).filter(|e| !e.text.trim().is_empty()) else { continue }; + return Some(PomGoto { + path: p.clone(), + range: range_of(text, e), + kind: PomGotoKind::Property, + label: name.clone(), + }); + } + return None; + } + // A property of ``: the first POM of the chain (child-first) that + // declares it, at project level or in an active profile. + for (p, text) in &chain { + let Some(root) = xml::parse(text).filter(|e| e.name == "project") else { continue }; + let active: Vec = parse_pom(text) + .map(|pom| resolver.active(&pom.profiles).map(|pr| pr.id.clone()).collect()) + .unwrap_or_default(); + // Project level first, then the first active profile declaring the property. + let e = root.child("properties").and_then(|props| props.child(&name)).or_else(|| { + root.list("profiles", "profile") + .into_iter() + .find(|profile| { + active.contains(&profile.child("id").map(|id| id.text.trim().to_owned()).unwrap_or_default()) + && profile.child("properties").and_then(|props| props.child(&name)).is_some() + })? + .child("properties")? + .child(&name) + }); + if let Some(e) = e { + return Some(PomGoto { + path: p.clone(), + range: range_of(text, e), + kind: PomGotoKind::Property, + label: name.clone(), + }); + } + } + None +} + +/// The `` / `` entry providing the version +/// of the dependency / plugin element `e`: this POM's own management first, then the +/// parent chain, child-first (the nearest declaration wins). Coordinates are matched +/// after interpolation with each POM's effective properties. +fn managed_target( + text: &str, + path: &Path, + e: &Element, + kind: PomGotoKind, + local_repo: Option, +) -> Option { + let mut resolver = Resolver::new(local_repo.clone(), &[]); + let chain = parent_chain(path, text, local_repo); + let key = coordinates(e, &properties_at(&mut resolver, &chain, 0))?; + let (section, item) = match kind { + PomGotoKind::ManagedDependency => ("dependencyManagement/dependencies", "dependency"), + _ => ("build/pluginManagement/plugins", "plugin"), + }; + for (i, (p, text)) in chain.iter().enumerate() { + let root = xml::parse(text).filter(|e| e.name == "project")?; + let props = properties_at(&mut resolver, &chain, i); + let Some(hit) = root.list(section, item).into_iter().find(|m| coordinates(m, &props).as_ref() == Some(&key)) + else { + continue; + }; + let target = hit.child("artifactId").unwrap_or(hit); + return Some(PomGoto { + path: p.clone(), + range: range_of(text, target), + kind, + label: format!("{}:{}", key.0, key.1), + }); + } + None +} + +/// The range of element `e` in `text`. +fn range_of(text: &str, e: &Element) -> Range { + Range::new(pos_at(text, e.start), pos_at(text, e.end)) +} + #[cfg(test)] mod tests { use super::*; + /// `parent_chain` returns paths normalized by `dunce::canonicalize`, so the + /// expected values compared against them must go through the same call: the + /// temp dirs sit behind symlinks on macOS (`/var` → `/private/var`) and under + /// an 8.3 short name on Windows, and raw paths would not match on those runners. + fn canon(path: &Path) -> PathBuf { + dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) + } + /// 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); @@ -2175,6 +2462,325 @@ mod tests { assert_eq!((d.group_id.as_str(), d.version.as_deref()), ("g", Some("1")), "{line}"); } } + + // ---------------------------------------------------- goto declaration + + /// The position of the first character of `needle` (a click on it). + fn at(text: &str, needle: &str) -> Pos { + pos_at(text, text.find(needle).unwrap()) + } + + /// A parent POM and a child of it in `tmp`: the parent at `tmp/pom.xml`, the + /// child at `tmp/app/pom.xml` (found through the default `../pom.xml`). + fn write_parent_child(tmp: &Path, parent: &str, child: &str) { + std::fs::create_dir_all(tmp.join("app")).unwrap(); + std::fs::write(tmp.join("pom.xml"), parent).unwrap(); + std::fs::write(tmp.join("app/pom.xml"), child).unwrap(); + } + + const CHILD_HEADER: &str = r#" + + com.example + parent + 1.0 + + app +"#; + + const PARENT_HEADER: &str = r#" + com.example + parent + 1.0 +"#; + + #[test] + fn a_property_reference_finds_its_definition_in_the_same_pom() { + let text = format!( + r#"{CHILD_HEADER} + 1.2.3 + + + + com.example + lib + ${{example.version}} + + +"# + ); + let path = Path::new("/tmp/pom.xml"); + let goto = goto_declaration(&text, path, at(&text, "${example.version}"), None).unwrap(); + assert_eq!(goto.kind, PomGotoKind::Property); + assert_eq!(goto.label, "example.version"); + assert_eq!(goto.path, path); + let from = text.find("").unwrap(); + let to = text.find("").unwrap() + "".len(); + assert_eq!(goto.range, Range::new(pos_at(&text, from), pos_at(&text, to))); + } + + #[test] + fn an_inherited_property_reference_finds_its_definition_in_the_parent_pom() { + let tmp = tempfile::tempdir().unwrap(); + let parent = format!( + r#"{PARENT_HEADER} + 1.2.3 + +"# + ); + let child = format!( + r#"{CHILD_HEADER} + + com.example + lib + ${{example.version}} + + +"# + ); + write_parent_child(tmp.path(), &parent, &child); + let path = tmp.path().join("app/pom.xml"); + let text = std::fs::read_to_string(&path).unwrap(); + let goto = goto_declaration(&text, &path, at(&text, "${example.version}"), None).unwrap(); + assert_eq!(goto.kind, PomGotoKind::Property); + assert_eq!(goto.path, canon(&tmp.path().join("pom.xml"))); + let from = parent.find("").unwrap(); + assert_eq!(goto.range.start, pos_at(&parent, from)); + } + + #[test] + fn a_child_property_wins_over_the_parent_s_definition() { + let tmp = tempfile::tempdir().unwrap(); + let parent = format!( + r#"{PARENT_HEADER} + 1.0.0 + +"# + ); + let child = format!( + r#"{CHILD_HEADER} + 2.0.0 + + + + com.example + lib + ${{example.version}} + + +"# + ); + write_parent_child(tmp.path(), &parent, &child); + let path = tmp.path().join("app/pom.xml"); + let text = std::fs::read_to_string(&path).unwrap(); + let goto = goto_declaration(&text, &path, at(&text, "${example.version}"), None).unwrap(); + assert_eq!(goto.path, canon(&path), "the child's own definition"); + let from = text.find("").unwrap(); + let to = text.find("").unwrap() + "".len(); + assert_eq!(goto.range, Range::new(pos_at(&text, from), pos_at(&text, to))); + assert!(text[from..to].contains("2.0.0")); + } + + #[test] + fn a_managed_dependency_reference_finds_the_managing_declaration_in_the_parent() { + let tmp = tempfile::tempdir().unwrap(); + let parent = format!( + r#"{PARENT_HEADER} + 1.2.3 + + + + + com.example + example-lib + ${{example.version}} + + + +"# + ); + let child = format!( + r#"{CHILD_HEADER} + + com.example + example-lib + + +"# + ); + write_parent_child(tmp.path(), &parent, &child); + let path = tmp.path().join("app/pom.xml"); + let text = std::fs::read_to_string(&path).unwrap(); + let goto = goto_declaration(&text, &path, at(&text, "example-lib"), None).unwrap(); + assert_eq!(goto.kind, PomGotoKind::ManagedDependency); + assert_eq!(goto.label, "com.example:example-lib"); + assert_eq!(goto.path, canon(&tmp.path().join("pom.xml"))); + let from = parent.find("example-lib").unwrap(); + let to = from + "example-lib".len(); + assert_eq!(goto.range, Range::new(pos_at(&parent, from), pos_at(&parent, to))); + } + + #[test] + fn a_managed_plugin_reference_finds_the_managing_declaration_in_the_parent() { + let tmp = tempfile::tempdir().unwrap(); + let parent = format!( + r#"{PARENT_HEADER} + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + + +"# + ); + let child = format!( + r#"{CHILD_HEADER} + + + maven-compiler-plugin + + + +"# + ); + write_parent_child(tmp.path(), &parent, &child); + let path = tmp.path().join("app/pom.xml"); + let text = std::fs::read_to_string(&path).unwrap(); + let goto = goto_declaration(&text, &path, at(&text, "maven-compiler-plugin"), None).unwrap(); + assert_eq!(goto.kind, PomGotoKind::ManagedPlugin); + assert_eq!(goto.label, "org.apache.maven.plugins:maven-compiler-plugin"); + assert_eq!(goto.path, canon(&tmp.path().join("pom.xml"))); + } + + #[test] + fn an_inherited_property_is_found_in_a_parent_pom_of_the_local_repository() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repository"); + let parent_dir = repo.join("com/example/parent/1.0"); + std::fs::create_dir_all(&parent_dir).unwrap(); + let parent = format!( + r#"{PARENT_HEADER} + 1.2.3 + +"# + ); + std::fs::write(parent_dir.join("parent-1.0.pom"), &parent).unwrap(); + let child = format!( + r#"{CHILD_HEADER} + + com.example + lib + ${{example.version}} + + +"# + ); + let path = tmp.path().join("app/pom.xml"); + std::fs::create_dir_all(tmp.path().join("app")).unwrap(); + std::fs::write(&path, &child).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + let goto = goto_declaration(&text, &path, at(&text, "${example.version}"), Some(repo)).unwrap(); + assert_eq!(goto.kind, PomGotoKind::Property); + assert_eq!(goto.path, canon(&parent_dir.join("parent-1.0.pom"))); + assert_eq!(goto.range.start, pos_at(&parent, parent.find("").unwrap())); + } + + #[test] + fn project_version_resolves_to_the_first_pom_declaring_its_own_version() { + let tmp = tempfile::tempdir().unwrap(); + let parent = format!(r#"{PARENT_HEADER}pom"#); + let child = format!( + r#"{CHILD_HEADER} + + com.example + lib + ${{project.version}} + + +"# + ); + write_parent_child(tmp.path(), &parent, &child); + let path = tmp.path().join("app/pom.xml"); + let text = std::fs::read_to_string(&path).unwrap(); + let goto = goto_declaration(&text, &path, at(&text, "${project.version}"), None).unwrap(); + assert_eq!(goto.path, canon(&tmp.path().join("pom.xml")), "the child has no own "); + assert_eq!(goto.range.start, pos_at(&parent, parent.find("").unwrap())); + } + + #[test] + fn clicks_without_a_resolvable_declaration_find_nothing() { + let tmp = tempfile::tempdir().unwrap(); + let parent = format!( + r#"{PARENT_HEADER} + + + com.example + managed-lib + 1.0 + + + +"# + ); + let child = format!( + r#"{CHILD_HEADER} + + com.example + direct-lib + ${{missing.key}} + + + com.example + other-lib + 2.0 + + +"# + ); + write_parent_child(tmp.path(), &parent, &child); + let path = tmp.path().join("app/pom.xml"); + let text = std::fs::read_to_string(&path).unwrap(); + // The property is declared nowhere along the chain. + assert_eq!(goto_declaration(&text, &path, at(&text, "${missing.key}"), None), None); + // The dependency names its own version: it is the declaration. + assert_eq!(goto_declaration(&text, &path, at(&text, "other-lib"), None), None); + // A management entry is a declaration itself, not a reference. + let parent_path = tmp.path().join("pom.xml"); + let parent_text = std::fs::read_to_string(&parent_path).unwrap(); + assert_eq!( + goto_declaration(&parent_text, &parent_path, at(&parent_text, "managed-lib"), None), + None + ); + // Offsets beyond the document, and non-POM documents, find nothing. + assert_eq!(goto_declaration(&text, &path, Pos::new(999, 0), None), None); + assert_eq!(goto_declaration("", &path, Pos::new(0, 3), None), None); + } + + #[test] + fn a_property_reference_after_non_ascii_text_is_still_found() { + let text = format!( + r#"{CHILD_HEADER} + + 1.2.3 + + + + com.example + lib + + ${{example.version}} + + +"# + ); + let path = Path::new("/tmp/pom.xml"); + let goto = goto_declaration(&text, path, at(&text, "${example.version}"), None).unwrap(); + assert_eq!(goto.kind, PomGotoKind::Property); + assert_eq!(goto.path, path); + } } #[cfg(test)] diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs index 455170813..b2b14aad7 100644 --- a/crates/ide-host/src/ops/build.rs +++ b/crates/ide-host/src/ops/build.rs @@ -5,10 +5,11 @@ use crate::rpc::Dispatcher; use crate::{Host, Project, apply_workspace_edit}; use ide_build::compile::{self, BuildEvent, BuildKind, BuildResult}; +use ide_build::maven::PomGoto; use ide_build::pom_edit::{ArtifactKey, Exclusion}; use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress}; use ide_core::settings::Settings; -use ide_core::{FileEdit, IdeError, IdeEvent, IdeResult, Location, Range, RequestId, WorkspaceEdit}; +use ide_core::{FileEdit, IdeError, IdeEvent, IdeResult, Location, Pos, Range, RequestId, WorkspaceEdit}; use std::collections::BTreeMap; use std::path::Path; @@ -440,6 +441,27 @@ pub async fn maven_dependency_declaration( .map_err(join_err)? } +/// The go-to-declaration target at `pos` of the Maven POM `pom` (project-relative, or +/// absolute for a parent POM opened from the local repository): the definition of the +/// `${property}` at the caret, or the `` / `` +/// entry providing the dependency's / plugin's version, along the parent chain (into +/// the local repository when a parent is not in the project). `None` when nothing +/// resolvable is under the caret. +pub async fn maven_goto_declaration(host: &Host, window: &str, pom: String, pos: Pos) -> IdeResult> { + let project = host.project(window)?; + tokio::task::spawn_blocking(move || { + let given = Path::new(&pom); + let path = if given.is_absolute() { given.to_path_buf() } else { project.root.join(given) }; + let text = project.text_of(&path)?; + let settings = project.settings.effective(); + let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings) + .or_else(ide_build::maven::default_local_repo); + Ok(ide_build::maven::goto_declaration(&text, &path, pos, repo)) + }) + .await + .map_err(join_err)? +} + /// Adds an `` of `exclusion` to the declaration of the direct dependency /// `dependency` of the Maven module `pom`, through the open document when there is one /// (saved). Returns the declaration; nothing changes when it already excludes it. @@ -582,6 +604,9 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("maven_dependency_declaration", |c| async move { maven_dependency_declaration(&c.host, &c.window, c.arg("pom")?, c.arg("dependency")?).await }); + d.add("maven_goto_declaration", |c| async move { + maven_goto_declaration(&c.host, &c.window, c.arg("pom")?, c.arg("pos")?).await + }); d.add("maven_exclude_dependency", |c| async move { maven_exclude_dependency(&c.host, &c.window, c.arg("pom")?, c.arg("dependency")?, c.arg("exclusion")?).await }); diff --git a/src-tauri/src/commands/build.rs b/src-tauri/src/commands/build.rs index fd157cad1..2e07f0f71 100644 --- a/src-tauri/src/commands/build.rs +++ b/src-tauri/src/commands/build.rs @@ -2,9 +2,10 @@ use crate::state::AppState; use ide_build::compile::{BuildEvent, BuildKind, BuildResult}; +use ide_build::maven::PomGoto; use ide_build::pom_edit::ArtifactKey; use ide_build::{BuildModel, BuildTool, SyncProgress}; -use ide_core::{IdeResult, Location, RequestId}; +use ide_core::{IdeResult, Location, Pos, RequestId}; #[allow(unused_imports)] pub use ide_host::ops::build::*; use tauri::ipc::Channel; @@ -114,6 +115,20 @@ pub async fn maven_dependency_declaration( ide_host::ops::build::maven_dependency_declaration(&state.host, window.label(), pom, dependency).await } +/// The go-to-declaration target at `pos` of a Maven POM: the definition of the +/// `${property}` at the caret, or the `` / `` +/// entry providing the dependency's / plugin's version, along the parent chain. +#[tauri::command] +#[specta::specta] +pub async fn maven_goto_declaration( + window: Window, + state: State<'_, AppState>, + pom: String, + pos: Pos, +) -> IdeResult> { + ide_host::ops::build::maven_goto_declaration(&state.host, window.label(), pom, pos).await +} + /// Adds an `` of `exclusion` to the declaration of the direct dependency /// `dependency` of the Maven module `pom`, through the open document when there is one /// (saved). Returns the declaration; nothing changes when it already excludes it. diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index c2de94ad3..fa9fa7c81 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -375,6 +375,7 @@ pub fn builder() -> tauri_specta::Builder { build::gradle_published_catalog, build::maven_dependency_declaration, build::maven_exclude_dependency, + build::maven_goto_declaration, build::build_run, java::java_resolve_launch, java::java_debug_server, diff --git a/src/bindings.ts b/src/bindings.ts index 07b6dabb0..770322ae9 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -730,6 +730,23 @@ export const commands = { * (saved). Returns the declaration; nothing changes when it already excludes it. */ mavenExcludeDependency: (pom: string, dependency: ArtifactKey, exclusion: ArtifactKey) => typedError(__TAURI_INVOKE("maven_exclude_dependency", { pom, dependency, exclusion })), + /** + * The go-to-declaration target at `pos` of a Maven POM: the definition of the + * `${property}` at the caret, or the `` / `` + * entry providing the dependency's / plugin's version, along the parent chain. + */ + mavenGotoDeclaration: (pom: string, pos: Pos) => typedError<{ + /** + * Absolute path of the file with the declaration: the POM itself, or a parent + * POM of the local repository when the definition is inherited from one. + */ + path: string, + range: Range, + /** What was found. */ + kind: PomGotoKind, + /** The declaration as named for the user (`example.version`, `com.example:example-lib`). */ + label: string, +} | null, IdeError>(__TAURI_INVOKE("maven_goto_declaration", { pom, pos })), /** * Build › Build Project / Rebuild Project / Build Module (`file`: a file or folder of the * module) / Build Artifacts, streaming the console and messages on `on_event`; cancellable @@ -5899,6 +5916,29 @@ export type PluginsSnapshot = { ui: UiContribution[], }; +/** What a go-to-declaration in a POM found, and where. */ +export type PomGoto = { + /** + * Absolute path of the file with the declaration: the POM itself, or a parent + * POM of the local repository when the definition is inherited from one. + */ + path: string, + range: Range, + /** What was found. */ + kind: PomGotoKind, + /** The declaration as named for the user (`example.version`, `com.example:example-lib`). */ + label: string, +}; + +/** The kind of declaration a POM go-to-declaration found. */ +export type PomGotoKind = +/** A `` entry of the POM or one of its parents. */ +"property" | +/** A `` entry providing the dependency's version. */ +"managedDependency" | +/** A `` entry providing the plugin's version. */ +"managedPlugin"; + /** A 0-based line / UTF-16 column position. */ export type Pos = { line: number, diff --git a/src/features/build-tools/index.ts b/src/features/build-tools/index.ts index 16dd13137..cfca30600 100644 --- a/src/features/build-tools/index.ts +++ b/src/features/build-tools/index.ts @@ -19,6 +19,7 @@ import { import { lazyComponent } from "@/ui"; import { GradleView, MavenView } from "./BuildToolView"; import { ExecutePopupHost } from "./ExecutePopup"; +import { pomNavigationExtension } from "./pomNavigation"; import { buildToolsRunAnything } from "./runAnything"; import { versionCatalogExtension } from "./versionCatalog"; import { expandableKeys, gradleChildren, gradleRoots, mavenChildren, mavenRoots, gradleProjectKey } from "./model"; @@ -177,6 +178,7 @@ export function activate(): () => void { const disposers: Array<() => void> = [ registerOverlay({ id: "build-tools.execute", component: ExecutePopupHost }), registerEditorContribution({ id: "build-tools.versionCatalog", extensions: versionCatalogExtension }), + registerEditorContribution({ id: "build-tools.pomNavigation", extensions: pomNavigationExtension }), registerRunAnythingProvider(buildToolsRunAnything), registerEditorKind({ kind: ANALYZER_KIND, diff --git a/src/features/build-tools/pomNavigation.test.ts b/src/features/build-tools/pomNavigation.test.ts new file mode 100644 index 000000000..99b5b8078 --- /dev/null +++ b/src/features/build-tools/pomNavigation.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it, vi } from "vitest"; + +const ipc = vi.hoisted(() => ({ mavenGotoDeclaration: vi.fn() })); +const workbench = vi.hoisted(() => ({ openFile: vi.fn() })); +const hints = vi.hoisted(() => ({ showHint: vi.fn() })); + +vi.mock("@/platform/ipc", () => ({ + call: async (p: Promise<{ status: string; data?: unknown; error?: unknown }>) => { + const r = await p; + if (r.status !== "ok") throw r.error; + return r.data; + }, + commands: ipc, +})); +vi.mock("@/workbench/contributions", () => ({ + editorService: () => workbench, +})); +vi.mock("@/editor/lsp/hint", () => ({ showHint: hints.showHint })); +vi.mock("@/editor/api", () => ({ + CONTRIBUTED_DEFINITION: "openideaDefinition", + whenSynced: () => Promise.resolve(), +})); + +import { EditorState } from "@codemirror/state"; +import { inArtifactElement, pomNavigationExtension, propertyRefAt } from "./pomNavigation"; + +const DEFINITION = "openideaDefinition"; + +/** An editor state over `text` with the contribution for `path` installed. */ +function state(text: string, path = "/p/app/pom.xml") { + return EditorState.create({ + doc: text, + extensions: [ + pomNavigationExtension({ + path, + language: "xml", + uiMode: "XML", + readOnly: false, + } as Parameters[0]), + ], + }); +} + +const definitionAt = (s: ReturnType, pos: number) => + s.languageDataAt<(v: { state: typeof s }, p: number) => boolean>(DEFINITION, pos)[0]; + +/** The definition only reads `view.state`; a bare handle is enough. */ +const run = (s: ReturnType, pos: number) => definitionAt(s, pos)!({ state: s }, pos); + +const pom = ` + + + com.example + parent + 1.0 + + app + + 1.2.3 + + + + com.example + example-lib + \${example.version} + + + com.example + other-lib + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + +`; + +describe("POM property references", () => { + it("finds the reference spanning the caret, the $ and the } included", () => { + const at = pom.indexOf("${example.version}"); + expect(propertyRefAt(pom, at)).toEqual({ + from: at, + to: at + "${example.version}".length, + }); + expect(propertyRefAt(pom, at + 1)).toEqual({ + from: at, + to: at + "${example.version}".length, + }); + expect(propertyRefAt(pom, at + "${example.version}".length - 1)).toEqual({ + from: at, + to: at + "${example.version}".length, + }); + // Outside a reference: none. + expect(propertyRefAt(pom, pom.indexOf("example-lib"))).toBeNull(); + // A reference closed before the caret does not reach over it. + const afterRef = at + "${example.version}".length + 1; + expect(propertyRefAt(pom, afterRef)).toBeNull(); + }); +}); + +describe("POM artifact elements", () => { + it("sees a dependency or plugin element around the caret", () => { + expect(inArtifactElement(pom, pom.indexOf("example-lib"))).toBe(true); + expect(inArtifactElement(pom, pom.indexOf("example-lib"))).toBe(true); + expect(inArtifactElement(pom, pom.indexOf("maven-compiler-plugin"))).toBe(true); + expect(inArtifactElement(pom, pom.indexOf("${example.version}"))).toBe(true); + }); + + it("sees no artifact element outside dependencies and plugins", () => { + expect(inArtifactElement(pom, pom.indexOf(""))).toBe(false); + expect(inArtifactElement(pom, pom.indexOf(""))).toBe(false); + // `` and `` are not a `` / ``. + expect(inArtifactElement(pom, pom.indexOf(""))).toBe(false); + }); +}); + +describe("POM go to declaration", () => { + it("contributes a definition on a property reference and an artifact element only", () => { + const s = state(pom); + expect(definitionAt(s, pom.indexOf("${example.version}"))).toBeDefined(); + expect(definitionAt(s, pom.indexOf("other-lib"))).toBeDefined(); + expect(definitionAt(s, pom.indexOf("maven-compiler-plugin"))).toBeDefined(); + expect(definitionAt(s, pom.indexOf(""))).toBeUndefined(); + expect(definitionAt(s, pom.indexOf(""))).toBeUndefined(); + }); + + it("opens the declaration the backend resolved", async () => { + const target = { + path: "/p/pom.xml", + range: { start: { line: 4, col: 6 }, end: { line: 4, col: 14 } }, + kind: "property", + label: "example.version", + }; + ipc.mavenGotoDeclaration.mockResolvedValue({ status: "ok", data: target }); + const s = state(pom); + const at = pom.indexOf("${example.version}"); + run(s, at); + await vi.waitFor(() => + expect(workbench.openFile).toHaveBeenCalledWith("/p/pom.xml", { + selection: { line: 4, col: 6, endLine: 4, endCol: 14 }, + }), + ); + expect(ipc.mavenGotoDeclaration).toHaveBeenCalledWith("/p/app/pom.xml", { + line: pom.slice(0, at).split("\n").length - 1, + col: at - (pom.lastIndexOf("\n", at - 1) + 1), + }); + }); + + it("says so when nothing resolves, without navigating", async () => { + ipc.mavenGotoDeclaration.mockResolvedValue({ status: "ok", data: null }); + const s = state(pom); + const at = pom.indexOf("other-lib"); + run(s, at); + await vi.waitFor(() => expect(hints.showHint).toHaveBeenCalled()); + expect(workbench.openFile).not.toHaveBeenCalled(); + }); + + it("contributes nothing outside a pom.xml", () => { + const s = state(pom, "/p/app/build.xml"); + expect(definitionAt(s, pom.indexOf("${example.version}"))).toBeUndefined(); + expect(ipc.mavenGotoDeclaration).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/build-tools/pomNavigation.ts b/src/features/build-tools/pomNavigation.ts new file mode 100644 index 000000000..36e967802 --- /dev/null +++ b/src/features/build-tools/pomNavigation.ts @@ -0,0 +1,90 @@ +/** + * Go to declaration in `pom.xml`: ⌘/Ctrl-click (or ⌘B) on a `${property}` reference + * opens the `` entry that defines it — the POM's own, else one inherited + * along the parent chain (a parent POM outside the project opens from the local + * repository, read-only as a plain file) — and a dependency / plugin whose version a + * `` / `` entry provides opens that managing + * declaration. The backend resolves both with the build model's POM reader and parent + * resolver; here only cheap eligibility is computed (a reference span, an artifact + * element), so the ⌘-hover underline stays out of the rest of the file, and nothing + * resolves to no jump at all: an unresolvable property or parent never navigates + * somewhere unrelated. + */ +import { EditorState, type Extension } from "@codemirror/state"; +import { CONTRIBUTED_DEFINITION, type ContributedDefinition, type EditorDocContext, whenSynced } from "@/editor/api"; +import { showHint } from "@/editor/lsp/hint"; +import { tx } from "@/platform/i18n"; +import { call, commands } from "@/platform/ipc"; +import { editorService } from "@/workbench/contributions"; + +/** The `${property}` reference covering `at`, if any (clicking the `$` counts). */ +export function propertyRefAt(text: string, at: number): { from: number; to: number } | null { + const open = text.startsWith("${", at) ? at : text.lastIndexOf("${", at - 1); + if (open < 0) return null; + const close = text.indexOf("}", open + 2); + if (close < 0 || close < at) return null; + return { from: open, to: close + 1 }; +} + +/** Whether an opener of `name` before `at` has no closer of it yet (POM elements do not nest). */ +function inside(name: string, text: string, at: number): boolean { + const before = text.slice(0, at); + const count = (re: RegExp) => (before.match(re) ?? []).length; + return count(new RegExp(`<${name}[\\s>]`, "g")) > count(new RegExp(``, "g")); +} + +/** + * Whether `at` may sit in a `` / `` element: an eligibility test + * only (a `` inside a comment counts too) — whether the element's version + * actually comes from a management entry is the backend's answer. + */ +export function inArtifactElement(text: string, at: number): boolean { + return inside("dependency", text, at) || inside("plugin", text, at); +} + +/** Go to declaration in a POM of the project (or an opened parent POM of the local repository). */ +export function pomNavigationExtension(ctx: EditorDocContext): Extension { + if (ctx.virtual || !/(^|\/)pom\.xml$/.test(ctx.path)) return []; + const eligible = (state: EditorState, pos: number) => { + const text = state.doc.toString(); + return propertyRefAt(text, pos) !== null || inArtifactElement(text, pos); + }; + const definition: ContributedDefinition = (view, pos) => { + if (!eligible(view.state, pos)) return false; + const doc = view.state.doc; + const line = doc.lineAt(pos); + void whenSynced(view) + .then(() => + call( + commands.mavenGotoDeclaration(ctx.path, { + line: line.number - 1, + col: pos - line.from, + }), + ), + ) + .then((goto) => { + if (!goto) { + showHint(view, tx`Cannot find declaration to go to`); + return; + } + if (view.state.doc !== doc) return; // The user kept typing while the POM resolved. + void editorService().openFile(goto.path, { + selection: { + line: goto.range.start.line, + col: goto.range.start.col, + endLine: goto.range.end.line, + endCol: goto.range.end.col, + }, + }); + }) + .catch((e: unknown) => + showHint(view, e instanceof Error ? e.message : String(e), { + tone: "error", + }), + ); + return true; + }; + return EditorState.languageData.of((state, pos) => + eligible(state, pos) ? [{ [CONTRIBUTED_DEFINITION]: definition }] : [], + ); +}