дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
feat(maven): go to declaration for POM properties and parent-managed dependencies (#1887)
* feat(maven): go to declaration for POM properties and parent-managed dependencies
Ctrl/Cmd-click (or Go to Declaration) on a `${property}` reference in a
pom.xml opens the `<properties>` entry that defines it - the POM's own,
else the first one along the parent chain (a child's definition wins; an
active profile's counts as the POM's own) - and a dependency / plugin
whose version a `<dependencyManagement>` / `<pluginManagement>` entry
provides opens that managing declaration. The chain follows
`<relativePath>` in the project and the local repository (Settings >
Maven's, else ~/.m2) for external parents, which open read-only as plain
files; `project.*` built-ins go to their coordinate elements. When
nothing resolves under the caret, nothing navigates: the usual 'Cannot
find declaration to go to' hint shows instead.
The backend answers a caret position (0-based line / UTF-16 column) with
the winner's file, range and kind (maven_goto_declaration), reusing the
build model's POM reader and parent resolver (ide_build::maven) instead
of a new parser. The frontend contribution (build-tools.pomNavigation)
claims only positions that can resolve - inside a `${...}` reference or
a <dependency> / <plugin> element - so the Cmd-hover underline stays out
of the rest of the file.
Refs #1735
* test(maven): normalize expected goto paths like the parent chain does
parent_chain resolves every path it returns through dunce::canonicalize,
but the goto_declaration tests compared those results against raw temp
paths, which differ on the CI runners: on macOS the temp dir sits behind
the /var -> /private/var symlink and on Windows under the RUNNER~1 8.3
short name with backslash separators, so the Rust (macos-latest) and
Rust (windows-latest) jobs failed in the new assertions while the
functionality itself was right.
Feed the expected paths through the same canonicalization call before
comparing, mirroring the production behavior on every platform; on a
plain Linux checkout the canonical form equals the raw path, so the
assertions stay equivalent there.
Refs #1735
Цей коміт міститься в:
1 джерело
66ecf4c967
коміт
99fafffaac
8 files changed
+949
-3
No files matched your search
@@ -730,6 +730,23 @@ export const commands = {
|
||||
* (saved). Returns the declaration; nothing changes when it already excludes it.
|
||||
*/
|
||||
mavenExcludeDependency: (pom: string, dependency: ArtifactKey, exclusion: ArtifactKey) => typedError<Location, IdeError>(__TAURI_INVOKE("maven_exclude_dependency", { pom, dependency, exclusion })),
|
||||
/**
|
||||
* The go-to-declaration target at `pos` of a Maven POM: the definition of the
|
||||
* `${property}` at the caret, or the `<dependencyManagement>` / `<pluginManagement>`
|
||||
* entry providing the dependency's / plugin's version, along the parent chain.
|
||||
*/
|
||||
mavenGotoDeclaration: (pom: string, pos: Pos) => typedError<{
|
||||
/**
|
||||
* Absolute path of the file with the declaration: the POM itself, or a parent
|
||||
* POM of the local repository when the definition is inherited from one.
|
||||
*/
|
||||
path: string,
|
||||
range: Range,
|
||||
/** What was found. */
|
||||
kind: PomGotoKind,
|
||||
/** The declaration as named for the user (`example.version`, `com.example:example-lib`). */
|
||||
label: string,
|
||||
} | null, IdeError>(__TAURI_INVOKE("maven_goto_declaration", { pom, pos })),
|
||||
/**
|
||||
* Build › Build Project / Rebuild Project / Build Module (`file`: a file or folder of the
|
||||
* module) / Build Artifacts, streaming the console and messages on `on_event`; cancellable
|
||||
@@ -5899,6 +5916,29 @@ export type PluginsSnapshot = {
|
||||
ui: UiContribution[],
|
||||
};
|
||||
|
||||
/** What a go-to-declaration in a POM found, and where. */
|
||||
export type PomGoto = {
|
||||
/**
|
||||
* Absolute path of the file with the declaration: the POM itself, or a parent
|
||||
* POM of the local repository when the definition is inherited from one.
|
||||
*/
|
||||
path: string,
|
||||
range: Range,
|
||||
/** What was found. */
|
||||
kind: PomGotoKind,
|
||||
/** The declaration as named for the user (`example.version`, `com.example:example-lib`). */
|
||||
label: string,
|
||||
};
|
||||
|
||||
/** The kind of declaration a POM go-to-declaration found. */
|
||||
export type PomGotoKind =
|
||||
/** A `<properties>` entry of the POM or one of its parents. */
|
||||
"property" |
|
||||
/** A `<dependencyManagement>` entry providing the dependency's version. */
|
||||
"managedDependency" |
|
||||
/** A `<pluginManagement>` entry providing the plugin's version. */
|
||||
"managedPlugin";
|
||||
|
||||
/** A 0-based line / UTF-16 column position. */
|
||||
export type Pos = {
|
||||
line: number,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { lazyComponent } from "@/ui";
|
||||
import { GradleView, MavenView } from "./BuildToolView";
|
||||
import { ExecutePopupHost } from "./ExecutePopup";
|
||||
import { pomNavigationExtension } from "./pomNavigation";
|
||||
import { buildToolsRunAnything } from "./runAnything";
|
||||
import { versionCatalogExtension } from "./versionCatalog";
|
||||
import { expandableKeys, gradleChildren, gradleRoots, mavenChildren, mavenRoots, gradleProjectKey } from "./model";
|
||||
@@ -177,6 +178,7 @@ export function activate(): () => void {
|
||||
const disposers: Array<() => void> = [
|
||||
registerOverlay({ id: "build-tools.execute", component: ExecutePopupHost }),
|
||||
registerEditorContribution({ id: "build-tools.versionCatalog", extensions: versionCatalogExtension }),
|
||||
registerEditorContribution({ id: "build-tools.pomNavigation", extensions: pomNavigationExtension }),
|
||||
registerRunAnythingProvider(buildToolsRunAnything),
|
||||
registerEditorKind({
|
||||
kind: ANALYZER_KIND,
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ipc = vi.hoisted(() => ({ mavenGotoDeclaration: vi.fn() }));
|
||||
const workbench = vi.hoisted(() => ({ openFile: vi.fn() }));
|
||||
const hints = vi.hoisted(() => ({ showHint: vi.fn() }));
|
||||
|
||||
vi.mock("@/platform/ipc", () => ({
|
||||
call: async (p: Promise<{ status: string; data?: unknown; error?: unknown }>) => {
|
||||
const r = await p;
|
||||
if (r.status !== "ok") throw r.error;
|
||||
return r.data;
|
||||
},
|
||||
commands: ipc,
|
||||
}));
|
||||
vi.mock("@/workbench/contributions", () => ({
|
||||
editorService: () => workbench,
|
||||
}));
|
||||
vi.mock("@/editor/lsp/hint", () => ({ showHint: hints.showHint }));
|
||||
vi.mock("@/editor/api", () => ({
|
||||
CONTRIBUTED_DEFINITION: "openideaDefinition",
|
||||
whenSynced: () => Promise.resolve(),
|
||||
}));
|
||||
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { inArtifactElement, pomNavigationExtension, propertyRefAt } from "./pomNavigation";
|
||||
|
||||
const DEFINITION = "openideaDefinition";
|
||||
|
||||
/** An editor state over `text` with the contribution for `path` installed. */
|
||||
function state(text: string, path = "/p/app/pom.xml") {
|
||||
return EditorState.create({
|
||||
doc: text,
|
||||
extensions: [
|
||||
pomNavigationExtension({
|
||||
path,
|
||||
language: "xml",
|
||||
uiMode: "XML",
|
||||
readOnly: false,
|
||||
} as Parameters<typeof pomNavigationExtension>[0]),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const definitionAt = (s: ReturnType<typeof state>, pos: number) =>
|
||||
s.languageDataAt<(v: { state: typeof s }, p: number) => boolean>(DEFINITION, pos)[0];
|
||||
|
||||
/** The definition only reads `view.state`; a bare handle is enough. */
|
||||
const run = (s: ReturnType<typeof state>, pos: number) => definitionAt(s, pos)!({ state: s }, pos);
|
||||
|
||||
const pom = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<parent>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>parent</artifactId>
|
||||
<version>1.0</version>
|
||||
</parent>
|
||||
<artifactId>app</artifactId>
|
||||
<properties>
|
||||
<example.version>1.2.3</example.version>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>example-lib</artifactId>
|
||||
<version>\${example.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>other-lib</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>`;
|
||||
|
||||
describe("POM property references", () => {
|
||||
it("finds the reference spanning the caret, the $ and the } included", () => {
|
||||
const at = pom.indexOf("${example.version}");
|
||||
expect(propertyRefAt(pom, at)).toEqual({
|
||||
from: at,
|
||||
to: at + "${example.version}".length,
|
||||
});
|
||||
expect(propertyRefAt(pom, at + 1)).toEqual({
|
||||
from: at,
|
||||
to: at + "${example.version}".length,
|
||||
});
|
||||
expect(propertyRefAt(pom, at + "${example.version}".length - 1)).toEqual({
|
||||
from: at,
|
||||
to: at + "${example.version}".length,
|
||||
});
|
||||
// Outside a reference: none.
|
||||
expect(propertyRefAt(pom, pom.indexOf("example-lib"))).toBeNull();
|
||||
// A reference closed before the caret does not reach over it.
|
||||
const afterRef = at + "${example.version}".length + 1;
|
||||
expect(propertyRefAt(pom, afterRef)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POM artifact elements", () => {
|
||||
it("sees a dependency or plugin element around the caret", () => {
|
||||
expect(inArtifactElement(pom, pom.indexOf("<artifactId>example-lib"))).toBe(true);
|
||||
expect(inArtifactElement(pom, pom.indexOf("example-lib</artifactId>"))).toBe(true);
|
||||
expect(inArtifactElement(pom, pom.indexOf("<artifactId>maven-compiler-plugin"))).toBe(true);
|
||||
expect(inArtifactElement(pom, pom.indexOf("${example.version}"))).toBe(true);
|
||||
});
|
||||
|
||||
it("sees no artifact element outside dependencies and plugins", () => {
|
||||
expect(inArtifactElement(pom, pom.indexOf("<properties>"))).toBe(false);
|
||||
expect(inArtifactElement(pom, pom.indexOf("<parent>"))).toBe(false);
|
||||
// `<plugins>` and `<dependencyManagement>` are not a `<plugin>` / `<dependency>`.
|
||||
expect(inArtifactElement(pom, pom.indexOf("<plugins>"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POM go to declaration", () => {
|
||||
it("contributes a definition on a property reference and an artifact element only", () => {
|
||||
const s = state(pom);
|
||||
expect(definitionAt(s, pom.indexOf("${example.version}"))).toBeDefined();
|
||||
expect(definitionAt(s, pom.indexOf("<artifactId>other-lib"))).toBeDefined();
|
||||
expect(definitionAt(s, pom.indexOf("<artifactId>maven-compiler-plugin"))).toBeDefined();
|
||||
expect(definitionAt(s, pom.indexOf("<properties>"))).toBeUndefined();
|
||||
expect(definitionAt(s, pom.indexOf("<parent>"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("opens the declaration the backend resolved", async () => {
|
||||
const target = {
|
||||
path: "/p/pom.xml",
|
||||
range: { start: { line: 4, col: 6 }, end: { line: 4, col: 14 } },
|
||||
kind: "property",
|
||||
label: "example.version",
|
||||
};
|
||||
ipc.mavenGotoDeclaration.mockResolvedValue({ status: "ok", data: target });
|
||||
const s = state(pom);
|
||||
const at = pom.indexOf("${example.version}");
|
||||
run(s, at);
|
||||
await vi.waitFor(() =>
|
||||
expect(workbench.openFile).toHaveBeenCalledWith("/p/pom.xml", {
|
||||
selection: { line: 4, col: 6, endLine: 4, endCol: 14 },
|
||||
}),
|
||||
);
|
||||
expect(ipc.mavenGotoDeclaration).toHaveBeenCalledWith("/p/app/pom.xml", {
|
||||
line: pom.slice(0, at).split("\n").length - 1,
|
||||
col: at - (pom.lastIndexOf("\n", at - 1) + 1),
|
||||
});
|
||||
});
|
||||
|
||||
it("says so when nothing resolves, without navigating", async () => {
|
||||
ipc.mavenGotoDeclaration.mockResolvedValue({ status: "ok", data: null });
|
||||
const s = state(pom);
|
||||
const at = pom.indexOf("<artifactId>other-lib");
|
||||
run(s, at);
|
||||
await vi.waitFor(() => expect(hints.showHint).toHaveBeenCalled());
|
||||
expect(workbench.openFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("contributes nothing outside a pom.xml", () => {
|
||||
const s = state(pom, "/p/app/build.xml");
|
||||
expect(definitionAt(s, pom.indexOf("${example.version}"))).toBeUndefined();
|
||||
expect(ipc.mavenGotoDeclaration).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Go to declaration in `pom.xml`: ⌘/Ctrl-click (or ⌘B) on a `${property}` reference
|
||||
* opens the `<properties>` entry that defines it — the POM's own, else one inherited
|
||||
* along the parent chain (a parent POM outside the project opens from the local
|
||||
* repository, read-only as a plain file) — and a dependency / plugin whose version a
|
||||
* `<dependencyManagement>` / `<pluginManagement>` entry provides opens that managing
|
||||
* declaration. The backend resolves both with the build model's POM reader and parent
|
||||
* resolver; here only cheap eligibility is computed (a reference span, an artifact
|
||||
* element), so the ⌘-hover underline stays out of the rest of the file, and nothing
|
||||
* resolves to no jump at all: an unresolvable property or parent never navigates
|
||||
* somewhere unrelated.
|
||||
*/
|
||||
import { EditorState, type Extension } from "@codemirror/state";
|
||||
import { CONTRIBUTED_DEFINITION, type ContributedDefinition, type EditorDocContext, whenSynced } from "@/editor/api";
|
||||
import { showHint } from "@/editor/lsp/hint";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { editorService } from "@/workbench/contributions";
|
||||
|
||||
/** The `${property}` reference covering `at`, if any (clicking the `$` counts). */
|
||||
export function propertyRefAt(text: string, at: number): { from: number; to: number } | null {
|
||||
const open = text.startsWith("${", at) ? at : text.lastIndexOf("${", at - 1);
|
||||
if (open < 0) return null;
|
||||
const close = text.indexOf("}", open + 2);
|
||||
if (close < 0 || close < at) return null;
|
||||
return { from: open, to: close + 1 };
|
||||
}
|
||||
|
||||
/** Whether an opener of `name` before `at` has no closer of it yet (POM elements do not nest). */
|
||||
function inside(name: string, text: string, at: number): boolean {
|
||||
const before = text.slice(0, at);
|
||||
const count = (re: RegExp) => (before.match(re) ?? []).length;
|
||||
return count(new RegExp(`<${name}[\\s>]`, "g")) > count(new RegExp(`</${name}\\s*>`, "g"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `at` may sit in a `<dependency>` / `<plugin>` element: an eligibility test
|
||||
* only (a `<dependency>` inside a comment counts too) — whether the element's version
|
||||
* actually comes from a management entry is the backend's answer.
|
||||
*/
|
||||
export function inArtifactElement(text: string, at: number): boolean {
|
||||
return inside("dependency", text, at) || inside("plugin", text, at);
|
||||
}
|
||||
|
||||
/** Go to declaration in a POM of the project (or an opened parent POM of the local repository). */
|
||||
export function pomNavigationExtension(ctx: EditorDocContext): Extension {
|
||||
if (ctx.virtual || !/(^|\/)pom\.xml$/.test(ctx.path)) return [];
|
||||
const eligible = (state: EditorState, pos: number) => {
|
||||
const text = state.doc.toString();
|
||||
return propertyRefAt(text, pos) !== null || inArtifactElement(text, pos);
|
||||
};
|
||||
const definition: ContributedDefinition = (view, pos) => {
|
||||
if (!eligible(view.state, pos)) return false;
|
||||
const doc = view.state.doc;
|
||||
const line = doc.lineAt(pos);
|
||||
void whenSynced(view)
|
||||
.then(() =>
|
||||
call(
|
||||
commands.mavenGotoDeclaration(ctx.path, {
|
||||
line: line.number - 1,
|
||||
col: pos - line.from,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.then((goto) => {
|
||||
if (!goto) {
|
||||
showHint(view, tx`Cannot find declaration to go to`);
|
||||
return;
|
||||
}
|
||||
if (view.state.doc !== doc) return; // The user kept typing while the POM resolved.
|
||||
void editorService().openFile(goto.path, {
|
||||
selection: {
|
||||
line: goto.range.start.line,
|
||||
col: goto.range.start.col,
|
||||
endLine: goto.range.end.line,
|
||||
endCol: goto.range.end.col,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((e: unknown) =>
|
||||
showHint(view, e instanceof Error ? e.message : String(e), {
|
||||
tone: "error",
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
};
|
||||
return EditorState.languageData.of((state, pos) =>
|
||||
eligible(state, pos) ? [{ [CONTRIBUTED_DEFINITION]: definition }] : [],
|
||||
);
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача