import type { EditorState } from "@codemirror/state"; export type CharClass = "space" | "word" | "punct"; export function charAt(state: EditorState, i: number): string { return i >= 0 && i < state.doc.length ? state.doc.sliceString(i, i + 1) : ""; } export function classOf(ch: string): CharClass { if (ch === "" || /\s/.test(ch)) return "space"; if (/[\p{L}\p{N}_]/u.test(ch)) return "word"; return "punct"; } // For WORD (big) motions only whitespace separates tokens. function bigClassOf(ch: string): CharClass { return ch === "" || /\s/.test(ch) ? "space" : "word"; } function classAt(state: EditorState, i: number, big: boolean): CharClass { const ch = charAt(state, i); return big ? bigClassOf(ch) : classOf(ch); } /** Helix `w`/`W`: move to the start of the next word. */ export function nextWordStart(state: EditorState, pos: number, big: boolean): number { const len = state.doc.length; let i = pos; if (i >= len) return len; if (classAt(state, i, big) === "space") { while (i < len && classAt(state, i, big) === "space") i++; } else { const cls = classAt(state, i, big); while (i < len && classAt(state, i, big) === cls) i++; while (i < len && classAt(state, i, big) === "space") i++; } return i; } /** Helix `b`/`B`: move to the start of the previous word. */ export function prevWordStart(state: EditorState, pos: number, big: boolean): number { let i = pos; if (i <= 0) return 0; i--; while (i > 0 && classAt(state, i, big) === "space") i--; const cls = classAt(state, i, big); while (i > 0 && classAt(state, i - 1, big) === cls) i--; return i; } /** Helix `e`/`E`: move to the end of the next word. */ export function nextWordEnd(state: EditorState, pos: number, big: boolean): number { const len = state.doc.length; let i = pos + 1; while (i < len && classAt(state, i, big) === "space") i++; const cls = classAt(state, i, big); while (i < len && classAt(state, i, big) === cls) i++; return Math.min(i, len); } export function firstNonBlank(state: EditorState, line: number): number { const l = state.doc.line(line); return l.from + (l.text.match(/^\s*/)?.[0].length ?? 0); } /** f/t/F/T: find `target` from `pos`. Returns null if not found on the line. */ export function findCharOnLine( state: EditorState, pos: number, target: string, kind: "f" | "t" | "F" | "T", ): number | null { const line = state.doc.lineAt(pos); const forward = kind === "f" || kind === "t"; const till = kind === "t" || kind === "T"; if (forward) { let i = pos + 1; // `t` should skip an adjacent target so repeated presses advance. if (till && charAt(state, i) === target) i++; for (; i <= line.to; i++) { if (charAt(state, i) === target) return till ? i - 1 : i; } } else { let i = pos - 1; if (till && charAt(state, i) === target) i--; for (; i >= line.from; i--) { if (charAt(state, i) === target) return till ? i + 1 : i; } } return null; } const PAIRS: Record = { "(": { open: "(", close: ")" }, ")": { open: "(", close: ")" }, "[": { open: "[", close: "]" }, "]": { open: "[", close: "]" }, "{": { open: "{", close: "}" }, "}": { open: "{", close: "}" }, "<": { open: "<", close: ">" }, ">": { open: "<", close: ">" }, }; export function pairFor(ch: string): { open: string; close: string } | null { return PAIRS[ch] ?? null; } /** Scan forward for the close that matches an open at/after `pos` (nesting-aware). */ export function findClose(state: EditorState, from: number, open: string, close: string): number | null { const len = state.doc.length; let depth = 0; for (let i = from; i < len; i++) { const ch = charAt(state, i); if (ch === open) depth++; else if (ch === close) { depth--; if (depth === 0) return i; } } return null; } /** Scan backward for the open that matches a close at/before `pos` (nesting-aware). */ export function findOpen(state: EditorState, from: number, open: string, close: string): number | null { let depth = 0; for (let i = from; i >= 0; i--) { const ch = charAt(state, i); if (ch === close) depth++; else if (ch === open) { depth--; if (depth === 0) return i; } } return null; } /** `mm`: position of the bracket matching the one at/under `pos`, or null. */ export function matchingBracket(state: EditorState, pos: number): number | null { for (const probe of [pos, pos - 1]) { const ch = charAt(state, probe); const pair = pairFor(ch); if (!pair) continue; if (ch === pair.open) return findClose2(state, probe, pair.open, pair.close); return findOpen2(state, probe, pair.open, pair.close); } return null; } function findClose2(state: EditorState, openPos: number, open: string, close: string): number | null { const len = state.doc.length; let depth = 0; for (let i = openPos; i < len; i++) { const ch = charAt(state, i); if (ch === open) depth++; else if (ch === close && --depth === 0) return i; } return null; } function findOpen2(state: EditorState, closePos: number, open: string, close: string): number | null { let depth = 0; for (let i = closePos; i >= 0; i--) { const ch = charAt(state, i); if (ch === close) depth++; else if (ch === open && --depth === 0) return i; } return null; } /** Paragraph bounds (blank-line delimited) containing `pos`. */ export function paragraphAt(state: EditorState, pos: number): { from: number; to: number } { const { doc } = state; let startLine = doc.lineAt(pos).number; let endLine = startLine; const blank = (n: number) => doc.line(n).text.trim() === ""; while (startLine > 1 && !blank(startLine - 1)) startLine--; while (endLine < doc.lines && !blank(endLine + 1)) endLine++; return { from: doc.line(startLine).from, to: doc.line(endLine).to }; }