дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
4a78310f76
коміт
30a0521d0d
5 files changed
+614
-32
No files matched your search
@@ -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("<", "<").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}", "<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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Заблокувати користувача