diff --git a/README.md b/README.md index 27ebfe5..ac286f7 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/main.ts b/main.ts index 7911085..a0b40d2 100644 --- a/main.ts +++ b/main.ts @@ -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'; @@ -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'); diff --git a/motions/jumpToCodeFence.ts b/motions/jumpToCodeFence.ts new file mode 100644 index 0000000..a68e27e --- /dev/null +++ b/motions/jumpToCodeFence.ts @@ -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", + }); +}; diff --git a/motions/jumpToHeading.ts b/motions/jumpToHeading.ts index 67a1d3a..f4ee545 100644 --- a/motions/jumpToHeading.ts +++ b/motions/jumpToHeading.ts @@ -1,4 +1,3 @@ -import { Editor as CodeMirrorEditor } from "codemirror"; import { EditorPosition } from "obsidian"; import { isWithinMatch, jumpToPattern } from "../utils/jumpToPattern"; import { MotionFn } from "../utils/vimApi"; @@ -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; @@ -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" }); }; @@ -45,7 +42,7 @@ function jumpToHeading({ repeat, direction, }: { - cm: CodeMirrorEditor; + cm: CodeMirror.Editor; cursorPosition: EditorPosition; repeat: number; direction: "next" | "previous"; @@ -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)]; } diff --git a/tests/createFakeCodeMirrorEditor.ts b/tests/createFakeCodeMirrorEditor.ts new file mode 100644 index 0000000..db06a49 --- /dev/null +++ b/tests/createFakeCodeMirrorEditor.ts @@ -0,0 +1,38 @@ +import { EditorPosition } from "obsidian"; + +/** + * Returns a fake CodeMirror editor bound to the given `contentLines`. + */ +export function createFakeCodeMirrorEditor( + contentLines: string[] +): Pick { + 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), + }; +} + +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 }; +} diff --git a/tests/jumpToCodeFence.test.ts b/tests/jumpToCodeFence.test.ts new file mode 100644 index 0000000..990e856 --- /dev/null +++ b/tests/jumpToCodeFence.test.ts @@ -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); +} diff --git a/utils/jumpToPattern.ts b/utils/jumpToPattern.ts index 05e3b4b..80120fb 100644 --- a/utils/jumpToPattern.ts +++ b/utils/jumpToPattern.ts @@ -1,4 +1,3 @@ -import { Editor as CodeMirrorEditor } from "codemirror"; import { EditorPosition } from "obsidian"; /** @@ -27,7 +26,7 @@ export function jumpToPattern({ filterMatch = () => true, direction, }: { - cm: CodeMirrorEditor; + cm: CodeMirror.Editor; cursorPosition: EditorPosition; repeat: number; regex: RegExp; diff --git a/utils/obsidianVimCommand.ts b/utils/obsidianVimCommand.ts index 39f1499..e270d22 100644 --- a/utils/obsidianVimCommand.ts +++ b/utils/obsidianVimCommand.ts @@ -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; diff --git a/utils/vimApi.ts b/utils/vimApi.ts index 60a662c..9e105da 100644 --- a/utils/vimApi.ts +++ b/utils/vimApi.ts @@ -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;