feat(refactor): Java Pull Members Up / Push Members Down (#1183)

Refactor | Pull Members Up… and Push Members Down… for Java, from the Refactor menu, the editor context menu and Refactor This. A dialog picks the destination and the members (Make abstract / Keep abstract); problems are confirmed before anything changes; the edit is applied as one change or previewed first.

JDT LS offers neither as a code action, so the app-owned JDT LS bundle (plugins/java-usages 1.3.0) adds two delegate commands that run JDT's PullUpRefactoringProcessor / PushDownRefactoringProcessor and return the problems and the edit without applying it, exposed as lsp_hierarchy_members / lsp_hierarchy_refactor (also over the remote host dispatcher). The bundled jar was rebuilt from source with JDT LS 1.63.0 and matches the recorded SHA-256.

Co-authored-by: fhrmrk <81476595+fhrmrk@users.noreply.github.com>
This commit is contained in:
Park Jong Hyun
2026-11-04 14:55:42 +08:00
committed by GitHub
parent 5b46844f88
commit ed145e3046
39 changed files with 2008 additions and 14 deletions
+66
View File
@@ -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<HierarchyMembers> {
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<String>,
choices: Vec<HierarchyChoice>,
) -> IdeResult<HierarchyRefactored> {
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
});
+1
View File
@@ -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.
+101
View File
@@ -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<T> {
Refused { refused: String },
Ok(T),
}
impl<T> Answer<T> {
fn into_result(self) -> IdeResult<T> {
match self {
Answer::Refused { refused } => Err(IdeError::invalid(refused)),
Answer::Ok(value) => Ok(value),
}
}
}
#[derive(Deserialize)]
struct Refactored {
problems: Vec<RefactoringProblem>,
edit: Option<lsp_types::WorkspaceEdit>,
}
fn feature(kind: HierarchyRefactoring) -> &'static str {
match kind {
HierarchyRefactoring::PullUp => "Pull Members Up",
HierarchyRefactoring::PushDown => "Push Members Down",
}
}
impl LspManager {
async fn hierarchy_command<R: DeserializeOwned>(
&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<R> =
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<HierarchyMembers> {
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<String>,
choices: Vec<HierarchyChoice>,
cancel: CancellationToken,
) -> IdeResult<HierarchyRefactored> {
let choices: Vec<Value> = 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)),
})
}
}
+81
View File
@@ -389,6 +389,87 @@ pub struct Renamed {
pub incomplete: Option<ReferencesIncomplete>,
}
/// 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<HierarchyClass>,
pub members: Vec<HierarchyMember>,
}
#[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<RefactoringProblem>,
pub edit: Option<WorkspaceEdit>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CallHierarchy {
+87
View File
@@ -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");
}
+1 -1
View File
@@ -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<PathBuf> {
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);
+172
View File
@@ -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);
});
+3 -2
View File
@@ -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
+27 -1
View File
@@ -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.
Binary file not shown.
+4
View File
@@ -7,5 +7,9 @@
<delegateCommandHandler class="ai.openidea.configuration.ConfigurationCommand">
<command id="openidea.java.configuration"/>
</delegateCommandHandler>
<delegateCommandHandler class="ai.openidea.hierarchy.HierarchyCommand">
<command id="openidea.java.hierarchy.members"/>
<command id="openidea.java.hierarchy.refactor"/>
</delegateCommandHandler>
</extension>
</plugin>
@@ -0,0 +1,261 @@
package ai.openidea.hierarchy;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.OperationCanceledException;
import org.eclipse.jdt.core.*;
import org.eclipse.jdt.internal.core.manipulation.JavaElementLabelsCore;
import org.eclipse.jdt.internal.corext.refactoring.RefactoringAvailabilityTesterCore;
import org.eclipse.jdt.internal.corext.refactoring.structure.PullUpRefactoringProcessor;
import org.eclipse.jdt.internal.corext.refactoring.structure.PushDownRefactoringProcessor;
import org.eclipse.jdt.internal.corext.refactoring.structure.PushDownRefactoringProcessor.MemberActionInfo;
import org.eclipse.jdt.internal.corext.util.JdtFlags;
import org.eclipse.jdt.ls.core.internal.ChangeUtil;
import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler;
import org.eclipse.jdt.ls.core.internal.JDTUtils;
import org.eclipse.jdt.ls.core.internal.handlers.JsonRpcHelpers;
import org.eclipse.jdt.ls.core.internal.preferences.PreferenceManager;
import org.eclipse.ltk.core.refactoring.Change;
import org.eclipse.ltk.core.refactoring.RefactoringStatus;
import org.eclipse.ltk.core.refactoring.RefactoringStatusEntry;
import org.eclipse.ltk.core.refactoring.participants.ProcessorBasedRefactoring;
import org.eclipse.ltk.core.refactoring.participants.RefactoringProcessor;
/**
* Pull Members Up / Push Members Down with JDT's own processors.
*
* <p>{@code openidea.java.hierarchy.members(uri, line, column, kind)} describes the class at the
* position: its movable members and, for pull up, the supertypes in project sources.
* {@code openidea.java.hierarchy.refactor(uri, line, column, kind, target, choices)} checks the
* chosen move and returns its problems and the edit, which is never applied here. {@code kind}
* is {@code pullUp} or {@code pushDown}; {@code choices} are {@code {id, abstract}} objects whose
* ids are the handle identifiers returned by {@code members}.
*/
public final class HierarchyCommand implements IDelegateCommandHandler {
static final String MEMBERS = "openidea.java.hierarchy.members";
static final String REFACTOR = "openidea.java.hierarchy.refactor";
private static final long LABEL = JavaElementLabelsCore.M_PARAMETER_TYPES | JavaElementLabelsCore.M_APP_RETURNTYPE
| JavaElementLabelsCore.F_APP_TYPE_SIGNATURE;
@Override
public Object executeCommand(String command, List<Object> arguments, IProgressMonitor monitor) throws Exception {
String uri = (String) arguments.get(0);
int line = ((Number) arguments.get(1)).intValue();
int column = ((Number) arguments.get(2)).intValue();
boolean pullUp = switch ((String) arguments.get(3)) {
case "pullUp" -> true;
case "pushDown" -> false;
default -> throw new IllegalArgumentException("unknown hierarchy refactoring " + arguments.get(3));
};
ICompilationUnit unit = JDTUtils.resolveCompilationUnit(uri);
if (unit == null || !unit.exists()) return refused("The file is not part of a Java project");
IMember start = memberAt(unit, JsonRpcHelpers.toOffset(unit, line, column));
IMember[] members = start == null ? null : selection(start, pullUp);
if (members == null) {
return refused(pullUp ? "Caret should be inside a class that has members to pull up"
: "Caret should be inside a class that has members to push down");
}
if (MEMBERS.equals(command)) return pullUp ? pullUpMembers(unit, members, monitor) : pushDownMembers(members, monitor);
if (REFACTOR.equals(command)) {
String target = (String) arguments.get(4);
List<Choice> choices = new ArrayList<>();
for (Object o : (List<?>) arguments.get(5)) {
Map<?, ?> map = (Map<?, ?>) o;
IJavaElement element = JavaCore.create((String) map.get("id"));
if (!(element instanceof IMember member) || !member.exists()) return refused("The class changed; open the dialog again");
choices.add(new Choice(member, Boolean.TRUE.equals(map.get("abstract"))));
}
if (choices.isEmpty()) return refused("No members selected");
// Started from the class, the processors know it even when no member is moved
// (a method only declared abstract in the destination).
IType declaring = members[0] instanceof IType type ? type : members[0].getDeclaringType();
IMember[] owner = { declaring };
return pullUp ? pullUp(unit, owner, target, choices, monitor) : pushDown(owner, choices, monitor);
}
throw new IllegalArgumentException("unknown command " + command);
}
private record Choice(IMember member, boolean makeAbstract) {}
/** The innermost member at the offset, or the type itself for an initializer. */
private static IMember memberAt(ICompilationUnit unit, int offset) throws JavaModelException {
IJavaElement element = unit.getElementAt(offset);
while (element != null && !(element instanceof IMember)) element = element.getParent();
if (element instanceof IInitializer initializer) return initializer.getDeclaringType();
return (IMember) element;
}
/** The members to start from: the one at the caret when it can move, otherwise its class. */
private static IMember[] selection(IMember start, boolean pullUp) throws JavaModelException {
for (IMember member = start; member != null; member = member.getDeclaringType()) {
if (member.isBinary() || member.isReadOnly()) return null;
if (pullUp ? RefactoringAvailabilityTesterCore.isPullUpAvailable(member)
: RefactoringAvailabilityTesterCore.isPushDownAvailable(member)) return new IMember[] { member };
if (member instanceof IType type && (pullUp ? RefactoringAvailabilityTesterCore.getPullUpMembers(type)
: RefactoringAvailabilityTesterCore.getPushDownMembers(type)).length > 0) {
return new IMember[] { type };
}
}
return null;
}
private static Object pullUpMembers(ICompilationUnit unit, IMember[] members, IProgressMonitor monitor) throws Exception {
var processor = new PullUpRefactoringProcessor(members, PreferenceManager.getCodeGenerationSettings(unit));
RefactoringStatus status = new ProcessorBasedRefactoring(processor).checkInitialConditions(monitor);
if (status.hasFatalError()) return refused(status.getMessageMatchingSeverity(RefactoringStatus.FATAL));
IType[] candidates = processor.getCandidateTypes(new RefactoringStatus(), monitor);
cancelled(monitor);
if (candidates.length == 0) {
return refused("'" + processor.getDeclaringType().getElementName()
+ "' has no superclass or interface in the project sources to pull members up to");
}
List<Map<String, Object>> targets = new ArrayList<>();
for (IType type : candidates) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("id", type.getHandleIdentifier());
row.put("name", type.getElementName());
row.put("qualifiedName", type.getFullyQualifiedName('.'));
row.put("interface", type.isInterface());
targets.add(row);
}
Set<IMember> selected = Set.of(processor.getMembersToMove());
List<Map<String, Object>> rows = new ArrayList<>();
for (IMember member : processor.getPullableMembersOfDeclaringType()) {
boolean abstractable = member instanceof IMethod method && !JdtFlags.isStatic(method);
rows.add(member(member, selected.contains(member), abstractable));
}
return info(processor.getDeclaringType(), targets, rows);
}
private static Object pushDownMembers(IMember[] members, IProgressMonitor monitor) throws Exception {
var processor = new PushDownRefactoringProcessor(members);
RefactoringStatus status = new ProcessorBasedRefactoring(processor).checkInitialConditions(monitor);
if (status.hasFatalError()) return refused(status.getMessageMatchingSeverity(RefactoringStatus.FATAL));
IType declaring = processor.getDeclaringType();
List<Map<String, Object>> targets = new ArrayList<>();
for (IType type : declaring.newTypeHierarchy(declaring.getJavaProject(), monitor).getSubtypes(declaring)) {
if (type.isBinary()) continue;
Map<String, Object> row = new LinkedHashMap<>();
row.put("id", type.getHandleIdentifier());
row.put("name", type.getElementName());
row.put("qualifiedName", type.getFullyQualifiedName('.'));
row.put("interface", type.isInterface());
targets.add(row);
}
cancelled(monitor);
List<Map<String, Object>> rows = new ArrayList<>();
for (MemberActionInfo info : processor.getMemberActionInfos()) {
boolean abstractable = false;
for (int action : info.getAvailableActions()) abstractable |= action == MemberActionInfo.PUSH_ABSTRACT_ACTION;
rows.add(member(info.getMember(), info.isToBePushedDown(), abstractable));
}
return info(declaring, targets, rows);
}
private static Object pullUp(ICompilationUnit unit, IMember[] members, String target, List<Choice> choices,
IProgressMonitor monitor) throws Exception {
if (!(JavaCore.create(target) instanceof IType destination) || !destination.exists()) {
return refused("The destination class no longer exists");
}
var processor = new PullUpRefactoringProcessor(members, PreferenceManager.getCodeGenerationSettings(unit));
List<IMember> moved = new ArrayList<>();
List<IMethod> declaredAbstract = new ArrayList<>();
List<IMethod> deleted = new ArrayList<>();
for (Choice choice : choices) {
// A method pulled up to an interface is declared there, as JDT's own wizard does.
if (choice.member instanceof IMethod method && (choice.makeAbstract || destination.isInterface())) {
declaredAbstract.add(method);
} else {
moved.add(choice.member);
if (choice.member instanceof IMethod method) deleted.add(method);
}
}
processor.setMembersToMove(moved.toArray(IMember[]::new));
processor.setAbstractMethods(declaredAbstract.toArray(IMethod[]::new));
processor.setDeletedMethods(deleted.toArray(IMethod[]::new));
processor.setDestinationType(destination);
processor.setCreateMethodStubs(true);
return run(processor, monitor);
}
private static Object pushDown(IMember[] members, List<Choice> choices, IProgressMonitor monitor) throws Exception {
var processor = new PushDownRefactoringProcessor(members);
var refactoring = new ProcessorBasedRefactoring(processor);
RefactoringStatus initial = refactoring.checkInitialConditions(monitor);
if (initial.hasFatalError()) return result(initial, null);
Map<IMember, Choice> chosen = new LinkedHashMap<>();
for (Choice choice : choices) chosen.put(choice.member, choice);
for (MemberActionInfo info : processor.getMemberActionInfos()) {
Choice choice = chosen.get(info.getMember());
if (choice == null) info.setAction(MemberActionInfo.NO_ACTION);
else info.setAction(choice.makeAbstract && info.getMember() instanceof IMethod
? MemberActionInfo.PUSH_ABSTRACT_ACTION : MemberActionInfo.PUSH_DOWN_ACTION);
}
RefactoringStatus status = refactoring.checkFinalConditions(monitor);
initial.merge(status);
return finish(refactoring, initial, monitor);
}
private static Object run(RefactoringProcessor processor, IProgressMonitor monitor) throws Exception {
var refactoring = new ProcessorBasedRefactoring(processor);
RefactoringStatus status = refactoring.checkAllConditions(monitor);
return finish(refactoring, status, monitor);
}
private static Object finish(ProcessorBasedRefactoring refactoring, RefactoringStatus status, IProgressMonitor monitor)
throws Exception {
cancelled(monitor);
if (status.hasFatalError()) return result(status, null);
Change change = refactoring.createChange(monitor);
cancelled(monitor);
return result(status, ChangeUtil.convertToWorkspaceEdit(change));
}
private static Map<String, Object> member(IMember member, boolean selected, boolean abstractable) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("id", member.getHandleIdentifier());
row.put("label", JavaElementLabelsCore.getElementLabel(member, LABEL));
row.put("kind", member instanceof IMethod ? "method" : member instanceof IField ? "field" : "type");
row.put("selected", selected);
row.put("abstractable", abstractable);
return row;
}
private static Map<String, Object> info(IType type, List<Map<String, Object>> targets, List<Map<String, Object>> members) {
Map<String, Object> out = new LinkedHashMap<>();
out.put("className", type.getElementName());
out.put("targets", targets);
out.put("members", members);
return out;
}
private static Map<String, Object> result(RefactoringStatus status, Object edit) {
List<Map<String, Object>> problems = new ArrayList<>();
for (RefactoringStatusEntry entry : status.getEntries()) {
String severity = switch (entry.getSeverity()) {
case RefactoringStatus.FATAL -> "fatal";
case RefactoringStatus.ERROR -> "error";
case RefactoringStatus.WARNING -> "warning";
default -> "info";
};
problems.add(Map.of("severity", severity, "message", entry.getMessage()));
}
Map<String, Object> out = new LinkedHashMap<>();
out.put("problems", problems);
out.put("edit", edit);
return out;
}
private static Map<String, Object> refused(String reason) {
return Map.of("refused", reason);
}
private static void cancelled(IProgressMonitor monitor) {
if (monitor.isCanceled()) throw new OperationCanceledException();
}
}
+272
View File
@@ -0,0 +1,272 @@
"""Pull Members Up / Push Members Down against a real JDT LS. Argument: jdtls home.
Each case computes the edit from the same unchanged sources, applies it to a copy
of the project and compiles the copy with javac.
"""
import json
import pathlib
import platform
import queue
import shutil
import subprocess
import sys
import tempfile
import threading
import time
import urllib.parse
home = pathlib.Path(sys.argv[1]).resolve()
bundle = pathlib.Path(__file__).resolve().parents[1] / 'openidea-java-usages.jar'
work = pathlib.Path(tempfile.mkdtemp(prefix='openidea-hierarchy-'))
config = work / 'config'
config.mkdir()
system, machine = platform.system(), platform.machine().lower()
config_name = {'Windows': 'config_win', 'Darwin': 'config_mac'}.get(system, 'config_linux')
config_names = ([config_name + '_arm'] if machine in ('arm64', 'aarch64') else []) + [config_name]
config_source = next(home / name / 'config.ini' for name in config_names if (home / name / 'config.ini').is_file())
(config / 'config.ini').write_bytes(config_source.read_bytes())
project = work / 'project'
(project / 'src/zoo').mkdir(parents=True)
(project / '.project').write_text('<projectDescription><name>zoo</name><buildSpec><buildCommand><name>org.eclipse.jdt.core.javabuilder</name></buildCommand></buildSpec><natures><nature>org.eclipse.jdt.core.javanature</nature></natures></projectDescription>')
(project / '.classpath').write_text('<classpath><classpathentry kind="src" path="src"/><classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER"/><classpathentry kind="output" path="bin"/></classpath>')
sources = {
'Animal.java': '''package zoo;
public abstract class Animal {
protected String name = "animal";
public String describe() {
return "I am " + name;
}
}
''',
'Speaker.java': '''package zoo;
public interface Speaker {
}
''',
'Dog.java': '''package zoo;
public class Dog extends Animal implements Speaker {
private int tail = 1;
public String bark() {
return "Woof";
}
public int wag() {
return tail * 2;
}
}
''',
'Cat.java': '''package zoo;
public class Cat extends Animal {
public String purr() {
return describe() + " purr";
}
}
''',
'Lonely.java': '''package zoo;
public class Lonely {
public void alone() {
}
}
''',
'Use.java': '''package zoo;
public class Use {
String run(Dog dog, Animal animal) {
return dog.bark() + dog.wag() + animal.describe();
}
}
''',
}
for name, text in sources.items():
(project / 'src/zoo' / name).write_text(text)
uri = lambda name: (project / 'src/zoo' / name).as_uri()
log = (work / 'stderr.log').open('w')
proc = subprocess.Popen(['java', '-Declipse.application=org.eclipse.jdt.ls.core.id1', '-Dosgi.bundles.defaultStartLevel=4', '-Declipse.product=org.eclipse.jdt.ls.core.product', '-Xmx1G', '--add-modules=ALL-SYSTEM', '--add-opens', 'java.base/java.util=ALL-UNNAMED', '--add-opens', 'java.base/java.lang=ALL-UNNAMED', '-jar', str(next((home / 'plugins').glob('org.eclipse.equinox.launcher_*.jar'))), '-configuration', str(config), '-data', str(work / 'metadata')], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log)
messages = queue.Queue()
lock = threading.Lock()
def send(message):
data = json.dumps(message).encode()
with lock:
proc.stdin.write(f'Content-Length: {len(data)}\r\n\r\n'.encode() + data)
proc.stdin.flush()
def read():
while True:
headers = {}
while True:
line = proc.stdout.readline()
if not line:
return
if line == b'\r\n':
break
k, v = line.decode().split(':', 1)
headers[k.lower()] = v.strip()
message = json.loads(proc.stdout.read(int(headers['content-length'])))
if 'method' in message and 'id' in message:
send({'jsonrpc': '2.0', 'id': message['id'], 'result': [] if message['method'] == 'workspace/configuration' else None})
else:
messages.put(message)
threading.Thread(target=read, daemon=True).start()
seq = 0
def request(method, params):
global seq
seq += 1
send({'jsonrpc': '2.0', 'id': seq, 'method': method, 'params': params})
deadline = time.monotonic() + 180
while time.monotonic() < deadline:
msg = messages.get(timeout=max(1, deadline - time.monotonic()))
if msg.get('id') == seq:
if 'error' in msg:
raise RuntimeError(msg['error'])
return msg.get('result')
raise TimeoutError(method)
def position(name, needle):
lines = sources[name].splitlines()
line = next(i for i, t in enumerate(lines) if needle in t)
return line, lines[line].index(needle)
def command(name, *args):
return request('workspace/executeCommand', {'command': name, 'arguments': list(args)})
def members(file, needle, kind):
line, col = position(file, needle)
return command('openidea.java.hierarchy.members', uri(file), line, col, kind)
def refactor(file, needle, kind, target, choices):
line, col = position(file, needle)
return command('openidea.java.hierarchy.refactor', uri(file), line, col, kind, target, choices)
def offset(text, pos):
lines = text.split('\n')
return sum(len(l) + 1 for l in lines[:pos['line']]) + pos['character']
def apply(edit):
"""The project after `edit`, compiled; returns the edited sources."""
per_file = {}
for u, edits in (edit.get('changes') or {}).items():
per_file.setdefault(u, []).extend(edits)
for change in edit.get('documentChanges') or []:
assert 'kind' not in change, change
per_file.setdefault(change['textDocument']['uri'], []).extend(change['edits'])
out = dict(sources)
for u, edits in per_file.items():
name = pathlib.Path(urllib.parse.unquote(urllib.parse.urlparse(u).path)).name
text = out[name]
for e in sorted(edits, key=lambda e: offset(text, e['range']['start']), reverse=True):
text = text[:offset(text, e['range']['start'])] + e['newText'] + text[offset(text, e['range']['end']):]
out[name] = text
copy = work / f'compile{seq}'
(copy / 'zoo').mkdir(parents=True)
for name, text in out.items():
(copy / 'zoo' / name).write_text(text)
javac = subprocess.run(['javac', '-d', str(copy / 'bin'), *map(str, (copy / 'zoo').glob('*.java'))], capture_output=True, text=True)
assert javac.returncode == 0, (javac.stderr, out)
return out
def body(text, name):
return [l.strip() for l in text.splitlines() if name in l]
try:
init = request('initialize', {'processId': None, 'rootUri': project.as_uri(), 'workspaceFolders': [{'uri': project.as_uri(), 'name': 'zoo'}], 'capabilities': {'workspace': {'workspaceEdit': {'documentChanges': True}}}, 'initializationOptions': {'bundles': [str(bundle)], 'settings': {'java': {'import': {'gradle': {'enabled': False}, 'maven': {'enabled': False}}}}}})
commands = init['capabilities']['executeCommandProvider']['commands']
assert 'openidea.java.hierarchy.members' in commands and 'openidea.java.hierarchy.refactor' in commands, commands
send({'jsonrpc': '2.0', 'method': 'initialized', 'params': {}})
for name, text in sources.items():
send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': uri(name), 'languageId': 'java', 'version': 1, 'text': text}}})
request('java/buildWorkspace', True)
evidence = {}
# Members of Dog, with bark() preselected; Animal and Speaker are destinations.
info = members('Dog.java', 'bark', 'pullUp')
evidence['pullUpMembers'] = info
assert info['className'] == 'Dog', info
assert {t['name'] for t in info['targets']} == {'Animal', 'Speaker'}, info['targets']
rows = {m['label'].split('(')[0].split(':')[0].strip(): m for m in info['members']}
assert rows['bark']['selected'] and not rows['wag']['selected'] and not rows['tail']['selected'], rows
assert rows['bark']['abstractable'] and rows['bark']['kind'] == 'method' and rows['tail']['kind'] == 'field', rows
animal = next(t for t in info['targets'] if t['name'] == 'Animal')
speaker = next(t for t in info['targets'] if t['name'] == 'Speaker')
# Caret on the class name: nothing preselected.
on_class = members('Dog.java', 'class Dog', 'pullUp')
assert on_class['className'] == 'Dog' and not any(m['selected'] for m in on_class['members']), on_class
# Pull bark() up to Animal: moved, call sites unchanged, compiles.
result = refactor('Dog.java', 'bark', 'pullUp', animal['id'], [{'id': rows['bark']['id'], 'abstract': False}])
evidence['pullUp'] = result
assert not [p for p in result['problems'] if p['severity'] in ('error', 'fatal')], result['problems']
out = apply(result['edit'])
assert 'bark()' in out['Animal.java'] and 'bark()' not in out['Dog.java'], out
assert out['Use.java'] == sources['Use.java']
# Make abstract: declared in Animal, kept (and overridden) in Dog.
result = refactor('Dog.java', 'bark', 'pullUp', animal['id'], [{'id': rows['bark']['id'], 'abstract': True}])
evidence['pullUpAbstract'] = result
out = apply(result['edit'])
assert any('abstract' in l for l in body(out['Animal.java'], 'bark()')), out['Animal.java']
assert 'Woof' in out['Dog.java'] and '@Override' in out['Dog.java'], out['Dog.java']
# To an interface, a method is always declared, never moved.
result = refactor('Dog.java', 'bark', 'pullUp', speaker['id'], [{'id': rows['bark']['id'], 'abstract': False}])
evidence['pullUpInterface'] = result
out = apply(result['edit'])
assert body(out['Speaker.java'], 'bark()') == ['String bark();'] or body(out['Speaker.java'], 'bark()') == ['public String bark();'], out['Speaker.java']
assert 'Woof' in out['Dog.java'] and '@Override' in out['Dog.java'], out['Dog.java']
# wag() uses Dog's private field: reported before anything changes.
result = refactor('Dog.java', 'wag', 'pullUp', animal['id'], [{'id': rows['wag']['id'], 'abstract': False}])
evidence['pullUpConflict'] = result
assert any(p['severity'] in ('error', 'fatal') and 'tail' in p['message'] for p in result['problems']), result
# No superclass in the sources.
refused = members('Lonely.java', 'alone', 'pullUp')
evidence['pullUpRefused'] = refused
assert 'refused' in refused, refused
# Push describe() down: copied into Dog and Cat, removed from Animal.
info = members('Animal.java', 'describe', 'pushDown')
evidence['pushDownMembers'] = info
assert info['className'] == 'Animal' and {t['name'] for t in info['targets']} == {'Dog', 'Cat'}, info
rows = {m['label'].split('(')[0].split(':')[0].strip(): m for m in info['members']}
assert rows['describe']['selected'] and rows['describe']['abstractable'] and not rows['name']['abstractable'], rows
result = refactor('Animal.java', 'describe', 'pushDown', None, [{'id': rows['describe']['id'], 'abstract': True}])
evidence['pushDownAbstract'] = result
assert not [p for p in result['problems'] if p['severity'] in ('error', 'fatal')], result['problems']
out = apply(result['edit'])
assert any('abstract' in l for l in body(out['Animal.java'], 'describe()')), out['Animal.java']
assert 'I am' in out['Dog.java'] and 'I am' in out['Cat.java'], out
# Without keeping a declaration, Use's animal.describe() breaks: JDT reports it.
result = refactor('Animal.java', 'describe', 'pushDown', None, [{'id': rows['describe']['id'], 'abstract': False}])
evidence['pushDownConflict'] = result
assert any(p['severity'] in ('error', 'fatal') for p in result['problems']), result
(work / 'evidence.json').write_text(json.dumps(evidence, indent=2))
print('hierarchy: all cases passed')
finally:
proc.terminate()
proc.wait(timeout=20)
print('Evidence:', work, file=sys.stderr)
+32
View File
@@ -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<HierarchyMembers> {
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<String>,
choices: Vec<HierarchyChoice>,
) -> IdeResult<HierarchyRefactored> {
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(
+2
View File
@@ -221,6 +221,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+3
View File
@@ -542,6 +542,9 @@ pub fn spec(os: Os) -> Vec<Entry> {
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…"),
+58
View File
@@ -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<Renamed, IdeError>(__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<HierarchyMembers, IdeError>(__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<HierarchyRefactored, IdeError>(__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";
+2
View File
@@ -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" },
],
+13 -1
View File
@@ -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) =>
+2
View File
@@ -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.
@@ -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);
}
+143
View File
@@ -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 <HierarchyDialogInner key={request.seq} request={request} />;
}
function HierarchyDialogInner({ request }: { request: HierarchyRequest }) {
const tx = useTx();
const { kind, info } = request;
const pullUp = kind === "pullUp";
const [target, setTarget] = useState<string | null>(info.targets[0]?.id ?? null);
const [rows, setRows] = useState<MemberRow[]>(() => initialRows(info));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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<MemberRow>) =>
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 (
<Dialog
open
onClose={close}
title={titleOf(kind)}
width={560}
buttons={[
{ label: "Preview", role: "extra", disabled: !ready, onClick: () => void submit(true) },
{ label: "Refactor", role: "default", disabled: !ready, onClick: () => void submit(false) },
{ label: "Cancel", role: "cancel" },
]}
footerStart={
busy ? (
<span className={styles.busy}>
<Spinner size={12} />
<T>Checking for conflicts…</T>
</span>
) : undefined
}
>
<div className={styles.body}>
{pullUp ? (
<label className={styles.field}>
<span>{tx`Pull up members of ${info.className} to:`}</span>
<Select
aria-label="Destination class"
value={target}
options={info.targets.map((t) => ({
value: t.id,
label: t.qualifiedName,
icon: t.interface ? "interface" : "class",
}))}
onChange={setTarget}
/>
</label>
) : (
<div className={styles.field}>
<span>{tx`Push members of ${info.className} down to:`}</span>
<span className={styles.targets}>{info.targets.map((t) => t.name).join(", ")}</span>
</div>
)}
<div
className={styles.table}
role="table"
aria-label={pullUp ? tx("Members to be pulled up") : tx("Members to be pushed down")}
>
<div className={styles.head} role="row">
<span role="columnheader">
<T>Member</T>
</span>
<span role="columnheader">{pullUp ? <T>Make abstract</T> : <T>Keep abstract</T>}</span>
</div>
<div className={styles.rows}>
{rows.map((row) => {
const forced = abstractForced(row, kind, toInterface);
return (
<div key={row.id} className={styles.row} role="row">
<Checkbox
className={styles.member}
checked={row.checked}
onChange={(checked) => update(row.id, { checked })}
label={<span className={styles.label}>{row.label}</span>}
/>
<Checkbox
aria-label={pullUp ? "Make abstract" : "Keep abstract"}
checked={row.checked && (forced || (row.abstractable && row.makeAbstract))}
disabled={!row.checked || forced || !row.abstractable}
onChange={(makeAbstract) => update(row.id, { makeAbstract })}
/>
</div>
);
})}
</div>
</div>
{toInterface && (
<span className={styles.hint}>
<T>Methods pulled up to an interface are declared abstract there.</T>
</span>
)}
{error && (
<span className={styles.error} role="alert">
{tx(error)}
</span>
)}
</div>
</Dialog>
);
}
+21 -1
View File
@@ -2,6 +2,7 @@
* IDEA's refactorings and how language-server code actions map onto them:
* `refactor.extract.*` → Extract Method / Introduce Variable…, `refactor.inline*`
* → Inline, `refactor.move*` → Move, signature rewrites → Change Signature.
* Pull Members Up / Push Members Down come from the Java extension, not code actions.
* Pure; used by Refactor This (⌃T) and the Refactor menu actions.
*/
import type { CodeAction } from "@/bindings";
@@ -16,7 +17,9 @@ export type RefactoringId =
| "extractConstant"
| "extractField"
| "extractParameter"
| "inline";
| "inline"
| "pullMembersUp"
| "pushMembersDown";
export interface Refactoring {
id: RefactoringId;
@@ -94,8 +97,25 @@ export const REFACTORINGS: readonly Refactoring[] = [
sel ? "Selected block should represent an expression" : "Caret should be inside an expression in a function",
},
{ id: "inline", action: "refactor.inline", label: "Inline…", kind: "refactor.inline", reason: () => AT_NAME },
{
id: "pullMembersUp",
action: "refactor.pullMembersUp",
label: "Pull Members Up…",
kind: null,
reason: () => "Caret should be inside a class that has members to pull up",
},
{
id: "pushMembersDown",
action: "refactor.pushMembersDown",
label: "Push Members Down…",
kind: null,
reason: () => "Caret should be inside a class that has members to push down",
},
];
/** Refactorings of the class hierarchy, run by the app-owned Java extension (`hierarchy.ts`). */
export const HIERARCHY_REFACTORINGS: ReadonlySet<RefactoringId> = new Set(["pullMembersUp", "pushMembersDown"]);
export function refactoring(id: RefactoringId): Refactoring {
return REFACTORINGS.find((r) => r.id === id)!;
}
+237
View File
@@ -0,0 +1,237 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { HierarchyMembers, HierarchyRefactored, WorkspaceEdit } from "@/bindings";
type Result = { status: "ok"; data: unknown } | { status: "error"; error: { kind: string; message: string } };
const ok = (data: unknown): Result => ({ status: "ok", data });
const refused = (message: string): Result => ({ status: "error", error: { kind: "invalidInput", message } });
let members: Result = ok(null);
let refactored: Result = ok(null);
const lspHierarchyMembers = vi.fn(async () => members);
const lspHierarchyRefactor = vi.fn(async () => refactored);
const applyWorkspaceEdit = vi.fn(async () => ok(null));
vi.mock("@/platform/ipc", async (orig) => {
const actual = await orig<typeof import("@/platform/ipc")>();
return {
...actual,
isTauri: () => true,
commands: {
...actual.commands,
lspHierarchyMembers,
lspHierarchyRefactor,
applyWorkspaceEdit,
cancelRequest: vi.fn(async () => undefined),
},
};
});
const showHint = vi.fn();
vi.mock("@/editor/lsp/hint", () => ({ showHint }));
let answer = "continue";
const ask = vi.fn(async () => ({ button: answer }));
vi.mock("@/workbench/messages", () => ({ ask }));
const {
abstractForced,
choicesOf,
conflicts,
fatalProblem,
initialRows,
refactorHierarchy,
startHierarchyRefactoring,
useHierarchyDialog,
} = await import("./hierarchy");
const { useLspPopup, closePopup } = await import("@/editor/lsp/popupStore");
const { lspDoc } = await import("@/editor/lsp/lsp");
const INFO: HierarchyMembers = {
className: "Dog",
targets: [
{ id: "animal", name: "Animal", qualifiedName: "zoo.Animal", interface: false },
{ id: "speaker", name: "Speaker", qualifiedName: "zoo.Speaker", interface: true },
],
members: [
{ id: "tail", label: "tail : int", kind: "field", selected: false, abstractable: false },
{ id: "bark", label: "bark() : String", kind: "method", selected: true, abstractable: true },
],
};
const at = (line: number) => ({ start: { line, col: 0 }, end: { line, col: 0 } });
const EDIT = {
files: [
{ kind: "edit", path: "/p/Dog.java", edits: [{ range: at(1), new_text: "" }] },
{ kind: "edit", path: "/p/Animal.java", edits: [{ range: at(2), new_text: "String bark() {}" }] },
],
} as WorkspaceEdit;
const done = (problems: HierarchyRefactored["problems"], edit: WorkspaceEdit | null = EDIT): Result =>
ok({ problems, edit } satisfies HierarchyRefactored);
let view: EditorView | null = null;
function editor(readOnly = false) {
view = new EditorView({
state: EditorState.create({
doc: 'class Dog extends Animal { String bark() { return ""; } }',
selection: { anchor: 30 },
extensions: [lspDoc.of({ doc: 7, path: "/p/Dog.java", language: "java", uiMode: null, readOnly })],
}),
});
return view;
}
afterEach(() => {
view?.destroy();
view = null;
closePopup();
useHierarchyDialog.setState({ request: null });
answer = "continue";
vi.clearAllMocks();
});
describe("member choices", () => {
it("starts from the member at the caret and sends what is checked", () => {
const rows = initialRows(INFO);
expect(rows.map((r) => [r.id, r.checked])).toEqual([
["tail", false],
["bark", true],
]);
rows[0]!.checked = true;
rows[1]!.makeAbstract = true;
expect(choicesOf(rows, "pullUp", false)).toEqual([
{ id: "tail", makeAbstract: false },
{ id: "bark", makeAbstract: true },
]);
});
it("declares methods abstract in an interface, and never abstracts a field", () => {
const rows = initialRows(INFO).map((r) => ({ ...r, checked: true, makeAbstract: true }));
const [field, method] = rows;
expect(abstractForced(method!, "pullUp", true)).toBe(true);
expect(abstractForced(method!, "pushDown", true)).toBe(false);
expect(abstractForced(field!, "pullUp", true)).toBe(false);
rows[1]!.makeAbstract = false;
expect(choicesOf(rows, "pullUp", true)).toEqual([
{ id: "tail", makeAbstract: false },
{ id: "bark", makeAbstract: true },
]);
expect(choicesOf(rows, "pushDown", false)[1]).toEqual({ id: "bark", makeAbstract: false });
});
it("separates fatal problems from the ones the user may accept", () => {
const problems = [
{ severity: "info", message: "i" },
{ severity: "warning", message: "w" },
{ severity: "error", message: "e" },
] as const;
expect(fatalProblem([...problems])).toBeNull();
expect(conflicts([...problems])).toEqual(["w", "e"]);
expect(fatalProblem([...problems, { severity: "fatal", message: "f" }])).toBe("f");
});
});
describe("Pull Members Up / Push Members Down", () => {
it("opens the dialog with the server's members, at the caret", async () => {
members = ok(INFO);
const v = editor();
await startHierarchyRefactoring(v, "pullUp");
expect(lspHierarchyMembers).toHaveBeenCalledWith(expect.any(Number), 7, { line: 0, col: 30 }, "pullUp");
const request = useHierarchyDialog.getState().request;
expect(request?.info.className).toBe("Dog");
expect(request?.kind).toBe("pullUp");
});
it("says why when the class can't be refactored, or the file is read-only", async () => {
members = refused("Type has no super types.");
await startHierarchyRefactoring(editor(), "pullUp");
expect(showHint).toHaveBeenCalledWith(expect.anything(), "Cannot perform refactoring. Type has no super types");
expect(useHierarchyDialog.getState().request).toBeNull();
view!.destroy();
await startHierarchyRefactoring(editor(true), "pushDown");
expect(showHint).toHaveBeenLastCalledWith(expect.anything(), "Cannot perform refactoring. The file is read-only");
expect(lspHierarchyMembers).toHaveBeenCalledOnce();
});
it("applies a clean edit at once", async () => {
members = ok(INFO);
refactored = done([{ severity: "info", message: "fine" }]);
await startHierarchyRefactoring(editor(), "pullUp");
const request = useHierarchyDialog.getState().request!;
const choices = [{ id: "bark", makeAbstract: false }];
expect(await refactorHierarchy(request, "animal", choices, false)).toEqual({ status: "done" });
expect(lspHierarchyRefactor).toHaveBeenCalledWith(
expect.any(Number),
7,
{ line: 0, col: 30 },
"pullUp",
"animal",
choices,
);
expect(ask).not.toHaveBeenCalled();
expect(applyWorkspaceEdit).toHaveBeenCalledWith(EDIT);
});
it("asks about conflicts first; Cancel changes nothing and keeps the dialog", async () => {
members = ok(INFO);
refactored = done([{ severity: "error", message: "Field 'tail' is not accessible from type 'Animal'" }]);
await startHierarchyRefactoring(editor(), "pullUp");
const request = useHierarchyDialog.getState().request!;
answer = "cancel";
expect(await refactorHierarchy(request, "animal", [{ id: "bark", makeAbstract: false }], false)).toEqual({
status: "stay",
});
expect(ask).toHaveBeenCalledWith(expect.objectContaining({ title: "Problems Detected" }));
expect(applyWorkspaceEdit).not.toHaveBeenCalled();
answer = "continue";
expect((await refactorHierarchy(request, "animal", [{ id: "bark", makeAbstract: false }], false)).status).toBe(
"done",
);
expect(applyWorkspaceEdit).toHaveBeenCalledOnce();
});
it("keeps the dialog with the reason when nothing can be done", async () => {
members = ok(INFO);
await startHierarchyRefactoring(editor(), "pushDown");
const request = useHierarchyDialog.getState().request!;
refactored = done([{ severity: "fatal", message: "No subclasses" }], null);
expect(await refactorHierarchy(request, null, [{ id: "bark", makeAbstract: true }], false)).toEqual({
status: "stay",
error: "No subclasses",
});
refactored = refused("The class changed; open the dialog again");
expect(await refactorHierarchy(request, null, [{ id: "bark", makeAbstract: true }], false)).toEqual({
status: "stay",
error: "The class changed; open the dialog again",
});
expect(applyWorkspaceEdit).not.toHaveBeenCalled();
});
it("refuses a request whose position an edit has moved", async () => {
members = ok(INFO);
const v = editor();
await startHierarchyRefactoring(v, "pullUp");
v.dispatch({ changes: { from: 0, insert: "// moved\n" } });
const request = useHierarchyDialog.getState().request!;
expect(await refactorHierarchy(request, "animal", [{ id: "bark", makeAbstract: false }], false)).toEqual({
status: "stay",
error: "The file changed; open the dialog again",
});
expect(lspHierarchyRefactor).not.toHaveBeenCalled();
});
it("previews the edit before applying it when asked", async () => {
members = ok(INFO);
refactored = done([]);
await startHierarchyRefactoring(editor(), "pullUp");
const request = useHierarchyDialog.getState().request!;
expect((await refactorHierarchy(request, "animal", [{ id: "bark", makeAbstract: false }], true)).status).toBe(
"done",
);
const popup = useLspPopup.getState().popup;
expect(popup?.kind).toBe("preview");
if (popup?.kind !== "preview") return;
expect(popup.title).toBe("Pull Members Up from 'Dog'");
expect(popup.summary.files).toBe(2);
expect(applyWorkspaceEdit).not.toHaveBeenCalled();
popup.onConfirm();
await vi.waitFor(() => expect(applyWorkspaceEdit).toHaveBeenCalledWith(EDIT));
});
});
+207
View File
@@ -0,0 +1,207 @@
/**
* Pull Members Up / Push Members Down, IDEA's class-hierarchy refactorings. The
* language server (the app-owned JDT extension) lists the class's members and
* destinations, checks the chosen move and computes the edit; the dialog
* (`HierarchyDialog.tsx`) picks the members, problems are confirmed like IDEA's
* "Problems Detected", and the edit is applied as one change (or previewed first).
*/
import type { Text } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { create } from "zustand";
import type {
HierarchyChoice,
HierarchyMemberKind,
HierarchyMembers,
HierarchyRefactoring,
Pos,
RefactoringProblem,
WorkspaceEdit,
} from "@/bindings";
import { commands, isCancelled, nextRequestId, unavailableText } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { whenAllSynced, whenSynced } from "@/editor/api";
import { ask } from "@/workbench/messages";
import { docOf, isUnavailable, lspRequest, reportLspError } from "@/editor/lsp/lsp";
import { offsetToPos } from "@/editor/lsp/positions";
import { showHint } from "@/editor/lsp/hint";
import { openPopup } from "@/editor/lsp/popupStore";
import { summarizeWorkspaceEdit } from "@/editor/lsp/workspaceEdit";
import { cannotPerform } from "./catalog";
export interface MemberRow {
id: string;
label: string;
kind: HierarchyMemberKind;
checked: boolean;
/** Pull up: declare abstract in the destination. Push down: keep an abstract declaration. */
makeAbstract: boolean;
abstractable: boolean;
}
export function initialRows(info: HierarchyMembers): MemberRow[] {
return info.members.map((m) => ({
id: m.id,
label: m.label,
kind: m.kind,
checked: m.selected,
makeAbstract: false,
abstractable: m.abstractable,
}));
}
/** A method pulled up to an interface is only declared there (JDT, like IDEA, never moves its body). */
export function abstractForced(row: MemberRow, kind: HierarchyRefactoring, toInterface: boolean): boolean {
return kind === "pullUp" && toInterface && row.kind === "method";
}
export function choicesOf(
rows: readonly MemberRow[],
kind: HierarchyRefactoring,
toInterface: boolean,
): HierarchyChoice[] {
return rows
.filter((r) => r.checked)
.map((r) => ({
id: r.id,
makeAbstract: abstractForced(r, kind, toInterface) || (r.abstractable && r.makeAbstract),
}));
}
/** The first fatal problem: nothing can be done. */
export function fatalProblem(problems: readonly RefactoringProblem[]): string | null {
return problems.find((p) => p.severity === "fatal")?.message ?? null;
}
/** Problems the user confirms before the change is made (IDEA lists errors and warnings). */
export function conflicts(problems: readonly RefactoringProblem[]): string[] {
return problems.filter((p) => p.severity === "error" || p.severity === "warning").map((p) => p.message);
}
export function titleOf(kind: HierarchyRefactoring): string {
return kind === "pullUp" ? "Pull Members Up" : "Push Members Down";
}
export interface HierarchyRequest {
seq: number;
view: EditorView;
kind: HierarchyRefactoring;
doc: number;
pos: Pos;
/** The document `pos` refers to. */
text: Text;
info: HierarchyMembers;
}
export const useHierarchyDialog = create<{ request: HierarchyRequest | null }>(() => ({ request: null }));
let seq = 0;
let running: AbortController | null = null;
function errorText(e: unknown): string {
if (isUnavailable(e)) return unavailableText(e, "No language server available");
return e instanceof Error ? e.message : String(e);
}
/** Pull Members Up… / Push Members Down…: asks the server what can move, then opens the dialog. */
export async function startHierarchyRefactoring(view: EditorView, kind: HierarchyRefactoring): Promise<void> {
const ctx = docOf(view);
if (!ctx) return;
if (ctx.readOnly) {
showHint(view, cannotPerform("The file is read-only"));
return;
}
running?.abort();
const run = (running = new AbortController());
await whenSynced(view);
const state = view.state;
const pos = offsetToPos(state.doc, state.selection.main.head);
const request = nextRequestId();
const abort = () => void commands.cancelRequest(request);
run.signal.addEventListener("abort", abort);
try {
const info = await lspRequest(() => commands.lspHierarchyMembers(request, ctx.doc, pos, kind));
if (run.signal.aborted || view.state.doc !== state.doc) return;
useHierarchyDialog.setState({ request: { seq: ++seq, view, kind, doc: ctx.doc, pos, text: state.doc, info } });
} catch (e) {
if (isCancelled(e) || run.signal.aborted) return;
showHint(view, cannotPerform(errorText(e)));
} finally {
run.signal.removeEventListener("abort", abort);
if (running === run) running = null;
}
}
async function applyEdit(view: EditorView, edit: WorkspaceEdit) {
try {
await whenAllSynced();
await lspRequest(() => commands.applyWorkspaceEdit(edit));
} catch (e) {
reportLspError(view, e, true);
}
}
/** IDEA's "Problems Detected": true to go on anyway. */
async function confirmConflicts(messages: readonly string[]): Promise<boolean> {
const { button } = await ask({
title: "Problems Detected",
icon: "warning",
body: messages.join("\n"),
buttons: [
{ id: "continue", label: "Continue", primary: true },
{ id: "cancel", label: "Cancel" },
],
});
return button === "continue";
}
export type RefactorOutcome =
/** The edit was applied or handed to the preview: close the dialog. */
| { status: "done" }
/** Stay in the dialog (the user cancelled at the problems); `error` says why nothing can be done. */
| { status: "stay"; error?: string };
/**
* Checks the chosen move, confirms its problems and applies the edit, or opens the
* preview with `preview`. The dialog stays open until the change is made.
*/
export async function refactorHierarchy(
request: HierarchyRequest,
target: string | null,
choices: HierarchyChoice[],
preview: boolean,
): Promise<RefactorOutcome> {
const { view, kind } = request;
// The position names the class: after an edit it may name another one.
if (view.state.doc !== request.text) return { status: "stay", error: "The file changed; open the dialog again" };
let refactored;
try {
await whenSynced(view);
refactored = await lspRequest(() =>
commands.lspHierarchyRefactor(nextRequestId(), request.doc, request.pos, kind, target, choices),
);
} catch (e) {
if (isCancelled(e)) return { status: "stay" };
return { status: "stay", error: errorText(e) };
}
const fatal = fatalProblem(refactored.problems);
if (fatal || !refactored.edit) return { status: "stay", error: fatal ?? "Nothing to change" };
const found = conflicts(refactored.problems);
if (found.length && !(await confirmConflicts(found))) return { status: "stay" };
const edit = refactored.edit;
const summary = summarizeWorkspaceEdit(edit);
if (!summary.files) return { status: "stay", error: "Nothing to change" };
if (preview) {
openPopup({
kind: "preview",
title:
kind === "pullUp"
? tx`Pull Members Up from '${request.info.className}'`
: tx`Push Members Down from '${request.info.className}'`,
summary,
confirmLabel: "Do Refactor",
onConfirm: () => void applyEdit(view, edit),
});
} else {
await applyEdit(view, edit);
}
return { status: "done" };
}
+2
View File
@@ -1,2 +1,4 @@
export { refactorThis, runRefactoring, showNothingHere } from "./refactorThis";
export { askUsagesDetected, fileUsages, safeDelete, viewUsages } from "./safeDelete";
export { startHierarchyRefactoring } from "./hierarchy";
export { HierarchyDialog } from "./HierarchyDialog";
+28 -2
View File
@@ -64,6 +64,8 @@ vi.mock("./introduce", () => ({
}));
const safeDelete = vi.fn();
vi.mock("./safeDelete", () => ({ safeDelete }));
const startHierarchyRefactoring = vi.fn();
vi.mock("./hierarchy", () => ({ startHierarchyRefactoring }));
const { classifyAction, kindAdvertised, cannotPerform } = await import("./catalog");
const { refactorThis, runRefactoring } = await import("./refactorThis");
@@ -88,13 +90,19 @@ const answer =
ok({ actions, advertisedKinds } satisfies CodeActionQuery);
let view: EditorView | null = null;
function editor(text = "fn main() { let total = price * 2; }") {
function editor(text = "fn main() { let total = price * 2; }", language = "rust") {
view = new EditorView({
state: EditorState.create({
doc: text,
selection: { anchor: text.indexOf("price") + 1 },
extensions: [
lspDoc.of({ doc: 1, path: "/p/a.rs", language: "rust", uiMode: null, readOnly: false }),
lspDoc.of({
doc: 1,
path: `/p/a.${language === "java" ? "java" : "rs"}`,
language,
uiMode: null,
readOnly: false,
}),
inplaceRenameExtension(),
],
}),
@@ -179,6 +187,24 @@ describe("Refactor This", () => {
expect(lspCodeActionQuery.mock.calls[0]![3]).toEqual(["refactor"]);
});
it("offers Pull Members Up / Push Members Down in Java files, after IDEA's other refactorings", async () => {
query = answer([action("Convert to switch expression", "refactor.rewrite")]);
const v = editor("class Dog extends Animal { int price; }", "java");
await refactorThis(v, vi.fn());
expect(labels()).toEqual(["Pull Members Up…", "Push Members Down…", "Convert to switch expression"]);
const items = (useLspPopup.getState().popup as { items: { label: string; run: () => void }[] }).items;
items.find((i) => i.label === "Push Members Down…")!.run();
expect(startHierarchyRefactoring).toHaveBeenCalledWith(v, "pushDown");
closePopup();
v.destroy();
// Other languages have no server for them; nor does Java while its server is down.
await refactorThis(editor(), vi.fn());
expect(labels()).not.toContain("Pull Members Up…");
query = async () => unavailable;
await refactorThis(editor("class Dog {}", "java"), vi.fn());
expect(labels()).toEqual(["Nothing here"]);
});
it("runs the mapped server action, Rename and Safe Delete", async () => {
prepare = ok({ start: { line: 0, col: 0 }, end: { line: 0, col: 1 } });
query = answer([action("Inline variable", "refactor.inline")]);
+20 -3
View File
@@ -20,6 +20,7 @@ import { caretAnchor, openPopup, type ListPopupItem } from "@/editor/lsp/popupSt
import { applyCodeAction } from "@/editor/lsp/intentions";
import { planLocalRename } from "@/editor/lsp/syntaxFallback";
import {
HIERARCHY_REFACTORINGS,
REFACTORINGS,
cannotPerform,
classifyAction,
@@ -38,6 +39,10 @@ import {
introducesName,
} from "./serverIntroduce";
import { safeDelete } from "./safeDelete";
import { startHierarchyRefactoring } from "./hierarchy";
/** Languages whose server runs the class-hierarchy refactorings (the app-owned JDT extension). */
const HIERARCHY_LANGUAGES = new Set(["java"]);
/** Outcome of the server query: its answer, or why there is none. */
export type ServerAnswer =
@@ -214,8 +219,12 @@ async function expressionAtCaret(view: EditorView, signal: AbortSignal): Promise
export function refactorThisItems(
view: EditorView,
answer: ServerAnswer,
facts: { symbol: boolean; expression: boolean },
run: { rename: (view: EditorView) => unknown; safeDelete: (view: EditorView) => unknown },
facts: { symbol: boolean; expression: boolean; hierarchy?: boolean },
run: {
rename: (view: EditorView) => unknown;
safeDelete: (view: EditorView) => unknown;
hierarchy?: (view: EditorView, kind: "pullUp" | "pushDown") => unknown;
},
): ListPopupItem[] {
const { mapped, other } = groupActions(answer.status === "ok" ? answer.query.actions : []);
const serverUsable = answer.status === "ok";
@@ -231,6 +240,8 @@ export function refactorThisItems(
else if (actions.length) go = () => runActions(view, r.label.replace(/…$/, ""), actions, r.id);
else if (r.id === "rename" && facts.symbol) go = () => run.rename(view);
else if (r.id === "safeDelete" && facts.symbol && !docOf(view)?.readOnly) go = () => run.safeDelete(view);
else if (HIERARCHY_REFACTORINGS.has(r.id) && facts.hierarchy && serverUsable && run.hierarchy)
go = () => run.hierarchy!(view, r.id === "pullMembersUp" ? "pullUp" : "pushDown");
else if (INTRODUCE[r.id] && facts.expression && !serverUsable)
go = () => introduceFallback(view, INTRODUCE[r.id]!, answer.status === "notReady" ? "notReady" : "error");
if (!go) continue;
@@ -266,7 +277,13 @@ export async function refactorThis(view: EditorView, rename: (view: EditorView)
symbolAtCaret(view, answer.status !== "notReady"),
expressionAtCaret(view, run.signal),
]);
items = refactorThisItems(view, answer, { symbol, expression }, { rename, safeDelete });
const hierarchy = HIERARCHY_LANGUAGES.has(ctx.language) && !ctx.readOnly;
items = refactorThisItems(
view,
answer,
{ symbol, expression, hierarchy },
{ rename, safeDelete, hierarchy: startHierarchyRefactoring },
);
} catch (e) {
if (isCancelled(e)) return;
throw e;
+2
View File
@@ -228,6 +228,8 @@ export const IDEA_ACTIONS: Readonly<Record<string, string>> = {
Inline: "refactor.inline",
Move: "refactor.move",
SafeDelete: "refactor.safeDelete",
MembersPullUp: "refactor.pullMembersUp",
MemberPushDown: "refactor.pushMembersDown",
Run: "run.run",
Debug: "run.debug",
RunClass: "run.runContext",
+2
View File
@@ -170,6 +170,8 @@ export const intellijMacos: Record<string, string[]> = {
"refactor.inline": ["Meta+Alt+N"],
"refactor.move": ["F6"],
"refactor.safeDelete": ["Meta+Delete"],
"refactor.pullMembersUp": [],
"refactor.pushMembersDown": [],
// File / editor tabs
"file.save": ["Meta+S"],
+2
View File
@@ -171,6 +171,8 @@ export const intellijWindows: Record<string, string[]> = {
"refactor.inline": ["Ctrl+Alt+N"],
"refactor.move": ["F6"],
"refactor.safeDelete": ["Alt+Delete"],
"refactor.pullMembersUp": [],
"refactor.pushMembersDown": [],
// File / editor tabs
"file.save": ["Ctrl+S"],
+2
View File
@@ -172,6 +172,8 @@ export const vscodeMacos: Record<string, string[]> = {
"refactor.inline": [],
"refactor.move": [],
"refactor.safeDelete": [],
"refactor.pullMembersUp": [],
"refactor.pushMembersDown": [],
// File / editor tabs
"file.save": ["Meta+S"],
+2
View File
@@ -242,6 +242,8 @@
"menu.refactor.extractVariable": "Introduce Variable…",
"menu.refactor.inline": "Inline…",
"menu.refactor.move": "Move…",
"menu.refactor.pullMembersUp": "Pull Members Up…",
"menu.refactor.pushMembersDown": "Push Members Down…",
"menu.refactor.rename": "Rename…",
"menu.refactor.safeDelete": "Safe Delete…",
"menu.refactor.thisMenu": "Refactor This…",
+2
View File
@@ -241,6 +241,8 @@
"menu.refactor.extractVariable": "Introduire une variable…",
"menu.refactor.inline": "Intégrer…",
"menu.refactor.move": "Déplacer…",
"menu.refactor.pullMembersUp": "Remonter les membres…",
"menu.refactor.pushMembersDown": "Descendre les membres…",
"menu.refactor.rename": "Renommer…",
"menu.refactor.safeDelete": "Suppression sûre…",
"menu.refactor.thisMenu": "Refactoriser ceci…",
+2
View File
@@ -242,6 +242,8 @@
"menu.refactor.extractVariable": "변수 도입…",
"menu.refactor.inline": "인라인…",
"menu.refactor.move": "이동…",
"menu.refactor.pullMembersUp": "멤버 끌어올리기…",
"menu.refactor.pushMembersDown": "멤버 끌어내리기…",
"menu.refactor.rename": "이름 변경…",
"menu.refactor.safeDelete": "안전 삭제…",
"menu.refactor.thisMenu": "이 항목 리팩터링…",
+22 -1
View File
@@ -5784,5 +5784,26 @@
"Weekly Sonnet limit": "Limite hebdomadaire Sonnet",
"Marketplace index URL (https://…/index.json or a folder path)": "URL de l'index de la marketplace (https://…/index.json ou un chemin de dossier)",
"Editor Providers": "Fournisseurs d'éditeurs",
"Syntax errors": "Erreurs de syntaxe"
"Syntax errors": "Erreurs de syntaxe",
"Pull Members Up…": "Remonter les membres…",
"Push Members Down…": "Descendre les membres…",
"Pull Members Up": "Remonter les membres",
"Push Members Down": "Descendre les membres",
"Caret should be inside a class that has members to pull up": "Le curseur doit se trouver dans une classe ayant des membres à remonter",
"Caret should be inside a class that has members to push down": "Le curseur doit se trouver dans une classe ayant des membres à descendre",
"Problems Detected": "Problèmes détectés",
"Nothing to change": "Rien à modifier",
"Pull Members Up from '{name}'": "Remonter les membres de '{name}'",
"Push Members Down from '{name}'": "Descendre les membres de '{name}'",
"Pull up members of {name} to:": "Remonter les membres de {name} vers :",
"Push members of {name} down to:": "Descendre les membres de {name} vers :",
"Checking for conflicts…": "Recherche de conflits…",
"Destination class": "Classe de destination",
"Members to be pulled up": "Membres à remonter",
"Members to be pushed down": "Membres à descendre",
"Member": "Membre",
"Make abstract": "Rendre abstrait",
"Keep abstract": "Garder abstrait",
"Methods pulled up to an interface are declared abstract there.": "Les méthodes remontées dans une interface y sont déclarées abstraites.",
"The file changed; open the dialog again": "Le fichier a changé ; rouvrez la boîte de dialogue"
}
+22 -1
View File
@@ -5830,5 +5830,26 @@
"Could not start Compose project {project}": "Compose 프로젝트 {project}을(를) 시작할 수 없습니다",
"Could not stop Compose project {project}": "Compose 프로젝트 {project}을(를) 중지할 수 없습니다",
"Could not restart Compose project {project}": "Compose 프로젝트 {project}을(를) 다시 시작할 수 없습니다",
"Could not take down Compose project {project}": "Compose 프로젝트 {project}을(를) 중지 및 제거할 수 없습니다"
"Could not take down Compose project {project}": "Compose 프로젝트 {project}을(를) 중지 및 제거할 수 없습니다",
"Pull Members Up…": "멤버 끌어올리기…",
"Push Members Down…": "멤버 끌어내리기…",
"Pull Members Up": "멤버 끌어올리기",
"Push Members Down": "멤버 끌어내리기",
"Caret should be inside a class that has members to pull up": "캐럿은 끌어올릴 멤버가 있는 클래스 안에 있어야 합니다",
"Caret should be inside a class that has members to push down": "캐럿은 끌어내릴 멤버가 있는 클래스 안에 있어야 합니다",
"Problems Detected": "문제 감지됨",
"Nothing to change": "변경할 내용이 없습니다",
"Pull Members Up from '{name}'": "'{name}'에서 멤버 끌어올리기",
"Push Members Down from '{name}'": "'{name}'에서 멤버 끌어내리기",
"Pull up members of {name} to:": "{name}의 멤버를 끌어올릴 위치:",
"Push members of {name} down to:": "{name}의 멤버를 끌어내릴 위치:",
"Checking for conflicts…": "충돌 확인 중…",
"Destination class": "대상 클래스",
"Members to be pulled up": "끌어올릴 멤버",
"Members to be pushed down": "끌어내릴 멤버",
"Member": "멤버",
"Make abstract": "추상으로 만들기",
"Keep abstract": "추상 선언 유지",
"Methods pulled up to an interface are declared abstract there.": "인터페이스로 끌어올린 메서드는 그곳에 추상 메서드로 선언됩니다.",
"The file changed; open the dialog again": "파일이 변경되었습니다. 대화상자를 다시 여세요"
}
+22 -1
View File
@@ -6025,5 +6025,26 @@
"Back to Changed Files": "返回更改文件列表",
"Default (the module's JDK)": "默认(模块的 JDK)",
"Use classpath of module": "使用模块的类路径",
"Find by main class": "按主类查找"
"Find by main class": "按主类查找",
"Pull Members Up…": "上拉成员…",
"Push Members Down…": "下推成员…",
"Pull Members Up": "上拉成员",
"Push Members Down": "下推成员",
"Caret should be inside a class that has members to pull up": "光标应位于具有可上拉成员的类中",
"Caret should be inside a class that has members to push down": "光标应位于具有可下推成员的类中",
"Problems Detected": "检测到问题",
"Nothing to change": "没有需要更改的内容",
"Pull Members Up from '{name}'": "从 '{name}' 上拉成员",
"Push Members Down from '{name}'": "从 '{name}' 下推成员",
"Pull up members of {name} to:": "将 {name} 的成员上拉到:",
"Push members of {name} down to:": "将 {name} 的成员下推到:",
"Checking for conflicts…": "正在检查冲突…",
"Destination class": "目标类",
"Members to be pulled up": "要上拉的成员",
"Members to be pushed down": "要下推的成员",
"Member": "成员",
"Make abstract": "设为抽象",
"Keep abstract": "保留抽象声明",
"Methods pulled up to an interface are declared abstract there.": "上拉到接口的方法将在接口中声明为抽象方法。",
"The file changed; open the dialog again": "文件已更改,请重新打开对话框"
}
+2
View File
@@ -242,6 +242,8 @@
"menu.refactor.extractVariable": "引入变量…",
"menu.refactor.inline": "内联…",
"menu.refactor.move": "移动…",
"menu.refactor.pullMembersUp": "上拉成员…",
"menu.refactor.pushMembersDown": "下推成员…",
"menu.refactor.rename": "重命名…",
"menu.refactor.safeDelete": "安全删除…",
"menu.refactor.thisMenu": "重构此项…",