diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index f8b27063..67f31cb4 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -412,6 +412,56 @@ pub async fn lsp_prepare_rename(host: &Host, window: &str, doc: DocId, pos: Pos) } /// Computes the rename edit without applying it, so the UI can preview conflicts. +/// The class at `pos` and the members a class-hierarchy refactoring can move. +pub async fn lsp_hierarchy_members( + host: &Host, + window: &str, + request: RequestId, + doc: DocId, + pos: Pos, + kind: HierarchyRefactoring, +) -> IdeResult { + let project = host.project(window)?; + let cancel = host.begin_request(request); + let result = project.lsp.hierarchy_members(doc, pos, kind, cancel).await; + host.end_request(request); + result +} + +/// Checks a class-hierarchy refactoring and computes its edit without applying it. +#[allow(clippy::too_many_arguments)] +pub async fn lsp_hierarchy_refactor( + host: &Host, + window: &str, + request: RequestId, + doc: DocId, + pos: Pos, + kind: HierarchyRefactoring, + target: Option, + choices: Vec, +) -> IdeResult { + let project = host.project(window)?; + let documents = project.documents.clone(); + let version = blocking(move || documents.version(doc)).await?; + let cancel = host.begin_request(request); + let result = project.lsp.hierarchy_refactor(doc, pos, kind, target, choices, cancel).await; + host.end_request(request); + let mut refactored = result?; + blocking(move || { + if project.documents.version(doc)? != version { + return Err(ide_core::IdeError::new( + ide_core::error::ErrorKind::OutOfSync, + "document changed while computing the refactoring; request it again", + )); + } + if let Some(edit) = &mut refactored.edit { + project.documents.prepare_workspace_edit(edit)?; + } + Ok(refactored) + }) + .await +} + pub async fn lsp_rename( host: &Host, window: &str, @@ -676,6 +726,22 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("lsp_rename", |c| async move { lsp_rename(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("newName")?).await }); + d.add("lsp_hierarchy_members", |c| async move { + lsp_hierarchy_members(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("kind")?).await + }); + d.add("lsp_hierarchy_refactor", |c| async move { + lsp_hierarchy_refactor( + &c.host, + &c.window, + c.arg("request")?, + c.arg("doc")?, + c.arg("pos")?, + c.arg("kind")?, + c.arg("target")?, + c.arg("choices")?, + ) + .await + }); d.add("lsp_format", |c| async move { lsp_format(&c.host, &c.window, c.arg("doc")?, c.arg("range")?, c.arg("options")?).await }); diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index 5852f53e..360dfa96 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -20,6 +20,7 @@ const SERVER: &str = "jdtls"; pub(crate) mod configuration; mod formatter; +mod hierarchy; pub(crate) mod reserved_names; pub use formatter::configure_formatter; /// First start imports the Maven / Gradle model, which can take minutes. diff --git a/crates/ide-lsp/src/jdtls/hierarchy.rs b/crates/ide-lsp/src/jdtls/hierarchy.rs new file mode 100644 index 00000000..abcdb667 --- /dev/null +++ b/crates/ide-lsp/src/jdtls/hierarchy.rs @@ -0,0 +1,101 @@ +//! Pull Members Up / Push Members Down through the app-owned JDT extension +//! (`plugins/java-usages`). The server checks the move and computes the edit; the UI +//! shows its problems and applies the edit itself. +use super::COMMAND_TIMEOUT; +use crate::types::{HierarchyChoice, HierarchyMembers, HierarchyRefactored, HierarchyRefactoring, RefactoringProblem}; +use crate::{DocSnap, LspManager, Target, convert}; +use ide_core::{CancellationToken, DocId, IdeError, IdeResult, Pos}; +use serde::Deserialize; +use serde::de::DeserializeOwned; +use serde_json::{Value, json}; + +const MEMBERS: &str = "openidea.java.hierarchy.members"; +const REFACTOR: &str = "openidea.java.hierarchy.refactor"; + +/// The extension's answer: a result, or why the position can't be refactored. +#[derive(Deserialize)] +#[serde(untagged)] +enum Answer { + Refused { refused: String }, + Ok(T), +} + +impl Answer { + fn into_result(self) -> IdeResult { + match self { + Answer::Refused { refused } => Err(IdeError::invalid(refused)), + Answer::Ok(value) => Ok(value), + } + } +} + +#[derive(Deserialize)] +struct Refactored { + problems: Vec, + edit: Option, +} + +fn feature(kind: HierarchyRefactoring) -> &'static str { + match kind { + HierarchyRefactoring::PullUp => "Pull Members Up", + HierarchyRefactoring::PushDown => "Push Members Down", + } +} + +impl LspManager { + async fn hierarchy_command( + &self, + doc: DocId, + pos: Pos, + kind: HierarchyRefactoring, + command: &str, + extra: &[Value], + cancel: &CancellationToken, + ) -> IdeResult<(R, DocSnap, Target)> { + let (snap, mut targets) = + self.doc_targets(doc, feature(kind), |c| c.commands().iter().any(|c| c == command)).await?; + let t = targets.swap_remove(0); + let at = snap.position(&t, pos); + let mut arguments = vec![json!(snap.uri), json!(at.line), json!(at.character), json!(kind)]; + arguments.extend_from_slice(extra); + let params = json!({ "command": command, "arguments": arguments }); + let answer: Answer = + self.call(&t, "workspace/executeCommand", params, Some(cancel), COMMAND_TIMEOUT).await?; + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + Ok((answer.into_result()?, snap, t)) + } + + /// The class at `pos` and its movable members; an invalid-input error says why there are none. + pub async fn hierarchy_members( + &self, + doc: DocId, + pos: Pos, + kind: HierarchyRefactoring, + cancel: CancellationToken, + ) -> IdeResult { + let (members, _, _) = self.hierarchy_command(doc, pos, kind, MEMBERS, &[], &cancel).await?; + Ok(members) + } + + /// Checks moving `choices` (to `target`, for pull up) and computes the edit without applying it. + pub async fn hierarchy_refactor( + &self, + doc: DocId, + pos: Pos, + kind: HierarchyRefactoring, + target: Option, + choices: Vec, + cancel: CancellationToken, + ) -> IdeResult { + let choices: Vec = choices.iter().map(|c| json!({ "id": c.id, "abstract": c.make_abstract })).collect(); + let (refactored, _, t): (Refactored, _, _) = + self.hierarchy_command(doc, pos, kind, REFACTOR, &[json!(target), json!(choices)], &cancel).await?; + let conv = self.conv(t.enc()); + Ok(HierarchyRefactored { + problems: refactored.problems, + edit: refactored.edit.map(|e| convert::workspace_edit(&conv, e)), + }) + } +} diff --git a/crates/ide-lsp/src/types.rs b/crates/ide-lsp/src/types.rs index 047aabf4..d2bdd5ff 100644 --- a/crates/ide-lsp/src/types.rs +++ b/crates/ide-lsp/src/types.rs @@ -389,6 +389,87 @@ pub struct Renamed { pub incomplete: Option, } +/// IDEA's class-hierarchy refactorings. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum HierarchyRefactoring { + PullUp, + PushDown, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct HierarchyClass { + /// Server-owned identity, passed back as the pull-up destination. + pub id: String, + pub name: String, + pub qualified_name: String, + pub interface: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum HierarchyMemberKind { + Field, + Method, + Type, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct HierarchyMember { + pub id: String, + /// `name(Params) : Type`, as the server renders it. + pub label: String, + pub kind: HierarchyMemberKind, + /// The member at the caret. + pub selected: bool, + /// Can be declared abstract in the destination (pull up) or kept abstract (push down). + pub abstractable: bool, +} + +/// The class at a position and what can move: pull-up destinations, or the subclasses +/// members are pushed into. +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct HierarchyMembers { + pub class_name: String, + pub targets: Vec, + pub members: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct HierarchyChoice { + pub id: String, + pub make_abstract: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum ProblemSeverity { + Info, + Warning, + Error, + /// The refactoring can't run at all; there is no edit. + Fatal, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct RefactoringProblem { + pub severity: ProblemSeverity, + pub message: String, +} + +/// A checked refactoring: what the server found, and the edit unless a problem is fatal. +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct HierarchyRefactored { + pub problems: Vec, + pub edit: Option, +} + #[derive(Clone, Debug, Default, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct CallHierarchy { diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 93d9d370..485fae03 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -3338,3 +3338,90 @@ async fn jdtls_import_failure_names_reserved_device_files_on_windows() { } let _ = std::fs::remove_file(&nul); } + +/// Pull Members Up / Push Members Down: the extension's answers, refusals and edits. +#[tokio::test(flavor = "multi_thread")] +async fn java_hierarchy_refactorings_use_the_extension_commands() { + let mut caps = incremental_caps(); + caps["executeCommandProvider"] = + json!({"commands": ["openidea.java.hierarchy.members", "openidea.java.hierarchy.refactor"]}); + let h = harness_for("java", "java", &[("jdtls", caps)], &[]); + let s = h.launcher.state("jdtls"); + *s.responder.lock() = Some(Arc::new(|_, method, params| { + if method != "workspace/executeCommand" { + return None; + } + let args = params["arguments"].as_array().unwrap(); + let uri = args[0].clone(); + Some(match (params["command"].as_str().unwrap(), args[1].as_u64().unwrap()) { + (_, 9) => json!({"refused": "Type has no super types."}), + ("openidea.java.hierarchy.members", _) => json!({ + "className": "Dog", + "targets": [{"id": "=p/src<{Animal.java[Animal", "name": "Animal", "qualifiedName": "Animal", "interface": false}], + "members": [{"id": "=p/src<{Dog.java[Dog~bark", "label": "bark() : String", "kind": "method", "selected": true, "abstractable": true}], + }), + _ => json!({ + "problems": [{"severity": "warning", "message": "The visibility of field 'tail' will be changed to package."}], + "edit": {"changes": {uri.as_str().unwrap(): [ + {"range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 5}}, "newText": "class"} + ]}}, + }), + }) + })); + let text = + format!("class Dog extends Animal {{ String bark() {{ return \"\"; }} }}{}class Lonely {{}}", "\n".repeat(9)); + let (doc, path) = h.open("Dog.java", &text); + h.wait_status("jdtls", ServerStatus::Ready).await; + eventually(|| s.docs.lock().len() == 1).await; + + let info = + h.mgr.hierarchy_members(doc, Pos::new(0, 35), HierarchyRefactoring::PullUp, Default::default()).await.unwrap(); + assert_eq!(info.class_name, "Dog"); + assert_eq!(info.targets[0].name, "Animal"); + assert!(info.members[0].selected && info.members[0].abstractable); + assert_eq!(info.members[0].kind, HierarchyMemberKind::Method); + let sent = s.requested("workspace/executeCommand"); + assert_eq!(sent[0]["arguments"], json!([uri_of(&path), 0, 35, "pullUp"])); + + let choices = vec![HierarchyChoice { id: info.members[0].id.clone(), make_abstract: true }]; + let done = h + .mgr + .hierarchy_refactor( + doc, + Pos::new(0, 35), + HierarchyRefactoring::PushDown, + Some(info.targets[0].id.clone()), + choices, + Default::default(), + ) + .await + .unwrap(); + assert_eq!(done.problems[0].severity, ProblemSeverity::Warning); + let edit = done.edit.unwrap(); + assert!(matches!(&edit.files[..], [FileEdit::Edit { path: p, edits, .. }] if p == &path && edits.len() == 1)); + let sent = s.requested("workspace/executeCommand"); + assert_eq!( + sent[1]["arguments"], + json!([uri_of(&path), 0, 35, "pushDown", "=p/src<{Animal.java[Animal", + [{"id": "=p/src<{Dog.java[Dog~bark", "abstract": true}]]) + ); + + let refused = h.mgr.hierarchy_members(doc, Pos::new(9, 0), HierarchyRefactoring::PullUp, Default::default()).await; + let e = refused.unwrap_err(); + assert_eq!(e.kind, ide_core::error::ErrorKind::InvalidInput); + assert_eq!(e.message, "Type has no super types."); +} + +#[tokio::test(flavor = "multi_thread")] +async fn hierarchy_refactorings_name_the_servers_without_them() { + let h = harness(&[("plain", incremental_caps())]); + let (doc, _) = h.open("a.mk", "class A {}"); + h.wait_status("plain", ServerStatus::Ready).await; + let e = h + .mgr + .hierarchy_members(doc, Pos::new(0, 6), HierarchyRefactoring::PullUp, Default::default()) + .await + .unwrap_err(); + assert_eq!(e.kind, ide_core::error::ErrorKind::Unavailable); + assert_eq!(e.message, "plain doesn't support Pull Members Up"); +} diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index 05f89fc9..c5c35aec 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -1170,7 +1170,7 @@ mod kotlin_bridge_bundle_tests { /// 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.2.1.jar"); + let extension = data_dir.join("java-usages-1.3.0.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); diff --git a/e2e/hierarchyRefactor.spec.ts b/e2e/hierarchyRefactor.spec.ts new file mode 100644 index 00000000..2ca991c1 --- /dev/null +++ b/e2e/hierarchyRefactor.spec.ts @@ -0,0 +1,172 @@ +/** + * Pull Members Up / Push Members Down. The language server is stubbed at the IPC layer: the + * Java side is covered by plugins/java-usages/tests/hierarchy.py against a real JDT LS. + */ +import type { Page } from "@playwright/test"; +import { expect, openFromTree, test } from "./fixtures"; + +const captureDir = process.env.OPENIDEA_ACCEPTANCE_DIR; + +/** Opens the dialog as the action does after the server answered, for the editor's view. */ +async function openDialog(page: Page, kind: "pullUp" | "pushDown") { + await page.evaluate(async (kind) => { + const module = "/src/editor/refactor/hierarchy.ts"; + const { useHierarchyDialog } = await import(/* @vite-ignore */ module); + const view = (document.querySelector(".cm-content") as unknown as { cmTile: { view: { state: { doc: unknown } } } }) + .cmTile.view; + const pullUp = kind === "pullUp"; + useHierarchyDialog.setState({ + request: { + seq: 1, + view, + kind, + doc: 1, + pos: { line: 0, col: 0 }, + text: view.state.doc, + info: { + className: pullUp ? "Dog" : "Animal", + targets: pullUp + ? [ + { id: "animal", name: "Animal", qualifiedName: "zoo.Animal", interface: false }, + { id: "speaker", name: "Speaker", qualifiedName: "zoo.Speaker", interface: true }, + ] + : [ + { id: "dog", name: "Dog", qualifiedName: "zoo.Dog", interface: false }, + { id: "cat", name: "Cat", qualifiedName: "zoo.Cat", interface: false }, + ], + members: pullUp + ? [ + { id: "tail", label: "tail : int", kind: "field", selected: false, abstractable: false }, + { id: "bark", label: "bark() : String", kind: "method", selected: true, abstractable: true }, + { id: "wag", label: "wag() : int", kind: "method", selected: false, abstractable: true }, + ] + : [ + { id: "name", label: "name : String", kind: "field", selected: false, abstractable: false }, + { id: "describe", label: "describe() : String", kind: "method", selected: true, abstractable: true }, + ], + }, + }, + }); + }, kind); +} + +/** Stubs the server's check and the edit application; returns what reached them. */ +async function stubServer(page: Page, problems: { severity: string; message: string }[]) { + await page.evaluate(async (problems) => { + const module = "/src/platform/ipc.ts"; + const { commands } = await import(/* @vite-ignore */ module); + // Only the stubbed commands answer; window lookups by other features keep working. + Object.defineProperty(window, "__TAURI_INTERNALS__", { + configurable: true, + value: { + transformCallback: () => 1, + invoke: async () => null, + metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main", windowLabel: "main" } }, + }, + }); + const seen = ((window as unknown as { hierarchySeen: unknown[] }).hierarchySeen = [] as unknown[]); + commands.lspHierarchyRefactor = async (...args: unknown[]) => { + seen.push({ refactor: args.slice(3) }); + return { + status: "ok", + data: { + problems, + edit: { + files: [ + { + kind: "edit", + path: "/dev/acme-api/README.md", + edits: [{ range: { start: { line: 0, col: 0 }, end: { line: 0, col: 0 } }, new_text: "" }], + }, + ], + }, + }, + }; + }; + commands.applyWorkspaceEdit = async (edit: unknown) => { + seen.push({ apply: edit }); + return { status: "ok", data: null }; + }; + }, problems); +} + +const seen = (page: Page) => page.evaluate(() => (window as unknown as { hierarchySeen: unknown[] }).hierarchySeen); + +test("the editor's Refactor menu offers Pull Members Up and Push Members Down", async ({ workbench: page }) => { + const editor = await openFromTree(page, "README.md"); + await editor.locator(".cm-content").click({ button: "right" }); + await page.getByRole("menuitem", { name: "Refactor", exact: true }).hover(); + await expect(page.getByRole("menuitem", { name: /^Pull Members Up…/ })).toBeVisible(); + await expect(page.getByRole("menuitem", { name: /^Push Members Down…/ })).toBeVisible(); + if (captureDir) await page.screenshot({ path: `${captureDir}/01-refactor-menu.png` }); + await page.keyboard.press("Escape"); +}); + +test("Pull Members Up: members, interface destination, conflicts, then the edit", async ({ workbench: page }) => { + await openFromTree(page, "README.md"); + await openDialog(page, "pullUp"); + const dialog = page.getByRole("dialog", { name: "Pull Members Up" }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText("Pull up members of Dog to:")).toBeVisible(); + const member = (label: string) => dialog.getByRole("row").filter({ hasText: label }); + // The member at the caret starts checked; fields can't be made abstract. + await expect(member("bark() : String").getByRole("checkbox").first()).toBeChecked(); + await expect(member("wag() : int").getByRole("checkbox").first()).not.toBeChecked(); + await member("tail : int").getByRole("checkbox").first().check(); + await expect(member("tail : int").getByRole("checkbox", { name: "Make abstract" })).toBeDisabled(); + if (captureDir) await page.screenshot({ path: `${captureDir}/02-pull-up-dialog.png` }); + + // To an interface, methods are declared abstract there. + await dialog.getByRole("button", { name: "Destination class" }).click(); + await page.getByRole("option", { name: /zoo\.Speaker/ }).click(); + const abstractBark = member("bark() : String").getByRole("checkbox", { name: "Make abstract" }); + await expect(abstractBark).toBeChecked(); + await expect(abstractBark).toBeDisabled(); + await expect(dialog.getByText("Methods pulled up to an interface are declared abstract there.")).toBeVisible(); + if (captureDir) await page.screenshot({ path: `${captureDir}/03-pull-up-to-interface.png` }); + + await stubServer(page, [ + { severity: "error", message: "Field 'zoo.Dog.tail' is not accessible from type 'zoo.Speaker'" }, + ]); + await dialog.getByRole("button", { name: "Refactor", exact: true }).click(); + const problems = page.getByRole("alertdialog").or(page.getByRole("dialog", { name: "Problems Detected" })); + await expect(problems.getByText(/is not accessible from type/)).toBeVisible(); + if (captureDir) await page.screenshot({ path: `${captureDir}/04-problems-detected.png` }); + // Cancel: nothing applied, the dialog stays. + await problems.getByRole("button", { name: "Cancel", exact: true }).click(); + await expect(dialog).toBeVisible(); + expect(await seen(page)).toEqual([ + { + refactor: [ + "pullUp", + "speaker", + [ + { id: "tail", makeAbstract: false }, + { id: "bark", makeAbstract: true }, + ], + ], + }, + ]); + // Continue: the edit is applied and the dialog closes. + await dialog.getByRole("button", { name: "Refactor", exact: true }).click(); + await problems.getByRole("button", { name: "Continue", exact: true }).click(); + await expect(dialog).toBeHidden(); + await expect.poll(async () => (await seen(page)).some((s) => "apply" in (s as object))).toBe(true); +}); + +test("Push Members Down keeps an abstract declaration when asked", async ({ workbench: page }) => { + await openFromTree(page, "README.md"); + await openDialog(page, "pushDown"); + const dialog = page.getByRole("dialog", { name: "Push Members Down" }); + await expect(dialog.getByText("Push members of Animal down to:")).toBeVisible(); + await expect(dialog.getByText("Dog, Cat")).toBeVisible(); + const describe = dialog.getByRole("row").filter({ hasText: "describe() : String" }); + await describe.getByRole("checkbox", { name: "Keep abstract" }).check(); + if (captureDir) await page.screenshot({ path: `${captureDir}/05-push-down-dialog.png` }); + await stubServer(page, []); + await dialog.getByRole("button", { name: "Refactor", exact: true }).click(); + await expect(dialog).toBeHidden(); + const calls = await seen(page); + expect(calls[0]).toEqual({ refactor: ["pushDown", null, [{ id: "describe", makeAbstract: true }]] }); + expect(calls.some((s) => "apply" in (s as object))).toBe(true); +}); diff --git a/plugins/java-usages/MANIFEST.MF b/plugins/java-usages/MANIFEST.MF index 2d5796c3..252534d5 100644 --- a/plugins/java-usages/MANIFEST.MF +++ b/plugins/java-usages/MANIFEST.MF @@ -2,9 +2,10 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: OpenIDEA Java usages Bundle-SymbolicName: ai.openidea.usages;singleton:=true -Bundle-Version: 1.2.1 +Bundle-Version: 1.3.0 Bundle-RequiredExecutionEnvironment: JavaSE-21 Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core, org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime, - org.eclipse.lsp4j,org.eclipse.core.resources,com.google.gson + org.eclipse.lsp4j,org.eclipse.core.resources,com.google.gson, + org.eclipse.ltk.core.refactoring diff --git a/plugins/java-usages/README.md b/plugins/java-usages/README.md index 819fd2b3..ec738cda 100644 --- a/plugins/java-usages/README.md +++ b/plugins/java-usages/README.md @@ -57,7 +57,7 @@ is not a claim of full IntelliJ feature parity. ## Rebuild and delivery The checked-in jar is embedded by ide-sdk `include_bytes!`, installed as -`java-usages-1.2.1.jar`, and added alongside existing initialization bundles. +`java-usages-1.3.0.jar`, and added alongside existing initialization bundles. Users need no compiler, Maven or Python. Manifest/install version must change when replacing the bundle because OSGi caches bundles. @@ -73,6 +73,8 @@ Version 1.2.0 adds the configuration command; its reproducible SHA-256 is Version 1.2.1 keeps the configuration snapshot across editor open/close, saves of unrelated content and non-Java resource changes; its reproducible SHA-256 is `cbae3e3a0bcf2818b4cc9d98998bd9b81f92e1ae7803dc757134485f9776a4e1`. +Version 1.3.0 adds the class-hierarchy refactoring commands; its reproducible SHA-256 is +`cca4cc385ace286111730d2a6164333b8958d74d69631548eadca8863255c24a`. ```sh export PATH=/path/to/jdk25/bin:$PATH @@ -101,6 +103,7 @@ not rebuild this jar. A maintainer must: 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 +python3 plugins/java-usages/tests/hierarchy.py /path/to/jdtls ``` The harnesses select the JDT LS configuration for the current platform. The first @@ -112,6 +115,24 @@ method references, receiver/array reads and a real cancellation response (`-3280 Use the actual desktop app to check read/write toggles, mutual toggle behavior, Find transfer, persistence and unfiltered non-Java counts with the same fixture. +`hierarchy.py` runs Pull Members Up and Push Members Down: member and destination +listing, moving, declaring abstract, pulling up to an interface, conflicts reported +before any change, and a class without supertypes. Each edit is applied to a copy of +the fixture and compiled with javac. + +## Class-hierarchy refactorings + +`openidea.java.hierarchy.members(uri, line, column, kind)` describes the class at the +position for `kind` `pullUp` or `pushDown`: its movable members (handle id, label, +kind, preselected, can be declared abstract) and the destination supertypes, or the +direct subtypes the members are pushed into. A position on a member preselects it. +`openidea.java.hierarchy.refactor(uri, line, column, kind, target, choices)` runs +JDT's `PullUpRefactoringProcessor` / `PushDownRefactoringProcessor` with the chosen +`{id, abstract}` members and returns `{problems, edit}`. Nothing is applied by the +server; the app shows the problems, then applies the edit as one undoable change. +A method pulled up to an interface is always declared there, as in JDT's wizard. +Either command answers `{refused: reason}` when the position can't be refactored. + ## Review and manual reproduction See [the English reproduction guide](tests/REPRODUCE.md) for a small Java/Lombok @@ -129,6 +150,11 @@ This is an app-owned JDT LS extension, not a standard LSP feature. It uses: objects (`isIncludeAccessors`). - `JDTUtils.resolveTypeRoot`, `findElementAtSelection`. - `org.eclipse.jdt.internal.corext.codemanipulation.GetterSetterUtil`. +- `org.eclipse.jdt.internal.corext.refactoring.structure.PullUpRefactoringProcessor`, + `PushDownRefactoringProcessor`, `RefactoringAvailabilityTesterCore`, `JdtFlags`, + `JavaElementLabelsCore`, `PreferenceManager.getCodeGenerationSettings`, + `JsonRpcHelpers.toOffset`, `JDTUtils.resolveCompilationUnit` and + `ChangeUtil.convertToWorkspaceEdit`. - Public JDT model/DOM APIs for exact target identity and source-expression context. The fingerprint protects native target discovery, not all binary compatibility. diff --git a/plugins/java-usages/openidea-java-usages.jar b/plugins/java-usages/openidea-java-usages.jar index bedb5a1a..2493c96f 100644 Binary files a/plugins/java-usages/openidea-java-usages.jar and b/plugins/java-usages/openidea-java-usages.jar differ diff --git a/plugins/java-usages/plugin.xml b/plugins/java-usages/plugin.xml index dad7e8d8..d6c345e0 100644 --- a/plugins/java-usages/plugin.xml +++ b/plugins/java-usages/plugin.xml @@ -7,5 +7,9 @@ + + + + diff --git a/plugins/java-usages/src/ai/openidea/hierarchy/HierarchyCommand.java b/plugins/java-usages/src/ai/openidea/hierarchy/HierarchyCommand.java new file mode 100644 index 00000000..b2504dd7 --- /dev/null +++ b/plugins/java-usages/src/ai/openidea/hierarchy/HierarchyCommand.java @@ -0,0 +1,261 @@ +package ai.openidea.hierarchy; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.OperationCanceledException; +import org.eclipse.jdt.core.*; +import org.eclipse.jdt.internal.core.manipulation.JavaElementLabelsCore; +import org.eclipse.jdt.internal.corext.refactoring.RefactoringAvailabilityTesterCore; +import org.eclipse.jdt.internal.corext.refactoring.structure.PullUpRefactoringProcessor; +import org.eclipse.jdt.internal.corext.refactoring.structure.PushDownRefactoringProcessor; +import org.eclipse.jdt.internal.corext.refactoring.structure.PushDownRefactoringProcessor.MemberActionInfo; +import org.eclipse.jdt.internal.corext.util.JdtFlags; +import org.eclipse.jdt.ls.core.internal.ChangeUtil; +import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler; +import org.eclipse.jdt.ls.core.internal.JDTUtils; +import org.eclipse.jdt.ls.core.internal.handlers.JsonRpcHelpers; +import org.eclipse.jdt.ls.core.internal.preferences.PreferenceManager; +import org.eclipse.ltk.core.refactoring.Change; +import org.eclipse.ltk.core.refactoring.RefactoringStatus; +import org.eclipse.ltk.core.refactoring.RefactoringStatusEntry; +import org.eclipse.ltk.core.refactoring.participants.ProcessorBasedRefactoring; +import org.eclipse.ltk.core.refactoring.participants.RefactoringProcessor; + +/** + * Pull Members Up / Push Members Down with JDT's own processors. + * + *

