export type VimMotion = "w" | "W" | "b" | "B" | "e" | "E" | "0" | "^" | "$" | "%" | "gg" | "G" | "{" | "}"; export type TextObjectKind = "i" | "a"; export interface TextRange { start: number; end: number; linewise?: boolean; } function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } function isKeyword(char: string | undefined): boolean { return Boolean(char && /[\p{L}\p{N}_]/u.test(char)); } function isWhitespace(char: string | undefined): boolean { return Boolean(char && /\s/u.test(char)); } function sameWordClass(a: string | undefined, b: string | undefined, big: boolean): boolean { if (!a || !b) return false; if (big) return !isWhitespace(a) && !isWhitespace(b); if (isWhitespace(a) || isWhitespace(b)) return isWhitespace(a) && isWhitespace(b); return isKeyword(a) === isKeyword(b); } export function lineBounds(text: string, index: number): { start: number; end: number } { const safe = clamp(index, 0, text.length); const previousNewline = text.lastIndexOf("\n", Math.max(0, safe - 1)); const nextNewline = text.indexOf("\n", safe); return { start: previousNewline + 1, end: nextNewline === -1 ? text.length : nextNewline }; } function nextWordStart(text: string, index: number, big: boolean): number { let i = clamp(index, 0, text.length); if (i < text.length && !isWhitespace(text[i])) { const initial = text[i]; while (i < text.length && sameWordClass(initial, text[i], big)) i++; } while (i < text.length && isWhitespace(text[i])) i++; return i; } function previousWordStart(text: string, index: number, big: boolean): number { let i = clamp(index - 1, -1, text.length - 1); while (i >= 0 && isWhitespace(text[i])) i--; if (i < 0) return 0; const initial = text[i]; while (i > 0 && sameWordClass(initial, text[i - 1], big)) i--; return i; } function wordEnd(text: string, index: number, big: boolean): number { let i = clamp(index + 1, 0, Math.max(0, text.length - 1)); while (i < text.length && isWhitespace(text[i])) i++; if (i >= text.length) return text.length; const initial = text[i]; while (i + 1 < text.length && sameWordClass(initial, text[i + 1], big)) i++; return i; } function matchingDelimiter(text: string, index: number): number { const pairs: Record = { "(": ")", "[": "]", "{": "}", ")": "(", "]": "[", "}": "{" }; let source = index; const bounds = lineBounds(text, index); while (source < bounds.end && !pairs[text[source] ?? ""]) source++; const char = text[source]; const target = pairs[char ?? ""]; if (!target) return index; const forward = char === "(" || char === "[" || char === "{"; let depth = 0; for (let i = source; i >= 0 && i < text.length; i += forward ? 1 : -1) { if (text[i] === char) depth++; else if (text[i] === target && --depth === 0) return i; } return index; } function paragraphTarget(text: string, index: number, direction: 1 | -1): number { const lines = text.split("\n"); const before = text.slice(0, index); let line = before.split("\n").length - 1; if (direction > 0) { line++; while (line < lines.length && (lines[line] ?? "").trim() !== "") line++; while (line < lines.length && (lines[line] ?? "").trim() === "") line++; } else { line--; while (line > 0 && (lines[line] ?? "").trim() === "") line--; while (line > 0 && (lines[line - 1] ?? "").trim() !== "") line--; } return lines.slice(0, clamp(line, 0, lines.length - 1)).reduce((sum, value) => sum + value.length + 1, 0); } export function motionTarget(text: string, index: number, motion: VimMotion, count = 1): number { let target = clamp(index, 0, text.length); const steps = clamp(Math.floor(count) || 1, 1, 9999); for (let step = 0; step < steps; step++) { if (motion === "w") target = nextWordStart(text, target, false); else if (motion === "W") target = nextWordStart(text, target, true); else if (motion === "b") target = previousWordStart(text, target, false); else if (motion === "B") target = previousWordStart(text, target, true); else if (motion === "e") target = wordEnd(text, target, false); else if (motion === "E") target = wordEnd(text, target, true); else if (motion === "0") target = lineBounds(text, target).start; else if (motion === "^") { const bounds = lineBounds(text, target); const match = /\S/.exec(text.slice(bounds.start, bounds.end)); target = bounds.start + (match?.index ?? 0); } else if (motion === "$") target = lineBounds(text, target).end; else if (motion === "%") target = matchingDelimiter(text, target); else if (motion === "gg") target = 0; else if (motion === "G") target = text.length; else if (motion === "{") target = paragraphTarget(text, target, -1); else if (motion === "}") target = paragraphTarget(text, target, 1); } return target; } export function operatorRange(text: string, index: number, motion: VimMotion, count = 1): TextRange | undefined { const target = motionTarget(text, index, motion, count); if (target === index) return undefined; const inclusive = motion === "e" || motion === "E" || motion === "%"; return { start: Math.min(index, target), end: Math.min(text.length, Math.max(index, target) + (target >= index && inclusive ? 1 : 0)), }; } export function findCharacter( text: string, index: number, char: string, direction: 1 | -1, until: boolean, count = 1, ): number | undefined { const bounds = lineBounds(text, index); let cursor = index; for (let found = 0; found < Math.max(1, count);) { cursor += direction; if (cursor < bounds.start || cursor >= bounds.end) return undefined; if (text[cursor] === char) found++; } return cursor + (until ? -direction : 0); } function delimitedRange(text: string, index: number, open: string, close: string, kind: TextObjectKind): TextRange | undefined { let start = -1; let depth = 0; for (let i = Math.min(index, text.length - 1); i >= 0; i--) { if (text[i] === close) depth++; else if (text[i] === open) { if (depth === 0) { start = i; break; } depth--; } } if (start < 0) return undefined; depth = 0; let end = -1; for (let i = start + 1; i < text.length; i++) { if (text[i] === open) depth++; else if (text[i] === close) { if (depth === 0) { end = i; break; } depth--; } } if (end < 0 || index > end) return undefined; return kind === "i" ? { start: start + 1, end } : { start, end: end + 1 }; } function quotedRange(text: string, index: number, quote: string, kind: TextObjectKind): TextRange | undefined { const bounds = lineBounds(text, index); const left = text.lastIndexOf(quote, index); if (left < bounds.start) return undefined; const right = text.indexOf(quote, Math.max(index + 1, left + 1)); if (right < 0 || right > bounds.end) return undefined; return kind === "i" ? { start: left + 1, end: right } : { start: left, end: right + 1 }; } function wordObjectRange(text: string, index: number, kind: TextObjectKind, big: boolean): TextRange | undefined { if (!text.length) return undefined; let cursor = clamp(index, 0, text.length - 1); while (cursor < text.length && isWhitespace(text[cursor])) cursor++; if (cursor >= text.length) return undefined; const initial = text[cursor]; let start = cursor; let end = cursor + 1; while (start > 0 && sameWordClass(initial, text[start - 1], big)) start--; while (end < text.length && sameWordClass(initial, text[end], big)) end++; if (kind === "a") { const trailingStart = end; while (end < text.length && text[end] !== "\n" && isWhitespace(text[end])) end++; if (end === trailingStart) while (start > 0 && text[start - 1] !== "\n" && isWhitespace(text[start - 1])) start--; } return { start, end }; } export function textObjectRange(text: string, index: number, kind: TextObjectKind, object: string): TextRange | undefined { if (object === "w" || object === "W") return wordObjectRange(text, index, kind, object === "W"); if (object === '"' || object === "'" || object === "`") return quotedRange(text, index, object, kind); const pairs: Record = { "(": ["(", ")"], ")": ["(", ")"], b: ["(", ")"], "[": ["[", "]"], "]": ["[", "]"], "{": ["{", "}"], "}": ["{", "}"], B: ["{", "}"], }; const pair = pairs[object]; return pair ? delimitedRange(text, index, pair[0], pair[1], kind) : undefined; }