mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 16:08:33 +00:00
Configuration navigation from YAML/properties still waited for a cold index the first time and repeated the lookup between the ⌘-hover probe and the click. - Trusted projects with a Maven/Gradle/Eclipse build file start Java shortly after opening. Once JDT reports ServiceReady, the app calls the new openidea.java.configuration.warmup command: a low-priority background job builds the configuration index one project at a time (open YAML/properties modules first), waits for workspace builds, has a 30 s budget per project, and only fills free cache room. - Index builds take a cancellable per-project lock instead of one global lock, so a background build never delays another project, and a warmed snapshot is dropped if the Java model changed meanwhile. - In YAML/properties the editor underlines the whole key (hyphens and dots included), hover and Go to Definition share one bounded request per key, and the declaration chooser names the whole key. Bundle java-usages 1.6.0 is rebuilt reproducibly with build.py against JDT LS 1.63.0-202610191322 (SHA-256 33cfa1bb…). The real-JDT harness checks that the first lookup reuses the warmed index. Co-authored-by: qjlqyf <[email protected]>
267 lines
17 KiB
Python
267 lines
17 KiB
Python
"""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
|
|
def warmup(action='start'):
|
|
return request('workspace/executeCommand', {'command': 'openidea.java.configuration.warmup', 'arguments': [action, [(project / 'application.yml').as_uri()]]})
|
|
# Queuing is cheap and idempotent; the index is built by a background job.
|
|
queued_at = time.perf_counter()
|
|
queued = warmup()
|
|
assert queued['scheduled'] and queued['pending'] > 0 and not queued['completed'], queued
|
|
assert warmup()['pending'] == queued['pending'], 'a repeated start queued the project twice'
|
|
deadline = time.monotonic() + 90
|
|
while time.monotonic() < deadline:
|
|
warmed = warmup('status')
|
|
if not warmed['scheduled']: break
|
|
time.sleep(.1)
|
|
assert not warmed['scheduled'] and warmed['completed'], warmed
|
|
assert warmup()['pending'] == 0, 'a repeated start queued a warmed project again'
|
|
prewarm_seconds = time.perf_counter() - queued_at
|
|
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}
|
|
if key == 'app':
|
|
assert result['generation'] in warmed['completed'].values(), ('the first lookup rebuilt the warmed index', result, warmed)
|
|
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)
|
|
# The snapshot stays warm across editor activity that cannot change it:
|
|
# opening/closing another file, a full build, and an edit once it is saved.
|
|
generation = query('app.time-out')['generation']
|
|
other = source_dir / 'demo/NotSpring.java'
|
|
other_text = other.read_text()
|
|
send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': other.as_uri(), 'languageId': 'java', 'version': 1, 'text': other_text}}})
|
|
assert query('app.time-out')['generation'] == generation, 'opening an editor rebuilt the index'
|
|
send({'jsonrpc': '2.0', 'method': 'textDocument/didClose', 'params': {'textDocument': {'uri': other.as_uri()}}})
|
|
request('java/buildWorkspace', True)
|
|
assert query('app.time-out')['generation'] == generation, 'closing an editor or building rebuilt the index'
|
|
saved_text = other_text + '// saved\n'
|
|
send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': other.as_uri(), 'languageId': 'java', 'version': 1, 'text': other_text}}})
|
|
send({'jsonrpc': '2.0', 'method': 'textDocument/didChange', 'params': {'textDocument': {'uri': other.as_uri(), 'version': 2}, 'contentChanges': [{'text': saved_text}]}})
|
|
other.write_text(saved_text)
|
|
send({'jsonrpc': '2.0', 'method': 'textDocument/didSave', 'params': {'textDocument': {'uri': other.as_uri()}}})
|
|
after_save = query('app.time-out')['generation']
|
|
assert query('app.time-out')['generation'] == after_save, 'a saved editor keeps rebuilding the index'
|
|
send({'jsonrpc': '2.0', 'method': 'textDocument/didClose', 'params': {'textDocument': {'uri': other.as_uri()}}})
|
|
after_close = query('app.time-out')['generation']
|
|
assert query('app.time-out')['generation'] == after_close
|
|
# A source added on disk still invalidates it.
|
|
added = write('demo/Added.java', 'package demo; @org.springframework.boot.context.properties.ConfigurationProperties("added") class Added { int size; }')
|
|
send({'jsonrpc': '2.0', 'method': 'workspace/didChangeWatchedFiles', 'params': {'changes': [{'uri': added.as_uri(), 'type': 1}]}})
|
|
deadline = time.monotonic() + 30
|
|
while not query('added.size')['locations']:
|
|
assert time.monotonic() < deadline, 'a new source on disk did not reach the index'
|
|
time.sleep(0.2)
|
|
# 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
|
|
cancelled = warmup('cancel')
|
|
assert not cancelled['scheduled'] and cancelled['pending'] == 0, cancelled
|
|
evidence = {'stress': os.environ.get('OPENIDEA_CONFIGURATION_STRESS') == '1', 'prewarmSeconds': prewarm_seconds, 'editRebuildMs': edit_ms, 'cases': results, 'warmMedianMs': sorted(warm)[len(warm)//2] * 1000, 'warmMaxMs': max(warm) * 1000, 'generations': [generation, after_save, after_close]}
|
|
(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)
|