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 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Цей коміт міститься в:
qiaojianlongтаClaude Opus 5.5 authored and GitHub committed 2026-11-02 22:23:09 +08:00
1 джерело 7fdf1d62c3
коміт d981603f3c
18 files changed
+1674 -15

No files matched your search

+1 -1
Переглянути файл
@@ -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));
}
}
+2
Переглянути файл
@@ -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!(
+3 -3
Переглянути файл
@@ -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/<id>.toml`. Backend-only; the UI gets [`LanguageInfo`].
+242
Переглянути файл
@@ -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<Vec<ConfigurationKey>, 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<ConfigurationKey> {
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<ConfigurationKey>,
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::<String>(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<ConfigurationKey> {
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<String> {
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::<Vec<_>>(), ["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::<Vec<_>>(),
["app.time-out", "app.name", "app.a", "space key"]
);
assert_eq!(keys[0].range.end.line, 2);
}
}
+2
Переглянути файл
@@ -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};
+1
Переглянути файл
@@ -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.
+416
Переглянути файл
@@ -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<PathBuf, CachedFile>,
}
struct CachedFile {
stamp: (SystemTime, u64),
keys: Vec<ConfigurationKey>,
}
#[derive(Deserialize)]
struct Pattern {
name: String,
relaxed: bool,
}
#[derive(Deserialize)]
struct Response {
#[serde(default)]
locations: Vec<lsp_types::Location>,
#[serde(default)]
keys: Vec<Pattern>,
#[serde(default)]
roots: Vec<String>,
#[serde(default)]
incomplete: bool,
}
fn syntax() -> &'static Syntax {
static SYNTAX: OnceLock<Syntax> = OnceLock::new();
SYNTAX.get_or_init(Syntax::new)
}
fn language(path: &Path) -> Option<LanguageId> {
match path.extension()?.to_str()? {
"yaml" | "yml" => Some(LanguageId::new("yaml")),
"properties" => Some(LanguageId::new("properties")),
_ => None,
}
}
fn parts(key: &str) -> Vec<String> {
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<Option<References>> {
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<String>,
usages: bool,
include_declaration: bool,
cancel: &CancellationToken,
) -> IdeResult<Option<References>> {
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<PathBuf> = response.roots.iter().filter_map(|uri| super::project_path(uri)).collect();
let overlays: HashMap<PathBuf, String> = self
.docs
.lock()
.values()
.filter(|d| language(&d.path).is_some() && d.text.as_str().len() as u64 <= MAX_BYTES)
.map(|d| (d.canonical.clone(), d.text.as_str().to_owned()))
.collect();
let cache = self.configuration_cache.clone();
let cancelled = cancel.clone();
let keys = response.keys;
let files =
tokio::task::spawn_blocking(move || configuration_files(&roots, &keys, &overlays, &cache, &cancelled))
.await
.map_err(|_| IdeError::unavailable("configuration search worker stopped"))?;
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<PathBuf, String>,
cache: &Mutex<ConfigurationCache>,
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::<usize>() + 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<PathBuf, String>| {
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"));
}
}
+45 -4
Переглянути файл
@@ -179,6 +179,7 @@ pub struct LspManager {
shutting_down: AtomicBool,
execution_allowed: AtomicBool,
java_usages_warned: AtomicBool,
configuration_cache: Arc<Mutex<jdtls::configuration::ConfigurationCache>>,
/// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure.
shown_messages: Mutex<HashSet<(String, String)>>,
/// 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<Vec<Location>> {
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<References> {
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<References> {
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());
}
+89
Переглянути файл
@@ -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"), "<project/>").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"), "<project/>").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);
}
+1 -1
Переглянути файл
@@ -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<PathBuf> {
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);
+1 -1
Переглянути файл
@@ -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;
}
+114
Переглянути файл
@@ -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.
+2 -2
Переглянути файл
@@ -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
+7 -3
Переглянути файл
@@ -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
Бінарний файл не відображається.
+3
Переглянути файл
@@ -4,5 +4,8 @@
<delegateCommandHandler class="ai.openidea.usages.UsagesCommand">
<command id="openidea.java.usages"/>
</delegateCommandHandler>
<delegateCommandHandler class="ai.openidea.configuration.ConfigurationCommand">
<command id="openidea.java.configuration"/>
</delegateCommandHandler>
</extension>
</plugin>
+525
Переглянути файл
@@ -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<String, Index> 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<Entry> entries, List<String> roots,
Map<Path, FileStamp> files, boolean incomplete, int parsed) {}
@Override
public Object executeCommand(String command, List<Object> 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<Map<String, Object>> keys = new LinkedHashSet<>();
Set<Location> 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<Location> 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<String> 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<Location> 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<String, ICompilationUnit> 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<String> 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<String> 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<String> parts(String key) {
List<String> 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<Entry> entries = new ArrayList<>();
final Map<Path, FileStamp> 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<String> 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<IMethodBinding> 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<String> 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<String> 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<String> 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<String> 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);
}
}
+220
Переглянути файл
@@ -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<Nested> clients;
java.util.Map<String, Nested> routes;
java.util.List<String> tags;
java.util.Map<String, String> 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('<projectDescription><name>configuration</name><buildSpec><buildCommand><name>org.eclipse.jdt.core.javabuilder</name></buildCommand></buildSpec><natures><nature>org.eclipse.jdt.core.javanature</nature></natures></projectDescription>')
spring_jar = os.environ.get('OPENIDEA_SPRING_BOOT_JAR')
extra_classpath = f'<classpathentry kind="lib" path="{pathlib.Path(spring_jar).resolve()}"/>' if spring_jar else ''
(project / '.classpath').write_text(f'<classpath><classpathentry kind="src" path="src"/><classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER"/><classpathentry kind="lib" path="{library}" sourcepath="{sourcejar}"/>{extra_classpath}<classpathentry kind="output" path="bin"/></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)