perf(java): consult configuration navigation only inside key arguments

Go to Definition in a Java file asked the configuration command first
whenever the file mentioned "Value" or "getProperty", which in Spring code
is nearly every file. The command builds a project-wide index before it
answers, the index is invalidated by any reconcile (opening the target
file is enough), and requests are serialized, so every Ctrl+click and
every Ctrl+hover probe waited seconds for a rebuild before JDT was asked.

A tree-sitter check now limits the configuration lookup to positions
inside a @Value annotation's arguments or a getProperty /
getRequiredProperty call's arguments; everything else goes straight to
JDT's textDocument/definition.
Цей коміт міститься в:
l0ng-ai committed 2026-11-03 17:29:41 +08:00
1 джерело 59766278c5
коміт 1f0b2a43e7
6 files changed
+140 -16

No files matched your search

+80 -2
Переглянути файл
@@ -1,9 +1,9 @@
//! 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::lines::{LineIndex, byte_at};
use super::{ParsedTree, Syntax, TreeError};
use ide_core::{LanguageId, Range};
use ide_core::{LanguageId, Pos, Range};
use tree_sitter::Node;
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -26,6 +26,40 @@ impl Syntax {
}
}
/// Spring's property lookups whose first argument names a configuration key.
const PROPERTY_LOOKUPS: [&str; 2] = ["getProperty", "getRequiredProperty"];
impl Syntax {
/// Whether `pos` in Java `text` lies where a configuration key can be written:
/// inside the arguments of a `@Value` annotation or of a `getProperty` /
/// `getRequiredProperty` call. Everywhere else Go to Definition is ordinary
/// Java navigation and need not consult the configuration index. A file the
/// grammar cannot parse answers false.
pub fn java_configuration_site(&self, text: &str, pos: Pos, cancelled: &dyn Fn() -> bool) -> bool {
let Some(at) = byte_at(text, pos) else { return false };
self.with_parsed(&LanguageId::new("java"), text, cancelled, |tree| {
let mut node = tree.tree.root_node().descendant_for_byte_range(at, at);
while let Some(n) = node {
let Some(parent) = n.parent() else { break };
let callee = parent.child_by_field_name("name").map(|name| &text[name.byte_range()]);
match (n.kind(), parent.kind(), callee) {
("annotation_argument_list", "annotation", Some(name)) => {
return name.rsplit('.').next() == Some("Value");
}
("argument_list", "method_invocation", Some(name)) if PROPERTY_LOOKUPS.contains(&name) => {
return true;
}
// Keys are expressions; nothing above a body or declaration can hold one.
(_, "block" | "class_body" | "interface_body" | "program", _) => return false,
_ => node = Some(parent),
}
}
false
})
.unwrap_or(false)
}
}
impl ParsedTree<'_> {
pub fn configuration_keys(&self, cancelled: &dyn Fn() -> bool) -> Vec<ConfigurationKey> {
let mut out = Vec::new();
@@ -188,6 +222,50 @@ fn unescape_property(raw: &str) -> Option<String> {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn java_configuration_sites() {
let text = r#"package a;
import org.springframework.beans.factory.annotation.Value;
class A {
@Value("${app.port:80}") int port;
@org.springframework.beans.factory.annotation.Value(value = Keys.NAME) String name;
@Values("x") int other;
@Override public String toString() {
String v = env.getProperty("app.host", "h") + env.getRequiredProperty(Keys.USER);
return helper.getValue("😀 app.port") + String.valueOf(port) + env.getProperty("k").trim();
}
}
"#;
let syntax = Syntax::new();
let at = |needle: &str, offset: u32| {
let line = text.lines().position(|l| l.contains(needle)).unwrap();
let byte = text.lines().nth(line).unwrap().find(needle).unwrap();
let col = text.lines().nth(line).unwrap()[..byte].encode_utf16().count() as u32 + offset;
syntax.java_configuration_site(text, Pos::new(line as u32, col), &|| false)
};
// @Value arguments (literal, qualified annotation, constant key).
assert!(at("${app.port", 2));
assert!(at("Keys.NAME", 6));
// Lookup arguments, including a constant and the default value.
assert!(at("\"app.host\"", 3));
assert!(at("\"h\"", 1));
assert!(at("Keys.USER", 1));
assert!(at("\"k\"", 1));
// Ordinary Java: names, other annotations, other calls, the lookup's own name and receiver.
assert!(!at("@Value(\"$", 2));
assert!(!at("int port", 4));
assert!(!at("@Values", 9));
assert!(!at("@Override", 2));
assert!(!at("getProperty(\"app", 3));
assert!(!at("env.getProperty(\"app", 1));
assert!(!at("app.port\")", 1));
assert!(!at("valueOf", 2));
assert!(!at(".trim", 2));
// Out of range and non-Java input.
assert!(!syntax.java_configuration_site(text, Pos::new(99, 0), &|| false));
assert!(!syntax.java_configuration_site("", Pos::new(0, 0), &|| false));
}
#[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";
+10
Переглянути файл
@@ -86,6 +86,16 @@ pub(crate) fn utf16_len(s: &str) -> u32 {
if s.is_ascii() { s.len() as u32 } else { s.chars().map(|c| c.len_utf16() as u32).sum() }
}
/// Byte offset of `pos` (line, UTF-16 column) in `text`; None past the last line.
pub(crate) fn byte_at(text: &str, pos: Pos) -> Option<usize> {
let mut start = 0;
for _ in 0..pos.line {
start += text[start..].find('\n')? + 1;
}
let line_end = text[start..].find('\n').map_or(text.len(), |i| start + i);
Some(start + utf16_to_byte(&text[start..line_end], pos.col as usize))
}
pub(crate) fn utf16_to_byte(s: &str, offset: usize) -> usize {
let mut units = 0;
for (i, c) in s.char_indices() {
+1 -10
Переглянути файл
@@ -6,7 +6,7 @@
//! `<style>` elements of HTML documents and feed the symbol index (see
//! [`super::Syntax::tags`]), so a lookup is an exact index query.
use super::lines::{LineIndex, utf16_to_byte};
use super::lines::{LineIndex, byte_at};
use super::{Grammar, parse};
use crate::SymbolTag;
use ide_core::{Pos, Range, SymbolKind};
@@ -130,15 +130,6 @@ pub(crate) fn definitions(g: &Grammar, css: Option<&Grammar>, tree: &Tree, text:
out
}
fn byte_at(text: &str, pos: Pos) -> Option<usize> {
let mut start = 0;
for _ in 0..pos.line {
start += text[start..].find('\n')? + 1;
}
let line_end = text[start..].find('\n').map_or(text.len(), |i| start + i);
Some(start + utf16_to_byte(&text[start..line_end], pos.col as usize))
}
/// The whitespace-separated token of `value` (starting at byte `base`) around byte `at`.
fn token_at(value: &str, base: usize, at: usize) -> Option<(usize, &str)> {
let rel = at.checked_sub(base)?;
+14 -2
Переглянути файл
@@ -117,13 +117,25 @@ impl LspManager {
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.
// configuration navigation is offered only inside `@Value` / property
// lookup arguments: the command builds a project-wide index, and every
// Ctrl+click and Ctrl+hover probe would otherwise wait on it before JDT
// answers. Field/config accessor usages are merged into Find Usages.
if !config && !usages {
let text = snap.text.as_str();
if !text.contains("Value") && !text.contains("getProperty") && !text.contains("getRequiredProperty") {
return Ok(None);
}
let text = text.to_owned();
let cancelled = cancel.clone();
let site = tokio::task::spawn_blocking(move || {
syntax().java_configuration_site(&text, pos, &|| cancelled.is_cancelled())
})
.await
.unwrap_or(false);
if !site {
return Ok(None);
}
}
let key = if config {
let text = snap.text.as_str().to_owned();
+34 -1
Переглянути файл
@@ -3035,7 +3035,40 @@ async fn configuration_extension_failure_preserves_native_java_navigation() {
"start": {"line": 0, "character": 1}, "end": {"line": 0, "character": 6}
}}),
);
let result = h.mgr.goto(doc, Pos::new(0, 2), GotoKind::Definition).await.unwrap();
let result = h.mgr.goto(doc, Pos::new(0, 12), GotoKind::Definition).await.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(s.requested("workspace/executeCommand").len(), 1);
assert_eq!(s.requested("textDocument/definition").len(), 1);
}
/// The configuration command indexes the whole project, so Go to Definition (and
/// every Ctrl+hover probe) consults it only inside a key argument; ordinary Java
/// symbols in a Spring file go straight to JDT.
#[tokio::test(flavor = "multi_thread")]
async fn java_definition_consults_configuration_only_inside_key_arguments() {
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 text = "class A {\n @Value(\"${app.port}\") int port;\n String a() { return env.getProperty(Keys.HOST) + x.getValue(); }\n}\n";
let (doc, path) = h.open("A.java", text);
h.wait_status("jdtls", ServerStatus::Ready).await;
s.respond("workspace/executeCommand", json!({"locations": [], "keys": [], "roots": []}));
s.respond(
"textDocument/definition",
json!({"uri": uri_of(&path), "range": {
"start": {"line": 0, "character": 6}, "end": {"line": 0, "character": 7}
}}),
);
// Field type, field name, method name, the lookup's receiver and name, another call.
for pos in [Pos::new(1, 24), Pos::new(1, 28), Pos::new(2, 9), Pos::new(2, 22), Pos::new(2, 28), Pos::new(2, 53)] {
assert_eq!(h.mgr.goto(doc, pos, GotoKind::Definition).await.unwrap().len(), 1, "{pos:?}");
}
assert!(s.requested("workspace/executeCommand").is_empty());
assert_eq!(s.requested("textDocument/definition").len(), 6);
// Inside a placeholder and a constant lookup key: configuration first, JDT as fallback.
for pos in [Pos::new(1, 13), Pos::new(2, 41)] {
h.mgr.goto(doc, pos, GotoKind::Definition).await.unwrap();
}
assert_eq!(s.requested("workspace/executeCommand").len(), 2);
}
+1 -1
Переглянути файл
@@ -53,7 +53,7 @@ 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
ordinary typing, code-lens probes, bulk scoped reference analysis, and Java Go to Definition / Ctrl+hover outside a `@Value` or property-lookup argument (checked with the bundled tree-sitter grammar) 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