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