';
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();
+ },
+ );
+});