From 714f1ff4de8a7e74c0f47bf1ab635fc207f5b4d9 Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Mon, 5 Oct 2026 16:21:01 +0200 Subject: [PATCH] feat: expose typed action and mode configuration values --- README.md | 30 +++++++++--------- examples/config.rb | 4 +-- notes.txt | 7 +++++ src/ruby_host.rb | 51 +++++++++++++++++++++++++++--- src/session.rs | 78 +++++++++++++++++++++++++++++++--------------- 5 files changed, 125 insertions(+), 45 deletions(-) create mode 100644 notes.txt diff --git a/README.md b/README.md index b218f97..54ea7f5 100644 --- a/README.md +++ b/README.md @@ -59,13 +59,15 @@ By default, vamp loads `$XDG_CONFIG_HOME/vamp/config.rb`, or `~/.config/vamp/con Vamp.configure do |config| config.plugin "plugins/greeting.rb" config.bind "g", "greet" - config.bind "ctrl+r", "core.redo" - config.bind "shift+j", "core.join_lines" - config.bind "ctrl+c", "core.normal", mode: "insert" + config.bind "ctrl+r", config.core.redo + config.bind "shift+j", config.core.join_lines + config.bind "ctrl+c", config.core.normal, mode: config.modes.insert end ``` -Plugin paths resolve relative to the config file, not the shell's working directory. `config.bind key, target, mode: "normal"` overrides one key in `normal`, `insert`, or `command` mode; the mode defaults to `normal`. Targets are the built-in actions below or exact registered Ex/plugin command names (`"w"`, `"greet"`, etc., without `:` or arguments). Ex/plugin commands can only be bound in Normal mode. +Plugin paths resolve relative to the config file, not the shell's working directory. `config.bind key, target, mode: config.modes.normal` overrides one key; available modes are `config.modes.normal`, `config.modes.insert`, and `config.modes.command`. Omitting `mode` selects Normal. + +`config.core` exposes the built-in actions below as immutable `Action` values; `config.modes` exposes immutable `Mode` values. Misspelled methods fail during configuration evaluation, and `bind` rejects the wrong value type. Built-in action strings and mode strings are not accepted. Registered Ex/plugin commands still use exact string names (`"w"`, `"greet"`, etc., without `:` or arguments) and can only be bound in Normal mode. Ruby provides runtime checks, not compile-time checking; the editor still validates action/mode compatibility. Key notation follows [Herdr's readable style](https://herdr.dev/docs/configuration/#keybindings). Literal non-control Unicode characters are case-sensitive (`n`, `J`, `+`); named keys and modifiers are case-insensitive: @@ -75,27 +77,27 @@ Key notation follows [Herdr's readable style](https://herdr.dev/docs/configurati Terminals share `ctrl+h` with `backspace`, `ctrl+i` with `tab`, and `ctrl+j`/`ctrl+m` with `enter`; these names address the same binding. Only a single modifier on an ASCII letter is supported. Key sequences, prefix mode, Alt/Cmd/Super, combined modifiers, modified special keys, and Vim-style angle-bracket notation are rejected rather than silently treated as another key. -| Available modes | Built-in targets | +| Available modes | Methods on `config.core` | | --- | --- | -| Normal | `core.insert`, `core.insert_first_nonblank`, `core.append`, `core.append_line_end`, `core.open_below`, `core.open_above` | -| Normal | `core.join_lines`, `core.undo`, `core.redo`, `core.command` | -| Normal, Insert | `core.left`, `core.down`, `core.up`, `core.right`, `core.line_start`, `core.line_end`, `core.delete` | -| Insert, Command | `core.normal`, `core.backspace` | -| Insert | `core.newline` | -| Command | `core.submit` | +| Normal | `insert`, `insert_first_nonblank`, `append`, `append_line_end`, `open_below`, `open_above` | +| Normal | `join_lines`, `undo`, `redo`, `command` | +| Normal, Insert | `left`, `down`, `up`, `right`, `line_start`, `line_end`, `delete` | +| Insert, Command | `normal`, `backspace` | +| Insert | `newline` | +| Command | `submit` | Adding a binding keeps the original key. Pass `nil` to disable a key, including literal typing in Insert/Command mode: ```ruby Vamp.configure do |config| - config.bind "n", "core.insert" + config.bind "n", config.core.insert config.bind "i", nil - config.bind "space", "core.command" + config.bind "space", config.core.command config.bind ":", nil end ``` -The last valid declaration for a key/mode wins. Invalid keys, modes, or targets leave that binding unchanged and appear in the status message; other valid bindings still load. Ruby syntax/type errors disable the entire configuration and fall back to defaults. One plugin's load failure does not prevent other plugins loading. Disabling a plugin removes its bindings, without restoring any default keys they replaced. +The last valid declaration for a key/mode wins. Invalid keys, incompatible action/mode pairs, or unknown command names leave that binding unchanged and appear in the status message; other valid bindings still load. Ruby syntax errors, unknown methods, and type errors disable the entire configuration and fall back to defaults. One plugin's load failure does not prevent other plugins loading. Disabling a plugin removes its bindings, without restoring any default keys they replaced. Bindings are resolved inside the editor module, not the terminal adapter. Bracketed paste remains literal and bypasses keybindings. If configuration disables your keys for leaving Insert/Command mode or entering commands, restart with `--no-config` to restore defaults. diff --git a/examples/config.rb b/examples/config.rb index 4fabda6..b54c4f9 100644 --- a/examples/config.rb +++ b/examples/config.rb @@ -1,6 +1,6 @@ Vamp.configure do |config| config.plugin "plugins/greeting.rb" config.bind "g", "greet" - config.bind "ctrl+r", "core.redo" - config.bind "ctrl+c", "core.normal", mode: "insert" + config.bind "ctrl+r", config.core.redo + config.bind "ctrl+c", config.core.normal, mode: config.modes.insert end diff --git a/notes.txt b/notes.txt new file mode 100644 index 0000000..24a3729 --- /dev/null +++ b/notes.txt @@ -0,0 +1,7 @@ +sjdalkjdas dsasda sdklam dalkmsd adadkajsdlakj +o +aaklsjdad + +ads +dasdkaljdalkjsda +sdalksdjaldskjadkjsla \ No newline at end of file diff --git a/src/ruby_host.rb b/src/ruby_host.rb index 12533f0..aef29dc 100644 --- a/src/ruby_host.rb +++ b/src/ruby_host.rb @@ -18,6 +18,30 @@ module Vamp value.dup.force_encoding(Encoding::UTF_8) end + Action = Struct.new(:name) + Mode = Struct.new(:name) + + module Core + %w[ + insert insert_first_nonblank append append_line_end open_below open_above + left right down up join_lines line_start line_end delete undo redo command + normal newline backspace submit + ].each do |name| + action = Action.new("core.#{name}".freeze).freeze + define_singleton_method(name) { action } + end + end + + module Modes + %w[normal insert command].each do |name| + mode = Mode.new(name.freeze).freeze + define_singleton_method(name) { mode } + end + end + + Action.private_class_method :new + Mode.private_class_method :new + class Config def initialize @plugins = [] @@ -31,13 +55,32 @@ module Vamp nil end - def bind(key, command, mode: "normal") + def core + Core + end + + def modes + Modes + end + + def bind(key, command, mode: Modes.normal) key = Vamp.string!(key, "binding key") - mode = Vamp.string!(mode, "binding mode") - command = Vamp.string!(command, "binding command") unless command.nil? + raise TypeError, "binding mode must be a value from config.modes" unless mode.is_a?(Mode) + command = case command + when Action + command.name + when String + name = Vamp.string!(command, "binding command") + raise TypeError, "use config.core for built-in actions" if name.start_with?("core.") + name + when nil + nil + else + raise TypeError, "binding command must be an Action, String, or nil" + end raise ArgumentError, "binding key must not be empty" if key.empty? raise ArgumentError, "binding command must not be empty" if command == "" - @bindings << { "mode" => mode, "key" => key, "command" => command } + @bindings << { "mode" => mode.name, "key" => key, "command" => command } nil end diff --git a/src/session.rs b/src/session.rs index 07d29fc..36a9e5b 100644 --- a/src/session.rs +++ b/src/session.rs @@ -329,37 +329,27 @@ end r#" Vamp.configure do |config| config.plugin "healthy.rb" - config.bind "n", "core.insert_first_nonblank" + config.bind "n", config.core.insert_first_nonblank config.bind "i", nil - config.bind "ctrl+r", "core.redo" - config.bind "ctrl+c", "core.normal", mode: "insert" - config.bind "esc", nil, mode: "insert" - config.bind "!", "core.newline", mode: "insert" - config.bind "shift+x", nil, mode: "insert" + config.bind "ctrl+r", config.core.redo + config.bind "ctrl+c", config.core.normal, mode: config.modes.insert + config.bind "esc", nil, mode: config.modes.insert + config.bind "!", config.core.newline, mode: config.modes.insert + config.bind "shift+x", nil, mode: config.modes.insert config.bind ":", nil - config.bind "space", "core.command" - config.bind "enter", nil, mode: "command" - config.bind "ctrl+x", "core.submit", mode: "command" - config.bind "ctrl+c", "core.normal", mode: "command" - config.bind "ctrl+h", "core.backspace", mode: "command" - config.bind "g", "core.undo" + config.bind "space", config.core.command + config.bind "enter", nil, mode: config.modes.command + config.bind "ctrl+x", config.core.submit, mode: config.modes.command + config.bind "ctrl+c", config.core.normal, mode: config.modes.command + config.bind "ctrl+h", config.core.backspace, mode: config.modes.command + config.bind "g", config.core.undo config.bind "g", "healthy" - config.bind "n", "core.missing" - config.bind "n", "core.undo", mode: "missing" - config.bind "unknown", "core.undo" - config.bind "ctrl+f", "healthy", mode: "insert" + config.bind "n", config.core.newline + config.bind "unknown", config.core.undo + config.bind "ctrl+f", "healthy", mode: config.modes.insert end "#, ); - let message = session.editor().message(); - for error in [ - "Unknown action for NORMAL: core.missing", - "Unknown binding mode: missing", - "Invalid binding key", - "Ex/plugin commands can only be bound in Normal mode", - ] { - assert!(message.contains(error), "{message}"); - } session.handle(Input::Char('i')); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); session.handle(Input::Char('n')); @@ -414,6 +404,44 @@ end assert!(session.editor().should_quit()); } + #[test] + fn typed_config_errors_discard_partial_bindings_and_leave_default_editing_available() { + let fixture = Fixture::new(); + for (expression, error) in [ + ("config.core.reod", "NoMethodError"), + ("config.modes.insret", "NoMethodError"), + (r#"config.bind "n", "core.insert""#, "TypeError"), + (r#"config.bind "n", nil, mode: "insert""#, "TypeError"), + (r#"config.bind "n", config.modes.insert"#, "TypeError"), + ( + r#"config.bind "n", nil, mode: config.core.insert"#, + "TypeError", + ), + (r#"config.core.redo.name = "core.undo""#, "FrozenError"), + ( + r#"config.core.redo.name.replace("core.undo")"#, + "FrozenError", + ), + (r#"config.modes.insert.name = "normal""#, "FrozenError"), + ( + r#"config.modes.insert.name.replace("normal")"#, + "FrozenError", + ), + ] { + let mut session = fixture.open(&format!( + "Vamp.configure do |config|\n config.bind \"i\", nil\n {expression}\nend" + )); + assert!(session.editor().message().contains(error), "{expression}"); + session.handle(Input::Char('i')); + session.handle(Input::Char('界')); + session.handle(Input::Escape); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); + assert_eq!(session.editor().lines(), &["界"], "{expression}"); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &[String::new()], "{expression}"); + } + } + #[test] fn broken_config_falls_back_to_core_without_failing_the_session() { let fixture = Fixture::new();