feat(java): navigate Spring configuration keys and usages (#823)

YAML/properties keys stayed inside the generic YAML language server, so Spring configuration bindings and their consumers were unreachable from the editor. A new command in the bundled JDT LS extension (java-usages 1.2.0) resolves keys to @ConfigurationProperties members (prefixes, nested/inherited members, records, constructor aliases, collections), @Value placeholders and PropertyResolver calls, and back from Java to configuration entries; Find Usages merges configuration consumers and declarations with the native Java results. Several profiles or modules are offered as candidates. Closes #796.

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

No files matched your search

+114
Переглянути файл
@@ -0,0 +1,114 @@
# Spring configuration navigation
`openidea.java.configuration` bridges YAML / properties to the existing JDT LS
Java model. It is bundled with the Java usages extension; it does not install a
second language server, run a build, change the application's dependencies, or
connect to external configuration services.
## Editor behavior
- **Go to Definition** on a YAML / properties key opens binding members and
literal consumers in Java. Multiple matches use the existing location chooser.
- **Go to Definition** inside a Java `@Value` placeholder or a constant
`Environment.getProperty` / `getRequiredProperty` argument opens matching
configuration keys. Ordinary Java definition navigation remains native.
- **Find Usages** joins configuration declarations, property consumers, and Java
references to associated binding members. Java's existing reference results and
read/write classifications are retained.
- Open editor contents take precedence over disk. All matching profiles/documents
are shown; navigation does not claim to know the runtime effective value.
- Missing or older extension bundles preserve native language-server behavior.
YAML aliases still use the YAML server's anchor navigation.
## Association sources
The Java command resolves annotation and method identities with JDT bindings:
- `@ConfigurationProperties` on classes and factory methods, including inherited
members, nested objects, records, constructor-only parameters / `@Name`, arrays,
collections and maps. Binder keys use relaxed spelling while retaining path
boundaries. Indexed collection positions and arbitrary map keys use wildcards.
- `@Value("${key:default}")`, including constant annotation values and nested
default placeholders.
- Literal or compile-time constant keys passed to Spring's `PropertyResolver`
methods, including through `Environment`. Unrelated methods with the same name
and non-Spring annotations are excluded.
- Dependency JAR `META-INF/spring-configuration-metadata.json` and
`additional-spring-configuration-metadata.json`. The closest group, source
type, and source method locate a member or its owning class. Source attachments
and JDT class-file/decompiler navigation use the existing virtual-document path.
- The same metadata files in source roots and `src/main/resources`.
YAML key ranges come from the bundled tree-sitter grammar, including nested/flow
mappings, sequences, quoted keys and multiple documents. Properties support
escaped keys, Unicode escapes and logical-line continuations. Ranges crossing
IPC are UTF-16. No new IPC type or frontend provider is required.
## Scope and costs
The command uses the selected JDT project's resolved package roots, including
source dependencies and its dependency JARs. Configuration search includes those
modules, skips nested independent modules and build output, and honors ignore
files for disk discovery. It does not guess a global association from matching
text in unrelated projects.
Indexing is lazy. The first explicit navigation/search builds a compact snapshot;
ordinary typing, code-lens probes and bulk scoped reference analysis do not start indexing. At most eight project snapshots are
retained, with a shared 100,000-association retention cap. Records contain property names, element handles and source locations;
ASTs and bindings are discarded after indexing. A lexical prefilter avoids AST
construction for ordinary Java units, but every accepted association is resolved
semantically. Warm requests reuse the snapshot. Java model/reconcile changes
invalidate it conservatively, including dependent-type changes; open working-copy
content fingerprints also prevent stale answers before reconciliation. This is
**snapshot invalidation**, not a fully incremental Java semantic index.
Metadata/JAR timestamps are checked before reuse. The client caches parsed disk
configuration files by modification time and size (at most 100,000 cached keys), overlays open buffers, and
performs bounded discovery/parsing on a blocking worker. It does not continuously
watch or parse an additional copy of the entire workspace.
Bounds: 20,000 Java units, 100,000 associations per snapshot, 2 MiB per candidate
metadata/configuration file (Java source: 2M UTF-16 units), 1,024-character property names, 2,000 configuration files/results, 50,000 filesystem
entries per root, and depth limits on recursive bindings/YAML. Java requests and
references are cancellable. File/association scan limits and unreadable files carry the
existing incomplete-result indicator; unsupported syntax keys are omitted. First navigation can take seconds on a
cold project; classpath import and source download remain owned by JDT LS.
## Deliberate limits
This is Java/Spring property navigation, not full IntelliJ Spring support. It
cannot infer dynamically constructed names, custom configuration APIs/annotations,
remote-only configuration, arbitrary SpEL evaluation, YAML merge/alias expansion,
or custom Spring converters that change the binding shape. Kotlin is not indexed
by this Java bundle. YAML-only quoted-string escape forms that cannot be decoded
safely are skipped. Properties surrogate-pair escapes are not decoded as keys.
A missing source attachment may navigate to the decompiled owning class when JDT
cannot provide an exact member range. Binding members are navigation candidates;
the feature does not validate whether Spring will successfully bind a bean at
runtime or resolve profile/precedence rules.
## Verification
Use the JDT LS distribution / Java version documented in [README](README.md).
```sh
python3 plugins/java-usages/build.py /path/to/jdtls
python3 plugins/java-usages/tests/configuration.py /path/to/jdtls
OPENIDEA_CONFIGURATION_STRESS=1 python3 plugins/java-usages/tests/configuration.py /path/to/jdtls
OPENIDEA_SPRING_BOOT_JAR=/path/to/spring-boot-autoconfigure.jar python3 plugins/java-usages/tests/configuration.py /path/to/jdtls
cargo test -p ide-language -p ide-lsp
```
The isolated real-server harness covers custom bindings, inheritance, nesting,
collections/maps, records, constructor aliases, factory methods, consumers,
constant keys, negative identity matches, dependency metadata/source locations,
reverse queries, and unsaved-change/close invalidation. Optional stress fixtures
add 3,000 ordinary units and 100 binding classes; output includes cold/warm/edit
latencies for the JDT command only, excluding server import, UI and configuration-file discovery. The optional actual Spring Boot JAR checks `server.port`,
`spring.datasource.url` and `spring.jackson.date-format` through dependency
metadata. No user's project is compiled or modified by the harness.
Rust tests cover configuration syntax/ranges and the actual manager bridge:
YAML navigation without YAML definition capabilities, Java native fallback,
reference merging, profiles, open-buffer overlays and sibling-module exclusion.
Existing Java usages / read-write integration harnesses remain regression checks.
+2 -2
Переглянути файл
@@ -2,9 +2,9 @@ Manifest-Version: 1.0
Bundle-ManifestVersion: 2
Bundle-Name: OpenIDEA Java usages
Bundle-SymbolicName: ai.openidea.usages;singleton:=true
Bundle-Version: 1.1.1
Bundle-Version: 1.2.0
Bundle-RequiredExecutionEnvironment: JavaSE-21
Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core,
org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime,
org.eclipse.lsp4j
org.eclipse.lsp4j,org.eclipse.core.resources,com.google.gson
+7 -3
Переглянути файл
@@ -1,4 +1,6 @@
# Semantic Java usages
# Semantic Java usages and configuration navigation
Spring YAML / properties navigation is described in [CONFIGURATION.md](CONFIGURATION.md).
The app-owned OSGi command `openidea.java.usages` returns `{location, kind, access}`.
`kind` retains field/getter/setter/unknown identity for compatibility. Optional
@@ -55,7 +57,7 @@ is not a claim of full IntelliJ feature parity.
## Rebuild and delivery
The checked-in jar is embedded by ide-sdk `include_bytes!`, installed as
`java-usages-1.1.1.jar`, and added alongside existing initialization bundles.
`java-usages-1.2.0.jar`, and added alongside existing initialization bundles.
Users need no compiler, Maven or Python. Manifest/install version must change
when replacing the bundle because OSGi caches bundles.
@@ -64,8 +66,10 @@ Use its shipped ECJ and plugin jars, with Java 25; emitted bytecode targets Java
The build uses sorted inputs and fixed zip timestamps and does not resolve Maven
artifacts. Check the extracted distribution against `build-dependencies.sha256`
from its root (`sha256sum -c /path/to/build-dependencies.sha256`). Build twice
and compare SHA-256. The 1.1.1 output is
and compare SHA-256. The prior 1.1.1 output was
`1e569939cba22c7db6e2c9b53de2a4c10ccaabe762a51427119a04af3d974ae4`.
Version 1.2.0 adds the configuration command; its reproducible SHA-256 is
`9140f1d2ecf27eadfe66b40a17daa18a08a65927ebef79456ba55d486ce5eeba`.
```sh
export PATH=/path/to/jdk25/bin:$PATH
Бінарний файл не відображається.
+3
Переглянути файл
@@ -4,5 +4,8 @@
<delegateCommandHandler class="ai.openidea.usages.UsagesCommand">
<command id="openidea.java.usages"/>
</delegateCommandHandler>
<delegateCommandHandler class="ai.openidea.configuration.ConfigurationCommand">
<command id="openidea.java.configuration"/>
</delegateCommandHandler>
</extension>
</plugin>
+525
Переглянути файл
@@ -0,0 +1,525 @@
package ai.openidea.configuration;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.zip.ZipFile;
import com.google.gson.*;
import org.eclipse.core.resources.ResourcesPlugin;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.OperationCanceledException;
import org.eclipse.jdt.core.*;
import org.eclipse.jdt.core.dom.*;
import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler;
import org.eclipse.jdt.ls.core.internal.JDTUtils;
import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin;
import org.eclipse.jdt.ls.core.internal.handlers.ReferencesHandler;
import org.eclipse.lsp4j.Location;
/** On-demand configuration index, scoped by JDT's resolved source/class path.
* Cached records contain strings and locations, never ASTs or bindings. Java model
* changes invalidate snapshots, including unsaved working copies and classpath edits.
*/
public final class ConfigurationCommand implements IDelegateCommandHandler {
private static final String PROPERTIES = "org.springframework.boot.context.properties.ConfigurationProperties";
private static final String VALUE = "org.springframework.beans.factory.annotation.Value";
private static final String RESOLVER = "org.springframework.core.env.PropertyResolver";
private static final int MAX_UNITS = 20_000, MAX_ENTRIES = 100_000, MAX_BYTES = 2 * 1024 * 1024;
private static final AtomicLong REVISION = new AtomicLong();
private static final Map<String, Index> CACHE = new LinkedHashMap<>();
static { JavaCore.addElementChangedListener(event -> REVISION.incrementAndGet(), ElementChangedEvent.POST_CHANGE | ElementChangedEvent.POST_RECONCILE); }
private record Entry(String name, String handle, Location location, boolean relaxed) {}
private record FileStamp(java.nio.file.attribute.FileTime modified, long size) {}
private static FileStamp stamp(Path path) {
try { return new FileStamp(Files.getLastModifiedTime(path), Files.size(path)); }
catch (java.io.IOException e) { return new FileStamp(null, -1); }
}
private record Index(long revision, String workingCopies, List<Entry> entries, List<String> roots,
Map<Path, FileStamp> files, boolean incomplete, int parsed) {}
@Override
public Object executeCommand(String command, List<Object> args, IProgressMonitor monitor) throws Exception {
String uri = (String) args.get(0);
String key = args.get(1) instanceof String s ? s : null;
int line = ((Number) args.get(2)).intValue(), column = ((Number) args.get(3)).intValue();
boolean references = args.size() > 4 && Boolean.TRUE.equals(args.get(4));
boolean declarations = args.size() > 5 && Boolean.TRUE.equals(args.get(5));
ITypeRoot selectedRoot = JDTUtils.resolveTypeRoot(uri);
IJavaProject project = selectedRoot == null ? project(uri) : selectedRoot.getJavaProject();
if (project == null) return Map.of("locations", List.of(), "keys", List.of(), "roots", List.of(), "incomplete", true);
Index index = index(project, monitor);
Set<Map<String, Object>> keys = new LinkedHashSet<>();
Set<Location> locations = new LinkedHashSet<>();
String selected = null;
if (key == null && selectedRoot != null && references) {
var element = JDTUtils.findElementAtSelection(selectedRoot, line, column,
JavaLanguageServerPlugin.getPreferencesManager(), monitor);
if (element != null) selected = element.getHandleIdentifier();
}
for (Entry entry : index.entries) {
check(monitor);
Location location = entry.location;
if (key != null) {
if (matches(entry.name, key, entry.relaxed)) {
if (!entry.handle.isEmpty() && references) {
var element = JavaCore.create(entry.handle);
if (element != null && element.exists()) {
List<Location> usages = new ArrayList<>();
new ReferencesHandler(JavaLanguageServerPlugin.getPreferencesManager())
.search(element, usages, monitor, declarations);
locations.addAll(usages);
}
} else {
if (location == null && !entry.handle.isEmpty()) location = location(entry.handle);
if (location != null) locations.add(location);
}
keys.add(Map.of("name", entry.name, "relaxed", entry.relaxed));
}
} else if ((!entry.handle.isEmpty() && entry.handle.equals(selected)) ||
(location != null && sameUri(location.getUri(), uri) && contains(location, line, column))) {
keys.add(Map.of("name", entry.name, "relaxed", entry.relaxed));
}
if (locations.size() >= 2000) break;
}
// Reverse Find Usages joins property consumers with the binding member's
// ordinary Java usages. Definition on a string only returns config keys.
if (key == null && references && !keys.isEmpty()) {
Set<String> searched = new HashSet<>();
for (Entry entry : index.entries) {
check(monitor);
boolean related = keys.stream().anyMatch(pattern ->
matches(entry.name, (String) pattern.get("name"), entry.relaxed) ||
matches((String) pattern.get("name"), entry.name, Boolean.TRUE.equals(pattern.get("relaxed"))));
if (!related) continue;
if (entry.location != null) locations.add(entry.location);
else if (!entry.handle.isEmpty() && searched.add(entry.handle)) {
IJavaElement element = JavaCore.create(entry.handle);
if (element != null && element.exists()) {
List<Location> usages = new ArrayList<>();
new ReferencesHandler(JavaLanguageServerPlugin.getPreferencesManager()).search(element, usages, monitor, declarations);
locations.addAll(usages);
}
}
if (locations.size() >= 2000) break;
}
}
return Map.of("locations", locations, "keys", keys, "roots", index.roots,
"incomplete", index.incomplete || locations.size() >= 2000,
"parsedUnits", index.parsed, "revision", index.revision);
}
private static boolean sameUri(String a, String b) {
URI left = URI.create(a), right = URI.create(b);
if (left.equals(right)) return true;
if (!"file".equals(left.getScheme()) || !"file".equals(right.getScheme())) return false;
try { return Path.of(left).toFile().getCanonicalFile().equals(Path.of(right).toFile().getCanonicalFile()); }
catch (java.io.IOException e) { return false; }
}
private static boolean contains(Location l, int line, int col) {
var r = l.getRange();
return (line > r.getStart().getLine() || line == r.getStart().getLine() && col >= r.getStart().getCharacter()) &&
(line < r.getEnd().getLine() || line == r.getEnd().getLine() && col < r.getEnd().getCharacter());
}
private static void check(IProgressMonitor monitor) { if (monitor.isCanceled()) throw new OperationCanceledException(); }
private static IJavaProject project(String uri) {
Path path;
try { path = Path.of(URI.create(uri)).toFile().getCanonicalFile().toPath(); } catch (Exception e) { return null; }
IJavaProject best = null;
int length = -1;
for (var p : ResourcesPlugin.getWorkspace().getRoot().getProjects()) {
if (!p.isOpen() || p.getLocation() == null) continue;
var candidate = JavaCore.create(p);
Path root;
try { root = p.getLocation().toFile().getCanonicalFile().toPath(); } catch (java.io.IOException e) { continue; }
if (candidate.exists() && path.startsWith(root) && root.getNameCount() > length) {
best = candidate; length = root.getNameCount();
}
}
return best;
}
private static Location location(String handle) throws JavaModelException {
IJavaElement element = JavaCore.create(handle);
if (element == null || !element.exists()) return null;
Location result = JDTUtils.toLocation(element);
if (result == null && element instanceof IMember member && member.getClassFile() != null)
result = JDTUtils.toLocation(member.getClassFile());
return result;
}
private static synchronized Index index(IJavaProject project, IProgressMonitor monitor) throws Exception {
long revision = REVISION.get();
String id = project.getHandleIdentifier();
Map<String, ICompilationUnit> copies = new TreeMap<>();
for (ICompilationUnit copy : JavaCore.getWorkingCopies(null)) copies.put(copy.getPrimary().getHandleIdentifier(), copy);
for (ICompilationUnit copy : JavaCore.getWorkingCopies(JavaLanguageServerPlugin.getInstance().getWorkingCopyOwner()))
copies.put(copy.getPrimary().getHandleIdentifier(), copy);
var digest = java.security.MessageDigest.getInstance("SHA-256");
for (var entry : copies.entrySet()) {
check(monitor);
digest.update(entry.getKey().getBytes(java.nio.charset.StandardCharsets.UTF_8));
String source = entry.getValue().getSource();
if (source != null) digest.update(source.getBytes(java.nio.charset.StandardCharsets.UTF_8));
}
String workingCopies = HexFormat.of().formatHex(digest.digest());
Index cached = CACHE.get(id);
if (cached != null && cached.revision == revision && cached.workingCopies.equals(workingCopies)) {
boolean valid = true;
for (var entry : cached.files.entrySet()) {
check(monitor);
if (!entry.getValue().equals(stamp(entry.getKey()))) { valid = false; break; }
}
if (valid) return cached;
}
Builder builder = new Builder(monitor);
Set<String> units = new HashSet<>(), roots = new LinkedHashSet<>();
int visited = 0;
for (IPackageFragmentRoot root : project.getAllPackageFragmentRoots()) {
check(monitor);
if (root.getKind() == IPackageFragmentRoot.K_SOURCE) {
if (root.getResource() != null && root.getResource().getLocationURI() != null)
roots.add(root.getResource().getLocationURI().toString());
if (root.getJavaProject().getProject().getLocationURI() != null)
roots.add(root.getJavaProject().getProject().getLocationURI().toString());
if (root.getResource() != null && root.getResource().getLocation() != null)
builder.metadataDirectory(root.getResource().getLocation().toFile().toPath(), project);
for (IJavaElement child : root.getChildren()) {
if (!(child instanceof IPackageFragment pkg)) continue;
for (ICompilationUnit unit : pkg.getCompilationUnits()) {
check(monitor);
unit = copies.getOrDefault(unit.getPrimary().getHandleIdentifier(), unit);
if (!units.add(unit.getHandleIdentifier())) continue;
if (++visited > MAX_UNITS || builder.entries.size() >= MAX_ENTRIES) { builder.incomplete = true; break; }
String source = unit.getSource();
if (source == null) continue;
if (source.length() > MAX_BYTES) { builder.incomplete = true; continue; }
// Cheap lexical filter only chooses candidates; all accepted symbols
// below are verified by JDT bindings, including constants and imports.
if (source.contains("ConfigurationProperties") || source.contains("Value") || source.contains("getProperty") || source.contains("getRequiredProperty"))
builder.parse(unit);
}
}
} else if (root.isArchive()) {
builder.metadata(root, project);
}
}
// Resource roots are not necessarily Java package roots (e.g. Maven resources).
// Return project locations as well; the client excludes nested independent modules.
roots.add(project.getProject().getLocationURI().toString());
builder.metadataDirectory(project.getProject().getLocation().toFile().toPath().resolve("src/main/resources"), project);
Index result = new Index(revision, workingCopies, List.copyOf(builder.entries), List.copyOf(roots), Map.copyOf(builder.files), builder.incomplete, builder.parsed);
CACHE.remove(id);
while (!CACHE.isEmpty() && (CACHE.size() >= 8 ||
CACHE.values().stream().mapToInt(value -> value.entries.size()).sum() + result.entries.size() > MAX_ENTRIES))
CACHE.remove(CACHE.keySet().iterator().next());
CACHE.put(id, result);
return result;
}
/** Relaxed matching is reserved for binder/metadata entries. @Value and
* Environment lookups retain their literal names; dots remain boundaries. */
static boolean matches(String pattern, String key, boolean relaxed) {
if (!relaxed) return pattern.equals(key);
List<String> a = parts(pattern), b = parts(key);
if (a.size() != b.size()) return false;
for (int i = 0; i < a.size(); i++) if (!a.get(i).equals("*") && !a.get(i).equals(b.get(i))) return false;
return true;
}
private static List<String> parts(String key) {
List<String> result = new ArrayList<>();
StringBuilder part = new StringBuilder();
for (int i = 0; i < key.length(); i++) {
char ch = key.charAt(i);
if (ch == '[') {
if (!part.isEmpty()) { result.add(canonical(part.toString())); part.setLength(0); }
int end = key.indexOf(']', i + 1);
if (end < 0) { part.append(key.substring(i)); break; }
result.add(canonical(key.substring(i + 1, end))); i = end;
} else if (ch == '.') {
if (!part.isEmpty() || i == 0 || key.charAt(i - 1) != ']') result.add(canonical(part.toString()));
part.setLength(0);
} else part.append(ch);
}
if (!part.isEmpty()) result.add(canonical(part.toString()));
return result;
}
private static String canonical(String key) {
return key.replace("-", "").replace("_", "").toLowerCase(Locale.ROOT);
}
private static final class Builder {
final IProgressMonitor monitor;
final List<Entry> entries = new ArrayList<>();
final Map<Path, FileStamp> files = new LinkedHashMap<>();
boolean incomplete;
int parsed;
Builder(IProgressMonitor monitor) { this.monitor = monitor; }
void add(String key, IJavaElement element) {
if (key.length() > 1024 || entries.size() >= MAX_ENTRIES) { incomplete = true; return; }
if (element != null) entries.add(new Entry(key, element.getHandleIdentifier(), null, true));
}
void use(String key, ICompilationUnit unit, ASTNode node) {
if (key.length() > 1024 || entries.size() >= MAX_ENTRIES) { incomplete = true; return; }
if (key.isBlank() || key.contains("${") || key.contains("#{")) return;
try { entries.add(new Entry(key, "", JDTUtils.toLocation(unit, node.getStartPosition(), node.getLength()), false)); }
catch (JavaModelException e) { incomplete = true; }
}
void parse(ICompilationUnit unit) {
check(monitor);
ASTParser parser = ASTParser.newParser(AST.getJLSLatest());
parser.setSource(unit);
parser.setResolveBindings(true);
parser.setWorkingCopyOwner(JavaLanguageServerPlugin.getInstance().getWorkingCopyOwner());
parser.setBindingsRecovery(false);
CompilationUnit tree = (CompilationUnit) parser.createAST(monitor);
parsed++;
tree.accept(new ASTVisitor() {
@Override public void preVisit(ASTNode node) { check(monitor); }
@Override public boolean visit(NormalAnnotation node) { annotation(node); return true; }
@Override public boolean visit(SingleMemberAnnotation node) { annotation(node); return true; }
@Override public boolean visit(MarkerAnnotation node) { annotation(node); return true; }
private void annotation(Annotation node) {
IAnnotationBinding binding = node.resolveAnnotationBinding();
if (binding == null) return;
String name = binding.getAnnotationType().getQualifiedName();
if (name.equals(PROPERTIES)) {
String prefix = annotationValue(binding, "prefix");
if (prefix == null || prefix.isEmpty()) prefix = annotationValue(binding, "value");
if (prefix == null) prefix = "";
ASTNode owner = node.getParent();
if (owner instanceof AbstractTypeDeclaration type) {
bind(prefix, type.resolveBinding(), new HashSet<>(), 0);
} else if (owner instanceof MethodDeclaration method && method.resolveBinding() != null) {
add(prefix, method.resolveBinding().getJavaElement());
bind(prefix, method.resolveBinding().getReturnType(), new HashSet<>(), 0);
}
} else if (name.equals(VALUE)) {
String value = annotationValue(binding, "value");
ASTNode expression = node;
if (node instanceof SingleMemberAnnotation single) expression = single.getValue();
else if (node instanceof NormalAnnotation normal) {
for (Object pair : normal.values()) if (pair instanceof MemberValuePair p && p.getName().getIdentifier().equals("value")) expression = p.getValue();
}
if (value != null) placeholders(value, unit, expression);
}
}
@Override public boolean visit(MethodInvocation node) {
IMethodBinding method = node.resolveMethodBinding();
if (method != null && (method.getName().equals("getProperty") || method.getName().equals("getRequiredProperty")) &&
subtype(method.getDeclaringClass(), RESOLVER, new HashSet<>()) && !node.arguments().isEmpty()) {
Expression arg = (Expression) node.arguments().get(0);
Object constant = arg.resolveConstantExpressionValue();
if (constant instanceof String key) use(key, unit, arg);
}
return true;
}
});
}
void placeholders(String text, ICompilationUnit unit, ASTNode node) {
// Include nested default placeholders, but never a dynamically composed name.
for (int at = text.indexOf("${"); at >= 0; at = text.indexOf("${", at + 2)) {
int end = at + 2;
while (end < text.length() && text.charAt(end) != ':' && text.charAt(end) != '}') end++;
if (end < text.length()) use(text.substring(at + 2, end), unit, node);
}
}
void bind(String prefix, ITypeBinding type, Set<String> visiting, int depth) {
check(monitor);
if (type == null || type.isRecovered() || type.isPrimitive() || type.isEnum()) return;
String qualified = type.getErasure().getQualifiedName();
if (qualified.startsWith("java.") || qualified.startsWith("javax.")) return;
if (depth > 12 || !visiting.add(type.getKey())) return;
try {
if (!prefix.isEmpty()) add(prefix, type.getJavaElement());
List<IMethodBinding> constructors = Arrays.stream(type.getDeclaredMethods())
.filter(m -> m.isConstructor() && !Modifier.isPrivate(m.getModifiers())).toList();
for (IMethodBinding constructor : constructors) {
boolean explicit = Arrays.stream(constructor.getAnnotations()).anyMatch(a ->
a.getAnnotationType().getQualifiedName().equals("org.springframework.boot.context.properties.bind.ConstructorBinding") ||
a.getAnnotationType().getQualifiedName().equals("org.springframework.boot.context.properties.ConstructorBinding"));
if (!explicit && constructors.size() != 1) continue;
String[] parameters = constructor.getParameterNames();
for (int i = 0; i < parameters.length; i++) {
String name = parameters[i];
for (IAnnotationBinding annotation : constructor.getParameterAnnotations(i)) {
if (annotation.getAnnotationType().getQualifiedName().equals("org.springframework.boot.context.properties.bind.Name")) {
String alias = annotationValue(annotation, "value");
if (alias != null) name = alias;
}
}
// Prefer fields/accessors for ordinary parameters; aliases and
// constructor-only properties still have a navigable declaration.
final String parameterName = name;
if (Arrays.stream(type.getDeclaredFields()).noneMatch(f -> f.getName().equals(parameterName))) {
add(join(prefix, name), constructor.getJavaElement());
nested(join(prefix, name), constructor.getParameterTypes()[i], constructor.getJavaElement(), visiting, depth + 1);
}
}
}
Set<String> names = new HashSet<>();
for (ITypeBinding current = type; current != null && !current.getQualifiedName().equals("java.lang.Object"); current = current.getSuperclass()) {
for (IVariableBinding field : current.getDeclaredFields()) {
if (Modifier.isStatic(field.getModifiers()) || field.isSynthetic() || !names.add(field.getName())) continue;
String key = join(prefix, field.getName());
add(key, field.getJavaElement());
nested(key, field.getType(), field.getJavaElement(), visiting, depth + 1);
}
for (IMethodBinding method : current.getDeclaredMethods()) {
if (Modifier.isStatic(method.getModifiers()) || method.isConstructor()) continue;
String name = method.getName();
String property = null;
ITypeBinding propertyType = null;
if (name.startsWith("set") && name.length() > 3 && method.getParameterTypes().length == 1) {
property = name.substring(3); propertyType = method.getParameterTypes()[0];
} else if (name.startsWith("get") && name.length() > 3 && method.getParameterTypes().length == 0) {
property = name.substring(3); propertyType = method.getReturnType();
}
if (property != null) {
property = Character.toLowerCase(property.charAt(0)) + property.substring(1);
String key = join(prefix, property);
add(key, method.getJavaElement());
if (names.add(property)) nested(key, propertyType, method.getJavaElement(), visiting, depth + 1);
}
}
}
} finally { visiting.remove(type.getKey()); }
}
void nested(String key, ITypeBinding type, IJavaElement owner, Set<String> visiting, int depth) {
if (type == null) return;
if (type.isArray()) { add(key + "[*]", owner); bind(key + "[*]", type.getElementType(), visiting, depth); return; }
String raw = type.getErasure().getQualifiedName();
if (subtype(type, "java.util.Map", new HashSet<>())) {
add(key + ".*", owner);
var args = type.getTypeArguments();
if (args.length == 2) bind(key + ".*", args[1], visiting, depth);
} else if (subtype(type, "java.util.Collection", new HashSet<>())) {
add(key + "[*]", owner);
var args = type.getTypeArguments();
if (args.length == 1) bind(key + "[*]", args[0], visiting, depth);
} else if (!type.isPrimitive() && !type.isEnum() && !raw.startsWith("java.") && !raw.startsWith("javax.")) {
bind(key, type, visiting, depth);
}
}
void metadata(IPackageFragmentRoot root, IJavaProject project) {
check(monitor);
Path path = root.getPath().toFile().toPath();
if (!root.isExternal() && root.getResource() != null && root.getResource().getLocation() != null)
path = root.getResource().getLocation().toFile().toPath();
files.put(path, stamp(path));
if (!Files.isRegularFile(path)) return;
try (ZipFile zip = new ZipFile(path.toFile())) {
for (String filename : List.of("spring-configuration-metadata.json", "additional-spring-configuration-metadata.json")) {
var entry = zip.getEntry("META-INF/" + filename);
if (entry == null) continue;
if (entry.getSize() > MAX_BYTES) { incomplete = true; continue; }
JsonObject metadata;
try (var stream = zip.getInputStream(entry)) {
byte[] bytes = stream.readNBytes(MAX_BYTES + 1);
if (bytes.length > MAX_BYTES) { incomplete = true; continue; }
metadata = JsonParser.parseString(new String(bytes, java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject();
}
metadataObject(metadata, project);
}
} catch (Exception e) { if (e instanceof OperationCanceledException) throw (OperationCanceledException)e; incomplete = true; }
}
void metadataObject(JsonObject metadata, IJavaProject project) throws JavaModelException {
JsonArray groups = metadata.has("groups") ? metadata.getAsJsonArray("groups") : new JsonArray();
for (JsonElement value : groups) {
check(monitor);
JsonObject group = value.getAsJsonObject();
String name = string(group, "name"), source = string(group, "sourceType");
if (source == null) source = string(group, "type");
if (name != null && source != null) add(name, project.findType(source.replace('$', '.'), monitor));
}
if (!metadata.has("properties")) return;
for (JsonElement value : metadata.getAsJsonArray("properties")) {
check(monitor);
if (entries.size() >= MAX_ENTRIES) { incomplete = true; return; }
JsonObject property = value.getAsJsonObject();
String key = string(property, "name"), source = string(property, "sourceType"), prefix = "";
if (key == null) continue;
JsonObject owner = null;
for (JsonElement groupValue : groups) {
JsonObject group = groupValue.getAsJsonObject();
String name = string(group, "name");
if (name != null && key.startsWith(name + ".") && name.length() > prefix.length()) {
prefix = name; owner = group;
}
}
if (source == null && owner != null) source = string(owner, "type");
if (source == null && owner != null) source = string(owner, "sourceType");
if (source == null) continue;
IType type = project.findType(source.replace('$', '.'), monitor);
if (type == null) continue;
String leaf = key.substring(prefix.isEmpty() ? 0 : prefix.length() + 1);
IJavaElement target = member(type, leaf, new HashSet<>());
if (target == null && owner != null && string(owner, "sourceMethod") != null) {
String declaring = string(owner, "sourceType");
IType factory = declaring == null ? null : project.findType(declaring.replace('$', '.'), monitor);
if (factory != null) {
String methodName = string(owner, "sourceMethod").split("\\(", 2)[0];
for (IMethod method : factory.getMethods()) if (method.getElementName().equals(methodName)) { target = method; break; }
}
}
add(key, target == null ? type : target);
}
}
void metadataDirectory(Path directory, IJavaProject project) {
for (String name : List.of("spring-configuration-metadata.json", "additional-spring-configuration-metadata.json")) {
Path file = directory.resolve("META-INF").resolve(name);
files.put(file, stamp(file));
if (!Files.isRegularFile(file)) continue;
try {
if (Files.size(file) > MAX_BYTES) { incomplete = true; continue; }
try (var stream = Files.newInputStream(file)) {
byte[] bytes = stream.readNBytes(MAX_BYTES + 1);
if (bytes.length > MAX_BYTES) { incomplete = true; continue; }
metadataObject(JsonParser.parseString(new String(bytes, java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject(), project);
}
} catch (Exception e) { if (e instanceof OperationCanceledException) throw (OperationCanceledException)e; incomplete = true; }
}
}
}
private static IJavaElement member(IType type, String key, Set<String> seen) throws JavaModelException {
if (type == null || !seen.add(type.getHandleIdentifier())) return null;
String part = key.split("\\.", 2)[0];
for (IField field : type.getFields()) if (canonical(field.getElementName()).equals(canonical(part))) {
if (!key.contains(".")) return field;
String signature = Signature.toString(Signature.getTypeErasure(field.getTypeSignature()));
String[][] resolved = type.resolveType(signature);
IType nested = type.getJavaProject().findType(signature);
if (nested == null && resolved != null && resolved.length == 1)
nested = type.getJavaProject().findType(resolved[0][0] + "." + resolved[0][1]);
IJavaElement found = member(nested, key.substring(key.indexOf('.') + 1), seen);
return found == null ? field : found;
}
for (IMethod method : type.getMethods()) {
String name = method.getElementName();
if (name.length() > 3 && (name.startsWith("get") || name.startsWith("set")) && canonical(name.substring(3)).equals(canonical(part))) return method;
}
String parent = type.getSuperclassName();
if (parent != null) {
String[][] resolved = type.resolveType(parent);
if (resolved != null && resolved.length == 1)
return member(type.getJavaProject().findType(resolved[0][0] + "." + resolved[0][1]), key, seen);
}
return null;
}
private static String string(JsonObject object, String key) {
return object.has(key) && object.get(key).isJsonPrimitive() ? object.get(key).getAsString() : null;
}
private static String annotationValue(IAnnotationBinding binding, String name) {
for (IMemberValuePairBinding pair : binding.getAllMemberValuePairs())
if (pair.getName().equals(name) && pair.getValue() instanceof String s) return s;
return null;
}
private static String join(String prefix, String name) { return prefix.isEmpty() ? name : prefix + "." + name; }
private static boolean subtype(ITypeBinding type, String name, Set<String> seen) {
if (type == null || !seen.add(type.getKey())) return false;
if (type.getErasure().getQualifiedName().equals(name)) return true;
for (ITypeBinding face : type.getInterfaces()) if (subtype(face, name, seen)) return true;
return subtype(type.getSuperclass(), name, seen);
}
}
+220
Переглянути файл
@@ -0,0 +1,220 @@
"""Real JDT LS configuration-navigation regression and warm-query benchmark.
Run with Java 25 on PATH: python3 configuration.py /path/to/jdtls
Fixtures use small Spring API stubs; no Maven/network/build of a user's project.
"""
import os
import json
import pathlib
import platform
import queue
import subprocess
import sys
import tempfile
import threading
import time
import zipfile
home = pathlib.Path(sys.argv[1]).resolve()
bundle = pathlib.Path(__file__).resolve().parents[1] / 'openidea-java-usages.jar'
work = pathlib.Path(tempfile.mkdtemp(prefix='openidea-configuration-'))
project = work / 'project'
source_dir = project / 'src'
source_dir.mkdir(parents=True)
def write(relative, text):
file = source_dir / relative
file.parent.mkdir(parents=True, exist_ok=True)
file.write_text(text)
return file
write('org/springframework/boot/context/properties/ConfigurationProperties.java', '''package org.springframework.boot.context.properties;
public @interface ConfigurationProperties { String value() default ""; String prefix() default ""; }''')
write('org/springframework/boot/context/properties/bind/Name.java', 'package org.springframework.boot.context.properties.bind; public @interface Name { String value(); }')
write('org/springframework/beans/factory/annotation/Value.java', '''package org.springframework.beans.factory.annotation;
public @interface Value { String value(); }''')
write('org/springframework/core/env/PropertyResolver.java', '''package org.springframework.core.env;
public interface PropertyResolver { String getProperty(String key); String getRequiredProperty(String key); }''')
write('org/springframework/core/env/Environment.java', '''package org.springframework.core.env;
public interface Environment extends PropertyResolver {}''')
source = '''package demo;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.env.Environment;
@ConfigurationProperties(prefix="app")
class Settings extends Parent {
int timeOut;
Nested nested;
java.util.List<Nested> clients;
java.util.Map<String, Nested> routes;
java.util.List<String> tags;
java.util.Map<String, String> labels;
public int getTimeOut() { return timeOut; }
public void setTimeOut(int value) { timeOut = value; }
}
class Parent { boolean enabled; }
class Nested { String url; }
@ConfigurationProperties("record")
record RecordSettings(String name, int count) {}
@ConfigurationProperties("constructor")
class ConstructorSettings {
ConstructorSettings(@org.springframework.boot.context.properties.bind.Name("remote-name") String name) {}
}
class Consumers {
static final String KEY = "app.time-out";
@Value("${app.time-out:30}") String timeout;
@Value("${app.missing:${app.backup:42}}") String fallback;
String call(Environment env) { return env.getProperty(KEY); }
String required(Environment env) { return env.getRequiredProperty("app.required"); }
String dynamic(Environment env, String key) { return env.getProperty(key); }
String unrelated(Fake env) { return env.getProperty("app.time-out"); }
@ConfigurationProperties("bean") Nested bean() { return new Nested(); }
}
class Fake { String getProperty(String name) { return name; } }
'''
file = write('demo/Settings.java', source)
if os.environ.get('OPENIDEA_CONFIGURATION_STRESS') == '1':
for i in range(3000):
write(f'noise/Plain{i}.java', f'package noise; class Plain{i} {{ int number; }}')
for i in range(100):
write(f'noise/Config{i}.java', f'package noise; @org.springframework.boot.context.properties.ConfigurationProperties(prefix="stress{i}") class Config{i} {{ int value; }}')
write('demo/NotSpring.java', '''package demo;
@interface ConfigurationProperties { String prefix(); }
@ConfigurationProperties(prefix="fake") class NotSpring { int timeOut; }
''')
local_metadata = project / 'src/main/resources/META-INF'
local_metadata.mkdir(parents=True)
(local_metadata / 'additional-spring-configuration-metadata.json').write_text(json.dumps({
'groups': [{'name': 'extra', 'sourceType': 'demo.Nested', 'type': 'demo.Nested'}],
'properties': [{'name': 'extra.url', 'sourceType': 'demo.Nested'}]
}))
# A dependency with metadata and attached source exercises actual library locations.
libsource = work / 'libsrc/vendor/LibrarySettings.java'
libsource.parent.mkdir(parents=True)
libsource.write_text('package vendor; public class LibrarySettings { public int port; public int getPort() { return port; } }')
classes = work / 'libclasses'
classes.mkdir()
subprocess.run(['javac', '--release', '21', '-d', str(classes), str(libsource)], check=True)
library = work / 'library.jar'
metadata = {'groups': [{'name': 'vendor', 'type': 'vendor.LibrarySettings', 'sourceType': 'vendor.LibrarySettings'}],
'properties': [{'name': 'vendor.port', 'type': 'java.lang.Integer', 'sourceType': 'vendor.LibrarySettings'}]}
with zipfile.ZipFile(library, 'w') as z:
z.write(classes / 'vendor/LibrarySettings.class', 'vendor/LibrarySettings.class')
z.writestr('META-INF/spring-configuration-metadata.json', json.dumps(metadata))
sourcejar = work / 'library-sources.jar'
with zipfile.ZipFile(sourcejar, 'w') as z:
z.write(libsource, 'vendor/LibrarySettings.java')
(project / '.project').write_text('<projectDescription><name>configuration</name><buildSpec><buildCommand><name>org.eclipse.jdt.core.javabuilder</name></buildCommand></buildSpec><natures><nature>org.eclipse.jdt.core.javanature</nature></natures></projectDescription>')
spring_jar = os.environ.get('OPENIDEA_SPRING_BOOT_JAR')
extra_classpath = f'<classpathentry kind="lib" path="{pathlib.Path(spring_jar).resolve()}"/>' if spring_jar else ''
(project / '.classpath').write_text(f'<classpath><classpathentry kind="src" path="src"/><classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER"/><classpathentry kind="lib" path="{library}" sourcepath="{sourcejar}"/>{extra_classpath}<classpathentry kind="output" path="bin"/></classpath>')
config = work / 'config'
config.mkdir()
name = {'Darwin': 'config_mac', 'Windows': 'config_win'}.get(platform.system(), 'config_linux')
names = ([name + '_arm'] if platform.machine().lower() in ('arm64', 'aarch64') else []) + [name]
(config / 'config.ini').write_bytes(next(home / n / 'config.ini' for n in names if (home / n / 'config.ini').exists()).read_bytes())
log = (work / 'stderr.log').open('w')
proc = subprocess.Popen(['java', '-Declipse.application=org.eclipse.jdt.ls.core.id1', '-Dosgi.bundles.defaultStartLevel=4', '-Declipse.product=org.eclipse.jdt.ls.core.product', '-Xmx1G', '--add-modules=ALL-SYSTEM', '--add-opens', 'java.base/java.util=ALL-UNNAMED', '--add-opens', 'java.base/java.lang=ALL-UNNAMED', '-jar', str(next((home / 'plugins').glob('org.eclipse.equinox.launcher_*.jar'))), '-configuration', str(config), '-data', str(work / 'metadata')], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log)
messages = queue.Queue()
lock = threading.Lock()
def send(message):
data = json.dumps(message).encode()
with lock:
proc.stdin.write(f'Content-Length: {len(data)}\r\n\r\n'.encode() + data)
proc.stdin.flush()
def read():
while True:
headers = {}
while True:
line = proc.stdout.readline()
if not line: return
if line == b'\r\n': break
k, v = line.decode().split(':', 1)
headers[k.lower()] = v.strip()
message = json.loads(proc.stdout.read(int(headers['content-length'])))
if 'method' in message and 'id' in message:
send({'jsonrpc': '2.0', 'id': message['id'], 'result': [] if message['method'] == 'workspace/configuration' else None})
else: messages.put(message)
threading.Thread(target=read, daemon=True).start()
sequence = 0
transcript = []
def request(method, params):
global sequence
sequence += 1
send({'jsonrpc': '2.0', 'id': sequence, 'method': method, 'params': params})
deadline = time.monotonic() + 180
while time.monotonic() < deadline:
msg = messages.get(timeout=max(1, deadline - time.monotonic()))
transcript.append(msg)
if msg.get('id') == sequence:
if 'error' in msg: raise RuntimeError(msg['error'])
return msg.get('result')
raise TimeoutError(method)
def query(key, uri=None, line=0, col=0):
return request('workspace/executeCommand', {'command': 'openidea.java.configuration', 'arguments': [uri or (project / 'application.yml').as_uri(), key, line, col, key is None, False]})
def names(result):
return {p['name'] for p in result['keys']}
try:
init = request('initialize', {'processId': None, 'rootUri': project.as_uri(), 'workspaceFolders': [{'uri': project.as_uri(), 'name': 'configuration'}], 'capabilities': {'textDocument': {'definition': {'linkSupport': False}}}, 'initializationOptions': {'bundles': [str(bundle)], 'extendedClientCapabilities': {'classFileContentsSupport': True}, 'settings': {'java': {'import': {'gradle': {'enabled': False}, 'maven': {'enabled': False}}}}}})
send({'jsonrpc': '2.0', 'method': 'initialized', 'params': {}})
send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': file.as_uri(), 'languageId': 'java', 'version': 1, 'text': source}}})
request('java/buildWorkspace', True)
assert 'openidea.java.configuration' in init['capabilities']['executeCommandProvider']['commands'], init
results = {}
for key in ['app', 'vendor', 'app.time-out', 'app.enabled', 'app.nested.url', 'app.clients[0].url', 'app.routes.east.url', 'app.tags[0]', 'app.labels.region', 'app.labels[region.name]', 'constructor.remote-name', 'record.name', 'bean.url', 'app.backup', 'app.required', 'vendor.port', 'extra.url']:
start = time.perf_counter()
result = query(key)
results[key] = {'seconds': time.perf_counter() - start, 'response': result}
assert result['locations'], (key, result)
attached = request('java/classFileContents', {'uri': results['vendor.port']['response']['locations'][0]['uri']})
assert attached and 'LibrarySettings' in attached and 'port' in attached, attached
if spring_jar:
for key in ['server.port', 'spring.datasource.url', 'spring.jackson.date-format']:
result = query(key)
assert result['locations'], (key, result)
assert all(loc['uri'].startswith('jdt://') for loc in result['locations']), result
results[key] = {'response': result}
if key == 'server.port':
decompiled = request('java/classFileContents', {'uri': result['locations'][0]['uri']})
assert decompiled and 'ServerProperties' in decompiled and 'port' in decompiled, decompiled
assert not query('app.tags[0].value')['locations']
assert not query('app.labels.region.coder')['locations']
assert not query('fake.time-out')['locations']
assert not query('app.does-not-exist')['locations']
metadata_file = local_metadata / 'additional-spring-configuration-metadata.json'
metadata_file.write_text(metadata_file.read_text().replace('extra', 'newer'))
assert query('newer.url')['locations']
assert not query('extra.url')['locations']
# Property references include member usages and consumers, without declarations.
references = request('workspace/executeCommand', {'command': 'openidea.java.configuration',
'arguments': [(project / 'application.yml').as_uri(), 'app.time-out', 0, 0, True, False]})
assert references['locations'], references
assert any(loc['range']['start']['line'] == next(i for i, line in enumerate(source.splitlines()) if 'getProperty(KEY)' in line) for loc in references['locations']), references
# 3 binding targets + @Value + Environment constant; Fake.getProperty excluded.
timeout = query('app.time-out')
assert len(timeout['locations']) == 5, timeout
for needle, expected in [('int timeOut;', 'app.timeOut'), ('"${app.time-out:30}"', 'app.time-out'), ('env.getProperty(KEY)', 'app.time-out')]:
line, text = next((i, s) for i, s in enumerate(source.splitlines()) if needle in s)
col = text.index(needle) + (4 if needle == 'int timeOut;' else (len('env.getProperty(') if needle.startswith('env.') else 4))
reverse = query(None, file.as_uri(), line, col)
assert expected in names(reverse), (needle, reverse)
warm = []
for _ in range(20):
start = time.perf_counter(); query('app.time-out'); warm.append(time.perf_counter() - start)
# Unsaved changes must replace old keys, and closing must restore disk keys.
changed = source.replace('prefix="app"', 'prefix="edited"').replace('${app.time-out:30}', '${edited.timeout:30}')
send({'jsonrpc': '2.0', 'method': 'textDocument/didChange', 'params': {'textDocument': {'uri': file.as_uri(), 'version': 2}, 'contentChanges': [{'text': changed}]}})
edit_start = time.perf_counter()
updated = query('edited.time-out')
edit_ms = (time.perf_counter() - edit_start) * 1000
assert updated['locations'], updated
assert not query('app.nested.url')['locations']
send({'jsonrpc': '2.0', 'method': 'textDocument/didClose', 'params': {'textDocument': {'uri': file.as_uri()}}})
restored = query('app.nested.url')
assert restored['locations'], restored
evidence = {'stress': os.environ.get('OPENIDEA_CONFIGURATION_STRESS') == '1', 'editRebuildMs': edit_ms, 'cases': results, 'warmMedianMs': sorted(warm)[len(warm)//2] * 1000, 'warmMaxMs': max(warm) * 1000}
(work / 'evidence.json').write_text(json.dumps(evidence, indent=2))
print(json.dumps(evidence, indent=2))
finally:
(work / 'transcript.json').write_text(json.dumps(transcript, indent=2))
proc.terminate(); proc.wait(timeout=20)
print('Evidence:', work, file=sys.stderr)