From 18dc8cc15565e1d35482e8f19de68619db59f090 Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Fri, 9 Oct 2026 17:07:29 +0200 Subject: [PATCH] refactor: derive Ruby action namespaces from Rust catalog --- CONTEXT.md | 1 + README.md | 4 +++ src/editor.rs | 9 +++++++ src/ruby.rs | 21 ++++++++++++--- src/ruby_host.rb | 66 ++++++++++++++++++++++++++---------------------- 5 files changed, 67 insertions(+), 34 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 9782af8..4a92778 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -26,6 +26,7 @@ A named frontend ability required by a feature for compatibility, not a permissi ## Configuration interfaces - Prefer typed objects for editor-owned actions, modes, and other closed sets. Expose new core binding targets through `config.core` rather than requiring command-name strings. +- `src/editor.rs`'s `CORE_ACTIONS` is authoritative for core action names and eligibility. Ruby action methods and nested namespaces are generated from its startup catalog; add or rename actions there rather than maintaining a second Ruby list. - Use strings for open-ended identifiers: language IDs, paths, and external plugin command names. Interactive Ex command names remain textual. - Ruby action/mode objects provide validation during configuration evaluation and discoverable members, not compile-time enum safety. diff --git a/README.md b/README.md index 43ac564..b996a53 100644 --- a/README.md +++ b/README.md @@ -321,6 +321,8 @@ Plugin paths resolve relative to the config file, not the shell's working direct `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. +Ruby builds `config.core` from the Rust action catalog before loading configuration or plugin scripts. Dotted identifiers create nested namespaces automatically; each namespace and Action value is frozen. Action names are not duplicated in Ruby, so Rust catalog additions/renames immediately change the Ruby interface without code generation or handwritten exports. + Registered command names may contain dot-separated segments, each starting with an ASCII letter followed by ASCII letters/digits/underscores/hyphens, plus one optional trailing `!`. Names cannot contain whitespace, empty segments, or leading/trailing dots. This grammar applies to core and plugin commands alike. 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: @@ -405,6 +407,8 @@ Navigation carries the source `buffer_id`/`revision`, destination `path`/`row`/` Rust and Ruby exchange UTF-8, newline-delimited JSON objects with `version: 1`. Startup returns `manifest` (name, required capabilities, commands, async opt-in) or `config` (plugin paths/options and bindings). `invoke` requests carry an ID, command, arguments, and context; `result` replies carry the matching ID and typed text effects (`insert`, `replace`, `message`). Async hosts also receive `document`/`shutdown` and send `update` frames with optional request IDs and typed presentation/navigation effects. Errors use `error` frames. Unknown fields, wrong versions, and mismatched replies are rejected. Frames and aggregate effect text are bounded to 4 MiB. +Before loading a script, Rust supplies a bounded startup JSON envelope containing `core_actions` and `options`. The action catalog initializes the Ruby interface; only the separate user-options object is exposed through `Vamp.options`, including when user options contain a key named `core_actions`. + JSON uses Ruby's standard library. Protocol Buffers is not installed or required; there is no frontend/network wire protocol yet. The frontend currently calls the Rust library directly. ## Current limits diff --git a/src/editor.rs b/src/editor.rs index e2828b9..45026fc 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -476,6 +476,15 @@ const CORE_ACTIONS: &[(&str, Action, &[Mode])] = &[ ), ]; +/// Serializes the authoritative action names without copying the catalog. +pub(crate) struct CoreActionCatalog; + +impl Serialize for CoreActionCatalog { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_seq(CORE_ACTIONS.iter().map(|(name, _, _)| *name)) + } +} + impl Action { fn named(mode: Mode, name: &str) -> Option { CORE_ACTIONS diff --git a/src/ruby.rs b/src/ruby.rs index bd7360e..12cc31a 100644 --- a/src/ruby.rs +++ b/src/ruby.rs @@ -463,15 +463,26 @@ struct Host { impl Host { fn spawn(mode: &str, path: &Path, options: &serde_json::Value) -> Result { - let mut encoded_options = FrameWriter(Vec::new()); - serde_json::to_writer(&mut encoded_options, options) - .map_err(|error| format!("Cannot encode bounded Ruby plugin options: {error}"))?; + #[derive(Serialize)] + struct Startup<'a> { + core_actions: crate::editor::CoreActionCatalog, + options: &'a serde_json::Value, + } + let mut encoded_startup = FrameWriter(Vec::new()); + serde_json::to_writer( + &mut encoded_startup, + &Startup { + core_actions: crate::editor::CoreActionCatalog, + options, + }, + ) + .map_err(|error| format!("Cannot encode bounded Ruby plugin options: {error}"))?; let executable = env::var_os("VAMP_RUBY").unwrap_or_else(|| "ruby".into()); let mut command = Command::new(&executable); command .args(["-e", RUNNER, "--", mode]) .arg(path) - .arg(String::from_utf8(encoded_options.0).expect("JSON is UTF-8")) + .arg(String::from_utf8(encoded_startup.0).expect("JSON is UTF-8")) .current_dir(path.parent().expect("absolute file has a parent")) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -879,6 +890,8 @@ end "config.core.lsp_diagnostics", "config.core.lsp.hvoer", "config.core.lsp", + "config.core.lsp.hover.tap { |action| action.name = 'core.delete' }", + "config.core.lsp.tap { |namespace| namespace.define_singleton_method(:bogus) { namespace.hover } }.bogus", ] { let invalid = fixture.file( "invalid_action.rb", diff --git a/src/ruby_host.rb b/src/ruby_host.rb index 85bbfc7..06e3c8c 100644 --- a/src/ruby_host.rb +++ b/src/ruby_host.rb @@ -53,33 +53,35 @@ module Vamp 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 palette repeat_last_edit - search search_next search_previous - word_next word_previous word_end big_word_next big_word_previous big_word_end - select copy cut change paste_before paste_after - copy_system paste_system_before paste_system_after - select_lines clone_above clone_below select_next skip_occurrence select_all - file_picker file_explorer - ].each do |name| - action = Action.new("core.#{name}".freeze).freeze - define_singleton_method(name) { action } - end + Core = Module.new - module Lsp - %w[definition diagnostics hover].each do |name| - action = Action.new("core.lsp.#{name}".freeze).freeze - define_singleton_method(name) { action } + def self.install_actions(names) + raise TypeError, "core action catalog must be an Array" unless names.is_a?(Array) + namespaces = [Core] + names.each do |name| + name = string!(name, "core action name") + unless name.match?(/\Acore(?:\.[a-z_][a-z0-9_]*)+\z/) + raise ArgumentError, "invalid core action name: #{name}" end + parts = name.split(".").drop(1) + namespace = Core + parts[0...-1].each do |part| + if namespace.singleton_methods(false).include?(part.to_sym) + child = namespace.public_send(part) + raise ArgumentError, "conflicting core action namespace: #{name}" unless child.is_a?(Module) + else + child = Module.new + namespace.define_singleton_method(part) { child } + namespaces << child + end + namespace = child + end + leaf = parts.last + raise ArgumentError, "conflicting core action name: #{name}" if namespace.singleton_methods(false).include?(leaf.to_sym) + action = Action.send(:new, name.freeze).freeze + namespace.define_singleton_method(leaf) { action } end - Lsp.freeze - - def self.lsp - Lsp - end + namespaces.each(&:freeze) end module Modes @@ -247,8 +249,12 @@ module Vamp end end - def self.setup(mode, options, sender) - raise TypeError, "plugin options must be a Hash" unless options.is_a?(Hash) + def self.setup(mode, startup, sender) + unless startup.is_a?(Hash) && startup.keys.sort == %w[core_actions options] && startup["options"].is_a?(Hash) + raise TypeError, "invalid Ruby startup catalog/options" + end + install_actions(startup["core_actions"]) + options = startup["options"] @mode = mode @config = Config.new @name = nil @@ -381,10 +387,10 @@ end request_id = nil begin - mode, path, encoded_options = ARGV - raise ArgumentError, "expected config or plugin mode, path, and options" unless %w[config plugin].include?(mode) && path && encoded_options - raise ArgumentError, "plugin options exceed 4 MiB" if encoded_options.bytesize > Vamp::MAX_FRAME - Vamp.setup(mode, JSON.parse(encoded_options), send_frame) + mode, path, encoded_startup = ARGV + raise ArgumentError, "expected config or plugin mode, path, and startup data" unless %w[config plugin].include?(mode) && path && encoded_startup + raise ArgumentError, "Ruby startup data exceeds 4 MiB" if encoded_startup.bytesize > Vamp::MAX_FRAME + Vamp.setup(mode, JSON.parse(encoded_startup), send_frame) load path send_frame.call(Vamp.startup) if mode == "plugin"