From 16d92d116add90445bbf06dc1173beb5bf43b827 Mon Sep 17 00:00:00 2001 From: shubhook Date: Thu, 15 Oct 2026 15:32:49 +0530 Subject: [PATCH] editor: add T-106 spec, command and keymap interface stubs and acceptance tests Adds tasks/106-commands-keymap.md and the headless key, command registry, keymap and dispatcher interface with todo!() bodies. Keymaps are data: modes plus bindings scoped by mode and context. Each editor keeps its own KeyState, and a keymap switch reaches every editor on its next key through a generation check. 63 acceptance tests stay ignored until the implementation lands. Refs: T-106, ED-4, ED-6 --- crates/helm-editor/src/command.rs | 237 ++++++ crates/helm-editor/src/dispatch.rs | 212 +++++ crates/helm-editor/src/editor_commands.rs | 62 ++ crates/helm-editor/src/key.rs | 295 +++++++ crates/helm-editor/src/keymap.rs | 234 ++++++ crates/helm-editor/src/lib.rs | 41 + crates/helm-editor/tests/commands.rs | 305 +++++++ crates/helm-editor/tests/dispatch.rs | 325 ++++++++ crates/helm-editor/tests/keymap_properties.rs | 289 +++++++ crates/helm-editor/tests/keymaps.rs | 772 ++++++++++++++++++ crates/helm-editor/tests/keys.rs | 280 +++++++ tasks/106-commands-keymap.md | 246 ++++++ 12 files changed, 3298 insertions(+) create mode 100644 crates/helm-editor/src/command.rs create mode 100644 crates/helm-editor/src/dispatch.rs create mode 100644 crates/helm-editor/src/editor_commands.rs create mode 100644 crates/helm-editor/src/key.rs create mode 100644 crates/helm-editor/src/keymap.rs create mode 100644 crates/helm-editor/tests/commands.rs create mode 100644 crates/helm-editor/tests/dispatch.rs create mode 100644 crates/helm-editor/tests/keymap_properties.rs create mode 100644 crates/helm-editor/tests/keymaps.rs create mode 100644 crates/helm-editor/tests/keys.rs create mode 100644 tasks/106-commands-keymap.md diff --git a/crates/helm-editor/src/command.rs b/crates/helm-editor/src/command.rs new file mode 100644 index 0000000..4d31e91 --- /dev/null +++ b/crates/helm-editor/src/command.rs @@ -0,0 +1,237 @@ +//! The command registry: every command has a stable string id, and runs +//! against one [`Editor`] and the [`BufferSet`] that owns its buffer. + +#![expect( + unused_variables, + dead_code, + reason = "T-106 interface stubs; the implementation deletes this" +)] + +use std::collections::BTreeMap; +use std::fmt; +use std::sync::Arc; + +use helm_text::BufferSet; + +use crate::{Editor, EditorError}; + +/// A command's id, such as `"editor.delete_backward"`. Keymaps, the +/// keybindings file and the command palette name commands by id, so an id +/// never changes once a command ships. +/// +/// Any string converts to an id. [`CommandRegistry::register`] checks the +/// form: one or more segments separated by `.`, each segment one or more +/// of `a` to `z`, `0` to `9` and `_`. The first segment names the crate +/// area that owns the command: `editor.` for [`Editor`] commands. +/// +/// Clones are cheap: the string is shared. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct CommandId(Arc); + +impl CommandId { + /// The id as text. + pub fn as_str(&self) -> &str { + todo!() + } +} + +impl From<&str> for CommandId { + fn from(id: &str) -> Self { + todo!() + } +} + +impl From for CommandId { + fn from(id: String) -> Self { + todo!() + } +} + +impl fmt::Display for CommandId { + /// Writes the id as it is. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + todo!() + } +} + +/// What a command gets besides the editor: the text to type, or the motion +/// a pending operator runs over. A command ignores what it doesn't use. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +pub struct CommandArgs { + /// The text [`editor_commands::INSERT`](crate::editor_commands::INSERT) + /// types. A key no binding matches arrives here (see + /// [`Dispatcher::resolve`](crate::Dispatcher::resolve)). + pub text: Option, + /// For an operator, the motion it applies to: the command a key bound + /// after the operator named (see [`Action::Operator`](crate::Action)). + /// What an operator does with it is up to the operator (T-108). + pub motion: Option, +} + +/// One command to run, with its arguments. +/// [`Dispatcher::resolve`](crate::Dispatcher::resolve) returns these, and +/// [`CommandRegistry::run`] runs them. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Invocation { + /// The command to run. + pub command: CommandId, + /// Its arguments. + pub args: CommandArgs, +} + +impl Invocation { + /// `command` with no arguments. + pub fn new(command: impl Into) -> Self { + todo!() + } + + /// The insert command, + /// [`editor_commands::INSERT`](crate::editor_commands::INSERT), typing + /// `text`. + pub fn insert(text: impl Into) -> Self { + todo!() + } +} + +/// What a command runs against. +pub struct CommandContext<'a> { + /// The editor the key went to. + pub editor: &'a mut Editor, + /// The set that owns the editor's buffer. + pub set: &'a mut BufferSet, + /// The invocation's arguments. + pub args: &'a CommandArgs, +} + +/// The function a command runs. Plain function pointers keep commands +/// `Send`, `Sync` and cheap to copy, and hold no state of their own. +pub type CommandFn = fn(CommandContext<'_>) -> Result<(), CommandError>; + +/// A command: its id, the title the command palette shows (ST-3), and the +/// function that runs it. +#[derive(Clone, Debug)] +pub struct Command { + /// The stable id. + pub id: CommandId, + /// What the command palette shows, in sentence case: "Delete backward". + pub title: &'static str, + /// What runs. + pub run: CommandFn, +} + +/// Every command `helm-editor` can run, by id. +/// +/// The registry doesn't hold every id a keymap may name. Commands that need +/// more than an editor and a buffer set, such as saving a file, belong to +/// `helm-ui`, which runs the ids it owns itself and hands the rest here. +#[derive(Clone, Debug, Default)] +pub struct CommandRegistry { + commands: BTreeMap, +} + +impl CommandRegistry { + /// An empty registry. + pub fn new() -> Self { + todo!() + } + + /// A registry holding one command for each [`Editor`] command, under + /// the ids in [`editor_commands`](crate::editor_commands). Each runs + /// the `Editor` method of the same name on the context's editor and + /// set. [`INSERT`](crate::editor_commands::INSERT) types + /// [`CommandArgs::text`] and errors with + /// [`CommandError::MissingText`] without it. + pub fn with_editor_commands() -> Self { + todo!() + } + + /// Adds `command`. Errors, and adds nothing, if its id isn't in the + /// form [`CommandId`] describes or is already registered. + pub fn register(&mut self, command: Command) -> Result<(), RegisterError> { + todo!() + } + + /// The command with id `id`, if registered. + pub fn get(&self, id: &str) -> Option<&Command> { + todo!() + } + + /// Every registered command, sorted by id. + pub fn commands(&self) -> impl Iterator { + self.commands.values() + } + + /// Runs `invocation`'s command on `editor` and `set`. + /// + /// Errors with [`CommandError::UnknownCommand`] if the id isn't + /// registered, and passes on the command's own error. Every editor + /// command changes nothing when it errors, as [`EditorError`] says. + pub fn run( + &self, + invocation: &Invocation, + editor: &mut Editor, + set: &mut BufferSet, + ) -> Result<(), CommandError> { + todo!() + } +} + +/// Whether `id` is in the form [`CommandId`] describes. +fn is_valid_id(id: &str) -> bool { + todo!() +} + +/// Why [`CommandRegistry::register`] refused a command. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum RegisterError { + /// The id isn't in the form [`CommandId`] describes. + InvalidId { + /// The id. + id: CommandId, + }, + /// A command with this id is already registered. + Duplicate { + /// The id. + id: CommandId, + }, +} + +impl fmt::Display for RegisterError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + todo!() + } +} + +impl std::error::Error for RegisterError {} + +/// Why a command didn't run. On an error the buffer and the editor are +/// unchanged. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum CommandError { + /// No command with this id is registered. + UnknownCommand { + /// The id. + id: CommandId, + }, + /// The command needs [`CommandArgs::text`] and got none. + MissingText { + /// The command's id. + id: CommandId, + }, + /// The editor command failed. + Editor(EditorError), +} + +impl From for CommandError { + fn from(error: EditorError) -> Self { + todo!() + } +} + +impl fmt::Display for CommandError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + todo!() + } +} + +impl std::error::Error for CommandError {} diff --git a/crates/helm-editor/src/dispatch.rs b/crates/helm-editor/src/dispatch.rs new file mode 100644 index 0000000..89c1416 --- /dev/null +++ b/crates/helm-editor/src/dispatch.rs @@ -0,0 +1,212 @@ +//! Turning key events into commands: the active keymap, each editor's key +//! state, and the rule that picks a binding. + +#![expect( + unused_variables, + dead_code, + reason = "T-106 interface stubs; the implementation deletes this" +)] + +use helm_text::BufferSet; + +use crate::{ + CommandError, CommandId, CommandRegistry, Editor, Invocation, KeyEvent, Keymap, Keystroke, + ModeId, +}; + +/// One editor's input state: its mode, the keys of a chord typed so far, +/// and a pending operator. +/// +/// Each editor has its own, so a chord half typed in one pane doesn't carry +/// over to another. A state belongs to the [`Dispatcher`] that made it with +/// [`Dispatcher::key_state`]. Handing it to another dispatcher is a logic +/// error: keys may resolve in the wrong mode, but nothing panics. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct KeyState { + mode: ModeId, + /// The keys of a chord typed so far. Empty when no chord is pending. + pending: Vec, + /// The pending operator, and the mode it was set in. + operator: Option<(CommandId, ModeId)>, + /// The dispatcher's keymap generation this state last synced to. + generation: u64, +} + +impl KeyState { + /// The current mode. + pub fn mode(&self) -> &ModeId { + todo!() + } + + /// The keys of the chord typed so far, or an empty slice if no chord is + /// pending. `helm-ui` shows these while it waits for the next key. + pub fn pending(&self) -> &[Keystroke] { + todo!() + } + + /// The pending operator, if one is held. + pub fn operator(&self) -> Option<&CommandId> { + todo!() + } + + /// Drops the pending chord and the pending operator, and returns to the + /// mode the operator was set in, if one was held. Returns whether + /// anything was pending. `helm-ui` calls this when the editor loses + /// focus or the user clicks in it. + pub fn cancel(&mut self) -> bool { + todo!() + } +} + +/// What a key event resolved to. The [`KeyState`] has already changed to +/// match when [`Dispatcher::resolve`] returns it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Resolution { + /// The key started or continued a chord, or set a pending operator. + /// Nothing runs yet. + Pending, + /// Run this command. Typed text is an invocation of + /// [`editor_commands::INSERT`](crate::editor_commands::INSERT). + Run(Invocation), + /// A binding matched that runs nothing: it only switched mode, or it + /// has no action. + Done, + /// No binding matched, and the key types nothing in this mode. + Unbound, + /// The key broke a pending chord or cancelled a pending operator. + /// Nothing runs, and the key itself is dropped. + Cancelled, +} + +/// The active keymap and the command registry: what every editor's keys +/// go through. +/// +/// # Switching keymaps (ED-6) +/// +/// The app holds one dispatcher, and every editor resolves its keys through +/// it with its own [`KeyState`]. [`set_keymap`](Self::set_keymap) replaces +/// the keymap, so every open editor uses the new one from its next key, with +/// no restart and no list of editors to update. Each state notices the +/// switch the next time it is passed to [`resolve`](Self::resolve) or +/// [`sync`](Self::sync), and then: +/// +/// - it cancels as [`KeyState::cancel`] does: it drops its pending chord +/// and pending operator, running nothing, and goes back to the mode the +/// operator was set in, if one was held; +/// - then it keeps its mode if the new keymap has a mode with the same id, +/// and otherwise goes to the new keymap's initial mode. +/// +/// The key that arrives after a switch is resolved in the new keymap as if +/// nothing had been pending. Reloading the same keymap after an edit to the +/// keybindings file (ST-1) is a switch too, so a Vim user in insert mode +/// stays in insert mode. +#[derive(Clone, Debug)] +pub struct Dispatcher { + registry: CommandRegistry, + keymap: Keymap, + /// Goes up by one on every `set_keymap`. + generation: u64, +} + +impl Dispatcher { + /// A dispatcher running commands from `registry`, with `keymap` active. + pub fn new(registry: CommandRegistry, keymap: Keymap) -> Self { + todo!() + } + + /// The command registry. + pub fn registry(&self) -> &CommandRegistry { + todo!() + } + + /// The active keymap. + pub fn keymap(&self) -> &Keymap { + todo!() + } + + /// Makes `keymap` the active keymap and returns the old one. See the + /// type docs for what happens to each editor's state. + pub fn set_keymap(&mut self, keymap: Keymap) -> Keymap { + todo!() + } + + /// A new state for one editor: in the active keymap's initial mode, + /// with nothing pending. + pub fn key_state(&self) -> KeyState { + todo!() + } + + /// Brings `state` up to date with the active keymap, as the type docs + /// describe, if the keymap changed since `state` last saw it. `helm-ui` + /// calls this on every open editor after a switch, so a status bar + /// shows the right mode before the next key. + pub fn sync(&self, state: &mut KeyState) { + todo!() + } + + /// Resolves one key event against the active keymap, updates `state`, + /// and says what to run. `contexts` are the context names that hold + /// right now. + /// + /// First `state` syncs, as [`sync`](Self::sync) does. Then, with `M` the + /// state's mode and `S` the pending keys followed by the event's + /// keystroke, a binding *applies* when its mode is `None` or `M` and + /// its context is `None` or one of `contexts`. The first rule that fits + /// decides: + /// + /// 1. **Chord prefix.** If `S` is a strict prefix of an applying + /// binding's keys, `S` becomes the pending chord: `Pending`. This + /// holds even when another applying binding's keys equal `S`. A + /// chord waits for its next key with no timeout. + /// 2. **Match.** If applying bindings' keys equal `S`, one wins: the + /// one with a mode over one without, then the one with a context + /// over one without, then the one added later. The pending chord + /// clears, and the winner acts: + /// - [`Action::Run`](crate::Action::Run) with no operator pending: + /// `Run` of the command. + /// - `Action::Run` with an operator pending: `Run` of the operator, + /// with the binding's command as its + /// [`motion`](crate::CommandArgs::motion). The operator clears and + /// the mode goes back to the one the operator was set in. + /// - [`Action::Operator`](crate::Action::Operator) with no operator + /// pending: the command becomes the pending operator, set in mode + /// `M`: `Pending`. + /// - `Action::Operator` with an operator pending: the pending one is + /// cancelled as in rule 3: `Cancelled`. The binding's `then_mode` + /// doesn't apply. + /// - No action: a pending operator is cancelled, without returning + /// `Cancelled`: `Done`. + /// + /// Then, except where noted, the binding's `then_mode`, if any, + /// becomes the mode, after any return to the operator's mode. + /// 3. **Broken chord or operator.** If a chord or an operator is + /// pending, both clear, the mode goes back to the one the operator + /// was set in, if one was held, and the key is dropped: + /// `Cancelled`. So Escape cancels anything pending unless a binding + /// says otherwise. + /// 4. **Typing.** If mode `M`'s [`Typing`](crate::Typing) is `Insert`, + /// the event has non-empty [`text`](KeyEvent::text), and neither + /// Ctrl nor Cmd is held: `Run` of [`Invocation::insert`] with the + /// text. Typing takes the same path as a bound command. + /// 5. Otherwise `Unbound`. + /// + /// Mode changes happen here, before the caller runs the returned + /// command, and stand even if the command then fails. + pub fn resolve(&self, state: &mut KeyState, event: &KeyEvent, contexts: &[&str]) -> Resolution { + todo!() + } + + /// [`resolve`](Self::resolve), then, for [`Resolution::Run`], runs the + /// invocation with [`CommandRegistry::run`] on `editor` and `set`. + /// Returns the resolution, or the command's error. + pub fn dispatch( + &self, + state: &mut KeyState, + event: &KeyEvent, + contexts: &[&str], + editor: &mut Editor, + set: &mut BufferSet, + ) -> Result { + todo!() + } +} diff --git a/crates/helm-editor/src/editor_commands.rs b/crates/helm-editor/src/editor_commands.rs new file mode 100644 index 0000000..f7a109a --- /dev/null +++ b/crates/helm-editor/src/editor_commands.rs @@ -0,0 +1,62 @@ +//! The ids [`CommandRegistry::with_editor_commands`] registers, one per +//! [`Editor`](crate::Editor) command. Keymaps (T-107, T-108) bind these. +//! +//! Each runs the `Editor` method of the same name. The selection commands +//! ([`ADD_NEXT_MATCH`] and the three after it) don't edit, so they read +//! the set through the context's `&mut BufferSet` without changing it. + +#![expect( + unused_variables, + dead_code, + reason = "T-106 interface stubs; the implementation deletes this" +)] + +use crate::{CommandContext, CommandError, CommandRegistry}; + +/// [`Editor::insert`](crate::Editor::insert) with +/// [`CommandArgs::text`](crate::CommandArgs::text). Keys no binding matches +/// type through this command. +pub const INSERT: &str = "editor.insert"; +/// [`Editor::newline`](crate::Editor::newline). +pub const NEWLINE: &str = "editor.newline"; +/// [`Editor::delete_backward`](crate::Editor::delete_backward). +pub const DELETE_BACKWARD: &str = "editor.delete_backward"; +/// [`Editor::delete_forward`](crate::Editor::delete_forward). +pub const DELETE_FORWARD: &str = "editor.delete_forward"; +/// [`Editor::add_next_match`](crate::Editor::add_next_match). +pub const ADD_NEXT_MATCH: &str = "editor.add_next_match"; +/// [`Editor::select_all_matches`](crate::Editor::select_all_matches). +pub const SELECT_ALL_MATCHES: &str = "editor.select_all_matches"; +/// [`Editor::add_cursor_above`](crate::Editor::add_cursor_above). +pub const ADD_CURSOR_ABOVE: &str = "editor.add_cursor_above"; +/// [`Editor::add_cursor_below`](crate::Editor::add_cursor_below). +pub const ADD_CURSOR_BELOW: &str = "editor.add_cursor_below"; +/// [`Editor::undo`](crate::Editor::undo). +pub const UNDO: &str = "editor.undo"; +/// [`Editor::redo`](crate::Editor::redo). +pub const REDO: &str = "editor.redo"; + +/// Every id above, sorted. +pub const ALL: [&str; 10] = [ + ADD_CURSOR_ABOVE, + ADD_CURSOR_BELOW, + ADD_NEXT_MATCH, + DELETE_BACKWARD, + DELETE_FORWARD, + INSERT, + NEWLINE, + REDO, + SELECT_ALL_MATCHES, + UNDO, +]; + +/// Registers one command for each id in [`ALL`]. +pub(crate) fn register_all(registry: &mut CommandRegistry) { + todo!() +} + +/// [`INSERT`]: errors with [`CommandError::MissingText`] when the +/// invocation has no text. +fn insert(cx: CommandContext<'_>) -> Result<(), CommandError> { + todo!() +} diff --git a/crates/helm-editor/src/key.rs b/crates/helm-editor/src/key.rs new file mode 100644 index 0000000..e47109d --- /dev/null +++ b/crates/helm-editor/src/key.rs @@ -0,0 +1,295 @@ +//! Key events with no window behind them: a key, its modifiers and the text +//! it types. `helm-ui` turns each platform key event into a [`KeyEvent`] +//! (Phase 2), so nothing below it sees a GPUI type. + +#![expect( + unused_variables, + dead_code, + reason = "T-106 interface stubs; the implementation deletes this" +)] + +use std::fmt; + +/// A key, apart from the modifiers held with it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Key { + /// A key that types a character. The character is what the key types + /// with Shift and the keyboard layout applied, but not Alt, Ctrl or + /// Cmd: Shift+A is `Char('A')`, Shift+4 on a US layout is `Char('$')`, + /// and Option+A on macOS is `Char('a')` with [`Modifiers::alt`] set. + /// The space bar is `Char(' ')`. Never a control character; `helm-ui` + /// sends the named keys below for those. + Char(char), + /// Return. + Enter, + /// Tab. + Tab, + /// Backspace, labelled "delete" on a Mac keyboard. + Backspace, + /// Forward delete, Fn+Delete on a Mac laptop. + Delete, + /// Escape. + Escape, + /// Left arrow. + Left, + /// Right arrow. + Right, + /// Up arrow. + Up, + /// Down arrow. + Down, + /// Home, Fn+Left on a Mac laptop. + Home, + /// End, Fn+Right on a Mac laptop. + End, + /// Page up. + PageUp, + /// Page down. + PageDown, + /// A function key, `F(1)` to `F(20)`. + F(u8), +} + +/// The modifier keys held during a keystroke. +/// +/// `cmd` is the Command key (⌘) on macOS. Fn is not a modifier here: macOS +/// folds it into the key (Fn+Left arrives as [`Key::Home`]). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Modifiers { + /// Control (⌃). + pub ctrl: bool, + /// Option (⌥) on macOS. + pub alt: bool, + /// Shift (⇧). + pub shift: bool, + /// Command (⌘) on macOS. + pub cmd: bool, +} + +impl Modifiers { + /// No modifiers. + pub const NONE: Self = Self { + ctrl: false, + alt: false, + shift: false, + cmd: false, + }; + /// Control alone. + pub const CTRL: Self = Self { + ctrl: true, + ..Self::NONE + }; + /// Option alone. + pub const ALT: Self = Self { + alt: true, + ..Self::NONE + }; + /// Shift alone. + pub const SHIFT: Self = Self { + shift: true, + ..Self::NONE + }; + /// Command alone. + pub const CMD: Self = Self { + cmd: true, + ..Self::NONE + }; +} + +/// One key with its modifiers, in normal form, so two keystrokes that mean +/// the same key compare equal. +/// +/// # Normal form +/// +/// Shift is folded into [`Key::Char`]: a char keystroke never has +/// [`Modifiers::shift`] set. [`new`](Self::new) turns an ASCII lowercase +/// letter with Shift into the uppercase letter, and for any other char +/// drops Shift and keeps the char, which `helm-ui` has already shifted. So +/// Shift+A, `Char('A')` and the notation `"shift-a"` and `"A"` are all one +/// keystroke. Named keys keep Shift: Shift+Tab and Shift+Left are not Tab +/// and Left. +/// +/// # Notation +/// +/// Keymaps and tests write keystrokes as text. [`parse`](Self::parse) reads +/// it and [`Display`](fmt::Display) writes it: +/// +/// - Zero or more modifiers, each followed by `-`: `ctrl-`, `alt-`, +/// `shift-` and `cmd-`, in any order, each at most once. +/// - Then the key: one char, which is a [`Key::Char`], or one of the names +/// `enter`, `tab`, `backspace`, `delete`, `escape`, `space`, `left`, +/// `right`, `up`, `down`, `home`, `end`, `pageup`, `pagedown` and `f1` to +/// `f20`. `space` is `Char(' ')`. +/// +/// Names and modifiers are lowercase. A single char is taken as it is, so +/// `"A"` is Shift+A, `"$"` is the dollar key and `"-"` is the minus key +/// (`"cmd--"` is Cmd and minus). `shift-` before a char other than an +/// ASCII letter is an error, since which char Shift makes depends on the +/// layout: write `"$"`, not `"shift-4"`. +/// +/// [`Display`](fmt::Display) writes the modifiers in the order `ctrl`, +/// `alt`, `shift`, `cmd`, then the key: the char itself, `space` for +/// `Char(' ')`, or the key's name. Parsing what it writes gives back the +/// same keystroke. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Keystroke { + key: Key, + modifiers: Modifiers, +} + +impl Keystroke { + /// `key` with `modifiers`, in normal form: Shift is folded into a + /// [`Key::Char`] as the type docs describe. + pub fn new(key: Key, modifiers: Modifiers) -> Self { + todo!() + } + + /// The key. + pub fn key(&self) -> Key { + todo!() + } + + /// The modifiers. `shift` is never set when the key is a + /// [`Key::Char`]. + pub fn modifiers(&self) -> Modifiers { + todo!() + } + + /// Reads one keystroke in the notation the type docs describe, such as + /// `"cmd-s"`, `"ctrl-shift-tab"` or `"A"`. + pub fn parse(text: &str) -> Result { + todo!() + } +} + +impl fmt::Display for Keystroke { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + todo!() + } +} + +/// The key that `text`, the part of a keystroke after its modifiers, +/// stands for in the notation: one char other than a control char, or a +/// key name. `None` for anything else. +fn parse_key(text: &str) -> Option { + todo!() +} + +/// One or more keystrokes pressed one after another. A sequence of two or +/// more is a chord, such as VS Code's `cmd-k cmd-s`. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct KeySequence { + /// Never empty. + keys: Vec, +} + +impl KeySequence { + /// The sequence `keys`, or `None` if `keys` is empty. + pub fn new(keys: Vec) -> Option { + todo!() + } + + /// Reads keystrokes separated by whitespace, each in + /// [`Keystroke`]'s notation: `"cmd-k cmd-s"`, `"g g"`. Errors with + /// [`KeyParseError::Empty`] if there is none. + pub fn parse(text: &str) -> Result { + todo!() + } + + /// The keystrokes, in the order they are pressed. + pub fn as_slice(&self) -> &[Keystroke] { + todo!() + } +} + +impl From for KeySequence { + /// A sequence of one keystroke. + fn from(keystroke: Keystroke) -> Self { + todo!() + } +} + +impl fmt::Display for KeySequence { + /// The keystrokes in [`Keystroke`]'s notation, joined by one space. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + todo!() + } +} + +/// A key press as `helm-ui` hands it to the editor: the keystroke, and the +/// text the key types, if any. +/// +/// The keystroke picks the binding. The text is what goes into the buffer +/// when no binding matches and the mode inserts typed text (see +/// [`Dispatcher::resolve`](crate::Dispatcher::resolve)). They differ for +/// Option on macOS: Option+A is the keystroke `alt-a` and types `"å"`. +/// +/// Text composed by an input method (ED-8) doesn't come through here; +/// `helm-ui` runs the insert command with it directly. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct KeyEvent { + /// The key and its modifiers. + pub keystroke: Keystroke, + /// The text the key types, or `None` for a key that types nothing. + pub text: Option, +} + +impl KeyEvent { + /// A key event with the text a plain key types: for a [`Key::Char`] + /// with none of Alt, Ctrl or Cmd held, the char itself; for anything + /// else, `None`. So `a` types `"a"`, `A` types `"A"`, `space` types + /// `" "`, and `ctrl-a`, `alt-a` and `enter` type nothing. + pub fn new(keystroke: Keystroke) -> Self { + todo!() + } + + /// A key event that types `text`, whatever the keystroke. `helm-ui` + /// uses this when the platform reports text the keystroke alone + /// doesn't give, such as `"å"` for `alt-a`. + pub fn with_text(keystroke: Keystroke, text: impl Into) -> Self { + todo!() + } + + /// [`Keystroke::parse`], then [`new`](Self::new). + pub fn parse(text: &str) -> Result { + todo!() + } +} + +/// Why [`Keystroke::parse`] or [`KeySequence::parse`] rejected some text. +/// Each case carries the keystroke's text as written. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum KeyParseError { + /// The text holds no keystroke: empty, or only whitespace. + Empty, + /// Modifiers with no key after them, like `"cmd-"`. + MissingKey { + /// The keystroke as written. + keystroke: String, + }, + /// A modifier written twice, like `"cmd-cmd-k"`. + RepeatedModifier { + /// The keystroke as written. + keystroke: String, + }, + /// The key is neither one char nor a known name, like `"cmd-foo"` or + /// `"Enter"`, or is a control char. + UnknownKey { + /// The keystroke as written. + keystroke: String, + }, + /// `shift-` before a char that isn't an ASCII letter, like + /// `"shift-4"`. Write the shifted char instead. + ShiftedChar { + /// The keystroke as written. + keystroke: String, + }, +} + +impl fmt::Display for KeyParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + todo!() + } +} + +impl std::error::Error for KeyParseError {} diff --git a/crates/helm-editor/src/keymap.rs b/crates/helm-editor/src/keymap.rs new file mode 100644 index 0000000..0d38d9f --- /dev/null +++ b/crates/helm-editor/src/keymap.rs @@ -0,0 +1,234 @@ +//! Keymaps as data: the modes a keymap has, and bindings from key sequences +//! to commands, each scoped by mode and by an optional context. + +#![expect( + unused_variables, + dead_code, + reason = "T-106 interface stubs; the implementation deletes this" +)] + +use std::fmt; +use std::sync::Arc; + +use crate::{CommandId, KeyParseError, KeySequence}; + +/// A mode's id, such as `"normal"` or `"insert"`. Unique within a keymap. +/// +/// Clones are cheap: the string is shared. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ModeId(Arc); + +impl ModeId { + /// The id as text. + pub fn as_str(&self) -> &str { + todo!() + } +} + +impl From<&str> for ModeId { + fn from(id: &str) -> Self { + todo!() + } +} + +impl From for ModeId { + fn from(id: String) -> Self { + todo!() + } +} + +impl fmt::Display for ModeId { + /// Writes the id as it is. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + todo!() + } +} + +/// What a key no binding matches does in a mode. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Typing { + /// It types its [`KeyEvent::text`](crate::KeyEvent::text), if it has + /// any and Ctrl and Cmd aren't held. VS Code's only mode, and Vim's + /// insert mode. + Insert, + /// It does nothing. Vim's normal and visual modes. + Ignore, +} + +/// One mode of a keymap. A mode is data: its id and what typing does in +/// it. Which keys do what in it are the keymap's bindings scoped to it. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Mode { + /// The mode's id. + pub id: ModeId, + /// What a key no binding matches does in this mode. + pub typing: Typing, +} + +impl Mode { + /// A mode with id `id`. + pub fn new(id: impl Into, typing: Typing) -> Self { + todo!() + } +} + +/// What a binding does with its command. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum Action { + /// Run the command. If an operator is pending, run the operator instead, + /// with this command as its [`motion`](crate::CommandArgs::motion). + Run(CommandId), + /// Hold the command as the pending operator, such as Vim's `d`. Nothing + /// runs until a key bound to [`Action::Run`] gives it a motion. + Operator(CommandId), +} + +/// One binding: a key sequence, where it applies, and what it does. +/// +/// A binding with no [`action`](Self::action) and no +/// [`then_mode`](Self::then_mode) still matches its keys and does nothing, +/// which hides a binding it beats. A keybindings file uses that to unbind +/// a default key. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Binding { + /// The keys, one keystroke or a chord. + pub keys: KeySequence, + /// The mode it applies in, or `None` for every mode of the keymap. + pub mode: Option, + /// A context that must hold for it to apply, or `None` for always. + /// Contexts are plain names, such as `"editor_has_selection"`, and the + /// caller of [`Dispatcher::resolve`](crate::Dispatcher::resolve) says + /// which hold. + pub context: Option, + /// What it does with a command, if anything. + pub action: Option, + /// The mode to switch to when it matches, if any. + pub then_mode: Option, +} + +impl Binding { + /// Keys `keys`, in [`KeySequence`]'s notation, that run `command`, in + /// every mode and context. + pub fn run(keys: &str, command: impl Into) -> Result { + todo!() + } + + /// Keys `keys` that hold `command` as the pending operator, in every + /// mode and context. + pub fn operator(keys: &str, command: impl Into) -> Result { + todo!() + } + + /// Keys `keys` that only switch to mode `mode`, in every mode and + /// context. + pub fn switch_mode(keys: &str, mode: impl Into) -> Result { + todo!() + } + + /// This binding, applying only in mode `mode`. + pub fn in_mode(self, mode: impl Into) -> Self { + todo!() + } + + /// This binding, applying only while context `context` holds. + pub fn when(self, context: impl Into) -> Self { + todo!() + } + + /// This binding, also switching to mode `mode` when it matches. + pub fn then_mode(self, mode: impl Into) -> Self { + todo!() + } +} + +/// A named set of modes and bindings: one editing model, such as VS Code's +/// (T-107) or Vim's (T-108). +/// +/// The first mode is the initial mode, the one a new +/// [`KeyState`](crate::KeyState) starts in. Bindings keep the order they +/// were added in, which breaks ties in +/// [`Dispatcher::resolve`](crate::Dispatcher::resolve): of two bindings +/// that both apply, the later one wins, so a keybindings file loaded after +/// the defaults overrides them. +/// +/// A binding may name a command no registry holds. The keymap doesn't know +/// which commands exist: `helm-ui` runs some itself, and running an unknown +/// one errors with [`CommandError::UnknownCommand`](crate::CommandError). +#[derive(Clone, Debug)] +pub struct Keymap { + name: String, + /// Never empty. The first is the initial mode. + modes: Vec, + /// In the order they were added. + bindings: Vec, +} + +impl Keymap { + /// A keymap named `name` with `modes` and no bindings. The first mode + /// is the initial mode. + /// + /// Errors with [`KeymapError::NoModes`] if `modes` is empty, and with + /// [`KeymapError::DuplicateMode`] if two modes share an id. + pub fn new(name: impl Into, modes: Vec) -> Result { + todo!() + } + + /// The keymap's name, such as `"vscode"` or `"vim"`. + pub fn name(&self) -> &str { + todo!() + } + + /// The modes, the initial mode first. + pub fn modes(&self) -> &[Mode] { + todo!() + } + + /// The mode with id `id`, if the keymap has it. + pub fn mode(&self, id: &str) -> Option<&Mode> { + todo!() + } + + /// The id of the first mode. + pub fn initial_mode(&self) -> &ModeId { + todo!() + } + + /// Adds `binding` after every binding added before it. + /// + /// Errors with [`KeymapError::UnknownMode`], and adds nothing, if + /// `binding.mode` or `binding.then_mode` names a mode the keymap + /// doesn't have. + pub fn bind(&mut self, binding: Binding) -> Result<(), KeymapError> { + todo!() + } + + /// The bindings, in the order they were added. + pub fn bindings(&self) -> &[Binding] { + todo!() + } +} + +/// Why [`Keymap::new`] or [`Keymap::bind`] refused. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum KeymapError { + /// A keymap needs at least one mode. + NoModes, + /// Two modes share this id. + DuplicateMode { + /// The id. + mode: ModeId, + }, + /// A binding names a mode the keymap doesn't have. + UnknownMode { + /// The mode named. + mode: ModeId, + }, +} + +impl fmt::Display for KeymapError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + todo!() + } +} + +impl std::error::Error for KeymapError {} diff --git a/crates/helm-editor/src/lib.rs b/crates/helm-editor/src/lib.rs index b52608f..40ab90c 100644 --- a/crates/helm-editor/src/lib.rs +++ b/crates/helm-editor/src/lib.rs @@ -39,11 +39,52 @@ //! back the selections the editor had just before or just after the command //! that made the transaction. Commands that only move or add selections are //! not transactions and are not undone. +//! +//! # Commands +//! +//! Every command has a stable string id, a [`CommandId`] such as +//! `"editor.delete_backward"`, and runs against one [`Editor`] and the +//! [`BufferSet`](helm_text::BufferSet) that owns its buffer. A +//! [`CommandRegistry`] maps ids to commands. +//! [`CommandRegistry::with_editor_commands`] registers every `Editor` +//! command under the ids in [`editor_commands`]. +//! +//! # Keys, keymaps and modes +//! +//! `helm-ui` turns each platform key press into a [`KeyEvent`]: a +//! [`Keystroke`] (a [`Key`] and its [`Modifiers`], Cmd included) and the +//! text it types. No GPUI type gets this far down. +//! +//! A [`Keymap`] is data: a list of [`Mode`]s and a list of [`Binding`]s +//! from a [`KeySequence`] (one keystroke, or a chord such as +//! `cmd-k cmd-s`) to a command id, each scoped to a mode and an optional +//! context. VS Code's keymap (T-107) has one mode; Vim's (T-108) has +//! several. A mode says whether a key no binding matches types its text, +//! so typing takes the same path as every other command. +//! +//! A [`Dispatcher`] holds the active keymap and the registry, and each +//! editor holds a [`KeyState`]: its mode, a chord typed so far and a +//! pending operator. [`Dispatcher::resolve`] turns one key event into a +//! [`Resolution`] and documents which binding wins. +//! [`Dispatcher::set_keymap`] switches keymaps at runtime, and every open +//! editor uses the new keymap from its next key (ED-6). +mod command; +mod dispatch; mod editor; +pub mod editor_commands; mod error; +mod key; +mod keymap; mod selection; +pub use command::{ + Command, CommandArgs, CommandContext, CommandError, CommandFn, CommandId, CommandRegistry, + Invocation, RegisterError, +}; +pub use dispatch::{Dispatcher, KeyState, Resolution}; pub use editor::Editor; pub use error::EditorError; +pub use key::{Key, KeyEvent, KeyParseError, KeySequence, Keystroke, Modifiers}; +pub use keymap::{Action, Binding, Keymap, KeymapError, Mode, ModeId, Typing}; pub use selection::{Selection, Selections}; diff --git a/crates/helm-editor/tests/commands.rs b/crates/helm-editor/tests/commands.rs new file mode 100644 index 0000000..2691713 --- /dev/null +++ b/crates/helm-editor/tests/commands.rs @@ -0,0 +1,305 @@ +//! T-106 acceptance tests: the command registry, its ids, and running +//! every `Editor` command through it. + +use helm_editor::{ + Command, CommandArgs, CommandContext, CommandError, CommandId, CommandRegistry, Editor, + EditorError, Invocation, RegisterError, Selection, Selections, editor_commands, +}; +use helm_text::{Buffer, BufferSet, FileFormat, LineEndings}; + +/// A set with one buffer holding `text`, and an editor on it with the +/// file's format and the given selections. +fn setup(text: &str, selections: &[Selection]) -> (BufferSet, Editor) { + let mut set = BufferSet::new(); + let id = set.add(Buffer::from(text)); + let format = FileFormat { + bom: false, + line_endings: LineEndings::count(text), + }; + let mut editor = Editor::new(id, format); + editor.set_selections(Selections::new(selections.to_vec(), 0).expect("non-empty")); + (set, editor) +} + +/// The text of the editor's buffer. +fn text(set: &BufferSet, editor: &Editor) -> String { + set.get(editor.buffer()) + .expect("buffer is in the set") + .to_string() +} + +/// A command that does nothing. +fn nothing(_: CommandContext<'_>) -> Result<(), CommandError> { + Ok(()) +} + +/// A command with id `id` that does nothing. +fn noop(id: &str) -> Command { + Command { + id: CommandId::from(id), + title: "Do nothing", + run: nothing, + } +} + +/// A command that types its motion's id, to show what `run` passes on. +fn type_motion(cx: CommandContext<'_>) -> Result<(), CommandError> { + let motion = cx.args.motion.as_ref().map_or("none", CommandId::as_str); + cx.editor.insert(cx.set, motion)?; + Ok(()) +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn command_ids_compare_as_text() { + let id = CommandId::from("editor.undo"); + assert_eq!(id.as_str(), "editor.undo"); + assert_eq!(id.to_string(), "editor.undo"); + assert_eq!(id, CommandId::from("editor.undo".to_owned())); + assert_ne!(id, CommandId::from("editor.redo")); + assert!(CommandId::from("a.b") < CommandId::from("a.c")); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn editor_command_ids_are_the_documented_ones() { + assert_eq!( + editor_commands::ALL, + [ + "editor.add_cursor_above", + "editor.add_cursor_below", + "editor.add_next_match", + "editor.delete_backward", + "editor.delete_forward", + "editor.insert", + "editor.newline", + "editor.redo", + "editor.select_all_matches", + "editor.undo", + ] + ); + // Calls into the registry so the test runs against the implementation. + assert!(CommandRegistry::new().commands().next().is_none()); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn registers_every_editor_command_sorted_with_a_title() { + let registry = CommandRegistry::with_editor_commands(); + let ids: Vec<&str> = registry.commands().map(|c| c.id.as_str()).collect(); + assert_eq!(ids, editor_commands::ALL); + for id in editor_commands::ALL { + let command = registry.get(id).expect("registered"); + assert_eq!(command.id.as_str(), id); + assert!(!command.title.is_empty(), "{id} has a title"); + } + assert!(registry.get("editor.nope").is_none()); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn register_checks_the_id() { + let mut registry = CommandRegistry::new(); + for id in ["a", "editor.x", "vim.delete_line", "a1.b_2.c3", "_"] { + assert_eq!(registry.register(noop(id)), Ok(()), "{id}"); + } + for id in [ + "", + ".", + "a.", + ".a", + "a..b", + "Editor.x", + "editor.x-y", + "editor x", + "é", + ] { + assert_eq!( + registry.register(noop(id)), + Err(RegisterError::InvalidId { + id: CommandId::from(id) + }), + "{id:?}" + ); + } + assert_eq!( + registry.register(noop("editor.x")), + Err(RegisterError::Duplicate { + id: CommandId::from("editor.x") + }) + ); + let ids: Vec<&str> = registry.commands().map(|c| c.id.as_str()).collect(); + assert_eq!(ids, ["_", "a", "a1.b_2.c3", "editor.x", "vim.delete_line"]); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn editor_commands_cannot_be_registered_twice() { + let mut registry = CommandRegistry::with_editor_commands(); + assert_eq!( + registry.register(noop(editor_commands::UNDO)), + Err(RegisterError::Duplicate { + id: CommandId::from(editor_commands::UNDO) + }) + ); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn each_editing_command_runs_its_editor_method() { + let registry = CommandRegistry::with_editor_commands(); + let (mut set, mut editor) = setup("ab\r\ncd", &[Selection::cursor(1)]); + + registry + .run(&Invocation::insert("X"), &mut editor, &mut set) + .unwrap(); + assert_eq!(text(&set, &editor), "aXb\r\ncd"); + + let run = |id: &str, editor: &mut Editor, set: &mut BufferSet| { + registry.run(&Invocation::new(id), editor, set).unwrap(); + }; + run(editor_commands::NEWLINE, &mut editor, &mut set); + assert_eq!(text(&set, &editor), "aX\r\nb\r\ncd"); + run(editor_commands::DELETE_BACKWARD, &mut editor, &mut set); + assert_eq!(text(&set, &editor), "aXb\r\ncd"); + run(editor_commands::DELETE_FORWARD, &mut editor, &mut set); + assert_eq!(text(&set, &editor), "aX\r\ncd"); + assert_eq!(editor.selections().as_slice(), &[Selection::cursor(2)]); + + run(editor_commands::UNDO, &mut editor, &mut set); + assert_eq!(text(&set, &editor), "aXb\r\ncd"); + run(editor_commands::REDO, &mut editor, &mut set); + assert_eq!(text(&set, &editor), "aX\r\ncd"); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn each_selection_command_runs_its_editor_method() { + let registry = CommandRegistry::with_editor_commands(); + let run = |id: &str, editor: &mut Editor, set: &mut BufferSet| { + registry.run(&Invocation::new(id), editor, set).unwrap(); + }; + + let (mut set, mut editor) = setup("ab ab ab", &[Selection::cursor(0)]); + run(editor_commands::ADD_NEXT_MATCH, &mut editor, &mut set); + assert_eq!(editor.selections().as_slice(), &[Selection::new(0, 2)]); + run(editor_commands::ADD_NEXT_MATCH, &mut editor, &mut set); + assert_eq!( + editor.selections().as_slice(), + &[Selection::new(0, 2), Selection::new(3, 5)] + ); + run(editor_commands::SELECT_ALL_MATCHES, &mut editor, &mut set); + assert_eq!(editor.selections().as_slice().len(), 3); + assert_eq!(text(&set, &editor), "ab ab ab"); + + let (mut set, mut editor) = setup("abc\nabc\nabc", &[Selection::cursor(5)]); + run(editor_commands::ADD_CURSOR_ABOVE, &mut editor, &mut set); + assert_eq!( + editor.selections().as_slice(), + &[Selection::cursor(1), Selection::cursor(5)] + ); + run(editor_commands::ADD_CURSOR_BELOW, &mut editor, &mut set); + assert_eq!( + editor.selections().as_slice(), + &[ + Selection::cursor(1), + Selection::cursor(5), + Selection::cursor(9) + ] + ); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn insert_without_text_is_an_error() { + let registry = CommandRegistry::with_editor_commands(); + let (mut set, mut editor) = setup("ab", &[Selection::cursor(1)]); + assert_eq!( + registry.run( + &Invocation::new(editor_commands::INSERT), + &mut editor, + &mut set + ), + Err(CommandError::MissingText { + id: CommandId::from(editor_commands::INSERT) + }) + ); + assert_eq!(text(&set, &editor), "ab"); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn unknown_commands_and_editor_errors_are_reported() { + let registry = CommandRegistry::with_editor_commands(); + let (mut set, mut editor) = setup("ab", &[Selection::cursor(1)]); + assert_eq!( + registry.run(&Invocation::new("workspace.save"), &mut editor, &mut set), + Err(CommandError::UnknownCommand { + id: CommandId::from("workspace.save") + }) + ); + + // An editor whose buffer isn't in the set. + let mut other = BufferSet::new(); + let error = registry + .run(&Invocation::insert("x"), &mut editor, &mut other) + .unwrap_err(); + assert_eq!( + error, + CommandError::Editor(EditorError::UnknownBuffer { + buffer: editor.buffer() + }) + ); + assert_eq!( + CommandError::from(EditorError::UnknownBuffer { + buffer: editor.buffer() + }), + error + ); + assert_eq!(text(&set, &editor), "ab"); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn run_passes_the_invocation_args() { + let mut registry = CommandRegistry::new(); + registry + .register(Command { + id: CommandId::from("test.type_motion"), + title: "Type the motion", + run: type_motion, + }) + .unwrap(); + let (mut set, mut editor) = setup("", &[Selection::cursor(0)]); + let invocation = Invocation { + command: CommandId::from("test.type_motion"), + args: CommandArgs { + text: None, + motion: Some(CommandId::from("vim.word")), + }, + }; + registry.run(&invocation, &mut editor, &mut set).unwrap(); + assert_eq!(text(&set, &editor), "vim.word"); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn invocation_constructors() { + assert_eq!( + Invocation::new("editor.undo"), + Invocation { + command: CommandId::from("editor.undo"), + args: CommandArgs::default(), + } + ); + assert_eq!( + Invocation::insert("hé"), + Invocation { + command: CommandId::from(editor_commands::INSERT), + args: CommandArgs { + text: Some("hé".to_owned()), + motion: None, + }, + } + ); +} diff --git a/crates/helm-editor/tests/dispatch.rs b/crates/helm-editor/tests/dispatch.rs new file mode 100644 index 0000000..8e9cb54 --- /dev/null +++ b/crates/helm-editor/tests/dispatch.rs @@ -0,0 +1,325 @@ +//! T-106 acceptance tests: typed keys dispatched into an `Editor`, checked +//! against the buffer, with a VS Code-like keymap, a Vim-like one, and a +//! switch between them mid-session (ED-6). + +use helm_editor::{ + Binding, CommandError, CommandId, CommandRegistry, Dispatcher, Editor, Invocation, KeyEvent, + KeySequence, KeyState, Keymap, Keystroke, Mode, Resolution, Selection, Selections, Typing, + editor_commands as ids, +}; +use helm_text::{Buffer, BufferSet, FileFormat, LineEndings}; + +/// A binding from `keys` to running `command`. +fn run(keys: &str, command: &str) -> Binding { + Binding::run(keys, command).expect("valid keys") +} + +/// A VS Code-like sample: one mode where typing inserts, and a few of the +/// bindings T-107 will add. +fn vscode_like() -> Keymap { + let mut keymap = + Keymap::new("vscode", vec![Mode::new("default", Typing::Insert)]).expect("one mode"); + for binding in [ + run("enter", ids::NEWLINE), + run("backspace", ids::DELETE_BACKWARD), + run("delete", ids::DELETE_FORWARD), + run("cmd-z", ids::UNDO), + run("shift-cmd-z", ids::REDO), + run("cmd-d", ids::ADD_NEXT_MATCH), + run("shift-cmd-l", ids::SELECT_ALL_MATCHES), + run("alt-cmd-up", ids::ADD_CURSOR_ABOVE), + run("alt-cmd-down", ids::ADD_CURSOR_BELOW), + run("cmd-k cmd-z", ids::UNDO), + ] { + keymap.bind(binding).expect("valid binding"); + } + keymap +} + +/// A Vim-like sample: normal mode ignores typing, insert mode types. +fn vim_like() -> Keymap { + let mut keymap = Keymap::new( + "vim", + vec![ + Mode::new("normal", Typing::Ignore), + Mode::new("insert", Typing::Insert), + ], + ) + .expect("two modes"); + for binding in [ + Binding::switch_mode("i", "insert") + .unwrap() + .in_mode("normal"), + Binding::switch_mode("escape", "normal") + .unwrap() + .in_mode("insert"), + run("x", ids::DELETE_FORWARD).in_mode("normal"), + run("X", ids::DELETE_BACKWARD).in_mode("normal"), + run("u", ids::UNDO).in_mode("normal"), + run("ctrl-r", ids::REDO).in_mode("normal"), + run("o", ids::NEWLINE).in_mode("normal").then_mode("insert"), + run("backspace", ids::DELETE_BACKWARD).in_mode("insert"), + run("enter", ids::NEWLINE).in_mode("insert"), + ] { + keymap.bind(binding).expect("valid binding"); + } + keymap +} + +/// One editor with its buffer set and key state. +struct Session { + dispatcher: Dispatcher, + state: KeyState, + set: BufferSet, + editor: Editor, +} + +impl Session { + /// An editor on a buffer holding `text`, with `selections` (the first + /// is the primary), keys going through `keymap` and the editor + /// commands. + fn new(keymap: Keymap, text: &str, selections: &[Selection]) -> Self { + let dispatcher = Dispatcher::new(CommandRegistry::with_editor_commands(), keymap); + let state = dispatcher.key_state(); + let mut set = BufferSet::new(); + let id = set.add(Buffer::from(text)); + let format = FileFormat { + bom: false, + line_endings: LineEndings::count(text), + }; + let mut editor = Editor::new(id, format); + editor.set_selections(Selections::new(selections.to_vec(), 0).expect("non-empty")); + Self { + dispatcher, + state, + set, + editor, + } + } + + /// Dispatches one event, which must not fail. + fn event(&mut self, event: &KeyEvent) -> Resolution { + self.dispatcher + .dispatch(&mut self.state, event, &[], &mut self.editor, &mut self.set) + .expect("the command runs") + } + + /// Dispatches each keystroke of `keys`, in `KeySequence` notation. + fn keys(&mut self, keys: &str) { + for &keystroke in KeySequence::parse(keys).expect("valid keys").as_slice() { + self.event(&KeyEvent::new(keystroke)); + } + } + + /// Dispatches one key event per char of `text`, as typing it would. + fn type_text(&mut self, text: &str) { + for c in text.chars() { + let keystroke = if c == ' ' { + Keystroke::parse("space").unwrap() + } else { + Keystroke::parse(&c.to_string()).unwrap() + }; + self.event(&KeyEvent::new(keystroke)); + } + } + + /// The buffer's text. + fn text(&self) -> String { + self.set + .get(self.editor.buffer()) + .expect("buffer is in the set") + .to_string() + } +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn typing_goes_into_the_buffer() { + let mut s = Session::new(vscode_like(), "", &[Selection::cursor(0)]); + s.type_text("hello world"); + assert_eq!(s.text(), "hello world"); + s.keys("enter"); + s.type_text("é中😀"); + assert_eq!(s.text(), "hello world\né中😀"); + assert_eq!(s.editor.selections().as_slice(), &[Selection::cursor(21)]); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn bound_keys_edit_and_undo() { + let mut s = Session::new(vscode_like(), "ab\r\ncd", &[Selection::cursor(2)]); + s.keys("enter"); + assert_eq!(s.text(), "ab\r\n\r\ncd"); + s.keys("backspace backspace"); + assert_eq!(s.text(), "a\r\ncd"); + s.keys("delete"); + assert_eq!(s.text(), "acd"); + s.keys("cmd-z"); + assert_eq!(s.text(), "a\r\ncd"); + s.keys("cmd-k cmd-z"); + assert_eq!(s.text(), "ab\r\ncd"); + s.keys("shift-cmd-z"); + assert_eq!(s.text(), "a\r\ncd"); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn typing_at_every_cursor() { + let mut s = Session::new(vscode_like(), "let a = a + a;", &[Selection::cursor(4)]); + // cmd-d selects the word, then shift-cmd-l every match of it. + s.keys("cmd-d shift-cmd-l"); + assert_eq!(s.editor.selections().as_slice().len(), 3); + s.type_text("total"); + assert_eq!(s.text(), "let total = total + total;"); + s.keys("cmd-z"); + // Each key is one transaction, so one undo takes back the last letter. + assert_eq!(s.text(), "let tota = tota + tota;"); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn adding_cursors_with_keys() { + let mut s = Session::new(vscode_like(), "ab\nab\nab", &[Selection::cursor(4)]); + s.keys("alt-cmd-up alt-cmd-down"); + s.type_text("-"); + assert_eq!(s.text(), "a-b\na-b\na-b"); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn a_broken_chord_types_nothing() { + let mut s = Session::new(vscode_like(), "", &[Selection::cursor(0)]); + s.keys("cmd-k x"); + assert_eq!(s.text(), ""); + s.keys("x"); + assert_eq!(s.text(), "x"); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn option_text_is_typed() { + let mut s = Session::new(vscode_like(), "", &[Selection::cursor(0)]); + let option_a = KeyEvent::with_text(Keystroke::parse("alt-a").unwrap(), "å"); + assert_eq!(s.event(&option_a), Resolution::Run(Invocation::insert("å"))); + assert_eq!(s.text(), "å"); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn vim_like_modes_edit_the_buffer() { + let mut s = Session::new(vim_like(), "abc", &[Selection::cursor(0)]); + // Normal mode: `x` deletes, other letters do nothing. + s.keys("x z q"); + assert_eq!(s.text(), "bc"); + // Insert mode types, escape goes back. + s.keys("i"); + s.type_text("hey "); + s.keys("escape"); + assert_eq!(s.text(), "hey bc"); + assert_eq!(s.state.mode().as_str(), "normal"); + s.type_text("q"); + assert_eq!(s.text(), "hey bc"); + // `X` deletes backward, `u` undoes, ctrl-r redoes. + s.keys("X"); + assert_eq!(s.text(), "heybc"); + s.keys("u"); + assert_eq!(s.text(), "hey bc"); + s.keys("ctrl-r"); + assert_eq!(s.text(), "heybc"); + // `o` runs newline and enters insert mode. + s.keys("o"); + s.type_text("x"); + assert_eq!(s.text(), "hey\nxbc"); + assert_eq!(s.state.mode().as_str(), "insert"); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn switching_keymaps_mid_session() { + let mut s = Session::new(vscode_like(), "", &[Selection::cursor(0)]); + s.type_text("abc"); + // Half a chord, then the switch: it is dropped. + s.keys("cmd-k"); + + s.dispatcher.set_keymap(vim_like()); + // Normal mode: `x` now deletes instead of typing. + s.keys("x"); + assert_eq!(s.text(), "abc"); + s.editor + .set_selections(Selections::single(Selection::cursor(1))); + s.keys("x"); + assert_eq!(s.text(), "ac"); + s.keys("i"); + s.type_text("b"); + assert_eq!(s.text(), "abc"); + + // And back: typing inserts again, from the first key. + s.dispatcher.set_keymap(vscode_like()); + s.type_text("x"); + assert_eq!(s.text(), "abxc"); + assert_eq!(s.state.mode().as_str(), "default"); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn two_editors_share_one_dispatcher() { + let mut s = Session::new(vim_like(), "one", &[Selection::cursor(0)]); + let id = s.set.add(Buffer::from("two")); + let mut other = Editor::new(id, FileFormat::default()); + let mut other_state = s.dispatcher.key_state(); + + // The first editor goes to insert mode; the second stays in normal. + s.keys("i"); + let x = KeyEvent::parse("x").unwrap(); + s.dispatcher + .dispatch(&mut other_state, &x, &[], &mut other, &mut s.set) + .unwrap(); + assert_eq!(s.set.get(id).unwrap().to_string(), "wo"); + s.type_text("x"); + assert_eq!(s.text(), "xone"); + + // One switch reaches both. + s.dispatcher.set_keymap(vscode_like()); + s.dispatcher + .dispatch(&mut other_state, &x, &[], &mut other, &mut s.set) + .unwrap(); + assert_eq!(s.set.get(id).unwrap().to_string(), "xwo"); + s.type_text("y"); + assert_eq!(s.text(), "xyone"); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn an_unknown_command_is_an_error_and_edits_nothing() { + let mut keymap = vscode_like(); + keymap.bind(run("cmd-s", "workspace.save")).unwrap(); + let mut s = Session::new(keymap, "ab", &[Selection::cursor(1)]); + let save = KeyEvent::parse("cmd-s").unwrap(); + assert_eq!( + s.dispatcher + .dispatch(&mut s.state, &save, &[], &mut s.editor, &mut s.set), + Err(CommandError::UnknownCommand { + id: CommandId::from("workspace.save") + }) + ); + assert_eq!(s.text(), "ab"); + // Resolving alone hands the invocation to whoever owns the command. + assert_eq!( + s.dispatcher.resolve(&mut s.state, &save, &[]), + Resolution::Run(Invocation::new("workspace.save")) + ); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn dispatch_returns_the_resolution() { + let mut s = Session::new(vscode_like(), "", &[Selection::cursor(0)]); + let parse = |text: &str| KeyEvent::parse(text).unwrap(); + assert_eq!(s.event(&parse("cmd-k")), Resolution::Pending); + assert_eq!(s.event(&parse("escape")), Resolution::Cancelled); + assert_eq!(s.event(&parse("ctrl-q")), Resolution::Unbound); + assert_eq!( + s.event(&parse("cmd-z")), + Resolution::Run(Invocation::new(ids::UNDO)) + ); +} diff --git a/crates/helm-editor/tests/keymap_properties.rs b/crates/helm-editor/tests/keymap_properties.rs new file mode 100644 index 0000000..20f033e --- /dev/null +++ b/crates/helm-editor/tests/keymap_properties.rs @@ -0,0 +1,289 @@ +//! T-106 acceptance tests: property tests of chord resolution, precedence, +//! switching keymaps, and typing through the dispatcher. + +use helm_editor::{ + Action, Binding, CommandId, CommandRegistry, Dispatcher, Editor, Invocation, Key, KeyEvent, + KeySequence, KeyState, Keymap, Keystroke, Mode, ModeId, Modifiers, Resolution, Selection, + Selections, Typing, +}; +use helm_text::{Buffer, BufferSet, FileFormat}; +use proptest::prelude::*; +use proptest::sample::Index; + +/// Keystrokes the generated keymaps and key presses draw from: some that +/// type text and some that don't. +const ALPHABET: [&str; 10] = [ + "a", + "b", + "escape", + "enter", + "ctrl-a", + "ctrl-b", + "cmd-1", + "cmd-2", + "alt-cmd-x", + "shift-tab", +]; + +/// The keystroke at `i` in `ALPHABET`. +fn key(i: usize) -> Keystroke { + Keystroke::parse(ALPHABET[i]).expect("valid keystroke") +} + +/// A sequence of one to three keystrokes from `ALPHABET`. +fn sequence() -> impl Strategy> { + prop::collection::vec(0..ALPHABET.len(), 1..=3) +} + +/// The `KeySequence` of `indexes` into `ALPHABET`. +fn keys(indexes: &[usize]) -> KeySequence { + KeySequence::new(indexes.iter().map(|&i| key(i)).collect()).expect("non-empty") +} + +/// Resolves every keystroke of `indexes` with no contexts. +fn press(dispatcher: &Dispatcher, state: &mut KeyState, indexes: &[usize]) -> Vec { + indexes + .iter() + .map(|&i| dispatcher.resolve(state, &KeyEvent::new(key(i)), &[])) + .collect() +} + +/// The sequences in `sequences` with duplicates removed and with every +/// sequence that is a prefix of another dropped, so none starts another. +fn prefix_free(mut sequences: Vec>) -> Vec> { + sequences.sort(); + sequences.dedup(); + let all = sequences.clone(); + sequences.retain(|s| { + !all.iter() + .any(|other| other.len() > s.len() && other.starts_with(s)) + }); + sequences +} + +/// One generated binding: its keys, mode scope, action kind, command +/// number and mode to switch to. Modes are indexes into the keymap's modes. +#[derive(Clone, Debug)] +struct Spec { + keys: Vec, + mode: Option, + /// 0 runs, 1 is an operator, 2 has no action. + action: u8, + command: usize, + then_mode: Option, +} + +fn spec(modes: usize) -> impl Strategy { + ( + sequence(), + prop::option::of(0..modes), + prop_oneof![6 => Just(0u8), 2 => Just(1u8), 1 => Just(2u8)], + 0..1000usize, + prop::option::weighted(0.3, 0..modes), + ) + .prop_map(|(keys, mode, action, command, then_mode)| Spec { + keys, + mode, + action, + command, + then_mode, + }) +} + +/// A keymap named `name` whose modes are `{prefix}0`, `{prefix}1` and +/// `{prefix}2`, typing in the first two and not the last, with a binding +/// for each spec. +fn build(name: &str, prefix: &str, specs: &[Spec]) -> Keymap { + let mode = |i: usize| ModeId::from(format!("{prefix}{i}")); + let modes = (0..3) + .map(|i| { + let typing = if i < 2 { + Typing::Insert + } else { + Typing::Ignore + }; + Mode::new(mode(i), typing) + }) + .collect(); + let mut keymap = Keymap::new(name, modes).expect("distinct modes"); + for spec in specs { + let command = CommandId::from(format!("{prefix}.c{}", spec.command)); + keymap + .bind(Binding { + keys: keys(&spec.keys), + mode: spec.mode.map(mode), + context: None, + action: match spec.action { + 0 => Some(Action::Run(command)), + 1 => Some(Action::Operator(command)), + _ => None, + }, + then_mode: spec.then_mode.map(mode), + }) + .expect("modes exist"); + } + keymap +} + +/// A dispatcher with an empty registry and `keymap`. +fn dispatcher(keymap: Keymap) -> Dispatcher { + Dispatcher::new(CommandRegistry::new(), keymap) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// Every binding of a prefix-free keymap, typed in any order, waits on + /// each key but its last and then runs its command, leaving nothing + /// pending. + #[test] + #[ignore = "T-106: not implemented yet"] + fn chords_resolve_to_their_command( + sequences in prop::collection::vec(sequence(), 1..40), + order in prop::collection::vec(any::(), 1..60), + ) { + let sequences = prefix_free(sequences); + let mut keymap = Keymap::new("k", vec![Mode::new("m", Typing::Insert)]).unwrap(); + for (i, sequence) in sequences.iter().enumerate() { + keymap.bind(Binding::run(&keys(sequence).to_string(), format!("k.c{i}")).unwrap()).unwrap(); + } + let dispatcher = dispatcher(keymap); + let mut state = dispatcher.key_state(); + for index in order { + let i = index.index(sequences.len()); + let resolutions = press(&dispatcher, &mut state, &sequences[i]); + let (last, waiting) = resolutions.split_last().unwrap(); + prop_assert!(waiting.iter().all(|r| *r == Resolution::Pending)); + prop_assert_eq!(last, &Resolution::Run(Invocation::new(format!("k.c{i}")))); + prop_assert!(state.pending().is_empty()); + } + } + + /// Of several bindings on one key, the one that wins is the applying + /// binding with a mode over one without, then a context over none, + /// then the later one. + #[test] + #[ignore = "T-106: not implemented yet"] + fn precedence_matches_the_documented_order( + bindings in prop::collection::vec( + (prop::option::of(0..2usize), prop::option::of(0..3usize)), + 1..10, + ), + in_second_mode in any::(), + holding in prop::collection::vec(any::(), 3), + ) { + let modes = ["one", "two"]; + let contexts = ["x", "y", "z"]; + let mut keymap = Keymap::new( + "k", + vec![Mode::new("one", Typing::Insert), Mode::new("two", Typing::Insert)], + ) + .unwrap(); + for (i, &(mode, context)) in bindings.iter().enumerate() { + let mut binding = Binding::run("cmd-1", format!("k.c{i}")).unwrap(); + binding.mode = mode.map(|m| ModeId::from(modes[m])); + binding.context = context.map(|c| contexts[c].to_owned()); + keymap.bind(binding).unwrap(); + } + keymap.bind(Binding::switch_mode("cmd-2", "two").unwrap().in_mode("one")).unwrap(); + let dispatcher = dispatcher(keymap); + let mut state = dispatcher.key_state(); + if in_second_mode { + let switch = KeyEvent::parse("cmd-2").unwrap(); + prop_assert_eq!(dispatcher.resolve(&mut state, &switch, &[]), Resolution::Done); + } + let mode = usize::from(in_second_mode); + let held: Vec<&str> = contexts + .iter() + .zip(&holding) + .filter(|&(_, &held)| held) + .map(|(&c, _)| c) + .collect(); + + // The oracle. + let winner = bindings + .iter() + .enumerate() + .filter(|&(_, &(m, c))| { + m.is_none_or(|m| m == mode) && c.is_none_or(|c| holding[c]) + }) + .max_by_key(|&(i, &(m, c))| (m.is_some(), c.is_some(), i)) + .map(|(i, _)| i); + let expected = match winner { + Some(i) => Resolution::Run(Invocation::new(format!("k.c{i}"))), + None => Resolution::Unbound, + }; + let event = KeyEvent::parse("cmd-1").unwrap(); + prop_assert_eq!(dispatcher.resolve(&mut state, &event, &held), expected); + } + + /// After a switch to a keymap that shares no mode with the old one, a + /// state that had any chord, operator or mode resolves keys exactly as + /// a new state does. + #[test] + #[ignore = "T-106: not implemented yet"] + fn after_a_switch_a_state_acts_like_a_new_one( + old in prop::collection::vec(spec(3), 0..30), + new in prop::collection::vec(spec(3), 0..30), + before in prop::collection::vec(0..ALPHABET.len(), 0..20), + after in prop::collection::vec(0..ALPHABET.len(), 1..20), + sync_first in any::(), + ) { + let mut switched = dispatcher(build("old", "o", &old)); + let mut state = switched.key_state(); + press(&switched, &mut state, &before); + let previous = switched.set_keymap(build("new", "n", &new)); + prop_assert_eq!(previous.name(), "old"); + if sync_first { + switched.sync(&mut state); + prop_assert_eq!(state.mode().as_str(), "n0"); + prop_assert!(state.pending().is_empty()); + prop_assert_eq!(state.operator(), None); + } + + let fresh = dispatcher(build("new", "n", &new)); + let mut fresh_state = fresh.key_state(); + for &i in &after { + let event = KeyEvent::new(key(i)); + prop_assert_eq!( + switched.resolve(&mut state, &event, &[]), + fresh.resolve(&mut fresh_state, &event, &[]) + ); + prop_assert_eq!(&state.mode(), &fresh_state.mode()); + prop_assert_eq!(state.pending(), fresh_state.pending()); + prop_assert_eq!(state.operator(), fresh_state.operator()); + } + } + + /// With no bindings on plain chars, typing any text one key per char + /// through the dispatcher puts exactly that text at the cursor. + #[test] + #[ignore = "T-106: not implemented yet"] + fn typed_keys_insert_their_text( + initial in "[a-c\n]{0,10}", + at in any::(), + typed in prop::collection::vec(any::().prop_filter("not a control char", |c| !c.is_control()), 0..40), + ) { + let mut keymap = Keymap::new("k", vec![Mode::new("m", Typing::Insert)]).unwrap(); + keymap.bind(Binding::run("cmd-z", "editor.undo").unwrap()).unwrap(); + let dispatcher = Dispatcher::new(CommandRegistry::with_editor_commands(), keymap); + let mut state = dispatcher.key_state(); + let mut set = BufferSet::new(); + let id = set.add(Buffer::from(initial.as_str())); + let mut editor = Editor::new(id, FileFormat::default()); + let at = at.index(initial.len() + 1); + editor.set_selections(Selections::single(Selection::cursor(at))); + + for &c in &typed { + let event = KeyEvent::new(Keystroke::new(Key::Char(c), Modifiers::NONE)); + let resolution = dispatcher + .dispatch(&mut state, &event, &[], &mut editor, &mut set) + .unwrap(); + prop_assert_eq!(resolution, Resolution::Run(Invocation::insert(c.to_string()))); + } + let typed: String = typed.into_iter().collect(); + let expected = format!("{}{}{}", &initial[..at], typed, &initial[at..]); + prop_assert_eq!(set.get(id).unwrap().to_string(), expected); + prop_assert_eq!(editor.selections().as_slice(), &[Selection::cursor(at + typed.len())]); + } +} diff --git a/crates/helm-editor/tests/keymaps.rs b/crates/helm-editor/tests/keymaps.rs new file mode 100644 index 0000000..f6eb094 --- /dev/null +++ b/crates/helm-editor/tests/keymaps.rs @@ -0,0 +1,772 @@ +//! T-106 acceptance tests: keymaps as data, and table tests of how +//! `Dispatcher::resolve` turns keys into commands: chords, precedence, +//! typing, modes, pending operators and switching keymaps (ED-6). + +use helm_editor::{ + Action, Binding, CommandId, CommandRegistry, Dispatcher, Invocation, KeyEvent, KeyParseError, + KeySequence, KeyState, Keymap, KeymapError, Keystroke, Mode, ModeId, Resolution, Typing, +}; + +/// `Keystroke::parse`, which must accept `text`. +fn ks(text: &str) -> Keystroke { + Keystroke::parse(text).expect("valid keystroke") +} + +/// A binding from `keys` to running `command`. +fn run(keys: &str, command: &str) -> Binding { + Binding::run(keys, command).expect("valid keys") +} + +/// The resolution that runs `command` with no arguments. +fn runs(command: &str) -> Resolution { + Resolution::Run(Invocation::new(command)) +} + +/// The resolution that runs operator `operator` over motion `motion`. +fn runs_operator(operator: &str, motion: &str) -> Resolution { + let mut invocation = Invocation::new(operator); + invocation.args.motion = Some(CommandId::from(motion)); + Resolution::Run(invocation) +} + +/// The resolution that types `text`. +fn types(text: &str) -> Resolution { + Resolution::Run(Invocation::insert(text)) +} + +/// A keymap named `name` with `modes`, given as id and typing, and +/// `bindings` in order. +fn keymap(name: &str, modes: &[(&str, Typing)], bindings: Vec) -> Keymap { + let modes = modes + .iter() + .map(|&(id, typing)| Mode::new(id, typing)) + .collect(); + let mut keymap = Keymap::new(name, modes).expect("valid modes"); + for binding in bindings { + keymap.bind(binding).expect("valid binding"); + } + keymap +} + +/// A keymap with one mode, `"default"`, where typing inserts. +fn one_mode(bindings: Vec) -> Keymap { + keymap("one", &[("default", Typing::Insert)], bindings) +} + +/// A small Vim-like keymap: `normal` first, then `insert` and `operator`. +fn vim_like() -> Keymap { + keymap( + "vim", + &[ + ("normal", Typing::Ignore), + ("insert", Typing::Insert), + ("operator", Typing::Ignore), + ], + vec![ + Binding::switch_mode("i", "insert") + .unwrap() + .in_mode("normal"), + run("a", "vim.right").in_mode("normal").then_mode("insert"), + Binding::switch_mode("escape", "normal") + .unwrap() + .in_mode("insert"), + run("x", "editor.delete_forward").in_mode("normal"), + Binding::operator("d", "vim.delete") + .unwrap() + .in_mode("normal") + .then_mode("operator"), + Binding::operator("c", "vim.change") + .unwrap() + .then_mode("operator"), + run("d", "vim.line").in_mode("operator"), + run("w", "vim.word"), + run("g g", "vim.top"), + run("j", "vim.down").in_mode("normal"), + run("j", "vim.down").in_mode("operator"), + ], + ) +} + +/// A dispatcher with an empty registry and `keymap`, and a state for one +/// editor. +fn dispatcher(keymap: Keymap) -> (Dispatcher, KeyState) { + let dispatcher = Dispatcher::new(CommandRegistry::new(), keymap); + let state = dispatcher.key_state(); + (dispatcher, state) +} + +/// Resolves each keystroke of `keys`, with no contexts, and returns the +/// resolutions. +fn press(dispatcher: &Dispatcher, state: &mut KeyState, keys: &str) -> Vec { + press_in(dispatcher, state, keys, &[]) +} + +/// Resolves each keystroke of `keys` with `contexts`. +fn press_in( + dispatcher: &Dispatcher, + state: &mut KeyState, + keys: &str, + contexts: &[&str], +) -> Vec { + KeySequence::parse(keys) + .expect("valid keys") + .as_slice() + .iter() + .map(|&keystroke| dispatcher.resolve(state, &KeyEvent::new(keystroke), contexts)) + .collect() +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn keymap_new_checks_modes() { + assert_eq!( + Keymap::new("empty", Vec::new()).unwrap_err(), + KeymapError::NoModes + ); + let twice = vec![ + Mode::new("normal", Typing::Ignore), + Mode::new("insert", Typing::Insert), + Mode::new("normal", Typing::Insert), + ]; + assert_eq!( + Keymap::new("twice", twice).unwrap_err(), + KeymapError::DuplicateMode { + mode: ModeId::from("normal") + } + ); + + let keymap = vim_like(); + assert_eq!(keymap.name(), "vim"); + assert_eq!(keymap.initial_mode().as_str(), "normal"); + let ids: Vec<&str> = keymap.modes().iter().map(|m| m.id.as_str()).collect(); + assert_eq!(ids, ["normal", "insert", "operator"]); + assert_eq!( + keymap.mode("insert"), + Some(&Mode { + id: ModeId::from("insert"), + typing: Typing::Insert + }) + ); + assert_eq!(keymap.mode("visual"), None); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn bind_checks_modes_and_keeps_order() { + let mut keymap = keymap( + "k", + &[("normal", Typing::Ignore), ("insert", Typing::Insert)], + Vec::new(), + ); + assert_eq!( + keymap.bind(run("x", "a.x").in_mode("visual")), + Err(KeymapError::UnknownMode { + mode: ModeId::from("visual") + }) + ); + assert_eq!( + keymap.bind(run("x", "a.x").then_mode("replace")), + Err(KeymapError::UnknownMode { + mode: ModeId::from("replace") + }) + ); + assert!(keymap.bindings().is_empty()); + + keymap.bind(run("x", "a.first")).unwrap(); + keymap.bind(run("y", "a.second").in_mode("insert")).unwrap(); + keymap.bind(run("x", "a.third")).unwrap(); + let commands: Vec> = + keymap.bindings().iter().map(|b| b.action.clone()).collect(); + assert_eq!( + commands, + [ + Some(Action::Run(CommandId::from("a.first"))), + Some(Action::Run(CommandId::from("a.second"))), + Some(Action::Run(CommandId::from("a.third"))), + ] + ); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn binding_builders_fill_the_fields() { + let binding = run("cmd-k cmd-s", "workspace.save_all") + .in_mode("default") + .when("editor_focus") + .then_mode("other"); + assert_eq!( + binding, + Binding { + keys: KeySequence::parse("cmd-k cmd-s").unwrap(), + mode: Some(ModeId::from("default")), + context: Some("editor_focus".to_owned()), + action: Some(Action::Run(CommandId::from("workspace.save_all"))), + then_mode: Some(ModeId::from("other")), + } + ); + assert_eq!( + Binding::operator("d", "vim.delete").unwrap(), + Binding { + keys: KeySequence::parse("d").unwrap(), + mode: None, + context: None, + action: Some(Action::Operator(CommandId::from("vim.delete"))), + then_mode: None, + } + ); + assert_eq!( + Binding::switch_mode("escape", "normal").unwrap(), + Binding { + keys: KeySequence::parse("escape").unwrap(), + mode: None, + context: None, + action: None, + then_mode: Some(ModeId::from("normal")), + } + ); + assert_eq!( + Binding::run("cmd-nope", "a.b"), + Err(KeyParseError::UnknownKey { + keystroke: "cmd-nope".to_owned() + }) + ); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn a_new_state_starts_idle_in_the_initial_mode() { + let (_, state) = dispatcher(vim_like()); + assert_eq!(state.mode().as_str(), "normal"); + assert!(state.pending().is_empty()); + assert_eq!(state.operator(), None); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn single_keys_run_their_command() { + let (d, mut state) = dispatcher(one_mode(vec![ + run("cmd-z", "editor.undo"), + run("shift-cmd-z", "editor.redo"), + run("enter", "editor.newline"), + ])); + assert_eq!( + press(&d, &mut state, "cmd-z cmd-Z enter"), + [ + runs("editor.undo"), + runs("editor.redo"), + runs("editor.newline") + ] + ); + assert!(state.pending().is_empty()); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn a_chord_waits_for_its_last_key() { + let (d, mut state) = dispatcher(one_mode(vec![ + run("cmd-k cmd-s", "workspace.save_all"), + run("cmd-k cmd-c", "editor.comment"), + run("ctrl-x ctrl-x ctrl-x", "a.three"), + ])); + assert_eq!(press(&d, &mut state, "cmd-k"), [Resolution::Pending]); + assert_eq!(state.pending(), &[ks("cmd-k")]); + assert_eq!(press(&d, &mut state, "cmd-c"), [runs("editor.comment")]); + assert!(state.pending().is_empty()); + + assert_eq!( + press(&d, &mut state, "cmd-k cmd-s ctrl-x ctrl-x ctrl-x"), + [ + Resolution::Pending, + runs("workspace.save_all"), + Resolution::Pending, + Resolution::Pending, + runs("a.three"), + ] + ); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn a_broken_chord_drops_every_key() { + let (d, mut state) = dispatcher(one_mode(vec![ + run("cmd-k cmd-s", "workspace.save_all"), + run("cmd-j", "a.j"), + ])); + // `x` would type, and `cmd-j` is bound, but after `cmd-k` both break the + // chord and are dropped. + assert_eq!( + press(&d, &mut state, "cmd-k x"), + [Resolution::Pending, Resolution::Cancelled] + ); + assert!(state.pending().is_empty()); + assert_eq!( + press(&d, &mut state, "cmd-k cmd-j"), + [Resolution::Pending, Resolution::Cancelled] + ); + // Escape cancels a chord when nothing binds it. + assert_eq!( + press(&d, &mut state, "cmd-k escape"), + [Resolution::Pending, Resolution::Cancelled] + ); + // Afterwards keys resolve as usual. + assert_eq!(press(&d, &mut state, "x cmd-j"), [types("x"), runs("a.j")]); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn a_chord_prefix_beats_an_exact_match() { + // `cmd-k` alone is bound, but it also starts a chord, so it waits. + let (d, mut state) = dispatcher(one_mode(vec![ + run("cmd-k cmd-s", "workspace.save_all"), + run("cmd-k", "a.k"), + ])); + assert_eq!(press(&d, &mut state, "cmd-k"), [Resolution::Pending]); + assert_eq!(press(&d, &mut state, "cmd-s"), [runs("workspace.save_all")]); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn precedence_table() { + // Every binding is on `cmd-d`, which types nothing. Each case lists the + // bindings as (mode, context, command) in the order they are added, the + // state's mode, the contexts that hold, and the command that wins. + type Case = ( + &'static [(Option<&'static str>, Option<&'static str>, &'static str)], + &'static str, + &'static [&'static str], + Option<&'static str>, + ); + let cases: &[Case] = &[ + // Two plain bindings: the later one. + ( + &[(None, None, "a.1"), (None, None, "a.2")], + "normal", + &[], + Some("a.2"), + ), + // A mode beats no mode, wherever it was added. + ( + &[(Some("normal"), None, "a.1"), (None, None, "a.2")], + "normal", + &[], + Some("a.1"), + ), + ( + &[(None, None, "a.1"), (Some("normal"), None, "a.2")], + "normal", + &[], + Some("a.2"), + ), + // A binding for another mode doesn't apply. + ( + &[(None, None, "a.1"), (Some("insert"), None, "a.2")], + "normal", + &[], + Some("a.1"), + ), + (&[(Some("insert"), None, "a.1")], "normal", &[], None), + // A context beats no context when it holds. + ( + &[(None, Some("sel"), "a.1"), (None, None, "a.2")], + "normal", + &["sel"], + Some("a.1"), + ), + // And doesn't apply when it doesn't hold. + ( + &[(None, Some("sel"), "a.1"), (None, None, "a.2")], + "normal", + &["other"], + Some("a.2"), + ), + (&[(None, Some("sel"), "a.1")], "normal", &[], None), + // Mode first, then context. + ( + &[(Some("normal"), None, "a.1"), (None, Some("sel"), "a.2")], + "normal", + &["sel"], + Some("a.1"), + ), + ( + &[ + (Some("normal"), Some("sel"), "a.1"), + (Some("normal"), None, "a.2"), + ], + "normal", + &["sel"], + Some("a.1"), + ), + // Equal rank: the later one. + ( + &[ + (Some("normal"), Some("x"), "a.1"), + (Some("normal"), Some("y"), "a.2"), + ], + "normal", + &["x", "y"], + Some("a.2"), + ), + ( + &[ + (Some("normal"), Some("x"), "a.1"), + (Some("normal"), Some("y"), "a.2"), + ], + "normal", + &["x"], + Some("a.1"), + ), + // The state's mode decides. + ( + &[(Some("normal"), None, "a.1"), (Some("insert"), None, "a.2")], + "insert", + &[], + Some("a.2"), + ), + ]; + for (i, &(bindings, mode, contexts, winner)) in cases.iter().enumerate() { + let mut list: Vec = bindings + .iter() + .map(|&(mode, context, command)| { + let mut binding = run("cmd-d", command); + if let Some(mode) = mode { + binding = binding.in_mode(mode); + } + if let Some(context) = context { + binding = binding.when(context); + } + binding + }) + .collect(); + // `escape` moves from normal to insert, to set up the state's mode. + list.push( + Binding::switch_mode("escape", "insert") + .unwrap() + .in_mode("normal"), + ); + let (d, mut state) = dispatcher(keymap( + "k", + &[("normal", Typing::Insert), ("insert", Typing::Insert)], + list, + )); + if mode == "insert" { + assert_eq!(press(&d, &mut state, "escape"), [Resolution::Done]); + } + let expected = winner.map_or(Resolution::Unbound, runs); + assert_eq!( + press_in(&d, &mut state, "cmd-d", contexts), + [expected], + "case {i}" + ); + } +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn contexts_scope_chords_too() { + let (d, mut state) = dispatcher(one_mode(vec![ + run("cmd-k cmd-s", "a.save").when("editor_focus"), + run("cmd-k", "a.k"), + ])); + // Without the context the chord doesn't apply, so `cmd-k` runs alone. + assert_eq!(press(&d, &mut state, "cmd-k"), [runs("a.k")]); + assert_eq!( + press_in(&d, &mut state, "cmd-k cmd-s", &["editor_focus"]), + [Resolution::Pending, runs("a.save")] + ); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn unbound_keys_type_their_text() { + let (d, mut state) = dispatcher(one_mode(vec![run("cmd-d", "a.d")])); + assert_eq!( + press(&d, &mut state, "a A shift-b space é 😀"), + [ + types("a"), + types("A"), + types("B"), + types(" "), + types("é"), + types("😀") + ] + ); + // Ctrl and Cmd never type, and named keys have no text. + assert_eq!( + press( + &d, + &mut state, + "ctrl-a cmd-a ctrl-cmd-a enter tab escape left f1" + ), + vec![Resolution::Unbound; 8] + ); + // Option types the text the platform reports. + let option_a = KeyEvent::with_text(ks("alt-a"), "å"); + assert_eq!(d.resolve(&mut state, &option_a, &[]), types("å")); + // Ctrl with text still doesn't type. + let ctrl = KeyEvent::with_text(ks("ctrl-a"), "a"); + assert_eq!(d.resolve(&mut state, &ctrl, &[]), Resolution::Unbound); + // Empty text types nothing. + let empty = KeyEvent::with_text(ks("x"), ""); + assert_eq!(d.resolve(&mut state, &empty, &[]), Resolution::Unbound); + // A binding beats typing. + let bound = KeyEvent::with_text(ks("cmd-d"), "d"); + assert_eq!(d.resolve(&mut state, &bound, &[]), runs("a.d")); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn ignoring_modes_drop_unbound_keys() { + let (d, mut state) = dispatcher(vim_like()); + assert_eq!( + press(&d, &mut state, "z Z space enter"), + vec![Resolution::Unbound; 4] + ); + assert_eq!(state.mode().as_str(), "normal"); + // `x` is bound in normal mode only. + assert_eq!( + press(&d, &mut state, "x i x"), + [runs("editor.delete_forward"), Resolution::Done, types("x")] + ); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn bindings_switch_modes() { + let (d, mut state) = dispatcher(vim_like()); + assert_eq!(press(&d, &mut state, "i"), [Resolution::Done]); + assert_eq!(state.mode().as_str(), "insert"); + assert_eq!(press(&d, &mut state, "j"), [types("j")]); + assert_eq!(press(&d, &mut state, "escape"), [Resolution::Done]); + assert_eq!(state.mode().as_str(), "normal"); + // A binding that runs a command and switches mode does both. + assert_eq!(press(&d, &mut state, "a"), [runs("vim.right")]); + assert_eq!(state.mode().as_str(), "insert"); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn an_operator_waits_for_its_motion() { + let (d, mut state) = dispatcher(vim_like()); + assert_eq!(press(&d, &mut state, "d"), [Resolution::Pending]); + assert_eq!(state.operator(), Some(&CommandId::from("vim.delete"))); + assert_eq!(state.mode().as_str(), "operator"); + assert!(state.pending().is_empty()); + + assert_eq!( + press(&d, &mut state, "w"), + [runs_operator("vim.delete", "vim.word")] + ); + assert_eq!(state.operator(), None); + assert_eq!(state.mode().as_str(), "normal"); + + // A mode-scoped binding gives `d d` its own motion, and a chord can be + // the motion. + assert_eq!( + press(&d, &mut state, "d d d g g d j"), + [ + Resolution::Pending, + runs_operator("vim.delete", "vim.line"), + Resolution::Pending, + Resolution::Pending, + runs_operator("vim.delete", "vim.top"), + Resolution::Pending, + runs_operator("vim.delete", "vim.down"), + ] + ); + assert_eq!(state.mode().as_str(), "normal"); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn a_pending_operator_is_cancelled_by_any_other_key() { + let (d, mut state) = dispatcher(vim_like()); + // An unbound key, escape, and a broken chord motion. + for keys in ["d z", "d escape", "d g x", "d space"] { + let resolutions = press(&d, &mut state, keys); + assert_eq!(resolutions.last(), Some(&Resolution::Cancelled), "{keys}"); + assert_eq!(state.operator(), None, "{keys}"); + assert!(state.pending().is_empty(), "{keys}"); + assert_eq!(state.mode().as_str(), "normal", "{keys}"); + } + // A second operator cancels the first, without its mode switch. + assert_eq!( + press(&d, &mut state, "d c"), + [Resolution::Pending, Resolution::Cancelled] + ); + assert_eq!(state.operator(), None); + assert_eq!(state.mode().as_str(), "normal"); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn an_operator_returns_to_the_mode_it_was_set_in() { + // `c` is bound in every mode, so it can start in insert mode too. + let (d, mut state) = dispatcher(vim_like()); + assert_eq!(press(&d, &mut state, "i"), [Resolution::Done]); + assert_eq!(press(&d, &mut state, "c"), [Resolution::Pending]); + assert_eq!(state.mode().as_str(), "operator"); + assert_eq!( + press(&d, &mut state, "w"), + [runs_operator("vim.change", "vim.word")] + ); + assert_eq!(state.mode().as_str(), "insert"); + + assert_eq!( + press(&d, &mut state, "c escape"), + [Resolution::Pending, Resolution::Cancelled] + ); + assert_eq!(state.mode().as_str(), "insert"); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn an_empty_binding_hides_an_earlier_one() { + let mut empty = run("cmd-d", "unused.command"); + empty.action = None; + let (d, mut state) = dispatcher(one_mode(vec![run("cmd-d", "a.d"), empty])); + assert_eq!(press(&d, &mut state, "cmd-d"), [Resolution::Done]); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn an_empty_binding_drops_a_pending_operator() { + let mut keymap = vim_like(); + let mut empty = run("q", "unused.command").in_mode("operator"); + empty.action = None; + keymap.bind(empty).unwrap(); + let (d, mut state) = dispatcher(keymap); + assert_eq!( + press(&d, &mut state, "d q"), + [Resolution::Pending, Resolution::Done] + ); + assert_eq!(state.operator(), None); + assert_eq!(state.mode().as_str(), "normal"); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn cancel_drops_what_is_pending() { + let (d, mut state) = dispatcher(vim_like()); + assert!(!state.cancel()); + + press(&d, &mut state, "g"); + assert_eq!(state.pending(), &[ks("g")]); + assert!(state.cancel()); + assert!(state.pending().is_empty()); + + press(&d, &mut state, "d g"); + assert!(state.cancel()); + assert!(state.pending().is_empty()); + assert_eq!(state.operator(), None); + assert_eq!(state.mode().as_str(), "normal"); + assert!(!state.cancel()); + + // The next key resolves from scratch. + assert_eq!( + press(&d, &mut state, "g g"), + [Resolution::Pending, runs("vim.top")] + ); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn switching_keymaps_drops_a_pending_chord() { + let (mut d, mut state) = dispatcher(one_mode(vec![run("cmd-k cmd-s", "a.save_all")])); + assert_eq!(press(&d, &mut state, "cmd-k"), [Resolution::Pending]); + + let old = d.set_keymap(keymap( + "other", + &[("default", Typing::Insert)], + vec![run("cmd-s", "b.save"), run("cmd-k cmd-s", "b.save_all")], + )); + assert_eq!(old.name(), "one"); + assert_eq!(d.keymap().name(), "other"); + + // `cmd-s` isn't the end of the old chord: it runs alone in the new map. + assert_eq!(press(&d, &mut state, "cmd-s"), [runs("b.save")]); + assert_eq!( + press(&d, &mut state, "cmd-k cmd-s"), + [Resolution::Pending, runs("b.save_all")] + ); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn switching_keymaps_reaches_every_editor() { + let (mut d, mut first) = dispatcher(vim_like()); + let mut second = d.key_state(); + let mut third = d.key_state(); + press(&d, &mut first, "i"); + press(&d, &mut second, "d"); + press(&d, &mut third, "g"); + + d.set_keymap(one_mode(vec![run("cmd-d", "b.d")])); + for state in [&mut first, &mut second, &mut third] { + assert_eq!(press(&d, state, "x cmd-d"), [types("x"), runs("b.d")]); + assert_eq!(state.mode().as_str(), "default"); + assert_eq!(state.operator(), None); + } +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn switching_keeps_a_mode_the_new_keymap_has() { + let (mut d, mut state) = dispatcher(vim_like()); + press(&d, &mut state, "i"); + assert_eq!(state.mode().as_str(), "insert"); + + // Reloading the same keymap keeps insert mode. + d.set_keymap(vim_like()); + d.sync(&mut state); + assert_eq!(state.mode().as_str(), "insert"); + + // VS Code-like has no insert mode: its initial mode. + d.set_keymap(one_mode(Vec::new())); + d.sync(&mut state); + assert_eq!(state.mode().as_str(), "default"); + + // And back: Vim has no default mode, so normal. + d.set_keymap(vim_like()); + d.sync(&mut state); + assert_eq!(state.mode().as_str(), "normal"); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn switching_drops_a_pending_operator_and_its_mode() { + let (mut d, mut state) = dispatcher(vim_like()); + press(&d, &mut state, "d g"); + assert_eq!(state.mode().as_str(), "operator"); + + // The new keymap has an `operator` mode too, but the state first goes + // back to the mode the operator was set in. + d.set_keymap(vim_like()); + d.sync(&mut state); + assert_eq!(state.operator(), None); + assert!(state.pending().is_empty()); + assert_eq!(state.mode().as_str(), "normal"); + assert_eq!(press(&d, &mut state, "w"), [runs("vim.word")]); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn sync_without_a_switch_changes_nothing() { + let (d, mut state) = dispatcher(vim_like()); + press(&d, &mut state, "d g"); + let before = state.clone(); + d.sync(&mut state); + assert_eq!(state, before); + assert_eq!( + press(&d, &mut state, "g"), + [runs_operator("vim.delete", "vim.top")] + ); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn a_state_made_after_a_switch_starts_in_the_new_initial_mode() { + let (mut d, _) = dispatcher(one_mode(Vec::new())); + d.set_keymap(vim_like()); + let state = d.key_state(); + assert_eq!(state.mode().as_str(), "normal"); +} diff --git a/crates/helm-editor/tests/keys.rs b/crates/helm-editor/tests/keys.rs new file mode 100644 index 0000000..c6ce0a6 --- /dev/null +++ b/crates/helm-editor/tests/keys.rs @@ -0,0 +1,280 @@ +//! T-106 acceptance tests: keystrokes in normal form, the key notation, +//! and the text a key event types. + +use helm_editor::{Key, KeyEvent, KeyParseError, KeySequence, Keystroke, Modifiers}; + +/// `Keystroke::parse`, which must accept `text`. +fn ks(text: &str) -> Keystroke { + Keystroke::parse(text).unwrap_or_else(|error| panic!("{text:?}: {error:?}")) +} + +/// Modifiers from flags in the order ctrl, alt, shift, cmd. +fn mods(ctrl: bool, alt: bool, shift: bool, cmd: bool) -> Modifiers { + Modifiers { + ctrl, + alt, + shift, + cmd, + } +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn parses_modifiers_in_any_order() { + let expected = Keystroke::new(Key::Char('k'), mods(true, true, false, true)); + for text in ["ctrl-alt-cmd-k", "cmd-alt-ctrl-k", "alt-cmd-ctrl-k"] { + assert_eq!(ks(text), expected, "{text}"); + } + assert_eq!(ks("cmd-s").key(), Key::Char('s')); + assert_eq!(ks("cmd-s").modifiers(), Modifiers::CMD); + assert_eq!(ks("x").modifiers(), Modifiers::NONE); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn parses_every_key_name() { + let names = [ + ("enter", Key::Enter), + ("tab", Key::Tab), + ("backspace", Key::Backspace), + ("delete", Key::Delete), + ("escape", Key::Escape), + ("space", Key::Char(' ')), + ("left", Key::Left), + ("right", Key::Right), + ("up", Key::Up), + ("down", Key::Down), + ("home", Key::Home), + ("end", Key::End), + ("pageup", Key::PageUp), + ("pagedown", Key::PageDown), + ("f1", Key::F(1)), + ("f12", Key::F(12)), + ("f20", Key::F(20)), + ]; + for (name, key) in names { + assert_eq!(ks(name), Keystroke::new(key, Modifiers::NONE), "{name}"); + } +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn single_chars_are_taken_as_they_are() { + assert_eq!(ks("$").key(), Key::Char('$')); + assert_eq!(ks("-").key(), Key::Char('-')); + assert_eq!(ks("é").key(), Key::Char('é')); + assert_eq!(ks("中").key(), Key::Char('中')); + // The minus key with Cmd: the modifier's dash, then the key. + assert_eq!(ks("cmd--"), Keystroke::new(Key::Char('-'), Modifiers::CMD)); + assert_eq!( + ks("ctrl-alt--"), + Keystroke::new(Key::Char('-'), mods(true, true, false, false)) + ); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn shift_folds_into_letters() { + let upper = Keystroke::new(Key::Char('A'), Modifiers::NONE); + assert_eq!(Keystroke::new(Key::Char('a'), Modifiers::SHIFT), upper); + assert_eq!(Keystroke::new(Key::Char('A'), Modifiers::SHIFT), upper); + assert_eq!(ks("shift-a"), upper); + assert_eq!(ks("shift-A"), upper); + assert_eq!(ks("A"), upper); + assert!(!upper.modifiers().shift); + + // With other modifiers too. + let cmd_p = Keystroke::new(Key::Char('P'), Modifiers::CMD); + assert_eq!(ks("cmd-shift-p"), cmd_p); + assert_eq!(ks("cmd-P"), cmd_p); + assert_eq!( + Keystroke::new(Key::Char('p'), mods(false, false, true, true)), + cmd_p + ); + + // Lowercase and uppercase stay different keystrokes. + assert_ne!(ks("a"), ks("A")); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn shift_is_dropped_from_other_chars_and_kept_on_named_keys() { + // helm-ui has already shifted the char. + assert_eq!( + Keystroke::new(Key::Char('$'), Modifiers::SHIFT), + Keystroke::new(Key::Char('$'), Modifiers::NONE) + ); + // Not an ASCII letter, so the char is kept as given. + assert_eq!( + Keystroke::new(Key::Char('é'), Modifiers::SHIFT), + Keystroke::new(Key::Char('é'), Modifiers::NONE) + ); + + let shift_tab = ks("shift-tab"); + assert_eq!(shift_tab.key(), Key::Tab); + assert!(shift_tab.modifiers().shift); + assert_ne!(shift_tab, ks("tab")); + assert_ne!(ks("shift-left"), ks("left")); + assert_eq!( + ks("cmd-shift-left"), + Keystroke::new(Key::Left, mods(false, false, true, true)) + ); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn rejects_bad_keystrokes() { + let cases = [ + ("cmd-", "missing"), + ("ctrl-alt-", "missing"), + ("cmd-cmd-k", "repeated"), + ("shift-ctrl-shift-x", "repeated"), + ("cmd-foo", "unknown"), + ("Enter", "unknown"), + ("Cmd-k", "unknown"), + ("f0", "unknown"), + ("f21", "unknown"), + ("ab", "unknown"), + ("\u{7}", "unknown"), + ("shift-4", "shifted"), + ("shift-$", "shifted"), + ("shift-space", "shifted"), + ("shift-é", "shifted"), + ]; + for (text, kind) in cases { + let keystroke = text.to_owned(); + let expected = match kind { + "missing" => KeyParseError::MissingKey { keystroke }, + "repeated" => KeyParseError::RepeatedModifier { keystroke }, + "unknown" => KeyParseError::UnknownKey { keystroke }, + _ => KeyParseError::ShiftedChar { keystroke }, + }; + assert_eq!(Keystroke::parse(text), Err(expected), "{text:?}"); + } + assert_eq!(Keystroke::parse(""), Err(KeyParseError::Empty)); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn display_writes_canonical_notation() { + let cases = [ + ("cmd-alt-ctrl-k", "ctrl-alt-cmd-k"), + ("cmd-shift-p", "cmd-P"), + ("shift-a", "A"), + ("shift-cmd-tab", "shift-cmd-tab"), + ("space", "space"), + ("ctrl-space", "ctrl-space"), + ("cmd--", "cmd--"), + ("f12", "f12"), + ("pagedown", "pagedown"), + ("中", "中"), + ]; + for (text, written) in cases { + assert_eq!(ks(text).to_string(), written, "{text}"); + } +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn display_round_trips_every_keystroke() { + let keys = [ + Key::Char('a'), + Key::Char('Z'), + Key::Char(' '), + Key::Char('-'), + Key::Char('$'), + Key::Char('😀'), + Key::Enter, + Key::Tab, + Key::Backspace, + Key::Delete, + Key::Escape, + Key::Left, + Key::Right, + Key::Up, + Key::Down, + Key::Home, + Key::End, + Key::PageUp, + Key::PageDown, + Key::F(1), + Key::F(20), + ]; + for key in keys { + for bits in 0..16u8 { + let modifiers = mods(bits & 1 != 0, bits & 2 != 0, bits & 4 != 0, bits & 8 != 0); + let keystroke = Keystroke::new(key, modifiers); + let written = keystroke.to_string(); + assert_eq!(Keystroke::parse(&written), Ok(keystroke), "{written:?}"); + } + } +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn sequences_split_on_whitespace() { + let chord = KeySequence::parse("cmd-k cmd-s").unwrap(); + assert_eq!(chord.as_slice(), &[ks("cmd-k"), ks("cmd-s")]); + assert_eq!(chord.to_string(), "cmd-k cmd-s"); + + let spaced = KeySequence::parse(" g \t g\n").unwrap(); + assert_eq!(spaced.as_slice(), &[ks("g"), ks("g")]); + assert_eq!(spaced.to_string(), "g g"); + + assert_eq!(KeySequence::from(ks("x")).as_slice(), &[ks("x")]); + assert_eq!( + KeySequence::new(vec![ks("a"), ks("b")]), + Some(KeySequence::parse("a b").unwrap()) + ); + assert_eq!(KeySequence::new(Vec::new()), None); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn sequences_reject_empty_text_and_bad_keystrokes() { + assert_eq!(KeySequence::parse(""), Err(KeyParseError::Empty)); + assert_eq!(KeySequence::parse(" \t "), Err(KeyParseError::Empty)); + assert_eq!( + KeySequence::parse("cmd-k nope"), + Err(KeyParseError::UnknownKey { + keystroke: "nope".to_owned() + }) + ); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn plain_keys_type_their_char() { + let typed = |text: &str| KeyEvent::parse(text).unwrap().text; + assert_eq!(typed("a"), Some("a".to_owned())); + assert_eq!(typed("A"), Some("A".to_owned())); + assert_eq!(typed("shift-a"), Some("A".to_owned())); + assert_eq!(typed("space"), Some(" ".to_owned())); + assert_eq!(typed("😀"), Some("😀".to_owned())); + + for text in [ + "ctrl-a", + "alt-a", + "cmd-a", + "enter", + "tab", + "shift-tab", + "escape", + "f1", + ] { + assert_eq!(typed(text), None, "{text}"); + } + + let event = KeyEvent::new(ks("x")); + assert_eq!(event.keystroke, ks("x")); + assert_eq!(event.text.as_deref(), Some("x")); +} + +#[test] +#[ignore = "T-106: not implemented yet"] +fn with_text_overrides_the_text() { + // Option+A on macOS: the keystroke alt-a, typing å. + let event = KeyEvent::with_text(ks("alt-a"), "å"); + assert_eq!(event.keystroke, ks("alt-a")); + assert_eq!(event.text.as_deref(), Some("å")); +} diff --git a/tasks/106-commands-keymap.md b/tasks/106-commands-keymap.md new file mode 100644 index 0000000..587157e --- /dev/null +++ b/tasks/106-commands-keymap.md @@ -0,0 +1,246 @@ +# T-106: Command registry and a keymap engine with modes + +After: T-105. This task waits until that one is merged. + +## Goal + +`helm-editor` gets a command registry, where every command has a stable string id and runs against an `Editor` and its `BufferSet`, and a keymap engine that turns headless key events into commands. A keymap is data: modes, plus bindings from keys and chords to command ids, scoped by mode and an optional context. VS Code (T-107) and Vim (T-108) will each be one keymap. Switching the active keymap takes effect in every open editor on its next key, with no restart (ED-6). + +PRD: ED-4, ED-6 + +## Crates and public interface + +Crates: `helm-editor` (the implementation) and `helm-bench` (the new benchmark file and its `[[bench]]` entry only). `helm-bench` already depends on `helm-editor`. + +The interface is already in four new files in `crates/helm-editor/src/` (`key.rs`, `command.rs`, `keymap.rs`, `dispatch.rs`) plus `editor_commands.rs`, with doc comments. `CommandRegistry::commands` and the `editor_commands` constants already work. Everything else has a `todo!()` body. Fill them in. Don't change a signature, a type or a doc comment's contract. If one looks wrong, say so in the PR description. Private fields and the private helpers (`parse_key`, `is_valid_id`, `register_all`, `insert`) are placeholders, so replace them freely. Each new file starts with `#![expect(unused_variables, dead_code, ...)]`. Delete it once that file's stubs are gone, or clippy with `-D warnings` fails. + +Don't touch `crates/helm-text`, since T-110 is being written there. Don't change `editor.rs`, `selection.rs` or `error.rs`. This task needs nothing new from them. + +```rust +// lib.rs +pub mod editor_commands; +pub use command::{Command, CommandArgs, CommandContext, CommandError, CommandFn, CommandId, + CommandRegistry, Invocation, RegisterError}; +pub use dispatch::{Dispatcher, KeyState, Resolution}; +pub use key::{Key, KeyEvent, KeyParseError, KeySequence, Keystroke, Modifiers}; +pub use keymap::{Action, Binding, Keymap, KeymapError, Mode, ModeId, Typing}; + +// key.rs +pub enum Key { Char(char), Enter, Tab, Backspace, Delete, Escape, Left, Right, Up, Down, + Home, End, PageUp, PageDown, F(u8) } +pub struct Modifiers { pub ctrl: bool, pub alt: bool, pub shift: bool, pub cmd: bool } +impl Modifiers { pub const NONE, CTRL, ALT, SHIFT, CMD: Self; } + +pub struct Keystroke { /* private: key, modifiers, in normal form */ } +impl Keystroke { + pub fn new(key: Key, modifiers: Modifiers) -> Self; + pub fn key(&self) -> Key; + pub fn modifiers(&self) -> Modifiers; + pub fn parse(text: &str) -> Result; +} +impl Display for Keystroke; + +pub struct KeySequence { /* private, never empty */ } +impl KeySequence { + pub fn new(keys: Vec) -> Option; + pub fn parse(text: &str) -> Result; + pub fn as_slice(&self) -> &[Keystroke]; +} +impl From for KeySequence; +impl Display for KeySequence; + +pub struct KeyEvent { pub keystroke: Keystroke, pub text: Option } +impl KeyEvent { + pub fn new(keystroke: Keystroke) -> Self; + pub fn with_text(keystroke: Keystroke, text: impl Into) -> Self; + pub fn parse(text: &str) -> Result; +} +pub enum KeyParseError { Empty, MissingKey { keystroke: String }, RepeatedModifier { keystroke: String }, + UnknownKey { keystroke: String }, ShiftedChar { keystroke: String } } + +// command.rs +pub struct CommandId(Arc); // private field +impl CommandId { pub fn as_str(&self) -> &str; } +impl From<&str>, From, Display for CommandId; + +pub struct CommandArgs { pub text: Option, pub motion: Option } +pub struct Invocation { pub command: CommandId, pub args: CommandArgs } +impl Invocation { + pub fn new(command: impl Into) -> Self; + pub fn insert(text: impl Into) -> Self; +} +pub struct CommandContext<'a> { pub editor: &'a mut Editor, pub set: &'a mut BufferSet, pub args: &'a CommandArgs } +pub type CommandFn = fn(CommandContext<'_>) -> Result<(), CommandError>; +pub struct Command { pub id: CommandId, pub title: &'static str, pub run: CommandFn } + +pub struct CommandRegistry { /* private */ } +impl CommandRegistry { + pub fn new() -> Self; + pub fn with_editor_commands() -> Self; + pub fn register(&mut self, command: Command) -> Result<(), RegisterError>; + pub fn get(&self, id: &str) -> Option<&Command>; + pub fn commands(&self) -> impl Iterator; + pub fn run(&self, invocation: &Invocation, editor: &mut Editor, set: &mut BufferSet) -> Result<(), CommandError>; +} +pub enum RegisterError { InvalidId { id: CommandId }, Duplicate { id: CommandId } } +pub enum CommandError { UnknownCommand { id: CommandId }, MissingText { id: CommandId }, Editor(EditorError) } +impl From for CommandError; + +// editor_commands.rs +pub const INSERT, NEWLINE, DELETE_BACKWARD, DELETE_FORWARD, ADD_NEXT_MATCH, SELECT_ALL_MATCHES, + ADD_CURSOR_ABOVE, ADD_CURSOR_BELOW, UNDO, REDO: &str; // "editor.insert", ... +pub const ALL: [&str; 10]; // sorted + +// keymap.rs +pub struct ModeId(Arc); // private field; as_str, From<&str>, From, Display +pub enum Typing { Insert, Ignore } +pub struct Mode { pub id: ModeId, pub typing: Typing } +impl Mode { pub fn new(id: impl Into, typing: Typing) -> Self; } +pub enum Action { Run(CommandId), Operator(CommandId) } +pub struct Binding { + pub keys: KeySequence, + pub mode: Option, + pub context: Option, + pub action: Option, + pub then_mode: Option, +} +impl Binding { + pub fn run(keys: &str, command: impl Into) -> Result; + pub fn operator(keys: &str, command: impl Into) -> Result; + pub fn switch_mode(keys: &str, mode: impl Into) -> Result; + pub fn in_mode(self, mode: impl Into) -> Self; + pub fn when(self, context: impl Into) -> Self; + pub fn then_mode(self, mode: impl Into) -> Self; +} +pub struct Keymap { /* private */ } +impl Keymap { + pub fn new(name: impl Into, modes: Vec) -> Result; + pub fn name(&self) -> &str; + pub fn modes(&self) -> &[Mode]; + pub fn mode(&self, id: &str) -> Option<&Mode>; + pub fn initial_mode(&self) -> &ModeId; + pub fn bind(&mut self, binding: Binding) -> Result<(), KeymapError>; + pub fn bindings(&self) -> &[Binding]; +} +pub enum KeymapError { NoModes, DuplicateMode { mode: ModeId }, UnknownMode { mode: ModeId } } + +// dispatch.rs +pub struct KeyState { /* private: mode, pending keys, pending operator, keymap generation */ } +impl KeyState { + pub fn mode(&self) -> &ModeId; + pub fn pending(&self) -> &[Keystroke]; + pub fn operator(&self) -> Option<&CommandId>; + pub fn cancel(&mut self) -> bool; +} +pub enum Resolution { Pending, Run(Invocation), Done, Unbound, Cancelled } +pub struct Dispatcher { /* private: registry, keymap, generation */ } +impl Dispatcher { + pub fn new(registry: CommandRegistry, keymap: Keymap) -> Self; + pub fn registry(&self) -> &CommandRegistry; + pub fn keymap(&self) -> &Keymap; + pub fn set_keymap(&mut self, keymap: Keymap) -> Keymap; + pub fn key_state(&self) -> KeyState; + pub fn sync(&self, state: &mut KeyState); + pub fn resolve(&self, state: &mut KeyState, event: &KeyEvent, contexts: &[&str]) -> Resolution; + pub fn dispatch(&self, state: &mut KeyState, event: &KeyEvent, contexts: &[&str], + editor: &mut Editor, set: &mut BufferSet) -> Result; +} +``` + +The error types also implement `Display` and `std::error::Error`. + +### How the pieces fit + +`helm-ui` owns one `Dispatcher`, which holds the active keymap and the registry. Each editor view owns an `Editor` and a `KeyState`. A key press goes: + +1. The platform event becomes a `KeyEvent` in `helm-ui` (Phase 2). No GPUI type reaches `helm-editor`. +2. `Dispatcher::resolve(&mut state, &event, contexts)` updates the editor's `KeyState` and returns a `Resolution`. +3. For `Resolution::Run(invocation)`, `helm-ui` runs it. Ids the registry holds go to `CommandRegistry::run`. Ids `helm-ui` owns, such as saving a file, it runs itself. `Dispatcher::dispatch` does steps 2 and 3 for editor commands, which is what the tests use. + +Typing is not a special case. A key no binding matches resolves to `Invocation::insert(text)`, an ordinary run of `editor.insert`, so typed text and bound commands take the same path and the same undo history. + +### Rules the implementation must follow + +The doc comments hold the full rules. In short: + +- **Keystrokes.** `Key::Char` holds the char after Shift and the layout, before Alt, Ctrl and Cmd. Shift is folded into chars. `Keystroke::new` turns an ASCII lowercase letter with Shift into the uppercase letter and drops Shift from every other char. Named keys keep Shift. So `shift-a`, `A` and Shift+A are one keystroke, and `shift-tab` isn't `tab`. +- **Notation.** Modifiers `ctrl-`, `alt-`, `shift-`, `cmd-` in any order, each at most once, then one char or a key name (`enter`, `tab`, `backspace`, `delete`, `escape`, `space`, `left`, `right`, `up`, `down`, `home`, `end`, `pageup`, `pagedown`, `f1` to `f20`). Lowercase only. `cmd--` is Cmd and minus. `shift-` before a char other than an ASCII letter is `ShiftedChar`, because which char Shift makes depends on the layout. `Display` writes `ctrl`, `alt`, `shift`, `cmd` in that order, and parsing its output gives the same keystroke. A sequence is keystrokes separated by whitespace. +- **Event text.** `KeyEvent::new` gives a `Key::Char` with none of Alt, Ctrl or Cmd its char as text. Anything else has no text. `helm-ui` uses `with_text` when the platform reports text the keystroke doesn't give, such as `å` for Option+A. +- **Command ids.** One or more `.`-separated segments of `[a-z0-9_]+`. `register` rejects other ids and duplicates, and adds nothing on an error. `commands()` lists commands sorted by id, which the command palette (ST-3) will use. +- **Editor commands.** `with_editor_commands` registers the ten ids in `editor_commands::ALL`, each running the `Editor` method of the same name, with a non-empty title. `editor.insert` types `args.text` and errors with `MissingText` without it. Running an id the registry doesn't hold is `UnknownCommand`. An `EditorError` comes back as `CommandError::Editor`. +- **Keymaps.** `Keymap::new` needs at least one mode and unique mode ids. The first mode is the initial mode. `bind` rejects a binding whose `mode` or `then_mode` names a mode the keymap lacks. Bindings keep the order they were added in. A binding may name a command id no registry holds. +- **Modes.** A mode is an id and a `Typing`: `Insert` (VS Code's one mode, Vim's insert mode) types unbound keys, `Ignore` (Vim's normal and visual modes) drops them. +- **Resolution.** `Dispatcher::resolve` documents five rules, applied in order: chord prefix, match, broken chord or operator, typing, unbound. A binding applies when its mode is unset or the state's mode, and its context is unset or one of the `contexts` passed in. +- **Which binding wins.** If the keys so far are a strict prefix of any applying binding, the chord waits (`Pending`), even when another applying binding matches them exactly. Among exact matches, a binding with a mode beats one without, then a binding with a context beats one without, then the later binding wins. So a keybindings file loaded after the defaults overrides them. +- **Broken chords.** A key that neither continues nor completes a pending chord cancels it, and that key is dropped too: `Cancelled`, nothing typed. VS Code does the same. Escape cancels any chord it doesn't complete. +- **Pending operators.** `Action::Operator(c)` holds `c` in the `KeyState`, remembers the mode it was set in, applies the binding's `then_mode`, and returns `Pending`. The next binding with `Action::Run(m)` returns `Run` of `c` with `args.motion = Some(m)`, and the mode goes back to the remembered one, then the motion binding's `then_mode` applies. A chord can be the motion. Any key that matches no binding, including Escape, cancels the operator and returns to the remembered mode. So does a second operator, without its `then_mode`, and so does a binding with no action. +- **Cancelling by hand.** `KeyState::cancel` drops the chord and the operator and goes back to the operator's mode. `helm-ui` calls it on focus loss or a click. +- **Typing.** If nothing matched and nothing was pending, a mode whose `Typing` is `Insert` turns an event with non-empty text and neither Ctrl nor Cmd held into `Run(Invocation::insert(text))`. Alt is allowed, so Option-typed characters work. Everything else is `Unbound`. +- **Switching keymaps (ED-6).** `set_keymap` swaps the keymap and bumps a generation number. Each `KeyState` stores the generation it last saw. The next `resolve` or `sync` on a stale state cancels its chord and operator as `cancel` does, keeps its mode if the new keymap has a mode with that id, and otherwise moves to the new initial mode. Then the key resolves in the new keymap as if nothing had been pending. Nothing pending ever runs across a switch. + +### Choices I made, and the ones I rejected + +- **Each editor holds its own `KeyState`, and switching is a generation check.** The alternative was a shared, lock-guarded active keymap that every editor reads. That puts a lock on the keypress path (NF-9 asserts on blocking calls from the UI thread) and still needs a way to reset each editor's pending chord. With a generation number, `set_keymap` is one assignment, no list of open editors is needed, and every editor catches up on its next key. `helm-ui` calls `sync` on each editor when it wants the status bar's mode right before the next key. +- **A chord prefix always waits, with no timeout.** Vim resolves `d` versus `dd` with `timeoutlen`. A timeout needs a clock in `helm-editor` and makes tests depend on time. VS Code waits, and Vim's ambiguous cases are better handled by operators (below). `helm-ui` can still call `cancel` after a delay later if wanted. +- **A broken chord drops the breaking key too.** This is VS Code's behaviour. Resolving the breaking key on its own would be friendlier for typos, but then `cmd-k x` would type `x`, which surprises in a different way. Easy to change later if Khakha prefers. Only `a_broken_chord_drops_every_key` and `a_broken_chord_types_nothing` pin it. +- **Mode beats context beats order.** VS Code has one mode and ranks only by order among bindings whose `when` holds. With modes added, a binding written for one mode should beat a binding for every mode, wherever either was added. Otherwise a global binding loaded late, say a user's `j`, silently takes over Vim's normal-mode `j`. A user who wants that writes the binding for normal mode. Contexts are a single name, not VS Code's `when` expressions. Expressions can come with the keybindings file. +- **Operators are state, not chords.** Binding `d w`, `d e`, `d $` and so on as chords multiplies every operator by every motion, and counts (`d3w`) would multiply again. Holding the operator and passing the motion as an argument keeps the keymap linear. A mode-scoped binding handles `dd` (in the tests, `d` in an `operator` mode runs a `vim.line` motion). What an operator does with its motion is T-108. +- **Keymaps may name unknown commands.** The registry only holds commands that run on an `Editor` and a `BufferSet`. Save, open file and the palette belong to `helm-ui`. Checking ids against the registry at `bind` time would reject every keymap that binds them. +- **Shift folds into chars, so Vim's `$` is written `$`.** Using unshifted keys (`shift-4`) breaks on any layout but US. The cost: VS Code's `cmd+shift+[` is written `cmd-{` here. T-107 should list any binding where that matters. +- **Commands are `fn` pointers, not closures.** They are `Copy`, `Send` and `Sync`, and they hold no state that could drift from the editor. A command that needs more context (the clipboard, for ED-1) can get a new field in `CommandContext`. +- **Commands return `()`.** Nothing reads a richer result yet. The tests check the buffer and the selections. + +### Dependencies + +None. No new dependency, no change to any `Cargo.toml`, and `Cargo.lock` is unchanged. + +## Acceptance tests + +Five new files, committed before the implementation. 63 tests: + +| File | What it proves | +| --- | --- | +| `crates/helm-editor/tests/keys.rs` (12) | Modifiers parse in any order. Every key name, including `space` and `f1` to `f20`. Single chars taken as they are, `cmd--`. Shift folded into ASCII letters, dropped from other chars, kept on named keys. Every `KeyParseError` case, including `Enter`, `Cmd-k`, `f0`, `f21`, a control char, `shift-4` and `shift-space`. `Display` writes the canonical order, and round-trips every key with every modifier combination. Sequences split on any whitespace and reject empty text. The text plain keys type, none for Ctrl, Alt, Cmd and named keys, and `with_text` for Option+A. | +| `crates/helm-editor/tests/commands.rs` (11) | `CommandId` compares as text. `editor_commands::ALL` is the ten documented ids. `with_editor_commands` registers exactly those, sorted, each with a title. `register` accepts and rejects ids by the documented form, and rejects duplicates. Each of the ten commands, run through the registry, does what its `Editor` method does, CRLF included. `insert` without text is `MissingText`. Unknown ids and `EditorError::UnknownBuffer` come back as errors and change nothing. `run` passes `args.motion` through. The `Invocation` constructors. | +| `crates/helm-editor/tests/keymaps.rs` (25) | `Keymap::new` and `bind` errors, order and accessors. The `Binding` builders. A new state is idle in the initial mode. Single keys, two- and three-key chords, a broken chord dropping every key, Escape cancelling, a prefix beating an exact match. A 13-row precedence table over mode, context and order. Contexts scoping chords. Unbound keys typing in an `Insert` mode, and not with Ctrl or Cmd, with empty text, or in an `Ignore` mode. Mode switches with and without a command. Operators: waiting, running with a motion, `d d` through a mode-scoped binding, a chord as the motion, cancelling by an unbound key, Escape, a broken chord or a second operator, returning to the mode they were set in, and a no-action binding dropping one. `KeyState::cancel`. ED-6: a switch drops a pending chord and the next key resolves in the new keymap, reaches three editors' states at once, keeps a mode the new keymap has, drops a pending operator and its mode, does nothing on `sync` without a switch, and a state made after a switch starts in the new initial mode. | +| `crates/helm-editor/tests/dispatch.rs` (11) | Typed keys dispatched into an `Editor` through a VS Code-like sample keymap: typing ASCII, é, 中 and 😀, Enter in a CRLF file, Backspace and Delete, `cmd-z`, the `cmd-k cmd-z` chord and redo, select all matches then typing at three cursors and undoing, adding cursors with keys, a broken chord typing nothing, Option text. A Vim-like sample: `x`, `X`, `u`, `ctrl-r`, `i`, Escape and `o` editing the buffer. Switching keymaps mid-session, and two editors with their own states sharing one dispatcher through a switch. An unknown command errors and edits nothing. `dispatch` returns the resolution. | +| `crates/helm-editor/tests/keymap_properties.rs` (4) | proptest, 256 cases per property, over keymaps built from a 10-keystroke alphabet. (1) In a prefix-free keymap of up to 40 random sequences of one to three keys, typing whole bindings in any order gives `Pending` for every key but the last, then `Run` of that binding's command, with nothing left pending. (2) Up to 9 bindings on one key, each with a random mode and context, the state in a random mode and random contexts held: the winner matches an oracle that ranks mode, then context, then order, or `Unbound` if none applies. (3) Random keymaps with runs, operators, empty bindings and mode switches: after any key history and a switch to a keymap with different mode ids, the state resolves every later key exactly as a new state does, with the same mode, pending keys and operator, whether or not `sync` ran first. (4) Any non-control chars typed one key each into a random text at a random cursor give the text with them inserted there, and the cursor after them. | + +Each test carries `#[ignore = "T-106: not implemented yet"]` so CI stays green until the implementation lands. Against the stubs, `cargo test -p helm-editor --no-fail-fast -- --ignored` fails all 63 (12 + 11 + 25 + 11 + 4). + +**The rule for the implementer: the only allowed change to these five files is deleting the `#[ignore = "T-106: not implemented yet"]` lines.** Nothing else in them changes. That means no edited assertion, no new `#[ignore]`, and no change to a case count. Khakha checks the diff of these files before adding the `approved-protected` label, which the protected-paths check needs for any edit under `crates/*/tests/`. If a test looks wrong, say so in the PR description instead of changing it. + +## Performance budget + +NF-1 gives a keypress 5 ms from input to pixel, and resolving the key is the first step. Add a new file, `crates/helm-bench/benches/keymap.rs`, with a `[[bench]] name = "keymap"`, `harness = false` entry in `crates/helm-bench/Cargo.toml`. It holds one criterion benchmark in a group named `keymap`: + +| Benchmark id | What one iteration does | +| --- | --- | +| `keymap/resolve_10k_keys_300_bindings` | Resolves 10,000 key events with `Dispatcher::resolve` against a keymap of 300 bindings, using one `KeyState` and the contexts `["editor"]`. | + +The keymap has two modes, `insert` (`Typing::Insert`, the initial mode) and `normal` (`Typing::Ignore`), and these 300 bindings, each running a distinct command id: + +- 144 single keystrokes: each of `a` to `z` and `0` to `9` with each of `ctrl-`, `alt-`, `ctrl-alt-` and `ctrl-cmd-`. +- 72 chords: `cmd-k` and then `cmd-j`, each followed by each of `a` to `z` and `0` to `9`. +- 36 bindings in `normal` mode: each of `a` to `z` and `0` to `9`, plain. +- 48 bindings in `insert` mode with context `editor`: `enter`, `tab`, `backspace`, `delete`, `escape`, `left`, `right`, `up`, `down`, `home`, `end` and `pageup`, each plain, with `shift-`, with `alt-` and with `cmd-`. + +The 10,000 events are a fixed list of 100 events repeated 100 times: 70 plain chars that type (cycling through `a` to `z`, `0` to `9` and space), 10 bound single keystrokes, 5 chords (10 events), and 10 bound named keys. Build the keymap, the dispatcher, the state and the event list once, outside the timed code. Every chord in the list completes, so each iteration ends with nothing pending and starts from the same state. Pass each resolution through `black_box`. + +Budget: the default 5% from `budgets.toml`. Add no per-benchmark override. The base commit has no `keymap` benchmark, so `bench-compare` reports it as head-only in this task's PR and gates it from the next PR on. No absolute limit is gated, but a resolution should take well under a microsecond. Index the bindings (by mode, and by first keystroke or as a trie) rather than scanning all 300 per key, and say in the PR what the benchmark measured. + +As in T-101 to T-105, the implementation PR adds the benchmark file. This spec PR doesn't. + +## Out of scope + +- The VS Code bindings: T-107. The tests' sample keymaps hold only a few bindings each. +- Vim's real modes, motions, operators and what an operator does with its motion: T-108. Counts (`3dw`), which T-108 adds to `KeyState` and `CommandArgs`. Text objects, registers, `.` repeat and `:w` `:q` `:e`: T-109. +- Cursor movement commands. `Editor` has none yet, so the registry has none. T-107 and T-108 add the movement commands their bindings need. +- An operator that ends in a different mode, such as Vim's `c` ending in insert mode: T-108, which may let a command ask for a mode switch. +- Chord timeouts, like Vim's `timeoutlen`. +- Loading keymaps and the keybindings file from TOML, reloading them on save, and the settings toggle between VS Code and Vim (ST-1): later tasks. This task gives them `Dispatcher::set_keymap`. +- `when`-clause expressions (`&&`, `!`, comparisons). A binding has at most one context name. +- Converting GPUI key events to `KeyEvent`, and IME composition (ED-8): `helm-ui`, Phase 2. Composed text runs `Invocation::insert` directly. +- Physical key codes and layout-independent bindings. +- The command palette UI and showing each command's binding (ST-3): later. `CommandRegistry::commands` and `Keymap::bindings` are what it will read. +- Commands outside the editor, such as save or open: `helm-ui`.