From 2fe6b7b87bc936c96b22e45507cbfc3606fe970b Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:02:54 +0800 Subject: [PATCH] feat(editor): Implemented / overridden gutter markers (#106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/ide-lsp/src/lib.rs | 66 ++- crates/ide-lsp/tests/mock_server.rs | 33 ++ src-tauri/src/commands/lsp.rs | 19 + src-tauri/src/commands/mod.rs | 1 + src/bindings.ts | 6 + src/editor/core/cm/prefs.ts | 2 + src/editor/core/cm/theme.ts | 3 + src/editor/lsp/index.ts | 4 +- src/editor/lsp/navigation.ts | 2 +- src/editor/navigation/LineMarkers.module.css | 19 + src/editor/navigation/implementationGutter.ts | 392 ++++++++++++++++++ src/editor/navigation/index.ts | 2 + src/editor/navigation/lineMarkers.test.ts | 353 ++++++++++++++++ src/editor/navigation/lineMarkers.ts | 222 ++++++++++ src/editor/navigation/structureNav.test.ts | 21 + src/editor/navigation/structureNav.ts | 43 +- .../settings/pages/editor/general.tsx | 12 + src/platform/locales/text/zh.json | 14 + src/ui/expui/gutter/implementedMethod.svg | 7 + .../expui/gutter/implementedMethod_dark.svg | 7 + src/ui/expui/gutter/implementingMethod.svg | 7 + .../expui/gutter/implementingMethod_dark.svg | 7 + src/ui/expui/gutter/overridenMethod.svg | 7 + src/ui/expui/gutter/overridenMethod_dark.svg | 7 + src/ui/expui/gutter/overridingMethod.svg | 7 + src/ui/expui/gutter/overridingMethod_dark.svg | 7 + src/ui/icons.tsx | 4 + 27 files changed, 1252 insertions(+), 22 deletions(-) create mode 100644 src/editor/navigation/LineMarkers.module.css create mode 100644 src/editor/navigation/implementationGutter.ts create mode 100644 src/editor/navigation/lineMarkers.test.ts create mode 100644 src/editor/navigation/lineMarkers.ts create mode 100644 src/ui/expui/gutter/implementedMethod.svg create mode 100644 src/ui/expui/gutter/implementedMethod_dark.svg create mode 100644 src/ui/expui/gutter/implementingMethod.svg create mode 100644 src/ui/expui/gutter/implementingMethod_dark.svg create mode 100644 src/ui/expui/gutter/overridenMethod.svg create mode 100644 src/ui/expui/gutter/overridenMethod_dark.svg create mode 100644 src/ui/expui/gutter/overridingMethod.svg create mode 100644 src/ui/expui/gutter/overridingMethod_dark.svg diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 0765e0f6..9a0489ac 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -29,6 +29,7 @@ pub use uri::{is_virtual_uri, path_to_uri_string, uri_target, uri_to_path}; use caps::{ServerCaps, SyncKind}; use convert::{Conv, lsp_range}; +use futures::StreamExt; use futures::future::join_all; use ide_core::error::ErrorKind; use ide_core::{ @@ -228,6 +229,18 @@ impl Outcome { } } +/// Implementation requests of one line-marker pass in flight at once. +const IMPLEMENTATION_CONCURRENCY: usize = 4; + +fn goto_method(kind: GotoKind) -> (&'static str, &'static str, &'static str) { + match kind { + GotoKind::Definition => ("textDocument/definition", "definitionProvider", "go to definition"), + GotoKind::Declaration => ("textDocument/declaration", "declarationProvider", "go to declaration"), + GotoKind::TypeDefinition => ("textDocument/typeDefinition", "typeDefinitionProvider", "go to type definition"), + GotoKind::Implementation => ("textDocument/implementation", "implementationProvider", "go to implementation"), + } +} + impl LspManager { pub fn new(project_root: PathBuf, languages: Arc, sink: SharedSink) -> Arc { Self::with_launcher(project_root, languages, sink, Arc::new(ProcessLauncher)) @@ -925,25 +938,54 @@ impl LspManager { } pub async fn goto(&self, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult> { - let (method, cap, feature) = match kind { - GotoKind::Definition => ("textDocument/definition", "definitionProvider", "go to definition"), - GotoKind::Declaration => ("textDocument/declaration", "declarationProvider", "go to declaration"), - GotoKind::TypeDefinition => { - ("textDocument/typeDefinition", "typeDefinitionProvider", "go to type definition") - } - GotoKind::Implementation => { - ("textDocument/implementation", "implementationProvider", "go to implementation") - } - }; + let (method, cap, feature) = goto_method(kind); let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?; + self.goto_in(&snap, &targets, method, pos, None).await + } + + /// Implementations of the symbols at each of `positions` (gutter line + /// markers), one list per position in order. A position whose request + /// fails gets an empty list; when every one fails, the first error is + /// returned (an unavailable server is not "no implementations"). + pub async fn implementations( + &self, + doc: DocId, + positions: &[Pos], + cancel: CancellationToken, + ) -> IdeResult>> { + let (method, cap, feature) = goto_method(GotoKind::Implementation); + let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?; + let requests = positions.iter().copied().map(|pos| { + let (snap, targets, cancel) = (&snap, &targets, &cancel); + async move { self.goto_in(snap, targets, method, pos, Some(cancel)).await } + }); + let results: Vec>> = + futures::stream::iter(requests).buffered(IMPLEMENTATION_CONCURRENCY).collect().await; + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + if !results.is_empty() && results.iter().all(Result::is_err) { + return Err(results.into_iter().find_map(Result::err).expect("all failed")); + } + Ok(results.into_iter().map(Result::unwrap_or_default).collect()) + } + + async fn goto_in( + &self, + snap: &DocSnap, + targets: &[Target], + method: &str, + pos: Pos, + cancel: Option<&CancellationToken>, + ) -> IdeResult> { let mut out = Outcome::new(); - for t in &targets { + for t in targets { match self .call::<_, Option>( t, method, snap.pos_params(t, pos), - None, + cancel, DEFAULT_TIMEOUT, ) .await diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 0b26e084..3d414f77 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -792,6 +792,39 @@ async fn fallback_servers_only_run_when_the_preferred_one_is_missing() { assert!(h.mgr.hover(doc, Pos::new(0, 0), Default::default()).await.is_ok()); } +#[tokio::test(flavor = "multi_thread")] +async fn implementations_per_position() { + let caps = json!({ "textDocumentSync": 1, "implementationProvider": true }); + let h = harness(&[("mock", caps)]); + let (doc, path) = h.open("a.mk", "iface A {}\nimpl B {}\nimpl C {}\n"); + h.wait_status("mock", ServerStatus::Ready).await; + let s = h.launcher.state("mock"); + let uri = uri_of(&path); + *s.responder.lock() = Some(Arc::new(move |_, method, params| { + if method != "textDocument/implementation" { + return None; + } + // Line 0 is implemented on lines 1 and 2; nothing else is. + Some(match params["position"]["line"].as_u64() { + Some(0) => json!([ + { "uri": uri, "range": {"start":{"line":1,"character":5},"end":{"line":1,"character":6}} }, + { "uri": uri, "range": {"start":{"line":2,"character":5},"end":{"line":2,"character":6}} }, + ]), + _ => json!([]), + }) + })); + let found = h.mgr.implementations(doc, &[Pos::new(0, 6), Pos::new(1, 5)], Default::default()).await.unwrap(); + assert_eq!(found.len(), 2); + assert_eq!(found[0].iter().map(|l| l.range.start.line).collect::>(), vec![1, 2]); + assert!(found[1].is_empty()); + assert!(h.mgr.implementations(doc, &[], Default::default()).await.unwrap().is_empty()); + + let cancel = ide_core::CancellationToken::new(); + cancel.cancel(); + let err = h.mgr.implementations(doc, &[Pos::new(0, 6)], cancel).await.unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled); +} + #[tokio::test(flavor = "multi_thread")] async fn navigation_rename_format_and_decorations() { let h = harness(&[("mock", incremental_caps())]); diff --git a/src-tauri/src/commands/lsp.rs b/src-tauri/src/commands/lsp.rs index 352e4c90..a4448cce 100644 --- a/src-tauri/src/commands/lsp.rs +++ b/src-tauri/src/commands/lsp.rs @@ -94,6 +94,25 @@ pub async fn lsp_goto( project!(window, state).lsp.goto(doc, pos, kind).await } +/// Implementations of the symbols at `positions`, one list per position (the +/// editor's "is implemented / overridden" gutter markers). Cancel with +/// `cancel_request(request)`. +#[tauri::command] +#[specta::specta] +pub async fn lsp_implementations( + window: Window, + state: State<'_, AppState>, + request: RequestId, + doc: DocId, + positions: Vec, +) -> IdeResult>> { + let lsp = project!(window, state).lsp.clone(); + let cancel = state.begin_request(request); + let result = lsp.implementations(doc, &positions, cancel).await; + state.end_request(request); + result +} + /// Contents of a virtual document (library class, decompiled code) that a /// language server returned a non-`file:` URI for. Cached per project. #[tauri::command] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 09cec38c..f99d3e3d 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -144,6 +144,7 @@ pub fn builder() -> tauri_specta::Builder { lsp::lsp_hover, lsp::lsp_signature_help, lsp::lsp_goto, + lsp::lsp_implementations, lsp::lsp_virtual_document, lsp::lsp_references, lsp::lsp_document_highlights, diff --git a/src/bindings.ts b/src/bindings.ts index 36fee4c4..7ce47b75 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -287,6 +287,12 @@ export const commands = { activeParameter: number, } | null, IdeError>(__TAURI_INVOKE("lsp_signature_help", { doc, pos })), lspGoto: (doc: DocId, pos: Pos, kind: GotoKind) => typedError(__TAURI_INVOKE("lsp_goto", { doc, pos, kind })), + /** + * Implementations of the symbols at `positions`, one list per position (the + * editor's "is implemented / overridden" gutter markers). Cancel with + * `cancel_request(request)`. + */ + lspImplementations: (request: RequestId, doc: DocId, positions: Pos[]) => typedError(__TAURI_INVOKE("lsp_implementations", { request, doc, positions })), /** * Contents of a virtual document (library class, decompiled code) that a * language server returned a non-`file:` URI for. Cached per project. diff --git a/src/editor/core/cm/prefs.ts b/src/editor/core/cm/prefs.ts index 95c5cf61..0e794b37 100644 --- a/src/editor/core/cm/prefs.ts +++ b/src/editor/core/cm/prefs.ts @@ -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 { diff --git a/src/editor/core/cm/theme.ts b/src/editor/core/cm/theme.ts index f053ee39..ff746dda 100644 --- a/src/editor/core/cm/theme.ts +++ b/src/editor/core/cm/theme.ts @@ -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" }, diff --git a/src/editor/lsp/index.ts b/src/editor/lsp/index.ts index 7ab85eda..34d5f1e7 100644 --- a/src/editor/lsp/index.ts +++ b/src/editor/lsp/index.ts @@ -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 }), diff --git a/src/editor/lsp/navigation.ts b/src/editor/lsp/navigation.ts index 973893e1..2136f963 100644 --- a/src/editor/lsp/navigation.ts +++ b/src/editor/lsp/navigation.ts @@ -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 }), diff --git a/src/editor/navigation/LineMarkers.module.css b/src/editor/navigation/LineMarkers.module.css new file mode 100644 index 00000000..0890c9e4 --- /dev/null +++ b/src/editor/navigation/LineMarkers.module.css @@ -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); +} diff --git a/src/editor/navigation/implementationGutter.ts b/src/editor/navigation/implementationGutter.ts new file mode 100644 index 00000000..b21bec47 --- /dev/null +++ b/src/editor/navigation/implementationGutter.ts @@ -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): Set { + 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; + supers: SuperDeps; + readFiles: (paths: readonly string[]) => Promise>; +} + +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; + unsupported: Set; +} + +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, + deps: MarkerDeps, + cache: MarkerCache, +): Promise { + 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, + 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(); + +const markersField = StateField.define({ + 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>(); + +function markerSet(placed: readonly PlacedMarker[]): RangeSet { + 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>(); + const outlines = new Map>([[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; + }, + }, + }), + ]; +} diff --git a/src/editor/navigation/index.ts b/src/editor/navigation/index.ts index 8dccb8f1..832b6fe1 100644 --- a/src/editor/navigation/index.ts +++ b/src/editor/navigation/index.ts @@ -173,3 +173,5 @@ export function registerNavigationActions(): () => void { editorAction("navigate.previousMethod", "Previous Method", (v) => gotoMethod(v, -1)), ]); } + +export { implementationGutter } from "./implementationGutter"; diff --git a/src/editor/navigation/lineMarkers.test.ts b/src/editor/navigation/lineMarkers.test.ts new file mode 100644 index 00000000..11f3ecc0 --- /dev/null +++ b/src/editor/navigation/lineMarkers.test.ts @@ -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()), + 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()), + 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 = { + [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, supers: Record) { + const calls = { implementations: [] as Pos[][], supertypes: 0 }; + const outlines: Record = { + [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); + }); +}); diff --git a/src/editor/navigation/lineMarkers.ts b/src/editor/navigation/lineMarkers.ts new file mode 100644 index 00000000..5ab67dae --- /dev/null +++ b/src/editor/navigation/lineMarkers.ts @@ -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 = { + 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(["class", "interface", "trait"]); +const ABSTRACT_TYPES = new Set(["interface", "trait"]); +/** Types whose methods may implement or override a supertype's. */ +const MEMBER_OWNERS = new Set(["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, +): 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): 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, +): 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)`, + }; +} diff --git a/src/editor/navigation/structureNav.test.ts b/src/editor/navigation/structureNav.test.ts index 9d18e8bc..d0781c0c 100644 --- a/src/editor/navigation/structureNav.test.ts +++ b/src/editor/navigation/structureNav.test.ts @@ -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]); + }); }); diff --git a/src/editor/navigation/structureNav.ts b/src/editor/navigation/structureNav.ts index f25bb1f0..ce18f092 100644 --- a/src/editor/navigation/structureNav.ts +++ b/src/editor/navigation/structureNav.ts @@ -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; } -/** 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 { +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 { let level: Array<{ path: string; pos: Pos }> = [{ path, pos: typePos }]; const seen = new Set(); 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 { + return (await superMethodTargets(path, typePos, method, deps, depth)).map((s) => s.location); +} diff --git a/src/features/settings/pages/editor/general.tsx b/src/features/settings/pages/editor/general.tsx index eb1cf7f8..0e512f18 100644 --- a/src/features/settings/pages/editor/general.tsx +++ b/src/features/settings/pages/editor/general.tsx @@ -536,6 +536,18 @@ const GUTTER = editorPage({ component: () => , }, { t: "custom", label: "Bookmarks", component: () => }, + { + t: "custom", + label: "Implemented and overridden", + keywords: ["implementations", "subclasses", "inheritors"], + component: () => , + }, + { + t: "custom", + label: "Implementing and overriding", + keywords: ["super method", "overrides", "implements"], + component: () => , + }, ], }, ], diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index d2c6a1b2..57dcb07b 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -3708,6 +3708,20 @@ "Can't rename '{name}' without the language server: only local variables can be renamed based on syntax": "没有语言服务器时无法重命名 '{name}':基于语法只能重命名局部变量", "Choose Super Class or Interface ({count} found)": "选择超类或接口(找到 {count} 个)", "Choose Super Method of {name} ({count} found)": "选择 {name} 的超方法(找到 {count} 个)", + "Choose Implementation of {name} ({count} found)": "选择 {name} 的实现(找到 {count} 个)", + "Choose Subclass of {name} ({count} found)": "选择 {name} 的子类(找到 {count} 个)", + "Choose Overriding Method of {name} ({count} found)": "选择 {name} 的重写方法(找到 {count} 个)", + "Is implemented by {names}": "由 {names} 实现", + "Is subclassed by {names}": "子类:{names}", + "Is implemented in {names}": "在 {names} 中实现", + "Is overridden in {names}": "在 {names} 中重写", + "Implements method in {names}": "实现 {names} 中的方法", + "Overrides method in {names}": "重写 {names} 中的方法", + "{names} and {count} more": "{names} 等另外 {count} 个", + "Implemented and overridden": "已实现和已重写", + "Implementing and overriding": "实现和重写", + "Implemented and overridden declarations": "已被实现和重写的声明", + "Implementing and overriding methods": "实现和重写超类型方法的方法", "Replace all {count} occurrences": "替换全部 {count} 处", "↵ Apply · Esc Keep '{name}'": "↵ 应用 · Esc 保留 '{name}'", " (in {files} files)": "(位于 {files} 个文件中)", diff --git a/src/ui/expui/gutter/implementedMethod.svg b/src/ui/expui/gutter/implementedMethod.svg new file mode 100644 index 00000000..62a79139 --- /dev/null +++ b/src/ui/expui/gutter/implementedMethod.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/ui/expui/gutter/implementedMethod_dark.svg b/src/ui/expui/gutter/implementedMethod_dark.svg new file mode 100644 index 00000000..e6397c7e --- /dev/null +++ b/src/ui/expui/gutter/implementedMethod_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/ui/expui/gutter/implementingMethod.svg b/src/ui/expui/gutter/implementingMethod.svg new file mode 100644 index 00000000..1a761cae --- /dev/null +++ b/src/ui/expui/gutter/implementingMethod.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/ui/expui/gutter/implementingMethod_dark.svg b/src/ui/expui/gutter/implementingMethod_dark.svg new file mode 100644 index 00000000..c9a9f345 --- /dev/null +++ b/src/ui/expui/gutter/implementingMethod_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/ui/expui/gutter/overridenMethod.svg b/src/ui/expui/gutter/overridenMethod.svg new file mode 100644 index 00000000..8ed3f014 --- /dev/null +++ b/src/ui/expui/gutter/overridenMethod.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/ui/expui/gutter/overridenMethod_dark.svg b/src/ui/expui/gutter/overridenMethod_dark.svg new file mode 100644 index 00000000..40c4e894 --- /dev/null +++ b/src/ui/expui/gutter/overridenMethod_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/ui/expui/gutter/overridingMethod.svg b/src/ui/expui/gutter/overridingMethod.svg new file mode 100644 index 00000000..75e1d387 --- /dev/null +++ b/src/ui/expui/gutter/overridingMethod.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/ui/expui/gutter/overridingMethod_dark.svg b/src/ui/expui/gutter/overridingMethod_dark.svg new file mode 100644 index 00000000..bb314139 --- /dev/null +++ b/src/ui/expui/gutter/overridingMethod_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 71277ef1..0f43b3cc 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -322,6 +322,10 @@ const ICONS = { "gutter-run-all": "actions/runAll", "gutter-run-passed": "gutter/runSuccess", "gutter-run-failed": "gutter/runError", + "gutter-implemented": "gutter/implementedMethod", + "gutter-implementing": "gutter/implementingMethod", + "gutter-overridden": "gutter/overridenMethod", + "gutter-overriding": "gutter/overridingMethod", stop: "run/stop", rerun: "run/rerun", "rerun-filled": "run/rerun_stroke",