Compare commits

...
Author SHA1 Message Date
l0ng-ai 93cccb9bb5 fix(navigation): show usages on a declaration a plugin's index links elsewhere
A plugin's document index can link a declaration's name elsewhere: MyBatis Duo links a mapper method to its XML statement. Go to Declaration followed such a link at once, in the editor and in the host, so ⌘B on the method jumped to the XML instead of showing its usages as it does on any declaration (and did for plugins that answer through a definition provider). When the syntax tree puts the caret on a declaration's name, the link is no longer followed directly: the language server's answer comes along and the usages show, the plugin's target among them. Elsewhere plugin links are followed as before, without waiting for the language server.

Fixes #3207
2026-11-14 02:43:03 +08:00
4 changed files with 111 additions and 1 deletions

No files matched your search

+46
View File
@@ -309,9 +309,13 @@ pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos)
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
let target = position_document(host, window, doc, pos).await?;
// ⌘B on a declaration shows its usages, also when a plugin's index links the name elsewhere
// (a MyBatis mapper method to its XML statement): the server's answer comes along for the
// editor to tell.
if target == doc
&& kind == GotoKind::Definition
&& let Some(plugins) = crate::plugins::editor_plugin_host()
&& !on_declaration_name(host, window, doc, pos).await
{
let project = host.project(window)?;
// A notebook cell is a language-server document only: no plugin navigates in it.
@@ -352,6 +356,30 @@ pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: Got
definition_targets(result, found, in_string)
}
/// Whether the syntax tree puts the identifier at `pos` as a declaration's name (`int f()`'s `f`).
async fn on_declaration_name(host: &Host, window: &str, doc: DocId, pos: Pos) -> bool {
let Ok(project) = host.project(window) else { return false };
blocking(move || {
let snap = project.documents.text_snapshot(doc)?;
if snap.large {
return Ok(false);
}
let s = ide_syntax::Snapshot::doc(doc, snap.version, &snap.language, &snap.text);
let cancel = ide_core::CancellationToken::new();
let Ok(Some(at)) = project.structure.node_at(&s, Range::new(pos, pos), &cancel) else { return Ok(false) };
Ok(is_declaration_name(&at))
})
.await
.unwrap_or(false)
}
fn is_declaration_name(at: &ide_syntax::NodeAt) -> bool {
at.node.field.as_deref() == Some("name")
&& at.ancestors.first().is_some_and(|parent| {
["declaration", "definition", "declarator"].iter().any(|kind| parent.kind.ends_with(kind))
})
}
async fn in_string_literal(host: &Host, window: &str, doc: DocId, pos: Pos) -> bool {
let Ok(project) = host.project(window) else { return false };
blocking(move || {
@@ -2136,6 +2164,24 @@ mod tests {
assert_eq!(paths(definition_targets(Ok(vec![helper, guess]), Vec::new(), false)), ["/p/app/helpers.php"]);
}
#[test]
fn declaration_names_are_told_apart_from_uses() {
let syntax = std::sync::Arc::new(ide_language::Syntax::new());
let structure = ide_syntax::StructureService::new(std::sync::Arc::new(ide_language::SyntaxCache::new(syntax)));
let java = ide_core::LanguageId::new("java");
let text = "interface ShareMapper {\n int batchCancel(List<Long> ids);\n}\nclass S { int f(ShareMapper m) { return m.batchCancel(null); } }\n";
let snap = ide_syntax::Snapshot::text(&java, text);
let cancel = ide_core::CancellationToken::new();
let at = |line, col| {
let pos = Pos::new(line, col);
is_declaration_name(&structure.node_at(&snap, Range::new(pos, pos), &cancel).unwrap().unwrap())
};
assert!(at(1, 10), "the mapper method's name");
assert!(at(0, 12), "the interface's name");
assert!(!at(3, 45), "a call of the method");
assert!(!at(1, 4), "its return type");
}
#[test]
fn positions_inside_string_literals_are_told_apart() {
let syntax = std::sync::Arc::new(ide_language::Syntax::new());
+14 -1
View File
@@ -63,6 +63,7 @@ import {
SYNTAX_NOTE,
declarationFallback,
indexDefinitions,
isDeclarationName,
isSelfTarget,
localHighlights,
type DeclarationTarget,
@@ -360,6 +361,15 @@ export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number)
}
}
/** Whether the caret is on the name of a declaration, by the syntax tree (no language server wait). */
async function onDeclarationName(view: EditorView, offset: number): Promise<boolean> {
const doc = docOf(view);
if (!doc) return false;
const pos = offsetToPos(view.state.doc, offset);
const at = await structure.nodeAt(doc.doc, { start: pos, end: pos }).catch(() => null);
return isDeclarationName(at);
}
async function gotoResolved(
view: EditorView,
kind: GotoKind,
@@ -374,7 +384,10 @@ async function gotoResolved(
const before = view.state.doc;
const plugin = await contributedNavigation(view, offset).catch(() => null);
if (view.state.doc !== before || superseded()) return;
if (plugin) {
// ⌘B on a declaration shows its usages, also when a plugin links the name elsewhere (a
// MyBatis mapper method to its XML statement).
if (plugin && !(kind === "definition" && (await onDeclarationName(view, offset)))) {
if (superseded()) return;
if (plugin.targets.length === 1) await navigateTo(plugin.targets[0]!);
else {
const items = await locationItems(plugin.targets);
+43
View File
@@ -45,6 +45,7 @@ provideEditorService({ openFile } as never);
const fb = await import("./syntaxFallback");
const { gotoTargets, navigationExtension, highlightUsages } = await import("./navigation");
const { registerNavigationProvider } = await import("@/editor/navigationProviders");
const { startRename, renameNamePart } = await import("./rename");
const { lspDoc } = await import("./lsp");
const { invalidateConfigurationNavigation } = await import("./configurationNavigation");
@@ -416,6 +417,48 @@ describe("navigation wiring", () => {
expect(openFile).not.toHaveBeenCalled();
});
it("shows usages on a declaration a plugin's index links elsewhere, and follows the link from a call", async () => {
// MyBatis Duo indexes the mapper method's name as a link to its XML statement.
const xml = { path: "/p/UserMapper.xml", range: r(3, 16, 24) };
const off = registerNavigationProvider(async (view) => {
const at = view.state.doc.toString().indexOf("findName");
return { from: at, to: at + 8, targets: [xml] };
});
try {
// The syntax tree puts the caret on the method's name; the host then answers both.
cmd.syntaxNodeAt.mockReturnValueOnce(
ok({
node: node("identifier", r(3, 11, 19), "name"),
ancestors: [node("method_declaration", { start: { line: 3, col: 4 }, end: { line: 3, col: 29 } })],
}),
);
cmd.lspGoto.mockReturnValueOnce(ok([xml, { path: "/p/UserMapper.java", range: r(3, 11, 19) }]));
const view = editor(
"package demo;\n\npublic interface UserMapper {\n String findName(Long id);\n}\n",
"java",
58,
"/p/UserMapper.java",
);
await gotoTargets(view, "definition");
expect(executeAction).toHaveBeenCalledWith("find.showUsages");
expect(openFile).not.toHaveBeenCalled();
// A call: the plugin's link is followed without asking the language server.
executeAction.mockClear();
cmd.lspGoto.mockClear();
cmd.syntaxNodeAt.mockReturnValueOnce(
ok({ node: node("identifier", r(1, 26, 34), "name"), ancestors: [node("method_invocation", r(1, 24, 38))] }),
);
const call = editor("class S {\n void f(UserMapper m) { m.findName(1L); }\n}\n", "java", 37, "/p/S.java");
await gotoTargets(call, "definition");
expect(openFile).toHaveBeenCalledWith("/p/UserMapper.xml", expect.objectContaining({ focus: true }));
expect(executeAction).not.toHaveBeenCalledWith("find.showUsages");
expect(cmd.lspGoto).not.toHaveBeenCalled();
} finally {
off();
}
});
it("looks past a server answer that points at the import itself", async () => {
cmd.lspGoto.mockReturnValueOnce(ok([{ path: "/p/a.py", range: r(0, 37, 43) }]));
cmd.navIndexDefinitions.mockReturnValueOnce(ok([sym("logger", "variable", "/deps/torchtitan/tools/logging.py")]));
+8
View File
@@ -63,6 +63,14 @@ export function usageRole(at: NodeAt | null): UsageRole {
return "other";
}
const DECLARATION = /(declaration|definition|declarator)$/;
/** Whether the syntax tree puts the identifier at `at` as the name of a declaration (`int f()`'s `f`). */
export function isDeclarationName(at: NodeAt | null): boolean {
const parent = at?.ancestors[0];
return !!parent && at.node.field === "name" && DECLARATION.test(parent.kind);
}
/** Index symbols that may be the declaration of `name` used in `role`. */
export function filterSymbols(matches: readonly SymbolMatch[], name: string, role: UsageRole): SymbolMatch[] {
return matches.filter((m) => {