дзеркало
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
@@ -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
Заблокувати користувача