{@code openidea.java.hierarchy.members(uri, line, column, kind)} describes the class at the + * position: its movable members and, for pull up, the supertypes in project sources. + * {@code openidea.java.hierarchy.refactor(uri, line, column, kind, target, choices)} checks the + * chosen move and returns its problems and the edit, which is never applied here. {@code kind} + * is {@code pullUp} or {@code pushDown}; {@code choices} are {@code {id, abstract}} objects whose + * ids are the handle identifiers returned by {@code members}. + */ +public final class HierarchyCommand implements IDelegateCommandHandler { + static final String MEMBERS = "openidea.java.hierarchy.members"; + static final String REFACTOR = "openidea.java.hierarchy.refactor"; + + private static final long LABEL = JavaElementLabelsCore.M_PARAMETER_TYPES | JavaElementLabelsCore.M_APP_RETURNTYPE + | JavaElementLabelsCore.F_APP_TYPE_SIGNATURE; + + @Override + public Object executeCommand(String command, List arguments, IProgressMonitor monitor) throws Exception { + String uri = (String) arguments.get(0); + int line = ((Number) arguments.get(1)).intValue(); + int column = ((Number) arguments.get(2)).intValue(); + boolean pullUp = switch ((String) arguments.get(3)) { + case "pullUp" -> true; + case "pushDown" -> false; + default -> throw new IllegalArgumentException("unknown hierarchy refactoring " + arguments.get(3)); + }; + ICompilationUnit unit = JDTUtils.resolveCompilationUnit(uri); + if (unit == null || !unit.exists()) return refused("The file is not part of a Java project"); + IMember start = memberAt(unit, JsonRpcHelpers.toOffset(unit, line, column)); + IMember[] members = start == null ? null : selection(start, pullUp); + if (members == null) { + return refused(pullUp ? "Caret should be inside a class that has members to pull up" + : "Caret should be inside a class that has members to push down"); + } + if (MEMBERS.equals(command)) return pullUp ? pullUpMembers(unit, members, monitor) : pushDownMembers(members, monitor); + if (REFACTOR.equals(command)) { + String target = (String) arguments.get(4); + List choices = new ArrayList<>(); + for (Object o : (List) arguments.get(5)) { + Map map = (Map) o; + IJavaElement element = JavaCore.create((String) map.get("id")); + if (!(element instanceof IMember member) || !member.exists()) return refused("The class changed; open the dialog again"); + choices.add(new Choice(member, Boolean.TRUE.equals(map.get("abstract")))); + } + if (choices.isEmpty()) return refused("No members selected"); + // Started from the class, the processors know it even when no member is moved + // (a method only declared abstract in the destination). + IType declaring = members[0] instanceof IType type ? type : members[0].getDeclaringType(); + IMember[] owner = { declaring }; + return pullUp ? pullUp(unit, owner, target, choices, monitor) : pushDown(owner, choices, monitor); + } + throw new IllegalArgumentException("unknown command " + command); + } + + private record Choice(IMember member, boolean makeAbstract) {} + + /** The innermost member at the offset, or the type itself for an initializer. */ + private static IMember memberAt(ICompilationUnit unit, int offset) throws JavaModelException { + IJavaElement element = unit.getElementAt(offset); + while (element != null && !(element instanceof IMember)) element = element.getParent(); + if (element instanceof IInitializer initializer) return initializer.getDeclaringType(); + return (IMember) element; + } + + /** The members to start from: the one at the caret when it can move, otherwise its class. */ + private static IMember[] selection(IMember start, boolean pullUp) throws JavaModelException { + for (IMember member = start; member != null; member = member.getDeclaringType()) { + if (member.isBinary() || member.isReadOnly()) return null; + if (pullUp ? RefactoringAvailabilityTesterCore.isPullUpAvailable(member) + : RefactoringAvailabilityTesterCore.isPushDownAvailable(member)) return new IMember[] { member }; + if (member instanceof IType type && (pullUp ? RefactoringAvailabilityTesterCore.getPullUpMembers(type) + : RefactoringAvailabilityTesterCore.getPushDownMembers(type)).length > 0) { + return new IMember[] { type }; + } + } + return null; + } + + private static Object pullUpMembers(ICompilationUnit unit, IMember[] members, IProgressMonitor monitor) throws Exception { + var processor = new PullUpRefactoringProcessor(members, PreferenceManager.getCodeGenerationSettings(unit)); + RefactoringStatus status = new ProcessorBasedRefactoring(processor).checkInitialConditions(monitor); + if (status.hasFatalError()) return refused(status.getMessageMatchingSeverity(RefactoringStatus.FATAL)); + IType[] candidates = processor.getCandidateTypes(new RefactoringStatus(), monitor); + cancelled(monitor); + if (candidates.length == 0) { + return refused("'" + processor.getDeclaringType().getElementName() + + "' has no superclass or interface in the project sources to pull members up to"); + } + List> targets = new ArrayList<>(); + for (IType type : candidates) { + Map row = new LinkedHashMap<>(); + row.put("id", type.getHandleIdentifier()); + row.put("name", type.getElementName()); + row.put("qualifiedName", type.getFullyQualifiedName('.')); + row.put("interface", type.isInterface()); + targets.add(row); + } + Set selected = Set.of(processor.getMembersToMove()); + List> rows = new ArrayList<>(); + for (IMember member : processor.getPullableMembersOfDeclaringType()) { + boolean abstractable = member instanceof IMethod method && !JdtFlags.isStatic(method); + rows.add(member(member, selected.contains(member), abstractable)); + } + return info(processor.getDeclaringType(), targets, rows); + } + + private static Object pushDownMembers(IMember[] members, IProgressMonitor monitor) throws Exception { + var processor = new PushDownRefactoringProcessor(members); + RefactoringStatus status = new ProcessorBasedRefactoring(processor).checkInitialConditions(monitor); + if (status.hasFatalError()) return refused(status.getMessageMatchingSeverity(RefactoringStatus.FATAL)); + IType declaring = processor.getDeclaringType(); + List> targets = new ArrayList<>(); + for (IType type : declaring.newTypeHierarchy(declaring.getJavaProject(), monitor).getSubtypes(declaring)) { + if (type.isBinary()) continue; + Map row = new LinkedHashMap<>(); + row.put("id", type.getHandleIdentifier()); + row.put("name", type.getElementName()); + row.put("qualifiedName", type.getFullyQualifiedName('.')); + row.put("interface", type.isInterface()); + targets.add(row); + } + cancelled(monitor); + List> rows = new ArrayList<>(); + for (MemberActionInfo info : processor.getMemberActionInfos()) { + boolean abstractable = false; + for (int action : info.getAvailableActions()) abstractable |= action == MemberActionInfo.PUSH_ABSTRACT_ACTION; + rows.add(member(info.getMember(), info.isToBePushedDown(), abstractable)); + } + return info(declaring, targets, rows); + } + + private static Object pullUp(ICompilationUnit unit, IMember[] members, String target, List choices, + IProgressMonitor monitor) throws Exception { + if (!(JavaCore.create(target) instanceof IType destination) || !destination.exists()) { + return refused("The destination class no longer exists"); + } + var processor = new PullUpRefactoringProcessor(members, PreferenceManager.getCodeGenerationSettings(unit)); + List moved = new ArrayList<>(); + List declaredAbstract = new ArrayList<>(); + List deleted = new ArrayList<>(); + for (Choice choice : choices) { + // A method pulled up to an interface is declared there, as JDT's own wizard does. + if (choice.member instanceof IMethod method && (choice.makeAbstract || destination.isInterface())) { + declaredAbstract.add(method); + } else { + moved.add(choice.member); + if (choice.member instanceof IMethod method) deleted.add(method); + } + } + processor.setMembersToMove(moved.toArray(IMember[]::new)); + processor.setAbstractMethods(declaredAbstract.toArray(IMethod[]::new)); + processor.setDeletedMethods(deleted.toArray(IMethod[]::new)); + processor.setDestinationType(destination); + processor.setCreateMethodStubs(true); + return run(processor, monitor); + } + + private static Object pushDown(IMember[] members, List choices, IProgressMonitor monitor) throws Exception { + var processor = new PushDownRefactoringProcessor(members); + var refactoring = new ProcessorBasedRefactoring(processor); + RefactoringStatus initial = refactoring.checkInitialConditions(monitor); + if (initial.hasFatalError()) return result(initial, null); + Map chosen = new LinkedHashMap<>(); + for (Choice choice : choices) chosen.put(choice.member, choice); + for (MemberActionInfo info : processor.getMemberActionInfos()) { + Choice choice = chosen.get(info.getMember()); + if (choice == null) info.setAction(MemberActionInfo.NO_ACTION); + else info.setAction(choice.makeAbstract && info.getMember() instanceof IMethod + ? MemberActionInfo.PUSH_ABSTRACT_ACTION : MemberActionInfo.PUSH_DOWN_ACTION); + } + RefactoringStatus status = refactoring.checkFinalConditions(monitor); + initial.merge(status); + return finish(refactoring, initial, monitor); + } + + private static Object run(RefactoringProcessor processor, IProgressMonitor monitor) throws Exception { + var refactoring = new ProcessorBasedRefactoring(processor); + RefactoringStatus status = refactoring.checkAllConditions(monitor); + return finish(refactoring, status, monitor); + } + + private static Object finish(ProcessorBasedRefactoring refactoring, RefactoringStatus status, IProgressMonitor monitor) + throws Exception { + cancelled(monitor); + if (status.hasFatalError()) return result(status, null); + Change change = refactoring.createChange(monitor); + cancelled(monitor); + return result(status, ChangeUtil.convertToWorkspaceEdit(change)); + } + + private static Map member(IMember member, boolean selected, boolean abstractable) { + Map row = new LinkedHashMap<>(); + row.put("id", member.getHandleIdentifier()); + row.put("label", JavaElementLabelsCore.getElementLabel(member, LABEL)); + row.put("kind", member instanceof IMethod ? "method" : member instanceof IField ? "field" : "type"); + row.put("selected", selected); + row.put("abstractable", abstractable); + return row; + } + + private static Map info(IType type, List> targets, List> members) { + Map out = new LinkedHashMap<>(); + out.put("className", type.getElementName()); + out.put("targets", targets); + out.put("members", members); + return out; + } + + private static Map result(RefactoringStatus status, Object edit) { + List> problems = new ArrayList<>(); + for (RefactoringStatusEntry entry : status.getEntries()) { + String severity = switch (entry.getSeverity()) { + case RefactoringStatus.FATAL -> "fatal"; + case RefactoringStatus.ERROR -> "error"; + case RefactoringStatus.WARNING -> "warning"; + default -> "info"; + }; + problems.add(Map.of("severity", severity, "message", entry.getMessage())); + } + Map out = new LinkedHashMap<>(); + out.put("problems", problems); + out.put("edit", edit); + return out; + } + + private static Map refused(String reason) { + return Map.of("refused", reason); + } + + private static void cancelled(IProgressMonitor monitor) { + if (monitor.isCanceled()) throw new OperationCanceledException(); + } +} diff --git a/plugins/java-usages/tests/hierarchy.py b/plugins/java-usages/tests/hierarchy.py new file mode 100644 index 00000000..7e0e4ad5 --- /dev/null +++ b/plugins/java-usages/tests/hierarchy.py @@ -0,0 +1,272 @@ +"""Pull Members Up / Push Members Down against a real JDT LS. Argument: jdtls home. + +Each case computes the edit from the same unchanged sources, applies it to a copy +of the project and compiles the copy with javac. +""" +import json +import pathlib +import platform +import queue +import shutil +import subprocess +import sys +import tempfile +import threading +import time +import urllib.parse + +home = pathlib.Path(sys.argv[1]).resolve() +bundle = pathlib.Path(__file__).resolve().parents[1] / 'openidea-java-usages.jar' +work = pathlib.Path(tempfile.mkdtemp(prefix='openidea-hierarchy-')) +config = work / 'config' +config.mkdir() +system, machine = platform.system(), platform.machine().lower() +config_name = {'Windows': 'config_win', 'Darwin': 'config_mac'}.get(system, 'config_linux') +config_names = ([config_name + '_arm'] if machine in ('arm64', 'aarch64') else []) + [config_name] +config_source = next(home / name / 'config.ini' for name in config_names if (home / name / 'config.ini').is_file()) +(config / 'config.ini').write_bytes(config_source.read_bytes()) +project = work / 'project' +(project / 'src/zoo').mkdir(parents=True) +(project / '.project').write_text('zooorg.eclipse.jdt.core.javabuilderorg.eclipse.jdt.core.javanature') +(project / '.classpath').write_text('') +sources = { + 'Animal.java': '''package zoo; + +public abstract class Animal { + protected String name = "animal"; + + public String describe() { + return "I am " + name; + } +} +''', + 'Speaker.java': '''package zoo; + +public interface Speaker { +} +''', + 'Dog.java': '''package zoo; + +public class Dog extends Animal implements Speaker { + private int tail = 1; + + public String bark() { + return "Woof"; + } + + public int wag() { + return tail * 2; + } +} +''', + 'Cat.java': '''package zoo; + +public class Cat extends Animal { + public String purr() { + return describe() + " purr"; + } +} +''', + 'Lonely.java': '''package zoo; + +public class Lonely { + public void alone() { + } +} +''', + 'Use.java': '''package zoo; + +public class Use { + String run(Dog dog, Animal animal) { + return dog.bark() + dog.wag() + animal.describe(); + } +} +''', +} +for name, text in sources.items(): + (project / 'src/zoo' / name).write_text(text) +uri = lambda name: (project / 'src/zoo' / name).as_uri() + +log = (work / 'stderr.log').open('w') +proc = subprocess.Popen(['java', '-Declipse.application=org.eclipse.jdt.ls.core.id1', '-Dosgi.bundles.defaultStartLevel=4', '-Declipse.product=org.eclipse.jdt.ls.core.product', '-Xmx1G', '--add-modules=ALL-SYSTEM', '--add-opens', 'java.base/java.util=ALL-UNNAMED', '--add-opens', 'java.base/java.lang=ALL-UNNAMED', '-jar', str(next((home / 'plugins').glob('org.eclipse.equinox.launcher_*.jar'))), '-configuration', str(config), '-data', str(work / 'metadata')], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log) +messages = queue.Queue() +lock = threading.Lock() + + +def send(message): + data = json.dumps(message).encode() + with lock: + proc.stdin.write(f'Content-Length: {len(data)}\r\n\r\n'.encode() + data) + proc.stdin.flush() + + +def read(): + while True: + headers = {} + while True: + line = proc.stdout.readline() + if not line: + return + if line == b'\r\n': + break + k, v = line.decode().split(':', 1) + headers[k.lower()] = v.strip() + message = json.loads(proc.stdout.read(int(headers['content-length']))) + if 'method' in message and 'id' in message: + send({'jsonrpc': '2.0', 'id': message['id'], 'result': [] if message['method'] == 'workspace/configuration' else None}) + else: + messages.put(message) + + +threading.Thread(target=read, daemon=True).start() +seq = 0 + + +def request(method, params): + global seq + seq += 1 + send({'jsonrpc': '2.0', 'id': seq, 'method': method, 'params': params}) + deadline = time.monotonic() + 180 + while time.monotonic() < deadline: + msg = messages.get(timeout=max(1, deadline - time.monotonic())) + if msg.get('id') == seq: + if 'error' in msg: + raise RuntimeError(msg['error']) + return msg.get('result') + raise TimeoutError(method) + + +def position(name, needle): + lines = sources[name].splitlines() + line = next(i for i, t in enumerate(lines) if needle in t) + return line, lines[line].index(needle) + + +def command(name, *args): + return request('workspace/executeCommand', {'command': name, 'arguments': list(args)}) + + +def members(file, needle, kind): + line, col = position(file, needle) + return command('openidea.java.hierarchy.members', uri(file), line, col, kind) + + +def refactor(file, needle, kind, target, choices): + line, col = position(file, needle) + return command('openidea.java.hierarchy.refactor', uri(file), line, col, kind, target, choices) + + +def offset(text, pos): + lines = text.split('\n') + return sum(len(l) + 1 for l in lines[:pos['line']]) + pos['character'] + + +def apply(edit): + """The project after `edit`, compiled; returns the edited sources.""" + per_file = {} + for u, edits in (edit.get('changes') or {}).items(): + per_file.setdefault(u, []).extend(edits) + for change in edit.get('documentChanges') or []: + assert 'kind' not in change, change + per_file.setdefault(change['textDocument']['uri'], []).extend(change['edits']) + out = dict(sources) + for u, edits in per_file.items(): + name = pathlib.Path(urllib.parse.unquote(urllib.parse.urlparse(u).path)).name + text = out[name] + for e in sorted(edits, key=lambda e: offset(text, e['range']['start']), reverse=True): + text = text[:offset(text, e['range']['start'])] + e['newText'] + text[offset(text, e['range']['end']):] + out[name] = text + copy = work / f'compile{seq}' + (copy / 'zoo').mkdir(parents=True) + for name, text in out.items(): + (copy / 'zoo' / name).write_text(text) + javac = subprocess.run(['javac', '-d', str(copy / 'bin'), *map(str, (copy / 'zoo').glob('*.java'))], capture_output=True, text=True) + assert javac.returncode == 0, (javac.stderr, out) + return out + + +def body(text, name): + return [l.strip() for l in text.splitlines() if name in l] + + +try: + init = request('initialize', {'processId': None, 'rootUri': project.as_uri(), 'workspaceFolders': [{'uri': project.as_uri(), 'name': 'zoo'}], 'capabilities': {'workspace': {'workspaceEdit': {'documentChanges': True}}}, 'initializationOptions': {'bundles': [str(bundle)], 'settings': {'java': {'import': {'gradle': {'enabled': False}, 'maven': {'enabled': False}}}}}}) + commands = init['capabilities']['executeCommandProvider']['commands'] + assert 'openidea.java.hierarchy.members' in commands and 'openidea.java.hierarchy.refactor' in commands, commands + send({'jsonrpc': '2.0', 'method': 'initialized', 'params': {}}) + for name, text in sources.items(): + send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': uri(name), 'languageId': 'java', 'version': 1, 'text': text}}}) + request('java/buildWorkspace', True) + evidence = {} + + # Members of Dog, with bark() preselected; Animal and Speaker are destinations. + info = members('Dog.java', 'bark', 'pullUp') + evidence['pullUpMembers'] = info + assert info['className'] == 'Dog', info + assert {t['name'] for t in info['targets']} == {'Animal', 'Speaker'}, info['targets'] + rows = {m['label'].split('(')[0].split(':')[0].strip(): m for m in info['members']} + assert rows['bark']['selected'] and not rows['wag']['selected'] and not rows['tail']['selected'], rows + assert rows['bark']['abstractable'] and rows['bark']['kind'] == 'method' and rows['tail']['kind'] == 'field', rows + animal = next(t for t in info['targets'] if t['name'] == 'Animal') + speaker = next(t for t in info['targets'] if t['name'] == 'Speaker') + + # Caret on the class name: nothing preselected. + on_class = members('Dog.java', 'class Dog', 'pullUp') + assert on_class['className'] == 'Dog' and not any(m['selected'] for m in on_class['members']), on_class + + # Pull bark() up to Animal: moved, call sites unchanged, compiles. + result = refactor('Dog.java', 'bark', 'pullUp', animal['id'], [{'id': rows['bark']['id'], 'abstract': False}]) + evidence['pullUp'] = result + assert not [p for p in result['problems'] if p['severity'] in ('error', 'fatal')], result['problems'] + out = apply(result['edit']) + assert 'bark()' in out['Animal.java'] and 'bark()' not in out['Dog.java'], out + assert out['Use.java'] == sources['Use.java'] + + # Make abstract: declared in Animal, kept (and overridden) in Dog. + result = refactor('Dog.java', 'bark', 'pullUp', animal['id'], [{'id': rows['bark']['id'], 'abstract': True}]) + evidence['pullUpAbstract'] = result + out = apply(result['edit']) + assert any('abstract' in l for l in body(out['Animal.java'], 'bark()')), out['Animal.java'] + assert 'Woof' in out['Dog.java'] and '@Override' in out['Dog.java'], out['Dog.java'] + + # To an interface, a method is always declared, never moved. + result = refactor('Dog.java', 'bark', 'pullUp', speaker['id'], [{'id': rows['bark']['id'], 'abstract': False}]) + evidence['pullUpInterface'] = result + out = apply(result['edit']) + assert body(out['Speaker.java'], 'bark()') == ['String bark();'] or body(out['Speaker.java'], 'bark()') == ['public String bark();'], out['Speaker.java'] + assert 'Woof' in out['Dog.java'] and '@Override' in out['Dog.java'], out['Dog.java'] + + # wag() uses Dog's private field: reported before anything changes. + result = refactor('Dog.java', 'wag', 'pullUp', animal['id'], [{'id': rows['wag']['id'], 'abstract': False}]) + evidence['pullUpConflict'] = result + assert any(p['severity'] in ('error', 'fatal') and 'tail' in p['message'] for p in result['problems']), result + + # No superclass in the sources. + refused = members('Lonely.java', 'alone', 'pullUp') + evidence['pullUpRefused'] = refused + assert 'refused' in refused, refused + + # Push describe() down: copied into Dog and Cat, removed from Animal. + info = members('Animal.java', 'describe', 'pushDown') + evidence['pushDownMembers'] = info + assert info['className'] == 'Animal' and {t['name'] for t in info['targets']} == {'Dog', 'Cat'}, info + rows = {m['label'].split('(')[0].split(':')[0].strip(): m for m in info['members']} + assert rows['describe']['selected'] and rows['describe']['abstractable'] and not rows['name']['abstractable'], rows + result = refactor('Animal.java', 'describe', 'pushDown', None, [{'id': rows['describe']['id'], 'abstract': True}]) + evidence['pushDownAbstract'] = result + assert not [p for p in result['problems'] if p['severity'] in ('error', 'fatal')], result['problems'] + out = apply(result['edit']) + assert any('abstract' in l for l in body(out['Animal.java'], 'describe()')), out['Animal.java'] + assert 'I am' in out['Dog.java'] and 'I am' in out['Cat.java'], out + + # Without keeping a declaration, Use's animal.describe() breaks: JDT reports it. + result = refactor('Animal.java', 'describe', 'pushDown', None, [{'id': rows['describe']['id'], 'abstract': False}]) + evidence['pushDownConflict'] = result + assert any(p['severity'] in ('error', 'fatal') for p in result['problems']), result + + (work / 'evidence.json').write_text(json.dumps(evidence, indent=2)) + print('hierarchy: all cases passed') +finally: + proc.terminate() + proc.wait(timeout=20) + print('Evidence:', work, file=sys.stderr) diff --git a/src-tauri/src/commands/lsp.rs b/src-tauri/src/commands/lsp.rs index 0092cdcf..1db210fa 100644 --- a/src-tauri/src/commands/lsp.rs +++ b/src-tauri/src/commands/lsp.rs @@ -240,6 +240,38 @@ pub async fn lsp_rename( ide_host::ops::lsp::lsp_rename(&state.host, window.label(), request, doc, pos, new_name).await } +/// The class at `pos` and the members Pull Members Up / Push Members Down can move. +#[tauri::command] +#[specta::specta] +pub async fn lsp_hierarchy_members( + window: Window, + state: State<'_, AppState>, + request: RequestId, + doc: DocId, + pos: Pos, + kind: HierarchyRefactoring, +) -> IdeResult { + ide_host::ops::lsp::lsp_hierarchy_members(&state.host, window.label(), request, doc, pos, kind).await +} + +/// Checks Pull Members Up / Push Members Down and computes the edit without applying it. +#[tauri::command] +#[specta::specta] +#[allow(clippy::too_many_arguments)] +pub async fn lsp_hierarchy_refactor( + window: Window, + state: State<'_, AppState>, + request: RequestId, + doc: DocId, + pos: Pos, + kind: HierarchyRefactoring, + target: Option, + choices: Vec, +) -> IdeResult { + ide_host::ops::lsp::lsp_hierarchy_refactor(&state.host, window.label(), request, doc, pos, kind, target, choices) + .await +} + #[tauri::command] #[specta::specta] pub async fn lsp_format( diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2c59878d..20884ff9 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -221,6 +221,8 @@ pub fn builder() -> tauri_specta::Builder { lsp::lsp_apply_code_action, lsp::lsp_prepare_rename, lsp::lsp_rename, + lsp::lsp_hierarchy_members, + lsp::lsp_hierarchy_refactor, lsp::lsp_format, lsp::lsp_inlay_hints, lsp::lsp_semantic_tokens, diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 57191232..f85412ef 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -542,6 +542,9 @@ pub fn spec(os: Os) -> Vec { act("refactor.extractMethod", "Extract Method…"), act("refactor.inline", "Inline…"), SEP, + act("refactor.pullMembersUp", "Pull Members Up…"), + act("refactor.pushMembersDown", "Push Members Down…"), + SEP, act("refactor.move", "Move…"), act("project.copyElement", "Copy…"), act("refactor.safeDelete", "Safe Delete…"), diff --git a/src/bindings.ts b/src/bindings.ts index 1a629c7a..5d09fc49 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -418,6 +418,10 @@ export const commands = { } | null, IdeError>(__TAURI_INVOKE("lsp_prepare_rename", { doc, pos })), /** Computes the rename edit without applying it, so the UI can preview conflicts. */ lspRename: (request: RequestId, doc: DocId, pos: Pos, newName: string) => typedError(__TAURI_INVOKE("lsp_rename", { request, doc, pos, newName })), + /** The class at `pos` and the members Pull Members Up / Push Members Down can move. */ + lspHierarchyMembers: (request: RequestId, doc: DocId, pos: Pos, kind: HierarchyRefactoring) => typedError(__TAURI_INVOKE("lsp_hierarchy_members", { request, doc, pos, kind })), + /** Checks Pull Members Up / Push Members Down and computes the edit without applying it. */ + lspHierarchyRefactor: (request: RequestId, doc: DocId, pos: Pos, kind: HierarchyRefactoring, target: string | null, choices: HierarchyChoice[]) => typedError(__TAURI_INVOKE("lsp_hierarchy_refactor", { request, doc, pos, kind, target, choices })), lspFormat: (doc: DocId, range: { start: Pos, end: Pos, @@ -3822,6 +3826,19 @@ export type GradleTask = { description: string | null, }; +export type HierarchyChoice = { + id: string, + makeAbstract: boolean, +}; + +export type HierarchyClass = { + /** Server-owned identity, passed back as the pull-up destination. */ + id: string, + name: string, + qualifiedName: string, + interface: boolean, +}; + export type HierarchyDirection = /** Callers / supertypes. */ "incoming" | @@ -3842,6 +3859,38 @@ export type HierarchyItem = { fromRanges: Range[], }; +export type HierarchyMember = { + id: string, + /** `name(Params) : Type`, as the server renders it. */ + label: string, + kind: HierarchyMemberKind, + /** The member at the caret. */ + selected: boolean, + /** Can be declared abstract in the destination (pull up) or kept abstract (push down). */ + abstractable: boolean, +}; + +export type HierarchyMemberKind = "field" | "method" | "type"; + +/** + * The class at a position and what can move: pull-up destinations, or the subclasses + * members are pushed into. + */ +export type HierarchyMembers = { + className: string, + targets: HierarchyClass[], + members: HierarchyMember[], +}; + +/** A checked refactoring: what the server found, and the edit unless a problem is fatal. */ +export type HierarchyRefactored = { + problems: RefactoringProblem[], + edit: WorkspaceEdit | null, +}; + +/** IDEA's class-hierarchy refactorings. */ +export type HierarchyRefactoring = "pullUp" | "pushDown"; + export type Highlight = { range: Range, kind: HighlightKind, @@ -5368,6 +5417,10 @@ export type PreparedRequest = { body: string | null, }; +export type ProblemSeverity = "info" | "warning" | "error" | +/** The refactoring can't run at all; there is no edit. */ +"fatal"; + /** Memory of one row of the breakdown. */ export type ProcessMemory = { category: MemoryCategory, @@ -5624,6 +5677,11 @@ export type RecentProject = { branch: string | null, }; +export type RefactoringProblem = { + severity: ProblemSeverity, + message: string, +}; + export type ReferenceAccess = "read" | "write" | "readWrite" | "unknown"; export type ReferenceKind = "getter" | "setter" | "field" | "unknown"; diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index ced5ea78..05f12416 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -716,6 +716,8 @@ export class EditorController implements SlotHost { { kind: "action", action: "refactor.extractConstant" }, { kind: "action", action: "refactor.extractMethod" }, { kind: "action", action: "refactor.inline" }, + { kind: "action", action: "refactor.pullMembersUp" }, + { kind: "action", action: "refactor.pushMembersDown" }, { kind: "separator" }, { kind: "action", action: "refactor.safeDelete" }, ], diff --git a/src/editor/lsp/actions.ts b/src/editor/lsp/actions.ts index 2765aca5..40cc2cfb 100644 --- a/src/editor/lsp/actions.ts +++ b/src/editor/lsp/actions.ts @@ -19,7 +19,13 @@ import { workflowSettings, type ActionsOnSaveValid } from "@/platform/workflowSe import { startRename } from "./rename"; import { formatDocument, reformatDialog } from "./format"; import { showHint } from "./hint"; -import { refactorThis, runRefactoring, safeDelete, showNothingHere } from "@/editor/refactor"; +import { + refactorThis, + runRefactoring, + safeDelete, + showNothingHere, + startHierarchyRefactoring, +} from "@/editor/refactor"; import { evaluateWhen } from "@/platform/context"; /** Editor actions apply to the active text editor unless a tool window or popup owns the keyboard. */ @@ -314,6 +320,12 @@ export function registerLspActions(): () => void { { keyWhen: GENERATE_KEY_WHEN }, ), editorAction("refactor.inline", "Inline…", "Refactor", (v) => runRefactoring(v, "inline")), + editorAction("refactor.pullMembersUp", "Pull Members Up…", "Refactor", (v) => + startHierarchyRefactoring(v, "pullUp"), + ), + editorAction("refactor.pushMembersDown", "Push Members Down…", "Refactor", (v) => + startHierarchyRefactoring(v, "pushDown"), + ), // Implement / Override Methods: the server's code actions for them (jdtls "Override/Implement // Methods…", rust-analyzer "Implement missing members", TS "Implement interface"…). editorAction("code.implementMethods", "Implement Methods…", "Code", (v) => diff --git a/src/editor/lsp/index.ts b/src/editor/lsp/index.ts index 0b8cc1f1..8816b715 100644 --- a/src/editor/lsp/index.ts +++ b/src/editor/lsp/index.ts @@ -39,6 +39,7 @@ import { DOCUMENTATION_WINDOW } from "./docs"; import { prefetchMarkdown } from "./markdown"; import { outsideProjectBanner } from "./outsideProject"; import { implementationGutter, registerNavigationActions } from "@/editor/navigation"; +import { HierarchyDialog } from "@/editor/refactor"; export function lspExtensions(ctx: EditorDocContext): Extension[] { return [ @@ -74,6 +75,7 @@ export function activate(): () => void { // After the templates (order 90): live template / postfix expansion keeps ⇥ first. registerEditorContribution({ id: "lsp.parameterTab", order: 95, extensions: () => parameterTabKeys }), registerOverlay({ id: "editor-lsp.popups", component: LspPopups }), + registerOverlay({ id: "editor-lsp.hierarchyDialog", component: HierarchyDialog }), registerEditorBanner(outsideProjectBanner), registerStatusItem({ id: "editor-lsp.usagesStatus", align: "left", priority: 10, component: UsagesStatus }), // IDEA's Documentation tool window: right side, listed once F1 moved documentation there. diff --git a/src/editor/refactor/HierarchyDialog.module.css b/src/editor/refactor/HierarchyDialog.module.css new file mode 100644 index 00000000..67aabcd9 --- /dev/null +++ b/src/editor/refactor/HierarchyDialog.module.css @@ -0,0 +1,80 @@ +.body { + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px; + font-size: var(--fs-ui); + color: var(--fg); +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.targets { + color: var(--fg-muted); + overflow-wrap: anywhere; +} + +.table { + display: flex; + flex-direction: column; + border: 1px solid var(--border); + border-radius: 4px; + min-height: 0; +} + +.head, +.row { + display: grid; + grid-template-columns: 1fr 110px; + align-items: center; + gap: 8px; + padding: 0 8px; + min-height: var(--h-list-row); +} + +.head { + color: var(--fg-muted); + border-bottom: 1px solid var(--border); +} + +.rows { + max-height: 280px; + overflow: auto; +} + +.row:hover { + background: var(--bg-hover); +} + +.member { + min-width: 0; +} + +.label { + font-family: var(--font-mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.hint { + color: var(--fg-subtle); + font-size: var(--fs-small); +} + +.error { + color: var(--danger); + font-size: var(--fs-small); + overflow-wrap: anywhere; +} + +.busy { + display: flex; + align-items: center; + gap: 6px; + color: var(--fg-muted); +} diff --git a/src/editor/refactor/HierarchyDialog.tsx b/src/editor/refactor/HierarchyDialog.tsx new file mode 100644 index 00000000..97a0601c --- /dev/null +++ b/src/editor/refactor/HierarchyDialog.tsx @@ -0,0 +1,143 @@ +/** IDEA's Pull Members Up and Push Members Down dialogs (`hierarchy.ts`). */ +import { useState } from "react"; +import { Checkbox, Dialog, Select, Spinner, T } from "@/ui"; +import { useTx } from "@/platform/i18n"; +import { + abstractForced, + choicesOf, + initialRows, + refactorHierarchy, + titleOf, + useHierarchyDialog, + type HierarchyRequest, + type MemberRow, +} from "./hierarchy"; +import styles from "./HierarchyDialog.module.css"; + +export function HierarchyDialog() { + const request = useHierarchyDialog((s) => s.request); + if (!request) return null; + return ; +} + +function HierarchyDialogInner({ request }: { request: HierarchyRequest }) { + const tx = useTx(); + const { kind, info } = request; + const pullUp = kind === "pullUp"; + const [target, setTarget] = useState(info.targets[0]?.id ?? null); + const [rows, setRows] = useState(() => initialRows(info)); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const toInterface = pullUp && !!info.targets.find((t) => t.id === target)?.interface; + const ready = rows.some((r) => r.checked) && (!pullUp || !!target) && !busy; + + const close = () => { + useHierarchyDialog.setState({ request: null }); + request.view.focus(); + }; + const update = (id: string, change: Partial) => + setRows((all) => all.map((r) => (r.id === id ? { ...r, ...change } : r))); + const submit = async (preview: boolean) => { + if (!ready) return; + setBusy(true); + setError(null); + const outcome = await refactorHierarchy( + request, + pullUp ? target : null, + choicesOf(rows, kind, toInterface), + preview, + ); + if (useHierarchyDialog.getState().request !== request) return; + setBusy(false); + if (outcome.status === "done") close(); + else if (outcome.error) setError(outcome.error); + }; + + return ( + void submit(true) }, + { label: "Refactor", role: "default", disabled: !ready, onClick: () => void submit(false) }, + { label: "Cancel", role: "cancel" }, + ]} + footerStart={ + busy ? ( + + + Checking for conflicts… + + ) : undefined + } + > +
+ {pullUp ? ( +