дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
feat(editor): Implemented / overridden gutter markers (#106)
Adds gutter markers for type hierarchies: ↓ "Is implemented / Has subclasses / Is overridden" on types and methods with implementations, ↑ "Implements / Overrides method in X" on methods that implement or override a supertype method. One target navigates, several open the Choose Implementation popup. Settings › Editor › General › Gutter Icons gets two toggles. Markers come from the language server: a new batched `lsp_implementations` command (up to 4 concurrent, cancellable, sharing the goto query path) for ↓, and the Super Method lookup for ↑, which now prefers the overload with matching parameters (also fixes ⌘U on overloads). Only declarations near the viewport are queried, debounced, cached per document version and cancelled when superseded. Works for any server with implementation / type hierarchy support; servers without type hierarchy show ↓ only. Fixes #99 Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
Цей коміт міститься в:
1 джерело
6de6b33449
коміт
2fe6b7b87b
27 files changed
+1252
-22
No files matched your search
@@ -402,6 +402,8 @@ const noSurround = EditorView.inputHandler.of((view, from, to, text) => {
|
||||
export const GUTTER_ICON_GROUPS = [
|
||||
{ id: "run", gutterClass: "cm-openidea-gutter-run" },
|
||||
{ id: "bookmarks", gutterClass: "cm-openidea-bookmarks" },
|
||||
{ id: "inheritors", gutterClass: "cm-openidea-gutter-lineMarkers" },
|
||||
{ id: "supers", gutterClass: "cm-openidea-gutter-lineMarkers" },
|
||||
] as const;
|
||||
|
||||
export interface PrefsOptions {
|
||||
|
||||
@@ -57,6 +57,9 @@ export const openideaTheme = EditorView.theme({
|
||||
boxShadow: "inset 0 1px 0 var(--border-method-separator, var(--border))",
|
||||
},
|
||||
"&.cm-openidea-hideGutter-run .cm-openidea-gutter-run, &.cm-openidea-hideGutter-bookmarks .cm-openidea-bookmarks": { display: "none" },
|
||||
// Inheritance markers share one gutter: each group hides its own icons.
|
||||
"&.cm-openidea-hideGutter-inheritors :is(.cm-openidea-lineMarker-implemented, .cm-openidea-lineMarker-overridden), &.cm-openidea-hideGutter-supers :is(.cm-openidea-lineMarker-implementing, .cm-openidea-lineMarker-overriding)":
|
||||
{ display: "none" },
|
||||
// Distraction Free Mode: no gutter; the text column (n chars + line padding) is centred.
|
||||
"&.cm-openidea-distractionFree": { "--openidea-df-width": "calc(var(--openidea-df-columns, 80) * 1ch + 22px)" },
|
||||
"&.cm-openidea-distractionFree .cm-gutters": { display: "none" },
|
||||
|
||||
@@ -31,7 +31,7 @@ import { spellingExtension } from "./spelling";
|
||||
import { DocumentationWindow, LspPopups, UsagesStatus } from "./Popups";
|
||||
import { DOCUMENTATION_WINDOW } from "./docs";
|
||||
import { prefetchMarkdown } from "./markdown";
|
||||
import { registerNavigationActions } from "@/editor/navigation";
|
||||
import { implementationGutter, registerNavigationActions } from "@/editor/navigation";
|
||||
|
||||
export function lspExtensions(ctx: EditorDocContext): Extension[] {
|
||||
return [
|
||||
@@ -61,6 +61,8 @@ export function activate(): () => void {
|
||||
installDiagnosticsEvents(),
|
||||
installAvailabilityEvents(),
|
||||
registerEditorContribution({ id: "lsp", order: 50, extensions: lspExtensions }),
|
||||
// After the run icons (order 55), as their own gutter column.
|
||||
registerEditorContribution({ id: "lsp.lineMarkers", order: 56, extensions: implementationGutter }),
|
||||
// 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 }),
|
||||
|
||||
@@ -121,7 +121,7 @@ export function chooserTitle(kind: GotoKind, name: string | null, count: number,
|
||||
}
|
||||
|
||||
/** Pin button of the targets chooser: the targets as a Find tool window tab (code-insight). */
|
||||
function pinToFind(symbol: string, locations: readonly Location[]) {
|
||||
export function pinToFind(symbol: string, locations: readonly Location[]) {
|
||||
return {
|
||||
label: "Open in Find Tool Window",
|
||||
run: () => void executeAction("find.showUsageResults", { symbol, locations }),
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/* Editor gutter inheritance icons (implemented / overridden / implementing / overriding). */
|
||||
.gutter :global(.cm-gutterElement) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
}
|
||||
.marker {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-small);
|
||||
cursor: pointer;
|
||||
}
|
||||
.marker:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* Gutter icons for inheritance: "is implemented / overridden" (↓) on types and
|
||||
* methods with implementations or overriders, "implements / overrides" (↑) on
|
||||
* methods with a super method. Clicking one navigates to its single target or
|
||||
* opens the targets chooser.
|
||||
*
|
||||
* Generic over language servers: ↓ comes from implementation requests (one
|
||||
* cancellable batch per pass), ↑ from the type hierarchy and the supertypes'
|
||||
* document symbols (as Super Method, ⌘U). Only declarations in and near the
|
||||
* viewport are asked, in one debounced pass after edits / scrolling; results
|
||||
* are cached per document text, and markers follow their lines in between.
|
||||
*/
|
||||
import { RangeSet, StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
|
||||
import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view";
|
||||
import type { EditorDocContext } from "@/editor/api";
|
||||
import { docOf, debounce, isUnavailable, lspRequest } from "@/editor/lsp/lsp";
|
||||
import { serverBackoff } from "@/editor/lsp/availability";
|
||||
import { readManyFileLines, withDocument } from "@/editor/lsp/files";
|
||||
import { locationItems, navigateTo, pinToFind } from "@/editor/lsp/navigation";
|
||||
import { openPopup } from "@/editor/lsp/popupStore";
|
||||
import {
|
||||
commands,
|
||||
isCancelled,
|
||||
nextRequestId,
|
||||
onIdeEvent,
|
||||
type HierarchyItem,
|
||||
type Location,
|
||||
type OutlineItem,
|
||||
type Pos,
|
||||
type RequestId,
|
||||
} from "@/platform/ipc";
|
||||
import { editorPrefs, type EditorExtras } from "@/platform/editorSettings";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { iconMarkup } from "@/ui";
|
||||
import {
|
||||
MARKER_ICONS,
|
||||
candidateKey,
|
||||
inheritorsMarker,
|
||||
markerCandidates,
|
||||
supersMarker,
|
||||
withoutSelf,
|
||||
type LineMarker,
|
||||
type MarkerCandidate,
|
||||
type MarkerGroup,
|
||||
} from "./lineMarkers";
|
||||
import { superMethodTargets, type SuperDeps, type SuperMethod } from "./structureNav";
|
||||
import styles from "./LineMarkers.module.css";
|
||||
|
||||
/** Lines above / below the viewport that are covered too, so scrolling feels instant. */
|
||||
const MARGIN_LINES = 60;
|
||||
const MAX_PER_PASS = 60;
|
||||
const DEBOUNCE_MS = 500;
|
||||
|
||||
/** Settings › Editor › General › Gutter Icons groups that are on. */
|
||||
export function enabledGroups(prefs: Pick<EditorExtras, "showGutterIcons" | "gutterIconsDisabled">): Set<MarkerGroup> {
|
||||
if (!prefs.showGutterIcons) return new Set();
|
||||
return new Set((["inheritors", "supers"] as const).filter((g) => !prefs.gutterIconsDisabled.includes(g)));
|
||||
}
|
||||
|
||||
export interface MarkerDeps {
|
||||
/** Implementations of the symbols at `positions`, one list each (rejects when the server cannot answer). */
|
||||
implementations: (positions: Pos[]) => Promise<Location[][]>;
|
||||
supers: SuperDeps;
|
||||
readFiles: (paths: readonly string[]) => Promise<Map<string, string[] | null>>;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
inheritors?: LineMarker | null;
|
||||
supers?: LineMarker | null;
|
||||
}
|
||||
|
||||
/** Results for one document text; `unsupported` remembers a server that cannot answer a group. */
|
||||
export interface MarkerCache {
|
||||
entries: Map<string, CacheEntry>;
|
||||
unsupported: Set<MarkerGroup>;
|
||||
}
|
||||
|
||||
export const emptyCache = (): MarkerCache => ({ entries: new Map(), unsupported: new Set() });
|
||||
|
||||
/**
|
||||
* Asks the server about the `candidates` not in `cache` yet (only the enabled
|
||||
* `groups`) and stores the answers. Cancellation propagates; an unavailable
|
||||
* server marks its group unsupported for this cache.
|
||||
*/
|
||||
export async function fillMarkers(
|
||||
path: string,
|
||||
candidates: readonly MarkerCandidate[],
|
||||
groups: ReadonlySet<MarkerGroup>,
|
||||
deps: MarkerDeps,
|
||||
cache: MarkerCache,
|
||||
): Promise<void> {
|
||||
const entry = (c: MarkerCandidate) => {
|
||||
const key = candidateKey(c);
|
||||
let e = cache.entries.get(key);
|
||||
if (!e) cache.entries.set(key, (e = {}));
|
||||
return e;
|
||||
};
|
||||
const down =
|
||||
groups.has("inheritors") && !cache.unsupported.has("inheritors")
|
||||
? candidates.filter((c) => c.inheritable && cache.entries.get(candidateKey(c))?.inheritors === undefined)
|
||||
: [];
|
||||
const up =
|
||||
groups.has("supers") && !cache.unsupported.has("supers")
|
||||
? candidates.filter((c) => c.overriding && c.owner && cache.entries.get(candidateKey(c))?.supers === undefined)
|
||||
: [];
|
||||
|
||||
let implementations: Location[][] = [];
|
||||
if (down.length) {
|
||||
try {
|
||||
implementations = (await deps.implementations(down.map((c) => ({ line: c.line, col: c.col })))).map((locs, i) =>
|
||||
withoutSelf(path, down[i]!, locs),
|
||||
);
|
||||
} catch (e) {
|
||||
if (isCancelled(e)) throw e;
|
||||
if (isUnavailable(e)) cache.unsupported.add("inheritors");
|
||||
down.length = 0;
|
||||
}
|
||||
}
|
||||
const supers: SuperMethod[][] = [];
|
||||
for (const c of up) supers.push(await superMethodTargets(path, c.owner!, c.symbol, deps.supers));
|
||||
|
||||
const files = await deps.readFiles([
|
||||
...implementations.flat().map((l) => l.path),
|
||||
...supers.flat().map((s) => s.location.path),
|
||||
]);
|
||||
down.forEach((c, i) => (entry(c).inheritors = inheritorsMarker(c, implementations[i] ?? [], files)));
|
||||
up.forEach((c, i) => (entry(c).supers = supersMarker(c, supers[i]!, files)));
|
||||
}
|
||||
|
||||
/** Markers known for `candidates`, in document order (↓ before ↑ on a line). */
|
||||
export function cachedMarkers(
|
||||
candidates: readonly MarkerCandidate[],
|
||||
groups: ReadonlySet<MarkerGroup>,
|
||||
cache: MarkerCache,
|
||||
): LineMarker[] {
|
||||
const out: LineMarker[] = [];
|
||||
for (const c of candidates) {
|
||||
const e = cache.entries.get(candidateKey(c));
|
||||
if (!e) continue;
|
||||
if (groups.has("inheritors") && e.inheritors) out.push(e.inheritors);
|
||||
if (groups.has("supers") && e.supers) out.push(e.supers);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Opens the single target, or the chooser over several. */
|
||||
export async function openMarkerTargets(marker: LineMarker, anchor: { x: number; y: number }) {
|
||||
if (marker.targets.length === 1) {
|
||||
await navigateTo(marker.targets[0]!);
|
||||
return;
|
||||
}
|
||||
openPopup({
|
||||
kind: "list",
|
||||
anchor,
|
||||
title: marker.chooserTitle,
|
||||
items: await locationItems(marker.targets),
|
||||
pin: pinToFind(marker.name, marker.targets),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CodeMirror
|
||||
|
||||
interface PlacedMarker {
|
||||
marker: LineMarker;
|
||||
/** Line start offset in the current state. */
|
||||
pos: number;
|
||||
}
|
||||
|
||||
const setMarkers = StateEffect.define<PlacedMarker[]>();
|
||||
|
||||
const markersField = StateField.define<PlacedMarker[]>({
|
||||
create: () => [],
|
||||
update(value, tr) {
|
||||
for (const e of tr.effects) if (e.is(setMarkers)) return e.value;
|
||||
if (!tr.docChanged || !value.length) return value;
|
||||
return value.map((m) => ({ ...m, pos: tr.state.doc.lineAt(tr.changes.mapPos(m.pos, -1)).from }));
|
||||
},
|
||||
});
|
||||
|
||||
function place(doc: Text, markers: readonly LineMarker[]): PlacedMarker[] {
|
||||
return markers.filter((m) => m.line < doc.lines).map((marker) => ({ marker, pos: doc.line(marker.line + 1).from }));
|
||||
}
|
||||
|
||||
const sameMarkers = (a: readonly PlacedMarker[], b: readonly PlacedMarker[]) =>
|
||||
a.length === b.length &&
|
||||
a.every(
|
||||
(m, i) => m.pos === b[i]!.pos && m.marker.kind === b[i]!.marker.kind && m.marker.tooltip === b[i]!.marker.tooltip,
|
||||
);
|
||||
|
||||
class InheritanceMarker extends GutterMarker {
|
||||
constructor(readonly marker: LineMarker) {
|
||||
super();
|
||||
}
|
||||
override eq(other: GutterMarker): boolean {
|
||||
return (
|
||||
other instanceof InheritanceMarker &&
|
||||
other.marker.kind === this.marker.kind &&
|
||||
other.marker.tooltip === this.marker.tooltip
|
||||
);
|
||||
}
|
||||
override toDOM(): Node {
|
||||
const el = document.createElement("div");
|
||||
el.className = `${styles.marker} cm-openidea-lineMarker-${this.marker.kind}`;
|
||||
el.dataset.kind = this.marker.kind;
|
||||
el.title = this.marker.tooltip;
|
||||
el.setAttribute("role", "button");
|
||||
el.setAttribute("aria-label", this.marker.tooltip);
|
||||
el.innerHTML = iconMarkup(MARKER_ICONS[this.marker.kind], 14) ?? "";
|
||||
return el;
|
||||
}
|
||||
}
|
||||
|
||||
const gutterMarkers = new WeakMap<readonly PlacedMarker[], RangeSet<GutterMarker>>();
|
||||
|
||||
function markerSet(placed: readonly PlacedMarker[]): RangeSet<GutterMarker> {
|
||||
let set = gutterMarkers.get(placed);
|
||||
if (!set) {
|
||||
set = RangeSet.of(
|
||||
placed.map((m) => new InheritanceMarker(m.marker).range(m.pos)),
|
||||
true,
|
||||
);
|
||||
gutterMarkers.set(placed, set);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
class MarkersPlugin {
|
||||
private refresh = debounce(() => void this.run(), DEBOUNCE_MS);
|
||||
private cache = emptyCache();
|
||||
private cacheDoc: Text | null = null;
|
||||
private outline: { doc: Text; items: OutlineItem[]; candidates: MarkerCandidate[] } | null = null;
|
||||
private generation = 0;
|
||||
private request: RequestId | null = null;
|
||||
private offSettings: () => void;
|
||||
private offServers: () => void;
|
||||
|
||||
constructor(private view: EditorView) {
|
||||
this.refresh();
|
||||
this.offSettings = useProject.subscribe((s, prev) => {
|
||||
if (s.settings !== prev.settings) this.refresh();
|
||||
});
|
||||
// Answers given while a server was starting or indexing may be incomplete.
|
||||
this.offServers = onIdeEvent("languageServer", (e) => {
|
||||
if (e.status !== "ready") return;
|
||||
this.invalidate();
|
||||
this.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
update(u: ViewUpdate) {
|
||||
if (u.docChanged || u.viewportChanged) this.refresh();
|
||||
// Other files may have changed meanwhile (a new implementation).
|
||||
else if (u.focusChanged && u.view.hasFocus) {
|
||||
this.invalidate();
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
private invalidate() {
|
||||
this.cacheDoc = null;
|
||||
this.outline = null;
|
||||
}
|
||||
|
||||
private cancelRequest() {
|
||||
if (this.request === null) return;
|
||||
void commands.cancelRequest(this.request).catch(() => {});
|
||||
this.request = null;
|
||||
}
|
||||
|
||||
private show(placed: PlacedMarker[]) {
|
||||
if (!sameMarkers(this.view.state.field(markersField), placed))
|
||||
this.view.dispatch({ effects: setMarkers.of(placed) });
|
||||
}
|
||||
|
||||
private async run() {
|
||||
const view = this.view;
|
||||
if (!view.dom.isConnected) return;
|
||||
const ctx = docOf(view);
|
||||
if (!ctx) return;
|
||||
const gen = ++this.generation;
|
||||
this.cancelRequest();
|
||||
const groups = enabledGroups(editorPrefs());
|
||||
if (!groups.size || serverBackoff.active(ctx.doc)) {
|
||||
if (!groups.size) this.show([]);
|
||||
return;
|
||||
}
|
||||
const text = view.state.doc;
|
||||
const stale = () => gen !== this.generation || view.state.doc !== text;
|
||||
if (this.cacheDoc !== text) {
|
||||
this.cache = emptyCache();
|
||||
this.cacheDoc = text;
|
||||
}
|
||||
try {
|
||||
if (this.outline?.doc !== text) {
|
||||
const items = await lspRequest(() => commands.docOutline(ctx.doc));
|
||||
if (stale()) return;
|
||||
this.outline = {
|
||||
doc: text,
|
||||
items,
|
||||
candidates: markerCandidates(items, (l) => (l < text.lines ? text.line(l + 1).text : "")),
|
||||
};
|
||||
}
|
||||
const { candidates, items } = this.outline;
|
||||
const { from, to } = view.viewport;
|
||||
const first = text.lineAt(from).number - 1 - MARGIN_LINES;
|
||||
const last = text.lineAt(to).number - 1 + MARGIN_LINES;
|
||||
const visible = candidates.filter((c) => c.line >= first && c.line <= last).slice(0, MAX_PER_PASS);
|
||||
const request = (this.request = nextRequestId());
|
||||
await fillMarkers(ctx.path, visible, groups, markerDeps(ctx, items, request, this.cache), this.cache);
|
||||
if (this.request === request) this.request = null;
|
||||
if (stale()) return;
|
||||
this.show(place(text, cachedMarkers(candidates, groups, this.cache)));
|
||||
} catch (e) {
|
||||
if (isUnavailable(e)) serverBackoff.mark(ctx.doc);
|
||||
else if (!isCancelled(e)) console.debug("[lineMarkers]", e);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.refresh.cancel();
|
||||
this.cancelRequest();
|
||||
this.offSettings();
|
||||
this.offServers();
|
||||
this.generation++;
|
||||
}
|
||||
}
|
||||
|
||||
/** LSP access for one pass: this document's outline as known, others' opened on demand, each asked once. */
|
||||
function markerDeps(ctx: EditorDocContext, items: OutlineItem[], request: RequestId, cache: MarkerCache): MarkerDeps {
|
||||
const supertypes = new Map<string, Promise<HierarchyItem[]>>();
|
||||
const outlines = new Map<string, Promise<OutlineItem[]>>([[ctx.path, Promise.resolve(items)]]);
|
||||
const hierarchy = (doc: number, pos: Pos) => lspRequest(() => commands.lspTypeHierarchy(doc, pos, "incoming"));
|
||||
return {
|
||||
implementations: (positions) => lspRequest(() => commands.lspImplementations(request, ctx.doc, positions)),
|
||||
supers: {
|
||||
supertypes: (path, pos) => {
|
||||
const key = `${path}:${pos.line}:${pos.col}`;
|
||||
let p = supertypes.get(key);
|
||||
if (!p) {
|
||||
p =
|
||||
path === ctx.path
|
||||
? hierarchy(ctx.doc, pos).catch((e) => {
|
||||
// No type hierarchy for this file: no ↑ markers until the server changes.
|
||||
if (isUnavailable(e)) cache.unsupported.add("supers");
|
||||
throw e;
|
||||
})
|
||||
: withDocument(path, (doc) => hierarchy(doc, pos));
|
||||
supertypes.set(key, p);
|
||||
}
|
||||
return p;
|
||||
},
|
||||
outline: (path) => {
|
||||
let p = outlines.get(path);
|
||||
if (!p) outlines.set(path, (p = withDocument(path, (doc) => lspRequest(() => commands.docOutline(doc)))));
|
||||
return p;
|
||||
},
|
||||
},
|
||||
readFiles: readManyFileLines,
|
||||
};
|
||||
}
|
||||
|
||||
function markerAt(view: EditorView, lineFrom: number, el: Element | null): LineMarker | null {
|
||||
const onLine = view.state.field(markersField).filter((m) => m.pos === lineFrom);
|
||||
const kind = (el?.closest(`.${styles.marker}`) as HTMLElement | null)?.dataset.kind;
|
||||
return (onLine.find((m) => m.marker.kind === kind) ?? (onLine.length === 1 ? onLine[0] : null))?.marker ?? null;
|
||||
}
|
||||
|
||||
export function implementationGutter(ctx: EditorDocContext): Extension {
|
||||
if (ctx.doc < 0) return [];
|
||||
return [
|
||||
markersField,
|
||||
ViewPlugin.fromClass(MarkersPlugin),
|
||||
gutter({
|
||||
class: `${styles.gutter} cm-openidea-gutter-lineMarkers`,
|
||||
markers: (view) => markerSet(view.state.field(markersField)),
|
||||
domEventHandlers: {
|
||||
mousedown: (view, block, event) => {
|
||||
const e = event as MouseEvent;
|
||||
if (e.button !== 0) return false;
|
||||
const target = e.target instanceof Element ? e.target : null;
|
||||
const marker = markerAt(view, block.from, target);
|
||||
if (!marker) return false;
|
||||
e.preventDefault();
|
||||
const rect = (target?.closest(`.${styles.marker}`) ?? target)?.getBoundingClientRect();
|
||||
void openMarkerTargets(marker, rect ? { x: rect.left, y: rect.bottom + 2 } : { x: e.clientX, y: e.clientY });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -173,3 +173,5 @@ export function registerNavigationActions(): () => void {
|
||||
editorAction("navigate.previousMethod", "Previous Method", (v) => gotoMethod(v, -1)),
|
||||
]);
|
||||
}
|
||||
|
||||
export { implementationGutter } from "./implementationGutter";
|
||||
@@ -0,0 +1,353 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
IpcError,
|
||||
type HierarchyItem,
|
||||
type Location,
|
||||
type OutlineItem,
|
||||
type Pos,
|
||||
type SymbolKind,
|
||||
} from "@/platform/ipc";
|
||||
|
||||
const nav = vi.hoisted(() => ({ navigateTo: vi.fn(async () => {}), openPopup: vi.fn() }));
|
||||
vi.mock("@/editor/lsp/navigation", async (orig) => ({
|
||||
...(await orig<typeof import("@/editor/lsp/navigation")>()),
|
||||
navigateTo: nav.navigateTo,
|
||||
locationItems: async (locs: Location[]) => locs.map((l, i) => ({ id: `${i}`, label: l.path, run: () => {} })),
|
||||
}));
|
||||
vi.mock("@/editor/lsp/popupStore", async (orig) => ({
|
||||
...(await orig<typeof import("@/editor/lsp/popupStore")>()),
|
||||
openPopup: nav.openPopup,
|
||||
}));
|
||||
|
||||
import { candidateKey, markerCandidates, type MarkerCandidate } from "./lineMarkers";
|
||||
import {
|
||||
cachedMarkers,
|
||||
emptyCache,
|
||||
enabledGroups,
|
||||
fillMarkers,
|
||||
openMarkerTargets,
|
||||
type MarkerDeps,
|
||||
} from "./implementationGutter";
|
||||
|
||||
const ALL = enabledGroups({ showGutterIcons: true, gutterIconsDisabled: [] });
|
||||
|
||||
function item(
|
||||
name: string,
|
||||
kind: SymbolKind,
|
||||
line: number,
|
||||
end: number,
|
||||
children: OutlineItem[] = [],
|
||||
col = 4,
|
||||
): OutlineItem {
|
||||
return {
|
||||
name,
|
||||
kind,
|
||||
detail: null,
|
||||
range: { start: { line, col: 0 }, end: { line: end, col: 1 } },
|
||||
selection_range: { start: { line, col }, end: { line, col: col + name.length } },
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
const loc = (path: string, line: number, col = 4): Location => ({
|
||||
path,
|
||||
range: { start: { line, col }, end: { line, col: col + 3 } },
|
||||
});
|
||||
|
||||
const hier = (name: string, kind: SymbolKind, path: string, line = 0): HierarchyItem => ({
|
||||
name,
|
||||
kind,
|
||||
detail: null,
|
||||
path,
|
||||
range: { start: { line, col: 0 }, end: { line: line + 20, col: 1 } },
|
||||
selectionRange: { start: { line, col: 4 }, end: { line, col: 4 + name.length } },
|
||||
fromRanges: [],
|
||||
});
|
||||
|
||||
// Shape.java, as jdtls outlines it.
|
||||
const SHAPE = [
|
||||
"public interface Shape {",
|
||||
" double area();",
|
||||
' default String label() { return ""; }',
|
||||
" static Shape unit() { return null; }",
|
||||
"}",
|
||||
];
|
||||
const SHAPE_OUTLINE = [
|
||||
item(
|
||||
"Shape",
|
||||
"interface",
|
||||
0,
|
||||
4,
|
||||
[
|
||||
item("area()", "method", 1, 1, [], 11),
|
||||
item("label()", "method", 2, 2, [], 19),
|
||||
item("unit()", "method", 3, 3, [], 17),
|
||||
],
|
||||
17,
|
||||
),
|
||||
];
|
||||
const SHAPE_PATH = "/p/Shape.java";
|
||||
|
||||
// Circle.java: implements Shape, overrides Base.label().
|
||||
const CIRCLE = [
|
||||
"public class Circle extends Base implements Shape {",
|
||||
" @Override",
|
||||
" public double area() { return 1; }",
|
||||
" private void helper() {}",
|
||||
' public final String label() { return "c"; }',
|
||||
"}",
|
||||
];
|
||||
const CIRCLE_OUTLINE = [
|
||||
item(
|
||||
"Circle",
|
||||
"class",
|
||||
0,
|
||||
5,
|
||||
[item("area()", "method", 1, 2), item("helper()", "method", 3, 3, [], 17), item("label()", "method", 4, 4, [], 24)],
|
||||
13,
|
||||
),
|
||||
];
|
||||
// Make `area()`'s identifier sit on line 2 (its range starts at the annotation).
|
||||
CIRCLE_OUTLINE[0]!.children[0]!.selection_range = { start: { line: 2, col: 18 }, end: { line: 2, col: 22 } };
|
||||
|
||||
const BASE = [
|
||||
"public abstract class Base {",
|
||||
" public abstract void run();",
|
||||
' public String label() { return ""; }',
|
||||
"}",
|
||||
];
|
||||
const BASE_OUTLINE = [
|
||||
item("Base", "class", 0, 3, [item("run()", "method", 1, 1, [], 25), item("label()", "method", 2, 2, [], 18)], 22),
|
||||
];
|
||||
|
||||
const FILES: Record<string, string[]> = {
|
||||
[SHAPE_PATH]: SHAPE,
|
||||
"/p/Circle.java": CIRCLE,
|
||||
"/p/Square.java": ["public class Square implements Shape {", " public double area() { return 2; }", "}"],
|
||||
"/p/Base.java": BASE,
|
||||
};
|
||||
|
||||
const lines = (text: string[]) => (l: number) => text[l] ?? "";
|
||||
|
||||
describe("marker candidates", () => {
|
||||
it("covers inheritable types and their methods, without private / static / final ones", () => {
|
||||
const shape = markerCandidates(SHAPE_OUTLINE, lines(SHAPE));
|
||||
expect(shape.map((c) => [c.name, c.abstract, c.inheritable, c.overriding])).toEqual([
|
||||
["Shape", true, true, false],
|
||||
["area", true, true, true],
|
||||
// A default method has a body: overriding it is not implementing it.
|
||||
["label", false, true, true],
|
||||
["unit", true, false, false],
|
||||
]);
|
||||
const circle = markerCandidates(CIRCLE_OUTLINE, lines(CIRCLE));
|
||||
expect(circle.map((c) => [c.name, c.line, c.inheritable, c.overriding])).toEqual([
|
||||
["Circle", 0, true, false],
|
||||
["area", 2, true, true],
|
||||
["helper", 3, false, false],
|
||||
["label", 4, false, true],
|
||||
]);
|
||||
expect(markerCandidates(BASE_OUTLINE, lines(BASE)).map((c) => [c.name, c.abstract])).toEqual([
|
||||
["Base", true],
|
||||
["run", true],
|
||||
["label", false],
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips structs, enums and free functions for implementation requests", () => {
|
||||
const outline = [item("Point", "struct", 0, 3, [item("len", "method", 1, 1)]), item("main", "function", 5, 6)];
|
||||
const found = markerCandidates(outline, () => "");
|
||||
expect(found.map((c) => [c.name, c.inheritable, c.overriding])).toEqual([["len", false, true]]);
|
||||
});
|
||||
});
|
||||
|
||||
/** A server answering implementation and type hierarchy requests over the files above. */
|
||||
function server(impls: Record<string, Location[]>, supers: Record<string, HierarchyItem[]>) {
|
||||
const calls = { implementations: [] as Pos[][], supertypes: 0 };
|
||||
const outlines: Record<string, OutlineItem[]> = {
|
||||
[SHAPE_PATH]: SHAPE_OUTLINE,
|
||||
"/p/Circle.java": CIRCLE_OUTLINE,
|
||||
"/p/Base.java": BASE_OUTLINE,
|
||||
};
|
||||
const deps = (path: string): MarkerDeps => ({
|
||||
implementations: async (positions) => {
|
||||
calls.implementations.push(positions);
|
||||
return positions.map((p) => impls[`${path}:${p.line}`] ?? []);
|
||||
},
|
||||
supers: {
|
||||
supertypes: async (p) => {
|
||||
calls.supertypes++;
|
||||
return supers[p] ?? [];
|
||||
},
|
||||
outline: async (p) => outlines[p] ?? [],
|
||||
},
|
||||
readFiles: async (paths) => new Map(paths.map((p) => [p, FILES[p] ?? null])),
|
||||
});
|
||||
return { calls, deps };
|
||||
}
|
||||
|
||||
describe("markers from server answers", () => {
|
||||
const shapeImpls = {
|
||||
// The server lists the declaration itself too.
|
||||
[`${SHAPE_PATH}:0`]: [loc(SHAPE_PATH, 0), loc("/p/Circle.java", 0, 13), loc("/p/Square.java", 0, 13)],
|
||||
[`${SHAPE_PATH}:1`]: [loc("/p/Circle.java", 2, 18), loc("/p/Square.java", 1, 18)],
|
||||
};
|
||||
|
||||
it("marks implemented interfaces and methods with every implementation as targets", async () => {
|
||||
const { deps, calls } = server(shapeImpls, {});
|
||||
const candidates = markerCandidates(SHAPE_OUTLINE, lines(SHAPE));
|
||||
const cache = emptyCache();
|
||||
await fillMarkers(SHAPE_PATH, candidates, ALL, deps(SHAPE_PATH), cache);
|
||||
// One batch for every inheritable declaration.
|
||||
expect(calls.implementations).toEqual([
|
||||
[
|
||||
{ line: 0, col: 17 },
|
||||
{ line: 1, col: 11 },
|
||||
{ line: 2, col: 19 },
|
||||
],
|
||||
]);
|
||||
const markers = cachedMarkers(candidates, ALL, cache);
|
||||
expect(markers.map((m) => [m.line, m.kind, m.targets.length, m.tooltip])).toEqual([
|
||||
[0, "implemented", 2, "Is implemented by Circle, Square"],
|
||||
[1, "implemented", 2, "Is implemented in Circle, Square"],
|
||||
]);
|
||||
expect(markers[0]!.chooserTitle).toBe("Choose Implementation of Shape (2 found)");
|
||||
});
|
||||
|
||||
it("marks subclassed classes and overridden methods", async () => {
|
||||
const { deps } = server(
|
||||
{
|
||||
"/p/Base.java:0": [loc("/p/Circle.java", 0, 13)],
|
||||
"/p/Base.java:2": [loc("/p/Circle.java", 4, 25)],
|
||||
},
|
||||
{},
|
||||
);
|
||||
const candidates = markerCandidates(BASE_OUTLINE, lines(BASE));
|
||||
const cache = emptyCache();
|
||||
await fillMarkers("/p/Base.java", candidates, ALL, deps("/p/Base.java"), cache);
|
||||
const markers = cachedMarkers(candidates, ALL, cache);
|
||||
// `abstract class Base` is implemented, `label()` overridden.
|
||||
expect(markers.map((m) => [m.line, m.kind, m.tooltip])).toEqual([
|
||||
[0, "implemented", "Is implemented by Circle"],
|
||||
[2, "overridden", "Is overridden in Circle"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks implementing and overriding methods from the type hierarchy", async () => {
|
||||
const supers = {
|
||||
"/p/Circle.java": [hier("Base", "class", "/p/Base.java"), hier("Shape", "interface", SHAPE_PATH)],
|
||||
};
|
||||
const { deps, calls } = server({}, supers);
|
||||
const candidates = markerCandidates(CIRCLE_OUTLINE, lines(CIRCLE));
|
||||
const cache = emptyCache();
|
||||
await fillMarkers("/p/Circle.java", candidates, ALL, deps("/p/Circle.java"), cache);
|
||||
const markers = cachedMarkers(candidates, ALL, cache);
|
||||
expect(
|
||||
markers.map((m) => [m.line, m.kind, m.tooltip, m.targets.map((t) => `${t.path}:${t.range.start.line}`)]),
|
||||
).toEqual([
|
||||
[2, "implementing", "Implements method in Shape", [`${SHAPE_PATH}:1`]],
|
||||
// Base.label() and the default Shape.label() have bodies.
|
||||
[4, "overriding", "Overrides method in Base, Shape", ["/p/Base.java:2", `${SHAPE_PATH}:2`]],
|
||||
]);
|
||||
expect(markers[1]!.chooserTitle).toBe("Choose Super Method of label (2 found)");
|
||||
expect(calls.supertypes).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("asks once per document version and only for enabled groups", async () => {
|
||||
const { deps, calls } = server(shapeImpls, {});
|
||||
const candidates = markerCandidates(SHAPE_OUTLINE, lines(SHAPE));
|
||||
const cache = emptyCache();
|
||||
await fillMarkers(SHAPE_PATH, candidates, ALL, deps(SHAPE_PATH), cache);
|
||||
await fillMarkers(SHAPE_PATH, candidates, ALL, deps(SHAPE_PATH), cache);
|
||||
expect(calls.implementations).toHaveLength(1);
|
||||
// A new document version starts from an empty cache.
|
||||
await fillMarkers(SHAPE_PATH, candidates, ALL, deps(SHAPE_PATH), emptyCache());
|
||||
expect(calls.implementations).toHaveLength(2);
|
||||
|
||||
const supersOnly = enabledGroups({ showGutterIcons: true, gutterIconsDisabled: ["inheritors"] });
|
||||
const fresh = emptyCache();
|
||||
await fillMarkers(SHAPE_PATH, candidates, supersOnly, deps(SHAPE_PATH), fresh);
|
||||
expect(calls.implementations).toHaveLength(2);
|
||||
expect(cachedMarkers(candidates, ALL, cache)).toHaveLength(2);
|
||||
expect(cachedMarkers(candidates, supersOnly, cache)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("stops asking a server that cannot answer, and caches nothing when cancelled", async () => {
|
||||
const candidates = markerCandidates(SHAPE_OUTLINE, lines(SHAPE));
|
||||
const failing = (kind: "unavailable" | "cancelled") => {
|
||||
const calls = { n: 0 };
|
||||
const deps: MarkerDeps = {
|
||||
implementations: async () => {
|
||||
calls.n++;
|
||||
throw new IpcError({ kind, message: kind });
|
||||
},
|
||||
supers: { supertypes: async () => [], outline: async () => [] },
|
||||
readFiles: async () => new Map(),
|
||||
};
|
||||
return { calls, deps };
|
||||
};
|
||||
const off = failing("unavailable");
|
||||
const cache = emptyCache();
|
||||
await fillMarkers(SHAPE_PATH, candidates, ALL, off.deps, cache);
|
||||
await fillMarkers(SHAPE_PATH, candidates, ALL, off.deps, cache);
|
||||
expect(off.calls.n).toBe(1);
|
||||
expect(cache.unsupported.has("inheritors")).toBe(true);
|
||||
|
||||
const cancelled = failing("cancelled");
|
||||
const kept = emptyCache();
|
||||
await expect(fillMarkers(SHAPE_PATH, candidates, ALL, cancelled.deps, kept)).rejects.toThrow();
|
||||
expect(kept.entries.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings", () => {
|
||||
it("follows Settings › Gutter Icons", () => {
|
||||
expect([...enabledGroups({ showGutterIcons: true, gutterIconsDisabled: [] })]).toEqual(["inheritors", "supers"]);
|
||||
expect([...enabledGroups({ showGutterIcons: true, gutterIconsDisabled: ["supers", "run"] })]).toEqual([
|
||||
"inheritors",
|
||||
]);
|
||||
expect(enabledGroups({ showGutterIcons: false, gutterIconsDisabled: [] }).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clicking a marker", () => {
|
||||
beforeEach(() => {
|
||||
nav.navigateTo.mockClear();
|
||||
nav.openPopup.mockClear();
|
||||
});
|
||||
const candidate = (): MarkerCandidate => markerCandidates(SHAPE_OUTLINE, lines(SHAPE))[0]!;
|
||||
|
||||
it("navigates to a single target", async () => {
|
||||
const target = loc("/p/Circle.java", 0, 13);
|
||||
await openMarkerTargets(
|
||||
{ kind: "implemented", line: 0, name: "Shape", targets: [target], tooltip: "", chooserTitle: "" },
|
||||
{ x: 1, y: 2 },
|
||||
);
|
||||
expect(nav.navigateTo).toHaveBeenCalledWith(target);
|
||||
expect(nav.openPopup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the chooser over several targets", async () => {
|
||||
const targets = [loc("/p/Circle.java", 0, 13), loc("/p/Square.java", 0, 13)];
|
||||
expect(candidateKey(candidate())).toBe("0:17:Shape");
|
||||
await openMarkerTargets(
|
||||
{
|
||||
kind: "implemented",
|
||||
line: 0,
|
||||
name: "Shape",
|
||||
targets,
|
||||
tooltip: "",
|
||||
chooserTitle: "Choose Implementation of Shape (2 found)",
|
||||
},
|
||||
{ x: 1, y: 2 },
|
||||
);
|
||||
expect(nav.navigateTo).not.toHaveBeenCalled();
|
||||
expect(nav.openPopup).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: "list",
|
||||
anchor: { x: 1, y: 2 },
|
||||
title: "Choose Implementation of Shape (2 found)",
|
||||
}),
|
||||
);
|
||||
expect(nav.openPopup.mock.calls[0]![0].items).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Inheritance line markers: which declarations of a document get an
|
||||
* "is implemented / overridden" (↓) or "implements / overrides" (↑) gutter
|
||||
* icon, from the language server's answers (implementation requests and the
|
||||
* type hierarchy). Pure; the gutter itself is `implementationGutter`.
|
||||
*/
|
||||
import type { Location, OutlineItem, SymbolKind } from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { identifierAt } from "@/editor/lsp/files";
|
||||
import { baseName, type SuperMethod } from "./structureNav";
|
||||
|
||||
export type MarkerKind = "implemented" | "overridden" | "implementing" | "overriding";
|
||||
|
||||
/** Settings › Gutter Icons groups: ↓ markers and ↑ markers. */
|
||||
export type MarkerGroup = "inheritors" | "supers";
|
||||
|
||||
export const markerGroup = (kind: MarkerKind): MarkerGroup =>
|
||||
kind === "implemented" || kind === "overridden" ? "inheritors" : "supers";
|
||||
|
||||
export const MARKER_ICONS: Record<MarkerKind, string> = {
|
||||
implemented: "gutter-implemented",
|
||||
overridden: "gutter-overridden",
|
||||
implementing: "gutter-implementing",
|
||||
overriding: "gutter-overriding",
|
||||
};
|
||||
|
||||
export interface MarkerCandidate {
|
||||
/** Outline name (`eat(String)` with jdtls), matched against supertypes' members. */
|
||||
symbol: string;
|
||||
/** Bare name for titles. */
|
||||
name: string;
|
||||
/** 0-based line / UTF-16 col of the identifier. */
|
||||
line: number;
|
||||
col: number;
|
||||
method: boolean;
|
||||
/** Declared abstract: an interface / trait, a member of one, or marked `abstract`. */
|
||||
abstract: boolean;
|
||||
/** Can have implementations / overriders (asked with an implementation request). */
|
||||
inheritable: boolean;
|
||||
/** Can implement / override a supertype's method (looked up in the type hierarchy). */
|
||||
overriding: boolean;
|
||||
/** Identifier position of the enclosing type (methods). */
|
||||
owner: { line: number; col: number } | null;
|
||||
}
|
||||
|
||||
export interface LineMarker {
|
||||
kind: MarkerKind;
|
||||
line: number;
|
||||
/** Name of the declaration the marker is on. */
|
||||
name: string;
|
||||
targets: Location[];
|
||||
tooltip: string;
|
||||
/** Title of the targets chooser. */
|
||||
chooserTitle: string;
|
||||
}
|
||||
|
||||
/** Types whose subtypes / implementations are worth a marker (not structs, enums: they cannot be extended). */
|
||||
const INHERITABLE_TYPES = new Set<SymbolKind>(["class", "interface", "trait"]);
|
||||
const ABSTRACT_TYPES = new Set<SymbolKind>(["interface", "trait"]);
|
||||
/** Types whose methods may implement or override a supertype's. */
|
||||
const MEMBER_OWNERS = new Set<SymbolKind>(["class", "interface", "trait", "enum", "struct"]);
|
||||
|
||||
/** Modifiers that rule out implementations / overriders. */
|
||||
const NOT_INHERITABLE = /\b(private|static|final)\b/;
|
||||
const NOT_OVERRIDING = /\b(private|static)\b/;
|
||||
const ABSTRACT = /\babstract\b/;
|
||||
/** An interface method with a body (Java `default`). */
|
||||
const DEFAULT = /\bdefault\b/;
|
||||
|
||||
/** The declaration's text before its identifier (annotations and modifiers included). */
|
||||
function declarationPrefix(item: OutlineItem, lineText: (line: number) => string): string {
|
||||
const { start } = item.range;
|
||||
const at = item.selection_range.start;
|
||||
if (start.line > at.line) return lineText(at.line).slice(0, at.col);
|
||||
const lines: string[] = [];
|
||||
for (let l = start.line; l <= at.line && lines.length < 12; l++) {
|
||||
const text = lineText(l);
|
||||
lines.push(
|
||||
l === at.line
|
||||
? text.slice(l === start.line ? start.col : 0, at.col)
|
||||
: l === start.line
|
||||
? text.slice(start.col)
|
||||
: text,
|
||||
);
|
||||
}
|
||||
// Annotation arguments (`@Foo(final = …)`) are not modifiers.
|
||||
return lines.join(" ").replace(/@[\w.]+(\([^)]*\))?/g, " ");
|
||||
}
|
||||
|
||||
/** Declarations that may get a marker, in document order. */
|
||||
export function markerCandidates(
|
||||
outline: readonly OutlineItem[],
|
||||
lineText: (line: number) => string,
|
||||
): MarkerCandidate[] {
|
||||
const out: MarkerCandidate[] = [];
|
||||
const walk = (items: readonly OutlineItem[], parent: OutlineItem | null) => {
|
||||
for (const it of items) {
|
||||
const prefix = () => declarationPrefix(it, lineText);
|
||||
const at = it.selection_range.start;
|
||||
if (INHERITABLE_TYPES.has(it.kind)) {
|
||||
const p = prefix();
|
||||
out.push({
|
||||
symbol: it.name,
|
||||
name: baseName(it.name),
|
||||
line: at.line,
|
||||
col: at.col,
|
||||
method: false,
|
||||
abstract: ABSTRACT_TYPES.has(it.kind) || ABSTRACT.test(p),
|
||||
inheritable: !NOT_INHERITABLE.test(p),
|
||||
overriding: false,
|
||||
owner: null,
|
||||
});
|
||||
} else if (it.kind === "method" && parent && MEMBER_OWNERS.has(parent.kind)) {
|
||||
const p = prefix();
|
||||
const inParent = ABSTRACT_TYPES.has(parent.kind);
|
||||
out.push({
|
||||
symbol: it.name,
|
||||
name: baseName(it.name),
|
||||
line: at.line,
|
||||
col: at.col,
|
||||
method: true,
|
||||
abstract: (inParent && !DEFAULT.test(p)) || ABSTRACT.test(p),
|
||||
inheritable: INHERITABLE_TYPES.has(parent.kind) && !NOT_INHERITABLE.test(p),
|
||||
overriding: !NOT_OVERRIDING.test(p),
|
||||
owner: parent.selection_range.start,
|
||||
});
|
||||
}
|
||||
if (it.children.length) walk(it.children, it);
|
||||
}
|
||||
};
|
||||
walk(outline, null);
|
||||
return out.sort((a, b) => a.line - b.line || a.col - b.col);
|
||||
}
|
||||
|
||||
export const candidateKey = (c: MarkerCandidate) => `${c.line}:${c.col}:${c.symbol}`;
|
||||
|
||||
/** Servers answer an implementation request on a declaration with the declaration itself too. */
|
||||
export function withoutSelf(path: string, c: MarkerCandidate, targets: readonly Location[]): Location[] {
|
||||
return targets.filter((t) => !(t.path === path && t.range.start.line === c.line));
|
||||
}
|
||||
|
||||
const TYPE_DECLARATION = /\b(?:class|interface|trait|object|struct|enum|record|impl)\s+([\p{L}_$][\p{L}\p{N}_$]*)/u;
|
||||
|
||||
/** Name of the type a target sits in: the nearest type declaration above it, else the file name. */
|
||||
export function enclosingTypeName(lines: readonly string[] | null | undefined, target: Location): string {
|
||||
for (let l = target.range.start.line - 1; lines && l >= 0 && l >= target.range.start.line - 2000; l--) {
|
||||
const m = TYPE_DECLARATION.exec(lines[l] ?? "");
|
||||
if (m && !/^\s*(\/\/|\*|\/\*)/.test(lines[l] ?? "")) return m[1]!;
|
||||
}
|
||||
const file = target.path.split(/[\\/]/).pop() ?? target.path;
|
||||
return file.replace(/\.[^.]+$/, "");
|
||||
}
|
||||
|
||||
/** Display name of a target: the type itself for type markers, the type around it for methods. */
|
||||
export function targetName(lines: readonly string[] | null | undefined, target: Location, method: boolean): string {
|
||||
if (!method) {
|
||||
const name = identifierAt(lines?.[target.range.start.line] ?? "", target.range.start.col);
|
||||
if (name) return name;
|
||||
}
|
||||
return enclosingTypeName(lines, target);
|
||||
}
|
||||
|
||||
const MAX_NAMES = 8;
|
||||
|
||||
function nameList(names: readonly string[]): string {
|
||||
const unique = [...new Set(names)].sort((a, b) => a.localeCompare(b));
|
||||
const shown = unique.slice(0, MAX_NAMES).join(", ");
|
||||
return unique.length > MAX_NAMES ? tx`${shown} and ${unique.length - MAX_NAMES} more` : shown;
|
||||
}
|
||||
|
||||
/** ↓ marker of `c` from its implementations (self excluded already); null without any. */
|
||||
export function inheritorsMarker(
|
||||
c: MarkerCandidate,
|
||||
targets: readonly Location[],
|
||||
files: ReadonlyMap<string, string[] | null>,
|
||||
): LineMarker | null {
|
||||
if (!targets.length) return null;
|
||||
const kind: MarkerKind = c.abstract ? "implemented" : "overridden";
|
||||
const names = nameList(targets.map((t) => targetName(files.get(t.path), t, c.method)));
|
||||
const n = targets.length;
|
||||
let tooltip: string;
|
||||
let chooserTitle: string;
|
||||
if (!c.method) {
|
||||
tooltip = c.abstract ? tx`Is implemented by ${names}` : tx`Is subclassed by ${names}`;
|
||||
chooserTitle = c.abstract
|
||||
? tx`Choose Implementation of ${c.name} (${n} found)`
|
||||
: tx`Choose Subclass of ${c.name} (${n} found)`;
|
||||
} else {
|
||||
tooltip = c.abstract ? tx`Is implemented in ${names}` : tx`Is overridden in ${names}`;
|
||||
chooserTitle = c.abstract
|
||||
? tx`Choose Implementation of ${c.name} (${n} found)`
|
||||
: tx`Choose Overriding Method of ${c.name} (${n} found)`;
|
||||
}
|
||||
return { kind, line: c.line, name: c.name, targets: [...targets], tooltip, chooserTitle };
|
||||
}
|
||||
|
||||
/** Whether a super method is abstract: declared in an interface / trait, or marked `abstract`. */
|
||||
export function isAbstractSuper(s: SuperMethod, files: ReadonlyMap<string, string[] | null>): boolean {
|
||||
const lines = files.get(s.location.path);
|
||||
const prefix = lines ? declarationPrefix(s.method, (l) => lines[l] ?? "") : "";
|
||||
return (ABSTRACT_TYPES.has(s.owner.kind) && !DEFAULT.test(prefix)) || ABSTRACT.test(prefix);
|
||||
}
|
||||
|
||||
/** ↑ marker of method `c` from its super methods; null without any. */
|
||||
export function supersMarker(
|
||||
c: MarkerCandidate,
|
||||
supers: readonly SuperMethod[],
|
||||
files: ReadonlyMap<string, string[] | null>,
|
||||
): LineMarker | null {
|
||||
if (!supers.length) return null;
|
||||
// Implementing wins when one of several super methods is abstract, as an interface method would.
|
||||
const implementing = supers.some((s) => isAbstractSuper(s, files));
|
||||
const owners = nameList(supers.map((s) => baseName(s.owner.name)));
|
||||
return {
|
||||
kind: implementing ? "implementing" : "overriding",
|
||||
line: c.line,
|
||||
name: c.name,
|
||||
targets: supers.map((s) => s.location),
|
||||
tooltip: implementing ? tx`Implements method in ${owners}` : tx`Overrides method in ${owners}`,
|
||||
chooserTitle: tx`Choose Super Method of ${c.name} (${supers.length} found)`,
|
||||
};
|
||||
}
|
||||
@@ -63,4 +63,25 @@ describe("Super Method", () => {
|
||||
// jdtls names methods with their parameters.
|
||||
expect(await superMethods("/Foo.java", { line: 0, col: 4 }, "a() : void", deps)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("prefers the overload with the same signature", async () => {
|
||||
const base: HierarchyItem = {
|
||||
name: "Base",
|
||||
kind: "class",
|
||||
detail: null,
|
||||
path: "/Base.java",
|
||||
range: r(0, 10),
|
||||
selectionRange: { start: { line: 0, col: 4 }, end: { line: 0, col: 8 } },
|
||||
fromRanges: [],
|
||||
};
|
||||
const deps = {
|
||||
supertypes: async (path: string) => (path === "/Foo.java" ? [base] : []),
|
||||
outline: async () => [item("Base", "class", 0, 10, [item("eat(String)", "method", 2, 2), item("eat(int)", "method", 4, 4)])],
|
||||
};
|
||||
const lines = async (name: string) => (await superMethods("/Foo.java", { line: 0, col: 4 }, name, deps)).map((l) => l.range.start.line);
|
||||
expect(await lines("eat(int)")).toEqual([4]);
|
||||
// Without a matching signature (or without signatures at all), every overload.
|
||||
expect(await lines("eat(T)")).toEqual([2, 4]);
|
||||
expect(await lines("eat")).toEqual([2, 4]);
|
||||
});
|
||||
});
|
||||
@@ -60,8 +60,11 @@ export function memberAt(outline: readonly OutlineItem[], caret: Pos): CaretMemb
|
||||
/** `beta(int)` (jdtls) and `beta` name the same method. */
|
||||
export const baseName = (name: string) => name.replace(/\s*[(<].*$/, "");
|
||||
|
||||
/** Methods named `name` directly inside the type declared at `item` (its outline node found by position). */
|
||||
export function methodsInType(outline: readonly OutlineItem[], item: HierarchyItem, name: string): OutlineItem[] {
|
||||
/** `beta(int) : void` (jdtls) is the signature `beta(int)`; plain names stay as they are. */
|
||||
const signature = (name: string) => name.replace(/\s*:.*$/, "");
|
||||
|
||||
/** The outline node of the type declared at `item` (found by position). */
|
||||
export function typeOf(outline: readonly OutlineItem[], item: HierarchyItem): OutlineItem | null {
|
||||
const at = item.selectionRange.start;
|
||||
const find = (items: readonly OutlineItem[]): OutlineItem | null => {
|
||||
for (const i of items) {
|
||||
@@ -71,9 +74,20 @@ export function methodsInType(outline: readonly OutlineItem[], item: HierarchyIt
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const type = find(outline);
|
||||
return find(outline);
|
||||
}
|
||||
|
||||
/**
|
||||
* Methods named `name` directly inside the type declared at `item`: the
|
||||
* overload with the same signature when the server names them with one
|
||||
* (`eat(String)`), else every method of that name.
|
||||
*/
|
||||
export function methodsInType(outline: readonly OutlineItem[], item: HierarchyItem, name: string): OutlineItem[] {
|
||||
const type = typeOf(outline, item);
|
||||
const bare = baseName(name);
|
||||
return type ? type.children.filter((c) => METHODS.has(c.kind) && baseName(c.name) === bare) : [];
|
||||
const named = type ? type.children.filter((c) => METHODS.has(c.kind) && baseName(c.name) === bare) : [];
|
||||
const exact = named.filter((c) => signature(c.name) === signature(name));
|
||||
return exact.length ? exact : named;
|
||||
}
|
||||
|
||||
export interface SuperDeps {
|
||||
@@ -81,13 +95,20 @@ export interface SuperDeps {
|
||||
outline: (path: string) => Promise<OutlineItem[]>;
|
||||
}
|
||||
|
||||
/** Super methods of `method` (a member of the type at `typePos`), searching up to `depth` levels of supertypes. */
|
||||
export async function superMethods(path: string, typePos: Pos, method: string, deps: SuperDeps, depth = 3): Promise<Location[]> {
|
||||
export interface SuperMethod {
|
||||
location: Location;
|
||||
/** The supertype declaring it. */
|
||||
owner: HierarchyItem;
|
||||
method: OutlineItem;
|
||||
}
|
||||
|
||||
/** Super methods of `method` (a member of the type at `typePos`), with their declaring types, nearest level first. */
|
||||
export async function superMethodTargets(path: string, typePos: Pos, method: string, deps: SuperDeps, depth = 3): Promise<SuperMethod[]> {
|
||||
let level: Array<{ path: string; pos: Pos }> = [{ path, pos: typePos }];
|
||||
const seen = new Set<string>();
|
||||
for (let d = 0; d < depth && level.length; d++) {
|
||||
const next: Array<{ path: string; pos: Pos }> = [];
|
||||
const found: Location[] = [];
|
||||
const found: SuperMethod[] = [];
|
||||
for (const t of level) {
|
||||
const supers = await deps.supertypes(t.path, t.pos).catch(() => [] as HierarchyItem[]);
|
||||
for (const s of supers) {
|
||||
@@ -95,7 +116,8 @@ export async function superMethods(path: string, typePos: Pos, method: string, d
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const methods = methodsInType(await deps.outline(s.path).catch(() => [] as OutlineItem[]), s, method);
|
||||
if (methods.length) for (const m of methods) found.push({ path: s.path, range: m.selection_range });
|
||||
if (methods.length)
|
||||
for (const m of methods) found.push({ location: { path: s.path, range: m.selection_range }, owner: s, method: m });
|
||||
else next.push({ path: s.path, pos: s.selectionRange.start });
|
||||
}
|
||||
}
|
||||
@@ -104,3 +126,8 @@ export async function superMethods(path: string, typePos: Pos, method: string, d
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Super methods of `method` (a member of the type at `typePos`), searching up to `depth` levels of supertypes. */
|
||||
export async function superMethods(path: string, typePos: Pos, method: string, deps: SuperDeps, depth = 3): Promise<Location[]> {
|
||||
return (await superMethodTargets(path, typePos, method, deps, depth)).map((s) => s.location);
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача