Skip to content
Open
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,19 @@ In some cases you can find workarounds by experimenting, and the easiest way to

Finally, this plugin also provides the following motions/mappings by default:

- `[[` and `]]` to jump to the previous and next Markdown heading.
- `zk` and `zj` to move up and down while skipping folds.
- `gl` and `gL` to jump to the next and previous link.
- `[[` and `]]` to jump to the previous and next Markdown heading. Supports repeat (e.g. `2]]` to jump two headings forward).
- `zk` and `zj` to move up and down while skipping folds. Supports repeat.
- `gc` and `gC` to jump to the next and previous Markdown code fence (opening or closing fence; just any line starting with >= 3 backticks). Supports repeat.
- `gl` and `gL` to jump to the next and previous link. Supports repeat.
- `gf` to open the link or file under the cursor (temporarily moving the cursor if necessary—e.g. if it's on the first square bracket of a [[Wikilink]]).

You can of course remap these as you wish. E.g. if you prefer `'h` and `gh` for jumping to headings:

```vim
map 'h [[
map gh ]]
```

## Installation

In the Obsidian.md settings under "Community plugins", click on "Turn on community plugins", then browse to this plugin.
Expand Down
3 changes: 3 additions & 0 deletions main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { App, EditorSelection, MarkdownView, Notice, Editor as ObsidianEditor, P

import { followLinkUnderCursor } from './actions/followLinkUnderCursor';
import { moveDownSkippingFolds, moveUpSkippingFolds } from './actions/moveSkippingFolds';
import { jumpToNextCodeFence, jumpToPreviousCodeFence } from './motions/jumpToCodeFence';
import { jumpToNextHeading, jumpToPreviousHeading } from './motions/jumpToHeading';
import { jumpToNextLink, jumpToPreviousLink } from './motions/jumpToLink';
import { defineAndMapObsidianVimAction, defineAndMapObsidianVimMotion } from './utils/obsidianVimCommand';
Expand Down Expand Up @@ -429,6 +430,8 @@ export default class VimrcPlugin extends Plugin {
defineAndMapObsidianVimCommands(vimObject: VimApi) {
defineAndMapObsidianVimMotion(vimObject, jumpToNextHeading, ']]');
defineAndMapObsidianVimMotion(vimObject, jumpToPreviousHeading, '[[');
defineAndMapObsidianVimMotion(vimObject, jumpToNextCodeFence, 'gc');
defineAndMapObsidianVimMotion(vimObject, jumpToPreviousCodeFence, 'gC');
defineAndMapObsidianVimMotion(vimObject, jumpToNextLink, 'gl');
defineAndMapObsidianVimMotion(vimObject, jumpToPreviousLink, 'gL');

Expand Down
41 changes: 41 additions & 0 deletions motions/jumpToCodeFence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { jumpToPattern } from "../utils/jumpToPattern";
import { MotionFn } from "../utils/vimApi";

/** Naive Regex for a Markdown code fence, i.e. a line beginning with at least three backticks.
*
* "Naive" for two reasons:
* 1. False negatives: Fences could be made of tildes instead of backticks. This is less common, but
* should be easy to add support for if users request it.
* 2. False positives: It could match lines within non-Markdown codeblocks. But since triple
* backticks aren't a syntax feature of any other programming language (AFAIK, at the time of this
* writing), this should be rare enough that the naive regex should work well in practice.
*
* Matches only the fence itself, so can be used for jumping to either an opening or closing fence.
*/
const NAIVE_CODE_FENCE_REGEX = /^```+/gm;

/**
* Jumps to the repeat-th next code fence.
*/
export const jumpToNextCodeFence: MotionFn = (cm, cursorPosition, { repeat }) => {
return jumpToPattern({
cm,
cursorPosition,
repeat,
regex: NAIVE_CODE_FENCE_REGEX,
direction: "next",
});
};

/**
* Jumps to the repeat-th previous code fence.
*/
export const jumpToPreviousCodeFence: MotionFn = (cm, cursorPosition, { repeat }) => {
return jumpToPattern({
cm,
cursorPosition,
repeat,
regex: NAIVE_CODE_FENCE_REGEX,
direction: "previous",
});
};
13 changes: 5 additions & 8 deletions motions/jumpToHeading.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { Editor as CodeMirrorEditor } from "codemirror";
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Realized we can just use the CodeMirror.Editor type without needing to import it, so went ahead and removed these unnecessary aliases I'd added in a previous PR

import { EditorPosition } from "obsidian";
import { isWithinMatch, jumpToPattern } from "../utils/jumpToPattern";
import { MotionFn } from "../utils/vimApi";
Expand All @@ -11,6 +10,8 @@ const NAIVE_HEADING_REGEX = /^#{1,6} /gm;
/** Regex for a Markdown fenced codeblock, which begins with some number >=3 of backticks at the
* start of a line. It either ends on the nearest future line that starts with at least as many
* backticks (\1 back-reference), or extends to the end of the string if no such future line exists.
*
* Matches the entire codeblock.
*/
const FENCED_CODEBLOCK_REGEX = /(^```+)(.*?^\1|.*)/gms;

Expand All @@ -24,11 +25,7 @@ export const jumpToNextHeading: MotionFn = (cm, cursorPosition, { repeat }) => {
/**
* Jumps to the repeat-th previous heading.
*/
export const jumpToPreviousHeading: MotionFn = (
cm,
cursorPosition,
{ repeat }
) => {
export const jumpToPreviousHeading: MotionFn = (cm, cursorPosition, { repeat }) => {
return jumpToHeading({ cm, cursorPosition, repeat, direction: "previous" });
};

Expand All @@ -45,7 +42,7 @@ function jumpToHeading({
repeat,
direction,
}: {
cm: CodeMirrorEditor;
cm: CodeMirror.Editor;
cursorPosition: EditorPosition;
repeat: number;
direction: "next" | "previous";
Expand All @@ -62,7 +59,7 @@ function jumpToHeading({
});
}

function findAllCodeblocks(cm: CodeMirrorEditor): RegExpExecArray[] {
function findAllCodeblocks(cm: CodeMirror.Editor): RegExpExecArray[] {
const content = cm.getValue();
return [...content.matchAll(FENCED_CODEBLOCK_REGEX)];
}
Expand Down
38 changes: 38 additions & 0 deletions tests/createFakeCodeMirrorEditor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { EditorPosition } from "obsidian";

/**
* Returns a fake CodeMirror editor bound to the given `contentLines`.
*/
export function createFakeCodeMirrorEditor(
contentLines: string[]
): Pick<CodeMirror.Editor, "getValue" | "indexFromPos" | "posFromIndex"> {
const content = contentLines.join("\n");
const lineStartIndexes = getLineStartIndexes(contentLines);
return {
getValue: () => content,
indexFromPos: ({ line, ch }: EditorPosition) => lineStartIndexes[line] + ch,
posFromIndex: (index: number) => getEditorPositionForIndex(index, lineStartIndexes),
};
}
Comment on lines +3 to +16
Copy link
Copy Markdown
Contributor Author

@alythobani alythobani May 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this CodeMirror.Editor-mocking util for testing motions in a slightly more integration-test way. (The existing tests/jumpToLink.test.ts file I previously made, just tests expected vs actual regex matches, vs tests/jumpToCodeFence.test.ts can now use this fake editor to test expected vs actual cursor position)


function getLineStartIndexes(lines: string[]): number[] {
const lineStartIndexes: number[] = [];
let currentIndex = 0;
for (const line of lines) {
lineStartIndexes.push(currentIndex);
currentIndex += line.length + 1;
}
return lineStartIndexes;
}

function getEditorPositionForIndex(
index: number,
lineStartIndexes: number[]
): EditorPosition {
for (let line = lineStartIndexes.length - 1; line >= 0; line -= 1) {
if (lineStartIndexes[line] <= index) {
return { line, ch: index - lineStartIndexes[line] };
}
}
return { line: 0, ch: 0 };
}
144 changes: 144 additions & 0 deletions tests/jumpToCodeFence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { EditorPosition } from "obsidian";
import { describe, expect, test } from "vitest";
import { jumpToNextCodeFence, jumpToPreviousCodeFence } from "../motions/jumpToCodeFence";
import { createFakeCodeMirrorEditor } from "./createFakeCodeMirrorEditor";

const CODE_FENCE_CONTENT_LINES = ["intro", "```ts", "const answer = 42;", "```", "outro"];
const INLINE_BACKTICK_CONTENT_LINES = [
"here are inline backticks ``` not a fence",
"```ts",
"const answer = 42;",
"```",
];
const MULTIPLE_CODE_FENCE_CONTENT_LINES = [
"intro",
"```ts",
"const firstAnswer = 42;",
"```",
"middle",
"````js",
"const secondAnswer = 7;",
"````",
"outro",
];
const NO_CODE_FENCE_CONTENT_LINES = ["intro", "plain text", "outro"];

describe("jumpToNextCodeFence", () => {
test("jumps to the next opening code fence", () => {
expectNextCodeFencePosition({ line: 0, ch: 0 }, { line: 1, ch: 0 });
});

test("jumps to the next closing code fence", () => {
expectNextCodeFencePosition({ line: 2, ch: 0 }, { line: 3, ch: 0 });
});

test("wraps to the first code fence after the last one", () => {
expectNextCodeFencePosition({ line: 4, ch: 0 }, { line: 1, ch: 0 });
});

test("ignores backticks that are not code fence lines", () => {
expectNextCodeFencePosition(
{ line: 0, ch: 0 },
{ line: 1, ch: 0 },
INLINE_BACKTICK_CONTENT_LINES
);
});

test("jumps to the second next code fence when repeat is two", () => {
expectNextCodeFencePosition(
{ line: 0, ch: 0 },
{ line: 3, ch: 0 },
MULTIPLE_CODE_FENCE_CONTENT_LINES,
2
);
});

test("jumps to the closing code fence when cursor is within a code block", () => {
expectNextCodeFencePosition({ line: 2, ch: 5 }, { line: 3, ch: 0 });
});

test("jumps to the next distinct code fence when cursor is on the first backtick of a fence", () => {
expectNextCodeFencePosition({ line: 1, ch: 0 }, { line: 3, ch: 0 });
});

test("jumps to the next distinct code fence when cursor is in the middle of a fence", () => {
expectNextCodeFencePosition({ line: 1, ch: 1 }, { line: 3, ch: 0 });
});

test("returns the original cursor position when there are no code fences", () => {
expectNextCodeFencePosition(
{ line: 1, ch: 2 },
{ line: 1, ch: 2 },
NO_CODE_FENCE_CONTENT_LINES
);
});

test("matches fences longer than three backticks", () => {
expectNextCodeFencePosition(
{ line: 4, ch: 0 },
{ line: 5, ch: 0 },
MULTIPLE_CODE_FENCE_CONTENT_LINES
);
});
});

describe("jumpToPreviousCodeFence", () => {
test("jumps to the previous closing code fence", () => {
expectPreviousCodeFencePosition({ line: 4, ch: 0 }, { line: 3, ch: 0 });
});

test("jumps to the previous opening code fence", () => {
expectPreviousCodeFencePosition({ line: 2, ch: 0 }, { line: 1, ch: 0 });
});

test("jumps to the second previous code fence when repeat is two", () => {
expectPreviousCodeFencePosition(
{ line: 8, ch: 0 },
{ line: 5, ch: 0 },
MULTIPLE_CODE_FENCE_CONTENT_LINES,
2
);
});

test("jumps to the opening code fence when cursor is within a code block", () => {
expectPreviousCodeFencePosition({ line: 2, ch: 5 }, { line: 1, ch: 0 });
});

test("jumps to the previous distinct code fence when cursor is on the first backtick of a fence", () => {
expectPreviousCodeFencePosition({ line: 3, ch: 0 }, { line: 1, ch: 0 });
});

test("jumps to the previous distinct code fence when cursor is in the middle of a fence", () => {
expectPreviousCodeFencePosition({ line: 3, ch: 1 }, { line: 1, ch: 0 });
});

test("returns the original cursor position when there are no code fences", () => {
expectPreviousCodeFencePosition(
{ line: 1, ch: 2 },
{ line: 1, ch: 2 },
NO_CODE_FENCE_CONTENT_LINES
);
});
});

function expectNextCodeFencePosition(
cursorPosition: EditorPosition,
expectedPosition: EditorPosition,
contentLines: string[] = CODE_FENCE_CONTENT_LINES,
repeat = 1
): void {
const cm = createFakeCodeMirrorEditor(contentLines);
const nextCodeFence = jumpToNextCodeFence(cm as any, cursorPosition, { repeat });
expect(nextCodeFence).toEqual(expectedPosition);
}

function expectPreviousCodeFencePosition(
cursorPosition: EditorPosition,
expectedPosition: EditorPosition,
contentLines: string[] = CODE_FENCE_CONTENT_LINES,
repeat = 1
): void {
const cm = createFakeCodeMirrorEditor(contentLines);
const previousCodeFence = jumpToPreviousCodeFence(cm as any, cursorPosition, { repeat });
expect(previousCodeFence).toEqual(expectedPosition);
}
3 changes: 1 addition & 2 deletions utils/jumpToPattern.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { Editor as CodeMirrorEditor } from "codemirror";
import { EditorPosition } from "obsidian";

/**
Expand Down Expand Up @@ -27,7 +26,7 @@ export function jumpToPattern({
filterMatch = () => true,
direction,
}: {
cm: CodeMirrorEditor;
cm: CodeMirror.Editor;
cursorPosition: EditorPosition;
repeat: number;
regex: RegExp;
Expand Down
3 changes: 1 addition & 2 deletions utils/obsidianVimCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@
* Utility types and functions for defining Obsidian-specific Vim commands.
*/

import { Editor as CodeMirrorEditor } from "codemirror";

import VimrcPlugin from "../main";
import { MotionFn, VimApi } from "./vimApi";

export type ObsidianActionFn = (
vimrcPlugin: VimrcPlugin, // Included so we can run Obsidian commands as part of the action
cm: CodeMirrorEditor,
cm: CodeMirror.Editor,
actionArgs: { repeat: number },
) => void;

Expand Down
5 changes: 2 additions & 3 deletions utils/vimApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,16 @@
* https://libvoyant.ucr.edu/resources/codemirror/doc/manual.html
*/

import { Editor as CodeMirrorEditor } from "codemirror";
import { EditorPosition } from "obsidian";

export type MotionFn = (
cm: CodeMirrorEditor,
cm: CodeMirror.Editor,
cursorPosition: EditorPosition, // called `head` in the API
motionArgs: { repeat: number }
) => EditorPosition;

export type ActionFn = (
cm: CodeMirrorEditor,
cm: CodeMirror.Editor,
actionArgs: { repeat: number },
) => void;

Expand Down