feat(java): navigate ${...} placeholders to configuration files and Spring XML (#2010)

Closes #1658

Go to Declaration on a `${key}` placeholder inside YAML, properties or Spring XML now resolves the key to its definitions in the module's configuration files (profiles included, several targets open a chooser) and in Spring XML. Placeholders are parsed brace-balanced, so nested placeholders and defaults resolve at every position; configuration files come from the same module roots as the Java-side lookup; the file list is cached per root and refreshed on watched-file create/delete/rename. Ranges are UTF-16. Verified on macOS with a Spring Boot project.

Co-authored-by: Inf1mum <[email protected]>
Цей коміт міститься в:
Vadim LazukтаInf1mum authored and GitHub committed 2026-11-08 03:19:58 +08:00
1 джерело 4a78310f76
коміт 30a0521d0d
5 files changed
+614 -32

No files matched your search

+272
Переглянути файл
@@ -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("<prop") && !text.contains("placeholder") && !text.contains("Placeholder") {
return Ok(Vec::new());
}
return self.with_parsed(language, text, cancelled, |tree| tree.spring_xml_keys(cancelled));
}
self.with_parsed(language, text, cancelled, |tree| tree.configuration_keys(cancelled))
}
/// Name of the Spring `${name[:default]}` placeholder covering `pos` in
/// `text`, if the position lies anywhere inside its braces. The name stops
/// at the first `:` or `}`; inside a nested default (`${a:${b}}`) the inner
/// braces name `b`. Empty names (`${}`) name nothing.
pub fn configuration_placeholder(&self, text: &str, pos: Pos) -> Option<String> {
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: `<prop key="name">` values
/// (util:properties, configurer `<props>`) and `<property name="name"
/// value="...">` entries under a placeholder configurer bean. Elements that
/// only reference a property file (`<context:property-placeholder>`,
/// `<util:properties location="...">`) 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<ConfigurationKey> {
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 `<property name="key" value="...">` 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<ConfigurationKey>,
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<Node<'_>> = 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("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&apos;", "'").replace("&amp;", "&")
}
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}", "<bean value=\"你好😀 ${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#"<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans">
<context:property-placeholder location="classpath:db.properties"/>
<util:properties id="db">
<prop key="db.url">jdbc:h2:mem:test</prop>
</util:properties>
<bean class="org.springframework.beans.factory.config.PropertyPlaceholderConfigurer">
<property name="properties">
<props>
<prop key="db.user">sa</prop>
</props>
</property>
</bean>
<bean class="org.springframework.jdbc.datasource.DriverManagerDataSource">
<property name="url" value="jdbc:h2:mem"/>
</bean>
<bean class="org.springframework.beans.factory.config.PropertySourcesPlaceholderConfigurer">
<property name="system.mode" value="test"/>
</bean>
</beans>
"#;
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 `<property name="url">` 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 = "<mappers><mapper resource='a.xml'/><select id='x'>select 1</select></mappers>";
let keys = Syntax::new().configuration_keys(&LanguageId::new("xml"), text, &|| false).unwrap();
assert!(keys.is_empty());
}
}
+130 -31
Переглянути файл
@@ -20,6 +20,16 @@ const MAX_CACHED_KEYS: usize = 100_000;
#[derive(Default)]
pub(crate) struct ConfigurationCache {
files: HashMap<PathBuf, CachedFile>,
roots: HashMap<PathBuf, CachedPaths>,
}
struct CachedPaths {
paths: Vec<PathBuf>,
incomplete: Option<ReferencesIncomplete>,
}
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<LanguageId> {
match path.extension()?.to_str()? {
"yaml" | "yml" => Some(LanguageId::new("yaml")),
"properties" => Some(LanguageId::new("properties")),
// Spring XML wiring declares properties in `<prop key="...">` values
// and placeholder-configurer `<property name="...">` 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<String>,
placeholder: bool,
usages: bool,
include_declaration: bool,
cancel: &CancellationToken,
) -> IdeResult<Option<References>> {
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<PathBuf> = response.roots.iter().filter_map(|uri| super::project_path(uri)).collect();
let overlays: HashMap<PathBuf, String> = 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<Pattern>,
roots: &[PathBuf],
cancel: &CancellationToken,
) -> IdeResult<References> {
let overlays: HashMap<PathBuf, String> = 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();
}
+6
Переглянути файл
@@ -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<Arc<Server>> = 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<Arc<Server>> = self.servers.lock().clone();
let open: HashMap<(ide_core::ServerId, u64), HashSet<PathBuf>> = {
let docs = self.docs.lock();
+192
Переглянути файл
@@ -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"), "<project/>").unwrap();
let resources = h.root.join("src/main/resources");
std::fs::create_dir_all(&resources).unwrap();
std::fs::write(
resources.join("context.xml"),
r#"<beans><util:properties id="db"><prop key="db.user">sa</prop></util:properties></beans>"#,
)
.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"),
"<project><packaging>pom</packaging><modules><module>child</module></modules></project>",
)
.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"), "<project/>").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"), "<project/>").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", "<bean value=\"你好😀 ${db.user}\"/>"),
] {
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"), "<project/>").unwrap();
let child = h.root.join("child");
std::fs::create_dir(&child).unwrap();
std::fs::write(child.join("pom.xml"), "<project/>").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"), "<project/>").unwrap();
std::fs::write(h.root.join("context.xml"), r#"<beans><prop key="db.user">sa</prop></beans>"#).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"), "<project/>").unwrap();
std::fs::write(h.root.join("context.xml"), r#"<beans><prop key="db.user">sa</prop></beans>"#).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"), "<project/>").unwrap();
std::fs::write(h.root.join("context.xml"), r#"<beans><prop key="spring.datasource.username">sa</prop></beans>"#)
.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"), "<project/>").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, "<beans><prop key=\"db.user\">sa</prop></beans>\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();
+14 -1
Переглянути файл
@@ -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
(`<prop key="...">` values and placeholder-configurer `<property name="..."
value="...">` 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 `<prop key="...">` values and placeholder-configurer
`<property name="..." value="...">` entries; XML is parsed with the bundled
tree-sitter grammar and files without placeholder markers are skipped without
parsing. Keys referenced by `<context:property-placeholder location="...">` or
`<util:properties location="...">` 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