diff --git a/crates/ide-language/src/syntax/configuration.rs b/crates/ide-language/src/syntax/configuration.rs index 4a1a081f0..85b19e244 100644 --- a/crates/ide-language/src/syntax/configuration.rs +++ b/crates/ide-language/src/syntax/configuration.rs @@ -22,8 +22,49 @@ impl Syntax { if language.as_str() == "properties" { return Ok(properties(text)); } + if language.as_str() == "xml" { + // Only Spring wiring that mentions property placeholders can declare + // a configuration key; skip parsing unrelated XML (POMs, mappers, + // manifests) during project scans. + if !text.contains(" Option { + let at = byte_at(text, pos)?; + let mut braces = Vec::new(); + let mut best: Option<(usize, usize)> = None; + for (i, byte) in text.bytes().enumerate() { + match byte { + b'{' => braces.push((i, None, i > 0 && text.as_bytes()[i - 1] == b'$')), + b':' => { + if let Some((_, end, true)) = braces.last_mut() { + end.get_or_insert(i); + } + } + b'}' => { + if let Some((open, end, true)) = braces.pop() { + let open = open - 1; + if open <= at && at <= i && best.is_none_or(|(previous, _)| open > previous) { + best = Some((open, end.unwrap_or(i))); + } + } + } + _ => {} + } + } + let (open, end) = best?; + let name = text[open + 2..end].trim(); + (!name.is_empty()).then(|| name.to_owned()) + } } /// Spring's property lookups whose first argument names a configuration key. @@ -68,6 +109,134 @@ impl ParsedTree<'_> { } out } + + /// Spring property declarations in XML wiring: `` values + /// (util:properties, configurer ``) and `` entries under a placeholder configurer bean. Elements that + /// only reference a property file (``, + /// ``) declare no key of their own; the + /// configuration scan finds the keys inside the referenced files. + pub fn spring_xml_keys(&self, cancelled: &dyn Fn() -> bool) -> Vec { + let mut out = Vec::new(); + spring_xml(self.tree.root_node(), self.text, &LineIndex::new(self.text), &mut out, cancelled, 0, false); + out + } +} + +/// Bean classes whose `` entries feed +/// placeholder resolution (the placeholder configurers). +fn placeholder_bean(class: &str) -> bool { + class.to_ascii_lowercase().contains("placeholder") +} + +fn local_name(tag: &str) -> &str { + tag.rsplit(':').next().unwrap_or(tag) +} + +fn spring_xml( + node: Node<'_>, + text: &str, + lines: &LineIndex<'_>, + out: &mut Vec, + cancelled: &dyn Fn() -> bool, + depth: usize, + configurer: bool, +) { + if depth > 64 || cancelled() || out.len() >= 20_000 { + return; + } + if node.kind() != "element" { + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + spring_xml(child, text, lines, out, cancelled, depth + 1, configurer); + } + return; + } + let mut cursor = node.walk(); + let children: Vec<_> = node.named_children(&mut cursor).collect(); + let mut tag = ""; + let mut inside = configurer; + let mut content: Vec> = Vec::new(); + for child in children { + match child.kind() { + "STag" | "EmptyElemTag" => { + let mut attrs_cursor = child.walk(); + let attrs: Vec<_> = child.named_children(&mut attrs_cursor).collect(); + let mut attributes: Vec<(&str, String, Range)> = Vec::new(); + for (i, attr) in attrs.iter().enumerate() { + if i == 0 && attr.kind() == "Name" { + tag = local_name(&text[attr.byte_range()]); + continue; + } + if attr.kind() != "Attribute" { + continue; + } + let mut parts_cursor = attr.walk(); + let mut name = ""; + let mut value = None; + for part in attr.named_children(&mut parts_cursor) { + match part.kind() { + "Name" => name = &text[part.byte_range()], + "AttValue" => value = Some(quoted_value(part, text, lines)), + _ => {} + } + } + if let Some((value, range)) = value { + attributes.push((name, value, range)); + } + } + let attribute = |wanted: &str| attributes.iter().find(|(name, _, _)| *name == wanted); + if tag == "prop" + && let Some((_, key, range)) = attribute("key") + && !key.is_empty() + && key.len() <= 1024 + { + out.push(ConfigurationKey { name: key.clone(), range: *range }); + } + if tag == "property" + && configurer + && let (Some((_, name, range)), Some(_)) = (attribute("name"), attribute("value")) + && !name.is_empty() + && name.len() <= 1024 + { + out.push(ConfigurationKey { name: name.clone(), range: *range }); + } + if tag == "bean" + && let Some((_, class, _)) = attribute("class") + { + inside = placeholder_bean(class); + } + } + "content" => content.extend(child.named_children(&mut cursor).filter(|n| n.kind() == "element")), + // Error recovery can nest elements without a `content` node. + "element" => content.push(child), + _ => {} + } + } + for element in content { + spring_xml(element, text, lines, out, cancelled, depth + 1, inside); + } +} + +/// The decoded text of an `AttValue` and the range of its quoted contents. +fn quoted_value(node: Node<'_>, text: &str, lines: &LineIndex<'_>) -> (String, Range) { + let raw = &text[node.byte_range()]; + let quoted = raw.len() >= 2 + && ((raw.starts_with('"') && raw.ends_with('"')) || (raw.starts_with('\'') && raw.ends_with('\''))); + let (value, range) = if quoted { + (&raw[1..raw.len() - 1], lines.range(node.start_byte() + 1, node.end_byte() - 1)) + } else { + (raw, lines.range(node.start_byte(), node.end_byte())) + }; + (unescape_xml(value), range) +} + +/// XML's five predefined entities; unknown references keep their text. +fn unescape_xml(value: &str) -> String { + if !value.contains('&') { + return value.to_owned(); + } + value.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&") } fn yaml( @@ -317,4 +486,107 @@ class A { ); assert_eq!(keys[0].range.end.line, 2); } + #[test] + fn placeholder_names_cover_the_whole_reference() { + let text = "name: ${db.url:8080}\nplain: value\nempty: ${}\nnested: ${a:${b.c}}\nspaced: ${ db.h }\n"; + let syntax = Syntax::new(); + let at = |needle: &str, offset: u32| { + let line = text.lines().position(|l| l.contains(needle)).unwrap(); + let line_text = text.lines().nth(line).unwrap(); + let byte = line_text.find(needle).unwrap(); + let col = line_text[..byte].encode_utf16().count() as u32 + offset; + syntax.configuration_placeholder(text, Pos::new(line as u32, col)) + }; + // On the `$`, inside the name and on the default all name `db.url`. + assert_eq!(at("${db.url", 0), Some("db.url".into())); + assert_eq!(at("db.url", 1), Some("db.url".into())); + assert_eq!(at("8080", 1), Some("db.url".into())); + assert_eq!(at("plain", 1), None); + assert_eq!(at("empty", 3), None); + // A nested default names its own property; the outer braces name `a`. + assert_eq!(at("${b.c", 2), Some("b.c".into())); + assert_eq!(at("${a:", 2), Some("a".into())); + assert_eq!(at("db.h", 1), Some("db.h".into())); + assert_eq!(syntax.configuration_placeholder("no placeholder", Pos::new(0, 3)), None); + assert_eq!(syntax.configuration_placeholder("trailing ${open", Pos::new(0, 12)), None); + } + #[test] + fn nested_placeholder_defaults_are_brace_balanced() { + let syntax = Syntax::new(); + let text = "${outer:${inner}suffix}"; + for col in 0..text.len() { + let expected = if (8..=15).contains(&col) { "inner" } else { "outer" }; + assert_eq!( + syntax.configuration_placeholder(text, Pos::new(0, col as u32)).as_deref(), + Some(expected), + "column {col}" + ); + } + let text = "${outer:${middle:${inner}}suffix}"; + for (needle, expected) in [("outer", "outer"), ("middle", "middle"), ("inner", "inner"), ("suffix", "outer")] { + assert_eq!( + syntax.configuration_placeholder(text, Pos::new(0, text.find(needle).unwrap() as u32)).as_deref(), + Some(expected) + ); + } + assert_eq!( + syntax.configuration_placeholder("${outer:{default}suffix}", Pos::new(0, 19)).as_deref(), + Some("outer") + ); + assert_eq!(syntax.configuration_placeholder("${outer:${inner}suffix", Pos::new(0, 18)), None); + } + + #[test] + fn configuration_placeholders_use_utf16_columns() { + let syntax = Syntax::new(); + for text in ["message=你好😀 ${db.user}", "message: 你好😀 ${db.user}", ""] + { + let col = text[..text.find("db.user").unwrap()].encode_utf16().count() as u32; + assert_eq!(syntax.configuration_placeholder(text, Pos::new(0, col + 2)).as_deref(), Some("db.user")); + } + } + + #[test] + fn spring_xml_declares_prop_and_configurer_property_keys() { + let text = r#" + + + + jdbc:h2:mem:test + + + + + sa + + + + + + + + + + +"#; + let keys = Syntax::new().configuration_keys(&LanguageId::new("xml"), text, &|| false).unwrap(); + let names: Vec<_> = keys.iter().map(|k| k.name.as_str()).collect(); + // The configurer's own `properties` injection names no property, and a + // plain bean's `` is not an environment property. + assert_eq!(names, ["db.url", "db.user", "system.mode"]); + // The range covers the quoted key attribute's value. + let line = text.lines().position(|l| l.contains("db.url")).unwrap() as u32; + let start = keys[0].range.start; + assert_eq!((start.line, keys[0].range.end.line), (line, line)); + assert_eq!( + &text.lines().nth(line as usize).unwrap()[start.col as usize..keys[0].range.end.col as usize], + "db.url" + ); + } + #[test] + fn xml_without_property_markers_is_not_parsed() { + let text = ""; + let keys = Syntax::new().configuration_keys(&LanguageId::new("xml"), text, &|| false).unwrap(); + assert!(keys.is_empty()); + } } diff --git a/crates/ide-lsp/src/jdtls/configuration.rs b/crates/ide-lsp/src/jdtls/configuration.rs index 6201f05d1..ea420e6e7 100644 --- a/crates/ide-lsp/src/jdtls/configuration.rs +++ b/crates/ide-lsp/src/jdtls/configuration.rs @@ -20,6 +20,16 @@ const MAX_CACHED_KEYS: usize = 100_000; #[derive(Default)] pub(crate) struct ConfigurationCache { files: HashMap, + roots: HashMap, +} +struct CachedPaths { + paths: Vec, + incomplete: Option, +} +impl ConfigurationCache { + pub(crate) fn invalidate_paths(&mut self) { + self.roots.clear(); + } } struct CachedFile { stamp: (SystemTime, u64), @@ -49,6 +59,9 @@ fn language(path: &Path) -> Option { match path.extension()?.to_str()? { "yaml" | "yml" => Some(LanguageId::new("yaml")), "properties" => Some(LanguageId::new("properties")), + // Spring XML wiring declares properties in `` values + // and placeholder-configurer `` entries. + "xml" => Some(LanguageId::new("xml")), _ => None, } } @@ -158,6 +171,16 @@ impl LspManager { } else { None }; + // A click inside a Spring `${name}` placeholder sits on a value, not a + // key: the name names the property whose declaration lives in + // configuration files or Spring XML, with Java binding members still + // answering through the Java index. + let placeholder = if config && key.is_none() && !usages && snap.text.as_str().len() as u64 <= MAX_BYTES { + syntax().configuration_placeholder(snap.text.as_str(), pos) + } else { + None + }; + let key = placeholder.clone().or(key); if config && key.is_none() { return Ok(None); } @@ -178,7 +201,8 @@ impl LspManager { return Ok(None); } } - let result = self.configuration_query(snap, pos, key, usages, include_declaration, cancel).await; + let result = + self.configuration_query(snap, pos, key, placeholder.is_some(), usages, include_declaration, cancel).await; match result { Err(e) if cancel.is_cancelled() || e.kind == ErrorKind::Cancelled => Err(IdeError::cancelled()), Err(e) => { @@ -189,22 +213,63 @@ impl LspManager { } } + #[allow(clippy::too_many_arguments)] async fn configuration_query( &self, snap: &DocSnap, pos: Pos, key: Option, + placeholder: bool, usages: bool, include_declaration: bool, cancel: &CancellationToken, ) -> IdeResult> { - let (server, run) = self.jdtls(&snap.path, cancel).await?; + // A `${name}` reference resolves from configuration files and Spring + // XML without Java; those declarations are collected first so + // navigation survives an unavailable or outdated Java extension. + let local = if placeholder { + let name = key.clone().unwrap_or_default(); + let path = snap.path.clone(); + let project_root = self.project_root.clone(); + let root = tokio::task::spawn_blocking(move || { + path.parent() + .into_iter() + .flat_map(Path::ancestors) + .take_while(|p| p.starts_with(&project_root)) + .find(|p| { + ["pom.xml", "build.gradle", "build.gradle.kts", ".classpath"] + .iter() + .any(|name| p.join(name).is_file()) + }) + .map(Path::to_path_buf) + .unwrap_or(project_root) + }) + .await + .map_err(|_| IdeError::unavailable("configuration search worker stopped"))?; + Some( + self.configuration_declarations( + vec![Pattern { name, relaxed: true }], + std::slice::from_ref(&root), + cancel, + ) + .await?, + ) + } else { + None + }; + let answered = local.as_ref().is_some_and(|local| !local.locations.is_empty()); + let (server, run) = match self.jdtls(&snap.path, cancel).await { + Ok(found) => found, + Err(e) if cancel.is_cancelled() || e.kind == ErrorKind::Cancelled => return Err(IdeError::cancelled()), + Err(_) if answered => return Ok(local), + Err(e) => return Err(e), + }; if !run.caps.read().commands().iter().any(|command| command == COMMAND) { - return Ok(None); + return Ok(if placeholder { local } else { None }); } let target = Target { server: server.clone(), run }; let position = snap.text.ide_to_lsp(Encoding::Utf16, pos); - let response: Response = self + let response: Response = match self .call( &target, "workspace/executeCommand", @@ -214,11 +279,17 @@ impl LspManager { Some(cancel), crate::LONG_TIMEOUT, ) - .await?; + .await + { + Ok(response) => response, + Err(e) if cancel.is_cancelled() || e.kind == ErrorKind::Cancelled => return Err(IdeError::cancelled()), + Err(_) if answered => return Ok(local), + Err(e) => return Err(e), + }; if cancel.is_cancelled() { return Err(IdeError::cancelled()); } - if response.keys.is_empty() { + if response.keys.is_empty() && !placeholder { return Ok(response .incomplete .then(|| References { incomplete: Some(ReferencesIncomplete::FilesSkipped), ..Default::default() })); @@ -229,22 +300,18 @@ impl LspManager { ..Default::default() }; self.note_virtual(&server, result.locations.iter().map(|l| l.uri.as_deref())); - if key.is_none() || usages { + if let Some(local) = local { + result.locations.extend(local.locations); + result.incomplete = result.incomplete.or(local.incomplete); + } + if key.is_none() || usages || placeholder { let roots: Vec = response.roots.iter().filter_map(|uri| super::project_path(uri)).collect(); - let overlays: HashMap = self - .docs - .lock() - .values() - .filter(|d| language(&d.path).is_some() && d.text.as_str().len() as u64 <= MAX_BYTES) - .map(|d| (d.canonical.clone(), d.text.as_str().to_owned())) - .collect(); - let cache = self.configuration_cache.clone(); - let cancelled = cancel.clone(); - let keys = response.keys; - let files = - tokio::task::spawn_blocking(move || configuration_files(&roots, &keys, &overlays, &cache, &cancelled)) - .await - .map_err(|_| IdeError::unavailable("configuration search worker stopped"))?; + let patterns = if placeholder { + vec![Pattern { name: key.clone().unwrap_or_default(), relaxed: true }] + } else { + response.keys + }; + let files = self.configuration_declarations(patterns, &roots, cancel).await?; result.locations.extend(files.locations); result.incomplete = result.incomplete.or(files.incomplete); } @@ -266,6 +333,29 @@ impl LspManager { crate::convert::dedup_locations(&mut result.locations); Ok(Some(result)) } + + /// Configuration-file and Spring-XML declarations matching `patterns` under + /// `roots`, with open editor contents taking precedence over disk. + async fn configuration_declarations( + &self, + patterns: Vec, + roots: &[PathBuf], + cancel: &CancellationToken, + ) -> IdeResult { + let overlays: HashMap = self + .docs + .lock() + .values() + .filter(|d| language(&d.path).is_some() && d.text.as_str().len() as u64 <= MAX_BYTES) + .map(|d| (d.canonical.clone(), d.text.as_str().to_owned())) + .collect(); + let cache = self.configuration_cache.clone(); + let cancelled = cancel.clone(); + let roots = roots.to_vec(); + tokio::task::spawn_blocking(move || configuration_files(&roots, &patterns, &overlays, &cache, &cancelled)) + .await + .map_err(|_| IdeError::unavailable("configuration search worker stopped")) + } } fn in_module(root: &Path, path: &Path) -> bool { @@ -296,11 +386,15 @@ fn configuration_files( ) -> References { let mut result = References::default(); let mut paths = std::collections::BTreeSet::new(); - // Do not scan a source root twice when its enclosing module is also present. - let roots: Vec<_> = - roots.iter().filter(|root| !roots.iter().any(|other| other != *root && root.starts_with(other))).collect(); - for root in &roots { - let module = (*root).clone(); + let mut cache = cache.lock(); + for root in roots { + if let Some(cached) = cache.roots.get(root) { + paths.extend(cached.paths.iter().cloned()); + result.incomplete = result.incomplete.or(cached.incomplete); + continue; + } + let mut found = CachedPaths { paths: Vec::new(), incomplete: None }; + let module = root.clone(); let walker = ignore::WalkBuilder::new(root) .follow_links(false) .filter_entry(move |entry| { @@ -324,21 +418,26 @@ fn configuration_files( if cancel.is_cancelled() { return result; } - if visited >= 50_000 || paths.len() >= MAX_FILES { - result.incomplete = Some(ReferencesIncomplete::TooManyFiles); + if visited >= 50_000 || found.paths.len() >= MAX_FILES { + found.incomplete = Some(ReferencesIncomplete::TooManyFiles); break; } match entry { Ok(entry) if entry.file_type().is_some_and(|t| t.is_file()) && language(entry.path()).is_some() => { - paths.insert(entry.into_path()); + found.paths.push(entry.into_path()); } - Err(_) => result.incomplete = Some(ReferencesIncomplete::FilesSkipped), + Err(_) => found.incomplete = Some(ReferencesIncomplete::FilesSkipped), _ => {} } } + paths.extend(found.paths.iter().cloned()); + result.incomplete = result.incomplete.or(found.incomplete); + if cache.roots.len() >= MAX_FILES { + cache.roots.clear(); + } + cache.roots.insert(root.clone(), found); } paths.extend(overlays.keys().filter(|p| roots.iter().any(|r| in_module(r, p))).cloned()); - let mut cache = cache.lock(); if cache.files.len() > MAX_FILES { cache.files.clear(); } diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 85e61a2fd..f20de93c7 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -2314,6 +2314,9 @@ impl LspManager { /// Tells running servers that files or folders moved (`moves`: from → to, after the move). pub fn did_rename_files(&self, moves: &[(PathBuf, PathBuf)]) { + if !moves.is_empty() { + self.configuration_cache.lock().invalidate_paths(); + } let moves: Vec<(PathBuf, PathBuf, bool)> = moves.iter().map(|(f, t)| (f.clone(), t.clone(), t.is_dir())).collect(); let servers: Vec> = self.servers.lock().clone(); @@ -2349,6 +2352,9 @@ impl LspManager { excluded_dirs: &[PathBuf], ) { use file_watch::Change; + if !created.is_empty() || !removed.is_empty() || !excluded_dirs.is_empty() { + self.configuration_cache.lock().invalidate_paths(); + } let servers: Vec> = self.servers.lock().clone(); let open: HashMap<(ide_core::ServerId, u64), HashSet> = { let docs = self.docs.lock(); diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 2da972ad2..be25b92fb 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -4387,6 +4387,198 @@ async fn configuration_extension_failure_preserves_native_java_navigation() { assert_eq!(s.requested("textDocument/definition").len(), 1); } +#[tokio::test(flavor = "multi_thread")] +async fn configuration_placeholder_definition_lands_in_spring_xml_and_properties() { + let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]); + let s = h.launcher.state("jdtls"); + std::fs::write(h.root.join("pom.xml"), "").unwrap(); + let resources = h.root.join("src/main/resources"); + std::fs::create_dir_all(&resources).unwrap(); + std::fs::write( + resources.join("context.xml"), + r#"sa"#, + ) + .unwrap(); + std::fs::write(h.root.join("db.properties"), "db.user=root\n").unwrap(); + let config = h.root.join("application.yml"); + let doc = DocId::next(); + h.mgr.did_open(doc, &config, &LanguageId::new("yaml"), 1, "spring:\n datasource:\n username: ${db.user}\n"); + let locations = h.mgr.goto(doc, Pos::new(2, 16), GotoKind::Definition).await.unwrap(); + // The Java extension is not needed: the declarations come from the XML + // wiring and the properties file, and the open YAML never matches itself. + assert_eq!(locations.len(), 2, "{locations:?}"); + assert!(locations.iter().any(|l| l.path.to_path_buf() == resources.join("context.xml"))); + assert!(locations.iter().any(|l| l.path.to_path_buf() == h.root.join("db.properties"))); + assert!(s.requested("workspace/executeCommand").is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn configuration_placeholders_use_java_module_roots_and_utf16_columns() { + let mut caps = incremental_caps(); + caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]}); + let h = harness_for("java", "java", &[("jdtls", caps)], &[]); + std::fs::write( + h.root.join("pom.xml"), + "pomchild", + ) + .unwrap(); + let child = h.root.join("child"); + let resources = child.join("src/main/resources"); + std::fs::create_dir_all(&resources).unwrap(); + std::fs::write(child.join("pom.xml"), "").unwrap(); + let definition = resources.join("application.properties"); + std::fs::write(&definition, "db.user=sa\n").unwrap(); + let sibling = h.root.join("sibling"); + std::fs::create_dir(&sibling).unwrap(); + std::fs::write(sibling.join("pom.xml"), "").unwrap(); + std::fs::write(sibling.join("application.properties"), "db.user=wrong\n").unwrap(); + h.launcher.state("jdtls").respond("workspace/executeCommand", json!({ + "locations": [], "keys": [], "roots": [uri_of(&h.root), uri_of(&child), uri_of(&child.join("src/main/java"))] + })); + for (file, language, text) in [ + ("references.properties", "properties", "message=你好😀 ${db.user}"), + ("references.yml", "yaml", "message: 你好😀 ${db.user}"), + ("context.xml", "xml", ""), + ] { + let doc = DocId::next(); + h.mgr.did_open(doc, &h.root.join(file), &LanguageId::new(language), 1, text); + let col = text[..text.find("db.user").unwrap()].encode_utf16().count() as u32; + let locations = h.mgr.goto(doc, Pos::new(0, col + 2), GotoKind::Definition).await.unwrap(); + assert_eq!(locations.len(), 1, "{file}: {locations:?}"); + assert_eq!(locations[0].path.to_path_buf(), definition); + assert_eq!(locations[0].range.start, Pos::new(0, 0)); + assert_eq!(locations[0].range.end, Pos::new(0, 7)); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn configuration_file_list_refreshes_on_watched_changes_and_renames() { + let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]); + std::fs::write(h.root.join("pom.xml"), "").unwrap(); + let child = h.root.join("child"); + std::fs::create_dir(&child).unwrap(); + std::fs::write(child.join("pom.xml"), "").unwrap(); + let file = child.join("application.properties"); + std::fs::write(&file, "db.user=sa\n").unwrap(); + let doc = DocId::next(); + h.mgr.did_open(doc, &child.join("reference.yml"), &LanguageId::new("yaml"), 1, "message: ${db.user}"); + assert_eq!(h.mgr.goto(doc, Pos::new(0, 13), GotoKind::Definition).await.unwrap().len(), 1); + let profile = child.join("application-dev.properties"); + std::fs::write(&profile, "db.user=dev\n").unwrap(); + // No notification yet: the existing file list is reused. + assert_eq!(h.mgr.goto(doc, Pos::new(0, 13), GotoKind::Definition).await.unwrap().len(), 1); + h.mgr.watched_files_changed(std::slice::from_ref(&profile), &[], &[], &[]); + assert_eq!(h.mgr.goto(doc, Pos::new(0, 13), GotoKind::Definition).await.unwrap().len(), 2); + std::fs::write(&profile, "db.user=development\n").unwrap(); + h.mgr.watched_files_changed(&[], std::slice::from_ref(&profile), &[], &[]); + assert_eq!(h.mgr.goto(doc, Pos::new(0, 13), GotoKind::Definition).await.unwrap().len(), 2); + let renamed = child.join("application-test.properties"); + std::fs::rename(&profile, &renamed).unwrap(); + h.mgr.did_rename_files(&[(profile, renamed.clone())]); + let locations = h.mgr.goto(doc, Pos::new(0, 13), GotoKind::Definition).await.unwrap(); + assert_eq!(locations.len(), 2); + assert!(locations.iter().any(|l| l.path.to_path_buf() == renamed)); + std::fs::remove_file(&renamed).unwrap(); + h.mgr.watched_files_changed(&[], &[], &[renamed], &[]); + assert_eq!(h.mgr.goto(doc, Pos::new(0, 13), GotoKind::Definition).await.unwrap().len(), 1); +} + +#[tokio::test(flavor = "multi_thread")] +async fn configuration_placeholder_definition_merges_java_members_with_xml_declarations() { + let mut caps = incremental_caps(); + caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]}); + let h = harness_for("java", "java", &[("jdtls", caps)], &[]); + std::fs::write(h.root.join("pom.xml"), "").unwrap(); + std::fs::write(h.root.join("context.xml"), r#"sa"#).unwrap(); + let s = h.launcher.state("jdtls"); + let target = uri_of(&h.root.join("Db.java")); + s.respond( + "workspace/executeCommand", + json!({ + "locations": [{"uri": target, "range": {"start": {"line": 1, "character": 4}, "end": {"line": 1, "character": 8}}}], + "keys": [{"name": "db.user", "relaxed": true}], "roots": [] + }), + ); + let config = h.root.join("application.yml"); + let doc = DocId::next(); + h.mgr.did_open(doc, &config, &LanguageId::new("yaml"), 1, "spring:\n datasource:\n username: ${db.user}\n"); + let locations = h.mgr.goto(doc, Pos::new(2, 16), GotoKind::Definition).await.unwrap(); + // A bound Java member and the XML declaration are both candidates. + assert_eq!(locations.len(), 2, "{locations:?}"); + assert!(locations.iter().any(|l| l.range.start == Pos::new(1, 4))); + assert!(locations.iter().any(|l| l.path.to_path_buf() == h.root.join("context.xml"))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn configuration_placeholder_definition_survives_a_failing_java_extension() { + let mut caps = incremental_caps(); + caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]}); + let h = harness_for("java", "java", &[("jdtls", caps)], &[]); + std::fs::write(h.root.join("pom.xml"), "").unwrap(); + std::fs::write(h.root.join("context.xml"), r#"sa"#).unwrap(); + let s = h.launcher.state("jdtls"); + s.errors.lock().insert("workspace/executeCommand".into(), (-32603, "old server".into())); + let config = h.root.join("application.yml"); + let doc = DocId::next(); + h.mgr.did_open(doc, &config, &LanguageId::new("yaml"), 1, "spring:\n datasource:\n username: ${db.user}\n"); + let locations = h.mgr.goto(doc, Pos::new(2, 16), GotoKind::Definition).await.unwrap(); + // The XML declaration still answers when the Java extension fails. + assert_eq!(locations.len(), 1, "{locations:?}"); + assert!(locations.iter().any(|l| l.path.to_path_buf() == h.root.join("context.xml"))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn configuration_key_definition_stays_java_only() { + let mut caps = incremental_caps(); + caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]}); + let h = harness_for("java", "java", &[("jdtls", caps)], &[]); + std::fs::write(h.root.join("pom.xml"), "").unwrap(); + std::fs::write(h.root.join("context.xml"), r#"sa"#) + .unwrap(); + let s = h.launcher.state("jdtls"); + let target = uri_of(&h.root.join("Settings.java")); + s.respond( + "workspace/executeCommand", + json!({ + "locations": [{"uri": target, "range": {"start": {"line": 2, "character": 4}, "end": {"line": 2, "character": 11}}}], + "keys": [{"name": "spring.datasource.username", "relaxed": true}], "roots": [] + }), + ); + let config = h.root.join("application.yml"); + let doc = DocId::next(); + h.mgr.did_open(doc, &config, &LanguageId::new("yaml"), 1, "spring:\n datasource:\n username: sa\n"); + let locations = h.mgr.goto(doc, Pos::new(2, 6), GotoKind::Definition).await.unwrap(); + // A key keeps the Java-only definition path; the XML declaration is not a candidate. + assert_eq!(locations.len(), 1, "{locations:?}"); + assert_eq!(locations[0].range.start, Pos::new(2, 4)); + assert_eq!(s.requested("workspace/executeCommand")[0]["arguments"][1], "spring.datasource.username"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn configuration_definition_on_an_xml_property_asks_java() { + let mut caps = incremental_caps(); + caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]}); + let h = harness_for("java", "java", &[("jdtls", caps)], &[]); + std::fs::write(h.root.join("pom.xml"), "").unwrap(); + let s = h.launcher.state("jdtls"); + let target = uri_of(&h.root.join("Db.java")); + s.respond( + "workspace/executeCommand", + json!({ + "locations": [{"uri": target, "range": {"start": {"line": 1, "character": 4}, "end": {"line": 1, "character": 8}}}], + "keys": [{"name": "db.user", "relaxed": true}], "roots": [] + }), + ); + let xml = h.root.join("context.xml"); + let doc = DocId::next(); + h.mgr.did_open(doc, &xml, &LanguageId::new("xml"), 1, "sa\n"); + let locations = h.mgr.goto(doc, Pos::new(0, 19), GotoKind::Definition).await.unwrap(); + // A Spring XML declaration is a configuration site: it navigates to the + // binding member through the Java index. + assert_eq!(locations.len(), 1, "{locations:?}"); + assert_eq!(s.requested("workspace/executeCommand")[0]["arguments"][1], "db.user"); +} + #[tokio::test(flavor = "multi_thread")] async fn configuration_prewarm_starts_java_without_an_open_editor_and_waits_for_import() { let mut caps = incremental_caps(); diff --git a/java/java-usages/CONFIGURATION.md b/java/java-usages/CONFIGURATION.md index 24f49b50f..5b4371ede 100644 --- a/java/java-usages/CONFIGURATION.md +++ b/java/java-usages/CONFIGURATION.md @@ -9,6 +9,12 @@ connect to external configuration services. - **Go to Definition** on a YAML / properties key opens binding members and literal consumers in Java. Multiple matches use the existing location chooser. +- **Go to Definition** inside a Spring `${name}` placeholder in YAML, + properties or Spring XML opens matching declarations: configuration keys in + other `.properties` / YAML files, properties declared by Spring XML wiring + (`` values and placeholder-configurer `` entries), and bound Java members. Ordinary YAML key definition + navigation remains native. - **Go to Definition** inside a Java `@Value` placeholder or a constant `Environment.getProperty` / `getRequiredProperty` argument opens matching configuration keys. Ordinary Java definition navigation remains native. @@ -41,7 +47,14 @@ The Java command resolves annotation and method identities with JDT bindings: YAML key ranges come from the bundled tree-sitter grammar, including nested/flow mappings, sequences, quoted keys and multiple documents. Properties support -escaped keys, Unicode escapes and logical-line continuations. Ranges crossing +escaped keys, Unicode escapes and logical-line continuations. Spring XML wiring +declares properties through `` values and placeholder-configurer +`` entries; XML is parsed with the bundled +tree-sitter grammar and files without placeholder markers are skipped without +parsing. Keys referenced by `` or +`` live in the referenced files, which the +configuration scan indexes directly — the XML element itself is not offered as a +declaration. Ranges crossing IPC are UTF-16. No new IPC type or frontend provider is required. ## Scope and costs