diff --git a/src/features/vim/components/vim-status-indicator.tsx b/src/features/vim/components/vim-status-indicator.tsx index b534ee04..552535eb 100644 --- a/src/features/vim/components/vim-status-indicator.tsx +++ b/src/features/vim/components/vim-status-indicator.tsx @@ -30,6 +30,9 @@ const VimStatusIndicator = () => { if (visualMode === "line") { return "VISUAL LINE"; } + if (visualMode === "block") { + return "VISUAL BLOCK"; + } return "VISUAL"; case "command": return "COMMAND"; @@ -49,6 +52,7 @@ const VimStatusIndicator = () => { return "bg-green-500/20 text-green-400 border-green-500/30"; case "VISUAL": case "VISUAL LINE": + case "VISUAL BLOCK": return "bg-yellow-500/20 text-yellow-400 border-yellow-500/30"; case "COMMAND": return "bg-purple-500/20 text-purple-400 border-purple-500/30"; diff --git a/src/features/vim/core/core/grammar/ast.ts b/src/features/vim/core/core/grammar/ast.ts new file mode 100644 index 00000000..9c59d6b6 --- /dev/null +++ b/src/features/vim/core/core/grammar/ast.ts @@ -0,0 +1,211 @@ +/** + * AST (Abstract Syntax Tree) types for Vim grammar + * + * Grammar (EBNF): + * Command := [Register] [Count] ( Action | OperatorInvocation | Motion ) + * Register := '"' RegisterName + * Count := DigitNonZero { Digit } + * Action := PutAction | CharAction | MiscAction | ModeChangeAction + * OperatorInvocation := Operator ( Operator | [Count] Target ) + * Operator := 'd'|'c'|'y'|'<'|'>'|'='|'!'|'g~'|'gu'|'gU'|'gq'|'g@' + * Target := ForcedKind? ( TextObject | Motion ) + * ForcedKind := 'v' | 'V' | '' + * TextObject := ('a'|'i') ObjectKey + * Motion := SimpleMotion | CharMotion | SearchMotion | MarkMotion | PrefixedMotion + */ + +export type Count = number; + +/** + * Register reference (e.g., "a, "0, "+, "*) + */ +export interface RegisterRef { + name: string; // a-z, 0-9, ", +, *, _, / +} + +/** + * Top-level command structure + */ +export type Command = + | { + kind: "action"; + reg?: RegisterRef; + count?: Count; + action: Action; + } + | { + kind: "operator"; + reg?: RegisterRef; + countBefore?: Count; + operator: OperatorKey; + doubled?: boolean; // true for dd, yy, cc, >>, etc. + target?: Target; // absent => incomplete command + countAfter?: Count; + } + | { + kind: "motion"; + count?: Count; + motion: Motion; + } + | { + kind: "visualOperator"; + reg?: RegisterRef; + operator: OperatorKey; + } + | { + kind: "visualTextObject"; + reg?: RegisterRef; + mode: "inner" | "around"; + object: string; + }; + +/** + * Actions - standalone commands that don't require a motion + */ +export type Action = + | { type: "put"; which: "p" | "P" } + | { type: "charReplace"; which: "r" | "gr"; char: string } + | { type: "modeChange"; mode: ModeChangeAction } + | { type: "singleChar"; operation: SingleCharOperation } + | { type: "undo" } + | { type: "redo" } + | { type: "repeat" } // dot command + | { type: "misc"; key: string }; // J, ~, etc. + +/** + * Mode change actions (i, a, A, I, o, O, s) + */ +export type ModeChangeAction = + | "insert" // i + | "append" // a + | "appendLine" // A + | "insertLineStart" // I + | "openBelow" // o + | "openAbove" // O + | "substitute"; // s + +/** + * Single character operations (x, X) + */ +export type SingleCharOperation = "deleteChar" | "deleteCharBefore"; + +/** + * Operator keys + */ +export type OperatorKey = + | "d" // delete + | "c" // change + | "y" // yank + | "<" // outdent + | ">" // indent + | "=" // format + | "!" // filter + | "g~" // toggle case + | "gu" // lowercase + | "gU" // uppercase + | "gq" // format text + | "g@"; // operator function + +/** + * Target for an operator (motion or text object) + */ +export type Target = + | { + type: "motion"; + forced?: "char" | "line" | "block"; // v, V, Ctrl-V + motion: Motion; + } + | { + type: "textObject"; + forced?: "char" | "line" | "block"; + mode: "inner" | "around"; // i or a + object: string; // w, s, p, ), ], }, >, ", ', `, t, b, B, etc. + }; + +/** + * Motion types + */ +export type Motion = + | SimpleMotion + | CharMotion + | SearchMotion + | SearchRepeatMotion + | MarkMotion + | PrefixedMotion; + +/** + * Simple single or multi-character motions + */ +export interface SimpleMotion { + type: "simple"; + key: string; // w, W, e, E, b, B, h, j, k, l, 0, ^, $, gg, G, {, }, (, ), g_, %, etc. +} + +/** + * Character-finding motions (f, F, t, T) + */ +export interface CharMotion { + type: "char"; + key: "f" | "F" | "t" | "T"; + char: string; +} + +/** + * Search motions (/, ?) + */ +export interface SearchMotion { + type: "search"; + dir: "fwd" | "bwd"; // / or ? + pattern: string; +} + +/** + * Search repeat motions (n, N, *, #) + */ +export interface SearchRepeatMotion { + type: "searchRepeat"; + key: "n" | "N" | "*" | "#"; +} + +/** + * Mark motions (', `) + */ +export interface MarkMotion { + type: "mark"; + style: "'" | "`"; // ' for line, ` for exact position + mark: string; // a-z, A-Z, 0-9, <, >, etc. +} + +/** + * Prefixed motions (g, z, [, ] families) + * Examples: g_, gj, gk, zt, zz, zb, ]], [[, ]m, [m + */ +export interface PrefixedMotion { + type: "prefixed"; + head: "g" | "z" | "[" | "]"; + tail: string; +} + +/** + * Parse result types + */ +export interface ParseOk { + status: "complete"; + command: Command; +} + +export interface ParseIncomplete { + status: "incomplete"; +} + +export interface ParseNeedsChar { + status: "needsChar"; + context?: string; // e.g., "f", "r", "'", for better UX +} + +export interface ParseInvalid { + status: "invalid"; + reason?: string; +} + +export type ParseResult = ParseOk | ParseIncomplete | ParseNeedsChar | ParseInvalid; diff --git a/src/features/vim/core/core/grammar/compat.ts b/src/features/vim/core/core/grammar/compat.ts new file mode 100644 index 00000000..aa511f01 --- /dev/null +++ b/src/features/vim/core/core/grammar/compat.ts @@ -0,0 +1,128 @@ +/** + * Compatibility layer between old and new Vim parsers + * + * Allows incremental migration by trying the new parser first, + * falling back to the old parser if needed. + */ + +import { executeVimCommand as executeOld } from "../command-executor"; +import { + getCommandParseStatus as getOldParseStatus, + parseVimCommand as parseOld, +} from "../command-parser"; +import type { ParseResult } from "./ast"; +import { executeAST } from "./executor"; +import { getCommandParseStatus as getNewParseStatus, parse as parseNew } from "./parser"; + +/** + * Feature flag to enable/disable new parser + * + * Set to true to use new parser, false to use old parser. + * Can be toggled at runtime for testing. + */ +let USE_NEW_PARSER = true; // Default: enabled for production use + +/** + * Enable or disable the new parser + */ +export function setUseNewParser(enabled: boolean): void { + USE_NEW_PARSER = enabled; +} + +/** + * Check if new parser is enabled + */ +export function isNewParserEnabled(): boolean { + return USE_NEW_PARSER; +} + +/** + * Parse command with compatibility fallback + * + * Tries new parser first, falls back to old parser if needed. + */ +export function parseVimCommandCompat(keys: string[]): ParseResult | null { + // If new parser is disabled, use old parser + if (!USE_NEW_PARSER) { + const command = parseOld(keys); + if (!command) return null; + + // Convert old command format to new ParseResult format + return { + status: "complete", + command: command as any, // Old command format is different but compatible + }; + } + + // Use new parser + const result = parseNew(keys); + + if (result.status === "complete") { + return result; + } + + return null; +} + +/** + * Execute command with compatibility fallback + * + * Tries new executor first, falls back to old executor if needed. + */ +export function executeVimCommandCompat(keys: string[]): boolean { + // If new parser is disabled, use old executor + if (!USE_NEW_PARSER) { + return executeOld(keys); + } + + // Use new parser and executor + const result = parseNew(keys); + + if (result.status === "complete") { + // Use new executor + return executeAST(result.command); + } + + return false; +} + +/** + * Get command parse status with compatibility + * + * Returns: "complete" | "incomplete" | "invalid" | "needsChar" + */ +export function getCommandParseStatusCompat( + keys: string[], +): "complete" | "incomplete" | "invalid" | "needsChar" { + // If new parser is disabled, use old parser + if (!USE_NEW_PARSER) { + return getOldParseStatus(keys); + } + + // Use new parser + return getNewParseStatus(keys); +} + +/** + * Check if command is complete (ready to execute) + */ +export function isCommandComplete(keys: string[]): boolean { + const status = getCommandParseStatusCompat(keys); + return status === "complete"; +} + +/** + * Check if command is waiting for more keys + */ +export function expectsMoreKeys(keys: string[]): boolean { + const status = getCommandParseStatusCompat(keys); + return status === "incomplete" || status === "needsChar"; +} + +/** + * Check if command is invalid + */ +export function isCommandInvalid(keys: string[]): boolean { + const status = getCommandParseStatusCompat(keys); + return status === "invalid"; +} diff --git a/src/features/vim/core/core/grammar/executor.ts b/src/features/vim/core/core/grammar/executor.ts new file mode 100644 index 00000000..1c7b7f48 --- /dev/null +++ b/src/features/vim/core/core/grammar/executor.ts @@ -0,0 +1,623 @@ +/** + * AST-based Vim command executor + * + * Executes parsed AST commands by coordinating with operators, motions, + * text objects, actions, and the register system. + */ + +import { useBufferStore } from "@/features/editor/stores/buffer-store"; +import { useEditorSettingsStore } from "@/features/editor/stores/settings-store"; +import { useEditorViewStore } from "@/features/editor/stores/view-store"; +import { calculateOffsetFromPosition } from "@/features/editor/utils/position"; +import { + type EditorContext, + getAction, + getMotion, + getOperator, + getTextObject, + type VimRange, +} from "@/features/vim/core"; +import { createVimEditing } from "@/features/vim/stores/vim-editing"; +import { useVimStore } from "@/features/vim/stores/vim-store"; +import { useEditorStateStore } from "@/stores/editor-cursor-store"; +import type { Command, Motion } from "./ast"; +import { getMotionInfo } from "./motion-kind"; +import { effectiveCount, getRegisterName, isRepeatable, normalize } from "./normalize"; + +/** + * Execute an AST command + * + * @param cmd - Parsed command AST + * @returns True if execution succeeded + */ +export function executeAST(cmd: Command): boolean { + // Normalize the command (expand aliases like D, C, S, Y) + const normalized = normalize(cmd); + + // Get editor context + const context = getEditorContext(); + if (!context) return false; + + const count = effectiveCount(normalized); + const registerName = getRegisterName(normalized); + + try { + // Handle ACTION commands + if (normalized.kind === "action") { + return executeAction(normalized, context, count, registerName); + } + + // Handle OPERATOR commands + if (normalized.kind === "operator") { + return executeOperator(normalized, context, count, registerName); + } + + // Handle MOTION commands (standalone cursor movement) + if (normalized.kind === "motion") { + return executeMotion(normalized, context, count); + } + + // Handle VISUAL OPERATOR commands + if (normalized.kind === "visualOperator") { + return executeVisualOperator(normalized, context, registerName); + } + + // Handle VISUAL TEXT OBJECT commands + if (normalized.kind === "visualTextObject") { + return executeVisualTextObject(normalized, context); + } + + return false; + } catch (error) { + console.error("Error executing vim command:", error); + return false; + } +} + +/** + * Execute an action command + */ +function executeAction( + cmd: Extract, + context: EditorContext, + count: number, + registerName: string, +): boolean { + const { action } = cmd; + + // Put actions (p, P) + if (action.type === "put") { + const vimStore = useVimStore.getState(); + const register = vimStore.actions.getRegisterContent(registerName); + + if (!register.content) { + console.warn("Nothing in register to paste"); + return false; + } + + const editing = createVimEditing(); + + for (let i = 0; i < count; i++) { + if (action.which === "p") { + editing.paste(register.content, register.type); + } else { + editing.pasteAbove(register.content, register.type); + } + } + + // Store for repeat + if (isRepeatable(cmd)) { + storeForRepeat(cmd); + } + + return true; + } + + // Character replace actions (r, gr) + if (action.type === "charReplace") { + const editing = createVimEditing(); + + for (let i = 0; i < count; i++) { + editing.replaceChar(action.char); + } + + // Store for repeat + if (isRepeatable(cmd)) { + storeForRepeat(cmd); + } + + return true; + } + + // Mode change actions (i, a, A, I, o, O, s) + if (action.type === "modeChange") { + return executeModeChange(action.mode, context, count); + } + + // Single char operations (x, X) + if (action.type === "singleChar") { + const editing = createVimEditing(); + const vimStore = useVimStore.getState(); + + // Precompute deleted content before making edits + const currentContent = context.content; + const currentOffset = context.cursor.offset; + let deletedContent = ""; + + if (action.operation === "deleteChar") { + // Delete characters forward: get substring from current offset + const endOffset = Math.min(currentOffset + count, currentContent.length); + deletedContent = currentContent.slice(currentOffset, endOffset); + } else { + // Delete characters backward: get substring before current offset + const startOffset = Math.max(0, currentOffset - count); + deletedContent = currentContent.slice(startOffset, currentOffset); + } + + // Now perform the actual deletions + for (let i = 0; i < count; i++) { + if (action.operation === "deleteChar") { + editing.deleteChar(); + } else { + editing.deleteCharBefore(); + } + } + + // Store deleted content in register + if (deletedContent) { + vimStore.actions.setRegisterContent(registerName, deletedContent, "char", { + source: "delete", + }); + } + + // Store for repeat + if (isRepeatable(cmd)) { + storeForRepeat(cmd); + } + + return true; + } + + // Undo + if (action.type === "undo") { + const editing = createVimEditing(); + for (let i = 0; i < count; i++) { + editing.undo(); + } + return true; + } + + // Redo + if (action.type === "redo") { + const editing = createVimEditing(); + for (let i = 0; i < count; i++) { + editing.redo(); + } + return true; + } + + // Repeat (dot command) + if (action.type === "repeat") { + const vimStore = useVimStore.getState(); + const lastCmd = vimStore.actions.getLastRepeatableCommand(); + + if (!lastCmd) { + console.warn("No command to repeat"); + return false; + } + + // Re-execute the last command (recursive call) + // The lastCmd is already normalized and validated, so we can execute it directly + return executeAST(lastCmd); + } + + // Misc actions (J, ~, etc.) - delegate to old action system + if (action.type === "misc") { + const oldAction = getAction(action.key); + if (!oldAction) return false; + + for (let i = 0; i < count; i++) { + oldAction.execute(context); + } + + // Store for repeat + if (isRepeatable(cmd)) { + storeForRepeat(cmd); + } + + return true; + } + + return false; +} + +/** + * Execute a standalone motion command (cursor movement) + */ +function executeMotion( + cmd: Extract, + context: EditorContext, + count: number, +): boolean { + const { motion } = cmd; + + // Get motion key for registry lookup + const motionKey = getMotionKey(motion); + if (!motionKey) return false; + + // Get motion implementation + const motionImpl = getMotion(motionKey); + if (!motionImpl) { + console.warn(`Motion not implemented: ${motionKey}`); + return false; + } + + // Calculate where the motion would move the cursor + const range = motionImpl.calculate(context.cursor, context.lines, count, { + explicitCount: count > 1, + }); + + if (!range) return false; + + // Move cursor to the end position of the range + const newOffset = calculateOffsetFromPosition(range.end.line, range.end.column, context.lines); + context.setCursorPosition({ + line: range.end.line, + column: range.end.column, + offset: newOffset, + }); + + // Update textarea cursor + const textarea = document.querySelector(".editor-textarea") as HTMLTextAreaElement; + if (textarea) { + textarea.selectionStart = textarea.selectionEnd = newOffset; + textarea.dispatchEvent(new Event("select")); + } + + return true; +} + +/** + * Execute an operator command + */ +function executeOperator( + cmd: Extract, + context: EditorContext, + count: number, + _registerName: string, +): boolean { + const { operator, target } = cmd; + + if (!target) { + return false; // Incomplete command + } + + // Get the operator + const op = getOperator(operator); + if (!op) return false; + + // Get the range based on target type + let range: VimRange | null = null; + + if (target.type === "textObject") { + // Text object + const textObj = getTextObject(target.object); + if (!textObj) return false; + + range = textObj.calculate(context.cursor, context.lines, target.mode); + if (!range) return false; + } else if (target.type === "motion") { + // Motion + range = calculateMotionRange(target.motion, context, count, target.forced); + if (!range) return false; + } + + // Final null check (should never happen due to earlier checks, but TypeScript needs it) + if (!range) return false; + + // Store for repeat + if (isRepeatable(cmd)) { + storeForRepeat(cmd); + } + + // Execute the operator on the range + // TODO: Update operators to accept register parameter + op.execute(range, context); + + // Handle mode transitions for operators that enter insert mode + if (op.entersInsertMode) { + const vimStore = useVimStore.getState(); + vimStore.actions.setMode("insert"); + } + + return true; +} + +/** + * Calculate range for a motion + */ +function calculateMotionRange( + motion: Motion, + context: EditorContext, + count: number, + forcedKind?: "char" | "line" | "block", +): VimRange | null { + // Map AST motion to motion key for registry lookup + const motionKey = getMotionKey(motion); + if (!motionKey) return null; + + const motionImpl = getMotion(motionKey); + if (!motionImpl) return null; + + // Calculate the range + const range = motionImpl.calculate(context.cursor, context.lines, count, { + explicitCount: count > 1, + }); + + // Determine motion kind using motion-kind system + const motionInfo = getMotionInfo(motion); + + // Apply forced kind if specified, otherwise use motion's natural kind + if (forcedKind) { + range.linewise = forcedKind === "line"; + // TODO: Handle blockwise + } else { + range.linewise = motionInfo.kind === "line"; + } + + // Also apply inclusivity from motion info if not already set + if (range.inclusive === undefined) { + range.inclusive = motionInfo.inclusive === "inclusive"; + } + + return range; +} + +/** + * Get motion key for registry lookup from AST motion + */ +function getMotionKey(motion: Motion): string | null { + if (motion.type === "simple") { + return motion.key; + } + + if (motion.type === "char") { + // For char motions like f/F/t/T, we need special handling + // For now, return the motion key - char will be handled separately + return motion.key; + } + + if (motion.type === "searchRepeat") { + return motion.key; + } + + if (motion.type === "mark") { + return motion.style; + } + + if (motion.type === "prefixed") { + return motion.head + motion.tail; + } + + if (motion.type === "search") { + return motion.dir === "fwd" ? "/" : "?"; + } + + return null; +} + +/** + * Execute mode change action + */ +function executeModeChange(mode: string, context: EditorContext, _count: number): boolean { + const editing = createVimEditing(); + const vimStore = useVimStore.getState(); + + switch (mode) { + case "insert": // i + vimStore.actions.setMode("insert"); + return true; + + case "append": { + // a + // Move cursor one position right before entering insert mode + const currentPos = context.cursor; + const lines = context.lines; + const newColumn = Math.min(lines[currentPos.line].length, currentPos.column + 1); + const newOffset = calculateOffsetFromPosition(currentPos.line, newColumn, lines); + context.setCursorPosition({ line: currentPos.line, column: newColumn, offset: newOffset }); + + vimStore.actions.setMode("insert"); + return true; + } + + case "appendLine": // A + editing.appendToLine(); + vimStore.actions.setMode("insert"); + return true; + + case "insertLineStart": // I + editing.insertAtLineStart(); + vimStore.actions.setMode("insert"); + return true; + + case "openBelow": // o + editing.openLineBelow(); + vimStore.actions.setMode("insert"); + return true; + + case "openAbove": // O + editing.openLineAbove(); + vimStore.actions.setMode("insert"); + return true; + + case "substitute": // s + editing.substituteChar(); + vimStore.actions.setMode("insert"); + return true; + + default: + return false; + } +} + +/** + * Execute a visual operator command + */ +function executeVisualOperator( + cmd: Extract, + context: EditorContext, + _registerName: string, +): boolean { + const { operator } = cmd; + const vimStore = useVimStore.getState(); + const visualSelection = vimStore.visualSelection; + const visualMode = vimStore.visualMode; + + if (!visualSelection.start || !visualSelection.end) { + console.warn("No visual selection"); + return false; + } + + // Get the operator + const op = getOperator(operator); + if (!op) { + console.warn(`Operator not found: ${operator}`); + return false; + } + + // Convert visual selection to VimRange + const startOffset = calculateOffsetFromPosition( + visualSelection.start.line, + visualSelection.start.column, + context.lines, + ); + const endOffset = calculateOffsetFromPosition( + visualSelection.end.line, + visualSelection.end.column, + context.lines, + ); + + const range: VimRange = { + start: { ...visualSelection.start, offset: startOffset }, + end: { ...visualSelection.end, offset: endOffset }, + linewise: visualMode === "line", + inclusive: true, + }; + + // Execute the operator on the range + op.execute(range, context); + + // Handle mode transitions + if (op.entersInsertMode) { + vimStore.actions.setMode("insert"); + } else { + vimStore.actions.setMode("normal"); + } + + return true; +} + +/** + * Execute a visual text object command (extends selection to text object) + */ +function executeVisualTextObject( + cmd: Extract, + context: EditorContext, +): boolean { + const { mode, object } = cmd; + const vimStore = useVimStore.getState(); + + // Get text object implementation + const textObj = getTextObject(object); + if (!textObj) { + console.warn(`Text object not found: ${object}`); + return false; + } + + // Calculate text object range from current cursor + const range = textObj.calculate(context.cursor, context.lines, mode); + if (!range) { + console.warn(`Failed to calculate text object range: ${object}`); + return false; + } + + // Update visual selection to the text object range + const { setVisualSelection } = vimStore.actions; + setVisualSelection(range.start, range.end); + + // Update textarea selection + const textarea = document.querySelector(".editor-textarea") as HTMLTextAreaElement; + if (textarea) { + const startOffset = calculateOffsetFromPosition( + range.start.line, + range.start.column, + context.lines, + ); + const endOffset = calculateOffsetFromPosition(range.end.line, range.end.column, context.lines); + + textarea.selectionStart = Math.min(startOffset, endOffset); + textarea.selectionEnd = Math.max(startOffset, endOffset); + textarea.dispatchEvent(new Event("select")); + } + + return true; +} + +/** + * Store command for dot repeat + */ +function storeForRepeat(cmd: Command): void { + const vimStore = useVimStore.getState(); + + // Store the normalized command for repeat + // Deep clone to avoid reference issues + const clonedCmd = JSON.parse(JSON.stringify(cmd)); + vimStore.actions.setLastRepeatableCommand(clonedCmd); +} + +/** + * Get the current editor context + */ +function getEditorContext(): EditorContext | null { + const cursorState = useEditorStateStore.getState(); + const viewState = useEditorViewStore.getState(); + const bufferState = useBufferStore.getState(); + const settingStore = useEditorSettingsStore.getState(); + + const { cursorPosition } = cursorState; + const { lines } = viewState; + const { activeBufferId } = bufferState; + const { tabSize } = settingStore; + + if (!lines || lines.length === 0) return null; + + const content = lines.join("\n"); + + const updateContent = (newContent: string) => { + if (activeBufferId) { + bufferState.actions.updateBufferContent(activeBufferId, newContent); + + // Update textarea + const textarea = document.querySelector(".editor-textarea") as HTMLTextAreaElement; + if (textarea) { + textarea.value = newContent; + textarea.dispatchEvent(new Event("input", { bubbles: true })); + } + } + }; + + const setCursorPosition = (position: any) => { + cursorState.actions.setCursorPosition(position); + }; + + return { + lines, + content, + cursor: cursorPosition, + activeBufferId, + updateContent, + setCursorPosition, + tabSize, + }; +} diff --git a/src/features/vim/core/core/grammar/motion-kind.ts b/src/features/vim/core/core/grammar/motion-kind.ts new file mode 100644 index 00000000..1b1a1e5f --- /dev/null +++ b/src/features/vim/core/core/grammar/motion-kind.ts @@ -0,0 +1,214 @@ +/** + * Motion kind resolution + * + * Determines whether a motion is characterwise, linewise, or blockwise. + * This affects how operators like delete, change, and yank behave. + * + * Vim documentation: :help motion.txt + */ + +import type { Motion } from "./ast"; + +/** + * Motion kind (characterwise, linewise, or blockwise) + */ +export type MotionKind = "char" | "line" | "block"; + +/** + * Motion inclusivity (whether the motion includes the end position) + */ +export type MotionInclusivity = "inclusive" | "exclusive"; + +/** + * Complete motion info + */ +export interface MotionInfo { + kind: MotionKind; + inclusive: MotionInclusivity; +} + +/** + * Linewise motions (operate on entire lines) + * + * Reference: :help linewise-motion + */ +const linewiseMotions = new Set([ + "_", // First non-blank character (linewise) + "gg", // First line + "G", // Last line / goto line + "j", // Down + "k", // Up + "{", // Paragraph backward + "}", // Paragraph forward + "(", // Sentence backward + ")", // Sentence forward + "H", // Top of screen + "M", // Middle of screen + "L", // Bottom of screen + "]]", // Section forward + "[[", // Section backward + "][", // Section end forward + "[]", // Section end backward + "]m", // Method forward + "[m", // Method backward +]); + +/** + * Inclusive characterwise motions + * + * Most characterwise motions are exclusive, but some are inclusive. + * Reference: :help inclusive + */ +const inclusiveMotions = new Set([ + "l", // Right (inclusive) + "$", // End of line (inclusive) + " ", // Right (space - same as l) + "f", // Find character forward (inclusive) + "t", // Till character forward (inclusive) + "F", // Find character backward (inclusive) + "T", // Till character backward (inclusive) + ";", // Repeat f/F/t/T (inclusive) + ",", // Repeat f/F/t/T reverse (inclusive) + "%", // Matching bracket (inclusive) + "/", // Search forward (inclusive) + "?", // Search backward (inclusive) + "n", // Repeat search (inclusive) + "N", // Repeat search reverse (inclusive) + "*", // Search word under cursor forward (inclusive) + "#", // Search word under cursor backward (inclusive) +]); + +/** + * Exclusive characterwise motions + * + * Default for characterwise motions. + * Reference: :help exclusive + */ +const exclusiveMotions = new Set([ + "h", // Left + "w", // Word forward + "W", // WORD forward + "e", // End of word + "E", // End of WORD + "b", // Word backward + "B", // WORD backward + "ge", // End of previous word + "gE", // End of previous WORD + "0", // Start of line + "^", // First non-blank + "g_", // Last non-blank + "gj", // Down display line + "gk", // Up display line + "g0", // Start of display line + "g^", // First non-blank of display line + "g$", // End of display line +]); + +/** + * Get motion kind (char, line, or block) for a given motion + * + * @param motion - The motion AST node + * @returns Motion information (kind and inclusivity) + */ +export function getMotionInfo(motion: Motion): MotionInfo { + // Simple motions + if (motion.type === "simple") { + if (linewiseMotions.has(motion.key)) { + return { kind: "line", inclusive: "inclusive" }; + } + + if (inclusiveMotions.has(motion.key)) { + return { kind: "char", inclusive: "inclusive" }; + } + + if (exclusiveMotions.has(motion.key)) { + return { kind: "char", inclusive: "exclusive" }; + } + + // Default: characterwise exclusive + return { kind: "char", inclusive: "exclusive" }; + } + + // Character motions (f, F, t, T) + if (motion.type === "char") { + // f, F, t, T are all inclusive + return { kind: "char", inclusive: "inclusive" }; + } + + // Search motions (/, ?) + if (motion.type === "search") { + // Search is inclusive + return { kind: "char", inclusive: "inclusive" }; + } + + // Search repeat motions (n, N, *, #) + if (motion.type === "searchRepeat") { + // Search repeats are inclusive + return { kind: "char", inclusive: "inclusive" }; + } + + // Mark motions (', `) + if (motion.type === "mark") { + // ' is linewise, ` is characterwise exclusive + if (motion.style === "'") { + return { kind: "line", inclusive: "inclusive" }; + } else { + return { kind: "char", inclusive: "exclusive" }; + } + } + + // Prefixed motions (g, z, [, ]) + if (motion.type === "prefixed") { + const fullKey = motion.head + motion.tail; + + if (linewiseMotions.has(fullKey)) { + return { kind: "line", inclusive: "inclusive" }; + } + + if (inclusiveMotions.has(fullKey)) { + return { kind: "char", inclusive: "inclusive" }; + } + + if (exclusiveMotions.has(fullKey)) { + return { kind: "char", inclusive: "exclusive" }; + } + + // Default for unknown prefixed motions + return { kind: "char", inclusive: "exclusive" }; + } + + // Fallback: characterwise exclusive + return { kind: "char", inclusive: "exclusive" }; +} + +/** + * Resolve final motion kind with forced kind override + * + * The v/V/ prefixes can force a motion to be char/line/block. + * + * @param motion - The motion AST node + * @param forcedKind - Optional forced kind (from v, V, or Ctrl-V) + * @returns Final motion kind + */ +export function resolveMotionKind( + motion: Motion, + forcedKind?: "char" | "line" | "block", +): MotionKind { + if (forcedKind) { + return forcedKind; + } + + const info = getMotionInfo(motion); + return info.kind; +} + +/** + * Check if a motion is inclusive + * + * @param motion - The motion AST node + * @returns True if motion is inclusive + */ +export function isMotionInclusive(motion: Motion): boolean { + const info = getMotionInfo(motion); + return info.inclusive === "inclusive"; +} diff --git a/src/features/vim/core/core/grammar/normalize.ts b/src/features/vim/core/core/grammar/normalize.ts new file mode 100644 index 00000000..16858673 --- /dev/null +++ b/src/features/vim/core/core/grammar/normalize.ts @@ -0,0 +1,220 @@ +/** + * Command normalization and count handling + * + * Normalizes command aliases and provides utilities for count multiplication. + */ + +import type { Command } from "./ast"; + +/** + * Normalize a command by expanding aliases + * + * Aliases: + * - D → d$ (delete to end of line) + * - C → c$ (change to end of line) + * - S → cc (substitute line) + * - Y → yy (yank line) + * - dd/yy/cc → d_/y_/c_ (doubled operators become linewise) + * + * The underscore (_) motion is a linewise motion to the first non-blank + * character on [count]-1 lines below. Modeling dd as d_ preserves linewise semantics. + */ +export function normalize(cmd: Command): Command { + // Handle action aliases + if (cmd.kind === "action") { + if (cmd.action.type === "misc") { + const k = cmd.action.key; + + // D → d$ (delete to end of line) + if (k === "D") { + return { + kind: "operator", + reg: cmd.reg, + countBefore: cmd.count, + operator: "d", + target: { + type: "motion", + motion: { type: "simple", key: "$" }, + }, + }; + } + + // C → c$ (change to end of line) + if (k === "C") { + return { + kind: "operator", + reg: cmd.reg, + countBefore: cmd.count, + operator: "c", + target: { + type: "motion", + motion: { type: "simple", key: "$" }, + }, + }; + } + + // S → cc (substitute line) + if (k === "S") { + return { + kind: "operator", + reg: cmd.reg, + countBefore: cmd.count, + operator: "c", + doubled: true, + target: { + type: "motion", + motion: { type: "simple", key: "_" }, + }, + }; + } + + // Y → yy (yank line) + if (k === "Y") { + return { + kind: "operator", + reg: cmd.reg, + countBefore: cmd.count, + operator: "y", + doubled: true, + target: { + type: "motion", + motion: { type: "simple", key: "_" }, + }, + }; + } + } + + return cmd; + } + + // Handle operator doubling (dd, yy, cc, >>, etc.) + // Normalize to operator + _ motion (linewise) + if (cmd.kind === "operator" && cmd.doubled && !cmd.target) { + return { + ...cmd, + doubled: true, + target: { + type: "motion", + motion: { type: "simple", key: "_" }, + }, + }; + } + + // Visual commands don't need normalization + if (cmd.kind === "visualOperator" || cmd.kind === "visualTextObject") { + return cmd; + } + + return cmd; +} + +/** + * Calculate effective count for a command + * + * For actions: just the count (default 1) + * For motions: just the count (default 1) + * For operators: countBefore * countAfter (default 1 each) + * + * Examples: + * - 3dw → countBefore=3, countAfter=undefined → count=3 + * - d3w → countBefore=undefined, countAfter=3 → count=3 + * - 2d3w → countBefore=2, countAfter=3 → count=6 + * - 5dd → countBefore=5, doubled=true → count=5 + * - 3w → count=3 (motion) + */ +export function effectiveCount(cmd: Command): number { + if (cmd.kind === "action") { + return cmd.count ?? 1; + } + + if (cmd.kind === "motion") { + return cmd.count ?? 1; + } + + // Visual commands don't use counts (count is already applied to the selection) + if (cmd.kind === "visualOperator" || cmd.kind === "visualTextObject") { + return 1; + } + + // For operators + const a = cmd.countBefore ?? 1; + const b = cmd.countAfter ?? 1; + + return a * b; +} + +/** + * Get the register to use for a command + * + * Returns the register name if specified, otherwise the default unnamed register (") + * Motion commands don't use registers. + */ +export function getRegisterName(cmd: Command): string { + if (cmd.kind === "action" && cmd.reg) { + return cmd.reg.name; + } + + if (cmd.kind === "operator" && cmd.reg) { + return cmd.reg.name; + } + + if (cmd.kind === "visualOperator" && cmd.reg) { + return cmd.reg.name; + } + + if (cmd.kind === "visualTextObject" && cmd.reg) { + return cmd.reg.name; + } + + // Motion commands don't use registers + if (cmd.kind === "motion") { + return '"'; + } + + // Default to unnamed register + return '"'; +} + +/** + * Check if a command is repeatable (for dot command) + * + * Most commands that modify text are repeatable. + * Navigation-only commands are not repeatable. + */ +export function isRepeatable(cmd: Command): boolean { + if (cmd.kind === "action") { + // Put, replace, mode changes, single char operations are repeatable + if ( + cmd.action.type === "put" || + cmd.action.type === "charReplace" || + cmd.action.type === "modeChange" || + cmd.action.type === "singleChar" + ) { + return true; + } + + // Misc actions: check specific keys + if (cmd.action.type === "misc") { + const k = cmd.action.key; + // J (join lines), ~ (toggle case) are repeatable + // D, C, S, Y are aliases (will be normalized to operators) + return k === "J" || k === "~" || k === "D" || k === "C" || k === "S" || k === "Y"; + } + + // Undo, redo, repeat are not repeatable + return false; + } + + // Motions are not repeatable (they don't modify text) + if (cmd.kind === "motion") { + return false; + } + + // Visual text objects are not repeatable (they don't modify text, just extend selection) + if (cmd.kind === "visualTextObject") { + return false; + } + + // Visual operators and all other operators are repeatable + return true; +} diff --git a/src/features/vim/core/core/grammar/parser.ts b/src/features/vim/core/core/grammar/parser.ts new file mode 100644 index 00000000..f3c620af --- /dev/null +++ b/src/features/vim/core/core/grammar/parser.ts @@ -0,0 +1,539 @@ +/** + * Vim grammar parser + * + * Streaming incremental parser that processes key sequences and produces AST. + * + * Grammar: + * Command := [Register] [Count] ( Action | OperatorInvocation ) + * Register := '"' RegisterName + * Count := DigitNonZero { Digit } + * OperatorInvocation := Operator ( Operator | [Count] Target ) + * Target := ForcedKind? ( TextObject | Motion ) + * ForcedKind := 'v' | 'V' | '' + * TextObject := ('a'|'i') ObjectKey + * Motion := SimpleMotion | CharMotion | SearchMotion | MarkMotion | ... + */ + +import type { Command, Motion, ParseResult, RegisterRef, Target } from "./ast"; +import { actions, forcedKinds, isTextObjectKey, motions, operators } from "./tokens"; + +/** + * Parser state (internal) + */ +interface ParseState { + reg?: RegisterRef; + count1?: number; + operator?: string; + doubled?: boolean; + forced?: "char" | "line" | "block"; + count2?: number; + target?: Target; +} + +/** + * Helper to check if a string is a digit + */ +const isDigit = (s: string): boolean => /^[0-9]$/.test(s); + +/** + * Helper to check if a string is a non-zero digit + */ +const isNonZeroDigit = (s: string): boolean => /^[1-9]$/.test(s); + +/** + * Parse a count from keys starting at index + * Returns { val: number, next: index } if found, otherwise { next: index } + */ +function parseNumber(keys: string[], i: number): { val?: number; next: number } { + if (i >= keys.length || !isNonZeroDigit(keys[i])) { + return { next: i }; + } + + let s = keys[i++]; + while (i < keys.length && isDigit(keys[i])) { + s += keys[i++]; + } + + return { val: parseInt(s, 10), next: i }; +} + +/** + * Main parser function + * + * Parses a sequence of keys into a Command AST or returns parse status. + * @param keys - The sequence of keys to parse + * @param mode - The current vim mode (defaults to "normal") + */ +export function parse(keys: string[], mode: "normal" | "visual" = "normal"): ParseResult { + if (keys.length === 0) { + return { status: "incomplete" }; + } + + let i = 0; + const st: ParseState = {}; + + // 1) Optional register: "x + if (keys[i] === '"') { + if (i + 1 >= keys.length) { + return { status: "incomplete" }; + } + st.reg = { name: keys[i + 1] }; + i += 2; + } + + // 2) Optional first count + const c1 = parseNumber(keys, i); + if (c1.val) { + st.count1 = c1.val; + } + i = c1.next; + + if (i >= keys.length) { + return { status: "incomplete" }; + } + + // 3) Try ACTION first (longest match) + const actionMatch = actions.match(keys, i); + if (actionMatch.kind === "complete") { + const tok = actionMatch.tok; + i += actionMatch.len; + + // Handle actions that expect a character argument (r, gr, f, t, F, T, ', `) + if (tok.expectsCharArg) { + if (i >= keys.length) { + return { status: "needsChar", context: tok.key }; + } + const char = keys[i++]; + + // Replace actions (r, gr) + if (tok.key === "r" || tok.key === "gr") { + const cmd: Command = { + kind: "action", + reg: st.reg, + count: st.count1, + action: { type: "charReplace", which: tok.key, char }, + }; + if (i !== keys.length) { + return { status: "invalid", reason: "Trailing keys after action" }; + } + return { status: "complete", command: cmd }; + } + } + + // Map action key to Action type + const action = mapKeyToAction(tok.key, st.count1); + if (!action) { + return { status: "invalid", reason: `Unknown action: ${tok.key}` }; + } + + const cmd: Command = { + kind: "action", + reg: st.reg, + count: st.count1, + action, + }; + + if (i !== keys.length) { + return { status: "invalid", reason: "Trailing keys after action" }; + } + + return { status: "complete", command: cmd }; + } + + // Don't return incomplete for partial action match yet - try operators and motions first + // This allows commands like "gg" to work even though "g" is a prefix of "gr" action + const actionIsPartial = actionMatch.kind === "partial"; + + // 4) Try OPERATOR + const opMatch = operators.match(keys, i); + if (opMatch.kind === "complete") { + st.operator = opMatch.tok.key; + i += opMatch.len; + + // In visual mode, operators are complete commands (no target needed) + if (mode === "visual") { + if (i !== keys.length) { + return { status: "invalid", reason: "Trailing keys after visual operator" }; + } + + const cmd: Command = { + kind: "visualOperator", + reg: st.reg, + operator: st.operator as any, + }; + + return { status: "complete", command: cmd }; + } + + // Check for operator doubling (dd, yy, cc, etc.) + const dblMatch = operators.match(keys, i); + if ( + dblMatch.kind === "complete" && + dblMatch.tok.key === st.operator && + opMatch.tok.linewiseIfDoubled + ) { + i += dblMatch.len; + st.doubled = true; + + // Doubled operators are complete (linewise on current line) + if (i !== keys.length) { + return { status: "invalid", reason: "Trailing keys after operator doubling" }; + } + + const cmd: Command = { + kind: "operator", + reg: st.reg, + countBefore: st.count1, + operator: st.operator as any, + doubled: true, + }; + + return { status: "complete", command: cmd }; + } + + // 5) Optional second count (after operator) + const c2 = parseNumber(keys, i); + if (c2.val) { + st.count2 = c2.val; + } + i = c2.next; + + // 6) Optional forced kind (v, V, Ctrl-V) + const fkMatch = forcedKinds.match(keys, i); + if (fkMatch.kind === "complete") { + i += fkMatch.len; + const fk = fkMatch.tok.key; + st.forced = fk === "V" ? "line" : fk === "" ? "block" : "char"; + } else if (fkMatch.kind === "partial") { + return { status: "incomplete" }; + } + + // 7) TARGET: text object or motion + if (i >= keys.length) { + return { status: "incomplete" }; + } + + // TEXT OBJECT? (i or a prefix) + if (keys[i] === "i" || keys[i] === "a") { + const mode = keys[i] === "i" ? "inner" : "around"; + i++; + + if (i >= keys.length) { + return { status: "incomplete" }; + } + + const objectKey = keys[i]; + if (!isTextObjectKey(objectKey)) { + return { status: "invalid", reason: `Invalid text object: ${objectKey}` }; + } + + i++; + st.target = { + type: "textObject", + forced: st.forced, + mode, + object: objectKey, + }; + + if (i !== keys.length) { + return { status: "invalid", reason: "Trailing keys after text object" }; + } + + const cmd: Command = { + kind: "operator", + reg: st.reg, + countBefore: st.count1, + operator: st.operator as any, + countAfter: st.count2, + target: st.target, + }; + + return { status: "complete", command: cmd }; + } + + // MOTION + const motionResult = parseMotion(keys, i); + if (motionResult.status !== "complete") { + return motionResult; + } + + i = motionResult.index; + st.target = { + type: "motion", + forced: st.forced, + motion: motionResult.motion, + }; + + if (i !== keys.length) { + return { status: "invalid", reason: "Trailing keys after motion" }; + } + + const cmd: Command = { + kind: "operator", + reg: st.reg, + countBefore: st.count1, + operator: st.operator as any, + countAfter: st.count2, + target: st.target, + }; + + return { status: "complete", command: cmd }; + } + + // Don't return incomplete for partial operator match yet - try motions first + const operatorIsPartial = opMatch.kind === "partial"; + + // In visual mode, check for text objects to extend selection (iw, aw, i", etc.) + if (mode === "visual" && (keys[i] === "i" || keys[i] === "a")) { + const objMode = keys[i] === "i" ? "inner" : "around"; + i++; + + if (i >= keys.length) { + return { status: "incomplete" }; + } + + const objectKey = keys[i]; + if (!isTextObjectKey(objectKey)) { + return { status: "invalid", reason: `Invalid text object: ${objectKey}` }; + } + + i++; + + if (i !== keys.length) { + return { status: "invalid", reason: "Trailing keys after visual text object" }; + } + + const cmd: Command = { + kind: "visualTextObject", + reg: st.reg, + mode: objMode, + object: objectKey, + }; + + return { status: "complete", command: cmd }; + } + + // 5) Try MOTION (standalone cursor movement) + const motionResult = parseMotion(keys, i); + if (motionResult.status === "complete") { + if (motionResult.index !== keys.length) { + return { status: "invalid", reason: "Trailing keys after motion" }; + } + + const cmd: Command = { + kind: "motion", + count: st.count1, + motion: motionResult.motion, + }; + + return { status: "complete", command: cmd }; + } + + if (motionResult.status === "incomplete" || motionResult.status === "needsChar") { + return motionResult; + } + + // If we get here, check if any token type had a partial match + // If so, we're waiting for more keys. Otherwise, it's invalid. + if (actionIsPartial || operatorIsPartial) { + return { status: "incomplete" }; + } + + return { status: "invalid", reason: "Unknown command prefix" }; +} + +/** + * Motion parse result (internal to parseMotion) + */ +type MotionParseResult = + | { status: "complete"; motion: Motion; index: number } + | { status: "incomplete" } + | { status: "needsChar"; context?: string } + | { status: "invalid"; reason?: string }; + +/** + * Parse a motion from keys starting at index + */ +function parseMotion(keys: string[], i: number): MotionParseResult { + const mm = motions.match(keys, i); + + if (mm.kind === "partial") { + return { status: "incomplete" }; + } + + if (mm.kind === "none") { + return { status: "invalid", reason: "Expected motion" }; + } + + const tok = mm.tok; + i += mm.len; + + // Handle search motions (/, ?) + if (tok.key === "/" || tok.key === "?") { + const start = i; + let end = i; + + // Read until token + while (end < keys.length && keys[end] !== "") { + end++; + } + + if (end >= keys.length) { + return { status: "incomplete" }; + } + + const pattern = keys.slice(start, end).join(""); + i = end + 1; + + const motion: Motion = { + type: "search", + dir: tok.key === "/" ? "fwd" : "bwd", + pattern, + }; + + return { status: "complete", motion, index: i }; + } + + // Handle char motions (f, F, t, T) + if ( + tok.expectsCharArg && + (tok.key === "f" || tok.key === "F" || tok.key === "t" || tok.key === "T") + ) { + if (i >= keys.length) { + return { status: "needsChar", context: tok.key }; + } + + const char = keys[i++]; + const motion: Motion = { + type: "char", + key: tok.key as "f" | "F" | "t" | "T", + char, + }; + + return { status: "complete", motion, index: i }; + } + + // Handle mark motions (', `) + if (tok.expectsCharArg && (tok.key === "'" || tok.key === "`")) { + if (i >= keys.length) { + return { status: "needsChar", context: tok.key }; + } + + const mark = keys[i++]; + const motion: Motion = { + type: "mark", + style: tok.key as "'" | "`", + mark, + }; + + return { status: "complete", motion, index: i }; + } + + // Handle search repeat motions (n, N, *, #) + if (tok.key === "n" || tok.key === "N" || tok.key === "*" || tok.key === "#") { + const motion: Motion = { + type: "searchRepeat", + key: tok.key as "n" | "N" | "*" | "#", + }; + + return { status: "complete", motion, index: i }; + } + + // Handle prefixed motions (g_, gj, gk, zt, zz, zb, etc.) + if (/^[gz[\]]/.test(tok.key)) { + const head = tok.key[0] as "g" | "z" | "[" | "]"; + const tail = tok.key.slice(1); + + const motion: Motion = { + type: "prefixed", + head, + tail, + }; + + return { status: "complete", motion, index: i }; + } + + // Simple motion + const motion: Motion = { + type: "simple", + key: tok.key, + }; + + return { status: "complete", motion, index: i }; +} + +/** + * Map action key to Action AST node + */ +function mapKeyToAction( + key: string, + _count?: number, +): Extract["action"] | null { + switch (key) { + // Put actions + case "p": + return { type: "put", which: "p" }; + case "P": + return { type: "put", which: "P" }; + + // Mode change actions + case "i": + return { type: "modeChange", mode: "insert" }; + case "a": + return { type: "modeChange", mode: "append" }; + case "A": + return { type: "modeChange", mode: "appendLine" }; + case "I": + return { type: "modeChange", mode: "insertLineStart" }; + case "o": + return { type: "modeChange", mode: "openBelow" }; + case "O": + return { type: "modeChange", mode: "openAbove" }; + case "s": + return { type: "modeChange", mode: "substitute" }; + + // Single char operations + case "x": + return { type: "singleChar", operation: "deleteChar" }; + case "X": + return { type: "singleChar", operation: "deleteCharBefore" }; + + // Undo/redo + case "u": + return { type: "undo" }; + case "": + return { type: "redo" }; + + // Repeat + case ".": + return { type: "repeat" }; + + // Misc actions (J, ~) + case "J": + case "~": + return { type: "misc", key }; + + // Aliases (D, C, S, Y) - handled by normalization layer + case "D": + case "C": + case "S": + case "Y": + return { type: "misc", key }; + + default: + return null; + } +} + +/** + * Utility: Get command parse status (for use-vim-keyboard integration) + */ +export function getCommandParseStatus( + keys: string[], +): "complete" | "incomplete" | "invalid" | "needsChar" { + const result = parse(keys); + if (result.status === "complete") return "complete"; + if (result.status === "incomplete") return "incomplete"; + if (result.status === "needsChar") return "needsChar"; + return "invalid"; +} diff --git a/src/features/vim/core/core/grammar/tokens.ts b/src/features/vim/core/core/grammar/tokens.ts new file mode 100644 index 00000000..564afe95 --- /dev/null +++ b/src/features/vim/core/core/grammar/tokens.ts @@ -0,0 +1,240 @@ +/** + * Token registries for Vim grammar + * + * Provides token tries for operators, actions, motions, forced kinds, and text objects. + * These tries support efficient longest-match prefix lookup during parsing. + */ + +import { TokenTrie, type TokSpec } from "./trie"; + +/** + * Operator tokens + * + * Operators act on a motion or text object (e.g., dw, ciw, >>, gUU). + * Operators can be doubled for linewise operation on current line (dd, yy, cc, etc.) + */ +export const operators = new TokenTrie(); + +const operatorTokens: TokSpec[] = [ + { key: "d", kind: "operator", linewiseIfDoubled: true }, // delete + { key: "c", kind: "operator", linewiseIfDoubled: true }, // change + { key: "y", kind: "operator", linewiseIfDoubled: true }, // yank + { key: ">", kind: "operator", linewiseIfDoubled: true }, // indent + { key: "<", kind: "operator", linewiseIfDoubled: true }, // outdent + { key: "=", kind: "operator", linewiseIfDoubled: true }, // format + { key: "!", kind: "operator", linewiseIfDoubled: false }, // filter + { key: "g~", kind: "operator", linewiseIfDoubled: true }, // toggle case + { key: "gu", kind: "operator", linewiseIfDoubled: true }, // lowercase + { key: "gU", kind: "operator", linewiseIfDoubled: true }, // uppercase + { key: "gq", kind: "operator", linewiseIfDoubled: false }, // format text + { key: "g@", kind: "operator", linewiseIfDoubled: false }, // operator function +]; + +operatorTokens.forEach((t) => operators.add(t)); + +/** + * Action tokens + * + * Actions are standalone commands that execute immediately without requiring a motion. + * Examples: p (paste), x (delete char), i (insert mode), u (undo) + */ +export const actions = new TokenTrie(); + +const actionTokens: TokSpec[] = [ + // Put (paste) actions + { key: "p", kind: "action" }, // paste after + { key: "P", kind: "action" }, // paste before + + // Replace actions (need character argument) + { key: "r", kind: "action", expectsCharArg: true }, // replace char + { key: "gr", kind: "action", expectsCharArg: true }, // virtual replace + + // Mode change actions + { key: "i", kind: "action" }, // insert mode + { key: "a", kind: "action" }, // append mode + { key: "A", kind: "action" }, // append at line end + { key: "I", kind: "action" }, // insert at line start + { key: "o", kind: "action" }, // open line below + { key: "O", kind: "action" }, // open line above + { key: "s", kind: "action" }, // substitute char + + // Single char operations + { key: "x", kind: "action" }, // delete char + { key: "X", kind: "action" }, // delete char before + + // Undo/redo + { key: "u", kind: "action" }, // undo + { key: "", kind: "action" }, // redo (Ctrl-r) + + // Repeat + { key: ".", kind: "action" }, // repeat last change + + // Misc actions + { key: "J", kind: "action" }, // join lines + { key: "~", kind: "action" }, // toggle case of char + + // Aliases (will be normalized to operator form) + { key: "D", kind: "action" }, // delete to end of line (d$) + { key: "C", kind: "action" }, // change to end of line (c$) + { key: "S", kind: "action" }, // substitute line (cc) + { key: "Y", kind: "action" }, // yank line (yy) +]; + +actionTokens.forEach((t) => actions.add(t)); + +/** + * Motion tokens + * + * Motions define cursor movement or text ranges. + * Can be used standalone or after an operator. + */ +export const motions = new TokenTrie(); + +const motionTokens: TokSpec[] = [ + // Word motions + { key: "w", kind: "motion" }, // word forward + { key: "W", kind: "motion" }, // WORD forward + { key: "e", kind: "motion" }, // end of word + { key: "E", kind: "motion" }, // end of WORD + { key: "b", kind: "motion" }, // word backward + { key: "B", kind: "motion" }, // WORD backward + { key: "ge", kind: "motion" }, // end of previous word + { key: "gE", kind: "motion" }, // end of previous WORD + + // Character motions (h, j, k, l) + { key: "h", kind: "motion" }, // left + { key: "j", kind: "motion" }, // down + { key: "k", kind: "motion" }, // up + { key: "l", kind: "motion" }, // right + + // Line motions + { key: "0", kind: "motion" }, // start of line + { key: "^", kind: "motion" }, // first non-blank + { key: "$", kind: "motion" }, // end of line + { key: "_", kind: "motion" }, // first non-blank (linewise) + { key: "g_", kind: "motion" }, // last non-blank + + // File motions + { key: "gg", kind: "motion" }, // first line + { key: "G", kind: "motion" }, // last line / goto line + + // Paragraph/block motions + { key: "{", kind: "motion" }, // paragraph backward + { key: "}", kind: "motion" }, // paragraph forward + { key: "(", kind: "motion" }, // sentence backward + { key: ")", kind: "motion" }, // sentence forward + + // Matching pair + { key: "%", kind: "motion" }, // matching bracket + + // Character find motions (need character argument) + { key: "f", kind: "motion", expectsCharArg: true }, // find char forward + { key: "F", kind: "motion", expectsCharArg: true }, // find char backward + { key: "t", kind: "motion", expectsCharArg: true }, // till char forward + { key: "T", kind: "motion", expectsCharArg: true }, // till char backward + { key: ";", kind: "motion" }, // repeat last f/F/t/T + { key: ",", kind: "motion" }, // repeat last f/F/t/T reverse + + // Search motions + { key: "/", kind: "motion" }, // search forward (needs pattern + ) + { key: "?", kind: "motion" }, // search backward (needs pattern + ) + { key: "n", kind: "motion" }, // repeat search + { key: "N", kind: "motion" }, // repeat search reverse + { key: "*", kind: "motion" }, // search word under cursor forward + { key: "#", kind: "motion" }, // search word under cursor backward + + // Mark motions (need mark character) + { key: "'", kind: "motion", expectsCharArg: true }, // jump to mark line + { key: "`", kind: "motion", expectsCharArg: true }, // jump to mark exact + + // Display motions + { key: "H", kind: "motion" }, // top of screen + { key: "M", kind: "motion" }, // middle of screen + { key: "L", kind: "motion" }, // bottom of screen + + // Scroll motions (z family) + { key: "zt", kind: "motion" }, // scroll cursor to top + { key: "zz", kind: "motion" }, // scroll cursor to middle + { key: "zb", kind: "motion" }, // scroll cursor to bottom + + // Section motions ([ and ] families) + { key: "]]", kind: "motion" }, // next section forward + { key: "[[", kind: "motion" }, // next section backward + { key: "][", kind: "motion" }, // next section end forward + { key: "[]", kind: "motion" }, // next section end backward + { key: "]m", kind: "motion" }, // next method forward + { key: "[m", kind: "motion" }, // next method backward + + // Display line motions (g family) + { key: "gj", kind: "motion" }, // down display line + { key: "gk", kind: "motion" }, // up display line + { key: "g0", kind: "motion" }, // start of display line + { key: "g^", kind: "motion" }, // first non-blank of display line + { key: "g$", kind: "motion" }, // end of display line +]; + +motionTokens.forEach((t) => motions.add(t)); + +/** + * Forced kind tokens (v, V, Ctrl-V) + * + * These force the following motion to be characterwise, linewise, or blockwise. + * Example: dvj (force charwise), dVw (force linewise) + */ +export const forcedKinds = new TokenTrie(); + +const forcedKindTokens: TokSpec[] = [ + { key: "v", kind: "forcedKind" }, // force characterwise + { key: "V", kind: "forcedKind" }, // force linewise + { key: "", kind: "forcedKind" }, // force blockwise (Ctrl-V) +]; + +forcedKindTokens.forEach((t) => forcedKinds.add(t)); + +/** + * Text object keys (used after 'i' or 'a') + * + * Text objects define regions of text (e.g., iw = inner word, a" = around quotes). + * They can only be used after an operator or in visual mode. + */ +export const textObjectKeys = new Set([ + "w", // word + "W", // WORD + "s", // sentence + "p", // paragraph + "(", // parentheses (opening) + ")", // parentheses (closing) + "[", // square brackets (opening) + "]", // square brackets (closing) + "{", // curly braces (opening) + "}", // curly braces (closing) + "<", // angle brackets (opening) + ">", // angle brackets (closing) + '"', // double quotes + "'", // single quotes + "`", // backticks + "t", // HTML/XML tag + "b", // block (same as () + "B", // Block (same as {}) +]); + +/** + * Check if a key is a valid text object key + */ +export function isTextObjectKey(key: string): boolean { + return textObjectKeys.has(key); +} + +/** + * Helper to check if a token expects a character argument + */ +export function expectsCharArg(tok: TokSpec): boolean { + return tok.expectsCharArg === true; +} + +/** + * Helper to check if an operator supports doubling for linewise operation + */ +export function supportsDoubling(tok: TokSpec): boolean { + return tok.linewiseIfDoubled === true; +} diff --git a/src/features/vim/core/core/grammar/trie.ts b/src/features/vim/core/core/grammar/trie.ts new file mode 100644 index 00000000..acc8995b --- /dev/null +++ b/src/features/vim/core/core/grammar/trie.ts @@ -0,0 +1,140 @@ +/** + * Trie data structure for efficient longest-match token lookup + * + * Supports multi-character tokens (e.g., "gg", "g~", "gU") and provides + * efficient prefix matching for the Vim parser. + */ + +/** + * Token kinds in the Vim grammar + */ +export type TokKind = "operator" | "motion" | "action" | "textobj" | "forcedKind"; + +/** + * Token specification + */ +export interface TokSpec { + key: string; // The token string (e.g., "d", "gg", "g~") + kind: TokKind; // Type of token + expectsCharArg?: boolean; // True for f/F/t/T/r/gr/'/` + linewiseIfDoubled?: boolean; // True for operators that support doubling (dd, yy, etc.) +} + +/** + * Node in the token trie + */ +export class TrieNode { + next = new Map(); + tok?: TokSpec; +} + +/** + * Match result from trie lookup + */ +export type TrieMatch = + | { kind: "complete"; tok: TokSpec; len: number } // Found complete token + | { kind: "partial" } // Valid prefix, but not complete + | { kind: "none" }; // No match + +/** + * Token trie for longest-match prefix lookup + */ +export class TokenTrie { + root = new TrieNode(); + + /** + * Add a token to the trie + */ + add(tok: TokSpec): void { + let cur = this.root; + for (const ch of tok.key) { + if (!cur.next.has(ch)) { + cur.next.set(ch, new TrieNode()); + } + cur = cur.next.get(ch)!; + } + cur.tok = tok; + } + + /** + * Find longest matching token from keys starting at index + * + * Returns: + * - complete: Found a complete token (potentially not the longest if more keys needed) + * - partial: Valid prefix but no complete token yet + * - none: Invalid prefix, no token possible + */ + match(keys: string[], index: number): TrieMatch { + let cur = this.root; + let lastTok: TokSpec | undefined; + let len = 0; + + for (let i = index; i < keys.length; i++) { + const ch = keys[i]; + const nxt = cur.next.get(ch); + + if (!nxt) { + // No further matches possible + break; + } + + cur = nxt; + len++; + + // Track the last complete token we found + if (cur.tok) { + lastTok = cur.tok; + } + } + + // If we found a complete token, return it + if (lastTok) { + // Calculate the actual length of the matched token + const tokenLen = lastTok.key.length; + return { kind: "complete", tok: lastTok, len: tokenLen }; + } + + // If we made progress but found no complete token, it's a partial match + if (len > 0) { + return { kind: "partial" }; + } + + // No match at all + return { kind: "none" }; + } + + /** + * Check if a key sequence could be a valid token (complete or partial) + */ + isValid(keys: string[], index: number): boolean { + const match = this.match(keys, index); + return match.kind !== "none"; + } + + /** + * Check if a key sequence is a complete token + */ + isComplete(keys: string[], index: number): boolean { + const match = this.match(keys, index); + return match.kind === "complete"; + } + + /** + * Get all registered token keys (for debugging/introspection) + */ + getAllTokens(): TokSpec[] { + const tokens: TokSpec[] = []; + + const traverse = (node: TrieNode) => { + if (node.tok) { + tokens.push(node.tok); + } + for (const child of node.next.values()) { + traverse(child); + } + }; + + traverse(this.root); + return tokens; + } +} diff --git a/src/features/vim/core/index.ts b/src/features/vim/core/index.ts index 7038bbd9..5c7a9e7c 100644 --- a/src/features/vim/core/index.ts +++ b/src/features/vim/core/index.ts @@ -105,13 +105,7 @@ */ // Actions -export { - getAction, - getActionKeys, - isAction, - pasteAction, - pasteBeforeAction, -} from "./actions"; +export { getAction, getActionKeys, isAction, pasteAction, pasteBeforeAction } from "./actions"; // Command execution export { canExecuteCommand, @@ -125,6 +119,54 @@ export { isCommandComplete, parseVimCommand, } from "./core/command-parser"; +export type { + Action as GrammarAction, + Command, + Count, + ModeChangeAction, + Motion as GrammarMotion, + OperatorKey, + ParseIncomplete, + ParseInvalid, + ParseNeedsChar, + ParseOk, + ParseResult, + RegisterRef, + SingleCharOperation, + Target, +} from "./core/grammar/ast"; +export { + executeVimCommandCompat, + expectsMoreKeys as expectsMoreKeysCompat, + getCommandParseStatusCompat, + isCommandComplete as isCommandCompleteCompat, + isCommandInvalid, + isNewParserEnabled, + parseVimCommandCompat, + setUseNewParser, +} from "./core/grammar/compat"; +export { executeAST } from "./core/grammar/executor"; +export { + getMotionInfo, + isMotionInclusive, + type MotionInclusivity, + type MotionInfo, + type MotionKind, + resolveMotionKind, +} from "./core/grammar/motion-kind"; +export { effectiveCount, getRegisterName, isRepeatable, normalize } from "./core/grammar/normalize"; +// New Grammar-Based Parser System +export { getCommandParseStatus as getGrammarParseStatus, parse } from "./core/grammar/parser"; +export { + actions as actionTokens, + expectsCharArg, + forcedKinds as forcedKindTokens, + isTextObjectKey, + motions as motionTokens, + operators as operatorTokens, + supportsDoubling, +} from "./core/grammar/tokens"; +export { TokenTrie, type TokKind, type TokSpec, type TrieMatch } from "./core/grammar/trie"; // Registries export { getMotion, getMotionKeys, isMotion } from "./core/motion-registry"; export { getTextObject } from "./core/text-objects"; @@ -139,11 +181,11 @@ export type { VimCommand, VimRange, } from "./core/types"; + export * from "./motions/character-motions"; export * from "./motions/file-motions"; export * from "./motions/line-motions"; export * from "./motions/viewport-motions"; - // Motions (for external use if needed) export * from "./motions/word-motions"; // Operators diff --git a/src/features/vim/core/operators/delete-operator.ts b/src/features/vim/core/operators/delete-operator.ts index c55f65fc..96becf93 100644 --- a/src/features/vim/core/operators/delete-operator.ts +++ b/src/features/vim/core/operators/delete-operator.ts @@ -3,6 +3,7 @@ */ import { calculateOffsetFromPosition } from "@/features/editor/utils/position"; +import { useVimStore } from "../../stores/vim-store"; import type { EditorContext, Operator, VimRange } from "../core/types"; import { setVimClipboard } from "./yank-operator"; @@ -30,6 +31,13 @@ export const deleteOperator: Operator = { linewise: true, }); + // Also store in vim store's register system + const vimStore = useVimStore.getState(); + const registerName = vimStore.activeRegister || '"'; + vimStore.actions.setRegisterContent(registerName, deletedContent, "line", { + source: "delete", + }); + const newLines = lines.filter((_, index) => { return index < startLine || index > endLine; }); @@ -43,11 +51,19 @@ export const deleteOperator: Operator = { const newOffset = newLines.length > 0 ? calculateOffsetFromPosition(newLine, newColumn, newLines) : 0; - setCursorPosition({ + const finalPosition = { line: Math.max(0, newLine), column: newColumn, offset: newOffset, - }); + }; + + setCursorPosition(finalPosition); + + // Sync textarea cursor to prevent viewport jumping + const textarea = document.querySelector(".editor-textarea") as HTMLTextAreaElement; + if (textarea) { + textarea.selectionStart = textarea.selectionEnd = newOffset; + } return; } @@ -66,6 +82,11 @@ export const deleteOperator: Operator = { linewise: false, }); + // Also store in vim store's register system + const vimStore = useVimStore.getState(); + const registerName = vimStore.activeRegister || '"'; + vimStore.actions.setRegisterContent(registerName, deletedContent, "char", { source: "delete" }); + const newContent = content.slice(0, startOffset) + content.slice(actualEndOffset); updateContent(newContent); @@ -86,10 +107,18 @@ export const deleteOperator: Operator = { const column = startOffset - offset; - setCursorPosition({ + const finalPosition = { line, column: Math.max(0, column), offset: startOffset, - }); + }; + + setCursorPosition(finalPosition); + + // Sync textarea cursor to prevent viewport jumping + const textarea = document.querySelector(".editor-textarea") as HTMLTextAreaElement; + if (textarea) { + textarea.selectionStart = textarea.selectionEnd = startOffset; + } }, }; diff --git a/src/features/vim/core/operators/yank-operator.ts b/src/features/vim/core/operators/yank-operator.ts index 22a293ea..1b241ec7 100644 --- a/src/features/vim/core/operators/yank-operator.ts +++ b/src/features/vim/core/operators/yank-operator.ts @@ -2,6 +2,7 @@ * Yank operator (y) */ +import { useVimStore } from "../../stores/vim-store"; import type { EditorContext, Operator, VimRange } from "../core/types"; /** @@ -33,10 +34,17 @@ export const yankOperator: Operator = { const startLine = Math.min(range.start.line, range.end.line); const endLine = Math.max(range.start.line, range.end.line); const yankedLines = lines.slice(startLine, endLine + 1); + const yankedContent = yankedLines.join("\n"); vimClipboard = { - content: yankedLines.join("\n"), + content: yankedContent, linewise: true, }; + + // Also store in vim store's register system + const vimStore = useVimStore.getState(); + const registerName = vimStore.activeRegister || '"'; + vimStore.actions.setRegisterContent(registerName, yankedContent, "line", { source: "yank" }); + return; } @@ -46,11 +54,17 @@ export const yankOperator: Operator = { // For inclusive ranges, include the end character const actualEndOffset = range.inclusive ? endOffset + 1 : endOffset; + const yankedContent = content.slice(startOffset, actualEndOffset); vimClipboard = { - content: content.slice(startOffset, actualEndOffset), + content: yankedContent, linewise: false, }; + + // Also store in vim store's register system + const vimStore = useVimStore.getState(); + const registerName = vimStore.activeRegister || '"'; + vimStore.actions.setRegisterContent(registerName, yankedContent, "char", { source: "yank" }); }, }; diff --git a/src/features/vim/hooks/use-vim-keyboard.ts b/src/features/vim/hooks/use-vim-keyboard.ts index a040f60f..ca823a76 100644 --- a/src/features/vim/hooks/use-vim-keyboard.ts +++ b/src/features/vim/hooks/use-vim-keyboard.ts @@ -4,12 +4,13 @@ import { useEditorViewStore } from "@/features/editor/stores/view-store"; import { calculateOffsetFromPosition } from "@/features/editor/utils/position"; import { useSettingsStore } from "@/features/settings/store"; import { - executeReplaceCommand, + executeAST, executeVimCommand, getCommandParseStatus, - parseVimCommand, + isNewParserEnabled, + // New grammar-based parser + parse as parseGrammar, } from "@/features/vim/core"; -import { createVimEditing } from "@/features/vim/stores/vim-editing"; import { useVimSearchStore } from "@/features/vim/stores/vim-search"; import { useVimStore } from "@/features/vim/stores/vim-store"; @@ -18,7 +19,7 @@ interface UseVimKeyboardProps { onGoToLine?: (line: number) => void; } -const getReplacementChar = (event: KeyboardEvent): string | null => { +const _getReplacementChar = (event: KeyboardEvent): string | null => { if (event.key.length === 1) { return event.key; } @@ -46,14 +47,12 @@ export const useVimKeyboard = ({ onSave, onGoToLine }: UseVimKeyboardProps) => { exitCommandMode, isCapturingInput, reset, - setLastKey, - clearLastKey, addToKeyBuffer, clearKeyBuffer, getKeyBuffer, setVisualMode, } = useVimStore.use.actions(); - const { setCursorVisibility, setCursorPosition } = useEditorStateStore.use.actions(); + const { setCursorVisibility } = useEditorStateStore.use.actions(); const { setDisabled } = useEditorStateStore.use.actions(); const { startSearch, findNext, findPrevious } = useVimSearchStore.use.actions(); @@ -136,10 +135,13 @@ export const useVimKeyboard = ({ onSave, onGoToLine }: UseVimKeyboardProps) => { textarea.selectionStart = textarea.selectionEnd = cursor.offset; textarea.style.caretColor = "transparent"; } else { - textarea.style.caretColor = "transparent"; - if (document.activeElement === textarea) { - textarea.blur(); + // Normal mode: keep textarea focused and show cursor with vim-normal color + if (document.activeElement !== textarea) { + textarea.focus(); } + const cursor = useEditorStateStore.getState().cursorPosition; + textarea.selectionStart = textarea.selectionEnd = cursor.offset; + textarea.style.caretColor = "transparent"; } } }, [vimMode, mode, isCommandMode, setCursorVisibility, setDisabled]); @@ -148,9 +150,6 @@ export const useVimKeyboard = ({ onSave, onGoToLine }: UseVimKeyboardProps) => { // Only activate vim keyboard handling when vim mode is enabled if (!vimMode) return; - // Create vim navigation and editing commands - const vimEdit = createVimEditing(); - const handleKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement; const isInputField = @@ -161,10 +160,11 @@ export const useVimKeyboard = ({ onSave, onGoToLine }: UseVimKeyboardProps) => { target.tagName === "TEXTAREA" && target.classList.contains("editor-textarea"); // Allow keyboard shortcuts with modifiers (Cmd/Ctrl/Alt) to pass through - // Exception: Ctrl+r is vim redo + // Exception: Ctrl+r is vim redo, Ctrl+v is vim visual block mode if ( (e.metaKey || e.ctrlKey || e.altKey) && - !(e.ctrlKey && e.key === "r" && !e.metaKey && !e.altKey) + !(e.ctrlKey && e.key === "r" && !e.metaKey && !e.altKey) && + !(e.ctrlKey && e.key === "v" && !e.metaKey && !e.altKey && mode === "normal") ) { return; } @@ -209,112 +209,41 @@ export const useVimKeyboard = ({ onSave, onGoToLine }: UseVimKeyboardProps) => { if (isCapturingInput()) return; const key = e.key; - const currentLastKey = useVimStore.getState().lastKey; - - if (currentLastKey === "r") { - if (key === "Escape") { - e.preventDefault(); - e.stopPropagation(); - clearLastKey(); - clearKeyBuffer(); - return true; - } - - const replacementChar = getReplacementChar(e); - if (!replacementChar) { - clearLastKey(); - clearKeyBuffer(); - return false; - } - - e.preventDefault(); - e.stopPropagation(); - - const buffer = [...useVimStore.getState().keyBuffer]; - const command = parseVimCommand(buffer); - const count = command?.count ?? 1; - const commandKeys = [...buffer, replacementChar]; - const success = executeReplaceCommand(replacementChar, { count }); - if (!success) { - console.warn("Failed to execute replace command:", commandKeys.join("")); - } - clearKeyBuffer(); - clearLastKey(); - return true; - } - - // Handle special commands that don't fit the operator-motion pattern - // These commands are handled directly without going through the key buffer + // Special UI-only commands that don't go through Vim grammar + // These need special handling for UI interaction switch (key) { - case "i": - e.preventDefault(); - e.stopPropagation(); - clearKeyBuffer(); - setMode("insert"); - return true; - case "a": { - e.preventDefault(); - e.stopPropagation(); - clearKeyBuffer(); - // Move cursor one position right before entering insert mode - const currentPos = getCursorPosition(); - const lines = getLines(); - const newColumn = Math.min(lines[currentPos.line].length, currentPos.column + 1); - const newOffset = calculateOffsetFromPosition(currentPos.line, newColumn, lines); - const newPosition = { line: currentPos.line, column: newColumn, offset: newOffset }; - setCursorPosition(newPosition); - - // Update textarea cursor - const textarea = document.querySelector(".editor-textarea") as HTMLTextAreaElement; - if (textarea) { - textarea.selectionStart = textarea.selectionEnd = newOffset; - } - - setMode("insert"); - return true; - } - case "A": - e.preventDefault(); - e.stopPropagation(); - clearKeyBuffer(); - vimEdit.appendToLine(); - setMode("insert"); - return true; - case "I": + case ":": e.preventDefault(); e.stopPropagation(); clearKeyBuffer(); - vimEdit.insertAtLineStart(); - setMode("insert"); + enterCommandMode(); return true; - case "o": + case "/": e.preventDefault(); e.stopPropagation(); clearKeyBuffer(); - vimEdit.openLineBelow(); - setMode("insert"); + startSearch(); return true; - case "O": + case "n": e.preventDefault(); e.stopPropagation(); clearKeyBuffer(); - vimEdit.openLineAbove(); - setMode("insert"); + findNext(); return true; - case ":": + case "N": e.preventDefault(); e.stopPropagation(); clearKeyBuffer(); - enterCommandMode(); + findPrevious(); return true; case "v": { e.preventDefault(); e.stopPropagation(); clearKeyBuffer(); const currentPos = useEditorStateStore.getState().cursorPosition; - const { setVisualSelection } = useVimStore.use.actions(); - setVisualSelection( + const vimStore = useVimStore.getState(); + vimStore.actions.setVisualSelection( { line: currentPos.line, column: currentPos.column }, { line: currentPos.line, column: currentPos.column }, ); @@ -336,8 +265,8 @@ export const useVimKeyboard = ({ onSave, onGoToLine }: UseVimKeyboardProps) => { clearKeyBuffer(); const currentPos = useEditorStateStore.getState().cursorPosition; const lines = useEditorViewStore.getState().lines; - const { setVisualSelection } = useVimStore.use.actions(); - setVisualSelection( + const vimStore = useVimStore.getState(); + vimStore.actions.setVisualSelection( { line: currentPos.line, column: 0 }, { line: currentPos.line, column: lines[currentPos.line].length }, ); @@ -366,69 +295,30 @@ export const useVimKeyboard = ({ onSave, onGoToLine }: UseVimKeyboardProps) => { clearKeyBuffer(); setMode("normal"); return true; - case "u": - e.preventDefault(); - e.stopPropagation(); - clearKeyBuffer(); - vimEdit.undo(); - return true; - case "r": { - if (e.ctrlKey) { - e.preventDefault(); - e.stopPropagation(); - clearKeyBuffer(); - vimEdit.redo(); - return true; - } - // Wait for next character for replace - e.preventDefault(); - e.stopPropagation(); - const bufferBeforeReplace = [...useVimStore.getState().keyBuffer]; - const candidateBuffer = [...bufferBeforeReplace, "r"]; - if (getCommandParseStatus(candidateBuffer) === "invalid") { - clearKeyBuffer(); - } - addToKeyBuffer("r"); - setLastKey("r"); - return true; + } + + // Handle Ctrl+v for block visual mode (outside switch since it requires modifier) + if (e.ctrlKey && key === "v" && !e.metaKey && !e.altKey) { + e.preventDefault(); + e.stopPropagation(); + clearKeyBuffer(); + const currentPos = useEditorStateStore.getState().cursorPosition; + const vimStore = useVimStore.getState(); + vimStore.actions.setVisualSelection( + { line: currentPos.line, column: currentPos.column }, + { line: currentPos.line, column: currentPos.column }, + ); + setVisualMode("block"); + setMode("visual"); + + // Initialize textarea selection at current position + const textarea = document.querySelector(".editor-textarea") as HTMLTextAreaElement; + if (textarea) { + textarea.selectionStart = textarea.selectionEnd = currentPos.offset; + textarea.focus(); } - case "s": - e.preventDefault(); - e.stopPropagation(); - clearKeyBuffer(); - vimEdit.substituteChar(); - setMode("insert"); - return true; - case "x": - e.preventDefault(); - e.stopPropagation(); - clearKeyBuffer(); - vimEdit.deleteChar(); - return true; - case "X": - e.preventDefault(); - e.stopPropagation(); - clearKeyBuffer(); - vimEdit.deleteCharBefore(); - return true; - case "/": - e.preventDefault(); - e.stopPropagation(); - clearKeyBuffer(); - startSearch(); - return true; - case "n": - e.preventDefault(); - e.stopPropagation(); - clearKeyBuffer(); - findNext(); - return true; - case "N": - e.preventDefault(); - e.stopPropagation(); - clearKeyBuffer(); - findPrevious(); - return true; + + return true; } // Handle arrow keys by mapping them to vim motions @@ -453,19 +343,60 @@ export const useVimKeyboard = ({ onSave, onGoToLine }: UseVimKeyboardProps) => { break; } - const success = executeVimCommand([vimKey]); - if (!success) { - console.warn("Failed to execute arrow key motion:", vimKey); + // Use new parser if enabled + if (isNewParserEnabled()) { + const result = parseGrammar([vimKey]); + if (result.status === "complete") { + const success = executeAST(result.command); + if (!success) { + console.warn("Failed to execute arrow key motion:", vimKey); + } + } + } else { + const success = executeVimCommand([vimKey]); + if (!success) { + console.warn("Failed to execute arrow key motion:", vimKey); + } } return true; } - // Now handle vim command sequences with the new modular system - // This supports: [count][operator][count][motion/text-object] - // Examples: 3j, 5w, d3w, 3dw, ciw, di", dd, yy, etc. - + // All other commands go through the grammar-based parser const buffer = getKeyBuffer(); const candidateBuffer = [...buffer, key]; + + // Use new grammar-based parser if enabled + if (isNewParserEnabled()) { + const result = parseGrammar(candidateBuffer); + + // Invalid command - reset buffer + if (result.status === "invalid") { + if (buffer.length > 0) { + clearKeyBuffer(); + } + return false; + } + + e.preventDefault(); + e.stopPropagation(); + addToKeyBuffer(key); + + // Command is complete - execute it + if (result.status === "complete") { + const success = executeAST(result.command); + clearKeyBuffer(); + + if (!success) { + console.warn("Failed to execute vim command:", candidateBuffer.join("")); + } + return true; + } + + // Waiting for more keys (incomplete or needsChar) + return true; + } + + // Fallback to old parser (for testing/compatibility) const parseStatus = getCommandParseStatus(candidateBuffer); if (parseStatus === "invalid") { @@ -497,6 +428,42 @@ export const useVimKeyboard = ({ onSave, onGoToLine }: UseVimKeyboardProps) => { if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); + + // In Vim, when exiting insert mode, the cursor moves back one position + // (unless already at the beginning of the line) + const currentPos = getCursorPosition(); + const lines = getLines(); + + if (currentPos.column > 0) { + const newColumn = currentPos.column - 1; + const newOffset = calculateOffsetFromPosition(currentPos.line, newColumn, lines); + const newPosition = { line: currentPos.line, column: newColumn, offset: newOffset }; + + const { setCursorPosition } = useEditorStateStore.getState().actions; + setCursorPosition(newPosition); + + // Update textarea selection + const textarea = document.querySelector(".editor-textarea") as HTMLTextAreaElement; + if (textarea) { + textarea.selectionStart = textarea.selectionEnd = newOffset; + } + } else if (currentPos.column === 0 && currentPos.line > 0) { + // If at column 0, move to end of previous line + const newLine = currentPos.line - 1; + const newColumn = Math.max(0, lines[newLine].length - 1); + const newOffset = calculateOffsetFromPosition(newLine, newColumn, lines); + const newPosition = { line: newLine, column: newColumn, offset: newOffset }; + + const { setCursorPosition } = useEditorStateStore.getState().actions; + setCursorPosition(newPosition); + + // Update textarea selection + const textarea = document.querySelector(".editor-textarea") as HTMLTextAreaElement; + if (textarea) { + textarea.selectionStart = textarea.selectionEnd = newOffset; + } + } + setMode("normal"); return true; } @@ -506,131 +473,144 @@ export const useVimKeyboard = ({ onSave, onGoToLine }: UseVimKeyboardProps) => { const handleVisualMode = (e: KeyboardEvent) => { const key = e.key; - const visualMode = useVimStore.getState().visualMode; - const visualSelection = useVimStore.getState().visualSelection; + // Special keys that don't go through the parser switch (key) { case "Escape": e.preventDefault(); e.stopPropagation(); + clearKeyBuffer(); setMode("normal"); return true; case ":": e.preventDefault(); e.stopPropagation(); + clearKeyBuffer(); enterCommandMode(); return true; - case "d": - case "y": - case "c": { - e.preventDefault(); - e.stopPropagation(); - // Handle operators on visual selection - const textarea = document.querySelector(".editor-textarea") as HTMLTextAreaElement; - if (textarea && visualSelection.start && visualSelection.end) { - const lines = useEditorViewStore.getState().lines; - const startOffset = calculateOffsetFromPosition( - visualSelection.start.line, - visualSelection.start.column, - lines, - ); - const endOffset = calculateOffsetFromPosition( - visualSelection.end.line, - visualSelection.end.column, - lines, - ); + } - if (key === "d") { - vimEdit.deleteVisualSelection(startOffset, endOffset); - } else if (key === "y") { - vimEdit.yankVisualSelection(startOffset, endOffset); - } else if (key === "c") { - vimEdit.deleteVisualSelection(startOffset, endOffset); - setMode("insert"); - return true; - } + // Helper to apply motion and update visual selection + const applyMotion = (motionKeys: string[]): boolean => { + let success = false; + + if (isNewParserEnabled()) { + const result = parseGrammar(motionKeys); + if (result.status === "complete") { + success = executeAST(result.command); } - setMode("normal"); - return true; + } else { + success = executeVimCommand(motionKeys); } - } - const applyMotion = (motionKeys: string[]): boolean => { - const success = executeVimCommand(motionKeys); if (!success) { - console.warn("Failed to execute visual motion:", motionKeys.join("")); return false; } const newPosition = useEditorStateStore.getState().cursorPosition; const lines = useEditorViewStore.getState().lines; - - const { setVisualSelection } = useVimStore.use.actions(); - if (visualSelection.start) { - if (visualMode === "line") { - setVisualSelection( - { line: visualSelection.start.line, column: 0 }, - { line: newPosition.line, column: lines[newPosition.line].length }, - ); + const vimStore = useVimStore.getState(); + + // Get fresh visual selection state + const currentVisualSelection = vimStore.visualSelection; + const currentVisualMode = vimStore.visualMode; + + if (currentVisualSelection.start) { + // Line mode: always select full lines + if (currentVisualMode === "line") { + const newStart = { line: currentVisualSelection.start.line, column: 0 }; + const newEnd = { line: newPosition.line, column: lines[newPosition.line].length }; + vimStore.actions.setVisualSelection(newStart, newEnd); } else { - setVisualSelection(visualSelection.start, { - line: newPosition.line, - column: newPosition.column, - }); + // Char/block mode: select from start to cursor + const newEnd = { line: newPosition.line, column: newPosition.column }; + vimStore.actions.setVisualSelection(currentVisualSelection.start, newEnd); } } + // Update textarea selection const textarea = document.querySelector(".editor-textarea") as HTMLTextAreaElement; - if (textarea && visualSelection.start) { + if (textarea && currentVisualSelection.start) { const startOffset = calculateOffsetFromPosition( - visualSelection.start.line, - visualSelection.start.column, + currentVisualSelection.start.line, + currentVisualSelection.start.column, lines, ); const endOffset = newPosition.offset; - if (startOffset <= endOffset) { - textarea.selectionStart = startOffset; - textarea.selectionEnd = endOffset; - } else { - textarea.selectionStart = endOffset; - textarea.selectionEnd = startOffset; - } - textarea.dispatchEvent(new Event("select")); + textarea.selectionStart = Math.min(startOffset, endOffset); + textarea.selectionEnd = Math.max(startOffset, endOffset); + + // Don't dispatch select event - it causes cursor position to be overridden + // textarea.dispatchEvent(new Event("select")); } return true; }; + // Handle arrow keys (map to hjkl motions) if (key === "ArrowLeft" || key === "ArrowRight" || key === "ArrowUp" || key === "ArrowDown") { e.preventDefault(); e.stopPropagation(); clearKeyBuffer(); - let motionKeys: string[] | null = null; - switch (key) { - case "ArrowLeft": - motionKeys = ["h"]; - break; - case "ArrowRight": - motionKeys = ["l"]; - break; - case "ArrowUp": - motionKeys = ["k"]; - break; - case "ArrowDown": - motionKeys = ["j"]; - break; - } + const motionMap: Record = { + ArrowLeft: "h", + ArrowRight: "l", + ArrowUp: "k", + ArrowDown: "j", + }; - if (motionKeys) { - applyMotion(motionKeys); - } + applyMotion([motionMap[key]]); return true; } + // Use grammar parser for all other commands const buffer = getKeyBuffer(); const candidateBuffer = [...buffer, key]; + + if (isNewParserEnabled()) { + const result = parseGrammar(candidateBuffer, "visual"); + + // Invalid command - reset buffer + if (result.status === "invalid") { + if (buffer.length > 0) { + clearKeyBuffer(); + } + return false; + } + + e.preventDefault(); + e.stopPropagation(); + addToKeyBuffer(key); + + // Command is complete - execute it + if (result.status === "complete") { + const cmd = result.command; + + // Visual operators and text objects are handled by executeAST + if (cmd.kind === "visualOperator" || cmd.kind === "visualTextObject") { + const success = executeAST(cmd); + clearKeyBuffer(); + return success; + } + + // Motions extend the selection + if (cmd.kind === "motion") { + const success = applyMotion(candidateBuffer); + clearKeyBuffer(); + return success; + } + + clearKeyBuffer(); + return true; + } + + // Waiting for more keys (incomplete or needsChar) + return true; + } + + // Fallback to old parser for motions const parseStatus = getCommandParseStatus(candidateBuffer); if (parseStatus === "invalid") { @@ -658,7 +638,22 @@ export const useVimKeyboard = ({ onSave, onGoToLine }: UseVimKeyboardProps) => { return () => { document.removeEventListener("keydown", handleKeyDown, true); }; - }, [vimMode, mode, isCommandMode, setMode, enterCommandMode, exitCommandMode, isCapturingInput]); + }, [ + vimMode, + mode, + isCommandMode, + setMode, + enterCommandMode, + exitCommandMode, + isCapturingInput, + setVisualMode, + clearKeyBuffer, + addToKeyBuffer, + getKeyBuffer, + startSearch, + findNext, + findPrevious, + ]); // Handle vim-specific custom events useEffect(() => { diff --git a/src/features/vim/stores/vim-editing.ts b/src/features/vim/stores/vim-editing.ts index a269cddc..1a980012 100644 --- a/src/features/vim/stores/vim-editing.ts +++ b/src/features/vim/stores/vim-editing.ts @@ -14,8 +14,8 @@ let vimClipboard: VimClipboard = { content: "", type: "char" }; export interface VimEditingCommands { deleteLine: () => void; yankLine: () => void; - paste: () => void; - pasteAbove: () => void; + paste: (content?: string, type?: "line" | "char") => void; + pasteAbove: (content?: string, type?: "line" | "char") => void; undo: () => void; redo: () => void; deleteChar: () => void; @@ -127,18 +127,22 @@ export const createVimEditing = (): VimEditingCommands => { vimClipboard = { content: `${lines[currentPos.line]}\n`, type: "line" }; }, - paste: () => { - if (!vimClipboard.content) return; + paste: (content?: string, type?: "line" | "char") => { + // Use provided content or fall back to vimClipboard + const pasteContent = content ?? vimClipboard.content; + const pasteType = type ?? vimClipboard.type; + + if (!pasteContent) return; saveUndoState(); const currentPos = getCursorPosition(); const lines = getLines(); - if (vimClipboard.type === "line") { + if (pasteType === "line") { // Paste as new line below current line const newLines = [...lines]; - newLines.splice(currentPos.line + 1, 0, vimClipboard.content.replace(/\n$/, "")); + newLines.splice(currentPos.line + 1, 0, pasteContent.replace(/\n$/, "")); const newContent = newLines.join("\n"); // Move cursor to beginning of pasted line @@ -154,13 +158,13 @@ export const createVimEditing = (): VimEditingCommands => { const currentContent = getContent(); const newContent = currentContent.slice(0, currentPos.offset) + - vimClipboard.content + + pasteContent + currentContent.slice(currentPos.offset); updateContent(newContent); // Move cursor to end of pasted content - const newOffset = currentPos.offset + vimClipboard.content.length; + const newOffset = currentPos.offset + pasteContent.length; const newLines = newContent.split("\n"); let line = 0; let offset = 0; @@ -177,26 +181,57 @@ export const createVimEditing = (): VimEditingCommands => { } }, - pasteAbove: () => { - if (!vimClipboard.content || vimClipboard.type !== "line") return; + pasteAbove: (content?: string, type?: "line" | "char") => { + // Use provided content or fall back to vimClipboard + const pasteContent = content ?? vimClipboard.content; + const pasteType = type ?? vimClipboard.type; + + if (!pasteContent) return; saveUndoState(); const currentPos = getCursorPosition(); const lines = getLines(); - // Paste as new line above current line - const newLines = [...lines]; - newLines.splice(currentPos.line, 0, vimClipboard.content.replace(/\n$/, "")); - const newContent = newLines.join("\n"); + if (pasteType === "line") { + // Paste as new line above current line + const newLines = [...lines]; + newLines.splice(currentPos.line, 0, pasteContent.replace(/\n$/, "")); + const newContent = newLines.join("\n"); - // Move cursor to beginning of pasted line - const newOffset = calculateOffsetFromPosition(currentPos.line, 0, newLines); + // Move cursor to beginning of pasted line + const newOffset = calculateOffsetFromPosition(currentPos.line, 0, newLines); - updateContent(newContent); - const newPosition = { line: currentPos.line, column: 0, offset: newOffset }; - setCursorPosition(newPosition); - updateTextareaCursor(newPosition); + updateContent(newContent); + const newPosition = { line: currentPos.line, column: 0, offset: newOffset }; + setCursorPosition(newPosition); + updateTextareaCursor(newPosition); + } else { + // Paste characterwise content before cursor + const currentContent = getContent(); + const newContent = + currentContent.slice(0, currentPos.offset) + + pasteContent + + currentContent.slice(currentPos.offset); + + updateContent(newContent); + + // Move cursor to last character of pasted content + const newOffset = currentPos.offset + pasteContent.length - 1; + const newLines = newContent.split("\n"); + let line = 0; + let offset = 0; + + while (offset + newLines[line].length + 1 <= newOffset && line < newLines.length - 1) { + offset += newLines[line].length + 1; + line++; + } + + const column = newOffset - offset; + const newPosition = { line, column, offset: newOffset }; + setCursorPosition(newPosition); + updateTextareaCursor(newPosition); + } }, undo: () => { @@ -278,8 +313,16 @@ export const createVimEditing = (): VimEditingCommands => { currentContent.slice(0, currentPos.offset) + currentContent.slice(currentPos.offset + 1); updateContent(newContent); - // Cursor position stays the same - updateTextareaCursor(currentPos); + + // Recalculate cursor position with new content + const newLines = newContent.split("\n"); + const newLine = Math.min(currentPos.line, newLines.length - 1); + const newColumn = Math.min(currentPos.column, newLines[newLine]?.length ?? 0); + const newOffset = calculateOffsetFromPosition(newLine, newColumn, newLines); + + const newPosition = { line: newLine, column: newColumn, offset: newOffset }; + setCursorPosition(newPosition); + updateTextareaCursor(newPosition); } }, @@ -323,7 +366,8 @@ export const createVimEditing = (): VimEditingCommands => { currentContent.slice(currentPos.offset + 1); updateContent(newContent); - // Cursor position stays the same + // Cursor position stays the same - update both store and textarea + setCursorPosition(currentPos); updateTextareaCursor(currentPos); } }, diff --git a/src/features/vim/stores/vim-store.ts b/src/features/vim/stores/vim-store.ts index 8a1a8f9a..debb553c 100644 --- a/src/features/vim/stores/vim-store.ts +++ b/src/features/vim/stores/vim-store.ts @@ -6,6 +6,14 @@ import { createSelectors } from "@/utils/zustand-selectors"; export type VimMode = "normal" | "insert" | "visual" | "command"; +/** + * Register content + */ +export interface RegisterContent { + content: string; + type: "line" | "char"; +} + interface VimState { mode: VimMode; relativeLineNumbers: boolean; @@ -18,16 +26,20 @@ interface VimState { start: { line: number; column: number } | null; end: { line: number; column: number } | null; }; - visualMode: "char" | "line" | null; // Track visual mode type + visualMode: "char" | "line" | "block" | null; // Track visual mode type register: { text: string; isLineWise: boolean; }; + // New multi-register system + registers: Record; // Named registers (a-z, 0-9, ", +, *, _, /) + activeRegister: string | null; // Currently selected register via " prefix lastOperation: { type: "command" | "action" | null; keys: string[]; count?: number; - } | null; // For repeat (.) functionality + } | null; // Legacy - for old parser + lastRepeatableCommand: any | null; // Store the last Command AST for repeat (.) functionality } const defaultVimState: VimState = { @@ -47,7 +59,19 @@ const defaultVimState: VimState = { text: "", isLineWise: false, }, + // Initialize multi-register system + registers: { + '"': { content: "", type: "char" }, // Unnamed register (default) + "0": { content: "", type: "char" }, // Yank register + "-": { content: "", type: "char" }, // Small delete register + _: { content: "", type: "char" }, // Black hole register + "/": { content: "", type: "char" }, // Last search pattern + "+": { content: "", type: "char" }, // System clipboard + "*": { content: "", type: "char" }, // Selection clipboard + }, + activeRegister: null, lastOperation: null, + lastRepeatableCommand: null, }; const useVimStoreBase = create( @@ -165,7 +189,7 @@ const useVimStoreBase = create( return get().keyBuffer; }, - setVisualMode: (mode: "char" | "line" | null) => { + setVisualMode: (mode: "char" | "line" | "block" | null) => { set((state) => { state.visualMode = mode; }); @@ -216,6 +240,74 @@ const useVimStoreBase = create( state.lastOperation = null; }); }, + + // Last command management for new grammar-based repeat + setLastRepeatableCommand: (command: any) => { + set((state) => { + state.lastRepeatableCommand = command; + }); + }, + + getLastRepeatableCommand: (): any => { + return get().lastRepeatableCommand; + }, + + clearLastRepeatableCommand: () => { + set((state) => { + state.lastRepeatableCommand = null; + }); + }, + + // Register management + setRegisterContent: ( + name: string, + content: string, + type: "line" | "char", + options?: { source?: "yank" | "delete" }, + ) => { + set((state) => { + state.registers[name] = { content, type }; + + // Also update unnamed register for most operations + if (name !== '"' && name !== "_") { + state.registers['"'] = { content, type }; + } + + // Update yank register (0) only for yank operations + // This prevents delete operations from polluting the yank register + if (options?.source === "yank") { + state.registers["0"] = { content, type }; + } + + // Sync with legacy register for backward compatibility + if (name === '"' || !name) { + state.register.text = content; + state.register.isLineWise = type === "line"; + } + }); + }, + + getRegisterContent: (name: string): RegisterContent => { + const state = get(); + return state.registers[name] || { content: "", type: "char" }; + }, + + setActiveRegisterName: (name: string | null) => { + set((state) => { + state.activeRegister = name; + }); + }, + + getActiveRegisterName: (): string => { + const state = get(); + return state.activeRegister || '"'; + }, + + clearActiveRegister: () => { + set((state) => { + state.activeRegister = null; + }); + }, }, })), ),