mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
chore: move the JDT LS components out of plugins/ (#1292)
java-usages, kotlin-bridge and the patched Lombok agent are JDT LS bundles and a Java agent embedded into the app, not OpenIDEA plugins. Move them to java/ so plugins/ holds only plugins, and update the paths that embed or mention them. Also fix the stale build path in the hello fixture and the contributor guide's layout (there is no templates/ directory; starter templates live in the openidea-plugin CLI).
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
# Reproduce Java usage access filtering
|
||||
|
||||
## Small fixture
|
||||
|
||||
Use [ReadWrite.java](ReadWrite.java), a 24-line Java/Lombok fixture. It contains
|
||||
ordinary `@Data`, explicit fluent `@Accessors`, direct field reads/writes,
|
||||
compound updates, nested calls, inherited/interface calls and method references.
|
||||
Its line positions are assertions in `read-write.py`; keep them stable.
|
||||
|
||||
For manual testing, create a Java project with Lombok 1.18.48 on its classpath
|
||||
and copy this file into its source directory as `Bean.java`. For Maven, use JDK
|
||||
21 or later and this minimal `pom.xml`:
|
||||
|
||||
```xml
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>example</groupId><artifactId>usage-access</artifactId><version>1.0</version>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId><artifactId>lombok</artifactId>
|
||||
<version>1.18.48</version><scope>provided</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<build><plugins><plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.14.1</version>
|
||||
<configuration><release>21</release><annotationProcessorPaths><path>
|
||||
<groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><version>1.18.48</version>
|
||||
</path></annotationProcessorPaths></configuration>
|
||||
</plugin></plugins></build>
|
||||
</project>
|
||||
```
|
||||
|
||||
Open the project in OpenIDEA, allow JDT LS import/indexing to finish and verify
|
||||
there are no Java error diagnostics. Leave `java.references.includeAccessors`
|
||||
enabled. Invoke Show Usages on the **declaration** of `Bean.count` (Cmd-click on
|
||||
macOS, or the corresponding navigation action). Use the declaration to avoid
|
||||
navigating from a reference to its definition instead.
|
||||
|
||||
## Before and after
|
||||
|
||||
On unmodified main, the references appear together and cannot be filtered by
|
||||
read/write access. Standard LSP references provide locations without this metadata.
|
||||
With this change, both toolbar buttons are initially selected:
|
||||
|
||||
| Expression | Access | Read only | Write only |
|
||||
| ---------------------------------------- | ---------- | --------- | ---------- |
|
||||
| `b.count` in an initializer | Read | Visible | Hidden |
|
||||
| `b.count = 1` | Write | Hidden | Visible |
|
||||
| `b.count += 2`, `b.count++`, `--b.count` | Read/write | Visible | Visible |
|
||||
| `b.getCount()` | Read | Visible | Hidden |
|
||||
| `b.setCount(...)` | Write | Hidden | Visible |
|
||||
| `b::getCount`, `b::setCount` | Unknown | Visible | Visible |
|
||||
|
||||
The two references in `b.setCount(b.getCount())` remain distinct even on one
|
||||
line. Inherited and interface calls are included. Selecting `Fluent.score`
|
||||
also includes `score()` and `score(int)` by method identity. `b.items[0] = 1`
|
||||
reads `items`; it writes an element, not the field holding the array.
|
||||
|
||||
Turn off Show write accesses to inspect reads. Turn it back on and turn off Show
|
||||
read accesses to inspect writes. Turning off the last enabled switch enables the
|
||||
other. Move the results to Find: filters and original location metadata remain
|
||||
available. Reopen the popup to check persistence. Unknown results must stay
|
||||
visible in either mode. English tooltips are "Show read accesses" and "Show write
|
||||
accesses"; the icons are vendored JetBrains resources, not a claim of complete UI
|
||||
parity.
|
||||
|
||||
## Automated server evidence and desktop checks
|
||||
|
||||
The harness creates an Eclipse project with the same fixture, loads the actual
|
||||
Lombok agent and extension, imports it in JDT LS, asserts semantic results and
|
||||
checks cancellation. No Maven download is needed for this harness.
|
||||
|
||||
```sh
|
||||
export PATH=/path/to/jdk25/bin:$PATH
|
||||
python3 java/java-usages/tests/read-write.py /path/to/jdtls /path/to/lombok.jar > /tmp/read-write.json
|
||||
```
|
||||
|
||||
Then launch the desktop app with `pnpm tauri dev` and open the same Java/Lombok
|
||||
fixture. Check Read only, Write only, both toggles and Open in Find Window. The
|
||||
chosen mode and exact source ranges must survive transfer. Select a Java access
|
||||
mode, then run a non-Java search: it must show a plain count and no access toolbar.
|
||||
Repeat Java usages in a project with a marked source root. Confirm import and
|
||||
indexing have completed before comparing results.
|
||||
|
||||
For a desktop before/after demonstration use separate builds of the base and PR
|
||||
with this same fixture, settings and import completion state.
|
||||
|
||||
## Scope and performance interpretation
|
||||
|
||||
This does not classify constructor initialization. Method references stay unknown,
|
||||
and `lombok.config`-only naming or unusual signatures may remain unmapped.
|
||||
See the parent README for internal API compatibility and conservative fallback.
|
||||
|
||||
A true no-feature timing baseline must omit this bundle entirely while retaining
|
||||
the same Lombok agent, then call `textDocument/references` directly. Compare the
|
||||
same query position, settings, server/compiler, fixture and import state. Measure
|
||||
cold first queries separately from warmed samples. RPC timings exclude UI and
|
||||
Rust orchestration. Fluent/prefixed fields may return fewer locations natively;
|
||||
those timings are not equal-workload comparisons and cannot establish a semantic
|
||||
speedup. Counter-instrumented runs must be separate from timing runs.
|
||||
@@ -0,0 +1,24 @@
|
||||
import lombok.*;
|
||||
import lombok.experimental.Accessors;
|
||||
interface Counter { int getCount(); void setCount(int value); }
|
||||
@Data class Bean implements Counter { public int count; public Bean next; public int[] items; }
|
||||
class Child extends Bean {}
|
||||
@Data @Accessors(fluent=true, chain=true) class Fluent { public int score; }
|
||||
class Use {
|
||||
void uses(Bean b, Child c, Counter i, Fluent f) {
|
||||
int read = b.count; // read
|
||||
b.count = 1; // write
|
||||
b.count += 2; // readWrite
|
||||
b.count++; // readWrite
|
||||
--b.count; // readWrite
|
||||
b.setCount(b.getCount()); // setter write, getter read; nested invocation ranges
|
||||
c.setCount(c.getCount()); // inherited
|
||||
i.setCount(i.getCount()); // interface
|
||||
java.util.function.IntSupplier ref = b::getCount; // unknown
|
||||
java.util.function.IntConsumer writeRef = b::setCount; // unknown
|
||||
f.score = f.score(); // field write, fluent read
|
||||
f.score(f.score()); // fluent setter write, getter read
|
||||
b.items[0] = 1; // reading array field, writing element
|
||||
b.next.count = 2; // reading receiver field, writing count
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
"""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)
|
||||
# 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
|
||||
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, '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)
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Pull Members Up / Push Members Down against a real JDT LS. Argument: jdtls home.
|
||||
|
||||
Each case computes the edit from the same unchanged sources, applies it to a copy
|
||||
of the project and compiles the copy with javac.
|
||||
"""
|
||||
import json
|
||||
import pathlib
|
||||
import platform
|
||||
import queue
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
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-hierarchy-'))
|
||||
config = work / 'config'
|
||||
config.mkdir()
|
||||
system, machine = platform.system(), platform.machine().lower()
|
||||
config_name = {'Windows': 'config_win', 'Darwin': 'config_mac'}.get(system, 'config_linux')
|
||||
config_names = ([config_name + '_arm'] if machine in ('arm64', 'aarch64') else []) + [config_name]
|
||||
config_source = next(home / name / 'config.ini' for name in config_names if (home / name / 'config.ini').is_file())
|
||||
(config / 'config.ini').write_bytes(config_source.read_bytes())
|
||||
project = work / 'project'
|
||||
(project / 'src/zoo').mkdir(parents=True)
|
||||
(project / '.project').write_text('<projectDescription><name>zoo</name><buildSpec><buildCommand><name>org.eclipse.jdt.core.javabuilder</name></buildCommand></buildSpec><natures><nature>org.eclipse.jdt.core.javanature</nature></natures></projectDescription>')
|
||||
(project / '.classpath').write_text('<classpath><classpathentry kind="src" path="src"/><classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER"/><classpathentry kind="output" path="bin"/></classpath>')
|
||||
sources = {
|
||||
'Animal.java': '''package zoo;
|
||||
|
||||
public abstract class Animal {
|
||||
protected String name = "animal";
|
||||
|
||||
public String describe() {
|
||||
return "I am " + name;
|
||||
}
|
||||
}
|
||||
''',
|
||||
'Speaker.java': '''package zoo;
|
||||
|
||||
public interface Speaker {
|
||||
}
|
||||
''',
|
||||
'Dog.java': '''package zoo;
|
||||
|
||||
public class Dog extends Animal implements Speaker {
|
||||
private int tail = 1;
|
||||
|
||||
public String bark() {
|
||||
return "Woof";
|
||||
}
|
||||
|
||||
public int wag() {
|
||||
return tail * 2;
|
||||
}
|
||||
}
|
||||
''',
|
||||
'Cat.java': '''package zoo;
|
||||
|
||||
public class Cat extends Animal {
|
||||
public String purr() {
|
||||
return describe() + " purr";
|
||||
}
|
||||
}
|
||||
''',
|
||||
'Lonely.java': '''package zoo;
|
||||
|
||||
public class Lonely {
|
||||
public void alone() {
|
||||
}
|
||||
}
|
||||
''',
|
||||
'Use.java': '''package zoo;
|
||||
|
||||
public class Use {
|
||||
String run(Dog dog, Animal animal) {
|
||||
return dog.bark() + dog.wag() + animal.describe();
|
||||
}
|
||||
}
|
||||
''',
|
||||
}
|
||||
for name, text in sources.items():
|
||||
(project / 'src/zoo' / name).write_text(text)
|
||||
uri = lambda name: (project / 'src/zoo' / name).as_uri()
|
||||
|
||||
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()
|
||||
seq = 0
|
||||
|
||||
|
||||
def request(method, params):
|
||||
global seq
|
||||
seq += 1
|
||||
send({'jsonrpc': '2.0', 'id': seq, 'method': method, 'params': params})
|
||||
deadline = time.monotonic() + 180
|
||||
while time.monotonic() < deadline:
|
||||
msg = messages.get(timeout=max(1, deadline - time.monotonic()))
|
||||
if msg.get('id') == seq:
|
||||
if 'error' in msg:
|
||||
raise RuntimeError(msg['error'])
|
||||
return msg.get('result')
|
||||
raise TimeoutError(method)
|
||||
|
||||
|
||||
def position(name, needle):
|
||||
lines = sources[name].splitlines()
|
||||
line = next(i for i, t in enumerate(lines) if needle in t)
|
||||
return line, lines[line].index(needle)
|
||||
|
||||
|
||||
def command(name, *args):
|
||||
return request('workspace/executeCommand', {'command': name, 'arguments': list(args)})
|
||||
|
||||
|
||||
def members(file, needle, kind):
|
||||
line, col = position(file, needle)
|
||||
return command('openidea.java.hierarchy.members', uri(file), line, col, kind)
|
||||
|
||||
|
||||
def refactor(file, needle, kind, target, choices):
|
||||
line, col = position(file, needle)
|
||||
return command('openidea.java.hierarchy.refactor', uri(file), line, col, kind, target, choices)
|
||||
|
||||
|
||||
def offset(text, pos):
|
||||
lines = text.split('\n')
|
||||
return sum(len(l) + 1 for l in lines[:pos['line']]) + pos['character']
|
||||
|
||||
|
||||
def apply(edit):
|
||||
"""The project after `edit`, compiled; returns the edited sources."""
|
||||
per_file = {}
|
||||
for u, edits in (edit.get('changes') or {}).items():
|
||||
per_file.setdefault(u, []).extend(edits)
|
||||
for change in edit.get('documentChanges') or []:
|
||||
assert 'kind' not in change, change
|
||||
per_file.setdefault(change['textDocument']['uri'], []).extend(change['edits'])
|
||||
out = dict(sources)
|
||||
for u, edits in per_file.items():
|
||||
name = pathlib.Path(urllib.parse.unquote(urllib.parse.urlparse(u).path)).name
|
||||
text = out[name]
|
||||
for e in sorted(edits, key=lambda e: offset(text, e['range']['start']), reverse=True):
|
||||
text = text[:offset(text, e['range']['start'])] + e['newText'] + text[offset(text, e['range']['end']):]
|
||||
out[name] = text
|
||||
copy = work / f'compile{seq}'
|
||||
(copy / 'zoo').mkdir(parents=True)
|
||||
for name, text in out.items():
|
||||
(copy / 'zoo' / name).write_text(text)
|
||||
javac = subprocess.run(['javac', '-d', str(copy / 'bin'), *map(str, (copy / 'zoo').glob('*.java'))], capture_output=True, text=True)
|
||||
assert javac.returncode == 0, (javac.stderr, out)
|
||||
return out
|
||||
|
||||
|
||||
def body(text, name):
|
||||
return [l.strip() for l in text.splitlines() if name in l]
|
||||
|
||||
|
||||
try:
|
||||
init = request('initialize', {'processId': None, 'rootUri': project.as_uri(), 'workspaceFolders': [{'uri': project.as_uri(), 'name': 'zoo'}], 'capabilities': {'workspace': {'workspaceEdit': {'documentChanges': True}}}, 'initializationOptions': {'bundles': [str(bundle)], 'settings': {'java': {'import': {'gradle': {'enabled': False}, 'maven': {'enabled': False}}}}}})
|
||||
commands = init['capabilities']['executeCommandProvider']['commands']
|
||||
assert 'openidea.java.hierarchy.members' in commands and 'openidea.java.hierarchy.refactor' in commands, commands
|
||||
send({'jsonrpc': '2.0', 'method': 'initialized', 'params': {}})
|
||||
for name, text in sources.items():
|
||||
send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': uri(name), 'languageId': 'java', 'version': 1, 'text': text}}})
|
||||
request('java/buildWorkspace', True)
|
||||
evidence = {}
|
||||
|
||||
# Members of Dog, with bark() preselected; Animal and Speaker are destinations.
|
||||
info = members('Dog.java', 'bark', 'pullUp')
|
||||
evidence['pullUpMembers'] = info
|
||||
assert info['className'] == 'Dog', info
|
||||
assert {t['name'] for t in info['targets']} == {'Animal', 'Speaker'}, info['targets']
|
||||
rows = {m['label'].split('(')[0].split(':')[0].strip(): m for m in info['members']}
|
||||
assert rows['bark']['selected'] and not rows['wag']['selected'] and not rows['tail']['selected'], rows
|
||||
assert rows['bark']['abstractable'] and rows['bark']['kind'] == 'method' and rows['tail']['kind'] == 'field', rows
|
||||
animal = next(t for t in info['targets'] if t['name'] == 'Animal')
|
||||
speaker = next(t for t in info['targets'] if t['name'] == 'Speaker')
|
||||
|
||||
# Caret on the class name: nothing preselected.
|
||||
on_class = members('Dog.java', 'class Dog', 'pullUp')
|
||||
assert on_class['className'] == 'Dog' and not any(m['selected'] for m in on_class['members']), on_class
|
||||
|
||||
# Pull bark() up to Animal: moved, call sites unchanged, compiles.
|
||||
result = refactor('Dog.java', 'bark', 'pullUp', animal['id'], [{'id': rows['bark']['id'], 'abstract': False}])
|
||||
evidence['pullUp'] = result
|
||||
assert not [p for p in result['problems'] if p['severity'] in ('error', 'fatal')], result['problems']
|
||||
out = apply(result['edit'])
|
||||
assert 'bark()' in out['Animal.java'] and 'bark()' not in out['Dog.java'], out
|
||||
assert out['Use.java'] == sources['Use.java']
|
||||
|
||||
# Make abstract: declared in Animal, kept (and overridden) in Dog.
|
||||
result = refactor('Dog.java', 'bark', 'pullUp', animal['id'], [{'id': rows['bark']['id'], 'abstract': True}])
|
||||
evidence['pullUpAbstract'] = result
|
||||
out = apply(result['edit'])
|
||||
assert any('abstract' in l for l in body(out['Animal.java'], 'bark()')), out['Animal.java']
|
||||
assert 'Woof' in out['Dog.java'] and '@Override' in out['Dog.java'], out['Dog.java']
|
||||
|
||||
# To an interface, a method is always declared, never moved.
|
||||
result = refactor('Dog.java', 'bark', 'pullUp', speaker['id'], [{'id': rows['bark']['id'], 'abstract': False}])
|
||||
evidence['pullUpInterface'] = result
|
||||
out = apply(result['edit'])
|
||||
assert body(out['Speaker.java'], 'bark()') == ['String bark();'] or body(out['Speaker.java'], 'bark()') == ['public String bark();'], out['Speaker.java']
|
||||
assert 'Woof' in out['Dog.java'] and '@Override' in out['Dog.java'], out['Dog.java']
|
||||
|
||||
# wag() uses Dog's private field: reported before anything changes.
|
||||
result = refactor('Dog.java', 'wag', 'pullUp', animal['id'], [{'id': rows['wag']['id'], 'abstract': False}])
|
||||
evidence['pullUpConflict'] = result
|
||||
assert any(p['severity'] in ('error', 'fatal') and 'tail' in p['message'] for p in result['problems']), result
|
||||
|
||||
# No superclass in the sources.
|
||||
refused = members('Lonely.java', 'alone', 'pullUp')
|
||||
evidence['pullUpRefused'] = refused
|
||||
assert 'refused' in refused, refused
|
||||
|
||||
# Push describe() down: copied into Dog and Cat, removed from Animal.
|
||||
info = members('Animal.java', 'describe', 'pushDown')
|
||||
evidence['pushDownMembers'] = info
|
||||
assert info['className'] == 'Animal' and {t['name'] for t in info['targets']} == {'Dog', 'Cat'}, info
|
||||
rows = {m['label'].split('(')[0].split(':')[0].strip(): m for m in info['members']}
|
||||
assert rows['describe']['selected'] and rows['describe']['abstractable'] and not rows['name']['abstractable'], rows
|
||||
result = refactor('Animal.java', 'describe', 'pushDown', None, [{'id': rows['describe']['id'], 'abstract': True}])
|
||||
evidence['pushDownAbstract'] = result
|
||||
assert not [p for p in result['problems'] if p['severity'] in ('error', 'fatal')], result['problems']
|
||||
out = apply(result['edit'])
|
||||
assert any('abstract' in l for l in body(out['Animal.java'], 'describe()')), out['Animal.java']
|
||||
assert 'I am' in out['Dog.java'] and 'I am' in out['Cat.java'], out
|
||||
|
||||
# Without keeping a declaration, Use's animal.describe() breaks: JDT reports it.
|
||||
result = refactor('Animal.java', 'describe', 'pushDown', None, [{'id': rows['describe']['id'], 'abstract': False}])
|
||||
evidence['pushDownConflict'] = result
|
||||
assert any(p['severity'] in ('error', 'fatal') for p in result['problems']), result
|
||||
|
||||
(work / 'evidence.json').write_text(json.dumps(evidence, indent=2))
|
||||
print('hierarchy: all cases passed')
|
||||
finally:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=20)
|
||||
print('Evidence:', work, file=sys.stderr)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Run actual JDT LS + Lombok integration test. Arguments: jdtls home, lombok jar."""
|
||||
import os
|
||||
import hashlib
|
||||
import zipfile
|
||||
import json
|
||||
import pathlib
|
||||
import platform
|
||||
import queue
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
home, lombok = map(lambda p: pathlib.Path(p).resolve(), sys.argv[1:3])
|
||||
bundle = pathlib.Path(__file__).resolve().parents[1] / 'openidea-java-usages.jar'
|
||||
work = pathlib.Path(tempfile.mkdtemp(prefix='openidea-usages-'))
|
||||
config = work / 'config'
|
||||
config.mkdir()
|
||||
system, machine = platform.system(), platform.machine().lower()
|
||||
config_name = {'Windows': 'config_win', 'Darwin': 'config_mac'}.get(system, 'config_linux')
|
||||
config_names = ([config_name + '_arm'] if machine in ('arm64', 'aarch64') else []) + [config_name]
|
||||
config_source = next(home / name / 'config.ini' for name in config_names
|
||||
if (home / name / 'config.ini').is_file())
|
||||
(config / 'config.ini').write_bytes(config_source.read_bytes())
|
||||
project = work / 'project'
|
||||
project.mkdir()
|
||||
(project / 'src').mkdir()
|
||||
(project / '.project').write_text('<projectDescription><name>accessors</name><buildSpec><buildCommand><name>org.eclipse.jdt.core.javabuilder</name></buildCommand></buildSpec><natures><nature>org.eclipse.jdt.core.javanature</nature></natures></projectDescription>')
|
||||
(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="{lombok}"/><classpathentry kind="output" path="bin"/></classpath>')
|
||||
source = (pathlib.Path(__file__).parent / 'ReadWrite.java').read_text()
|
||||
index_stress = os.environ.get('OPENIDEA_USAGES_INDEX_STRESS') == '1'
|
||||
if index_stress:
|
||||
# Keep the original nested/method-reference cases, but cross the index threshold
|
||||
# for both standard and fluent accessors without changing their expected rows.
|
||||
source = source[:-len(' }\n}\n')] + (' b.getCount(); b.setCount(0); f.score(); f.score(0);' * 12) + '\n }\n}\n'
|
||||
source += 'class More extends Bean { void calls() { super.setCount(super.getCount()); } }\n'
|
||||
file = project / 'src/Bean.java'
|
||||
file.write_text(source)
|
||||
log = (work / 'stderr.log').open('w')
|
||||
proc = subprocess.Popen(['java', f'-javaagent:{lombok}', '-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()
|
||||
seq = 0
|
||||
transcript = []
|
||||
def request(method, params):
|
||||
global seq
|
||||
seq += 1
|
||||
send({'jsonrpc':'2.0', 'id':seq, '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') == seq:
|
||||
if 'error' in msg: raise RuntimeError(msg['error'])
|
||||
return msg.get('result')
|
||||
raise TimeoutError(method)
|
||||
try:
|
||||
init = request('initialize', {'processId':None, 'rootUri':project.as_uri(), 'workspaceFolders':[{'uri':project.as_uri(), 'name':'accessors'}], 'capabilities':{}, 'initializationOptions':{'bundles':[str(bundle)], 'settings':{'java':{'references':{'includeAccessors':True}, '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)
|
||||
evidence = {'workspace':str(work), 'source':source, 'lombokSha256':hashlib.sha256(lombok.read_bytes()).hexdigest(), 'lombokManifest':zipfile.ZipFile(lombok).read('META-INF/MANIFEST.MF').decode(), 'jdtls':next((home/'plugins').glob('org.eclipse.jdt.ls.core_*.jar')).name, 'commands':init.get('capabilities', {}).get('executeCommandProvider'), 'cases':{}}
|
||||
evidence['cases']={}
|
||||
for field in ['count','score','items','next']:
|
||||
line,text=next((i,t) for i,t in enumerate(source.splitlines()) if f'public int {field};' in t or f'public int[] {field};' in t or f'public Bean {field};' in t)
|
||||
col=text.index(field)
|
||||
evidence['cases'][field]=request('workspace/executeCommand',{'command':'openidea.java.usages','arguments':[file.as_uri(),line,col,False]})
|
||||
errors=[d for m in transcript if m.get('method')=='textDocument/publishDiagnostics' for d in m['params']['diagnostics'] if d.get('severity')==1]
|
||||
assert not errors, errors
|
||||
expected={8:['read'],9:['write'],10:['readWrite'],11:['readWrite'],12:['readWrite'],13:['read','write'],14:['read','write'],15:['read','write'],16:['unknown'],17:['unknown'],21:['write']}
|
||||
if index_stress:
|
||||
expected[22]=['read','write']*12
|
||||
expected[len(source.splitlines())-1]=['read','write']
|
||||
actual={}
|
||||
for row in evidence['cases']['count']:
|
||||
actual.setdefault(row['location']['range']['start']['line'],[]).append(row['access'])
|
||||
for line,accesses in expected.items(): assert sorted(actual.get(line,[]))==sorted(accesses),(line,actual)
|
||||
assert [r['access'] for r in evidence['cases']['items']]==['read'],evidence['cases']['items']
|
||||
assert [r['access'] for r in evidence['cases']['next']]==['read'],evidence['cases']['next']
|
||||
scores={}
|
||||
for row in evidence['cases']['score']: scores.setdefault(row['location']['range']['start']['line'],[]).append(row['access'])
|
||||
expected_scores={18:['read','write'],19:['read','write']}
|
||||
if index_stress: expected_scores[22]=sorted(['read','write']*12)
|
||||
assert {l:sorted(a) for l,a in scores.items()}==expected_scores,scores
|
||||
# Exercise the real JSON-RPC cancellation path separately from assertions
|
||||
# about successful responses. Send cancellation immediately after dispatch.
|
||||
line,text=next((i,t) for i,t in enumerate(source.splitlines()) if 'public int count;' in t)
|
||||
seq += 1
|
||||
send({'jsonrpc':'2.0','id':seq,'method':'workspace/executeCommand','params':{'command':'openidea.java.usages','arguments':[file.as_uri(),line,text.index('count'),False]}})
|
||||
send({'jsonrpc':'2.0','method':'$/cancelRequest','params':{'id':seq}})
|
||||
deadline=time.monotonic()+30
|
||||
while True:
|
||||
message=messages.get(timeout=max(.1,deadline-time.monotonic()))
|
||||
transcript.append(message)
|
||||
if message.get('id')==seq:
|
||||
evidence['cancellation']=message
|
||||
assert message.get('error',{}).get('code') == -32800, message
|
||||
break
|
||||
assert time.monotonic()<deadline, 'Cancellation response timed out'
|
||||
evidence['assertions']='direct field read/write/readWrite, nested calls, inherited/interface, method references unknown, fluent, array/receiver reads passed'
|
||||
(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)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Run actual JDT LS + Lombok integration test. Arguments: jdtls home, lombok jar."""
|
||||
import hashlib
|
||||
import zipfile
|
||||
import json
|
||||
import pathlib
|
||||
import platform
|
||||
import queue
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
home, lombok = map(lambda p: pathlib.Path(p).resolve(), sys.argv[1:3])
|
||||
bundle = pathlib.Path(__file__).resolve().parents[1] / 'openidea-java-usages.jar'
|
||||
work = pathlib.Path(tempfile.mkdtemp(prefix='openidea-usages-'))
|
||||
config = work / 'config'
|
||||
config.mkdir()
|
||||
system, machine = platform.system(), platform.machine().lower()
|
||||
config_name = {'Windows': 'config_win', 'Darwin': 'config_mac'}.get(system, 'config_linux')
|
||||
config_names = ([config_name + '_arm'] if machine in ('arm64', 'aarch64') else []) + [config_name]
|
||||
config_source = next(home / name / 'config.ini' for name in config_names
|
||||
if (home / name / 'config.ini').is_file())
|
||||
(config / 'config.ini').write_bytes(config_source.read_bytes())
|
||||
project = work / 'project'
|
||||
project.mkdir()
|
||||
(project / 'src').mkdir()
|
||||
(project / '.project').write_text('<projectDescription><name>accessors</name><buildSpec><buildCommand><name>org.eclipse.jdt.core.javabuilder</name></buildCommand></buildSpec><natures><nature>org.eclipse.jdt.core.javanature</nature></natures></projectDescription>')
|
||||
(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="{lombok}"/><classpathentry kind="output" path="bin"/></classpath>')
|
||||
source = '''import lombok.Data;
|
||||
@Data
|
||||
class Bean {
|
||||
private String name;
|
||||
private boolean active;
|
||||
void direct() { name = "direct"; System.out.println(name); }
|
||||
}
|
||||
@Data
|
||||
@lombok.experimental.Accessors(fluent=true, chain=true, prefix="m")
|
||||
class Fluent {
|
||||
private String mTitle;
|
||||
}
|
||||
@Data
|
||||
@lombok.experimental.Accessors(chain=true, prefix="m")
|
||||
class Prefixed {
|
||||
private String mLabel;
|
||||
}
|
||||
class Manual {
|
||||
private int size;
|
||||
public int getSize() { return size; }
|
||||
public void setSize(int value) { size = value; }
|
||||
public void setSize(String unrelated) {}
|
||||
}
|
||||
class Child extends Manual {}
|
||||
@Data
|
||||
@lombok.experimental.Accessors(fluent=true)
|
||||
class NotSetter {
|
||||
private String value;
|
||||
public int value(String x) { return 1; }
|
||||
}
|
||||
class Use {
|
||||
void unrelated(NotSetter n) { n.value("not a setter"); }
|
||||
void manual(Manual m, Child c) { m.setSize(m.getSize()); System.out.println(c.getSize()); m.setSize("unrelated"); }
|
||||
void prefixed(Prefixed p) { p.setLabel(p.getLabel()); }
|
||||
void fluent(Fluent f) { f.title(f.title()); }
|
||||
void calls(Bean b) { b.setName(b.getName()); b.setActive(b.isActive()); }
|
||||
java.util.function.Supplier<String> getter(Bean b) { return b::getName; }
|
||||
java.util.function.Consumer<String> setter(Bean b) { return b::setName; }
|
||||
}
|
||||
'''
|
||||
file = project / 'src/Bean.java'
|
||||
file.write_text(source)
|
||||
log = (work / 'stderr.log').open('w')
|
||||
proc = subprocess.Popen(['java', f'-javaagent:{lombok}', '-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()
|
||||
seq = 0
|
||||
transcript = []
|
||||
def request(method, params):
|
||||
global seq
|
||||
seq += 1
|
||||
send({'jsonrpc':'2.0', 'id':seq, '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') == seq:
|
||||
if 'error' in msg: raise RuntimeError(msg['error'])
|
||||
return msg.get('result')
|
||||
raise TimeoutError(method)
|
||||
try:
|
||||
init = request('initialize', {'processId':None, 'rootUri':project.as_uri(), 'workspaceFolders':[{'uri':project.as_uri(), 'name':'accessors'}], 'capabilities':{}, 'initializationOptions':{'bundles':[str(bundle)], 'settings':{'java':{'references':{'includeAccessors':True}, '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)
|
||||
evidence = {'workspace':str(work), 'source':source, 'lombokSha256':hashlib.sha256(lombok.read_bytes()).hexdigest(), 'lombokManifest':zipfile.ZipFile(lombok).read('META-INF/MANIFEST.MF').decode(), 'jdtls':next((home/'plugins').glob('org.eclipse.jdt.ls.core_*.jar')).name, 'commands':init.get('capabilities', {}).get('executeCommandProvider'), 'cases':{}}
|
||||
for field, expected in [('name',(2,2,2)), ('active',(1,1,0)), ('mTitle',(1,1,0)), ('mLabel',(1,1,0)), ('size',(2,1,2)), ('value',(0,0,0))]:
|
||||
line, text = next((i, t) for i,t in enumerate(source.splitlines()) if 'private ' in t and f' {field};' in t)
|
||||
col = text.index(field)
|
||||
before = request('textDocument/references', {'textDocument':{'uri':file.as_uri()},'position':{'line':line,'character':col},'context':{'includeDeclaration':False}})
|
||||
after = request('workspace/executeCommand', {'command':'openidea.java.usages','arguments':[file.as_uri(),line,col,False]})
|
||||
evidence['cases'][field] = {'before':before,'after':after}
|
||||
assert all(any(row['location'] == loc for row in after) for loc in before), 'lost standard references'
|
||||
kinds = [r['kind'] for r in after]
|
||||
assert kinds.count('getter') == expected[0], (field, kinds)
|
||||
assert kinds.count('setter') == expected[1], (field, kinds)
|
||||
assert kinds.count('field') == expected[2], (field, kinds)
|
||||
# Respect the existing global preference: the command must not switch it on.
|
||||
send({'jsonrpc':'2.0','method':'workspace/didChangeConfiguration','params':{'settings':{'java':{'references':{'includeAccessors':False}}}}})
|
||||
disabled = request('workspace/executeCommand', {'command':'openidea.java.usages','arguments':[file.as_uri(),3,19,False]})
|
||||
assert len(disabled) == 2 and all(r['kind'] == 'field' for r in disabled), disabled
|
||||
evidence['accessorsDisabled'] = disabled
|
||||
(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
Block a user