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 0eee59354..3f521b3a3 100644 Binary files a/plugins/java-usages/openidea-java-usages.jar and b/plugins/java-usages/openidea-java-usages.jar differ 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)