From 3be7f7010c65b98ee7a14b8711bf9cc27b353621 Mon Sep 17 00:00:00 2001 From: Syed Muzamil Date: Thu, 30 Oct 2025 14:26:55 +0530 Subject: [PATCH 01/10] Implement multi-register system and grammar parser This commit introduces a new multi-register system to store and retrieve content for named registers (a-z, 0-9, ", +, *, _, /). It also initializes default registers (unnamed, yank, delete, black hole, search pattern, system clipboard, selection clipboard). The legacy register is still updated for backward compatibility. Additionally, this commit implements a grammar-based parser system for Vim commands. This provides a more robust and extensible way to parse Vim commands, and supports future features. The grammar parser implementation includes: - A new Abstract Syntax Tree (AST) for representing commands. - A streaming incremental parser. - A compatibility layer that enables incremental migration between the old and new parser systems. - An executor to execute AST commands. This commit also introduces new types for working with the grammar parser. --- src/stores/vim-store.ts | 67 ++++ src/stores/vim/grammar/ast.ts | 195 +++++++++++ src/stores/vim/grammar/compat.ts | 137 ++++++++ src/stores/vim/grammar/executor.ts | 453 +++++++++++++++++++++++++ src/stores/vim/grammar/motion-kind.ts | 214 ++++++++++++ src/stores/vim/grammar/normalize.ts | 180 ++++++++++ src/stores/vim/grammar/parser.ts | 468 ++++++++++++++++++++++++++ src/stores/vim/grammar/tokens.ts | 236 +++++++++++++ src/stores/vim/grammar/trie.ts | 140 ++++++++ src/stores/vim/index.ts | 62 +++- 10 files changed, 2151 insertions(+), 1 deletion(-) create mode 100644 src/stores/vim/grammar/ast.ts create mode 100644 src/stores/vim/grammar/compat.ts create mode 100644 src/stores/vim/grammar/executor.ts create mode 100644 src/stores/vim/grammar/motion-kind.ts create mode 100644 src/stores/vim/grammar/normalize.ts create mode 100644 src/stores/vim/grammar/parser.ts create mode 100644 src/stores/vim/grammar/tokens.ts create mode 100644 src/stores/vim/grammar/trie.ts diff --git a/src/stores/vim-store.ts b/src/stores/vim-store.ts index 5210e29d..38696505 100644 --- a/src/stores/vim-store.ts +++ b/src/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; @@ -23,6 +31,9 @@ interface VimState { 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[]; @@ -47,6 +58,17 @@ 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, }; @@ -216,6 +238,51 @@ const useVimStoreBase = create( state.lastOperation = null; }); }, + + // Register management + setRegisterContent: (name: string, content: string, type: "line" | "char") => { + 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) for yank operations + if (name === '"' && type === "char") { + 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; + }); + }, }, })), ), diff --git a/src/stores/vim/grammar/ast.ts b/src/stores/vim/grammar/ast.ts new file mode 100644 index 00000000..50a94dba --- /dev/null +++ b/src/stores/vim/grammar/ast.ts @@ -0,0 +1,195 @@ +/** + * AST (Abstract Syntax Tree) types for Vim grammar + * + * Grammar (EBNF): + * Command := [Register] [Count] ( Action | OperatorInvocation ) + * 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; + }; + +/** + * 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/stores/vim/grammar/compat.ts b/src/stores/vim/grammar/compat.ts new file mode 100644 index 00000000..afbe397b --- /dev/null +++ b/src/stores/vim/grammar/compat.ts @@ -0,0 +1,137 @@ +/** + * 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 "../core/command-executor"; +import { + getCommandParseStatus as getOldParseStatus, + parseVimCommand as parseOld, +} from "../core/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; + +/** + * 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 (!USE_NEW_PARSER) { + // Use old parser + const oldCmd = parseOld(keys); + if (!oldCmd) return null; + + // Convert old command format to new ParseResult format + // This is a simplified conversion - full conversion would be more complex + return { + status: "complete", + command: { + kind: "action", // Simplified + action: { type: "misc", key: "" }, + }, + }; + } + + // Try new parser + const result = parseNew(keys); + + if (result.status === "complete") { + return result; + } + + // If new parser fails, try old parser as fallback + const oldCmd = parseOld(keys); + if (!oldCmd) return result; // Return new parser's error + + // Convert old command to new format + // For now, just return the new parser result + return result; +} + +/** + * Execute command with compatibility fallback + * + * Tries new executor first, falls back to old executor if needed. + */ +export function executeVimCommandCompat(keys: string[]): boolean { + if (!USE_NEW_PARSER) { + // Use old executor + return executeOld(keys); + } + + // Try new parser + const result = parseNew(keys); + + if (result.status === "complete") { + // Use new executor + return executeAST(result.command); + } + + // Parser didn't complete - try old system + return executeOld(keys); +} + +/** + * Get command parse status with compatibility + * + * Returns: "complete" | "incomplete" | "invalid" | "needsChar" + */ +export function getCommandParseStatusCompat( + keys: string[], +): "complete" | "incomplete" | "invalid" | "needsChar" { + if (!USE_NEW_PARSER) { + return getOldParseStatus(keys); + } + + 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/stores/vim/grammar/executor.ts b/src/stores/vim/grammar/executor.ts new file mode 100644 index 00000000..c336a87b --- /dev/null +++ b/src/stores/vim/grammar/executor.ts @@ -0,0 +1,453 @@ +/** + * AST-based Vim command executor + * + * Executes parsed AST commands by coordinating with operators, motions, + * text objects, actions, and the register system. + */ + +import { useBufferStore } from "@/stores/buffer-store"; +import { useEditorCursorStore } from "@/stores/editor-cursor-store"; +import { useEditorSettingsStore } from "@/stores/editor-settings-store"; +import { useEditorViewStore } from "@/stores/editor-view-store"; +import { createVimEditing } from "@/stores/vim-editing"; +import { useVimStore } from "@/stores/vim-store"; +import { calculateOffsetFromPosition } from "@/utils/editor-position"; +import { getAction } from "../actions"; +import { getMotion } from "../core/motion-registry"; +import { getTextObject } from "../core/text-objects"; +import type { EditorContext, VimRange } from "../core/types"; +import { getOperator } from "../operators"; +import type { Command, Motion } from "./ast"; +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); + } + + 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); + + for (let i = 0; i < count; i++) { + if (action.which === "p") { + pastePutAction(context, register.content, register.type); + } else { + pastePutBeforeAction(context, 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(); + + for (let i = 0; i < count; i++) { + if (action.operation === "deleteChar") { + editing.deleteChar(); + } else { + editing.deleteCharBefore(); + } + } + + // Store deleted content in register + // (deleteChar and deleteCharBefore already handle this) + + // 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 lastOp = vimStore.actions.getLastOperation(); + + if (lastOp?.keys) { + // Re-execute the last operation (not implemented yet - needs recursion guard) + // For now, return true to avoid error + console.warn("Dot repeat not fully implemented in new executor yet"); + return true; + } + + return false; + } + + // 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 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, + }); + + // Apply forced kind if specified + if (forcedKind) { + range.linewise = forcedKind === "line"; + // TODO: Handle blockwise + } + + 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; + } +} + +/** + * Paste action (p) + */ +function pastePutAction(_context: EditorContext, content: string, type: "line" | "char"): void { + const editing = createVimEditing(); + + // Temporarily set the clipboard to the register content + const vimStore = useVimStore.getState(); + const oldClipboard = vimStore.register; + + vimStore.actions.setRegister(content, type === "line"); + editing.paste(); + + // Restore old clipboard + vimStore.actions.setRegister(oldClipboard.text, oldClipboard.isLineWise); +} + +/** + * Paste before action (P) + */ +function pastePutBeforeAction( + _context: EditorContext, + content: string, + type: "line" | "char", +): void { + const editing = createVimEditing(); + + // Temporarily set the clipboard to the register content + const vimStore = useVimStore.getState(); + const oldClipboard = vimStore.register; + + vimStore.actions.setRegister(content, type === "line"); + editing.pasteAbove(); + + // Restore old clipboard + vimStore.actions.setRegister(oldClipboard.text, oldClipboard.isLineWise); +} + +/** + * Store command for dot repeat + */ +function storeForRepeat(cmd: Command): void { + // For now, just log - full implementation requires storing AST + // TODO: Store AST command for proper repeat + console.log("Storing for repeat (not fully implemented):", cmd); +} + +/** + * Get the current editor context + */ +function getEditorContext(): EditorContext | null { + const cursorState = useEditorCursorStore.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/stores/vim/grammar/motion-kind.ts b/src/stores/vim/grammar/motion-kind.ts new file mode 100644 index 00000000..1b1a1e5f --- /dev/null +++ b/src/stores/vim/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/stores/vim/grammar/normalize.ts b/src/stores/vim/grammar/normalize.ts new file mode 100644 index 00000000..4e03e6cf --- /dev/null +++ b/src/stores/vim/grammar/normalize.ts @@ -0,0 +1,180 @@ +/** + * 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: "_" }, + }, + }; + } + + return cmd; +} + +/** + * Calculate effective count for a command + * + * For actions: 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 + */ +export function effectiveCount(cmd: Command): number { + if (cmd.kind === "action") { + return cmd.count ?? 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 (") + */ +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; + } + + // 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; + } + + // All operators are repeatable + return true; +} diff --git a/src/stores/vim/grammar/parser.ts b/src/stores/vim/grammar/parser.ts new file mode 100644 index 00000000..40b2186f --- /dev/null +++ b/src/stores/vim/grammar/parser.ts @@ -0,0 +1,468 @@ +/** + * 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. + */ +export function parse(keys: string[]): 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 }; + } + + if (actionMatch.kind === "partial") { + return { status: "incomplete" }; + } + + // 4) Try OPERATOR + const opMatch = operators.match(keys, i); + if (opMatch.kind === "complete") { + st.operator = opMatch.tok.key; + i += opMatch.len; + + // 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 }; + } + + if (opMatch.kind === "partial") { + return { status: "incomplete" }; + } + + // If we get here, no valid command was found + 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/stores/vim/grammar/tokens.ts b/src/stores/vim/grammar/tokens.ts new file mode 100644 index 00000000..822ada1b --- /dev/null +++ b/src/stores/vim/grammar/tokens.ts @@ -0,0 +1,236 @@ +/** + * 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 + "]", // square brackets + "}", // curly braces + ">", // angle brackets + '"', // 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/stores/vim/grammar/trie.ts b/src/stores/vim/grammar/trie.ts new file mode 100644 index 00000000..acc8995b --- /dev/null +++ b/src/stores/vim/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/stores/vim/index.ts b/src/stores/vim/index.ts index 0aeee76a..cceb3fa3 100644 --- a/src/stores/vim/index.ts +++ b/src/stores/vim/index.ts @@ -139,11 +139,71 @@ export type { VimCommand, VimRange, } from "./core/types"; +export type { + Action as GrammarAction, + Command, + Count, + ModeChangeAction, + Motion as GrammarMotion, + OperatorKey, + ParseIncomplete, + ParseInvalid, + ParseNeedsChar, + ParseOk, + ParseResult, + RegisterRef, + SingleCharOperation, + Target, +} from "./grammar/ast"; +export { + executeVimCommandCompat, + expectsMoreKeys as expectsMoreKeysCompat, + getCommandParseStatusCompat, + isCommandComplete as isCommandCompleteCompat, + isCommandInvalid, + isNewParserEnabled, + parseVimCommandCompat, + setUseNewParser, +} from "./grammar/compat"; +export { executeAST } from "./grammar/executor"; +export { + getMotionInfo, + isMotionInclusive, + type MotionInclusivity, + type MotionInfo, + type MotionKind, + resolveMotionKind, +} from "./grammar/motion-kind"; +export { + effectiveCount, + getRegisterName, + isRepeatable, + normalize, +} from "./grammar/normalize"; +// New Grammar-Based Parser System +export { + getCommandParseStatus as getGrammarParseStatus, + parse, +} from "./grammar/parser"; +export { + actions as actionTokens, + expectsCharArg, + forcedKinds as forcedKindTokens, + isTextObjectKey, + motions as motionTokens, + operators as operatorTokens, + supportsDoubling, +} from "./grammar/tokens"; +export { + TokenTrie, + type TokKind, + type TokSpec, + type TrieMatch, +} from "./grammar/trie"; 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 From 20f406cad08722222dcfefc50364dff700b51b60 Mon Sep 17 00:00:00 2001 From: Syed Muzamil Date: Thu, 30 Oct 2025 19:55:59 +0530 Subject: [PATCH 02/10] WIP --- src/hooks/use-vim-keyboard.ts | 255 +++++++------------- src/stores/vim-editing.ts | 45 ++-- src/stores/vim-store.ts | 21 +- src/stores/vim/grammar/ast.ts | 7 +- src/stores/vim/grammar/compat.ts | 2 +- src/stores/vim/grammar/executor.ts | 146 +++++++---- src/stores/vim/grammar/normalize.ts | 17 ++ src/stores/vim/grammar/parser.ts | 20 ++ src/stores/vim/operators/delete-operator.ts | 35 ++- src/stores/vim/operators/yank-operator.ts | 18 +- 10 files changed, 328 insertions(+), 238 deletions(-) diff --git a/src/hooks/use-vim-keyboard.ts b/src/hooks/use-vim-keyboard.ts index 40a1d251..01e8e09c 100644 --- a/src/hooks/use-vim-keyboard.ts +++ b/src/hooks/use-vim-keyboard.ts @@ -4,10 +4,12 @@ import { useEditorCursorStore } from "@/stores/editor-cursor-store"; import { useEditorInstanceStore } from "@/stores/editor-instance-store"; import { useEditorViewStore } from "@/stores/editor-view-store"; import { - executeReplaceCommand, + executeAST, executeVimCommand, getCommandParseStatus, - parseVimCommand, + isNewParserEnabled, + // New grammar-based parser + parse as parseGrammar, } from "@/stores/vim"; import { createVimEditing } from "@/stores/vim-editing"; import { useVimSearchStore } from "@/stores/vim-search"; @@ -19,7 +21,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; } @@ -47,20 +49,18 @@ export const useVimKeyboard = ({ onSave, onGoToLine }: UseVimKeyboardProps) => { exitCommandMode, isCapturingInput, reset, - setLastKey, - clearLastKey, addToKeyBuffer, clearKeyBuffer, getKeyBuffer, setVisualMode, } = useVimStore.use.actions(); - const { setCursorVisibility, setCursorPosition } = useEditorCursorStore.use.actions(); + const { setCursorVisibility } = useEditorCursorStore.use.actions(); const { setDisabled } = useEditorInstanceStore.use.actions(); const { startSearch, findNext, findPrevious } = useVimSearchStore.use.actions(); // Helper functions for accessing editor state - const getCursorPosition = () => useEditorCursorStore.getState().cursorPosition; - const getLines = () => useEditorViewStore.getState().lines; + const _getCursorPosition = () => useEditorCursorStore.getState().cursorPosition; + const _getLines = () => useEditorViewStore.getState().lines; // Reset vim state when vim mode is enabled/disabled useEffect(() => { @@ -137,10 +137,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 = useEditorCursorStore.getState().cursorPosition; + textarea.selectionStart = textarea.selectionEnd = cursor.offset; + textarea.style.caretColor = "transparent"; } } }, [vimMode, mode, isCommandMode, setCursorVisibility, setDisabled]); @@ -210,104 +213,33 @@ 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(); @@ -367,69 +299,6 @@ 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; - } - 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; } // Handle arrow keys by mapping them to vim motions @@ -454,19 +323,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") { @@ -498,6 +408,27 @@ 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 } = useEditorCursorStore.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; } diff --git a/src/stores/vim-editing.ts b/src/stores/vim-editing.ts index 370a5ead..f97b7acc 100644 --- a/src/stores/vim-editing.ts +++ b/src/stores/vim-editing.ts @@ -15,8 +15,8 @@ const redoStack: string[] = []; 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; @@ -122,18 +122,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 @@ -149,13 +153,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; @@ -172,8 +176,12 @@ 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 || pasteType !== "line") return; saveUndoState(); @@ -182,7 +190,7 @@ export const createVimEditing = (): VimEditingCommands => { // Paste as new line above current line const newLines = [...lines]; - newLines.splice(currentPos.line, 0, vimClipboard.content.replace(/\n$/, "")); + newLines.splice(currentPos.line, 0, pasteContent.replace(/\n$/, "")); const newContent = newLines.join("\n"); // Move cursor to beginning of pasted line @@ -254,8 +262,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); } }, @@ -299,7 +315,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/stores/vim-store.ts b/src/stores/vim-store.ts index 38696505..f12f24ca 100644 --- a/src/stores/vim-store.ts +++ b/src/stores/vim-store.ts @@ -38,7 +38,8 @@ interface VimState { 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 = { @@ -70,6 +71,7 @@ const defaultVimState: VimState = { }, activeRegister: null, lastOperation: null, + lastRepeatableCommand: null, }; const useVimStoreBase = create( @@ -239,6 +241,23 @@ const useVimStoreBase = create( }); }, + // 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") => { set((state) => { diff --git a/src/stores/vim/grammar/ast.ts b/src/stores/vim/grammar/ast.ts index 50a94dba..20bbe314 100644 --- a/src/stores/vim/grammar/ast.ts +++ b/src/stores/vim/grammar/ast.ts @@ -2,7 +2,7 @@ * AST (Abstract Syntax Tree) types for Vim grammar * * Grammar (EBNF): - * Command := [Register] [Count] ( Action | OperatorInvocation ) + * Command := [Register] [Count] ( Action | OperatorInvocation | Motion ) * Register := '"' RegisterName * Count := DigitNonZero { Digit } * Action := PutAction | CharAction | MiscAction | ModeChangeAction @@ -41,6 +41,11 @@ export type Command = doubled?: boolean; // true for dd, yy, cc, >>, etc. target?: Target; // absent => incomplete command countAfter?: Count; + } + | { + kind: "motion"; + count?: Count; + motion: Motion; }; /** diff --git a/src/stores/vim/grammar/compat.ts b/src/stores/vim/grammar/compat.ts index afbe397b..c4319bb1 100644 --- a/src/stores/vim/grammar/compat.ts +++ b/src/stores/vim/grammar/compat.ts @@ -20,7 +20,7 @@ import { getCommandParseStatus as getNewParseStatus, parse as parseNew } from ". * Set to true to use new parser, false to use old parser. * Can be toggled at runtime for testing. */ -let USE_NEW_PARSER = true; +let USE_NEW_PARSER = true; // Default: enabled for production use /** * Enable or disable the new parser diff --git a/src/stores/vim/grammar/executor.ts b/src/stores/vim/grammar/executor.ts index c336a87b..c6f79600 100644 --- a/src/stores/vim/grammar/executor.ts +++ b/src/stores/vim/grammar/executor.ts @@ -18,6 +18,7 @@ import { getTextObject } from "../core/text-objects"; import type { EditorContext, VimRange } from "../core/types"; import { getOperator } from "../operators"; import type { Command, Motion } from "./ast"; +import { getMotionInfo } from "./motion-kind"; import { effectiveCount, getRegisterName, isRepeatable, normalize } from "./normalize"; /** @@ -48,6 +49,11 @@ export function executeAST(cmd: Command): boolean { return executeOperator(normalized, context, count, registerName); } + // Handle MOTION commands (standalone cursor movement) + if (normalized.kind === "motion") { + return executeMotion(normalized, context, count); + } + return false; } catch (error) { console.error("Error executing vim command:", error); @@ -71,11 +77,18 @@ function executeAction( 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") { - pastePutAction(context, register.content, register.type); + editing.paste(register.content, register.type); } else { - pastePutBeforeAction(context, register.content, register.type); + editing.pasteAbove(register.content, register.type); } } @@ -111,18 +124,32 @@ function executeAction( // Single char operations (x, X) if (action.type === "singleChar") { const editing = createVimEditing(); - const _vimStore = useVimStore.getState(); + const vimStore = useVimStore.getState(); + + // Collect all deleted characters + let deletedContent = ""; + const currentContent = context.content; + let currentOffset = context.cursor.offset; for (let i = 0; i < count; i++) { if (action.operation === "deleteChar") { + if (currentOffset < currentContent.length) { + deletedContent += currentContent[currentOffset]; + } editing.deleteChar(); } else { + if (currentOffset > 0) { + deletedContent = currentContent[currentOffset - 1] + deletedContent; + currentOffset--; + } editing.deleteCharBefore(); } } // Store deleted content in register - // (deleteChar and deleteCharBefore already handle this) + if (deletedContent) { + vimStore.actions.setRegisterContent(registerName, deletedContent, "char"); + } // Store for repeat if (isRepeatable(cmd)) { @@ -153,16 +180,16 @@ function executeAction( // Repeat (dot command) if (action.type === "repeat") { const vimStore = useVimStore.getState(); - const lastOp = vimStore.actions.getLastOperation(); + const lastCmd = vimStore.actions.getLastRepeatableCommand(); - if (lastOp?.keys) { - // Re-execute the last operation (not implemented yet - needs recursion guard) - // For now, return true to avoid error - console.warn("Dot repeat not fully implemented in new executor yet"); - return true; + if (!lastCmd) { + console.warn("No command to repeat"); + return false; } - 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 @@ -185,6 +212,44 @@ function executeAction( 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 + context.setCursorPosition({ + line: range.end.line, + column: range.end.column, + offset: calculateOffsetFromPosition(range.end.line, range.end.column, context.lines), + }); + + return true; +} + /** * Execute an operator command */ @@ -262,10 +327,20 @@ function calculateMotionRange( explicitCount: count > 1, }); - // Apply forced kind if specified + // 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; @@ -359,51 +434,16 @@ function executeModeChange(mode: string, context: EditorContext, _count: number) } } -/** - * Paste action (p) - */ -function pastePutAction(_context: EditorContext, content: string, type: "line" | "char"): void { - const editing = createVimEditing(); - - // Temporarily set the clipboard to the register content - const vimStore = useVimStore.getState(); - const oldClipboard = vimStore.register; - - vimStore.actions.setRegister(content, type === "line"); - editing.paste(); - - // Restore old clipboard - vimStore.actions.setRegister(oldClipboard.text, oldClipboard.isLineWise); -} - -/** - * Paste before action (P) - */ -function pastePutBeforeAction( - _context: EditorContext, - content: string, - type: "line" | "char", -): void { - const editing = createVimEditing(); - - // Temporarily set the clipboard to the register content - const vimStore = useVimStore.getState(); - const oldClipboard = vimStore.register; - - vimStore.actions.setRegister(content, type === "line"); - editing.pasteAbove(); - - // Restore old clipboard - vimStore.actions.setRegister(oldClipboard.text, oldClipboard.isLineWise); -} - /** * Store command for dot repeat */ function storeForRepeat(cmd: Command): void { - // For now, just log - full implementation requires storing AST - // TODO: Store AST command for proper repeat - console.log("Storing for repeat (not fully implemented):", cmd); + 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); } /** diff --git a/src/stores/vim/grammar/normalize.ts b/src/stores/vim/grammar/normalize.ts index 4e03e6cf..4def4546 100644 --- a/src/stores/vim/grammar/normalize.ts +++ b/src/stores/vim/grammar/normalize.ts @@ -107,6 +107,7 @@ export function normalize(cmd: Command): Command { * 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: @@ -114,12 +115,17 @@ export function normalize(cmd: Command): Command { * - 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; + } + // For operators const a = cmd.countBefore ?? 1; const b = cmd.countAfter ?? 1; @@ -131,6 +137,7 @@ export function effectiveCount(cmd: Command): number { * 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) { @@ -141,6 +148,11 @@ export function getRegisterName(cmd: Command): string { return cmd.reg.name; } + // Motion commands don't use registers + if (cmd.kind === "motion") { + return '"'; + } + // Default to unnamed register return '"'; } @@ -175,6 +187,11 @@ export function isRepeatable(cmd: Command): boolean { return false; } + // Motions are not repeatable (they don't modify text) + if (cmd.kind === "motion") { + return false; + } + // All operators are repeatable return true; } diff --git a/src/stores/vim/grammar/parser.ts b/src/stores/vim/grammar/parser.ts index 40b2186f..2f40a9b6 100644 --- a/src/stores/vim/grammar/parser.ts +++ b/src/stores/vim/grammar/parser.ts @@ -267,6 +267,26 @@ export function parse(keys: string[]): ParseResult { return { status: "incomplete" }; } + // 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, no valid command was found return { status: "invalid", reason: "Unknown command prefix" }; } diff --git a/src/stores/vim/operators/delete-operator.ts b/src/stores/vim/operators/delete-operator.ts index a6239e2e..1ad44593 100644 --- a/src/stores/vim/operators/delete-operator.ts +++ b/src/stores/vim/operators/delete-operator.ts @@ -2,6 +2,7 @@ * Delete operator (d) */ +import { useVimStore } from "@/stores/vim-store"; import { calculateOffsetFromPosition } from "@/utils/editor-position"; import type { EditorContext, Operator, VimRange } from "../core/types"; import { setVimClipboard } from "./yank-operator"; @@ -30,6 +31,11 @@ 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"); + const newLines = lines.filter((_, index) => { return index < startLine || index > endLine; }); @@ -43,11 +49,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 +80,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"); + const newContent = content.slice(0, startOffset) + content.slice(actualEndOffset); updateContent(newContent); @@ -86,10 +105,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/stores/vim/operators/yank-operator.ts b/src/stores/vim/operators/yank-operator.ts index 22a293ea..a6bb437d 100644 --- a/src/stores/vim/operators/yank-operator.ts +++ b/src/stores/vim/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"); + 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"); }, }; From 4b7653977134a74cd7a45e7cf67ccf9f04fe8e37 Mon Sep 17 00:00:00 2001 From: Mehmet Ozgul <91568457+mehmetozguldev@users.noreply.github.com> Date: Thu, 30 Oct 2025 15:47:12 +0300 Subject: [PATCH 03/10] Cleanup leftover files --- docs/RELEASING.md | 12 ++++++------ test-sample.db | Bin 28672 -> 0 bytes test.sqlite | Bin 8192 -> 0 bytes 3 files changed, 6 insertions(+), 6 deletions(-) delete mode 100644 test-sample.db delete mode 100644 test.sqlite diff --git a/docs/RELEASING.md b/docs/RELEASING.md index b960f877..d1532690 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -21,11 +21,11 @@ bun release -- 2.0.0 ``` That's it! The script will: -1. ✅ Update version in `package.json` and `src-tauri/tauri.conf.json` -2. ✅ Show you the commits since the last release -3. ✅ Create a commit with the version changes -4. ✅ Create and push a git tag -5. ✅ Trigger GitHub Actions to build and release +1. Update version in `package.json` and `src-tauri/tauri.conf.json` +2. Show you the commits since the last release +3. Create a commit with the version changes +4. Create and push a git tag +5. Trigger GitHub Actions to build and release ## What Happens After @@ -157,7 +157,7 @@ Before releasing: - [ ] All new features are tested - [ ] Documentation is updated -- [ ] CHANGELOG reflects changes (optional) +- [ ] CHANGELOG reflects changes - [ ] No critical bugs in the current build - [ ] All tests pass locally - [ ] Working directory is clean diff --git a/test-sample.db b/test-sample.db deleted file mode 100644 index f81c4b4d335d3badf40bc7aa95db40621d174b87..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28672 zcmeI)&2QsG7zbcGj@_mrH4-ejth6H#qEWY>QCV1ov}xmZ18LHxiMG`qqDee$433?( z-H+wQB5?rmH*n+5feW`i!;KRc5C;wj4jei1&ZOyPn~0CYYImidCA%}uyzh7(Cx^J1 z+1ssniC~9e)Z+;&XfJBI{=zNBG)=Q;pQ3%ZrKuqI;ieA@|M1t+7Qgr_YyY8H*|cUG z_QTn)vtMQRse%mx5P$##AOHafKmY;|fWT7_s9RQUDW~_mz!hhnd)DfQaT2$VV-dAH zS5>Aq>Q1rYuyVEJ?6a{ptX5^J-Q0OlzG7Or`J65;a>^ItmgsTMS4C?q<46mcPgUns zK>opWdMcO8>7RrNZ~H=Zr4HtKmPT>C;tVc<&CM{nLb}w3v*py;PQAQctRJxVodZ^E zHfrT6#cVs(#&v2ZJE1WS8_QFPm-vD;oc#u?)@W~5D)MwE3=$C}>IGTvAGduk?h2Q! z*J>4~SY;(=v)HUO*s^LK37!bIMPDjas^N87Mba1*^E$=oRDZr1!=soECSG}2hK4sQ# z7FKVhE$#Isy-?yO^lapg=f`2-%JOL7!i2y#4NH5C0ylUR`b>t$9a$WWdu2l0YHDzi z>tUPi^}M9p4%?$KuTF@0OSiN)sIQ{$bp(4a>;`m|TvkRSuTF>@y@@kaG40Pa`)B(* z`y2a9Dq@2G1Rwwb2tWV=5P$##AOHafK;X#vl3<+nIHYO$37wIv-m$ij-Lg}Pa34iX?%-}}O3SPrWeP?2 zgwuKhF5BC!u-(CE%K=NeUaS^jpmgli^L-}H`hG}#(lBVg!5RC2=HbTBwF>V>l-xkdoHFqdQT!w&cVeHf!}V*LJpR0CxQKmY;| zfB*y_009U<00Izzz!MjED!>1i-~TQ92hIM|{@wnSRsr~tD%c diff --git a/test.sqlite b/test.sqlite deleted file mode 100644 index 9bd697ae288e4e567d8bb29a608b0e7c5d80fd8a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8192 zcmeI#u?oU45C-5&m4bs*km7n|QVR`MNl)iR!p&I6*P;_<{SA09c{F`baVKR zTrN2d0^gSHI?1i!?0PFw%Twx+5Hw>ZBCpP>d_q)vyFR<(?!UsLN%rbiQzCd$wGa@1 z00bZa0SG_<0uX=z1Rwwb2s9Ijg+B;GakA Date: Sat, 1 Nov 2025 00:59:25 +0900 Subject: [PATCH 04/10] Fix git status rows for nested paths and stage directories correctly (#379) --- src-tauri/src/commands/git/staging.rs | 35 ++++++++++++++++--- .../git/views/git-status-panel.tsx | 16 +++++++-- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/commands/git/staging.rs b/src-tauri/src/commands/git/staging.rs index bb0bb208..01669d98 100644 --- a/src-tauri/src/commands/git/staging.rs +++ b/src-tauri/src/commands/git/staging.rs @@ -1,6 +1,6 @@ use crate::commands::git::IntoStringError; use anyhow::{Context, Result}; -use git2::Repository; +use git2::{ErrorCode, Repository}; use std::path::Path; use tauri::command; @@ -13,9 +13,36 @@ fn _git_add(repo_path: String, file_path: String) -> Result<()> { let repo = Repository::open(&repo_path).context("Failed to open repository")?; let mut index = repo.index().context("Failed to get index")?; - index - .add_path(Path::new(&file_path)) - .context("Failed to add file")?; + let relative_path = Path::new(&file_path); + let absolute_path = Path::new(&repo_path).join(relative_path); + + if absolute_path.is_dir() { + index + .add_all( + [file_path.as_str()].iter(), + git2::IndexAddOption::DEFAULT, + None, + ) + .with_context(|| format!("Failed to add directory {}", file_path))?; + } else { + match index.add_path(relative_path) { + Ok(_) => {} + Err(err) => { + if err.code() == ErrorCode::NotFound { + index + .add_all( + [file_path.as_str()].iter(), + git2::IndexAddOption::DEFAULT, + None, + ) + .with_context(|| format!("Failed to add path {}", file_path))?; + } else { + return Err(err).with_context(|| format!("Failed to add file {}", file_path)); + } + } + } + } + index.write().context("Failed to write index")?; Ok(()) diff --git a/src/version-control/git/views/git-status-panel.tsx b/src/version-control/git/views/git-status-panel.tsx index 59125d54..0654c819 100644 --- a/src/version-control/git/views/git-status-panel.tsx +++ b/src/version-control/git/views/git-status-panel.tsx @@ -86,6 +86,18 @@ const GitStatusPanel = ({ } }; + const getFileName = (path: string) => { + if (!path) return "(unknown)"; + + const segments = path.split(/[\\/]/).filter(Boolean); + if (segments.length === 0) { + const trimmed = path.trim(); + return trimmed.length > 0 ? trimmed : "(unknown)"; + } + + return segments[segments.length - 1]; + }; + const handleStageFile = async (filePath: string) => { if (!repoPath) return; setIsLoading(true); @@ -202,7 +214,7 @@ const GitStatusPanel = ({ {getFileIcon(file)} - {file.path.split("/").pop()} + {getFileName(file.path)}