diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index d184ef89c..9a377fecd 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -27,6 +27,12 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor `fileTypes/hashiCorp`, Apache-2.0); `makefile` (`expui/makefile` → `fileTypes/makefile`, MIT, Copyright (c) 2016 Victor Kropp — full text below). +The read/write usage filter assets (`actions/showReadAccess{,_dark}.svg` and +`actions/showWriteAccess{,_dark}.svg`) are copied unmodified from +`platform/icons/src/expui/actions/` at intellij-community commit +[`e992ff4750b4c36afe2caadfa8b191a8f36fb7a2`](https://github.com/JetBrains/intellij-community/tree/e992ff4750b4c36afe2caadfa8b191a8f36fb7a2/platform/icons/src/expui/actions). +Their original copyright headers and Apache-2.0 licensing are retained. + - [intellij-rust](https://github.com/intellij-rust/intellij-rust) (MIT): `src/main/resources/icons/expui/rustFile{,_dark}.svg` → `src/ui/expui/fileTypes/rust{,_dark}.svg`; diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 63636f9e4..24cc4aa34 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -147,8 +147,10 @@ impl Host { &effective.project_structure, ), ); - let bundles: Vec = - ide_tools::managed_bundle(&data_dir, "java-debug").into_iter().collect(); + let bundles: Vec = ide_tools::managed_bundle(&data_dir, "java-debug") + .into_iter() + .chain(ide_sdk::java::usages_bundle(&data_dir)) + .collect(); let min_java = ide_sdk::java::jdtls_min_java(cfg); let jdks = ide_sdk::java::jdtls_jdks(min_java, &data_dir); let lombok = ide_sdk::lombok::lombok_agent( diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 94d009846..5f349358f 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -129,6 +129,7 @@ impl Target { } struct DocSnap { + version: i32, path: PathBuf, uri: lsp::Uri, language: LanguageId, @@ -175,6 +176,7 @@ pub struct LspManager { excludes_hook: RwLock>>, shutting_down: AtomicBool, execution_allowed: AtomicBool, + java_usages_warned: AtomicBool, /// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure. shown_messages: Mutex>, /// Virtual (non-`file:`) URIs a server returned, by the server that owns them. @@ -307,6 +309,7 @@ impl LspManager { excludes_hook: RwLock::new(None), shutting_down: AtomicBool::new(false), execution_allowed: AtomicBool::new(true), + java_usages_warned: AtomicBool::new(false), shown_messages: Mutex::new(HashSet::new()), virtual_origins: Mutex::new(HashMap::new()), virtual_docs: Mutex::new(HashMap::new()), @@ -914,6 +917,7 @@ impl LspManager { let docs = self.docs.lock(); let d = docs.get(&doc).ok_or_else(|| IdeError::not_found(format!("{doc} is not open")))?; Ok(DocSnap { + version: d.version, path: d.path.clone(), uri: d.uri.clone(), language: d.language.clone(), @@ -1182,6 +1186,106 @@ impl LspManager { cancel: CancellationToken, ) -> IdeResult { let (snap, targets) = self.doc_targets(doc, "find references", |c| c.has("referencesProvider")).await?; + // The extension preserves method identity before JDT collapses references to locations. + // Old/custom servers retain the standard protocol, including cancellation and scope widening. + if targets.len() == 1 + && targets[0].server.name == "jdtls" + && targets[0].caps().commands().iter().any(|c| c == "openidea.java.usages") + { + let classified = async { + let t = &targets[0]; + let extension_error = Mutex::new(None); + let query = || async { + if cancel.is_cancelled() || !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version) { + return Err(IdeError::cancelled()); + } + let params = snap.pos_params(t, pos); + #[derive(serde::Deserialize)] + struct JavaReference { + #[serde(default)] + access: Option, + location: lsp::Location, + kind: ReferenceKind, + } + let result: IdeResult> = self + .call( + t, + "workspace/executeCommand", + json!({ + "command": "openidea.java.usages", + "arguments": [params["textDocument"]["uri"], params["position"]["line"], + params["position"]["character"], include_declaration] + }), + Some(&cancel), + LONG_TIMEOUT, + ) + .await; + if let Err(e) = &result { + *extension_error.lock() = Some(e.clone()); + } + let rows = result?; + if cancel.is_cancelled() || !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version) { + return Err(IdeError::cancelled()); + } + let conv = self.conv(t.enc()); + Ok(rows + .into_iter() + .filter_map(|r| { + conv.location(&r.location).map(|location| ClassifiedReference { + location, + kind: r.kind, + access: r.access, + }) + }) + .collect::>()) + }; + let files = |v: &Vec| { + v.iter().filter(|r| r.location.uri.is_none()).map(|r| r.location.path.to_path_buf()).collect() + }; + let widened = if project_wide { + self.widened(&snap, t, pos, &cancel, query, files).await? + } else { + let first = query().await?; + let empty = first.is_empty(); + scope::Widened::only(first, empty, &t.server) + }; + // A changed source also invalidates an earlier answer retained by scope widening. + if cancel.is_cancelled() || !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version) { + return Err(IdeError::cancelled()); + } + // Scope widening can retain the first answer after a failed second query. + if let Some(e) = extension_error.into_inner() { + return Err(e); + } + let mut seen = std::collections::HashSet::new(); + // Prefer the second query's metadata for repeated locations. + let classified: Vec<_> = widened + .second + .into_iter() + .flatten() + .chain(widened.first) + .filter(|r| seen.insert(r.location.clone())) + .collect(); + let locations: Vec = classified.iter().map(|r| r.location.clone()).collect(); + self.note_virtual(&t.server, locations.iter().map(|l| l.uri.as_deref())); + Ok(References { locations, classified, incomplete: widened.incomplete }) + } + .await; + match classified { + Ok(references) => return Ok(references), + Err(e) => { + if e.kind == ErrorKind::Cancelled + || cancel.is_cancelled() + || !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version) + { + return Err(IdeError::cancelled()); + } + if !self.java_usages_warned.swap(true, Ordering::Relaxed) { + tracing::warn!("Java usages extension failed; using standard references: {e}"); + } + } + } + } let requests = targets.iter().map(|t| { let (snap, cancel) = (&snap, &cancel); async move { diff --git a/crates/ide-lsp/src/types.rs b/crates/ide-lsp/src/types.rs index 57ba429d5..599460a00 100644 --- a/crates/ide-lsp/src/types.rs +++ b/crates/ide-lsp/src/types.rs @@ -338,6 +338,10 @@ pub enum ReferencesIncomplete { #[derive(Clone, Debug, Default, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct References { + /// Semantic classifications supplied by a language extension. Missing entries are unknown. + #[serde(default)] + #[specta(optional)] + pub classified: Vec, pub locations: Vec, pub incomplete: Option, } @@ -355,3 +359,31 @@ pub struct CallHierarchy { pub items: Vec, pub incomplete: Option, } + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum ReferenceKind { + Getter, + Setter, + Field, + Unknown, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum ReferenceAccess { + Read, + Write, + ReadWrite, + Unknown, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +pub struct ClassifiedReference { + /// Optional for older Java extensions; missing means unclassified. + #[serde(default)] + #[specta(optional)] + pub access: Option, + pub location: Location, + pub kind: ReferenceKind, +} diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 080d3ed84..8d3074e02 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -27,6 +27,7 @@ struct MockState { /// uri → text as reconstructed by the mock from didOpen/didChange. docs: Mutex>, responses: Mutex>, + errors: Mutex>, /// Computes a response from the request and the open documents (before `responses`). responder: Mutex>, peer: Mutex>, @@ -122,6 +123,9 @@ impl Handler for MockHandler { "initialize" => Ok(json!({ "capabilities": s.caps.lock().clone(), "serverInfo": { "name": "mock" } })), "shutdown" => Ok(Value::Null), _ => { + if let Some(error) = s.errors.lock().get(method).cloned() { + return Err(error); + } let responder = s.responder.lock().clone(); if let Some(v) = responder.and_then(|f| f(s, method, ¶ms)) { return Ok(v); @@ -2030,6 +2034,155 @@ async fn references_report_a_capped_candidate_search() { assert_eq!(refs.incomplete, Some(ReferencesIncomplete::TooManyFiles)); } +/// The Java command must participate in the same widening contract as standard LSP. +#[tokio::test(flavor = "multi_thread")] +async fn classified_java_references_widen_without_losing_metadata() { + let mut caps = incremental_caps(); + caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages"]}); + let h = harness_for("java", "java", &[("jdtls", caps)], &[]); + let s = h.launcher.state("jdtls"); + *s.responder.lock() = Some(Arc::new(|s, method, _| { + (method == "workspace/executeCommand").then(|| { + let docs = s.docs.lock(); + let access = if docs.len() == 1 { "read" } else { "readWrite" }; + Value::Array( + docs.iter() + .flat_map(|(uri, text)| { + occurrences(uri, text) + .into_iter() + .map(|location| json!({"location": location, "kind": "field", "access": access})) + }) + .collect(), + ) + }) + })); + let b = h.root.join("b.java"); + std::fs::write(&b, "target++;").unwrap(); + let (doc, _) = h.open("a.java", "int target;"); + h.wait_status("jdtls", ServerStatus::Ready).await; + eventually(|| s.docs.lock().len() == 1).await; + let seen = grep_candidates(&h); + let scoped = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap(); + assert_eq!(scoped.locations.len(), 1); + assert!(seen.lock().is_empty()); + let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap(); + assert_eq!(refs.locations.len(), 2); + assert_eq!(refs.classified.len(), 2); + assert!(refs.classified.iter().all(|r| matches!(r.access, Some(ReferenceAccess::ReadWrite)))); + assert!(refs.classified.iter().all(|r| matches!(r.kind, ReferenceKind::Field))); + assert_eq!(refs.incomplete, None); + assert_eq!(s.requested("workspace/executeCommand").len(), 3); + assert!(s.requested("textDocument/references").is_empty()); + eventually(|| uris(&s.notified("textDocument/didClose")) == vec![uri_of(&b)]).await; + for limit_hit in [true, false] { + let skipped = h.root.join("large.java"); + std::fs::write(&skipped, " ".repeat((1 << 20) + 1)).unwrap(); + h.mgr.set_candidates_hook(Box::new(move |_, _| ide_lsp::CandidateFiles { + files: vec![skipped.clone()], + limit_hit, + })); + let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap(); + assert_eq!( + refs.incomplete, + Some(if limit_hit { ReferencesIncomplete::TooManyFiles } else { ReferencesIncomplete::FilesSkipped }) + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn failed_java_command_falls_back_to_unclassified_native_references() { + for project_wide in [false, true] { + let mut caps = incremental_caps(); + caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages"]}); + let h = harness_for("java", "java", &[("jdtls", caps)], &[]); + let s = h.launcher.state("jdtls"); + s.errors.lock().insert("workspace/executeCommand".into(), (-32603, "NoSuchMethodError".into())); + *s.responder.lock() = Some(open_files_only(Vec::new())); + let (doc, path) = h.open("a.java", "int target;"); + h.wait_status("jdtls", ServerStatus::Ready).await; + eventually(|| s.docs.lock().len() == 1).await; + let refs = h.mgr.references(doc, Pos::new(0, 5), true, project_wide, Default::default()).await.unwrap(); + assert_eq!(refs.locations.len(), 1); + assert_eq!(refs.locations[0].path, path); + assert!(refs.classified.is_empty()); + assert_eq!(s.requested("workspace/executeCommand").len(), 1); + assert_eq!(s.requested("textDocument/references").len(), 1); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn failed_widened_java_command_retries_with_native_references() { + let mut caps = incremental_caps(); + caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages"]}); + let h = harness_for("java", "java", &[("jdtls", caps)], &[]); + let s = h.launcher.state("jdtls"); + let native = open_files_only(Vec::new()); + *s.responder.lock() = Some(Arc::new(move |s, method, params| { + if method == "workspace/executeCommand" { + // The initial query succeeds, but the next extension query fails. + s.errors.lock().insert(method.into(), (-32603, "reflection failed".into())); + return Some(Value::Array( + s.docs + .lock() + .iter() + .flat_map(|(uri, text)| { + occurrences(uri, text) + .into_iter() + .map(|location| json!({"location": location, "kind": "field", "access": "read"})) + }) + .collect(), + )); + } + native(s, method, params) + })); + std::fs::write(h.root.join("b.java"), "target++;").unwrap(); + grep_candidates(&h); + let (doc, _) = h.open("a.java", "int target;"); + h.wait_status("jdtls", ServerStatus::Ready).await; + eventually(|| s.docs.lock().len() == 1).await; + let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap(); + assert_eq!(refs.locations.len(), 2); + assert!(refs.classified.is_empty()); + assert_eq!(s.requested("workspace/executeCommand").len(), 2); + assert_eq!(s.requested("textDocument/references").len(), 2); +} + +#[tokio::test(flavor = "multi_thread")] +async fn superseded_java_command_does_not_fall_back() { + let mut caps = incremental_caps(); + caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages"]}); + let h = harness_for("java", "java", &[("jdtls", caps)], &[]); + let s = h.launcher.state("jdtls"); + let (doc, _) = h.open("a.java", "int target;"); + h.wait_status("jdtls", ServerStatus::Ready).await; + let mgr = Arc::downgrade(&h.mgr); + *s.responder.lock() = Some(Arc::new(move |_, method, _| { + if method != "workspace/executeCommand" { + return None; + } + mgr.upgrade().unwrap().did_change(doc, 2, &[change(rng(0, 0, 0, 0), " ")], &|| unreachable!()); + // Invalid extension response after the source version changed. + Some(json!({"failed": true})) + })); + let err = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled); + assert!(s.requested("textDocument/references").is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn cancelled_java_command_does_not_fall_back() { + let mut caps = incremental_caps(); + caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages"]}); + let h = harness_for("java", "java", &[("jdtls", caps)], &[]); + let s = h.launcher.state("jdtls"); + s.errors.lock().insert("workspace/executeCommand".into(), (-32800, "cancelled".into())); + let (doc, _) = h.open("a.java", "int target;"); + h.wait_status("jdtls", ServerStatus::Ready).await; + let err = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled); + assert!(s.requested("textDocument/references").is_empty()); +} + /// What each search widened with candidate files reports for the symbol at the start of `doc`. async fn incomplete_reports(h: &Harness, doc: DocId) -> Vec<(&'static str, Option)> { let at = Pos::new(0, 5); diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index 3bb7d2329..755778568 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -933,3 +933,100 @@ mod tests { assert_eq!(project_java_version(dir), Some(8)); } } + +/// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure. +pub fn usages_bundle(data_dir: &Path) -> Option { + let extension = data_dir.join("java-usages-1.1.1.jar"); + let bytes = include_bytes!("../../../plugins/java-usages/openidea-java-usages.jar"); + if std::fs::read(&extension).is_ok_and(|old| old == bytes) { + clean_usages_bundles(data_dir, &extension); + return Some(extension); + } + // Write then rename: another window must never read a partially written jar. + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?.as_nanos(); + let temporary = data_dir.join(format!("java-usages-{}-{nonce}.tmp", std::process::id())); + let result = std::fs::create_dir_all(data_dir) + .and_then(|_| std::fs::write(&temporary, bytes)) + .and_then(|_| std::fs::rename(&temporary, &extension)); + match result { + Ok(()) => { + clean_usages_bundles(data_dir, &extension); + Some(extension) + } + Err(e) => { + let _ = std::fs::remove_file(&temporary); + if std::fs::read(&extension).is_ok_and(|old| old == bytes) { + clean_usages_bundles(data_dir, &extension); + return Some(extension); + } + tracing::warn!("Java usages extension unavailable; using standard references: {e}"); + None + } + } +} + +fn clean_usages_bundles(data_dir: &Path, current: &Path) { + let Ok(entries) = std::fs::read_dir(data_dir) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue }; + let Some(suffix) = name.strip_prefix("java-usages-") else { continue }; + let numeric = |s: &str| !s.is_empty() && s.bytes().all(|c| c.is_ascii_digit()); + let old_jar = suffix.strip_suffix(".jar").is_some_and(|v| { + let parts: Vec<_> = v.split('.').collect(); + parts.len() == 3 && parts.into_iter().all(numeric) + }) && path != current; + // Preserve recent temporary files: another window may still be installing. + let stale_temp = suffix + .strip_suffix(".tmp") + .and_then(|v| v.split_once('-')) + .is_some_and(|(pid, nonce)| numeric(pid) && numeric(nonce)) + && entry + .metadata() + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.elapsed().ok()) + .is_some_and(|age| age.as_secs() > 24 * 60 * 60); + if old_jar || stale_temp { + // A running server can hold an old bundle open on Windows. + let _ = std::fs::remove_file(path); + } + } +} + +#[cfg(test)] +mod usages_bundle_tests { + #[test] + fn cleans_old_bundles_and_abandoned_temporary_files_on_install_and_reuse() { + let dir = tempfile::tempdir().unwrap(); + for reuse in [false, true] { + let old = dir.path().join("java-usages-1.0.0.jar"); + let stale = dir.path().join("java-usages-123-456.tmp"); + std::fs::write(&old, b"old").unwrap(); + std::fs::write(&stale, b"partial").unwrap(); + let times = std::fs::FileTimes::new() + .set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(48 * 60 * 60)); + std::fs::File::options().write(true).open(&stale).unwrap().set_times(times).unwrap(); + for name in ["java-usages-123-789.tmp", "java-usages-custom.jar", "other.jar", "java-usages-not-ours.tmp"] { + std::fs::write(dir.path().join(name), b"keep").unwrap(); + } + let jar = super::usages_bundle(dir.path()).unwrap(); + assert!(jar.exists()); + assert!(!old.exists(), "reuse={reuse}"); + assert!(!stale.exists(), "reuse={reuse}"); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 5); + } + } + + #[test] + fn installs_the_exact_bundle_and_reuses_it() { + let dir = tempfile::tempdir().unwrap(); + let jar = super::usages_bundle(dir.path()).unwrap(); + assert_eq!( + std::fs::read(&jar).unwrap(), + include_bytes!("../../../plugins/java-usages/openidea-java-usages.jar") + ); + assert_eq!(super::usages_bundle(dir.path()), Some(jar)); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1); + } +} diff --git a/plugins/java-usages/MANIFEST.MF b/plugins/java-usages/MANIFEST.MF new file mode 100644 index 000000000..1775ddec8 --- /dev/null +++ b/plugins/java-usages/MANIFEST.MF @@ -0,0 +1,10 @@ +Manifest-Version: 1.0 +Bundle-ManifestVersion: 2 +Bundle-Name: OpenIDEA Java usages +Bundle-SymbolicName: ai.openidea.usages;singleton:=true +Bundle-Version: 1.1.1 +Bundle-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 + diff --git a/plugins/java-usages/README.md b/plugins/java-usages/README.md new file mode 100644 index 000000000..5be955109 --- /dev/null +++ b/plugins/java-usages/README.md @@ -0,0 +1,142 @@ +# Semantic Java usages + +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.1.1.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 1.1.1 output is +`1e569939cba22c7db6e2c9b53de2a4c10ccaabe762a51427119a04af3d974ae4`. + +```sh +export PATH=/path/to/jdk25/bin:$PATH +python3 plugins/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 plugins/java-usages/tests/verify.py /path/to/jdtls /path/to/lombok.jar +python3 plugins/java-usages/tests/read-write.py /path/to/jdtls /path/to/lombok.jar > /tmp/read-write.json +OPENIDEA_USAGES_INDEX_STRESS=1 python3 plugins/java-usages/tests/read-write.py /path/to/jdtls /path/to/lombok.jar > /tmp/read-write-indexed.json +``` + +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. + +## 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`. +- 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. diff --git a/plugins/java-usages/build-dependencies.sha256 b/plugins/java-usages/build-dependencies.sha256 new file mode 100644 index 000000000..bb0540f5b --- /dev/null +++ b/plugins/java-usages/build-dependencies.sha256 @@ -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 diff --git a/plugins/java-usages/build.py b/plugins/java-usages/build.py new file mode 100644 index 000000000..b82e7beca --- /dev/null +++ b/plugins/java-usages/build.py @@ -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()) diff --git a/plugins/java-usages/openidea-java-usages.jar b/plugins/java-usages/openidea-java-usages.jar new file mode 100644 index 000000000..0eee59354 Binary files /dev/null and b/plugins/java-usages/openidea-java-usages.jar differ diff --git a/plugins/java-usages/plugin.xml b/plugins/java-usages/plugin.xml new file mode 100644 index 000000000..f654f9def --- /dev/null +++ b/plugins/java-usages/plugin.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/plugins/java-usages/src/ai/openidea/usages/UsageAccess.java b/plugins/java-usages/src/ai/openidea/usages/UsageAccess.java new file mode 100644 index 000000000..b213a6d55 --- /dev/null +++ b/plugins/java-usages/src/ai/openidea/usages/UsageAccess.java @@ -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 exactNodes) {} + private final Map files = new HashMap<>(); + private final IProgressMonitor monitor; + private final Map> requests = new HashMap<>(); + UsageAccess(IProgressMonitor monitor, Map 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 index(CompilationUnit tree, String uri) { + List 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(); + 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 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"; + } + } +} diff --git a/plugins/java-usages/src/ai/openidea/usages/UsagesCommand.java b/plugins/java-usages/src/ai/openidea/usages/UsagesCommand.java new file mode 100644 index 000000000..18b1fad66 --- /dev/null +++ b/plugins/java-usages/src/ai/openidea/usages/UsagesCommand.java @@ -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 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 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> 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 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 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 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 results, IProgressMonitor monitor) throws Exception { + if (element == null) return; + List 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"); + } + } +} diff --git a/plugins/java-usages/tests/REPRODUCE.md b/plugins/java-usages/tests/REPRODUCE.md new file mode 100644 index 000000000..aad80dc63 --- /dev/null +++ b/plugins/java-usages/tests/REPRODUCE.md @@ -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 + + 4.0.0 + exampleusage-access1.0 + + + org.projectlomboklombok + 1.18.48provided + + + + org.apache.maven.pluginsmaven-compiler-plugin + 3.14.1 + 21 + org.projectlomboklombok1.18.48 + + + +``` + +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 plugins/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. diff --git a/plugins/java-usages/tests/ReadWrite.java b/plugins/java-usages/tests/ReadWrite.java new file mode 100644 index 000000000..2413e6d86 --- /dev/null +++ b/plugins/java-usages/tests/ReadWrite.java @@ -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 + } +} diff --git a/plugins/java-usages/tests/read-write.py b/plugins/java-usages/tests/read-write.py new file mode 100644 index 000000000..b9dc23ab6 --- /dev/null +++ b/plugins/java-usages/tests/read-write.py @@ -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('accessorsorg.eclipse.jdt.core.javabuilderorg.eclipse.jdt.core.javanature') +(project / '.classpath').write_text(f'') +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()accessorsorg.eclipse.jdt.core.javabuilderorg.eclipse.jdt.core.javanature') +(project / '.classpath').write_text(f'') +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 getter(Bean b) { return b::getName; } + java.util.function.Consumer 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) diff --git a/src/bindings.ts b/src/bindings.ts index 60fcea018..9bed62739 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1726,6 +1726,13 @@ export type CheckoutOutcome = { kind: "done" } | */ { kind: "conflicts"; files: string[] }; +export type ClassifiedReference = { + /** Optional for older Java extensions; missing means unclassified. */ + access?: ReferenceAccess | null, + location: Location, + kind: ReferenceKind, +}; + export type ClearCachesResult = { /** Syntax trees dropped (they are rebuilt on demand). */ syntaxTrees: number, @@ -4968,7 +4975,13 @@ export type RecentProject = { branch: string | null, }; +export type ReferenceAccess = "read" | "write" | "readWrite" | "unknown"; + +export type ReferenceKind = "getter" | "setter" | "field" | "unknown"; + export type References = { + /** Semantic classifications supplied by a language extension. Missing entries are unknown. */ + classified?: ClassifiedReference[], locations: Location[], incomplete: ReferencesIncomplete | null, }; diff --git a/src/features/code-insight/UsageAccessFilter.module.css b/src/features/code-insight/UsageAccessFilter.module.css new file mode 100644 index 000000000..8f58ad8eb --- /dev/null +++ b/src/features/code-insight/UsageAccessFilter.module.css @@ -0,0 +1,7 @@ +.toolbar { + display: flex; + align-items: center; + gap: 2px; + padding: 3px 6px; + flex-shrink: 0; +} diff --git a/src/features/code-insight/UsageAccessFilter.tsx b/src/features/code-insight/UsageAccessFilter.tsx new file mode 100644 index 000000000..4e40c270c --- /dev/null +++ b/src/features/code-insight/UsageAccessFilter.tsx @@ -0,0 +1,71 @@ +import { create } from "zustand"; +import { IconButton } from "@/ui"; +import type { Usage } from "./usages"; +import styles from "./UsageAccessFilter.module.css"; + +export interface UsageAccessOptions { + read: boolean; + write: boolean; +} +const STORAGE_KEY = "openidea.usages.accessFilter.v1"; + +export function loadAccessOptions(): UsageAccessOptions { + try { + const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "null") as UsageAccessOptions | null; + if (saved && typeof saved.read === "boolean" && typeof saved.write === "boolean" && (saved.read || saved.write)) + return saved; + } catch { + /* unavailable or invalid storage: show everything */ + } + return { read: true, write: true }; +} + +export const useUsageAccessOptions = create(() => loadAccessOptions()); + +export function toggleUsageAccess(which: keyof UsageAccessOptions) { + const next = { ...useUsageAccessOptions.getState() }; + next[which] = !next[which]; + if (!next.read && !next.write) next[which === "read" ? "write" : "read"] = true; + useUsageAccessOptions.setState(next); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + /* session-only */ + } +} + +export function filterUsageAccess(items: readonly Usage[], options: UsageAccessOptions): Usage[] { + return items.filter((u) => { + // Old categories and ordinary LSP locations are unclassified. Never infer + // access from an accessor name/category or silently hide unknown entries. + if (!u.access || u.access === "unknown" || u.access === "readWrite") return true; + return u.access === "read" ? options.read : options.write; + }); +} + +export const supportsUsageAccess = (items: readonly Usage[]) => items.some((u) => u.access && u.access !== "unknown"); + +export function UsageAccessFilter({ items }: { items: readonly Usage[] }) { + const options = useUsageAccessOptions(); + if (!supportsUsageAccess(items)) return null; + return ( +
+ toggleUsageAccess("read")} + /> + toggleUsageAccess("write")} + /> +
+ ); +} diff --git a/src/features/code-insight/UsagesPopup.tsx b/src/features/code-insight/UsagesPopup.tsx index 61e060a87..dd34938c4 100644 --- a/src/features/code-insight/UsagesPopup.tsx +++ b/src/features/code-insight/UsagesPopup.tsx @@ -13,12 +13,21 @@ import { basename } from "@/platform/project"; import { shortcutLabel } from "@/platform/keymap"; import { Icon, IconButton, List, Popup, fileIconName, listNavigate } from "@/ui"; import { openLocation, readFileLines } from "./ipc"; -import { showUsageResults, usageCount, useUsagesPopup, type Usage, type UsageGroup } from "./usages"; +import { + dismissUsagesPopup, + showUsageResults, + usageCount, + useUsagesPopup, + type Usage, + type UsageGroup, +} from "./usages"; import { UsagePreview } from "./UsagesView"; import styles from "./UsagesPopup.module.css"; import { tx } from "@/platform/i18n"; -const close = () => useUsagesPopup.setState({ open: false }); +import { UsageAccessFilter, filterUsageAccess, useUsageAccessOptions } from "./UsageAccessFilter"; + +const close = dismissUsagesPopup; const PREVIEW_KEY = "openidea.usages.popupPreview.v1"; @@ -52,10 +61,13 @@ export function filterUsages(items: readonly Usage[], filter: string): Usage[] { /** The popup's groups as locations for the Find Usages tool window. */ export function usageLocations(groups: readonly UsageGroup[]) { return groups.flatMap((g) => - g.usages.map((u) => ({ - path: u.path, - range: { start: { line: u.line, col: u.col }, end: { line: u.line, col: u.endCol } }, - })), + g.usages.map( + (u) => + u.sourceLocation ?? { + path: u.path, + range: { start: { line: u.line, col: u.col }, end: { line: u.line, col: u.endCol } }, + }, + ), ); } @@ -71,7 +83,11 @@ function UsagesPopupInner() { const preview = useUsagesPopupPreview((s) => s.preview); const all = useMemo(() => groups.flatMap((g) => g.usages), [groups]); const [filter, setFilter] = useState(""); - const items = useMemo(() => filterUsages(all, filter), [all, filter]); + const accessOptions = useUsageAccessOptions(); + const items = useMemo( + () => filterUsages(filterUsageAccess(all, accessOptions), filter), + [all, filter, accessOptions], + ); const [active, setActive] = useState(0); const current = Math.min(active, items.length - 1); const total = usageCount(groups); @@ -82,10 +98,22 @@ function UsagesPopupInner() { }; const openInFindWindow = () => { close(); - void showUsageResults({ symbol, locations: usageLocations(groups) }); + void showUsageResults({ + symbol, + note, + locations: usageLocations(groups), + classified: groups.flatMap((g) => + g.usages.map((u) => ({ + location: usageLocations([{ path: g.path, usages: [u] }])[0]!, + kind: u.kind ?? "unknown", + access: u.access, + })), + ), + }); }; const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== "Escape" && e.target instanceof Element && e.target.closest("button")) return; const next = listNavigate(e.key, { items, active: current, wrap: true, pageSize: 10 }); if (next !== null) { e.preventDefault(); @@ -120,7 +148,9 @@ function UsagesPopupInner() {
{symbol ? tx`Usages of ${symbol}` : tx("Usages")}{" "} - {filter ? tx`${items.length} of ${total} found` : tx`${total} found`} + + {filter || items.length !== total ? tx`${items.length} of ${total} found` : tx`${total} found`} + {filter && ( @@ -140,11 +170,14 @@ function UsagesPopupInner() { {keys ? ` ${keys}` : ""}
+ {note &&
{note}
}
`${u.path}:${u.line}:${u.col}`} + getKey={(u) => + `${u.path}:${u.line}:${u.col}:${u.sourceLocation?.range.end.line ?? u.line}:${u.sourceLocation?.range.end.col ?? u.endCol}` + } activeIndex={current} onActiveChange={setActive} focusable={false} @@ -152,7 +185,11 @@ function UsagesPopupInner() { rowHeight={ROW} aria-label="Usages" onActivate={pick} - empty={
{tx`Nothing matches '${filter}'`}
} + empty={ +
+ {filter ? tx`Nothing matches '${filter}'` : tx("No usages match the access filters")} +
+ } renderItem={(u) => (
diff --git a/src/features/code-insight/UsagesView.module.css b/src/features/code-insight/UsagesView.module.css index 2bd6b9425..2b53680e5 100644 --- a/src/features/code-insight/UsagesView.module.css +++ b/src/features/code-insight/UsagesView.module.css @@ -52,6 +52,9 @@ flex: 1; min-height: 0; } +.body > [role="toolbar"] { + flex: 0 0 auto; +} .row { display: flex; align-items: center; diff --git a/src/features/code-insight/UsagesView.tsx b/src/features/code-insight/UsagesView.tsx index c59e50496..ca7ea44c0 100644 --- a/src/features/code-insight/UsagesView.tsx +++ b/src/features/code-insight/UsagesView.tsx @@ -41,6 +41,8 @@ import { } from "./usages"; import styles from "./UsagesView.module.css"; +import { UsageAccessFilter, filterUsageAccess, supportsUsageAccess, useUsageAccessOptions } from "./UsageAccessFilter"; + type Node = | { kind: "summary"; key: string } | { kind: "file"; key: string; group: UsageGroup } @@ -101,7 +103,8 @@ export function UsagesTabs() { const SUMMARY_KEY = "\u0000summary"; -const usageKey = (u: Usage) => `${u.path}:${u.line}:${u.col}`; +const usageKey = (u: Usage) => + `${u.path}:${u.line}:${u.col}:${u.sourceLocation?.range.end.line ?? u.line}:${u.sourceLocation?.range.end.col ?? u.endCol}`; /** Excluded (⌫) usages per search. */ const useExcluded = create<{ byTab: Record> }>(() => ({ byTab: {} })); @@ -135,9 +138,15 @@ export function UsagesView() { id: null, keys: new Set(), }); + const accessOptions = useUsageAccessOptions(); + const allUsages = useMemo(() => search?.groups.flatMap((g) => g.usages) ?? [], [search?.groups]); const files = useMemo( - () => (search?.groups ?? []).map((g) => ({ kind: "file", key: g.path, group: g })), - [search?.groups], + () => + (search?.groups ?? []) + .map((g) => ({ ...g, usages: filterUsageAccess(g.usages, accessOptions) })) + .filter((g) => g.usages.length > 0) + .map((g) => ({ kind: "file", key: g.path, group: g })), + [search?.groups, accessOptions], ); const roots = useMemo(() => (files.length ? [{ kind: "summary", key: SUMMARY_KEY }] : []), [files.length]); const searchId = search?.id ?? null; @@ -173,12 +182,22 @@ export function UsagesView() { ); const total = usageCount(search.groups); + const visibleLead = + lead?.kind === "usage" + ? files.some((f) => f.kind === "file" && f.group.usages.some((u) => usageKey(u) === lead.key)) + ? lead + : null + : lead?.kind === "file" + ? (files.find((f) => f.key === lead.key) ?? null) + : roots.length + ? lead + : null; const setAll = (collapse: boolean) => setCollapsed({ id: search.id, keys: collapse ? new Set(files.map((f) => f.key)) : new Set() }); const exclude = () => { - if (!lead) return; - const next = toggleExcludedUsages(excluded, nodeUsages(lead, files)); + if (!visibleLead) return; + const next = toggleExcludedUsages(excluded, nodeUsages(visibleLead, files)); useExcluded.setState((s) => ({ byTab: { ...s.byTab, [search.id]: next } })); // Excluding moves on to the next usage, as in IDEA. if (next.size > excluded.size) tree.current?.selectOccurrence(1, { skipSubtree: true }); @@ -199,7 +218,10 @@ export function UsagesView() { Found usages - {total} {tx(total === 1 ? "result" : "results")} + {!supportsUsageAccess(allUsages) || (accessOptions.read && accessOptions.write) + ? total + : `${files.reduce((n, f) => n + (f.kind === "file" ? f.group.usages.length : 0), 0)} / ${total}`}{" "} + {tx(total === 1 ? "result" : "results")} {search.note && {search.note}} @@ -245,6 +267,7 @@ export function UsagesView() { setAll(true)} />
+ {(search.loading || search.error) && (
{search.loading ? ( @@ -260,9 +283,9 @@ export function UsagesView() { )}
)} - }> + }> - key={search.id} + key={`${search.id}:${accessOptions.read}:${accessOptions.write}`} aria-label="Usages" handleRef={tree} roots={roots} @@ -308,9 +331,7 @@ export function UsagesView() { }} empty={ !search.loading && !search.error ? ( - - No usages found - + {total ? tx("No usages match the access filters") : No usages found} ) : undefined } /> diff --git a/src/features/code-insight/usageAccessFilter.test.tsx b/src/features/code-insight/usageAccessFilter.test.tsx new file mode 100644 index 000000000..fc6209ac7 --- /dev/null +++ b/src/features/code-insight/usageAccessFilter.test.tsx @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { filterUsageAccess, loadAccessOptions, toggleUsageAccess, useUsageAccessOptions } from "./UsageAccessFilter"; +import type { Usage } from "./usages"; +const rows: Usage[] = ["read", "write", "readWrite", "unknown", undefined].map((access, line) => ({ + path: "/Bean.java", + line, + col: 0, + endCol: 1, + text: "x", + access: access as Usage["access"], +})); +afterEach(() => { + localStorage.clear(); + useUsageAccessOptions.setState({ read: true, write: true }); +}); +describe("read/write visibility", () => { + it("matches all three reachable states and retains unknown/legacy rows", () => { + expect(filterUsageAccess(rows, { read: true, write: true })).toHaveLength(5); + expect(filterUsageAccess(rows, { read: true, write: false }).map((u) => u.line)).toEqual([0, 2, 3, 4]); + expect(filterUsageAccess(rows, { read: false, write: true }).map((u) => u.line)).toEqual([1, 2, 3, 4]); + expect(filterUsageAccess([{ ...rows[4]!, kind: "getter" }], { read: false, write: true })).toHaveLength(1); + }); + it("turns the other button on when the last visible access is turned off", () => { + toggleUsageAccess("write"); + expect(useUsageAccessOptions.getState()).toEqual({ read: true, write: false }); + toggleUsageAccess("read"); + expect(useUsageAccessOptions.getState()).toEqual({ read: false, write: true }); + toggleUsageAccess("write"); + expect(useUsageAccessOptions.getState()).toEqual({ read: true, write: false }); + expect(loadAccessOptions()).toEqual({ read: true, write: false }); + }); + it("starts with both on and repairs invalid persisted state", () => { + expect(loadAccessOptions()).toEqual({ read: true, write: true }); + localStorage.setItem("openidea.usages.accessFilter.v1", '{"read":false,"write":false}'); + expect(loadAccessOptions()).toEqual({ read: true, write: true }); + }); +}); diff --git a/src/features/code-insight/usagePreview.test.tsx b/src/features/code-insight/usagePreview.test.tsx index 51b8c2056..7c276925d 100644 --- a/src/features/code-insight/usagePreview.test.tsx +++ b/src/features/code-insight/usagePreview.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { render, waitFor } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; vi.mock(import("@/platform/ipc"), async (importOriginal) => ({ ...(await importOriginal()), @@ -8,7 +8,7 @@ vi.mock(import("@/platform/ipc"), async (importOriginal) => ({ })); vi.mock(import("@/editor/api"), async (importOriginal) => ({ ...(await importOriginal()), editorsFor: () => [] })); -const { UsagePreview } = await import("./UsagesView"); +const { UsagePreview, UsagesView } = await import("./UsagesView"); describe("UsagePreview", () => { it("colours the usage line with its file as context", async () => { @@ -27,3 +27,31 @@ describe("UsagePreview", () => { await waitFor(() => expect(container.querySelector("mark .tok-comment")?.textContent).toBe("foo")); }); }); + +it("shows a plain count for unclassified searches after a Java access mode was selected", async () => { + const { useUsages } = await import("./usages"); + const { useUsageAccessOptions } = await import("./UsageAccessFilter"); + const rows = [0, 1].map((line) => ({ path: "/p/a.ts", line, col: 0, endCol: 3, text: "foo()" })); + useUsageAccessOptions.setState({ read: true, write: false }); + useUsages.setState({ + tabs: [ + { + id: 123, + symbol: "foo", + groups: [{ path: "/p/a.ts", usages: rows }], + loading: false, + error: null, + rerun: null, + note: null, + pinned: false, + }, + ], + active: 123, + }); + const view = render(); + expect(screen.getByText("2 results")).toBeInTheDocument(); + expect(screen.queryByRole("toolbar", { name: "Usage access filters" })).toBeNull(); + view.unmount(); + useUsageAccessOptions.setState({ read: true, write: true }); + useUsages.setState({ tabs: [], active: null }); +}); diff --git a/src/features/code-insight/usages.ts b/src/features/code-insight/usages.ts index 061b28a42..305996477 100644 --- a/src/features/code-insight/usages.ts +++ b/src/features/code-insight/usages.ts @@ -1,6 +1,6 @@ /** Find Usages (⌥F7 → "usages" tool window) and Show Usages (⌥⌘F7 → popup). */ import { create } from "zustand"; -import type { Location, RequestId } from "@/bindings"; +import type { ClassifiedReference, ReferenceAccess, ReferenceKind, Location, RequestId } from "@/bindings"; import { clearLineTokens } from "@/editor/lineTokens"; import { incompleteNote } from "@/editor/lsp/lsp"; import { commands, isCancelled, nextRequestId } from "@/platform/ipc"; @@ -18,6 +18,10 @@ import { import { syntaxUsages } from "./syntaxUsages"; export interface Usage { + kind?: ReferenceKind; + access?: ReferenceAccess | null; + /** Exact semantic range, retained when the line preview is clamped. */ + sourceLocation?: Location; path: string; line: number; col: number; @@ -36,18 +40,33 @@ export interface UsageGroup { export function groupUsages( locs: readonly Location[], files: ReadonlyMap, + classified: readonly ClassifiedReference[] = [], ): UsageGroup[] { + const kinds = new Map(classified.map((r) => [usageLocationKey(r.location), r])); const byPath = new Map(); const seen = new Set(); for (const l of locs) { - const key = `${l.path}:${l.range.start.line}:${l.range.start.col}`; + const key = usageLocationKey(l); if (seen.has(key)) continue; seen.add(key); const text = files.get(l.path)?.[l.range.start.line] ?? ""; const endCol = l.range.end.line === l.range.start.line ? l.range.end.col : Math.max(text.length, l.range.start.col); let list = byPath.get(l.path); if (!list) byPath.set(l.path, (list = [])); - list.push({ path: l.path, line: l.range.start.line, col: l.range.start.col, endCol, text }); + list.push({ + ...(kinds.has(usageLocationKey(l)) + ? { + kind: kinds.get(usageLocationKey(l))!.kind, + access: kinds.get(usageLocationKey(l))!.access, + sourceLocation: l, + } + : {}), + path: l.path, + line: l.range.start.line, + col: l.range.start.col, + endCol, + text, + }); } return [...byPath.entries()] .sort(([a], [b]) => a.localeCompare(b)) @@ -169,12 +188,14 @@ export async function fetchUsages(c: CaretContext, request: RequestId = nextRequ // Usage lines are read afresh: so are the files their syntax colours come from. clearLineTokens(); let locs: Location[]; + let classified: ClassifiedReference[] = []; let note: string | null = null; try { const refs = await lspRequest(() => commands.lspReferences(request, c.doc, { line: c.line, col: c.col }, false, true), ); locs = refs.locations; + classified = refs.classified ?? []; if (refs.incomplete) note = incompleteNote(refs.incomplete, c.word); } catch (e) { if (isCancelled(e)) throw e; @@ -190,7 +211,7 @@ export async function fetchUsages(c: CaretContext, request: RequestId = nextRequ note = fb.note; } const files = await readManyFileLines(locs.map((l) => l.path)); - return { groups: groupUsages(locs, files), note }; + return { groups: groupUsages(locs, files, classified), note }; } /** In-flight server requests: of tool window searches by search id, and of the popup. */ @@ -249,7 +270,12 @@ export async function findUsages() { * Ignores malformed arguments. */ export async function showUsageResults(arg: unknown) { - const a = (arg ?? {}) as { symbol?: unknown; locations?: unknown }; + const a = (arg ?? {}) as { + symbol?: unknown; + locations?: unknown; + classified?: ClassifiedReference[]; + note?: unknown; + }; if (!Array.isArray(a.locations)) return; const locs = a.locations as Location[]; const symbol = typeof a.symbol === "string" ? a.symbol : null; @@ -259,7 +285,19 @@ export async function showUsageResults(arg: unknown) { toolWindowService().show("usages"); clearLineTokens(); const files = await readManyFileLines(locs.map((l) => l.path)); - replaceUsageTab(id, { ...base, loading: false, groups: groupUsages(locs, files), error: null, note: null }); + replaceUsageTab(id, { + ...base, + loading: false, + groups: groupUsages(locs, files, a.classified), + error: null, + note: typeof a.note === "string" ? a.note : null, + }); +} + +export function dismissUsagesPopup() { + if (popupRequest !== null) void commands.cancelRequest(popupRequest).catch(() => {}); + popupRequest = null; + useUsagesPopup.setState({ open: false }); } export async function showUsagesPopup() { @@ -273,6 +311,7 @@ export async function showUsagesPopup() { popupRequest = request; try { const { groups, note } = await fetchUsages(c, request); + if (popupRequest !== request) return; const total = usageCount(groups); if (!total) { const none = c.word ? `No usages of '${c.word}' found` : "No usages found"; @@ -288,8 +327,13 @@ export async function showUsagesPopup() { } useUsagesPopup.setState({ open: true, anchor: caretPoint(), symbol: c.word, groups, note }); } catch (e) { + if (popupRequest !== request || isCancelled(e)) return; reportError(e); } finally { if (popupRequest === request) popupRequest = null; } } + +function usageLocationKey(l: Location): string { + return JSON.stringify([l.path, l.range.start.line, l.range.start.col, l.range.end.line, l.range.end.col]); +} diff --git a/src/features/code-insight/usagesPopup.test.tsx b/src/features/code-insight/usagesPopup.test.tsx index d1fd513f6..671c6a3ca 100644 --- a/src/features/code-insight/usagesPopup.test.tsx +++ b/src/features/code-insight/usagesPopup.test.tsx @@ -2,11 +2,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { act, fireEvent, render, screen } from "@testing-library/react"; const calls: string[] = []; +const referenceResponse = vi.fn(async () => ({ locations: [loc], incomplete: null })); const loc = { path: "/p/b.ts", range: { start: { line: 1, col: 4 }, end: { line: 1, col: 8 } } }; vi.mock("./ipc", async (orig) => ({ ...(await orig()), caretContext: () => ({ doc: 1, path: "/p/a.ts", line: 0, col: 0, word: "used", view: null }), - lspRequest: async () => ({ locations: [loc], incomplete: null }), + lspRequest: () => referenceResponse(), readManyFileLines: async () => new Map([["/p/b.ts", ["", " used();"]]]), openLocation: async (path: string, line: number) => void calls.push(`open ${path}:${line}`), hint: (text: string) => void calls.push(`hint ${text}`), @@ -17,8 +18,8 @@ vi.mock("./usages", async (orig) => ({ showUsageResults: (a: unknown) => showUsageResults(a), })); -const { UsagesPopup, filterUsages } = await import("./UsagesPopup"); -const { showUsagesPopup, useUsagesPopup } = await import("./usages"); +const { UsagesPopup, filterUsages, usageLocations } = await import("./UsagesPopup"); +const { showUsagesPopup, useUsagesPopup, dismissUsagesPopup } = await import("./usages"); const usage = (path: string, line: number, text: string) => ({ path, line, col: 0, endCol: 3, text }); const groups = [ @@ -32,7 +33,14 @@ function open() { return screen.getByRole("dialog", { name: "Usages" }).querySelector("[tabindex='0']")!; } -afterEach(() => act(() => useUsagesPopup.setState({ open: false }))); +const { useUsageAccessOptions } = await import("./UsageAccessFilter"); +afterEach(() => + act(() => { + dismissUsagesPopup(); + useUsageAccessOptions.setState({ read: true, write: true }); + localStorage.removeItem("openidea.usages.accessFilter.v1"); + }), +); describe("Show Usages popup", () => { it("filters by typing; Esc clears the filter, then closes", () => { @@ -61,6 +69,8 @@ describe("Show Usages popup", () => { expect(useUsagesPopup.getState().open).toBe(false); expect(showUsageResults).toHaveBeenCalledWith({ symbol: "foo", + note: null, + classified: expect.arrayContaining([expect.objectContaining({ kind: "unknown" })]), locations: [ { path: "/p/a.ts", range: { start: { line: 2, col: 0 }, end: { line: 2, col: 3 } } }, { path: "/p/a.ts", range: { start: { line: 5, col: 0 }, end: { line: 5, col: 3 } } }, @@ -84,3 +94,138 @@ describe("Show Usages popup", () => { expect(useUsagesPopup.getState().open).toBe(false); }); }); + +describe("semantic usage categories", () => { + it("separates calls on the same line and preserves categories when opening the Find window", () => { + open(); + const text = "b.setName(b.getName())"; + act(() => + useUsagesPopup.setState({ + groups: [ + { + path: "/p/Bean.java", + usages: [ + { path: "/p/Bean.java", line: 2, col: 2, endCol: 9, text, kind: "setter", access: "write" }, + { path: "/p/Bean.java", line: 2, col: 12, endCol: 19, text, kind: "getter", access: "read" }, + { path: "/p/Bean.java", line: 3, col: 0, endCol: 4, text: "name", kind: "field", access: "readWrite" }, + { path: "/p/Bean.java", line: 4, col: 0, endCol: 4, text: "other" }, + ], + }, + ], + }), + ); + expect(screen.queryByRole("combobox")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Show write accesses" })); + expect(screen.getByText("3 of 4 found")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Show read accesses" })); + expect(screen.getByRole("button", { name: "Show write accesses" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByText("3 of 4 found")).toBeInTheDocument(); + fireEvent.click(screen.getByText(/Open in Find Window/)); + expect(showUsageResults).toHaveBeenLastCalledWith( + expect.objectContaining({ + classified: expect.arrayContaining([ + expect.objectContaining({ kind: "getter", access: "read" }), + expect.objectContaining({ kind: "setter", access: "write" }), + expect.objectContaining({ kind: "field", access: "readWrite" }), + expect.objectContaining({ kind: "unknown" }), + ]), + }), + ); + }); +}); + +it("keeps unknown results visible after classified results are replaced", () => { + open(); + act(() => + useUsagesPopup.setState({ + groups: [ + { path: "/Bean.java", usages: [{ ...usage("/Bean.java", 1, "getName()"), kind: "getter", access: "read" }] }, + ], + }), + ); + fireEvent.click(screen.getByRole("button", { name: "Show write accesses" })); + act(() => useUsagesPopup.setState({ groups })); + expect(screen.queryByRole("toolbar", { name: "Usage access filters" })).toBeNull(); + expect(screen.getAllByRole("option")).toHaveLength(3); +}); + +it("preserves an exact multiline semantic range when transferring to Find", () => { + const sourceLocation = { path: "/Bean.java", range: { start: { line: 1, col: 0 }, end: { line: 2, col: 10 } } }; + expect( + usageLocations([{ path: "/Bean.java", usages: [{ ...usage("/Bean.java", 1, "b.value()"), sourceLocation }] }]), + ).toEqual([sourceLocation]); +}); + +it("does not reopen after dismissal or let a superseded response replace a newer popup", async () => { + const result = { + locations: [loc, { ...loc, range: { start: { line: 2, col: 0 }, end: { line: 2, col: 4 } } }], + incomplete: null, + }; + let oldResolve!: (value: typeof result) => void; + referenceResponse.mockReturnValueOnce( + new Promise((resolve) => { + oldResolve = resolve; + }), + ); + const old = showUsagesPopup(); + const fresh = showUsagesPopup(); + await fresh; + dismissUsagesPopup(); + oldResolve(result); + await old; + expect(useUsagesPopup.getState().open).toBe(false); +}); + +it("keeps filter controls available when all classified rows are hidden", () => { + open(); + act(() => + useUsagesPopup.setState({ + groups: [ + { + path: "/Bean.java", + usages: [ + { ...usage("/Bean.java", 1, "b.count"), access: "read" }, + { ...usage("/Bean.java", 2, "b.count"), access: "read" }, + ], + }, + ], + }), + ); + fireEvent.click(screen.getByRole("button", { name: "Show read accesses" })); + expect(screen.getByText("No usages match the access filters")).toBeInTheDocument(); + expect(screen.queryAllByRole("option")).toHaveLength(0); + fireEvent.click(screen.getByRole("button", { name: "Show read accesses" })); + expect(screen.getAllByRole("option")).toHaveLength(2); +}); + +it("discards a pending response after the current popup is dismissed", async () => { + let resolve!: (value: { locations: (typeof loc)[]; incomplete: null }) => void; + referenceResponse.mockReturnValueOnce( + new Promise((r) => { + resolve = r; + }), + ); + const pending = showUsagesPopup(); + dismissUsagesPopup(); + resolve({ locations: [loc, { ...loc, path: "/p/other.ts" }], incomplete: null }); + await pending; + expect(useUsagesPopup.getState().open).toBe(false); +}); + +it("clears speed search before closing when Escape comes from an access button", () => { + const body = open(); + act(() => + useUsagesPopup.setState({ + groups: groups.map((g) => ({ ...g, usages: g.usages.map((u) => ({ ...u, access: "read" as const })) })), + }), + ); + for (const key of "cart") fireEvent.keyDown(body, { key }); + const button = screen.getByRole("button", { name: "Show write accesses" }); + fireEvent.click(button); + button.focus(); + fireEvent.keyDown(button, { key: "Escape" }); + expect(useUsagesPopup.getState().open).toBe(true); + expect(screen.getAllByRole("option")).toHaveLength(3); + fireEvent.keyDown(button, { key: "Escape" }); + expect(useUsagesPopup.getState().open).toBe(false); +}); diff --git a/src/features/code-insight/views.test.ts b/src/features/code-insight/views.test.ts index ac6d7dd92..f97042eca 100644 --- a/src/features/code-insight/views.test.ts +++ b/src/features/code-insight/views.test.ts @@ -110,9 +110,33 @@ describe("toggleExcludedUsages", () => { const u = (line: number) => ({ path: "/a", line, col: 0, endCol: 1, text: "" }); it("excludes a group unless all of it is excluded already", () => { const one = toggleExcludedUsages(new Set(), [u(1)]); - expect([...one]).toEqual(["/a:1:0"]); + expect([...one]).toEqual(["/a:1:0:1:1"]); const both = toggleExcludedUsages(one, [u(1), u(2)]); - expect([...both].sort()).toEqual(["/a:1:0", "/a:2:0"]); + expect([...both].sort()).toEqual(["/a:1:0:1:1", "/a:2:0:2:1"]); expect(toggleExcludedUsages(both, [u(1), u(2)]).size).toBe(0); }); }); + +it("preserves semantic categories for separate ranges on the same source line", () => { + const getter = { path: "/Bean.java", range: { start: { line: 0, col: 12 }, end: { line: 0, col: 19 } } }; + const setter = { path: "/Bean.java", range: { start: { line: 0, col: 2 }, end: { line: 0, col: 9 } } }; + const groups = groupUsages([getter, setter], new Map([["/Bean.java", ["b.setName(b.getName())"]]]), [ + { location: getter, kind: "getter" }, + { location: setter, kind: "setter" }, + ]); + expect(groups[0]!.usages.map((u) => [u.col, u.kind])).toEqual([ + [2, "setter"], + [12, "getter"], + ]); +}); + +it("retains nested references sharing a start and their exact multiline range", () => { + const inner = { path: "/Bean.java", range: { start: { line: 0, col: 0 }, end: { line: 0, col: 9 } } }; + const outer = { path: "/Bean.java", range: { start: { line: 0, col: 0 }, end: { line: 1, col: 10 } } }; + const groups = groupUsages([inner, outer], new Map([["/Bean.java", ["b.value()", ".value(x);"]]]), [ + { location: inner, kind: "getter" }, + { location: outer, kind: "setter" }, + ]); + expect(groups[0]!.usages).toHaveLength(2); + expect(groups[0]!.usages[1]!.sourceLocation).toEqual(outer); +}); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 80c3cf0b0..509dee215 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5351,6 +5351,10 @@ "The attached process may not provide standard output to the debugger.": "附加的进程可能不会向调试器提供标准输出。", "Show in Debug Tool Window": "在调试工具窗口中显示", "Suspended": "已暂停", + "Usage access filters": "引用读写筛选", + "Show read accesses": "显示读取引用", + "Show write accesses": "显示写入引用", + "No usages match the access filters": "没有符合读写筛选条件的引用", "On 'Update' action": "执行“更新”操作时", "Show chooser": "显示选择菜单", "On frame deactivation": "窗口失去焦点时", diff --git a/src/ui/expui/actions/showReadAccess.svg b/src/ui/expui/actions/showReadAccess.svg new file mode 100644 index 000000000..50d861461 --- /dev/null +++ b/src/ui/expui/actions/showReadAccess.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/actions/showReadAccess_dark.svg b/src/ui/expui/actions/showReadAccess_dark.svg new file mode 100644 index 000000000..887c1b345 --- /dev/null +++ b/src/ui/expui/actions/showReadAccess_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/actions/showWriteAccess.svg b/src/ui/expui/actions/showWriteAccess.svg new file mode 100644 index 000000000..0ac9f26fa --- /dev/null +++ b/src/ui/expui/actions/showWriteAccess.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/actions/showWriteAccess_dark.svg b/src/ui/expui/actions/showWriteAccess_dark.svg new file mode 100644 index 000000000..15d96c632 --- /dev/null +++ b/src/ui/expui/actions/showWriteAccess_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/icons.test.tsx b/src/ui/icons.test.tsx index 484a454a7..0e1a7063a 100644 --- a/src/ui/icons.test.tsx +++ b/src/ui/icons.test.tsx @@ -64,6 +64,21 @@ describe("Icon", () => { expect(colour!.querySelectorAll("g")).toHaveLength(2); }); + it("preserves the dedicated read/write palettes in both themes", () => { + for (const [name, light, dark] of [ + ["show-read-access", "#369650", "#57965C"], + ["show-write-access", "#DB3B4B", "#DB5C5C"], + ] as const) { + const { container, unmount } = render(); + const svg = container.querySelector("svg")!; + expect(svg.getAttribute("width")).toBe("16"); + expect(svg.querySelectorAll("g")).toHaveLength(2); + expect(svg.innerHTML).toContain(light); + expect(svg.innerHTML).toContain(dark); + unmount(); + } + }); + it("uses the 20px variant for large tool window icons", () => { const { container } = render(); expect(container.querySelector("svg")!.getAttribute("viewBox")).toBe("0 0 20 20"); diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 796e6b56b..bcf50a27a 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -239,6 +239,8 @@ const ICONS = { hide: "general/hide", pin: "general/pin", eye: "general/show", + "show-read-access": "actions/showReadAccess", + "show-write-access": "actions/showWriteAccess", lock: "general/locked", unlock: "openidea/general/unlocked", scratches: "fileTypes/scratches",