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
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-27 21:02:54 +08:00
1 джерело 6de6b33449
коміт 2fe6b7b87b
27 files changed
+1252 -22

No files matched your search

+2
Переглянути файл
@@ -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 {
+3
Переглянути файл
@@ -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" },
+3 -1
Переглянути файл
@@ -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 }),
+1 -1
Переглянути файл
@@ -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 }),
+19
Переглянути файл
@@ -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);
}
+392
Переглянути файл
@@ -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;
},
},
}),
];
}
+2
Переглянути файл
@@ -173,3 +173,5 @@ export function registerNavigationActions(): () => void {
editorAction("navigate.previousMethod", "Previous Method", (v) => gotoMethod(v, -1)),
]);
}
export { implementationGutter } from "./implementationGutter";
+353
Переглянути файл
@@ -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);
});
});
+222
Переглянути файл
@@ -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)`,
};
}
+21
Переглянути файл
@@ -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]);
});
});
+35 -8
Переглянути файл
@@ -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);
}