refactor: derive Ruby action namespaces from Rust catalog
This commit is contained in:
@@ -26,6 +26,7 @@ A named frontend ability required by a feature for compatibility, not a permissi
|
|||||||
## Configuration interfaces
|
## 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.
|
- 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.
|
- 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.
|
- Ruby action/mode objects provide validation during configuration evaluation and discoverable members, not compile-time enum safety.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
`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.
|
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:
|
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.
|
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.
|
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
|
## Current limits
|
||||||
|
|||||||
@@ -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<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
serializer.collect_seq(CORE_ACTIONS.iter().map(|(name, _, _)| *name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Action {
|
impl Action {
|
||||||
fn named(mode: Mode, name: &str) -> Option<Self> {
|
fn named(mode: Mode, name: &str) -> Option<Self> {
|
||||||
CORE_ACTIONS
|
CORE_ACTIONS
|
||||||
|
|||||||
+16
-3
@@ -463,15 +463,26 @@ struct Host {
|
|||||||
|
|
||||||
impl Host {
|
impl Host {
|
||||||
fn spawn(mode: &str, path: &Path, options: &serde_json::Value) -> Result<Self, String> {
|
fn spawn(mode: &str, path: &Path, options: &serde_json::Value) -> Result<Self, String> {
|
||||||
let mut encoded_options = FrameWriter(Vec::new());
|
#[derive(Serialize)]
|
||||||
serde_json::to_writer(&mut encoded_options, options)
|
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}"))?;
|
.map_err(|error| format!("Cannot encode bounded Ruby plugin options: {error}"))?;
|
||||||
let executable = env::var_os("VAMP_RUBY").unwrap_or_else(|| "ruby".into());
|
let executable = env::var_os("VAMP_RUBY").unwrap_or_else(|| "ruby".into());
|
||||||
let mut command = Command::new(&executable);
|
let mut command = Command::new(&executable);
|
||||||
command
|
command
|
||||||
.args(["-e", RUNNER, "--", mode])
|
.args(["-e", RUNNER, "--", mode])
|
||||||
.arg(path)
|
.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"))
|
.current_dir(path.parent().expect("absolute file has a parent"))
|
||||||
.stdin(Stdio::piped())
|
.stdin(Stdio::piped())
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
@@ -879,6 +890,8 @@ end
|
|||||||
"config.core.lsp_diagnostics",
|
"config.core.lsp_diagnostics",
|
||||||
"config.core.lsp.hvoer",
|
"config.core.lsp.hvoer",
|
||||||
"config.core.lsp",
|
"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(
|
let invalid = fixture.file(
|
||||||
"invalid_action.rb",
|
"invalid_action.rb",
|
||||||
|
|||||||
+35
-29
@@ -53,33 +53,35 @@ module Vamp
|
|||||||
Action = Struct.new(:name)
|
Action = Struct.new(:name)
|
||||||
Mode = Struct.new(:name)
|
Mode = Struct.new(:name)
|
||||||
|
|
||||||
module Core
|
Core = Module.new
|
||||||
%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
|
|
||||||
|
|
||||||
module Lsp
|
def self.install_actions(names)
|
||||||
%w[definition diagnostics hover].each do |name|
|
raise TypeError, "core action catalog must be an Array" unless names.is_a?(Array)
|
||||||
action = Action.new("core.lsp.#{name}".freeze).freeze
|
namespaces = [Core]
|
||||||
define_singleton_method(name) { action }
|
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
|
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
|
end
|
||||||
Lsp.freeze
|
namespace = child
|
||||||
|
|
||||||
def self.lsp
|
|
||||||
Lsp
|
|
||||||
end
|
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
|
||||||
|
namespaces.each(&:freeze)
|
||||||
end
|
end
|
||||||
|
|
||||||
module Modes
|
module Modes
|
||||||
@@ -247,8 +249,12 @@ module Vamp
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.setup(mode, options, sender)
|
def self.setup(mode, startup, sender)
|
||||||
raise TypeError, "plugin options must be a Hash" unless options.is_a?(Hash)
|
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
|
@mode = mode
|
||||||
@config = Config.new
|
@config = Config.new
|
||||||
@name = nil
|
@name = nil
|
||||||
@@ -381,10 +387,10 @@ end
|
|||||||
|
|
||||||
request_id = nil
|
request_id = nil
|
||||||
begin
|
begin
|
||||||
mode, path, encoded_options = ARGV
|
mode, path, encoded_startup = ARGV
|
||||||
raise ArgumentError, "expected config or plugin mode, path, and options" unless %w[config plugin].include?(mode) && path && encoded_options
|
raise ArgumentError, "expected config or plugin mode, path, and startup data" unless %w[config plugin].include?(mode) && path && encoded_startup
|
||||||
raise ArgumentError, "plugin options exceed 4 MiB" if encoded_options.bytesize > Vamp::MAX_FRAME
|
raise ArgumentError, "Ruby startup data exceeds 4 MiB" if encoded_startup.bytesize > Vamp::MAX_FRAME
|
||||||
Vamp.setup(mode, JSON.parse(encoded_options), send_frame)
|
Vamp.setup(mode, JSON.parse(encoded_startup), send_frame)
|
||||||
load path
|
load path
|
||||||
send_frame.call(Vamp.startup)
|
send_frame.call(Vamp.startup)
|
||||||
if mode == "plugin"
|
if mode == "plugin"
|
||||||
|
|||||||
Reference in New Issue
Block a user