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,117 @@
|
||||
# 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, bulk scoped reference analysis, and Java Go to Definition / Ctrl+hover outside a `@Value` or property-lookup argument (checked with the bundled tree-sitter grammar) do not start indexing. At most eight project snapshots are
|
||||
retained, with a shared 100,000-association retention cap. Records contain property names, element handles and source locations;
|
||||
ASTs and bindings are discarded after indexing. A lexical prefilter avoids AST
|
||||
construction for ordinary Java units, but every accepted association is resolved
|
||||
semantically. Warm requests reuse the snapshot. Java model changes (sources on
|
||||
disk, classpath, archives) invalidate it conservatively, including dependent-type
|
||||
changes; a fingerprint of working copies with unsaved changes covers edits before
|
||||
they are saved. Opening or closing an editor, reconciling, building, and non-Java
|
||||
resource changes (configuration files are parsed by the client; metadata files are
|
||||
checked by timestamp) keep it. 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 java/java-usages/build.py /path/to/jdtls
|
||||
python3 java/java-usages/tests/configuration.py /path/to/jdtls
|
||||
OPENIDEA_CONFIGURATION_STRESS=1 python3 java/java-usages/tests/configuration.py /path/to/jdtls
|
||||
OPENIDEA_SPRING_BOOT_JAR=/path/to/spring-boot-autoconfigure.jar python3 java/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.
|
||||
@@ -0,0 +1,11 @@
|
||||
Manifest-Version: 1.0
|
||||
Bundle-ManifestVersion: 2
|
||||
Bundle-Name: OpenIDEA Java usages
|
||||
Bundle-SymbolicName: ai.openidea.usages;singleton:=true
|
||||
Bundle-Version: 1.3.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.core.resources,com.google.gson,
|
||||
org.eclipse.ltk.core.refactoring
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
# 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
|
||||
`access` is read/write/readWrite/unknown. Missing access metadata is unknown;
|
||||
old servers and ordinary LSP locations remain visible under either filter.
|
||||
An absent or failing extension command uses standard LSP references, without access
|
||||
classification or toolbar controls. This includes errors during scope widening.
|
||||
The Rust bridge logs the failure once per project session. Cancelled requests and
|
||||
changed source documents remain cancelled and do not start a fallback search.
|
||||
No global `includeAccessors` preference is changed.
|
||||
|
||||
## Search plan and access semantics
|
||||
|
||||
For source fields on the verified JDT LS implementation, a single plan merges
|
||||
field, native getter/setter, native builder and explicit Lombok Accessors targets.
|
||||
Targets are deduplicated by JDT element identity before search, retaining native
|
||||
search scope and ranges. Builder results remain unknown. The optimized native
|
||||
plan is guarded by SHA-256 of ReferencesHandler.class:
|
||||
`31ff5a10c7d69d58c6a9b619ef17e3ec41b11b545751ba428618a76cc1341064`
|
||||
(JDT LS 1.63.0.202610191322). Different implementations, non-field selections and
|
||||
library/decompiled selections retain native discovery plus deduplicated category
|
||||
searches. This conservative fallback preserves coverage when the server changes;
|
||||
it does not promise the same speedup on unverified versions.
|
||||
|
||||
SearchEngine resolves exact targets. A query-local syntax AST per referenced source
|
||||
file supplies expression context without another reference search: simple field
|
||||
assignment is write, compound assignment/increment is readWrite, ordinary field
|
||||
use is read. Writing an array element or a receiver's member reads the array or
|
||||
receiver field. Getter/setter call expressions are read/write respectively.
|
||||
Method references, declarations, unavailable/changed source, and unclassified
|
||||
search targets remain unknown. No call text or get/set prefix classifies a usage.
|
||||
Explicit `@Accessors` is resolved to JDT method signatures as in the prior bundle.
|
||||
|
||||
For larger result sets, a query-local AST range index avoids repeating NodeFinder
|
||||
traversals. It indexes only requested exact AST ranges and verified invocation
|
||||
selector-to-call-end spans; duplicate, recovered, malformed or uncontained ranges
|
||||
fall back to NodeFinder. Method references do not get invocation aliases. Source
|
||||
snapshot checks remain per result, and the index is discarded after the query.
|
||||
There is no cross-request cache or binding-resolution shortcut.
|
||||
|
||||
Popup and Find share persisted read/write toolbar switches. Both initially show;
|
||||
turning the last one off automatically enables the other. Unknown and readWrite
|
||||
entries survive both single-access modes. Filtered-out selections are not previewed.
|
||||
Exact original ranges and access metadata survive transfer to Find. Closing or
|
||||
superseding a popup request prevents late responses from reopening it.
|
||||
|
||||
The interaction and primary-field/secondary-accessor design were checked against
|
||||
[JetBrains IntelliJ Community e992ff4](https://github.com/JetBrains/intellij-community/tree/e992ff4750b4c36afe2caadfa8b191a8f36fb7a2):
|
||||
`LombokFieldFindUsagesHandlerFactory`, `LombokReadWriteAccessDetector`,
|
||||
`UsageFilteringRuleStateRWAware`, `ReadAccessFilteringRule`, and
|
||||
`WriteAccessFilteringRule`. This implementation does not copy those sources and
|
||||
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.3.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.
|
||||
|
||||
Build dependency: [JDT LS 1.63.0 build 202610191322](https://download.eclipse.org/jdtls/milestones/1.63.0/jdt-language-server-1.63.0-202610191322.tar.gz).
|
||||
Use its shipped ECJ and plugin jars, with Java 25; emitted bytecode targets Java 21.
|
||||
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 prior 1.1.1 output was
|
||||
`1e569939cba22c7db6e2c9b53de2a4c10ccaabe762a51427119a04af3d974ae4`.
|
||||
Version 1.2.0 adds the configuration command; its reproducible SHA-256 is
|
||||
`9140f1d2ecf27eadfe66b40a17daa18a08a65927ebef79456ba55d486ce5eeba`.
|
||||
Version 1.2.1 keeps the configuration snapshot across editor open/close, saves of
|
||||
unrelated content and non-Java resource changes; its reproducible SHA-256 is
|
||||
`cbae3e3a0bcf2818b4cc9d98998bd9b81f92e1ae7803dc757134485f9776a4e1`.
|
||||
Version 1.3.0 adds the class-hierarchy refactoring commands; its reproducible SHA-256 is
|
||||
`cca4cc385ace286111730d2a6164333b8958d74d69631548eadca8863255c24a`.
|
||||
|
||||
```sh
|
||||
export PATH=/path/to/jdk25/bin:$PATH
|
||||
python3 java/java-usages/build.py /path/to/jdtls
|
||||
```
|
||||
|
||||
## Manual upgrades
|
||||
|
||||
Rebuilding on a JDT LS upgrade is manual. Updating JDT LS or compiling the app does
|
||||
not rebuild this jar. A maintainer must:
|
||||
|
||||
1. Obtain the intended JDT LS distribution and verify its provenance and plugin hashes.
|
||||
Update `build-dependencies.sha256` for the reviewed distribution.
|
||||
2. Review the internal APIs listed below. Check `ReferencesHandler.class` against
|
||||
the native-plan fingerprint; update the guarded plan only after reviewing changes.
|
||||
3. Bump the bundle version in `MANIFEST.MF` and the installed filename in
|
||||
`crates/ide-sdk/src/java.rs` so OSGi does not reuse the old cached bundle.
|
||||
4. Run `build.py` with that distribution and JDK 25 as shown above. Build twice and
|
||||
compare hashes, then run both real-server harnesses and the desktop checks below.
|
||||
5. Commit the reviewed source, dependency inventory, version changes and rebuilt jar
|
||||
together. Test the current and newly supported JDT LS versions before release.
|
||||
|
||||
## Real server verification
|
||||
|
||||
```sh
|
||||
python3 java/java-usages/tests/verify.py /path/to/jdtls /path/to/lombok.jar
|
||||
python3 java/java-usages/tests/read-write.py /path/to/jdtls /path/to/lombok.jar > /tmp/read-write.json
|
||||
OPENIDEA_USAGES_INDEX_STRESS=1 python3 java/java-usages/tests/read-write.py /path/to/jdtls /path/to/lombok.jar > /tmp/read-write-indexed.json
|
||||
python3 java/java-usages/tests/hierarchy.py /path/to/jdtls
|
||||
```
|
||||
|
||||
The harnesses select the JDT LS configuration for the current platform. The first
|
||||
verifies category coverage, standard-reference inclusion, boolean/fluent/prefix/chain
|
||||
naming, overloads, inheritance and includeAccessors=false. The second checks
|
||||
structural read/write cases, interface and inherited calls, nested same-line calls,
|
||||
method references, receiver/array reads and a real cancellation response (`-32800`).
|
||||
|
||||
Use the actual desktop app to check read/write toggles, mutual toggle behavior,
|
||||
Find transfer, persistence and unfiltered non-Java counts with the same fixture.
|
||||
|
||||
`hierarchy.py` runs Pull Members Up and Push Members Down: member and destination
|
||||
listing, moving, declaring abstract, pulling up to an interface, conflicts reported
|
||||
before any change, and a class without supertypes. Each edit is applied to a copy of
|
||||
the fixture and compiled with javac.
|
||||
|
||||
## Class-hierarchy refactorings
|
||||
|
||||
`openidea.java.hierarchy.members(uri, line, column, kind)` describes the class at the
|
||||
position for `kind` `pullUp` or `pushDown`: its movable members (handle id, label,
|
||||
kind, preselected, can be declared abstract) and the destination supertypes, or the
|
||||
direct subtypes the members are pushed into. A position on a member preselects it.
|
||||
`openidea.java.hierarchy.refactor(uri, line, column, kind, target, choices)` runs
|
||||
JDT's `PullUpRefactoringProcessor` / `PushDownRefactoringProcessor` with the chosen
|
||||
`{id, abstract}` members and returns `{problems, edit}`. Nothing is applied by the
|
||||
server; the app shows the problems, then applies the edit as one undoable change.
|
||||
A method pulled up to an interface is always declared there, as in JDT's wizard.
|
||||
Either command answers `{refused: reason}` when the position can't be refactored.
|
||||
|
||||
## Review and manual reproduction
|
||||
|
||||
See [the English reproduction guide](tests/REPRODUCE.md) for a small Java/Lombok
|
||||
fixture, before/after behavior and desktop verification steps.
|
||||
|
||||
## Internal API compatibility
|
||||
|
||||
This is an app-owned JDT LS extension, not a standard LSP feature. It uses:
|
||||
|
||||
- `org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler` and the
|
||||
`org.eclipse.jdt.ls.core.delegateCommandHandler` extension point.
|
||||
- `ReferencesHandler.findReferences` and `search`; the optimized plan also
|
||||
reflects its private `getBuilderName(IField)` on the fingerprinted version.
|
||||
- `JavaLanguageServerPlugin.getPreferencesManager` and the returned preference
|
||||
objects (`isIncludeAccessors`).
|
||||
- `JDTUtils.resolveTypeRoot`, `findElementAtSelection`.
|
||||
- `org.eclipse.jdt.internal.corext.codemanipulation.GetterSetterUtil`.
|
||||
- `org.eclipse.jdt.internal.corext.refactoring.structure.PullUpRefactoringProcessor`,
|
||||
`PushDownRefactoringProcessor`, `RefactoringAvailabilityTesterCore`, `JdtFlags`,
|
||||
`JavaElementLabelsCore`, `PreferenceManager.getCodeGenerationSettings`,
|
||||
`JsonRpcHelpers.toOffset`, `JDTUtils.resolveCompilationUnit` and
|
||||
`ChangeUtil.convertToWorkspaceEdit`.
|
||||
- Public JDT model/DOM APIs for exact target identity and source-expression context.
|
||||
|
||||
The fingerprint protects native target discovery, not all binary compatibility.
|
||||
A server update that breaks linkage or command execution falls back to standard
|
||||
references in the Rust bridge. Results remain visible without classification.
|
||||
Rebuild and run the real-server and desktop tests when changing the distribution.
|
||||
The current read/write implementation was tested on JDT LS 1.63.0.202610191322
|
||||
with Lombok 1.18.48 and Java 25. It is not certified for every milestone.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- `lombok.config`-only naming, unusual generic spellings, custom capitalization or
|
||||
ambiguous annotations retain the prior limitations; no speculative mapping.
|
||||
- Method references are conservatively unknown rather than asserted immediate reads/writes.
|
||||
- Constructor initialization is outside this product change.
|
||||
- Internal JDT APIs still require compatibility testing on updates; guarded native
|
||||
planning is intentionally limited to a verified implementation.
|
||||
- Packaged desktop/macOS WebKit and the user's own project require separate testing.
|
||||
@@ -0,0 +1,110 @@
|
||||
beebede8db065fe1b72909ecc66bb49acda618901635d86c25fce14a5915e37e plugins/ch.qos.logback.classic_1.6.3.jar
|
||||
a6967a28c8b086dee75a694d2f6fb8830c71153539866d7d8af065c9b6df91e0 plugins/ch.qos.logback.core_1.6.3.jar
|
||||
2cbd119bf1961c28788310963dc80ba65f58cdeec1dd139c8bdb1240faa2c36f plugins/com.google.gson_2.14.0.jar
|
||||
cbfc3906b19b8f55dd7cfd6dfe0aa4532e834250d7f080bd8d211a3e246b59cb plugins/com.google.guava.failureaccess_1.0.3.jar
|
||||
796d8e28ac64e83a47c4c5935a8fecc4682650a04bbdead738ef0f5a3a0e6c46 plugins/com.google.guava_33.7.1.jre.jar
|
||||
3b3864f5b449e9c3c24b16861524b622b086563f44e0cd8384c8efc5a6052f82 plugins/com.sun.jna.platform_5.19.1.jar
|
||||
467b2990d4f406e7d41f7a989f89df257767ca11d566f844560e2ab8d09f6433 plugins/com.sun.jna_5.19.1.v20260612-1000.jar
|
||||
85fb03fc054cdf4efca8efd9b6712bbb418e1ab98241c4539c8585bbc23e1b8a plugins/jakarta.annotation-api_1.3.5.jar
|
||||
a852b0971c589491e24334bd77e92760c048d2c102fc4c68e582e1cd53130f84 plugins/jakarta.inject.jakarta.inject-api_1.0.5.jar
|
||||
2506fb25a748d4a5e5ec549d8bb063889d0f213e9cfbd6fd6fcbdc93b4f35bbb plugins/jakarta.servlet-api_6.2.0.M2.jar
|
||||
cbf2aa09c14441d2ddea7096f60b738b3aee7f08fbf98f5f91e2ca48b08fff4a plugins/org.apache.ant_1.10.18.v20260906-1000.jar
|
||||
8f7f8605d68e15bf32db61ec94eac6fdafc51b1bdbe1e0e0802b57d23f387792 plugins/org.apache.commons.cli_1.11.0.jar
|
||||
78a5d732fbd715e2d10bd7150d2f8030bae57267f8aacc5c88f642cb6c2e5d3f plugins/org.apache.commons.commons-codec_1.22.1.jar
|
||||
69e5c9fa35da7a51a5fd2099dfe56a2d8d32cf233e2f6d770e796146440263f4 plugins/org.apache.commons.lang3_3.20.0.jar
|
||||
c5bd887c8fd5ec4cd563249e7552bb1a1a1ca57e05a1f1ef8425a7b7b7bd2fbe plugins/org.apache.felix.scr_2.2.18.jar
|
||||
c375b6d5b71efe179e5763a6d83fcf10e303c0bf82031310a46769727ac90da0 plugins/org.commonmark.ext-gfm-tables_0.30.1.jar
|
||||
9b0d20064975a17e876fcb4b599c6b3667f46a7739ed61e2f06c96f44b50629f plugins/org.commonmark_0.30.1.jar
|
||||
a6a16f8f2b6977a10f951ab4ff46b155e4e20dd8c2fcd2cb835b74d90f30b456 plugins/org.eclipse.ant.core_3.7.1100.v20260831-0701.jar
|
||||
61c479b6544db7eeebb5ba796051c092032d03a5136295baabd72a233baef29c plugins/org.eclipse.buildship.compat_3.1.13.v20260923-0232-s.jar
|
||||
213baab808816e0661acd61a5382a8f120923288212defbd189b4ee6540ecce1 plugins/org.eclipse.buildship.core_3.1.13.v20260923-0232-s.jar
|
||||
e75b07863128a076989d15e815d63a49cab772a338f1f0444d007bef3fa94601 plugins/org.eclipse.compare.core_3.8.900.v20260731-0809.jar
|
||||
01425989976dccd56d348b207e11239b3d8bf572f7b7ef12dbd29eb39c40205e plugins/org.eclipse.core.commands_3.13.100.v20260902-1129.jar
|
||||
a63b488fb9d5382fbb3b8bae0951f1da5fd3a20211aa6814c7e8c1e21d2ffc78 plugins/org.eclipse.core.contenttype_3.9.1000.v20260921-1824.jar
|
||||
06a18236ae181659e1e5499d912d68de527720686f1f51dab632d3e298e32b78 plugins/org.eclipse.core.expressions_3.9.700.v20260710-0925.jar
|
||||
422a33041ea01954f48ff3969e2c6d96e64831b3e372202a4d2a914282e3ffd9 plugins/org.eclipse.core.filebuffers_3.9.0.v20261010-1449.jar
|
||||
d0f5e3fb7cd17bb8daebc684311198138e380e089292477f12d2dfbb4c8a30a8 plugins/org.eclipse.core.filesystem_1.12.0.v20260927-0940.jar
|
||||
941b3c5feeba4ec0a949a8a94ff10283b2505813fa736c9e6345f146ae1b7e8a plugins/org.eclipse.core.jobs_3.15.900.v20260604-0622.jar
|
||||
6e7a8db7d87a44bb35924bb345ddf0d9372413e3fee2edd842b50ed630ec254f plugins/org.eclipse.core.net_1.5.900.v20260507-0401.jar
|
||||
e22d47e2e68713a74bfacd134dc01b1aa904a1b429769241483e058de166acea plugins/org.eclipse.core.resources_3.25.0.v20261010-1447.jar
|
||||
0947f29f5c5b7d234e3b735de2d00d33ae6e3ee428a61bb818f9c0fe2b809735 plugins/org.eclipse.core.runtime_3.35.100.v20260910-1515.jar
|
||||
553f09a59f2662fa4a3bbc94e84bd83221c1c6fb532d81b57010162fb47460a6 plugins/org.eclipse.core.variables_3.6.900.v20260602-0702.jar
|
||||
a4e87f5e883e6c864783dc8148e9f915c3e7a9e3e0e26acf6b82210d1134ed79 plugins/org.eclipse.debug.core_3.24.100.v20261009-1915.jar
|
||||
fd353398835107608f554779a6e91891725efb94e0f57559d0ab3cea4b6156c7 plugins/org.eclipse.equinox.app_1.7.600.v20251211-1038.jar
|
||||
28749ec64dac9d0e20e00becc3d5f9daccb5637455c9c546ab07be324011ef84 plugins/org.eclipse.equinox.common_3.21.100.v20260829-1451.jar
|
||||
9beeb1098a3da89a470c547aa840dea0d240d144138a5ba7b9ceedb84ed301ab plugins/org.eclipse.equinox.frameworkadmin.equinox_1.3.500.v20260928-0506.jar
|
||||
f2b956930b8383d708f1fe9df92938bffbe2e129082d4fd0b3d5f59c20a5c9f0 plugins/org.eclipse.equinox.frameworkadmin_2.3.600.v20260306-0554.jar
|
||||
8e889f92718fb8c612dd358265da2c94738bbc08b46473b81cad36f1177a8235 plugins/org.eclipse.equinox.launcher.cocoa.macosx.aarch64_1.2.1700.v20260913-1737.jar
|
||||
347674eb3d3d4000c184573618241141297fb9586c8f65ebdbacace59b8c81a2 plugins/org.eclipse.equinox.launcher.cocoa.macosx.x86_64_1.2.1700.v20260913-1737.jar
|
||||
57e8a4b46906751e214dfa8fab1e02462c40ad4e51f2226d9ef70af5f0941ccb plugins/org.eclipse.equinox.launcher.gtk.linux.aarch64_1.2.1700.v20260913-1737.jar
|
||||
d0e7418232627c53288e566728d24d1578650514152992b87f82a5d982429606 plugins/org.eclipse.equinox.launcher.gtk.linux.x86_64_1.2.1700.v20260913-1737.jar
|
||||
aa8c31a082b25b87f75008548f6544379174c98091934fb8399e3f917a0445f1 plugins/org.eclipse.equinox.launcher.win32.win32.x86_64_1.3.200.v20260913-1737.jar
|
||||
9b3b1da60813ac5fa09dd5c198f1884be7076d0a5daec54faa3f351a787e34b7 plugins/org.eclipse.equinox.launcher_1.8.0.v20260804-1928.jar
|
||||
fd04e81a28ab32b6bb073286eb293c44b760176fcf1e35ef15614d62df820f86 plugins/org.eclipse.equinox.preferences_3.12.100.v20251111-0704.jar
|
||||
9b149d3367d33fc1f9a19d3f87c425ecf9fc235ee748b3cf200ec387169b042b plugins/org.eclipse.equinox.registry_3.12.700.v20260830-1519.jar
|
||||
60388d163f70329c39a9cf4e32389ec4fba8dc80ae5518d97cd0be2ebb971510 plugins/org.eclipse.equinox.security.linux_1.2.0.v20260914-1851.jar
|
||||
a8408d4bcdc70e019d2d89ed8b1b0413eb74ea73e74fa3d064b7401643539797 plugins/org.eclipse.equinox.security.macosx_1.102.500.v20250521-0414.jar
|
||||
cf9668c1d1e481059366abc2719468aa52e1276475cf9c32d4d10f57b34d7182 plugins/org.eclipse.equinox.security.win32_1.3.0.v20240419-2334.jar
|
||||
91b74719f9c1a130193407808fe5daab3b92431efbf30b8f886f1ff5e97f468f plugins/org.eclipse.equinox.security_1.4.700.v20250622-1644.jar
|
||||
1a24a3c0fb01ad71f97432d5c2b364d9868b5a5ebd99a1308aacb1a2c03c47da plugins/org.eclipse.equinox.simpleconfigurator.manipulator_2.3.700.v20260306-0556.jar
|
||||
d250938d3715529a9822fcdf46ad2f5de39720558d7195b5c1a8713af00bcd13 plugins/org.eclipse.equinox.simpleconfigurator_1.5.700.v20251111-1031.jar
|
||||
db9a4e856bb4728a2719df544b0f2ccef52b54ebf4e643a417fdb471f85703c1 plugins/org.eclipse.equinox.spi_1.0.0.v20261009-1945.jar
|
||||
7615ee7abdd14695957a52864c57869018dc10ddaac80d3cd24b6a97e05d0d9f plugins/org.eclipse.jdt.apt.core_3.8.1000.v20261012-1009.jar
|
||||
ea365808514a22c2e5a1a3fe0b9c07b88a371094d5d5716d31906a8818d56b2a plugins/org.eclipse.jdt.apt.pluggable.core_1.4.800.v20260930-1106.jar
|
||||
f667bb3666d9875a2309fa2447a19e6cdd960281473bd7125a22c5a435246d21 plugins/org.eclipse.jdt.core.compiler.batch_3.46.200.v20261008-1115.jar
|
||||
a6d0f8fabd64c248c37c463789a1a13f801252b68b0a4f4808c207677b5cae17 plugins/org.eclipse.jdt.core.javac_1.0.0.z20260430-1959.jar
|
||||
8ea98fcbecbd89f311849e448f4c4274a2aba5e15cfcdcad29951330c40e0ee8 plugins/org.eclipse.jdt.core.manipulation_1.26.0.v20261009-1526.jar
|
||||
70b4bb168aab096ee7f33da7b72f470f4172fe3e18e80289dd89cf344937ed22 plugins/org.eclipse.jdt.core_3.48.0.v20261007-1246.jar
|
||||
12113df750bb22f7ef0dd3f044f7c334feecd55ddb56b9340529899c55f5893b plugins/org.eclipse.jdt.debug_3.27.0.v20261012-1250.jar
|
||||
f140fad97dc5c452af0cb0f87ed3c6c83ff518f1dda49a43b8b9bec07f17d0fd plugins/org.eclipse.jdt.junit.core_3.16.0.v20261008-2002.jar
|
||||
4ea3fe4a12a0e1e8b10b7ce3f577a0ff4f5115c6a5e40364c53b8b329a8505b0 plugins/org.eclipse.jdt.junit.runtime_3.8.200.v20260911-1643.jar
|
||||
8b928263817f5dd0a9210a8842682903f779dc83253fea501591e150e5303095 plugins/org.eclipse.jdt.launching_3.25.0.v20261010-0421.jar
|
||||
6e5e5e39d82e9ac04925e8b3f432ef6fe81284441e99952d2f720779c9e2831b plugins/org.eclipse.jdt.ls.core_1.63.0.202610191322.jar
|
||||
666cc544f8a63e41d2dc8e6f3626071214027e4f698dea5bf36137f85477bba4 plugins/org.eclipse.jdt.ls.filesystem_1.63.0.202610191322.jar
|
||||
792f7df214d4ab5e4915ac1805922d44de61ebb880f1fc4881ecd4ecfdca6aca plugins/org.eclipse.jdt.ls.logback.appender_1.63.0.202610191322.jar
|
||||
9647feb0524bf763c878e12ab878a684102b81cccb3f77feecbec709d54f9bbb plugins/org.eclipse.lsp4j.jsonrpc_1.0.0.v20260209-1721.jar
|
||||
ccd78893facc6bfcc359d56cba05d3d5b85eb41e4c40d4b4215ca45db5f416d9 plugins/org.eclipse.lsp4j_1.0.0.v20260209-1721.jar
|
||||
a5d6bdc3e6f5017f19375697c7a5929fbc83f5734f35d5ed4540c7b2c843d4bb plugins/org.eclipse.ltk.core.refactoring_3.17.0.v20261010-1449.jar
|
||||
69d1b41401686fd99337e1ddd8aeec309255ae464393ca19a8025c85bfae6601 plugins/org.eclipse.m2e.apt.core_2.3.200.20260815-0704.jar
|
||||
34872f7712dffca0b39804708c0c1b58f2f2576e27cb7176b92a1ea437d384e6 plugins/org.eclipse.m2e.core_2.8.0.20260815-0704.jar
|
||||
8c3fa36fe9f62ace897b0196df8e6bba6a50610c186f6689ada5f386a8b223b1 plugins/org.eclipse.m2e.jdt_2.5.300.20260813-1359.jar
|
||||
5c5b419a101210127f21fa7d37bd2605b90f96efe9d3e91ea31099fb243dec8e plugins/org.eclipse.m2e.maven.runtime_3.9.1601.20260722-2149.jar
|
||||
53376fa2d7bfd2d5166806e74af9576ee3f8f1c1ac916e2cb4d2bb0c3e72c02f plugins/org.eclipse.m2e.workspace.cli_0.4.0.jar
|
||||
20e09b8f88845480b6ad0b89ac001679b6713592cd0ee4bd78cdb326172eee62 plugins/org.eclipse.osgi.compatibility.state_1.3.100.v20260909-1123.jar
|
||||
fe66fd0b071bebc7e1a122be571ed8ecde373d2966fa6b1e161a899b3c7a054d plugins/org.eclipse.osgi_3.25.0.v20261008-2211.jar
|
||||
2714e0c7548a0c23ab99d4e84b68d3e67419bbd6b295efda15d881e7cb173a1c plugins/org.eclipse.search.core_3.17.0.v20261010-1449.jar
|
||||
0ca3b552c867619285b8ae36c7defdd6c0f013e692643d433fd111796da55f19 plugins/org.eclipse.text_3.14.900.v20261012-1310.jar
|
||||
c6c2b8593cd39c279e74e7055cbd146d39dc6f16011961b4928b92728d32d00d plugins/org.eclipse.xtext.xbase.lib_2.44.0.v20260824-1228.jar
|
||||
ea7a53f8b7966039c6f7d54f462cbbaaad38a5e313a4e7d9125fa39640988725 plugins/org.gradle.toolingapi_9.7.1.v20260923-0232-s.jar
|
||||
5d66b6a4a680755cb6ed7cb104fa7835ef644667586ff0737adeb977c39ecdbc plugins/org.hamcrest_3.0.0.jar
|
||||
e9d8c856856680427f096d156f02a414289985c930998fcb519ce5be39b42003 plugins/org.jsoup_1.23.2.jar
|
||||
3efac657139b0b8024748e937e3e1dba71fbdf69fb526c60f504a3fb5490381b plugins/org.junit_4.13.2.v20240929-1000.jar
|
||||
ed825d10ab1399c8c0cb669e688cf0c8c82629b4c8399b58352b68e92ca10fcb plugins/org.objectweb.asm_9.10.1.jar
|
||||
0653852deab5c6f6e9c84e3aa8f72e72b9ce7dfaf7a6431f3d80f6f9d7a433ad plugins/org.osgi.service.component_1.5.1.202212101352.jar
|
||||
43c7c870710e363405d422da653cce0d798a4537f76e4930f79bceadd3a55345 plugins/org.osgi.service.prefs_1.1.2.202109301733.jar
|
||||
208819c7c71690c15a6bb8b187474e7f9d0147946b680182a62b9f222ae014ec plugins/org.osgi.util.function_1.2.0.202109301733.jar
|
||||
7053c57e7d7d88fec6b90979a3af125e1d2bb847268a328a2f1ed65ad0a4c185 plugins/org.osgi.util.promise_1.3.0.202212101352.jar
|
||||
7e1446499b359675d8aabe6af2de86b85e72ae8a2a932c0b40d0e5ba57097439 plugins/slf4j.api_2.0.20.jar
|
||||
3970dab20b4deb8380ed9656392f289f3f3c2ee882d8c3942f1b5b486284620d plugins/wrapped.com.jetbrains.intellij.java.java-decompiler-engine_253.29346.240.jar
|
||||
65d87386b7e926c0b6fb330a8d2eb6a987f5949634f7574731c0a3c2329d5629 plugins/wrapped.com.vladsch.flexmark.flexmark-ext-emoji_0.64.8.jar
|
||||
7a350f43f858da8f4a230552607ced119aeaf97484ca11ce4cd792e1e41e5bf7 plugins/wrapped.com.vladsch.flexmark.flexmark-ext-gfm-strikethrough_0.64.8.jar
|
||||
7a424dae7df0ee86200a08e163d9d1d8f63cd7a1a0c980b84ccbec029571fb4e plugins/wrapped.com.vladsch.flexmark.flexmark-ext-ins_0.64.8.jar
|
||||
75a2e7168145f799a039832708aa0bc277a954c4a367f58711ccd6b9b7e7d04e plugins/wrapped.com.vladsch.flexmark.flexmark-ext-superscript_0.64.8.jar
|
||||
dbe6355ea9e04731d534d3d4151870f83684c7a44c87d541a2c53ef5ee2c4fa4 plugins/wrapped.com.vladsch.flexmark.flexmark-ext-tables_0.64.8.jar
|
||||
91ae2f77e832d20607fad5b7f407d438ca3f16ac032b4f14956e4e7eb3a60931 plugins/wrapped.com.vladsch.flexmark.flexmark-ext-wikilink_0.64.8.jar
|
||||
0b650a4b47d29ffe0a08802c5d58e2270f733ce5443420c0e8904cfa1605964f plugins/wrapped.com.vladsch.flexmark.flexmark-html2md-converter_0.64.8.jar
|
||||
74bab2d767f48f930471c9a72a0c3b89e9073549ad3754e6b7f83f09585463bc plugins/wrapped.com.vladsch.flexmark.flexmark-jira-converter_0.64.8.jar
|
||||
1c7d205afeef618760a5fa907d579835df625ee258cbebd49813f14c2254a535 plugins/wrapped.com.vladsch.flexmark.flexmark-util-ast_0.64.8.jar
|
||||
9179310212700753a4ecdc7aa057a04591b9cfdb089787a248e8269c34740310 plugins/wrapped.com.vladsch.flexmark.flexmark-util-builder_0.64.8.jar
|
||||
796b9acec9af0fa290725a14be8a3fc1d6fe7a9cb518a13f67124b5172a3485b plugins/wrapped.com.vladsch.flexmark.flexmark-util-collection_0.64.8.jar
|
||||
20458c20bc72808e1718ff8f1652272bbb32f62ddaecb2e9a0ddc84d77fd7283 plugins/wrapped.com.vladsch.flexmark.flexmark-util-data_0.64.8.jar
|
||||
b59fbc922e3604442ff6aae1c6b83220245ba4c3f89d38870bc28c982b69d01d plugins/wrapped.com.vladsch.flexmark.flexmark-util-dependency_0.64.8.jar
|
||||
b6073ee177aa2476d64787f9ec813fdb881943d402c195df0631ba672bfb1311 plugins/wrapped.com.vladsch.flexmark.flexmark-util-format_0.64.8.jar
|
||||
c892e6cdbba715c55462decbe1c83cec432548cf2877cfd0715975c44dfab55c plugins/wrapped.com.vladsch.flexmark.flexmark-util-html_0.64.8.jar
|
||||
1726c33ee95f77cfadc58c8ad27949ed72653f4045fbc399aff62827b44c7fcd plugins/wrapped.com.vladsch.flexmark.flexmark-util-misc_0.64.8.jar
|
||||
cd0f9212530aa73f67de94d080018b2c3a3a55d41050d3abb4be8afb69989be0 plugins/wrapped.com.vladsch.flexmark.flexmark-util-options_0.64.8.jar
|
||||
340167af15da00e3ae5fb89d6b44751abfd648951bb6d2e05c48287d9f9e70c9 plugins/wrapped.com.vladsch.flexmark.flexmark-util-sequence_0.64.8.jar
|
||||
a8fad4a006411d020624c7539e6145e97920067844acddc10b019a178a680948 plugins/wrapped.com.vladsch.flexmark.flexmark-util-visitor_0.64.8.jar
|
||||
792c13bc5536704fc0bfba9120e5bac712843a7ae6da6104b1392b382feaf63d plugins/wrapped.com.vladsch.flexmark.flexmark-util_0.64.8.jar
|
||||
a2ec63056bcfe8c4babc62f13f5f49b502fccccf7c011b5d4ba7dd93f4d09393 plugins/wrapped.com.vladsch.flexmark.flexmark_0.64.8.jar
|
||||
482b6aade7b0320e28db530a22f1382b3d30addf26a5ffd55a347f5c32f6dfc2 plugins/wrapped.org.jetbrains.annotations_24.0.1.jar
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Reproducible bundle build: python3 build.py /path/to/jdtls (JDT LS 1.63.0, Java 25; target Java 21)."""
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
root = pathlib.Path(__file__).resolve().parent
|
||||
plugins = pathlib.Path(sys.argv[1]).resolve() / 'plugins'
|
||||
with tempfile.TemporaryDirectory() as out:
|
||||
subprocess.run(['java', '-cp', str(plugins / '*'),
|
||||
'org.eclipse.jdt.internal.compiler.batch.Main', '-21', '-proc:none',
|
||||
'-classpath', os.pathsep.join(str(p) for p in sorted(plugins.glob('*.jar'))),
|
||||
'-d', out, *[str(p) for p in sorted((root / 'src').rglob('*.java'))]], check=True)
|
||||
with zipfile.ZipFile(root / 'openidea-java-usages.jar', 'w', zipfile.ZIP_DEFLATED) as jar:
|
||||
files = {'META-INF/MANIFEST.MF': root / 'MANIFEST.MF', 'plugin.xml': root / 'plugin.xml'}
|
||||
files.update({str(p.relative_to(out)): p for p in pathlib.Path(out).rglob('*.class')})
|
||||
for name, path in sorted(files.items()):
|
||||
jar.writestr(zipfile.ZipInfo(name, (2026, 1, 1, 0, 0, 0)), path.read_bytes())
|
||||
Binary file not shown.
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<plugin>
|
||||
<extension point="org.eclipse.jdt.ls.core.delegateCommandHandler">
|
||||
<delegateCommandHandler class="ai.openidea.usages.UsagesCommand">
|
||||
<command id="openidea.java.usages"/>
|
||||
</delegateCommandHandler>
|
||||
<delegateCommandHandler class="ai.openidea.configuration.ConfigurationCommand">
|
||||
<command id="openidea.java.configuration"/>
|
||||
</delegateCommandHandler>
|
||||
<delegateCommandHandler class="ai.openidea.hierarchy.HierarchyCommand">
|
||||
<command id="openidea.java.hierarchy.members"/>
|
||||
<command id="openidea.java.hierarchy.refactor"/>
|
||||
</delegateCommandHandler>
|
||||
</extension>
|
||||
</plugin>
|
||||
@@ -0,0 +1,542 @@
|
||||
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(), GENERATION = new AtomicLong();
|
||||
private static final Map<String, Index> CACHE = new LinkedHashMap<>();
|
||||
/** Unsaved edits are covered by the working-copy digest, so reconciles never
|
||||
* invalidate. Opening/closing a working copy (F_PRIMARY_WORKING_COPY) and
|
||||
* non-Java resources, reported as F_CONTENT on a project, root or package
|
||||
* (configuration files are parsed by the client; metadata files carry their
|
||||
* own stamps), do not change the index either. */
|
||||
private static final int IGNORED_FLAGS = IJavaElementDelta.F_CHILDREN | IJavaElementDelta.F_PRIMARY_WORKING_COPY
|
||||
| IJavaElementDelta.F_AST_AFFECTED | IJavaElementDelta.F_FINE_GRAINED;
|
||||
static { JavaCore.addElementChangedListener(event -> { if (affects(event.getDelta())) REVISION.incrementAndGet(); }, ElementChangedEvent.POST_CHANGE); }
|
||||
static boolean affects(IJavaElementDelta delta) {
|
||||
int flags = delta.getFlags() & ~IGNORED_FLAGS;
|
||||
if (delta.getElement().getElementType() < IJavaElement.COMPILATION_UNIT) flags &= ~IJavaElementDelta.F_CONTENT;
|
||||
if (delta.getKind() != IJavaElementDelta.CHANGED || flags != 0) return true;
|
||||
for (IJavaElementDelta child : delta.getAffectedChildren()) if (affects(child)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
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, long generation) {}
|
||||
|
||||
@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, "generation", index.generation);
|
||||
}
|
||||
|
||||
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);
|
||||
// Only unsaved contents differ from what the Java model reports for disk,
|
||||
// so merely opening or closing an editor keeps the snapshot.
|
||||
var digest = java.security.MessageDigest.getInstance("SHA-256");
|
||||
for (var entry : copies.entrySet()) {
|
||||
check(monitor);
|
||||
if (!entry.getValue().hasUnsavedChanges()) continue;
|
||||
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, GENERATION.incrementAndGet());
|
||||
CACHE.remove(id);
|
||||
while (!CACHE.isEmpty() && (CACHE.size() >= 8 ||
|
||||
CACHE.values().stream().mapToInt(value -> value.entries.size()).sum() + result.entries.size() > MAX_ENTRIES))
|
||||
CACHE.remove(CACHE.keySet().iterator().next());
|
||||
CACHE.put(id, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Relaxed matching is reserved for binder/metadata entries. @Value and
|
||||
* Environment lookups retain their literal names; dots remain boundaries. */
|
||||
static boolean matches(String pattern, String key, boolean relaxed) {
|
||||
if (!relaxed) return pattern.equals(key);
|
||||
List<String> a = parts(pattern), b = parts(key);
|
||||
if (a.size() != b.size()) return false;
|
||||
for (int i = 0; i < a.size(); i++) if (!a.get(i).equals("*") && !a.get(i).equals(b.get(i))) return false;
|
||||
return true;
|
||||
}
|
||||
private static List<String> parts(String key) {
|
||||
List<String> result = new ArrayList<>();
|
||||
StringBuilder part = new StringBuilder();
|
||||
for (int i = 0; i < key.length(); i++) {
|
||||
char ch = key.charAt(i);
|
||||
if (ch == '[') {
|
||||
if (!part.isEmpty()) { result.add(canonical(part.toString())); part.setLength(0); }
|
||||
int end = key.indexOf(']', i + 1);
|
||||
if (end < 0) { part.append(key.substring(i)); break; }
|
||||
result.add(canonical(key.substring(i + 1, end))); i = end;
|
||||
} else if (ch == '.') {
|
||||
if (!part.isEmpty() || i == 0 || key.charAt(i - 1) != ']') result.add(canonical(part.toString()));
|
||||
part.setLength(0);
|
||||
} else part.append(ch);
|
||||
}
|
||||
if (!part.isEmpty()) result.add(canonical(part.toString()));
|
||||
return result;
|
||||
}
|
||||
private static String canonical(String key) {
|
||||
return key.replace("-", "").replace("_", "").toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static final class Builder {
|
||||
final IProgressMonitor monitor;
|
||||
final List<Entry> entries = new ArrayList<>();
|
||||
final Map<Path, FileStamp> files = new LinkedHashMap<>();
|
||||
boolean incomplete;
|
||||
int parsed;
|
||||
Builder(IProgressMonitor monitor) { this.monitor = monitor; }
|
||||
void add(String key, IJavaElement element) {
|
||||
if (key.length() > 1024 || entries.size() >= MAX_ENTRIES) { incomplete = true; return; }
|
||||
if (element != null) entries.add(new Entry(key, element.getHandleIdentifier(), null, true));
|
||||
}
|
||||
void use(String key, ICompilationUnit unit, ASTNode node) {
|
||||
if (key.length() > 1024 || entries.size() >= MAX_ENTRIES) { incomplete = true; return; }
|
||||
if (key.isBlank() || key.contains("${") || key.contains("#{")) return;
|
||||
try { entries.add(new Entry(key, "", JDTUtils.toLocation(unit, node.getStartPosition(), node.getLength()), false)); }
|
||||
catch (JavaModelException e) { incomplete = true; }
|
||||
}
|
||||
void parse(ICompilationUnit unit) {
|
||||
check(monitor);
|
||||
ASTParser parser = ASTParser.newParser(AST.getJLSLatest());
|
||||
parser.setSource(unit);
|
||||
parser.setResolveBindings(true);
|
||||
parser.setWorkingCopyOwner(JavaLanguageServerPlugin.getInstance().getWorkingCopyOwner());
|
||||
parser.setBindingsRecovery(false);
|
||||
CompilationUnit tree = (CompilationUnit) parser.createAST(monitor);
|
||||
parsed++;
|
||||
tree.accept(new ASTVisitor() {
|
||||
@Override public void preVisit(ASTNode node) { check(monitor); }
|
||||
@Override public boolean visit(NormalAnnotation node) { annotation(node); return true; }
|
||||
@Override public boolean visit(SingleMemberAnnotation node) { annotation(node); return true; }
|
||||
@Override public boolean visit(MarkerAnnotation node) { annotation(node); return true; }
|
||||
private void annotation(Annotation node) {
|
||||
IAnnotationBinding binding = node.resolveAnnotationBinding();
|
||||
if (binding == null) return;
|
||||
String name = binding.getAnnotationType().getQualifiedName();
|
||||
if (name.equals(PROPERTIES)) {
|
||||
String prefix = annotationValue(binding, "prefix");
|
||||
if (prefix == null || prefix.isEmpty()) prefix = annotationValue(binding, "value");
|
||||
if (prefix == null) prefix = "";
|
||||
ASTNode owner = node.getParent();
|
||||
if (owner instanceof AbstractTypeDeclaration type) {
|
||||
bind(prefix, type.resolveBinding(), new HashSet<>(), 0);
|
||||
} else if (owner instanceof MethodDeclaration method && method.resolveBinding() != null) {
|
||||
add(prefix, method.resolveBinding().getJavaElement());
|
||||
bind(prefix, method.resolveBinding().getReturnType(), new HashSet<>(), 0);
|
||||
}
|
||||
} else if (name.equals(VALUE)) {
|
||||
String value = annotationValue(binding, "value");
|
||||
ASTNode expression = node;
|
||||
if (node instanceof SingleMemberAnnotation single) expression = single.getValue();
|
||||
else if (node instanceof NormalAnnotation normal) {
|
||||
for (Object pair : normal.values()) if (pair instanceof MemberValuePair p && p.getName().getIdentifier().equals("value")) expression = p.getValue();
|
||||
}
|
||||
if (value != null) placeholders(value, unit, expression);
|
||||
}
|
||||
}
|
||||
@Override public boolean visit(MethodInvocation node) {
|
||||
IMethodBinding method = node.resolveMethodBinding();
|
||||
if (method != null && (method.getName().equals("getProperty") || method.getName().equals("getRequiredProperty")) &&
|
||||
subtype(method.getDeclaringClass(), RESOLVER, new HashSet<>()) && !node.arguments().isEmpty()) {
|
||||
Expression arg = (Expression) node.arguments().get(0);
|
||||
Object constant = arg.resolveConstantExpressionValue();
|
||||
if (constant instanceof String key) use(key, unit, arg);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
void placeholders(String text, ICompilationUnit unit, ASTNode node) {
|
||||
// Include nested default placeholders, but never a dynamically composed name.
|
||||
for (int at = text.indexOf("${"); at >= 0; at = text.indexOf("${", at + 2)) {
|
||||
int end = at + 2;
|
||||
while (end < text.length() && text.charAt(end) != ':' && text.charAt(end) != '}') end++;
|
||||
if (end < text.length()) use(text.substring(at + 2, end), unit, node);
|
||||
}
|
||||
}
|
||||
void bind(String prefix, ITypeBinding type, Set<String> visiting, int depth) {
|
||||
check(monitor);
|
||||
if (type == null || type.isRecovered() || type.isPrimitive() || type.isEnum()) return;
|
||||
String qualified = type.getErasure().getQualifiedName();
|
||||
if (qualified.startsWith("java.") || qualified.startsWith("javax.")) return;
|
||||
if (depth > 12 || !visiting.add(type.getKey())) return;
|
||||
try {
|
||||
if (!prefix.isEmpty()) add(prefix, type.getJavaElement());
|
||||
List<IMethodBinding> constructors = Arrays.stream(type.getDeclaredMethods())
|
||||
.filter(m -> m.isConstructor() && !Modifier.isPrivate(m.getModifiers())).toList();
|
||||
for (IMethodBinding constructor : constructors) {
|
||||
boolean explicit = Arrays.stream(constructor.getAnnotations()).anyMatch(a ->
|
||||
a.getAnnotationType().getQualifiedName().equals("org.springframework.boot.context.properties.bind.ConstructorBinding") ||
|
||||
a.getAnnotationType().getQualifiedName().equals("org.springframework.boot.context.properties.ConstructorBinding"));
|
||||
if (!explicit && constructors.size() != 1) continue;
|
||||
String[] parameters = constructor.getParameterNames();
|
||||
for (int i = 0; i < parameters.length; i++) {
|
||||
String name = parameters[i];
|
||||
for (IAnnotationBinding annotation : constructor.getParameterAnnotations(i)) {
|
||||
if (annotation.getAnnotationType().getQualifiedName().equals("org.springframework.boot.context.properties.bind.Name")) {
|
||||
String alias = annotationValue(annotation, "value");
|
||||
if (alias != null) name = alias;
|
||||
}
|
||||
}
|
||||
// Prefer fields/accessors for ordinary parameters; aliases and
|
||||
// constructor-only properties still have a navigable declaration.
|
||||
final String parameterName = name;
|
||||
if (Arrays.stream(type.getDeclaredFields()).noneMatch(f -> f.getName().equals(parameterName))) {
|
||||
add(join(prefix, name), constructor.getJavaElement());
|
||||
nested(join(prefix, name), constructor.getParameterTypes()[i], constructor.getJavaElement(), visiting, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
Set<String> names = new HashSet<>();
|
||||
for (ITypeBinding current = type; current != null && !current.getQualifiedName().equals("java.lang.Object"); current = current.getSuperclass()) {
|
||||
for (IVariableBinding field : current.getDeclaredFields()) {
|
||||
if (Modifier.isStatic(field.getModifiers()) || field.isSynthetic() || !names.add(field.getName())) continue;
|
||||
String key = join(prefix, field.getName());
|
||||
add(key, field.getJavaElement());
|
||||
nested(key, field.getType(), field.getJavaElement(), visiting, depth + 1);
|
||||
}
|
||||
for (IMethodBinding method : current.getDeclaredMethods()) {
|
||||
if (Modifier.isStatic(method.getModifiers()) || method.isConstructor()) continue;
|
||||
String name = method.getName();
|
||||
String property = null;
|
||||
ITypeBinding propertyType = null;
|
||||
if (name.startsWith("set") && name.length() > 3 && method.getParameterTypes().length == 1) {
|
||||
property = name.substring(3); propertyType = method.getParameterTypes()[0];
|
||||
} else if (name.startsWith("get") && name.length() > 3 && method.getParameterTypes().length == 0) {
|
||||
property = name.substring(3); propertyType = method.getReturnType();
|
||||
}
|
||||
if (property != null) {
|
||||
property = Character.toLowerCase(property.charAt(0)) + property.substring(1);
|
||||
String key = join(prefix, property);
|
||||
add(key, method.getJavaElement());
|
||||
if (names.add(property)) nested(key, propertyType, method.getJavaElement(), visiting, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally { visiting.remove(type.getKey()); }
|
||||
}
|
||||
void nested(String key, ITypeBinding type, IJavaElement owner, Set<String> visiting, int depth) {
|
||||
if (type == null) return;
|
||||
if (type.isArray()) { add(key + "[*]", owner); bind(key + "[*]", type.getElementType(), visiting, depth); return; }
|
||||
String raw = type.getErasure().getQualifiedName();
|
||||
if (subtype(type, "java.util.Map", new HashSet<>())) {
|
||||
add(key + ".*", owner);
|
||||
var args = type.getTypeArguments();
|
||||
if (args.length == 2) bind(key + ".*", args[1], visiting, depth);
|
||||
} else if (subtype(type, "java.util.Collection", new HashSet<>())) {
|
||||
add(key + "[*]", owner);
|
||||
var args = type.getTypeArguments();
|
||||
if (args.length == 1) bind(key + "[*]", args[0], visiting, depth);
|
||||
} else if (!type.isPrimitive() && !type.isEnum() && !raw.startsWith("java.") && !raw.startsWith("javax.")) {
|
||||
bind(key, type, visiting, depth);
|
||||
}
|
||||
}
|
||||
void metadata(IPackageFragmentRoot root, IJavaProject project) {
|
||||
check(monitor);
|
||||
Path path = root.getPath().toFile().toPath();
|
||||
if (!root.isExternal() && root.getResource() != null && root.getResource().getLocation() != null)
|
||||
path = root.getResource().getLocation().toFile().toPath();
|
||||
files.put(path, stamp(path));
|
||||
if (!Files.isRegularFile(path)) return;
|
||||
try (ZipFile zip = new ZipFile(path.toFile())) {
|
||||
for (String filename : List.of("spring-configuration-metadata.json", "additional-spring-configuration-metadata.json")) {
|
||||
var entry = zip.getEntry("META-INF/" + filename);
|
||||
if (entry == null) continue;
|
||||
if (entry.getSize() > MAX_BYTES) { incomplete = true; continue; }
|
||||
JsonObject metadata;
|
||||
try (var stream = zip.getInputStream(entry)) {
|
||||
byte[] bytes = stream.readNBytes(MAX_BYTES + 1);
|
||||
if (bytes.length > MAX_BYTES) { incomplete = true; continue; }
|
||||
metadata = JsonParser.parseString(new String(bytes, java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject();
|
||||
}
|
||||
metadataObject(metadata, project);
|
||||
}
|
||||
} catch (Exception e) { if (e instanceof OperationCanceledException) throw (OperationCanceledException)e; incomplete = true; }
|
||||
}
|
||||
void metadataObject(JsonObject metadata, IJavaProject project) throws JavaModelException {
|
||||
JsonArray groups = metadata.has("groups") ? metadata.getAsJsonArray("groups") : new JsonArray();
|
||||
for (JsonElement value : groups) {
|
||||
check(monitor);
|
||||
JsonObject group = value.getAsJsonObject();
|
||||
String name = string(group, "name"), source = string(group, "sourceType");
|
||||
if (source == null) source = string(group, "type");
|
||||
if (name != null && source != null) add(name, project.findType(source.replace('$', '.'), monitor));
|
||||
}
|
||||
if (!metadata.has("properties")) return;
|
||||
for (JsonElement value : metadata.getAsJsonArray("properties")) {
|
||||
check(monitor);
|
||||
if (entries.size() >= MAX_ENTRIES) { incomplete = true; return; }
|
||||
JsonObject property = value.getAsJsonObject();
|
||||
String key = string(property, "name"), source = string(property, "sourceType"), prefix = "";
|
||||
if (key == null) continue;
|
||||
JsonObject owner = null;
|
||||
for (JsonElement groupValue : groups) {
|
||||
JsonObject group = groupValue.getAsJsonObject();
|
||||
String name = string(group, "name");
|
||||
if (name != null && key.startsWith(name + ".") && name.length() > prefix.length()) {
|
||||
prefix = name; owner = group;
|
||||
}
|
||||
}
|
||||
if (source == null && owner != null) source = string(owner, "type");
|
||||
if (source == null && owner != null) source = string(owner, "sourceType");
|
||||
if (source == null) continue;
|
||||
IType type = project.findType(source.replace('$', '.'), monitor);
|
||||
if (type == null) continue;
|
||||
String leaf = key.substring(prefix.isEmpty() ? 0 : prefix.length() + 1);
|
||||
IJavaElement target = member(type, leaf, new HashSet<>());
|
||||
if (target == null && owner != null && string(owner, "sourceMethod") != null) {
|
||||
String declaring = string(owner, "sourceType");
|
||||
IType factory = declaring == null ? null : project.findType(declaring.replace('$', '.'), monitor);
|
||||
if (factory != null) {
|
||||
String methodName = string(owner, "sourceMethod").split("\\(", 2)[0];
|
||||
for (IMethod method : factory.getMethods()) if (method.getElementName().equals(methodName)) { target = method; break; }
|
||||
}
|
||||
}
|
||||
add(key, target == null ? type : target);
|
||||
}
|
||||
}
|
||||
void metadataDirectory(Path directory, IJavaProject project) {
|
||||
for (String name : List.of("spring-configuration-metadata.json", "additional-spring-configuration-metadata.json")) {
|
||||
Path file = directory.resolve("META-INF").resolve(name);
|
||||
files.put(file, stamp(file));
|
||||
if (!Files.isRegularFile(file)) continue;
|
||||
try {
|
||||
if (Files.size(file) > MAX_BYTES) { incomplete = true; continue; }
|
||||
try (var stream = Files.newInputStream(file)) {
|
||||
byte[] bytes = stream.readNBytes(MAX_BYTES + 1);
|
||||
if (bytes.length > MAX_BYTES) { incomplete = true; continue; }
|
||||
metadataObject(JsonParser.parseString(new String(bytes, java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject(), project);
|
||||
}
|
||||
} catch (Exception e) { if (e instanceof OperationCanceledException) throw (OperationCanceledException)e; incomplete = true; }
|
||||
}
|
||||
}
|
||||
}
|
||||
private static IJavaElement member(IType type, String key, Set<String> seen) throws JavaModelException {
|
||||
if (type == null || !seen.add(type.getHandleIdentifier())) return null;
|
||||
String part = key.split("\\.", 2)[0];
|
||||
for (IField field : type.getFields()) if (canonical(field.getElementName()).equals(canonical(part))) {
|
||||
if (!key.contains(".")) return field;
|
||||
String signature = Signature.toString(Signature.getTypeErasure(field.getTypeSignature()));
|
||||
String[][] resolved = type.resolveType(signature);
|
||||
IType nested = type.getJavaProject().findType(signature);
|
||||
if (nested == null && resolved != null && resolved.length == 1)
|
||||
nested = type.getJavaProject().findType(resolved[0][0] + "." + resolved[0][1]);
|
||||
IJavaElement found = member(nested, key.substring(key.indexOf('.') + 1), seen);
|
||||
return found == null ? field : found;
|
||||
}
|
||||
for (IMethod method : type.getMethods()) {
|
||||
String name = method.getElementName();
|
||||
if (name.length() > 3 && (name.startsWith("get") || name.startsWith("set")) && canonical(name.substring(3)).equals(canonical(part))) return method;
|
||||
}
|
||||
String parent = type.getSuperclassName();
|
||||
if (parent != null) {
|
||||
String[][] resolved = type.resolveType(parent);
|
||||
if (resolved != null && resolved.length == 1)
|
||||
return member(type.getJavaProject().findType(resolved[0][0] + "." + resolved[0][1]), key, seen);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
private static String string(JsonObject object, String key) {
|
||||
return object.has(key) && object.get(key).isJsonPrimitive() ? object.get(key).getAsString() : null;
|
||||
}
|
||||
private static String annotationValue(IAnnotationBinding binding, String name) {
|
||||
for (IMemberValuePairBinding pair : binding.getAllMemberValuePairs())
|
||||
if (pair.getName().equals(name) && pair.getValue() instanceof String s) return s;
|
||||
return null;
|
||||
}
|
||||
private static String join(String prefix, String name) { return prefix.isEmpty() ? name : prefix + "." + name; }
|
||||
private static boolean subtype(ITypeBinding type, String name, Set<String> seen) {
|
||||
if (type == null || !seen.add(type.getKey())) return false;
|
||||
if (type.getErasure().getQualifiedName().equals(name)) return true;
|
||||
for (ITypeBinding face : type.getInterfaces()) if (subtype(face, name, seen)) return true;
|
||||
return subtype(type.getSuperclass(), name, seen);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package ai.openidea.hierarchy;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.eclipse.core.runtime.IProgressMonitor;
|
||||
import org.eclipse.core.runtime.OperationCanceledException;
|
||||
import org.eclipse.jdt.core.*;
|
||||
import org.eclipse.jdt.internal.core.manipulation.JavaElementLabelsCore;
|
||||
import org.eclipse.jdt.internal.corext.refactoring.RefactoringAvailabilityTesterCore;
|
||||
import org.eclipse.jdt.internal.corext.refactoring.structure.PullUpRefactoringProcessor;
|
||||
import org.eclipse.jdt.internal.corext.refactoring.structure.PushDownRefactoringProcessor;
|
||||
import org.eclipse.jdt.internal.corext.refactoring.structure.PushDownRefactoringProcessor.MemberActionInfo;
|
||||
import org.eclipse.jdt.internal.corext.util.JdtFlags;
|
||||
import org.eclipse.jdt.ls.core.internal.ChangeUtil;
|
||||
import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler;
|
||||
import org.eclipse.jdt.ls.core.internal.JDTUtils;
|
||||
import org.eclipse.jdt.ls.core.internal.handlers.JsonRpcHelpers;
|
||||
import org.eclipse.jdt.ls.core.internal.preferences.PreferenceManager;
|
||||
import org.eclipse.ltk.core.refactoring.Change;
|
||||
import org.eclipse.ltk.core.refactoring.RefactoringStatus;
|
||||
import org.eclipse.ltk.core.refactoring.RefactoringStatusEntry;
|
||||
import org.eclipse.ltk.core.refactoring.participants.ProcessorBasedRefactoring;
|
||||
import org.eclipse.ltk.core.refactoring.participants.RefactoringProcessor;
|
||||
|
||||
/**
|
||||
* Pull Members Up / Push Members Down with JDT's own processors.
|
||||
*
|
||||
* <p>{@code openidea.java.hierarchy.members(uri, line, column, kind)} describes the class at the
|
||||
* position: its movable members and, for pull up, the supertypes in project sources.
|
||||
* {@code openidea.java.hierarchy.refactor(uri, line, column, kind, target, choices)} checks the
|
||||
* chosen move and returns its problems and the edit, which is never applied here. {@code kind}
|
||||
* is {@code pullUp} or {@code pushDown}; {@code choices} are {@code {id, abstract}} objects whose
|
||||
* ids are the handle identifiers returned by {@code members}.
|
||||
*/
|
||||
public final class HierarchyCommand implements IDelegateCommandHandler {
|
||||
static final String MEMBERS = "openidea.java.hierarchy.members";
|
||||
static final String REFACTOR = "openidea.java.hierarchy.refactor";
|
||||
|
||||
private static final long LABEL = JavaElementLabelsCore.M_PARAMETER_TYPES | JavaElementLabelsCore.M_APP_RETURNTYPE
|
||||
| JavaElementLabelsCore.F_APP_TYPE_SIGNATURE;
|
||||
|
||||
@Override
|
||||
public Object executeCommand(String command, List<Object> arguments, IProgressMonitor monitor) throws Exception {
|
||||
String uri = (String) arguments.get(0);
|
||||
int line = ((Number) arguments.get(1)).intValue();
|
||||
int column = ((Number) arguments.get(2)).intValue();
|
||||
boolean pullUp = switch ((String) arguments.get(3)) {
|
||||
case "pullUp" -> true;
|
||||
case "pushDown" -> false;
|
||||
default -> throw new IllegalArgumentException("unknown hierarchy refactoring " + arguments.get(3));
|
||||
};
|
||||
ICompilationUnit unit = JDTUtils.resolveCompilationUnit(uri);
|
||||
if (unit == null || !unit.exists()) return refused("The file is not part of a Java project");
|
||||
IMember start = memberAt(unit, JsonRpcHelpers.toOffset(unit, line, column));
|
||||
IMember[] members = start == null ? null : selection(start, pullUp);
|
||||
if (members == null) {
|
||||
return refused(pullUp ? "Caret should be inside a class that has members to pull up"
|
||||
: "Caret should be inside a class that has members to push down");
|
||||
}
|
||||
if (MEMBERS.equals(command)) return pullUp ? pullUpMembers(unit, members, monitor) : pushDownMembers(members, monitor);
|
||||
if (REFACTOR.equals(command)) {
|
||||
String target = (String) arguments.get(4);
|
||||
List<Choice> choices = new ArrayList<>();
|
||||
for (Object o : (List<?>) arguments.get(5)) {
|
||||
Map<?, ?> map = (Map<?, ?>) o;
|
||||
IJavaElement element = JavaCore.create((String) map.get("id"));
|
||||
if (!(element instanceof IMember member) || !member.exists()) return refused("The class changed; open the dialog again");
|
||||
choices.add(new Choice(member, Boolean.TRUE.equals(map.get("abstract"))));
|
||||
}
|
||||
if (choices.isEmpty()) return refused("No members selected");
|
||||
// Started from the class, the processors know it even when no member is moved
|
||||
// (a method only declared abstract in the destination).
|
||||
IType declaring = members[0] instanceof IType type ? type : members[0].getDeclaringType();
|
||||
IMember[] owner = { declaring };
|
||||
return pullUp ? pullUp(unit, owner, target, choices, monitor) : pushDown(owner, choices, monitor);
|
||||
}
|
||||
throw new IllegalArgumentException("unknown command " + command);
|
||||
}
|
||||
|
||||
private record Choice(IMember member, boolean makeAbstract) {}
|
||||
|
||||
/** The innermost member at the offset, or the type itself for an initializer. */
|
||||
private static IMember memberAt(ICompilationUnit unit, int offset) throws JavaModelException {
|
||||
IJavaElement element = unit.getElementAt(offset);
|
||||
while (element != null && !(element instanceof IMember)) element = element.getParent();
|
||||
if (element instanceof IInitializer initializer) return initializer.getDeclaringType();
|
||||
return (IMember) element;
|
||||
}
|
||||
|
||||
/** The members to start from: the one at the caret when it can move, otherwise its class. */
|
||||
private static IMember[] selection(IMember start, boolean pullUp) throws JavaModelException {
|
||||
for (IMember member = start; member != null; member = member.getDeclaringType()) {
|
||||
if (member.isBinary() || member.isReadOnly()) return null;
|
||||
if (pullUp ? RefactoringAvailabilityTesterCore.isPullUpAvailable(member)
|
||||
: RefactoringAvailabilityTesterCore.isPushDownAvailable(member)) return new IMember[] { member };
|
||||
if (member instanceof IType type && (pullUp ? RefactoringAvailabilityTesterCore.getPullUpMembers(type)
|
||||
: RefactoringAvailabilityTesterCore.getPushDownMembers(type)).length > 0) {
|
||||
return new IMember[] { type };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Object pullUpMembers(ICompilationUnit unit, IMember[] members, IProgressMonitor monitor) throws Exception {
|
||||
var processor = new PullUpRefactoringProcessor(members, PreferenceManager.getCodeGenerationSettings(unit));
|
||||
RefactoringStatus status = new ProcessorBasedRefactoring(processor).checkInitialConditions(monitor);
|
||||
if (status.hasFatalError()) return refused(status.getMessageMatchingSeverity(RefactoringStatus.FATAL));
|
||||
IType[] candidates = processor.getCandidateTypes(new RefactoringStatus(), monitor);
|
||||
cancelled(monitor);
|
||||
if (candidates.length == 0) {
|
||||
return refused("'" + processor.getDeclaringType().getElementName()
|
||||
+ "' has no superclass or interface in the project sources to pull members up to");
|
||||
}
|
||||
List<Map<String, Object>> targets = new ArrayList<>();
|
||||
for (IType type : candidates) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("id", type.getHandleIdentifier());
|
||||
row.put("name", type.getElementName());
|
||||
row.put("qualifiedName", type.getFullyQualifiedName('.'));
|
||||
row.put("interface", type.isInterface());
|
||||
targets.add(row);
|
||||
}
|
||||
Set<IMember> selected = Set.of(processor.getMembersToMove());
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (IMember member : processor.getPullableMembersOfDeclaringType()) {
|
||||
boolean abstractable = member instanceof IMethod method && !JdtFlags.isStatic(method);
|
||||
rows.add(member(member, selected.contains(member), abstractable));
|
||||
}
|
||||
return info(processor.getDeclaringType(), targets, rows);
|
||||
}
|
||||
|
||||
private static Object pushDownMembers(IMember[] members, IProgressMonitor monitor) throws Exception {
|
||||
var processor = new PushDownRefactoringProcessor(members);
|
||||
RefactoringStatus status = new ProcessorBasedRefactoring(processor).checkInitialConditions(monitor);
|
||||
if (status.hasFatalError()) return refused(status.getMessageMatchingSeverity(RefactoringStatus.FATAL));
|
||||
IType declaring = processor.getDeclaringType();
|
||||
List<Map<String, Object>> targets = new ArrayList<>();
|
||||
for (IType type : declaring.newTypeHierarchy(declaring.getJavaProject(), monitor).getSubtypes(declaring)) {
|
||||
if (type.isBinary()) continue;
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("id", type.getHandleIdentifier());
|
||||
row.put("name", type.getElementName());
|
||||
row.put("qualifiedName", type.getFullyQualifiedName('.'));
|
||||
row.put("interface", type.isInterface());
|
||||
targets.add(row);
|
||||
}
|
||||
cancelled(monitor);
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (MemberActionInfo info : processor.getMemberActionInfos()) {
|
||||
boolean abstractable = false;
|
||||
for (int action : info.getAvailableActions()) abstractable |= action == MemberActionInfo.PUSH_ABSTRACT_ACTION;
|
||||
rows.add(member(info.getMember(), info.isToBePushedDown(), abstractable));
|
||||
}
|
||||
return info(declaring, targets, rows);
|
||||
}
|
||||
|
||||
private static Object pullUp(ICompilationUnit unit, IMember[] members, String target, List<Choice> choices,
|
||||
IProgressMonitor monitor) throws Exception {
|
||||
if (!(JavaCore.create(target) instanceof IType destination) || !destination.exists()) {
|
||||
return refused("The destination class no longer exists");
|
||||
}
|
||||
var processor = new PullUpRefactoringProcessor(members, PreferenceManager.getCodeGenerationSettings(unit));
|
||||
List<IMember> moved = new ArrayList<>();
|
||||
List<IMethod> declaredAbstract = new ArrayList<>();
|
||||
List<IMethod> deleted = new ArrayList<>();
|
||||
for (Choice choice : choices) {
|
||||
// A method pulled up to an interface is declared there, as JDT's own wizard does.
|
||||
if (choice.member instanceof IMethod method && (choice.makeAbstract || destination.isInterface())) {
|
||||
declaredAbstract.add(method);
|
||||
} else {
|
||||
moved.add(choice.member);
|
||||
if (choice.member instanceof IMethod method) deleted.add(method);
|
||||
}
|
||||
}
|
||||
processor.setMembersToMove(moved.toArray(IMember[]::new));
|
||||
processor.setAbstractMethods(declaredAbstract.toArray(IMethod[]::new));
|
||||
processor.setDeletedMethods(deleted.toArray(IMethod[]::new));
|
||||
processor.setDestinationType(destination);
|
||||
processor.setCreateMethodStubs(true);
|
||||
return run(processor, monitor);
|
||||
}
|
||||
|
||||
private static Object pushDown(IMember[] members, List<Choice> choices, IProgressMonitor monitor) throws Exception {
|
||||
var processor = new PushDownRefactoringProcessor(members);
|
||||
var refactoring = new ProcessorBasedRefactoring(processor);
|
||||
RefactoringStatus initial = refactoring.checkInitialConditions(monitor);
|
||||
if (initial.hasFatalError()) return result(initial, null);
|
||||
Map<IMember, Choice> chosen = new LinkedHashMap<>();
|
||||
for (Choice choice : choices) chosen.put(choice.member, choice);
|
||||
for (MemberActionInfo info : processor.getMemberActionInfos()) {
|
||||
Choice choice = chosen.get(info.getMember());
|
||||
if (choice == null) info.setAction(MemberActionInfo.NO_ACTION);
|
||||
else info.setAction(choice.makeAbstract && info.getMember() instanceof IMethod
|
||||
? MemberActionInfo.PUSH_ABSTRACT_ACTION : MemberActionInfo.PUSH_DOWN_ACTION);
|
||||
}
|
||||
RefactoringStatus status = refactoring.checkFinalConditions(monitor);
|
||||
initial.merge(status);
|
||||
return finish(refactoring, initial, monitor);
|
||||
}
|
||||
|
||||
private static Object run(RefactoringProcessor processor, IProgressMonitor monitor) throws Exception {
|
||||
var refactoring = new ProcessorBasedRefactoring(processor);
|
||||
RefactoringStatus status = refactoring.checkAllConditions(monitor);
|
||||
return finish(refactoring, status, monitor);
|
||||
}
|
||||
|
||||
private static Object finish(ProcessorBasedRefactoring refactoring, RefactoringStatus status, IProgressMonitor monitor)
|
||||
throws Exception {
|
||||
cancelled(monitor);
|
||||
if (status.hasFatalError()) return result(status, null);
|
||||
Change change = refactoring.createChange(monitor);
|
||||
cancelled(monitor);
|
||||
return result(status, ChangeUtil.convertToWorkspaceEdit(change));
|
||||
}
|
||||
|
||||
private static Map<String, Object> member(IMember member, boolean selected, boolean abstractable) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("id", member.getHandleIdentifier());
|
||||
row.put("label", JavaElementLabelsCore.getElementLabel(member, LABEL));
|
||||
row.put("kind", member instanceof IMethod ? "method" : member instanceof IField ? "field" : "type");
|
||||
row.put("selected", selected);
|
||||
row.put("abstractable", abstractable);
|
||||
return row;
|
||||
}
|
||||
|
||||
private static Map<String, Object> info(IType type, List<Map<String, Object>> targets, List<Map<String, Object>> members) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("className", type.getElementName());
|
||||
out.put("targets", targets);
|
||||
out.put("members", members);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static Map<String, Object> result(RefactoringStatus status, Object edit) {
|
||||
List<Map<String, Object>> problems = new ArrayList<>();
|
||||
for (RefactoringStatusEntry entry : status.getEntries()) {
|
||||
String severity = switch (entry.getSeverity()) {
|
||||
case RefactoringStatus.FATAL -> "fatal";
|
||||
case RefactoringStatus.ERROR -> "error";
|
||||
case RefactoringStatus.WARNING -> "warning";
|
||||
default -> "info";
|
||||
};
|
||||
problems.add(Map.of("severity", severity, "message", entry.getMessage()));
|
||||
}
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("problems", problems);
|
||||
out.put("edit", edit);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static Map<String, Object> refused(String reason) {
|
||||
return Map.of("refused", reason);
|
||||
}
|
||||
|
||||
private static void cancelled(IProgressMonitor monitor) {
|
||||
if (monitor.isCanceled()) throw new OperationCanceledException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package ai.openidea.usages;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
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.JDTUtils;
|
||||
import org.eclipse.lsp4j.Location;
|
||||
|
||||
/** Structural access context for already semantically bound search results.
|
||||
* One syntax parse per source file per query; no second reference search.
|
||||
*/
|
||||
final class UsageAccess {
|
||||
private record Parsed(ICompilationUnit unit, String source, CompilationUnit tree, Map<Long, ASTNode> exactNodes) {}
|
||||
private final Map<String, Parsed> files = new HashMap<>();
|
||||
private final IProgressMonitor monitor;
|
||||
private final Map<String, List<Location>> requests = new HashMap<>();
|
||||
UsageAccess(IProgressMonitor monitor, Map<Location, String> results) {
|
||||
this.monitor = monitor;
|
||||
results.forEach((location, kind) -> {
|
||||
if (!kind.equals("unknown")) requests.computeIfAbsent(location.getUri(), k -> new ArrayList<>()).add(location);
|
||||
});
|
||||
}
|
||||
|
||||
private static long rangeKey(int start, int length) { return ((long) start << 32) | (length & 0xffffffffL); }
|
||||
|
||||
private Map<Long, ASTNode> index(CompilationUnit tree, String uri) {
|
||||
List<Location> locations = requests.get(uri);
|
||||
// A few lookups do not justify traversing an entire file. Index exact AST
|
||||
// and selector-to-call-end ranges; all other ranges retain NodeFinder.
|
||||
if (locations == null || locations.size() < 16) return Map.of();
|
||||
var wanted = new HashSet<Long>();
|
||||
for (Location location : locations) {
|
||||
var range = location.getRange();
|
||||
int start = tree.getPosition(range.getStart().getLine() + 1, range.getStart().getCharacter());
|
||||
int end = tree.getPosition(range.getEnd().getLine() + 1, range.getEnd().getCharacter());
|
||||
if (start >= 0 && end > start) wanted.add(rangeKey(start, end - start));
|
||||
}
|
||||
Map<Long, ASTNode> nodes = new HashMap<>();
|
||||
tree.accept(new ASTVisitor(true) {
|
||||
int visited;
|
||||
@Override public void preVisit(ASTNode node) {
|
||||
if ((visited++ & 127) == 0 && monitor.isCanceled()) throw new OperationCanceledException();
|
||||
int start = node.getStartPosition();
|
||||
long end = (long) start + node.getLength();
|
||||
add(rangeKey(start, node.getLength()), node);
|
||||
// JDT method matches may start at the selector (after receiver)
|
||||
// and end after the arguments. That span is covered by this call,
|
||||
// but not by any of its children. It is not a method reference.
|
||||
SimpleName name = node instanceof MethodInvocation call ? call.getName() :
|
||||
node instanceof SuperMethodInvocation call ? call.getName() : null;
|
||||
if (name != null && end <= Integer.MAX_VALUE && name.getLength() > 0 &&
|
||||
(name.getFlags() & (ASTNode.MALFORMED | ASTNode.RECOVERED)) == 0 &&
|
||||
name.getStartPosition() >= start && end > (long) name.getStartPosition() + name.getLength())
|
||||
add(rangeKey(name.getStartPosition(), (int) (end - name.getStartPosition())), node);
|
||||
}
|
||||
private void add(long key, ASTNode node) {
|
||||
if (!wanted.contains(key)) return;
|
||||
if (nodes.containsKey(key)) {
|
||||
if (nodes.get(key) != node) nodes.put(key, null);
|
||||
return;
|
||||
}
|
||||
boolean contained = true;
|
||||
for (ASTNode parent = node; parent != null; parent = parent.getParent()) {
|
||||
if ((parent.getFlags() & (ASTNode.MALFORMED | ASTNode.RECOVERED)) != 0 ||
|
||||
parent.getStartPosition() > node.getStartPosition() ||
|
||||
(long) parent.getStartPosition() + parent.getLength() < (long) node.getStartPosition() + node.getLength()) {
|
||||
contained = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
nodes.put(key, contained ? node : null);
|
||||
}
|
||||
});
|
||||
return nodes;
|
||||
}
|
||||
|
||||
String classify(Location location, String kind) {
|
||||
if (kind.equals("unknown")) return "unknown";
|
||||
if (monitor.isCanceled()) throw new OperationCanceledException();
|
||||
try {
|
||||
String uri = location.getUri();
|
||||
if (!files.containsKey(uri)) {
|
||||
Parsed parsed = null;
|
||||
ITypeRoot root = JDTUtils.resolveTypeRoot(uri);
|
||||
if (root instanceof ICompilationUnit unit && unit.getResource() != null) {
|
||||
String source = unit.getBuffer().getContents();
|
||||
ASTParser parser = ASTParser.newParser(AST.getJLSLatest());
|
||||
parser.setSource(source.toCharArray());
|
||||
parser.setCompilerOptions(unit.getJavaProject().getOptions(true));
|
||||
// SearchEngine has already resolved the target identity. Here only
|
||||
// expression structure is needed; do not resolve the project again.
|
||||
CompilationUnit tree = (CompilationUnit) parser.createAST(monitor);
|
||||
parsed = new Parsed(unit, source, tree, index(tree, uri));
|
||||
}
|
||||
files.put(uri, parsed);
|
||||
}
|
||||
Parsed file = files.get(uri);
|
||||
if (file == null || !file.source.equals(file.unit.getBuffer().getContents())) return "unknown";
|
||||
var range = location.getRange();
|
||||
int start = file.tree.getPosition(range.getStart().getLine() + 1, range.getStart().getCharacter());
|
||||
int end = file.tree.getPosition(range.getEnd().getLine() + 1, range.getEnd().getCharacter());
|
||||
if (start < 0 || end < start) return "unknown";
|
||||
ASTNode node = file.exactNodes.get(rangeKey(start, end - start));
|
||||
if (node == null) node = NodeFinder.perform(file.tree, start, end - start);
|
||||
if (node == null || (node.getFlags() & (ASTNode.MALFORMED | ASTNode.RECOVERED)) != 0) return "unknown";
|
||||
if (kind.equals("getter") || kind.equals("setter")) {
|
||||
for (ASTNode p = node; p != null; p = p.getParent()) {
|
||||
if (p instanceof MethodReference) return "unknown";
|
||||
if (p instanceof MethodInvocation || p instanceof SuperMethodInvocation)
|
||||
return kind.equals("getter") ? "read" : "write";
|
||||
if (p instanceof Statement || p instanceof BodyDeclaration) break;
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
if (!(node instanceof SimpleName name) || name.isDeclaration()) return "unknown";
|
||||
ASTNode expression = node;
|
||||
while (true) {
|
||||
ASTNode parent = expression.getParent();
|
||||
if (parent instanceof QualifiedName q && q.getName() == expression ||
|
||||
parent instanceof FieldAccess f && f.getName() == expression ||
|
||||
parent instanceof SuperFieldAccess f && f.getName() == expression ||
|
||||
parent instanceof ParenthesizedExpression p && p.getExpression() == expression) {
|
||||
expression = parent;
|
||||
} else break;
|
||||
}
|
||||
ASTNode parent = expression.getParent();
|
||||
if (parent instanceof Assignment a && a.getLeftHandSide() == expression)
|
||||
return a.getOperator() == Assignment.Operator.ASSIGN ? "write" : "readWrite";
|
||||
if (parent instanceof PostfixExpression) return "readWrite";
|
||||
if (parent instanceof PrefixExpression p && (p.getOperator() == PrefixExpression.Operator.INCREMENT ||
|
||||
p.getOperator() == PrefixExpression.Operator.DECREMENT)) return "readWrite";
|
||||
return "read";
|
||||
} catch (OperationCanceledException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
// A missing/changed/unparseable document must remain visible, never guessed.
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package ai.openidea.usages;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.eclipse.core.runtime.IProgressMonitor;
|
||||
import org.eclipse.jdt.core.*;
|
||||
import org.eclipse.jdt.internal.corext.codemanipulation.GetterSetterUtil;
|
||||
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.*;
|
||||
|
||||
/** Classifies search results using JDT method identities, never reference text. */
|
||||
public final class UsagesCommand implements IDelegateCommandHandler {
|
||||
@Override
|
||||
public Object executeCommand(String command, List<Object> arguments, IProgressMonitor monitor) throws Exception {
|
||||
String uri = (String) arguments.get(0);
|
||||
int line = ((Number) arguments.get(1)).intValue();
|
||||
int column = ((Number) arguments.get(2)).intValue();
|
||||
boolean declarations = Boolean.TRUE.equals(arguments.get(3));
|
||||
var preferences = JavaLanguageServerPlugin.getPreferencesManager();
|
||||
var handler = new ReferencesHandler(preferences);
|
||||
var params = new ReferenceParams(new TextDocumentIdentifier(uri), new Position(line, column), new ReferenceContext(declarations));
|
||||
Map<Location, String> results = new LinkedHashMap<>();
|
||||
ITypeRoot root = JDTUtils.resolveTypeRoot(uri);
|
||||
IJavaElement selected = root == null ? null :
|
||||
JDTUtils.findElementAtSelection(root, line, column, preferences, monitor);
|
||||
if (monitor.isCanceled()) throw new org.eclipse.core.runtime.OperationCanceledException();
|
||||
boolean accessors = preferences.getPreferences().isIncludeAccessors();
|
||||
SearchPlan plan = new SearchPlan();
|
||||
// Only replace native target discovery for the exact implementation verified by
|
||||
// integration tests. New server implementations retain their full native behavior.
|
||||
if (selected instanceof IField field && root instanceof ICompilationUnit &&
|
||||
root.getResource() != null && NATIVE_PLAN_SUPPORTED) {
|
||||
plan.add(field, "field", declarations);
|
||||
if (accessors) {
|
||||
plan.add(GetterSetterUtil.getGetter(field), "getter", false);
|
||||
plan.add(GetterSetterUtil.getSetter(field), "setter", false);
|
||||
addNativeBuilderTargets(handler, field, plan);
|
||||
addConfiguredAccessors(field, plan);
|
||||
}
|
||||
} else {
|
||||
// Includes non-fields, library/decompiled roots, and unverified JDT LS builds.
|
||||
for (var location : handler.findReferences(params, monitor)) results.put(location, "unknown");
|
||||
if (selected instanceof IField field) {
|
||||
plan.add(field, "field", declarations);
|
||||
if (accessors) {
|
||||
plan.add(GetterSetterUtil.getGetter(field), "getter", false);
|
||||
plan.add(GetterSetterUtil.getSetter(field), "setter", false);
|
||||
addConfiguredAccessors(field, plan);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (var target : plan.targets.values()) {
|
||||
if (monitor.isCanceled()) throw new org.eclipse.core.runtime.OperationCanceledException();
|
||||
add(handler, target.element, target.kind(), target.declarations, results, monitor);
|
||||
}
|
||||
if (monitor.isCanceled()) throw new org.eclipse.core.runtime.OperationCanceledException();
|
||||
List<Map<String, Object>> response = new ArrayList<>();
|
||||
UsageAccess access = new UsageAccess(monitor, results);
|
||||
results.forEach((location, kind) -> response.add(Map.of("location", location, "kind", kind, "access", access.classify(location, kind))));
|
||||
return response;
|
||||
}
|
||||
|
||||
/** Resolve explicit Lombok @Accessors naming, then search exact JDT method identities.
|
||||
* Configuration-file-only naming is deliberately not guessed.
|
||||
*/
|
||||
private static void addConfiguredAccessors(IField field, SearchPlan plan) throws Exception {
|
||||
Boolean fluent = null;
|
||||
boolean configured = false;
|
||||
String[] prefixes = null;
|
||||
// Field annotations override enclosing type annotations, as Lombok does.
|
||||
List<IAnnotatable> owners = new ArrayList<>();
|
||||
owners.add(field);
|
||||
for (IType type = field.getDeclaringType(); type != null; type = type.getDeclaringType()) owners.add(type);
|
||||
for (IAnnotatable owner : owners) {
|
||||
for (IAnnotation annotation : owner.getAnnotations()) {
|
||||
String name = annotation.getElementName();
|
||||
if (!name.equals("Accessors") && !name.equals("lombok.experimental.Accessors")) continue;
|
||||
String[][] resolved = field.getDeclaringType().resolveType(name);
|
||||
if (!name.equals("lombok.experimental.Accessors") && (resolved == null || resolved.length != 1 ||
|
||||
!resolved[0][0].equals("lombok.experimental") || !resolved[0][1].equals("Accessors"))) continue;
|
||||
configured = true;
|
||||
for (IMemberValuePair pair : annotation.getMemberValuePairs()) {
|
||||
if (pair.getMemberName().equals("fluent") && fluent == null && pair.getValue() instanceof Boolean value) fluent = value;
|
||||
if (pair.getMemberName().equals("prefix") && prefixes == null) {
|
||||
Object value = pair.getValue();
|
||||
if (value instanceof String prefix) prefixes = new String[] { prefix };
|
||||
else if (value instanceof Object[] values) {
|
||||
prefixes = new String[values.length];
|
||||
for (int i = 0; i < values.length; i++) prefixes[i] = (String) values[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!configured) return;
|
||||
String name = field.getElementName();
|
||||
if (prefixes != null && prefixes.length > 0) {
|
||||
String stripped = null;
|
||||
for (String prefix : prefixes) {
|
||||
if (prefix.isEmpty()) { stripped = name; break; }
|
||||
if (!name.startsWith(prefix) || name.length() == prefix.length()) continue;
|
||||
char next = name.charAt(prefix.length());
|
||||
if (Character.isLetter(prefix.charAt(prefix.length() - 1)) && Character.isLowerCase(next)) continue;
|
||||
stripped = Character.toLowerCase(next) + name.substring(prefix.length() + 1);
|
||||
break;
|
||||
}
|
||||
if (stripped == null) return;
|
||||
name = stripped;
|
||||
}
|
||||
String signature = field.getTypeSignature();
|
||||
String getterName = name;
|
||||
String setterName = name;
|
||||
if (!Boolean.TRUE.equals(fluent)) {
|
||||
boolean bool = signature.equals(Signature.SIG_BOOLEAN);
|
||||
String base = name;
|
||||
if (bool && base.startsWith("is") && base.length() > 2 && Character.isUpperCase(base.charAt(2))) base = base.substring(2);
|
||||
String capital = Character.toUpperCase(base.charAt(0)) + base.substring(1);
|
||||
getterName = (bool ? "is" : "get") + capital;
|
||||
setterName = "set" + capital;
|
||||
}
|
||||
for (IMethod method : field.getDeclaringType().getMethods()) {
|
||||
if (Flags.isStatic(method.getFlags()) != Flags.isStatic(field.getFlags())) continue;
|
||||
if (method.getElementName().equals(getterName) && method.getNumberOfParameters() == 0 && method.getReturnType().equals(signature)) {
|
||||
plan.add(method, "getter", false);
|
||||
} else if (method.getElementName().equals(setterName) && method.getNumberOfParameters() == 1 && method.getParameterTypes()[0].equals(signature) && setterReturnType(method)) {
|
||||
plan.add(method, "setter", false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final boolean NATIVE_PLAN_SUPPORTED = nativePlanSupported();
|
||||
|
||||
private static boolean nativePlanSupported() {
|
||||
// JDT LS 1.63.0.202610191322 ReferencesHandler.class. Fingerprinting guards
|
||||
// against silently dropping new native search targets in future versions.
|
||||
try (var stream = ReferencesHandler.class.getResourceAsStream("ReferencesHandler.class")) {
|
||||
if (stream == null) return false;
|
||||
String digest = java.util.HexFormat.of().formatHex(
|
||||
java.security.MessageDigest.getInstance("SHA-256").digest(stream.readAllBytes()));
|
||||
return digest.equals("31ff5a10c7d69d58c6a9b619ef17e3ec41b11b545751ba428618a76cc1341064");
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void addNativeBuilderTargets(ReferencesHandler handler, IField field, SearchPlan plan) throws Exception {
|
||||
// Reuse the verified server's own builder naming (including custom names).
|
||||
var resolver = ReferencesHandler.class.getDeclaredMethod("getBuilderName", IField.class);
|
||||
resolver.setAccessible(true);
|
||||
String name = (String) resolver.invoke(handler, field);
|
||||
IType builder = field.getJavaProject().findType(name);
|
||||
if (builder == null) return;
|
||||
for (IMethod method : builder.getMethods()) {
|
||||
String[] parameters = method.getParameterTypes();
|
||||
if (parameters.length == 1 && field.getElementName().equals(method.getElementName()) &&
|
||||
field.getTypeSignature().equals(parameters[0])) plan.add(method, "unknown", false);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SearchTarget {
|
||||
final IJavaElement element;
|
||||
final java.util.Set<String> kinds = new java.util.HashSet<>();
|
||||
boolean declarations;
|
||||
SearchTarget(IJavaElement element) { this.element = element; }
|
||||
String kind() { return kinds.size() == 1 ? kinds.iterator().next() : "unknown"; }
|
||||
}
|
||||
|
||||
private static final class SearchPlan {
|
||||
final Map<IJavaElement, SearchTarget> targets = new LinkedHashMap<>();
|
||||
void add(IJavaElement element, String kind, boolean declarations) {
|
||||
if (element == null) return;
|
||||
SearchTarget target = targets.computeIfAbsent(element, SearchTarget::new);
|
||||
if (!kind.equals("unknown")) target.kinds.add(kind);
|
||||
target.declarations |= declarations;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean setterReturnType(IMethod method) throws JavaModelException {
|
||||
String result = method.getReturnType();
|
||||
if (result.equals(Signature.SIG_VOID)) return true;
|
||||
String name = Signature.toString(Signature.getTypeErasure(result));
|
||||
IType owner = method.getDeclaringType();
|
||||
if (name.equals(owner.getFullyQualifiedName())) return true;
|
||||
String[][] resolved = owner.resolveType(name);
|
||||
return resolved != null && resolved.length == 1 &&
|
||||
(resolved[0][0].isEmpty() ? resolved[0][1] : resolved[0][0] + "." + resolved[0][1]).equals(owner.getFullyQualifiedName());
|
||||
}
|
||||
|
||||
private static void add(ReferencesHandler handler, IJavaElement element, String kind, boolean declarations,
|
||||
Map<Location, String> results, IProgressMonitor monitor) throws Exception {
|
||||
if (element == null) return;
|
||||
List<Location> locations = new ArrayList<>();
|
||||
handler.search(element, locations, monitor, declarations);
|
||||
for (var location : locations) {
|
||||
if (kind.equals("unknown")) { results.putIfAbsent(location, kind); continue; }
|
||||
String previous = results.get(location);
|
||||
results.put(location, previous == null || previous.equals("unknown") || previous.equals(kind) ? kind : "unknown");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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