From d981603f3c9b7b618d745a3514256f5f63d18339 Mon Sep 17 00:00:00 2001 From: qiaojianlong <109515705+qjlqyf@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:23:09 +0800 Subject: [PATCH] feat(java): navigate Spring configuration keys and usages (#823) YAML/properties keys stayed inside the generic YAML language server, so Spring configuration bindings and their consumers were unreachable from the editor. A new command in the bundled JDT LS extension (java-usages 1.2.0) resolves keys to @ConfigurationProperties members (prefixes, nested/inherited members, records, constructor aliases, collections), @Value placeholders and PropertyResolver calls, and back from Java to configuration entries; Find Usages merges configuration consumers and declarations with the native Java results. Several profiles or modules are offered as candidates. Closes #796. Co-authored-by: qjlqyf <109515705+qjlqyf@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- .../ide-agent/src/native/claude/translate.rs | 2 +- crates/ide-host/src/data_dirs.rs | 2 + crates/ide-language/src/lib.rs | 6 +- .../ide-language/src/syntax/configuration.rs | 242 ++++++++ crates/ide-language/src/syntax/mod.rs | 2 + crates/ide-lsp/src/jdtls.rs | 1 + crates/ide-lsp/src/jdtls/configuration.rs | 416 ++++++++++++++ crates/ide-lsp/src/lib.rs | 49 +- crates/ide-lsp/tests/mock_server.rs | 89 +++ crates/ide-sdk/src/java.rs | 2 +- crates/ide-syntax/src/jvm.rs | 2 +- plugins/java-usages/CONFIGURATION.md | 114 ++++ plugins/java-usages/MANIFEST.MF | 4 +- plugins/java-usages/README.md | 10 +- plugins/java-usages/openidea-java-usages.jar | Bin 31030 -> 82033 bytes plugins/java-usages/plugin.xml | 3 + .../configuration/ConfigurationCommand.java | 525 ++++++++++++++++++ plugins/java-usages/tests/configuration.py | 220 ++++++++ 18 files changed, 1674 insertions(+), 15 deletions(-) create mode 100644 crates/ide-language/src/syntax/configuration.rs create mode 100644 crates/ide-lsp/src/jdtls/configuration.rs create mode 100644 plugins/java-usages/CONFIGURATION.md create mode 100644 plugins/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java create mode 100644 plugins/java-usages/tests/configuration.py diff --git a/crates/ide-agent/src/native/claude/translate.rs b/crates/ide-agent/src/native/claude/translate.rs index ac8158f01..066be5f03 100644 --- a/crates/ide-agent/src/native/claude/translate.rs +++ b/crates/ide-agent/src/native/claude/translate.rs @@ -169,7 +169,7 @@ impl Translator { Some("tool_use") => { // The input streams after this: shown by name until the message has it. let (id, name) = (str_of(&block, "id"), str_of(&block, "name")); - if !id.is_empty() && !tools::is_plan(&name) && !(tools::is_agent(&name) && self.subagents) { + if !(id.is_empty() || tools::is_plan(&name) || tools::is_agent(&name) && self.subagents) { out.extend(self.ensure_call(&id, &name, &json!({}), &session)); } } diff --git a/crates/ide-host/src/data_dirs.rs b/crates/ide-host/src/data_dirs.rs index 9c96c4153..b76b168cc 100644 --- a/crates/ide-host/src/data_dirs.rs +++ b/crates/ide-host/src/data_dirs.rs @@ -57,6 +57,7 @@ fn migrate_entries(data: &Path, local: &Path) { "notebook-runtime", "server", "java-usages-1.1.1.jar", + "java-usages-1.2.0.jar", "agents/registry.json", ] .into_iter() @@ -298,6 +299,7 @@ mod tests { "agents/tools", "agents/registry.json", "java-usages-1.1.1.jar", + "java-usages-1.2.0.jar", ] { assert!(is_local(Path::new(entry)), "{entry}"); assert_eq!( diff --git a/crates/ide-language/src/lib.rs b/crates/ide-language/src/lib.rs index 908e8315c..bd4fa6d59 100644 --- a/crates/ide-language/src/lib.rs +++ b/crates/ide-language/src/lib.rs @@ -15,9 +15,9 @@ mod syntax; pub use markers::marked_root; pub use registry::{FileTypes, LanguageRegistry}; pub use syntax::{ - CSS_CLASS, CSS_ID, CSS_VARIABLE, FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES, - ParsedTree, SpellingKind, SpellingRegion, Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, - SyntaxHighlights, TreeError, WebReference, WebReferenceKind, merge_visibility, + CSS_CLASS, CSS_ID, CSS_VARIABLE, ConfigurationKey, FileReference, Literal, LiteralKind, MAX_CACHED_TREES, + MAX_PARSE_BYTES, ParsedTree, SpellingKind, SpellingRegion, Syntax, SyntaxCache, SyntaxCacheStats, + SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference, WebReferenceKind, merge_visibility, }; /// Parsed form of `languages/.toml`. Backend-only; the UI gets [`LanguageInfo`]. diff --git a/crates/ide-language/src/syntax/configuration.rs b/crates/ide-language/src/syntax/configuration.rs new file mode 100644 index 000000000..43c1cb5cf --- /dev/null +++ b/crates/ide-language/src/syntax/configuration.rs @@ -0,0 +1,242 @@ +//! Source ranges for Spring-style configuration keys. This is a syntax index, +//! not a YAML evaluator: aliases, merge keys and runtime profile precedence are +//! intentionally left to the YAML server / application. +use super::lines::LineIndex; +use super::{ParsedTree, Syntax, TreeError}; +use ide_core::{LanguageId, Range}; +use tree_sitter::Node; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConfigurationKey { + pub name: String, + pub range: Range, +} + +impl Syntax { + pub fn configuration_keys( + &self, + language: &LanguageId, + text: &str, + cancelled: &dyn Fn() -> bool, + ) -> Result, TreeError> { + if language.as_str() == "properties" { + return Ok(properties(text)); + } + self.with_parsed(language, text, cancelled, |tree| tree.configuration_keys(cancelled)) + } +} + +impl ParsedTree<'_> { + pub fn configuration_keys(&self, cancelled: &dyn Fn() -> bool) -> Vec { + let mut out = Vec::new(); + if self.grammar == "yaml" { + yaml(self.tree.root_node(), "", self.text, &LineIndex::new(self.text), &mut out, cancelled, 0); + } + out + } +} + +fn yaml( + node: Node<'_>, + prefix: &str, + text: &str, + lines: &LineIndex<'_>, + out: &mut Vec, + cancelled: &dyn Fn() -> bool, + depth: usize, +) { + if depth > 64 || cancelled() || out.len() >= 20_000 { + return; + } + match node.kind() { + "block_mapping_pair" | "flow_pair" => { + let Some(key) = node.child_by_field_name("key") else { return }; + let raw = &text[key.byte_range()]; + if raw.contains('\n') { + return; + } + let name = if raw.starts_with('\'') && raw.ends_with('\'') { + raw[1..raw.len() - 1].replace("''", "'") + } else if raw.starts_with('"') { + // JSON escapes cover ordinary quoted configuration keys. Unsupported + // YAML-only escapes are skipped rather than indexed under a wrong key. + let Ok(name) = serde_json::from_str::(raw) else { return }; + name + } else { + if raw.contains(['\n', '{', '[', '*', '&', '!']) || raw == "<<" { + return; + } + raw.to_owned() + }; + if name.is_empty() { + return; + } + let name = if prefix.is_empty() { + name + } else if name.starts_with('[') { + format!("{prefix}{name}") + } else { + format!("{prefix}.{name}") + }; + if name.len() > 1024 { + return; + } + out.push(ConfigurationKey { name: name.clone(), range: lines.range(key.start_byte(), key.end_byte()) }); + if let Some(value) = node.child_by_field_name("value") { + yaml(value, &name, text, lines, out, cancelled, depth + 1); + } + } + "block_sequence" | "flow_sequence" => { + let mut cursor = node.walk(); + for (i, child) in node.named_children(&mut cursor).filter(|n| n.kind() != "comment").enumerate() { + yaml(child, &format!("{prefix}[{i}]"), text, lines, out, cancelled, depth + 1); + } + } + // Do not descend into scalars, anchors or aliases. + "stream" + | "document" + | "block_node" + | "flow_node" + | "block_mapping" + | "flow_mapping" + | "block_sequence_item" => { + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + yaml(child, prefix, text, lines, out, cancelled, depth + 1); + } + } + _ => {} + } +} + +fn properties(text: &str) -> Vec { + let lines = LineIndex::new(text); + let mut out = Vec::new(); + let mut logical = String::new(); + let mut offsets = Vec::new(); + let mut base = 0; + for line in text.split_inclusive('\n') { + let physical = line.trim_end_matches(['\r', '\n']); + let start = physical.len() - physical.trim_start_matches([' ', '\t', '\u{c}']).len(); + let slice = &physical[start..]; + if logical.is_empty() && (slice.is_empty() || slice.starts_with(['#', '!'])) { + base += line.len(); + continue; + } + let continued = slice.bytes().rev().take_while(|b| *b == b'\\').count() % 2 == 1; + let end = physical.len() - usize::from(continued); + for (i, ch) in physical[start..end].char_indices() { + offsets.extend((0..ch.len_utf8()).map(|byte| base + start + i + byte)); + logical.push(ch); + } + if !continued || base + line.len() == text.len() { + let mut escaped = false; + let key_end = logical + .char_indices() + .find_map(|(i, ch)| { + if escaped { + escaped = false; + return None; + } + if ch == '\\' { + escaped = true; + return None; + } + matches!(ch, '=' | ':' | ' ' | '\t' | '\u{c}').then_some(i) + }) + .unwrap_or(logical.len()); + if key_end > 0 + && key_end <= 1024 + && let Some(name) = unescape_property(&logical[..key_end]) + { + out.push(ConfigurationKey { name, range: lines.range(offsets[0], offsets[key_end - 1] + 1) }); + } + logical.clear(); + offsets.clear(); + } + base += line.len(); + } + out +} + +fn unescape_property(raw: &str) -> Option { + let mut chars = raw.chars(); + let mut out = String::new(); + while let Some(ch) = chars.next() { + if ch != '\\' { + out.push(ch); + continue; + } + match chars.next()? { + 'u' => { + let hex: String = chars.by_ref().take(4).collect(); + if hex.len() != 4 { + return None; + } + out.push(char::from_u32(u32::from_str_radix(&hex, 16).ok()?)?); + } + 't' => out.push('\t'), + 'n' => out.push('\n'), + 'r' => out.push('\r'), + 'f' => out.push('\u{c}'), + ch => out.push(ch), + } + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn yaml_structures_and_utf16() { + let text = "app:\n 'time-out': 1\n hosts:\n - url: a\n - {url: b}\n map: {east: {size: 2}}\n---\n\"app.name\": ok\n'πŸ˜€': {port: 3}\n"; + let keys = Syntax::new().configuration_keys(&LanguageId::new("yaml"), text, &|| false).unwrap(); + let names: Vec<_> = keys.iter().map(|k| k.name.as_str()).collect(); + assert_eq!( + names, + [ + "app", + "app.time-out", + "app.hosts", + "app.hosts[0].url", + "app.hosts[1].url", + "app.map", + "app.map.east", + "app.map.east.size", + "app.name", + "πŸ˜€", + "πŸ˜€.port" + ] + ); + assert_eq!(keys.last().unwrap().range.start.col, 7); + } + #[test] + fn aliases_and_values_are_not_configuration_keys() { + let keys = Syntax::new() + .configuration_keys( + &LanguageId::new("yaml"), + "base: &base {size: 1}\napp:\n <<: *base\n text: |\n fake: key\n", + &|| false, + ) + .unwrap(); + assert_eq!(keys.iter().map(|k| k.name.as_str()).collect::>(), ["base", "base.size", "app", "app.text"]); + } + #[test] + fn properties_unicode_ranges_and_eof_continuation() { + let keys = properties("app.δΈ­ζ–‡=1\nlast\\\n"); + assert_eq!(keys[0].range.end.col, 6); + assert_eq!(keys[1].name, "last"); + } + #[test] + fn properties_escapes_continuations_and_comments() { + let keys = properties( + "# comment\r\napp.time\\\n -out : 1\napp\\.name=ok\napp.\\u0061=2\nspace\\ key value\n!ignored=x\n", + ); + assert_eq!( + keys.iter().map(|k| k.name.as_str()).collect::>(), + ["app.time-out", "app.name", "app.a", "space key"] + ); + assert_eq!(keys[0].range.end.line, 2); + } +} diff --git a/crates/ide-language/src/syntax/mod.rs b/crates/ide-language/src/syntax/mod.rs index 5e22c169f..c578dbba8 100644 --- a/crates/ide-language/src/syntax/mod.rs +++ b/crates/ide-language/src/syntax/mod.rs @@ -1,6 +1,7 @@ //! Tree-sitter driven structure: outline, folding, symbol tags, selection ranges, highlighting. mod cache; +mod configuration; mod folding; mod grammars; mod highlight; @@ -12,6 +13,7 @@ mod web; use crate::{FoldRange, LanguageConfig, LanguageRegistry, SymbolTag}; pub use cache::{MAX_CACHED_TREES, SyntaxCache, SyntaxCacheStats}; +pub use configuration::ConfigurationKey; use grammars::{SPECS, Spec}; pub use highlight::{SyntaxHighlightKind, SyntaxHighlights}; use ide_core::{LanguageId, OutlineItem, Pos, Range}; diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index 82a52ef41..8198a443c 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -18,6 +18,7 @@ use std::time::{Duration, Instant}; const SERVER: &str = "jdtls"; +pub(crate) mod configuration; mod formatter; pub use formatter::configure_formatter; /// First start imports the Maven / Gradle model, which can take minutes. diff --git a/crates/ide-lsp/src/jdtls/configuration.rs b/crates/ide-lsp/src/jdtls/configuration.rs new file mode 100644 index 000000000..b11260ff4 --- /dev/null +++ b/crates/ide-lsp/src/jdtls/configuration.rs @@ -0,0 +1,416 @@ +//! Bridges configuration syntax to the app-owned JDT extension. No additional +//! language server, build, or network request is needed. Disk parsing runs on a +//! blocking worker; open buffers always win over cached disk contents. +use super::*; +use crate::text::Encoding; +use crate::types::{References, ReferencesIncomplete}; +use crate::{DocSnap, Target}; +use ide_core::{Location, Pos}; +use ide_language::{ConfigurationKey, Syntax}; +use parking_lot::Mutex; +use std::collections::HashMap; +use std::sync::OnceLock; +use std::time::SystemTime; + +const COMMAND: &str = "openidea.java.configuration"; +const MAX_BYTES: u64 = 2 * 1024 * 1024; +const MAX_FILES: usize = 2000; +const MAX_CACHED_KEYS: usize = 100_000; + +#[derive(Default)] +pub(crate) struct ConfigurationCache { + files: HashMap, +} +struct CachedFile { + stamp: (SystemTime, u64), + keys: Vec, +} +#[derive(Deserialize)] +struct Pattern { + name: String, + relaxed: bool, +} +#[derive(Deserialize)] +struct Response { + #[serde(default)] + locations: Vec, + #[serde(default)] + keys: Vec, + #[serde(default)] + roots: Vec, + #[serde(default)] + incomplete: bool, +} +fn syntax() -> &'static Syntax { + static SYNTAX: OnceLock = OnceLock::new(); + SYNTAX.get_or_init(Syntax::new) +} +fn language(path: &Path) -> Option { + match path.extension()?.to_str()? { + "yaml" | "yml" => Some(LanguageId::new("yaml")), + "properties" => Some(LanguageId::new("properties")), + _ => None, + } +} +fn parts(key: &str) -> Vec { + let mut out = Vec::new(); + let mut part = String::new(); + let mut chars = key.chars().peekable(); + let mut bracket = false; + while let Some(ch) = chars.next() { + match ch { + '[' => { + if !part.is_empty() { + out.push(std::mem::take(&mut part)); + } + for ch in chars.by_ref() { + if ch == ']' { + break; + } + if !matches!(ch, '-' | '_') { + part.extend(ch.to_lowercase()); + } + } + out.push(std::mem::take(&mut part)); + bracket = true; + } + '.' => { + if !bracket { + out.push(std::mem::take(&mut part)); + } + bracket = false; + } + '-' | '_' => {} + ch => { + part.extend(ch.to_lowercase()); + bracket = false; + } + } + } + if !part.is_empty() { + out.push(part); + } + out +} +fn matches(pattern: &Pattern, key: &str) -> bool { + if !pattern.relaxed { + return pattern.name == key; + } + let (a, b) = (parts(&pattern.name), parts(key)); + a.len() == b.len() && a.iter().zip(b).all(|(a, b)| a == "*" || *a == b) +} + +impl LspManager { + pub(crate) async fn configuration_navigation( + &self, + snap: &DocSnap, + pos: Pos, + usages: bool, + include_declaration: bool, + cancel: &CancellationToken, + ) -> IdeResult> { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let config = language(&snap.path).is_some(); + if !config && snap.language.as_str() != "java" { + return Ok(None); + } + // Definition in ordinary Java code remains JDT's definition. Reverse + // configuration navigation is offered on string expressions; field/config + // accessor usages are merged into the ordinary Find Usages response. + if !config && !usages { + let text = snap.text.as_str(); + if !text.contains("Value") && !text.contains("getProperty") && !text.contains("getRequiredProperty") { + return Ok(None); + } + } + let key = if config { + let text = snap.text.as_str().to_owned(); + if text.len() as u64 > MAX_BYTES { + return Ok(None); + } + let lang = language(&snap.path).expect("configuration language"); + let cancelled = cancel.clone(); + tokio::task::spawn_blocking(move || { + syntax() + .configuration_keys(&lang, &text, &|| cancelled.is_cancelled()) + .ok()? + .into_iter() + .find(|key| key.range.start <= pos && pos < key.range.end) + .map(|key| key.name) + }) + .await + .ok() + .flatten() + } else { + None + }; + if config && key.is_none() { + return Ok(None); + } + if config { + // Avoid starting a JVM for unrelated YAML (CI, Kubernetes, etc.). + let path = snap.path.clone(); + let root = self.project_root.clone(); + let java_project = tokio::task::spawn_blocking(move || { + path.ancestors().take_while(|p| p.starts_with(&root)).any(|p| { + ["pom.xml", "build.gradle", "build.gradle.kts", ".classpath"] + .iter() + .any(|name| p.join(name).is_file()) + }) + }) + .await + .unwrap_or(false); + if !java_project { + return Ok(None); + } + } + let result = self.configuration_query(snap, pos, key, usages, include_declaration, cancel).await; + match result { + Err(e) if cancel.is_cancelled() || e.kind == ErrorKind::Cancelled => Err(IdeError::cancelled()), + Err(e) => { + tracing::debug!("Configuration navigation unavailable; retaining native language navigation: {e}"); + Ok(None) + } + result => result, + } + } + + async fn configuration_query( + &self, + snap: &DocSnap, + pos: Pos, + key: Option, + usages: bool, + include_declaration: bool, + cancel: &CancellationToken, + ) -> IdeResult> { + let (server, run) = self.jdtls(&snap.path, cancel).await?; + if !run.caps.read().commands().iter().any(|command| command == COMMAND) { + return Ok(None); + } + let target = Target { server: server.clone(), run }; + let position = snap.text.ide_to_lsp(Encoding::Utf16, pos); + let response: Response = self + .call( + &target, + "workspace/executeCommand", + json!({ + "command": COMMAND, "arguments": [snap.uri, key, position.line, position.character, usages, include_declaration] + }), + Some(cancel), + crate::LONG_TIMEOUT, + ) + .await?; + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + if response.keys.is_empty() { + return Ok(response + .incomplete + .then(|| References { incomplete: Some(ReferencesIncomplete::FilesSkipped), ..Default::default() })); + } + let mut result = References { + locations: response.locations.iter().filter_map(|l| self.conv(Encoding::Utf16).location(l)).collect(), + incomplete: response.incomplete.then_some(ReferencesIncomplete::FilesSkipped), + ..Default::default() + }; + self.note_virtual(&server, result.locations.iter().map(|l| l.uri.as_deref())); + if key.is_none() || usages { + 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"))?; + result.locations.extend(files.locations); + result.incomplete = result.incomplete.or(files.incomplete); + } + if cancel.is_cancelled() || !self.docs.lock().values().any(|d| d.uri == snap.uri && d.version == snap.version) { + return Err(IdeError::cancelled()); + } + if usages && key.is_some() && !include_declaration { + let source_path = self + .docs + .lock() + .values() + .find(|d| d.uri == snap.uri) + .map(|d| d.canonical.clone()) + .unwrap_or_else(|| snap.path.clone()); + result.locations.retain(|location| { + location.path.to_path_buf() != source_path || !(location.range.start <= pos && pos < location.range.end) + }); + } + crate::convert::dedup_locations(&mut result.locations); + Ok(Some(result)) + } +} + +fn in_module(root: &Path, path: &Path) -> bool { + if !path.starts_with(root) { + return false; + } + for parent in path.parent().into_iter().flat_map(Path::ancestors).take_while(|p| *p != root) { + if matches!( + parent.file_name().and_then(|n| n.to_str()), + Some("target" | "build" | "node_modules" | ".git" | ".gradle") + ) { + return false; + } + if ["pom.xml", "build.gradle", "build.gradle.kts", ".classpath"].iter().any(|name| parent.join(name).is_file()) + { + return false; + } + } + true +} + +fn configuration_files( + roots: &[PathBuf], + patterns: &[Pattern], + overlays: &HashMap, + cache: &Mutex, + cancel: &CancellationToken, +) -> 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 walker = ignore::WalkBuilder::new(root) + .follow_links(false) + .filter_entry(move |entry| { + if !entry.file_type().is_some_and(|t| t.is_dir()) { + return true; + } + if entry.path() == module { + return true; + } + if matches!(entry.file_name().to_str(), Some("target" | "build" | "node_modules" | ".git" | ".gradle")) + { + return false; + } + // A sibling application's identical key must not leak into this module. + !["pom.xml", "build.gradle", "build.gradle.kts", ".classpath"] + .iter() + .any(|name| entry.path().join(name).is_file()) + }) + .build(); + for (visited, entry) in walker.enumerate() { + if cancel.is_cancelled() { + return result; + } + if visited >= 50_000 || paths.len() >= MAX_FILES { + result.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()); + } + Err(_) => result.incomplete = Some(ReferencesIncomplete::FilesSkipped), + _ => {} + } + } + } + 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(); + } + for path in paths { + if cancel.is_cancelled() { + return result; + } + let lang = language(&path).expect("configuration file"); + let keys = if let Some(text) = overlays.get(&path) { + syntax().configuration_keys(&lang, text, &|| cancel.is_cancelled()).ok() + } else { + (|| { + let metadata = std::fs::metadata(&path).ok()?; + if metadata.len() > MAX_BYTES { + return None; + } + let stamp = (metadata.modified().ok()?, metadata.len()); + if let Some(file) = cache.files.get(&path).filter(|f| f.stamp == stamp) { + return Some(file.keys.clone()); + } + let text = std::fs::read_to_string(&path).ok()?; + let keys = syntax().configuration_keys(&lang, &text, &|| cancel.is_cancelled()).ok()?; + cache.files.remove(&path); + if cache.files.len() >= MAX_FILES + || cache.files.values().map(|file| file.keys.len()).sum::() + keys.len() > MAX_CACHED_KEYS + { + cache.files.clear(); + } + cache.files.insert(path.clone(), CachedFile { stamp, keys: keys.clone() }); + Some(keys) + })() + }; + match keys { + Some(keys) => result.locations.extend( + keys.into_iter() + .filter(|key| patterns.iter().any(|p| matches(p, &key.name))) + .map(|key| Location::file(path.clone(), key.range)), + ), + None => result.incomplete = Some(ReferencesIncomplete::FilesSkipped), + } + if result.locations.len() >= MAX_FILES { + result.locations.truncate(MAX_FILES); + result.incomplete = Some(ReferencesIncomplete::TooManyFiles); + break; + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn file_cache_tracks_edits_overlays_removals_and_limits() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().to_path_buf(); + let file = root.join("application.properties"); + std::fs::write(&file, "app.port=1\n").unwrap(); + let patterns = [Pattern { name: "app.port".into(), relaxed: true }]; + let cache = Mutex::new(ConfigurationCache::default()); + let cancel = CancellationToken::new(); + let query = |overlays: &HashMap| { + configuration_files(std::slice::from_ref(&root), &patterns, overlays, &cache, &cancel) + }; + assert_eq!(query(&HashMap::new()).locations.len(), 1); + std::fs::write(&file, "app.other=12\n").unwrap(); + assert!(query(&HashMap::new()).locations.is_empty()); + assert_eq!(query(&HashMap::from([(file.clone(), "app.port=unsaved\n".into())])).locations.len(), 1); + std::fs::remove_file(&file).unwrap(); + assert!(query(&HashMap::new()).locations.is_empty()); + std::fs::write(&file, "x".repeat(MAX_BYTES as usize + 1)).unwrap(); + assert_eq!(query(&HashMap::new()).incomplete, Some(ReferencesIncomplete::FilesSkipped)); + cancel.cancel(); + assert!(query(&HashMap::new()).locations.is_empty()); + } + + #[test] + fn relaxed_binding_keeps_boundaries() { + let pattern = Pattern { name: "app.clients[*].timeOut".into(), relaxed: true }; + assert!(matches(&pattern, "app.clients[2].time-out")); + assert!(!matches(&pattern, "app.clientsTimeOut")); + let map = Pattern { name: "app.routes.*.url".into(), relaxed: true }; + assert!(matches(&map, "app.routes[us.east].url")); + assert!(!matches(&map, "app.routes.us.east.url")); + assert!(!matches(&Pattern { name: "app.time-out".into(), relaxed: false }, "app.timeOut")); + } +} diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index d8ead994b..45bef903a 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -179,6 +179,7 @@ pub struct LspManager { shutting_down: AtomicBool, execution_allowed: AtomicBool, java_usages_warned: AtomicBool, + configuration_cache: Arc>, /// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure. shown_messages: Mutex>, /// Virtual (non-`file:`) URIs a server returned, by the server that owns them. @@ -313,6 +314,7 @@ impl LspManager { shutting_down: AtomicBool::new(false), execution_allowed: AtomicBool::new(true), java_usages_warned: AtomicBool::new(false), + configuration_cache: Arc::new(Mutex::new(Default::default())), shown_messages: Mutex::new(HashSet::new()), virtual_origins: Mutex::new(HashMap::new()), virtual_docs: Mutex::new(HashMap::new()), @@ -1122,6 +1124,15 @@ impl LspManager { } pub async fn goto(&self, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult> { + if matches!(kind, GotoKind::Definition) { + let snap = self.snapshot(doc)?; + if let Some(result) = + self.configuration_navigation(&snap, pos, false, false, &CancellationToken::new()).await? + && !result.locations.is_empty() + { + return Ok(result.locations); + } + } let (method, cap, feature) = goto_method(kind); let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?; self.goto_in(&snap, &targets, method, pos, None).await @@ -1200,6 +1211,36 @@ impl LspManager { include_declaration: bool, project_wide: bool, cancel: CancellationToken, + ) -> IdeResult { + let snap = self.snapshot(doc)?; + // Scoped probes back code lenses and bulk analysis. Keep their native cost; + // configuration indexing is reserved for explicit, project-wide usages. + let configuration = if project_wide || snap.language.as_str() != "java" { + self.configuration_navigation(&snap, pos, true, include_declaration, &cancel).await? + } else { + None + }; + if snap.language.as_str() != "java" + && let Some(result) = configuration + { + return Ok(result); + } + let mut result = self.native_references(doc, pos, include_declaration, project_wide, cancel).await?; + if let Some(configuration) = configuration { + result.locations.extend(configuration.locations); + result.incomplete = result.incomplete.or(configuration.incomplete); + convert::dedup_locations(&mut result.locations); + } + Ok(result) + } + + async fn native_references( + &self, + doc: DocId, + pos: Pos, + include_declaration: bool, + project_wide: bool, + cancel: CancellationToken, ) -> IdeResult { let (snap, targets) = self.doc_targets(doc, "find references", |c| c.has("referencesProvider")).await?; // The extension preserves method identity before JDT collapses references to locations. @@ -1212,7 +1253,7 @@ impl LspManager { let t = &targets[0]; let extension_error = Mutex::new(None); let query = || async { - if cancel.is_cancelled() || !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version) { + if cancel.is_cancelled() || self.docs.lock().get(&doc).is_none_or(|d| d.version != snap.version) { return Err(IdeError::cancelled()); } let params = snap.pos_params(t, pos); @@ -1240,7 +1281,7 @@ impl LspManager { *extension_error.lock() = Some(e.clone()); } let rows = result?; - if cancel.is_cancelled() || !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version) { + if cancel.is_cancelled() || self.docs.lock().get(&doc).is_none_or(|d| d.version != snap.version) { return Err(IdeError::cancelled()); } let conv = self.conv(t.enc()); @@ -1266,7 +1307,7 @@ impl LspManager { scope::Widened::only(first, empty, &t.server) }; // A changed source also invalidates an earlier answer retained by scope widening. - if cancel.is_cancelled() || !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version) { + if cancel.is_cancelled() || self.docs.lock().get(&doc).is_none_or(|d| d.version != snap.version) { return Err(IdeError::cancelled()); } // Scope widening can retain the first answer after a failed second query. @@ -1292,7 +1333,7 @@ impl LspManager { Err(e) => { if e.kind == ErrorKind::Cancelled || cancel.is_cancelled() - || !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version) + || self.docs.lock().get(&doc).is_none_or(|d| d.version != snap.version) { return Err(IdeError::cancelled()); } diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index a473e8cbc..b33110a20 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -2916,3 +2916,92 @@ async fn clang_tidy_config_for_a_later_opened_file_warns_on_a_running_server() { assert_eq!(h.mgr.servers().len(), 1); h.mgr.shutdown_all().await; } + +#[tokio::test(flavor = "multi_thread")] +async fn configuration_definition_uses_java_without_yaml_definition_capability() { + 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("Settings.java")); + s.respond("workspace/executeCommand", json!({ + "locations": [{"uri": target, "range": {"start": {"line": 2, "character": 4}, "end": {"line": 2, "character": 11}}}], + "keys": [{"name": "app.timeOut", "relaxed": true}], "roots": [] + })); + let path = h.root.join("application.yml"); + let doc = DocId::next(); + h.mgr.did_open(doc, &path, &LanguageId::new("yaml"), 1, "app:\n time-out: 5\n"); + let locations = h.mgr.goto(doc, Pos::new(1, 4), GotoKind::Definition).await.unwrap(); + assert_eq!(locations.len(), 1); + assert_eq!(locations[0].range.start, Pos::new(2, 4)); + assert_eq!(s.requested("workspace/executeCommand")[0]["arguments"][1], "app.time-out"); + assert!(s.requested("textDocument/definition").is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn configuration_usages_merge_native_and_unsaved_profiles_without_sibling_modules() { + let mut caps = incremental_caps(); + caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]}); + let h = harness_for("java", "java", &[("jdtls", caps)], &[]); + let s = h.launcher.state("jdtls"); + let (doc, path) = h.open("Settings.java", "int timeOut;"); + h.wait_status("jdtls", ServerStatus::Ready).await; + let root = uri_of(&h.root); + s.respond( + "workspace/executeCommand", + json!({ + "locations": [], "keys": [{"name": "app.timeOut", "relaxed": true}], "roots": [root] + }), + ); + s.respond( + "textDocument/references", + json!([{"uri": uri_of(&path), "range": { + "start": {"line": 3, "character": 0}, "end": {"line": 3, "character": 7} + }}]), + ); + std::fs::write(h.root.join("application.yml"), "app:\n time-out: 1\n").unwrap(); + std::fs::write(h.root.join("application-prod.properties"), "app.time-out=2\n").unwrap(); + let overlay = h.root.join("application-dev.yml"); + std::fs::write(&overlay, "app:\n old: 3\n").unwrap(); + h.mgr.did_open(DocId::next(), &overlay, &LanguageId::new("yaml"), 1, "app:\n time-out: 3\n"); + let sibling = h.root.join("other"); + std::fs::create_dir(&sibling).unwrap(); + std::fs::write(sibling.join("pom.xml"), "").unwrap(); + std::fs::write(sibling.join("application.yml"), "app:\n time-out: wrong\n").unwrap(); + h.mgr.did_open( + DocId::next(), + &sibling.join("application.yml"), + &LanguageId::new("yaml"), + 1, + "app:\n time-out: still-wrong\n", + ); + let scoped = h.mgr.references(doc, Pos::new(0, 6), false, false, Default::default()).await.unwrap(); + assert_eq!(scoped.locations.len(), 1); + assert!(s.requested("workspace/executeCommand").is_empty()); + let refs = h.mgr.references(doc, Pos::new(0, 6), false, true, Default::default()).await.unwrap(); + assert_eq!(refs.locations.len(), 4, "{refs:?}"); + assert!(refs.locations.iter().all(|l| !l.path.to_path_buf().starts_with(&sibling))); + assert!(refs.locations.iter().any(|l| l.path.to_path_buf() == overlay)); + assert_eq!(refs.incomplete, None); +} + +#[tokio::test(flavor = "multi_thread")] +async fn configuration_extension_failure_preserves_native_java_navigation() { + let mut caps = incremental_caps(); + caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]}); + let h = harness_for("java", "java", &[("jdtls", caps)], &[]); + let s = h.launcher.state("jdtls"); + let (doc, path) = h.open("Settings.java", "@Value(\"${app.port}\") int port;"); + h.wait_status("jdtls", ServerStatus::Ready).await; + s.errors.lock().insert("workspace/executeCommand".into(), (-32603, "old server".into())); + s.respond( + "textDocument/definition", + json!({"uri": uri_of(&path), "range": { + "start": {"line": 0, "character": 1}, "end": {"line": 0, "character": 6} + }}), + ); + let result = h.mgr.goto(doc, Pos::new(0, 2), GotoKind::Definition).await.unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(s.requested("textDocument/definition").len(), 1); +} diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index 56d81ecf7..9120b0e5f 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -1173,7 +1173,7 @@ mod kotlin_bridge_bundle_tests { /// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure. pub fn usages_bundle(data_dir: &Path) -> Option { - let extension = data_dir.join("java-usages-1.1.1.jar"); + let extension = data_dir.join("java-usages-1.2.0.jar"); let bytes = include_bytes!("../../../plugins/java-usages/openidea-java-usages.jar"); if std::fs::read(&extension).is_ok_and(|old| old == bytes) { clean_usages_bundles(data_dir, &extension); diff --git a/crates/ide-syntax/src/jvm.rs b/crates/ide-syntax/src/jvm.rs index 9da0f7b46..88e3c7ca6 100644 --- a/crates/ide-syntax/src/jvm.rs +++ b/crates/ide-syntax/src/jvm.rs @@ -159,7 +159,7 @@ impl StructureService { let runnable = body.is_some_and(|b| { members(*b).into_iter().any(|n| { if !matches!(n.kind(), "method_declaration" | "function_declaration") - || !n.child_by_field_name("name").is_some_and(|id| text(id, snap.text) == "main") + || n.child_by_field_name("name").is_none_or(|id| text(id, snap.text) != "main") { return false; } diff --git a/plugins/java-usages/CONFIGURATION.md b/plugins/java-usages/CONFIGURATION.md new file mode 100644 index 000000000..fae42f40c --- /dev/null +++ b/plugins/java-usages/CONFIGURATION.md @@ -0,0 +1,114 @@ +# Spring configuration navigation + +`openidea.java.configuration` bridges YAML / properties to the existing JDT LS +Java model. It is bundled with the Java usages extension; it does not install a +second language server, run a build, change the application's dependencies, or +connect to external configuration services. + +## Editor behavior + +- **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 Java `@Value` placeholder or a constant + `Environment.getProperty` / `getRequiredProperty` argument opens matching + configuration keys. Ordinary Java definition navigation remains native. +- **Find Usages** joins configuration declarations, property consumers, and Java + references to associated binding members. Java's existing reference results and + read/write classifications are retained. +- Open editor contents take precedence over disk. All matching profiles/documents + are shown; navigation does not claim to know the runtime effective value. +- Missing or older extension bundles preserve native language-server behavior. + YAML aliases still use the YAML server's anchor navigation. + +## Association sources + +The Java command resolves annotation and method identities with JDT bindings: + +- `@ConfigurationProperties` on classes and factory methods, including inherited + members, nested objects, records, constructor-only parameters / `@Name`, arrays, + collections and maps. Binder keys use relaxed spelling while retaining path + boundaries. Indexed collection positions and arbitrary map keys use wildcards. +- `@Value("${key:default}")`, including constant annotation values and nested + default placeholders. +- Literal or compile-time constant keys passed to Spring's `PropertyResolver` + methods, including through `Environment`. Unrelated methods with the same name + and non-Spring annotations are excluded. +- Dependency JAR `META-INF/spring-configuration-metadata.json` and + `additional-spring-configuration-metadata.json`. The closest group, source + type, and source method locate a member or its owning class. Source attachments + and JDT class-file/decompiler navigation use the existing virtual-document path. +- The same metadata files in source roots and `src/main/resources`. + +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 +IPC are UTF-16. No new IPC type or frontend provider is required. + +## Scope and costs + +The command uses the selected JDT project's resolved package roots, including +source dependencies and its dependency JARs. Configuration search includes those +modules, skips nested independent modules and build output, and honors ignore +files for disk discovery. It does not guess a global association from matching +text in unrelated projects. + +Indexing is lazy. The first explicit navigation/search builds a compact snapshot; +ordinary typing, code-lens probes and bulk scoped reference analysis do not start indexing. At most eight project snapshots are +retained, with a shared 100,000-association retention cap. Records contain property names, element handles and source locations; +ASTs and bindings are discarded after indexing. A lexical prefilter avoids AST +construction for ordinary Java units, but every accepted association is resolved +semantically. Warm requests reuse the snapshot. Java model/reconcile changes +invalidate it conservatively, including dependent-type changes; open working-copy +content fingerprints also prevent stale answers before reconciliation. This is +**snapshot invalidation**, not a fully incremental Java semantic index. + +Metadata/JAR timestamps are checked before reuse. The client caches parsed disk +configuration files by modification time and size (at most 100,000 cached keys), overlays open buffers, and +performs bounded discovery/parsing on a blocking worker. It does not continuously +watch or parse an additional copy of the entire workspace. + +Bounds: 20,000 Java units, 100,000 associations per snapshot, 2 MiB per candidate +metadata/configuration file (Java source: 2M UTF-16 units), 1,024-character property names, 2,000 configuration files/results, 50,000 filesystem +entries per root, and depth limits on recursive bindings/YAML. Java requests and +references are cancellable. File/association scan limits and unreadable files carry the +existing incomplete-result indicator; unsupported syntax keys are omitted. First navigation can take seconds on a +cold project; classpath import and source download remain owned by JDT LS. + +## Deliberate limits + +This is Java/Spring property navigation, not full IntelliJ Spring support. It +cannot infer dynamically constructed names, custom configuration APIs/annotations, +remote-only configuration, arbitrary SpEL evaluation, YAML merge/alias expansion, +or custom Spring converters that change the binding shape. Kotlin is not indexed +by this Java bundle. YAML-only quoted-string escape forms that cannot be decoded +safely are skipped. Properties surrogate-pair escapes are not decoded as keys. +A missing source attachment may navigate to the decompiled owning class when JDT +cannot provide an exact member range. Binding members are navigation candidates; +the feature does not validate whether Spring will successfully bind a bean at +runtime or resolve profile/precedence rules. + +## Verification + +Use the JDT LS distribution / Java version documented in [README](README.md). + +```sh +python3 plugins/java-usages/build.py /path/to/jdtls +python3 plugins/java-usages/tests/configuration.py /path/to/jdtls +OPENIDEA_CONFIGURATION_STRESS=1 python3 plugins/java-usages/tests/configuration.py /path/to/jdtls +OPENIDEA_SPRING_BOOT_JAR=/path/to/spring-boot-autoconfigure.jar python3 plugins/java-usages/tests/configuration.py /path/to/jdtls +cargo test -p ide-language -p ide-lsp +``` + +The isolated real-server harness covers custom bindings, inheritance, nesting, +collections/maps, records, constructor aliases, factory methods, consumers, +constant keys, negative identity matches, dependency metadata/source locations, +reverse queries, and unsaved-change/close invalidation. Optional stress fixtures +add 3,000 ordinary units and 100 binding classes; output includes cold/warm/edit +latencies for the JDT command only, excluding server import, UI and configuration-file discovery. The optional actual Spring Boot JAR checks `server.port`, +`spring.datasource.url` and `spring.jackson.date-format` through dependency +metadata. No user's project is compiled or modified by the harness. + +Rust tests cover configuration syntax/ranges and the actual manager bridge: +YAML navigation without YAML definition capabilities, Java native fallback, +reference merging, profiles, open-buffer overlays and sibling-module exclusion. +Existing Java usages / read-write integration harnesses remain regression checks. diff --git a/plugins/java-usages/MANIFEST.MF b/plugins/java-usages/MANIFEST.MF index 1775ddec8..7ec5b7f32 100644 --- a/plugins/java-usages/MANIFEST.MF +++ b/plugins/java-usages/MANIFEST.MF @@ -2,9 +2,9 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: OpenIDEA Java usages Bundle-SymbolicName: ai.openidea.usages;singleton:=true -Bundle-Version: 1.1.1 +Bundle-Version: 1.2.0 Bundle-RequiredExecutionEnvironment: JavaSE-21 Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core, org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime, - org.eclipse.lsp4j + org.eclipse.lsp4j,org.eclipse.core.resources,com.google.gson diff --git a/plugins/java-usages/README.md b/plugins/java-usages/README.md index 5be955109..6f8203215 100644 --- a/plugins/java-usages/README.md +++ b/plugins/java-usages/README.md @@ -1,4 +1,6 @@ -# Semantic Java usages +# Semantic Java usages and configuration navigation + +Spring YAML / properties navigation is described in [CONFIGURATION.md](CONFIGURATION.md). The app-owned OSGi command `openidea.java.usages` returns `{location, kind, access}`. `kind` retains field/getter/setter/unknown identity for compatibility. Optional @@ -55,7 +57,7 @@ is not a claim of full IntelliJ feature parity. ## Rebuild and delivery The checked-in jar is embedded by ide-sdk `include_bytes!`, installed as -`java-usages-1.1.1.jar`, and added alongside existing initialization bundles. +`java-usages-1.2.0.jar`, and added alongside existing initialization bundles. Users need no compiler, Maven or Python. Manifest/install version must change when replacing the bundle because OSGi caches bundles. @@ -64,8 +66,10 @@ Use its shipped ECJ and plugin jars, with Java 25; emitted bytecode targets Java The build uses sorted inputs and fixed zip timestamps and does not resolve Maven artifacts. Check the extracted distribution against `build-dependencies.sha256` from its root (`sha256sum -c /path/to/build-dependencies.sha256`). Build twice -and compare SHA-256. The 1.1.1 output is +and compare SHA-256. The prior 1.1.1 output was `1e569939cba22c7db6e2c9b53de2a4c10ccaabe762a51427119a04af3d974ae4`. +Version 1.2.0 adds the configuration command; its reproducible SHA-256 is +`9140f1d2ecf27eadfe66b40a17daa18a08a65927ebef79456ba55d486ce5eeba`. ```sh export PATH=/path/to/jdk25/bin:$PATH diff --git a/plugins/java-usages/openidea-java-usages.jar b/plugins/java-usages/openidea-java-usages.jar index 0eee593542b614d0580af64f0a9732b3042f96a6..3f521b3a3218b8f52fc4fa0808705773eec01462 100644 GIT binary patch literal 82033 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 delta 249 zcmey^z`E@dqj-QfGm8iV1SrP%TP~gz$H>4CH&J{$v!R~h#EW~GxVScNK6<8@)AB*n zL0O=j?Dh^zMsddJ&en|ToNx`(i>(95!$f(dj2aYB(I=A{^6Y9T>&z u;4T5Wh70O;5Y@owSIWRpkW-qTnWtBgn-k#8$_5hW0K#4-28PLHARYi9&^=xN diff --git a/plugins/java-usages/plugin.xml b/plugins/java-usages/plugin.xml index f654f9def..dad7e8d8c 100644 --- a/plugins/java-usages/plugin.xml +++ b/plugins/java-usages/plugin.xml @@ -4,5 +4,8 @@ + + + diff --git a/plugins/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java b/plugins/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java new file mode 100644 index 000000000..39fce3a9e --- /dev/null +++ b/plugins/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java @@ -0,0 +1,525 @@ +package ai.openidea.configuration; + +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.atomic.AtomicLong; +import java.util.zip.ZipFile; +import com.google.gson.*; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.OperationCanceledException; +import org.eclipse.jdt.core.*; +import org.eclipse.jdt.core.dom.*; +import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler; +import org.eclipse.jdt.ls.core.internal.JDTUtils; +import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin; +import org.eclipse.jdt.ls.core.internal.handlers.ReferencesHandler; +import org.eclipse.lsp4j.Location; + +/** On-demand configuration index, scoped by JDT's resolved source/class path. + * Cached records contain strings and locations, never ASTs or bindings. Java model + * changes invalidate snapshots, including unsaved working copies and classpath edits. + */ +public final class ConfigurationCommand implements IDelegateCommandHandler { + private static final String PROPERTIES = "org.springframework.boot.context.properties.ConfigurationProperties"; + private static final String VALUE = "org.springframework.beans.factory.annotation.Value"; + private static final String RESOLVER = "org.springframework.core.env.PropertyResolver"; + private static final int MAX_UNITS = 20_000, MAX_ENTRIES = 100_000, MAX_BYTES = 2 * 1024 * 1024; + private static final AtomicLong REVISION = new AtomicLong(); + private static final Map CACHE = new LinkedHashMap<>(); + static { JavaCore.addElementChangedListener(event -> REVISION.incrementAndGet(), ElementChangedEvent.POST_CHANGE | ElementChangedEvent.POST_RECONCILE); } + + private record Entry(String name, String handle, Location location, boolean relaxed) {} + private record FileStamp(java.nio.file.attribute.FileTime modified, long size) {} + private static FileStamp stamp(Path path) { + try { return new FileStamp(Files.getLastModifiedTime(path), Files.size(path)); } + catch (java.io.IOException e) { return new FileStamp(null, -1); } + } + private record Index(long revision, String workingCopies, List entries, List roots, + Map files, boolean incomplete, int parsed) {} + + @Override + public Object executeCommand(String command, List args, IProgressMonitor monitor) throws Exception { + String uri = (String) args.get(0); + String key = args.get(1) instanceof String s ? s : null; + int line = ((Number) args.get(2)).intValue(), column = ((Number) args.get(3)).intValue(); + boolean references = args.size() > 4 && Boolean.TRUE.equals(args.get(4)); + boolean declarations = args.size() > 5 && Boolean.TRUE.equals(args.get(5)); + ITypeRoot selectedRoot = JDTUtils.resolveTypeRoot(uri); + IJavaProject project = selectedRoot == null ? project(uri) : selectedRoot.getJavaProject(); + if (project == null) return Map.of("locations", List.of(), "keys", List.of(), "roots", List.of(), "incomplete", true); + Index index = index(project, monitor); + Set> keys = new LinkedHashSet<>(); + Set locations = new LinkedHashSet<>(); + String selected = null; + if (key == null && selectedRoot != null && references) { + var element = JDTUtils.findElementAtSelection(selectedRoot, line, column, + JavaLanguageServerPlugin.getPreferencesManager(), monitor); + if (element != null) selected = element.getHandleIdentifier(); + } + for (Entry entry : index.entries) { + check(monitor); + Location location = entry.location; + if (key != null) { + if (matches(entry.name, key, entry.relaxed)) { + if (!entry.handle.isEmpty() && references) { + var element = JavaCore.create(entry.handle); + if (element != null && element.exists()) { + List usages = new ArrayList<>(); + new ReferencesHandler(JavaLanguageServerPlugin.getPreferencesManager()) + .search(element, usages, monitor, declarations); + locations.addAll(usages); + } + } else { + if (location == null && !entry.handle.isEmpty()) location = location(entry.handle); + if (location != null) locations.add(location); + } + keys.add(Map.of("name", entry.name, "relaxed", entry.relaxed)); + } + } else if ((!entry.handle.isEmpty() && entry.handle.equals(selected)) || + (location != null && sameUri(location.getUri(), uri) && contains(location, line, column))) { + keys.add(Map.of("name", entry.name, "relaxed", entry.relaxed)); + } + if (locations.size() >= 2000) break; + } + // Reverse Find Usages joins property consumers with the binding member's + // ordinary Java usages. Definition on a string only returns config keys. + if (key == null && references && !keys.isEmpty()) { + Set searched = new HashSet<>(); + for (Entry entry : index.entries) { + check(monitor); + boolean related = keys.stream().anyMatch(pattern -> + matches(entry.name, (String) pattern.get("name"), entry.relaxed) || + matches((String) pattern.get("name"), entry.name, Boolean.TRUE.equals(pattern.get("relaxed")))); + if (!related) continue; + if (entry.location != null) locations.add(entry.location); + else if (!entry.handle.isEmpty() && searched.add(entry.handle)) { + IJavaElement element = JavaCore.create(entry.handle); + if (element != null && element.exists()) { + List usages = new ArrayList<>(); + new ReferencesHandler(JavaLanguageServerPlugin.getPreferencesManager()).search(element, usages, monitor, declarations); + locations.addAll(usages); + } + } + if (locations.size() >= 2000) break; + } + } + return Map.of("locations", locations, "keys", keys, "roots", index.roots, + "incomplete", index.incomplete || locations.size() >= 2000, + "parsedUnits", index.parsed, "revision", index.revision); + } + + private static boolean sameUri(String a, String b) { + URI left = URI.create(a), right = URI.create(b); + if (left.equals(right)) return true; + if (!"file".equals(left.getScheme()) || !"file".equals(right.getScheme())) return false; + try { return Path.of(left).toFile().getCanonicalFile().equals(Path.of(right).toFile().getCanonicalFile()); } + catch (java.io.IOException e) { return false; } + } + private static boolean contains(Location l, int line, int col) { + var r = l.getRange(); + return (line > r.getStart().getLine() || line == r.getStart().getLine() && col >= r.getStart().getCharacter()) && + (line < r.getEnd().getLine() || line == r.getEnd().getLine() && col < r.getEnd().getCharacter()); + } + private static void check(IProgressMonitor monitor) { if (monitor.isCanceled()) throw new OperationCanceledException(); } + private static IJavaProject project(String uri) { + Path path; + try { path = Path.of(URI.create(uri)).toFile().getCanonicalFile().toPath(); } catch (Exception e) { return null; } + IJavaProject best = null; + int length = -1; + for (var p : ResourcesPlugin.getWorkspace().getRoot().getProjects()) { + if (!p.isOpen() || p.getLocation() == null) continue; + var candidate = JavaCore.create(p); + Path root; + try { root = p.getLocation().toFile().getCanonicalFile().toPath(); } catch (java.io.IOException e) { continue; } + if (candidate.exists() && path.startsWith(root) && root.getNameCount() > length) { + best = candidate; length = root.getNameCount(); + } + } + return best; + } + private static Location location(String handle) throws JavaModelException { + IJavaElement element = JavaCore.create(handle); + if (element == null || !element.exists()) return null; + Location result = JDTUtils.toLocation(element); + if (result == null && element instanceof IMember member && member.getClassFile() != null) + result = JDTUtils.toLocation(member.getClassFile()); + return result; + } + + private static synchronized Index index(IJavaProject project, IProgressMonitor monitor) throws Exception { + long revision = REVISION.get(); + String id = project.getHandleIdentifier(); + Map copies = new TreeMap<>(); + for (ICompilationUnit copy : JavaCore.getWorkingCopies(null)) copies.put(copy.getPrimary().getHandleIdentifier(), copy); + for (ICompilationUnit copy : JavaCore.getWorkingCopies(JavaLanguageServerPlugin.getInstance().getWorkingCopyOwner())) + copies.put(copy.getPrimary().getHandleIdentifier(), copy); + var digest = java.security.MessageDigest.getInstance("SHA-256"); + for (var entry : copies.entrySet()) { + check(monitor); + digest.update(entry.getKey().getBytes(java.nio.charset.StandardCharsets.UTF_8)); + String source = entry.getValue().getSource(); + if (source != null) digest.update(source.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + String workingCopies = HexFormat.of().formatHex(digest.digest()); + Index cached = CACHE.get(id); + if (cached != null && cached.revision == revision && cached.workingCopies.equals(workingCopies)) { + boolean valid = true; + for (var entry : cached.files.entrySet()) { + check(monitor); + if (!entry.getValue().equals(stamp(entry.getKey()))) { valid = false; break; } + } + if (valid) return cached; + } + Builder builder = new Builder(monitor); + Set units = new HashSet<>(), roots = new LinkedHashSet<>(); + int visited = 0; + for (IPackageFragmentRoot root : project.getAllPackageFragmentRoots()) { + check(monitor); + if (root.getKind() == IPackageFragmentRoot.K_SOURCE) { + if (root.getResource() != null && root.getResource().getLocationURI() != null) + roots.add(root.getResource().getLocationURI().toString()); + if (root.getJavaProject().getProject().getLocationURI() != null) + roots.add(root.getJavaProject().getProject().getLocationURI().toString()); + if (root.getResource() != null && root.getResource().getLocation() != null) + builder.metadataDirectory(root.getResource().getLocation().toFile().toPath(), project); + for (IJavaElement child : root.getChildren()) { + if (!(child instanceof IPackageFragment pkg)) continue; + for (ICompilationUnit unit : pkg.getCompilationUnits()) { + check(monitor); + unit = copies.getOrDefault(unit.getPrimary().getHandleIdentifier(), unit); + if (!units.add(unit.getHandleIdentifier())) continue; + if (++visited > MAX_UNITS || builder.entries.size() >= MAX_ENTRIES) { builder.incomplete = true; break; } + String source = unit.getSource(); + if (source == null) continue; + if (source.length() > MAX_BYTES) { builder.incomplete = true; continue; } + // Cheap lexical filter only chooses candidates; all accepted symbols + // below are verified by JDT bindings, including constants and imports. + if (source.contains("ConfigurationProperties") || source.contains("Value") || source.contains("getProperty") || source.contains("getRequiredProperty")) + builder.parse(unit); + } + } + } else if (root.isArchive()) { + builder.metadata(root, project); + } + } + // Resource roots are not necessarily Java package roots (e.g. Maven resources). + // Return project locations as well; the client excludes nested independent modules. + roots.add(project.getProject().getLocationURI().toString()); + builder.metadataDirectory(project.getProject().getLocation().toFile().toPath().resolve("src/main/resources"), project); + Index result = new Index(revision, workingCopies, List.copyOf(builder.entries), List.copyOf(roots), Map.copyOf(builder.files), builder.incomplete, builder.parsed); + CACHE.remove(id); + while (!CACHE.isEmpty() && (CACHE.size() >= 8 || + CACHE.values().stream().mapToInt(value -> value.entries.size()).sum() + result.entries.size() > MAX_ENTRIES)) + CACHE.remove(CACHE.keySet().iterator().next()); + CACHE.put(id, result); + return result; + } + + /** Relaxed matching is reserved for binder/metadata entries. @Value and + * Environment lookups retain their literal names; dots remain boundaries. */ + static boolean matches(String pattern, String key, boolean relaxed) { + if (!relaxed) return pattern.equals(key); + List a = parts(pattern), b = parts(key); + if (a.size() != b.size()) return false; + for (int i = 0; i < a.size(); i++) if (!a.get(i).equals("*") && !a.get(i).equals(b.get(i))) return false; + return true; + } + private static List parts(String key) { + List result = new ArrayList<>(); + StringBuilder part = new StringBuilder(); + for (int i = 0; i < key.length(); i++) { + char ch = key.charAt(i); + if (ch == '[') { + if (!part.isEmpty()) { result.add(canonical(part.toString())); part.setLength(0); } + int end = key.indexOf(']', i + 1); + if (end < 0) { part.append(key.substring(i)); break; } + result.add(canonical(key.substring(i + 1, end))); i = end; + } else if (ch == '.') { + if (!part.isEmpty() || i == 0 || key.charAt(i - 1) != ']') result.add(canonical(part.toString())); + part.setLength(0); + } else part.append(ch); + } + if (!part.isEmpty()) result.add(canonical(part.toString())); + return result; + } + private static String canonical(String key) { + return key.replace("-", "").replace("_", "").toLowerCase(Locale.ROOT); + } + + private static final class Builder { + final IProgressMonitor monitor; + final List entries = new ArrayList<>(); + final Map files = new LinkedHashMap<>(); + boolean incomplete; + int parsed; + Builder(IProgressMonitor monitor) { this.monitor = monitor; } + void add(String key, IJavaElement element) { + if (key.length() > 1024 || entries.size() >= MAX_ENTRIES) { incomplete = true; return; } + if (element != null) entries.add(new Entry(key, element.getHandleIdentifier(), null, true)); + } + void use(String key, ICompilationUnit unit, ASTNode node) { + if (key.length() > 1024 || entries.size() >= MAX_ENTRIES) { incomplete = true; return; } + if (key.isBlank() || key.contains("${") || key.contains("#{")) return; + try { entries.add(new Entry(key, "", JDTUtils.toLocation(unit, node.getStartPosition(), node.getLength()), false)); } + catch (JavaModelException e) { incomplete = true; } + } + void parse(ICompilationUnit unit) { + check(monitor); + ASTParser parser = ASTParser.newParser(AST.getJLSLatest()); + parser.setSource(unit); + parser.setResolveBindings(true); + parser.setWorkingCopyOwner(JavaLanguageServerPlugin.getInstance().getWorkingCopyOwner()); + parser.setBindingsRecovery(false); + CompilationUnit tree = (CompilationUnit) parser.createAST(monitor); + parsed++; + tree.accept(new ASTVisitor() { + @Override public void preVisit(ASTNode node) { check(monitor); } + @Override public boolean visit(NormalAnnotation node) { annotation(node); return true; } + @Override public boolean visit(SingleMemberAnnotation node) { annotation(node); return true; } + @Override public boolean visit(MarkerAnnotation node) { annotation(node); return true; } + private void annotation(Annotation node) { + IAnnotationBinding binding = node.resolveAnnotationBinding(); + if (binding == null) return; + String name = binding.getAnnotationType().getQualifiedName(); + if (name.equals(PROPERTIES)) { + String prefix = annotationValue(binding, "prefix"); + if (prefix == null || prefix.isEmpty()) prefix = annotationValue(binding, "value"); + if (prefix == null) prefix = ""; + ASTNode owner = node.getParent(); + if (owner instanceof AbstractTypeDeclaration type) { + bind(prefix, type.resolveBinding(), new HashSet<>(), 0); + } else if (owner instanceof MethodDeclaration method && method.resolveBinding() != null) { + add(prefix, method.resolveBinding().getJavaElement()); + bind(prefix, method.resolveBinding().getReturnType(), new HashSet<>(), 0); + } + } else if (name.equals(VALUE)) { + String value = annotationValue(binding, "value"); + ASTNode expression = node; + if (node instanceof SingleMemberAnnotation single) expression = single.getValue(); + else if (node instanceof NormalAnnotation normal) { + for (Object pair : normal.values()) if (pair instanceof MemberValuePair p && p.getName().getIdentifier().equals("value")) expression = p.getValue(); + } + if (value != null) placeholders(value, unit, expression); + } + } + @Override public boolean visit(MethodInvocation node) { + IMethodBinding method = node.resolveMethodBinding(); + if (method != null && (method.getName().equals("getProperty") || method.getName().equals("getRequiredProperty")) && + subtype(method.getDeclaringClass(), RESOLVER, new HashSet<>()) && !node.arguments().isEmpty()) { + Expression arg = (Expression) node.arguments().get(0); + Object constant = arg.resolveConstantExpressionValue(); + if (constant instanceof String key) use(key, unit, arg); + } + return true; + } + }); + } + void placeholders(String text, ICompilationUnit unit, ASTNode node) { + // Include nested default placeholders, but never a dynamically composed name. + for (int at = text.indexOf("${"); at >= 0; at = text.indexOf("${", at + 2)) { + int end = at + 2; + while (end < text.length() && text.charAt(end) != ':' && text.charAt(end) != '}') end++; + if (end < text.length()) use(text.substring(at + 2, end), unit, node); + } + } + void bind(String prefix, ITypeBinding type, Set visiting, int depth) { + check(monitor); + if (type == null || type.isRecovered() || type.isPrimitive() || type.isEnum()) return; + String qualified = type.getErasure().getQualifiedName(); + if (qualified.startsWith("java.") || qualified.startsWith("javax.")) return; + if (depth > 12 || !visiting.add(type.getKey())) return; + try { + if (!prefix.isEmpty()) add(prefix, type.getJavaElement()); + List constructors = Arrays.stream(type.getDeclaredMethods()) + .filter(m -> m.isConstructor() && !Modifier.isPrivate(m.getModifiers())).toList(); + for (IMethodBinding constructor : constructors) { + boolean explicit = Arrays.stream(constructor.getAnnotations()).anyMatch(a -> + a.getAnnotationType().getQualifiedName().equals("org.springframework.boot.context.properties.bind.ConstructorBinding") || + a.getAnnotationType().getQualifiedName().equals("org.springframework.boot.context.properties.ConstructorBinding")); + if (!explicit && constructors.size() != 1) continue; + String[] parameters = constructor.getParameterNames(); + for (int i = 0; i < parameters.length; i++) { + String name = parameters[i]; + for (IAnnotationBinding annotation : constructor.getParameterAnnotations(i)) { + if (annotation.getAnnotationType().getQualifiedName().equals("org.springframework.boot.context.properties.bind.Name")) { + String alias = annotationValue(annotation, "value"); + if (alias != null) name = alias; + } + } + // Prefer fields/accessors for ordinary parameters; aliases and + // constructor-only properties still have a navigable declaration. + final String parameterName = name; + if (Arrays.stream(type.getDeclaredFields()).noneMatch(f -> f.getName().equals(parameterName))) { + add(join(prefix, name), constructor.getJavaElement()); + nested(join(prefix, name), constructor.getParameterTypes()[i], constructor.getJavaElement(), visiting, depth + 1); + } + } + } + Set names = new HashSet<>(); + for (ITypeBinding current = type; current != null && !current.getQualifiedName().equals("java.lang.Object"); current = current.getSuperclass()) { + for (IVariableBinding field : current.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers()) || field.isSynthetic() || !names.add(field.getName())) continue; + String key = join(prefix, field.getName()); + add(key, field.getJavaElement()); + nested(key, field.getType(), field.getJavaElement(), visiting, depth + 1); + } + for (IMethodBinding method : current.getDeclaredMethods()) { + if (Modifier.isStatic(method.getModifiers()) || method.isConstructor()) continue; + String name = method.getName(); + String property = null; + ITypeBinding propertyType = null; + if (name.startsWith("set") && name.length() > 3 && method.getParameterTypes().length == 1) { + property = name.substring(3); propertyType = method.getParameterTypes()[0]; + } else if (name.startsWith("get") && name.length() > 3 && method.getParameterTypes().length == 0) { + property = name.substring(3); propertyType = method.getReturnType(); + } + if (property != null) { + property = Character.toLowerCase(property.charAt(0)) + property.substring(1); + String key = join(prefix, property); + add(key, method.getJavaElement()); + if (names.add(property)) nested(key, propertyType, method.getJavaElement(), visiting, depth + 1); + } + } + } + } finally { visiting.remove(type.getKey()); } + } + void nested(String key, ITypeBinding type, IJavaElement owner, Set visiting, int depth) { + if (type == null) return; + if (type.isArray()) { add(key + "[*]", owner); bind(key + "[*]", type.getElementType(), visiting, depth); return; } + String raw = type.getErasure().getQualifiedName(); + if (subtype(type, "java.util.Map", new HashSet<>())) { + add(key + ".*", owner); + var args = type.getTypeArguments(); + if (args.length == 2) bind(key + ".*", args[1], visiting, depth); + } else if (subtype(type, "java.util.Collection", new HashSet<>())) { + add(key + "[*]", owner); + var args = type.getTypeArguments(); + if (args.length == 1) bind(key + "[*]", args[0], visiting, depth); + } else if (!type.isPrimitive() && !type.isEnum() && !raw.startsWith("java.") && !raw.startsWith("javax.")) { + bind(key, type, visiting, depth); + } + } + void metadata(IPackageFragmentRoot root, IJavaProject project) { + check(monitor); + Path path = root.getPath().toFile().toPath(); + if (!root.isExternal() && root.getResource() != null && root.getResource().getLocation() != null) + path = root.getResource().getLocation().toFile().toPath(); + files.put(path, stamp(path)); + if (!Files.isRegularFile(path)) return; + try (ZipFile zip = new ZipFile(path.toFile())) { + for (String filename : List.of("spring-configuration-metadata.json", "additional-spring-configuration-metadata.json")) { + var entry = zip.getEntry("META-INF/" + filename); + if (entry == null) continue; + if (entry.getSize() > MAX_BYTES) { incomplete = true; continue; } + JsonObject metadata; + try (var stream = zip.getInputStream(entry)) { + byte[] bytes = stream.readNBytes(MAX_BYTES + 1); + if (bytes.length > MAX_BYTES) { incomplete = true; continue; } + metadata = JsonParser.parseString(new String(bytes, java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject(); + } + metadataObject(metadata, project); + } + } catch (Exception e) { if (e instanceof OperationCanceledException) throw (OperationCanceledException)e; incomplete = true; } + } + void metadataObject(JsonObject metadata, IJavaProject project) throws JavaModelException { + JsonArray groups = metadata.has("groups") ? metadata.getAsJsonArray("groups") : new JsonArray(); + for (JsonElement value : groups) { + check(monitor); + JsonObject group = value.getAsJsonObject(); + String name = string(group, "name"), source = string(group, "sourceType"); + if (source == null) source = string(group, "type"); + if (name != null && source != null) add(name, project.findType(source.replace('$', '.'), monitor)); + } + if (!metadata.has("properties")) return; + for (JsonElement value : metadata.getAsJsonArray("properties")) { + check(monitor); + if (entries.size() >= MAX_ENTRIES) { incomplete = true; return; } + JsonObject property = value.getAsJsonObject(); + String key = string(property, "name"), source = string(property, "sourceType"), prefix = ""; + if (key == null) continue; + JsonObject owner = null; + for (JsonElement groupValue : groups) { + JsonObject group = groupValue.getAsJsonObject(); + String name = string(group, "name"); + if (name != null && key.startsWith(name + ".") && name.length() > prefix.length()) { + prefix = name; owner = group; + } + } + if (source == null && owner != null) source = string(owner, "type"); + if (source == null && owner != null) source = string(owner, "sourceType"); + if (source == null) continue; + IType type = project.findType(source.replace('$', '.'), monitor); + if (type == null) continue; + String leaf = key.substring(prefix.isEmpty() ? 0 : prefix.length() + 1); + IJavaElement target = member(type, leaf, new HashSet<>()); + if (target == null && owner != null && string(owner, "sourceMethod") != null) { + String declaring = string(owner, "sourceType"); + IType factory = declaring == null ? null : project.findType(declaring.replace('$', '.'), monitor); + if (factory != null) { + String methodName = string(owner, "sourceMethod").split("\\(", 2)[0]; + for (IMethod method : factory.getMethods()) if (method.getElementName().equals(methodName)) { target = method; break; } + } + } + add(key, target == null ? type : target); + } + } + void metadataDirectory(Path directory, IJavaProject project) { + for (String name : List.of("spring-configuration-metadata.json", "additional-spring-configuration-metadata.json")) { + Path file = directory.resolve("META-INF").resolve(name); + files.put(file, stamp(file)); + if (!Files.isRegularFile(file)) continue; + try { + if (Files.size(file) > MAX_BYTES) { incomplete = true; continue; } + try (var stream = Files.newInputStream(file)) { + byte[] bytes = stream.readNBytes(MAX_BYTES + 1); + if (bytes.length > MAX_BYTES) { incomplete = true; continue; } + metadataObject(JsonParser.parseString(new String(bytes, java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject(), project); + } + } catch (Exception e) { if (e instanceof OperationCanceledException) throw (OperationCanceledException)e; incomplete = true; } + } + } + } + private static IJavaElement member(IType type, String key, Set seen) throws JavaModelException { + if (type == null || !seen.add(type.getHandleIdentifier())) return null; + String part = key.split("\\.", 2)[0]; + for (IField field : type.getFields()) if (canonical(field.getElementName()).equals(canonical(part))) { + if (!key.contains(".")) return field; + String signature = Signature.toString(Signature.getTypeErasure(field.getTypeSignature())); + String[][] resolved = type.resolveType(signature); + IType nested = type.getJavaProject().findType(signature); + if (nested == null && resolved != null && resolved.length == 1) + nested = type.getJavaProject().findType(resolved[0][0] + "." + resolved[0][1]); + IJavaElement found = member(nested, key.substring(key.indexOf('.') + 1), seen); + return found == null ? field : found; + } + for (IMethod method : type.getMethods()) { + String name = method.getElementName(); + if (name.length() > 3 && (name.startsWith("get") || name.startsWith("set")) && canonical(name.substring(3)).equals(canonical(part))) return method; + } + String parent = type.getSuperclassName(); + if (parent != null) { + String[][] resolved = type.resolveType(parent); + if (resolved != null && resolved.length == 1) + return member(type.getJavaProject().findType(resolved[0][0] + "." + resolved[0][1]), key, seen); + } + return null; + } + private static String string(JsonObject object, String key) { + return object.has(key) && object.get(key).isJsonPrimitive() ? object.get(key).getAsString() : null; + } + private static String annotationValue(IAnnotationBinding binding, String name) { + for (IMemberValuePairBinding pair : binding.getAllMemberValuePairs()) + if (pair.getName().equals(name) && pair.getValue() instanceof String s) return s; + return null; + } + private static String join(String prefix, String name) { return prefix.isEmpty() ? name : prefix + "." + name; } + private static boolean subtype(ITypeBinding type, String name, Set seen) { + if (type == null || !seen.add(type.getKey())) return false; + if (type.getErasure().getQualifiedName().equals(name)) return true; + for (ITypeBinding face : type.getInterfaces()) if (subtype(face, name, seen)) return true; + return subtype(type.getSuperclass(), name, seen); + } +} diff --git a/plugins/java-usages/tests/configuration.py b/plugins/java-usages/tests/configuration.py new file mode 100644 index 000000000..9a42f4a0d --- /dev/null +++ b/plugins/java-usages/tests/configuration.py @@ -0,0 +1,220 @@ +"""Real JDT LS configuration-navigation regression and warm-query benchmark. +Run with Java 25 on PATH: python3 configuration.py /path/to/jdtls +Fixtures use small Spring API stubs; no Maven/network/build of a user's project. +""" +import os +import json +import pathlib +import platform +import queue +import subprocess +import sys +import tempfile +import threading +import time +import zipfile + +home = pathlib.Path(sys.argv[1]).resolve() +bundle = pathlib.Path(__file__).resolve().parents[1] / 'openidea-java-usages.jar' +work = pathlib.Path(tempfile.mkdtemp(prefix='openidea-configuration-')) +project = work / 'project' +source_dir = project / 'src' +source_dir.mkdir(parents=True) + +def write(relative, text): + file = source_dir / relative + file.parent.mkdir(parents=True, exist_ok=True) + file.write_text(text) + return file + +write('org/springframework/boot/context/properties/ConfigurationProperties.java', '''package org.springframework.boot.context.properties; +public @interface ConfigurationProperties { String value() default ""; String prefix() default ""; }''') +write('org/springframework/boot/context/properties/bind/Name.java', 'package org.springframework.boot.context.properties.bind; public @interface Name { String value(); }') +write('org/springframework/beans/factory/annotation/Value.java', '''package org.springframework.beans.factory.annotation; +public @interface Value { String value(); }''') +write('org/springframework/core/env/PropertyResolver.java', '''package org.springframework.core.env; +public interface PropertyResolver { String getProperty(String key); String getRequiredProperty(String key); }''') +write('org/springframework/core/env/Environment.java', '''package org.springframework.core.env; +public interface Environment extends PropertyResolver {}''') +source = '''package demo; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.env.Environment; +@ConfigurationProperties(prefix="app") +class Settings extends Parent { + int timeOut; + Nested nested; + java.util.List clients; + java.util.Map routes; + java.util.List tags; + java.util.Map labels; + public int getTimeOut() { return timeOut; } + public void setTimeOut(int value) { timeOut = value; } +} +class Parent { boolean enabled; } +class Nested { String url; } +@ConfigurationProperties("record") +record RecordSettings(String name, int count) {} +@ConfigurationProperties("constructor") +class ConstructorSettings { + ConstructorSettings(@org.springframework.boot.context.properties.bind.Name("remote-name") String name) {} +} +class Consumers { + static final String KEY = "app.time-out"; + @Value("${app.time-out:30}") String timeout; + @Value("${app.missing:${app.backup:42}}") String fallback; + String call(Environment env) { return env.getProperty(KEY); } + String required(Environment env) { return env.getRequiredProperty("app.required"); } + String dynamic(Environment env, String key) { return env.getProperty(key); } + String unrelated(Fake env) { return env.getProperty("app.time-out"); } + @ConfigurationProperties("bean") Nested bean() { return new Nested(); } +} +class Fake { String getProperty(String name) { return name; } } +''' +file = write('demo/Settings.java', source) +if os.environ.get('OPENIDEA_CONFIGURATION_STRESS') == '1': + for i in range(3000): + write(f'noise/Plain{i}.java', f'package noise; class Plain{i} {{ int number; }}') + for i in range(100): + write(f'noise/Config{i}.java', f'package noise; @org.springframework.boot.context.properties.ConfigurationProperties(prefix="stress{i}") class Config{i} {{ int value; }}') +write('demo/NotSpring.java', '''package demo; +@interface ConfigurationProperties { String prefix(); } +@ConfigurationProperties(prefix="fake") class NotSpring { int timeOut; } +''') +local_metadata = project / 'src/main/resources/META-INF' +local_metadata.mkdir(parents=True) +(local_metadata / 'additional-spring-configuration-metadata.json').write_text(json.dumps({ + 'groups': [{'name': 'extra', 'sourceType': 'demo.Nested', 'type': 'demo.Nested'}], + 'properties': [{'name': 'extra.url', 'sourceType': 'demo.Nested'}] +})) +# A dependency with metadata and attached source exercises actual library locations. +libsource = work / 'libsrc/vendor/LibrarySettings.java' +libsource.parent.mkdir(parents=True) +libsource.write_text('package vendor; public class LibrarySettings { public int port; public int getPort() { return port; } }') +classes = work / 'libclasses' +classes.mkdir() +subprocess.run(['javac', '--release', '21', '-d', str(classes), str(libsource)], check=True) +library = work / 'library.jar' +metadata = {'groups': [{'name': 'vendor', 'type': 'vendor.LibrarySettings', 'sourceType': 'vendor.LibrarySettings'}], + 'properties': [{'name': 'vendor.port', 'type': 'java.lang.Integer', 'sourceType': 'vendor.LibrarySettings'}]} +with zipfile.ZipFile(library, 'w') as z: + z.write(classes / 'vendor/LibrarySettings.class', 'vendor/LibrarySettings.class') + z.writestr('META-INF/spring-configuration-metadata.json', json.dumps(metadata)) +sourcejar = work / 'library-sources.jar' +with zipfile.ZipFile(sourcejar, 'w') as z: + z.write(libsource, 'vendor/LibrarySettings.java') +(project / '.project').write_text('configurationorg.eclipse.jdt.core.javabuilderorg.eclipse.jdt.core.javanature') +spring_jar = os.environ.get('OPENIDEA_SPRING_BOOT_JAR') +extra_classpath = f'' if spring_jar else '' +(project / '.classpath').write_text(f'{extra_classpath}') +config = work / 'config' +config.mkdir() +name = {'Darwin': 'config_mac', 'Windows': 'config_win'}.get(platform.system(), 'config_linux') +names = ([name + '_arm'] if platform.machine().lower() in ('arm64', 'aarch64') else []) + [name] +(config / 'config.ini').write_bytes(next(home / n / 'config.ini' for n in names if (home / n / 'config.ini').exists()).read_bytes()) +log = (work / 'stderr.log').open('w') +proc = subprocess.Popen(['java', '-Declipse.application=org.eclipse.jdt.ls.core.id1', '-Dosgi.bundles.defaultStartLevel=4', '-Declipse.product=org.eclipse.jdt.ls.core.product', '-Xmx1G', '--add-modules=ALL-SYSTEM', '--add-opens', 'java.base/java.util=ALL-UNNAMED', '--add-opens', 'java.base/java.lang=ALL-UNNAMED', '-jar', str(next((home / 'plugins').glob('org.eclipse.equinox.launcher_*.jar'))), '-configuration', str(config), '-data', str(work / 'metadata')], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log) +messages = queue.Queue() +lock = threading.Lock() +def send(message): + data = json.dumps(message).encode() + with lock: + proc.stdin.write(f'Content-Length: {len(data)}\r\n\r\n'.encode() + data) + proc.stdin.flush() +def read(): + while True: + headers = {} + while True: + line = proc.stdout.readline() + if not line: return + if line == b'\r\n': break + k, v = line.decode().split(':', 1) + headers[k.lower()] = v.strip() + message = json.loads(proc.stdout.read(int(headers['content-length']))) + if 'method' in message and 'id' in message: + send({'jsonrpc': '2.0', 'id': message['id'], 'result': [] if message['method'] == 'workspace/configuration' else None}) + else: messages.put(message) +threading.Thread(target=read, daemon=True).start() +sequence = 0 +transcript = [] +def request(method, params): + global sequence + sequence += 1 + send({'jsonrpc': '2.0', 'id': sequence, 'method': method, 'params': params}) + deadline = time.monotonic() + 180 + while time.monotonic() < deadline: + msg = messages.get(timeout=max(1, deadline - time.monotonic())) + transcript.append(msg) + if msg.get('id') == sequence: + if 'error' in msg: raise RuntimeError(msg['error']) + return msg.get('result') + raise TimeoutError(method) +def query(key, uri=None, line=0, col=0): + return request('workspace/executeCommand', {'command': 'openidea.java.configuration', 'arguments': [uri or (project / 'application.yml').as_uri(), key, line, col, key is None, False]}) +def names(result): + return {p['name'] for p in result['keys']} +try: + init = request('initialize', {'processId': None, 'rootUri': project.as_uri(), 'workspaceFolders': [{'uri': project.as_uri(), 'name': 'configuration'}], 'capabilities': {'textDocument': {'definition': {'linkSupport': False}}}, 'initializationOptions': {'bundles': [str(bundle)], 'extendedClientCapabilities': {'classFileContentsSupport': True}, 'settings': {'java': {'import': {'gradle': {'enabled': False}, 'maven': {'enabled': False}}}}}}) + send({'jsonrpc': '2.0', 'method': 'initialized', 'params': {}}) + send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': file.as_uri(), 'languageId': 'java', 'version': 1, 'text': source}}}) + request('java/buildWorkspace', True) + assert 'openidea.java.configuration' in init['capabilities']['executeCommandProvider']['commands'], init + results = {} + for key in ['app', 'vendor', 'app.time-out', 'app.enabled', 'app.nested.url', 'app.clients[0].url', 'app.routes.east.url', 'app.tags[0]', 'app.labels.region', 'app.labels[region.name]', 'constructor.remote-name', 'record.name', 'bean.url', 'app.backup', 'app.required', 'vendor.port', 'extra.url']: + start = time.perf_counter() + result = query(key) + results[key] = {'seconds': time.perf_counter() - start, 'response': result} + assert result['locations'], (key, result) + attached = request('java/classFileContents', {'uri': results['vendor.port']['response']['locations'][0]['uri']}) + assert attached and 'LibrarySettings' in attached and 'port' in attached, attached + if spring_jar: + for key in ['server.port', 'spring.datasource.url', 'spring.jackson.date-format']: + result = query(key) + assert result['locations'], (key, result) + assert all(loc['uri'].startswith('jdt://') for loc in result['locations']), result + results[key] = {'response': result} + if key == 'server.port': + decompiled = request('java/classFileContents', {'uri': result['locations'][0]['uri']}) + assert decompiled and 'ServerProperties' in decompiled and 'port' in decompiled, decompiled + assert not query('app.tags[0].value')['locations'] + assert not query('app.labels.region.coder')['locations'] + assert not query('fake.time-out')['locations'] + assert not query('app.does-not-exist')['locations'] + metadata_file = local_metadata / 'additional-spring-configuration-metadata.json' + metadata_file.write_text(metadata_file.read_text().replace('extra', 'newer')) + assert query('newer.url')['locations'] + assert not query('extra.url')['locations'] + # Property references include member usages and consumers, without declarations. + references = request('workspace/executeCommand', {'command': 'openidea.java.configuration', + 'arguments': [(project / 'application.yml').as_uri(), 'app.time-out', 0, 0, True, False]}) + assert references['locations'], references + assert any(loc['range']['start']['line'] == next(i for i, line in enumerate(source.splitlines()) if 'getProperty(KEY)' in line) for loc in references['locations']), references + # 3 binding targets + @Value + Environment constant; Fake.getProperty excluded. + timeout = query('app.time-out') + assert len(timeout['locations']) == 5, timeout + for needle, expected in [('int timeOut;', 'app.timeOut'), ('"${app.time-out:30}"', 'app.time-out'), ('env.getProperty(KEY)', 'app.time-out')]: + line, text = next((i, s) for i, s in enumerate(source.splitlines()) if needle in s) + col = text.index(needle) + (4 if needle == 'int timeOut;' else (len('env.getProperty(') if needle.startswith('env.') else 4)) + reverse = query(None, file.as_uri(), line, col) + assert expected in names(reverse), (needle, reverse) + warm = [] + for _ in range(20): + start = time.perf_counter(); query('app.time-out'); warm.append(time.perf_counter() - start) + # Unsaved changes must replace old keys, and closing must restore disk keys. + changed = source.replace('prefix="app"', 'prefix="edited"').replace('${app.time-out:30}', '${edited.timeout:30}') + send({'jsonrpc': '2.0', 'method': 'textDocument/didChange', 'params': {'textDocument': {'uri': file.as_uri(), 'version': 2}, 'contentChanges': [{'text': changed}]}}) + edit_start = time.perf_counter() + updated = query('edited.time-out') + edit_ms = (time.perf_counter() - edit_start) * 1000 + assert updated['locations'], updated + assert not query('app.nested.url')['locations'] + send({'jsonrpc': '2.0', 'method': 'textDocument/didClose', 'params': {'textDocument': {'uri': file.as_uri()}}}) + restored = query('app.nested.url') + assert restored['locations'], restored + evidence = {'stress': os.environ.get('OPENIDEA_CONFIGURATION_STRESS') == '1', 'editRebuildMs': edit_ms, 'cases': results, 'warmMedianMs': sorted(warm)[len(warm)//2] * 1000, 'warmMaxMs': max(warm) * 1000} + (work / 'evidence.json').write_text(json.dumps(evidence, indent=2)) + print(json.dumps(evidence, indent=2)) +finally: + (work / 'transcript.json').write_text(json.dumps(transcript, indent=2)) + proc.terminate(); proc.wait(timeout=20) + print('Evidence:', work, file=sys.stderr)