diff --git a/src/editor/lsp/links.test.ts b/src/editor/lsp/links.test.ts index f4c47d79..801d3b61 100644 --- a/src/editor/lsp/links.test.ts +++ b/src/editor/lsp/links.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { Text } from "@codemirror/state"; -import { fragmentOffset, linkReference, looksLikePath, textReference } from "./links"; +import { fragmentOffset, linkReference, looksLikePath, resolvePath, textReference } from "./links"; const at = (src: string, marker = "|") => { const offset = src.indexOf(marker); @@ -28,6 +28,20 @@ describe("text references", () => { expect(textReference(text, offset)).toMatchObject({ kind: "path", value: "img/logo.svg" }); }); + it("finds fragment-only link targets", () => { + for (const source of ["[artist::order](#Artist-or|der)", 'order']) { + const { text, offset } = at(source); + expect(textReference(text, offset)).toMatchObject({ kind: "path", value: "#Artist-order" }); + } + }); + + it("does not treat arbitrary fragment strings as links", () => { + for (const source of ['const color = "#ff|f"', '', "url(#ic|on)"]) { + const { text, offset } = at(source); + expect(textReference(text, offset)).toBeNull(); + } + }); + it("ignores prose and identifiers", () => { expect(looksLikePath("hello world.txt")).toBe(false); expect(looksLikePath("utf-8")).toBe(false); @@ -41,6 +55,18 @@ describe("text references", () => { }); describe("links and fragments", () => { + it("resolves local fragments without probing the filesystem", async () => { + const ref = { kind: "path" as const, from: 5, to: 20, value: "#Artist%2Dorder" }; + expect(await resolvePath(ref, "/p/notes.md")).toEqual({ + kind: "file", + from: 5, + to: 20, + path: "/p/notes.md", + fragment: "Artist-order", + }); + expect(await resolvePath({ ...ref, value: "#bad%" }, "/p/notes.md")).toBeNull(); + }); + it("picks the server link around the offset", () => { const text = Text.of(['']); const range = { start: { line: 0, col: 9 }, end: { line: 0, col: 19 } }; @@ -55,6 +81,12 @@ describe("links and fragments", () => { const html = '

\n

'; expect(fragmentOffset(html, "/i.html", "main")).toBe(html.indexOf("id=")); expect(fragmentOffset("# Intro\n\n## Getting Started!\n", "/r.md", "getting-started")).toBe(9); + for (const path of ["/r.md", "/r.markdown", "/r.mdx"]) { + for (const attr of ["id", "name"]) { + const source = `[artist::order](#Artist-order)\n\n`; + expect(fragmentOffset(source, path, "Artist-order")).toBe(source.indexOf(`${attr}=`)); + } + } expect(fragmentOffset("x", "/i.html", "nope")).toBeNull(); }); }); diff --git a/src/editor/lsp/links.ts b/src/editor/lsp/links.ts index 675862c7..4deb9695 100644 --- a/src/editor/lsp/links.ts +++ b/src/editor/lsp/links.ts @@ -87,14 +87,19 @@ export function textReference(text: Text, offset: number): Reference | null { return { kind: "url", from: line.from + m.index, to: line.from + m.index + m[0].length, url: m[0] }; } const strings = [ - ...[...line.text.matchAll(QUOTED_RE)].map((m) => ({ value: m[1] ?? m[2] ?? m[3] ?? "", from: m.index + 1 })), + ...[...line.text.matchAll(QUOTED_RE)].map((m) => ({ + value: m[1] ?? m[2] ?? m[3] ?? "", + from: m.index + 1, + fragment: /\bhref\s*=\s*$/i.test(line.text.slice(0, m.index)), + })), ...[...line.text.matchAll(BARE_RE)].map((m) => { const value = m[1] ?? m[2] ?? ""; - return { value, from: m.index + m[0].indexOf(value) }; + return { value, from: m.index + m[0].indexOf(value), fragment: m[1] !== undefined }; }), ]; - for (const { value, from } of strings) { - if (col < from || col > from + value.length || !looksLikePath(value)) continue; + for (const { value, from, fragment } of strings) { + if (col < from || col > from + value.length || (!looksLikePath(value) && !(fragment && /^#[^\s]+$/.test(value)))) + continue; return { kind: "path", from: line.from + from, to: line.from + from + value.length, value }; } return null; @@ -113,6 +118,13 @@ async function isFile(path: string): Promise { /** Resolves a `path` reference (in the backend, like IDEA's file references); null when nothing exists there. */ export async function resolvePath(ref: Reference, from: string): Promise { if (ref.kind !== "path") return ref; + if (ref.value.startsWith("#")) { + try { + return { kind: "file", from: ref.from, to: ref.to, path: from, fragment: decodeURIComponent(ref.value.slice(1)) }; + } catch { + return null; + } + } if (!isTauri()) return null; const path = await call(commands.fsResolveReference(from, ref.value)).catch(() => null); if (!path) return null; @@ -142,7 +154,6 @@ export function fragmentOffset(text: string, path: string, fragment: string): nu if (h && slug(h[1]!) === fragment.toLowerCase()) return offset; offset += l.length + 1; } - return null; } const m = new RegExp(`\\b(?:id|name)\\s*=\\s*["']?${esc}(?=["'\\s>])`).exec(text); return m ? m.index : null; diff --git a/src/editor/lsp/navigation.ts b/src/editor/lsp/navigation.ts index 8dc29472..6b27c97e 100644 --- a/src/editor/lsp/navigation.ts +++ b/src/editor/lsp/navigation.ts @@ -50,7 +50,15 @@ import { showHint, showPendingHint } from "./hint"; import { outsideProjectReason } from "./outsideProject"; import { caretAnchor, openPopup, type ListPopupItem } from "./popupStore"; import { readManyFileLines, targetLabel } from "./files"; -import { documentLinks, followReference, knownLinks, linkReference, referenceAt, textReference } from "./links"; +import { + documentLinks, + followReference, + fragmentOffset, + knownLinks, + linkReference, + referenceAt, + textReference, +} from "./links"; import { featureBackoff } from "./availability"; import { requestHover, showPhpConstructDocumentation } from "./hover"; import { declarationSummary, renderCode } from "./markdown"; @@ -277,6 +285,14 @@ async function followReferenceAt(view: EditorView, doc: EditorDocContext, offset const ref = await referenceAt(view, doc, offset); if (!ref) return false; try { + if (ref.kind === "file" && ref.path === doc.path && ref.fragment) { + const target = fragmentOffset(view.state.doc.toString(), doc.path, ref.fragment); + if (target !== null) { + view.dispatch({ selection: EditorSelection.cursor(target), effects: EditorView.scrollIntoView(target) }); + view.focus(); + return true; + } + } await followReference(ref); } catch (e) { showHint(view, e instanceof Error ? e.message : String(e), { tone: "error" }); diff --git a/src/editor/lsp/syntaxFallback.test.ts b/src/editor/lsp/syntaxFallback.test.ts index 4aa131f4..07aaa304 100644 --- a/src/editor/lsp/syntaxFallback.test.ts +++ b/src/editor/lsp/syntaxFallback.test.ts @@ -82,15 +82,12 @@ const local = (over: Partial["symbol"]> = { }); const views: EditorView[] = []; -function editor(doc: string, language = "typescript", anchor = 0) { +function editor(doc: string, language = "typescript", anchor = 0, path = "/p/a.ts") { const view = new EditorView({ state: EditorState.create({ doc, selection: { anchor }, - extensions: [ - lspDoc.of({ doc: 7, path: "/p/a.ts", language, uiMode: null, readOnly: false }), - navigationExtension(), - ], + extensions: [lspDoc.of({ doc: 7, path, language, uiMode: null, readOnly: false }), navigationExtension()], }), parent: document.body, }); @@ -670,3 +667,16 @@ describe("modifier-hover navigation recovery", () => { expect(view.dom.querySelector(".cm-lsp-link")?.textContent).toBe("bar"); }); }); + +describe("same-document fragment navigation", () => { + it.each(["definition", "declaration"] as const)( + "follows Markdown HTML anchors through %s without a server", + async (kind) => { + const source = '[artist::order](#Artist-order)\n\n'; + const view = editor(source, "markdown", source.indexOf("#Artist"), "/p/notes.md"); + await gotoTargets(view, kind); + expect(view.state.selection.main.head).toBe(source.indexOf("id=")); + expect(openFile).not.toHaveBeenCalled(); + }, + ); +});