Skip to content

Commit 2795bb6

Browse files
authored
Fix: jumpToHeading shouldn't match within codeblocks (also improve jumpToLink a bit) (#233)
* fix: jumpToHeading should not jump to "headings" within codeblocks jumpToPattern now accepts an optional filterMatch param to make this happen * jumpToLink can now jump to standalone hyperlinks * Adjust/clarify docstring for `jumpToNextLink`
1 parent b5b5032 commit 2795bb6

File tree

3 files changed

+89
-30
lines changed

3 files changed

+89
-30
lines changed

motions/jumpToHeading.ts

+53-12
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,24 @@
1-
import { jumpToPattern } from "../utils/jumpToPattern";
1+
import { Editor as CodeMirrorEditor } from "codemirror";
2+
import { EditorPosition } from "obsidian";
3+
import { isWithinMatch, jumpToPattern } from "../utils/jumpToPattern";
24
import { MotionFn } from "../utils/vimApi";
35

4-
const HEADING_REGEX = /^#+ /gm;
6+
/** Naive Regex for a Markdown heading (H1 through H6). "Naive" because it does not account for
7+
* whether the match is within a codeblock (e.g. it could be a Python comment, not a heading).
8+
*/
9+
const NAIVE_HEADING_REGEX = /^#{1,6} /gm;
10+
11+
/** Regex for a Markdown fenced codeblock, which begins with some number >=3 of backticks at the
12+
* start of a line. It either ends on the nearest future line that starts with at least as many
13+
* backticks (\1 back-reference), or extends to the end of the string if no such future line exists.
14+
*/
15+
const FENCED_CODEBLOCK_REGEX = /(^```+)(.*?^\1|.*)/gms;
516

617
/**
718
* Jumps to the repeat-th next heading.
819
*/
920
export const jumpToNextHeading: MotionFn = (cm, cursorPosition, { repeat }) => {
10-
return jumpToPattern({
11-
cm,
12-
cursorPosition,
13-
repeat,
14-
regex: HEADING_REGEX,
15-
direction: "next",
16-
});
21+
return jumpToHeading({ cm, cursorPosition, repeat, direction: "next" });
1722
};
1823

1924
/**
@@ -24,11 +29,47 @@ export const jumpToPreviousHeading: MotionFn = (
2429
cursorPosition,
2530
{ repeat }
2631
) => {
32+
return jumpToHeading({ cm, cursorPosition, repeat, direction: "previous" });
33+
};
34+
35+
/**
36+
* Jumps to the repeat-th heading in the given direction.
37+
*
38+
* Under the hood, we use the naive heading regex to find all headings, and then filter out those
39+
* that are within codeblocks. `codeblockMatches` is passed in a closure to avoid repeated
40+
* computation.
41+
*/
42+
function jumpToHeading({
43+
cm,
44+
cursorPosition,
45+
repeat,
46+
direction,
47+
}: {
48+
cm: CodeMirrorEditor;
49+
cursorPosition: EditorPosition;
50+
repeat: number;
51+
direction: "next" | "previous";
52+
}): EditorPosition {
53+
const codeblockMatches = findAllCodeblocks(cm);
54+
const filterMatch = (match: RegExpExecArray) => !isMatchWithinCodeblock(match, codeblockMatches);
2755
return jumpToPattern({
2856
cm,
2957
cursorPosition,
3058
repeat,
31-
regex: HEADING_REGEX,
32-
direction: "previous",
59+
regex: NAIVE_HEADING_REGEX,
60+
filterMatch,
61+
direction,
3362
});
34-
};
63+
}
64+
65+
function findAllCodeblocks(cm: CodeMirrorEditor): RegExpExecArray[] {
66+
const content = cm.getValue();
67+
return [...content.matchAll(FENCED_CODEBLOCK_REGEX)];
68+
}
69+
70+
function isMatchWithinCodeblock(
71+
match: RegExpExecArray,
72+
codeblockMatches: RegExpExecArray[]
73+
): boolean {
74+
return codeblockMatches.some((codeblockMatch) => isWithinMatch(codeblockMatch, match.index));
75+
}

motions/jumpToLink.ts

+13-3
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
11
import { jumpToPattern } from "../utils/jumpToPattern";
22
import { MotionFn } from "../utils/vimApi";
33

4-
const WIKILINK_REGEX_STRING = "\\[\\[[^\\]\\]]+?\\]\\]";
5-
const MARKDOWN_LINK_REGEX_STRING = "\\[[^\\]]+?\\]\\([^)]+?\\)";
6-
const LINK_REGEX_STRING = `${WIKILINK_REGEX_STRING}|${MARKDOWN_LINK_REGEX_STRING}`;
4+
const WIKILINK_REGEX_STRING = "\\[\\[.*?\\]\\]";
5+
const MARKDOWN_LINK_REGEX_STRING = "\\[.*?\\]\\(.*?\\)";
6+
const URL_REGEX_STRING = "\\w+://\\S+";
7+
8+
/**
9+
* Regex for a link (which can be a wikilink, a markdown link, or a standalone URL).
10+
*/
11+
const LINK_REGEX_STRING = `${WIKILINK_REGEX_STRING}|${MARKDOWN_LINK_REGEX_STRING}|${URL_REGEX_STRING}`;
712
const LINK_REGEX = new RegExp(LINK_REGEX_STRING, "g");
813

914
/**
1015
* Jumps to the repeat-th next link.
16+
*
17+
* Note that `jumpToPattern` uses `String.matchAll`, which internally updates `lastIndex` after each
18+
* match; and that `LINK_REGEX` matches wikilinks / markdown links first. So, this won't catch
19+
* non-standalone URLs (e.g. the URL in a markdown link). This should be a good thing in most cases;
20+
* otherwise it could be tedious (as a user) for each markdown link to contain two jumpable spots.
1121
*/
1222
export const jumpToNextLink: MotionFn = (cm, cursorPosition, { repeat }) => {
1323
return jumpToPattern({

utils/jumpToPattern.ts

+23-15
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,34 @@ import { EditorPosition } from "obsidian";
99
* Under the hood, to avoid repeated loops of the document: we get all matches at once, order them
1010
* according to `direction` and `cursorPosition`, and use modulo arithmetic to return the
1111
* appropriate match.
12+
*
13+
* @param cm The CodeMirror editor instance.
14+
* @param cursorPosition The current cursor position.
15+
* @param repeat The number of times to repeat the jump (e.g. 1 to jump to the very next match). Is
16+
* modulo-ed for efficiency.
17+
* @param regex The regex pattern to jump to.
18+
* @param filterMatch Optional filter function to run on the regex matches. Return false to ignore
19+
* a given match.
20+
* @param direction The direction to jump in.
1221
*/
1322
export function jumpToPattern({
1423
cm,
1524
cursorPosition,
1625
repeat,
1726
regex,
27+
filterMatch = () => true,
1828
direction,
1929
}: {
2030
cm: CodeMirrorEditor;
2131
cursorPosition: EditorPosition;
2232
repeat: number;
2333
regex: RegExp;
34+
filterMatch?: (match: RegExpExecArray) => boolean;
2435
direction: "next" | "previous";
2536
}): EditorPosition {
2637
const content = cm.getValue();
2738
const cursorIdx = cm.indexFromPos(cursorPosition);
28-
const orderedMatches = getOrderedMatches({
29-
content,
30-
regex,
31-
cursorIdx,
32-
direction,
33-
});
39+
const orderedMatches = getOrderedMatches({ content, regex, cursorIdx, filterMatch, direction });
3440
const effectiveRepeat = (repeat % orderedMatches.length) || orderedMatches.length;
3541
const matchIdx = orderedMatches[effectiveRepeat - 1]?.index;
3642
if (matchIdx === undefined) {
@@ -48,17 +54,20 @@ function getOrderedMatches({
4854
content,
4955
regex,
5056
cursorIdx,
57+
filterMatch,
5158
direction,
5259
}: {
5360
content: string;
5461
regex: RegExp;
5562
cursorIdx: number;
63+
filterMatch: (match: RegExpExecArray) => boolean;
5664
direction: "next" | "previous";
5765
}): RegExpExecArray[] {
5866
const { previousMatches, currentMatches, nextMatches } = getAndGroupMatches(
5967
content,
6068
regex,
61-
cursorIdx
69+
cursorIdx,
70+
filterMatch
6271
);
6372
if (direction === "next") {
6473
return [...nextMatches, ...previousMatches, ...currentMatches];
@@ -77,20 +86,19 @@ function getOrderedMatches({
7786
function getAndGroupMatches(
7887
content: string,
7988
regex: RegExp,
80-
cursorIdx: number
89+
cursorIdx: number,
90+
filterMatch: (match: RegExpExecArray) => boolean
8191
): {
8292
previousMatches: RegExpExecArray[];
8393
currentMatches: RegExpExecArray[];
8494
nextMatches: RegExpExecArray[];
8595
} {
8696
const globalRegex = makeGlobalRegex(regex);
87-
const allMatches = [...content.matchAll(globalRegex)];
97+
const allMatches = [...content.matchAll(globalRegex)].filter(filterMatch);
8898
const previousMatches = allMatches.filter(
89-
(match) => match.index < cursorIdx && !isCursorOnMatch(match, cursorIdx)
90-
);
91-
const currentMatches = allMatches.filter((match) =>
92-
isCursorOnMatch(match, cursorIdx)
99+
(match) => match.index < cursorIdx && !isWithinMatch(match, cursorIdx)
93100
);
101+
const currentMatches = allMatches.filter((match) => isWithinMatch(match, cursorIdx));
94102
const nextMatches = allMatches.filter((match) => match.index > cursorIdx);
95103
return { previousMatches, currentMatches, nextMatches };
96104
}
@@ -105,6 +113,6 @@ function getGlobalFlags(regex: RegExp): string {
105113
return flags.includes("g") ? flags : `${flags}g`;
106114
}
107115

108-
function isCursorOnMatch(match: RegExpExecArray, cursorIdx: number): boolean {
109-
return match.index <= cursorIdx && cursorIdx < match.index + match[0].length;
116+
export function isWithinMatch(match: RegExpExecArray, index: number): boolean {
117+
return match.index <= index && index < match.index + match[0].length;
110118
}

0 commit comments

Comments
 (0)