refactor: align Ruby host with RuboCop while retaining double quotes
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
AllCops:
|
||||||
|
TargetRubyVersion: 2.6
|
||||||
|
NewCops: enable
|
||||||
|
|
||||||
|
Style/StringLiterals:
|
||||||
|
EnforcedStyle: double_quotes
|
||||||
|
|
||||||
|
Style/StringLiteralsInInterpolation:
|
||||||
|
EnforcedStyle: double_quotes
|
||||||
+11
-2
@@ -10,14 +10,23 @@ Local verification and implementation constraints. See [architecture](architectu
|
|||||||
cargo test
|
cargo test
|
||||||
cargo clippy --all-targets -- -D warnings
|
cargo clippy --all-targets -- -D warnings
|
||||||
cargo fmt --check
|
cargo fmt --check
|
||||||
ruby -c src/ruby_host.rb
|
for file in src/ruby_*.rb examples/config.rb examples/plugins/greeting.rb; do
|
||||||
|
ruby -c "$file" || exit
|
||||||
|
done
|
||||||
|
rubocop
|
||||||
```
|
```
|
||||||
|
|
||||||
## Ruby types
|
## Ruby types
|
||||||
|
|
||||||
Use YARD `@param`, `@return`, and `@yieldparam` annotations for Ruby APIs, and `@type` for locals where inference needs help. Examples annotate DSL block parameters before the call, without adding runtime assignments or dependencies. `Vamp::Action` / `Vamp::Mode` field types and the dynamically defined mode methods use Solargraph-supported directives.
|
Use YARD `@param`, `@return`, and `@yieldparam` annotations for Ruby APIs, and `@type` for locals where inference needs help. Examples annotate DSL block parameters before the call, without adding runtime assignments or dependencies. `Vamp::Action` / `Vamp::Mode` field types and the dynamically defined mode methods use Solargraph-supported directives.
|
||||||
|
|
||||||
Core action methods are generated from Rust's authoritative catalog at host startup. Their namespace is annotated, but static Solargraph analysis cannot enumerate those generated members. Do not maintain a second action list in Ruby comments; use the [configuration action reference](configuration.md#action-reference).
|
Core action methods are generated from Rust's authoritative catalog at host startup. Their namespace is annotated, but static Solargraph analysis cannot enumerate those generated members: CLI `typecheck` can misidentify `config.core.select` as inherited `Kernel#select`. Do not maintain a second action list in Ruby comments; use the [configuration action reference](configuration.md#action-reference). Runtime catalog validation remains authoritative.
|
||||||
|
|
||||||
|
## Ruby implementation and style
|
||||||
|
|
||||||
|
Rust embeds the Ruby implementation at compile time: [`ruby_values.rb`](../src/ruby_values.rb) owns value validation and core action/mode objects, [`ruby_config.rb`](../src/ruby_config.rb) owns configuration and editor contexts, [`ruby_plugin.rb`](../src/ruby_plugin.rb) owns registration and callbacks, and [`ruby_host.rb`](../src/ruby_host.rb) owns framed subprocess I/O. Rebuild the editor after changing these files; installed binaries do not load implementation files from the filesystem.
|
||||||
|
|
||||||
|
`.rubocop.yml` uses RuboCop defaults with double-quoted strings (including interpolation) and Ruby 2.6-compatible syntax. Ruby implementation and example files use frozen string literals. Narrow inline style exceptions retain physical `STDIN`/`STDOUT`/`STDERR` capture/redirection and native exception state at the process boundary; plugin output must not corrupt the protocol, and errors including `SystemExit` and `Interrupt` must discard effects and report a failure.
|
||||||
|
|
||||||
## Test organization
|
## Test organization
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ Run `:greet vamp`, or press `g` with the example configuration. [`examples/plugi
|
|||||||
|
|
||||||
Callbacks receive an immutable primary-only context snapshot: `editor.text`, `editor.row`, `editor.column`, `editor.mode`, `editor.path`, `editor.buffer_id`, `editor.revision`, `editor.root`, and `editor.request_id`. Row/column are **one-based grapheme positions**; mode is `NORMAL`; path is a string or `nil`. Arguments are the remaining command text, without surrounding whitespace. Commands run once regardless of target count; successful text effects collapse to the primary before editing once. The plugin API does not expose selections.
|
Callbacks receive an immutable primary-only context snapshot: `editor.text`, `editor.row`, `editor.column`, `editor.mode`, `editor.path`, `editor.buffer_id`, `editor.revision`, `editor.root`, and `editor.request_id`. Row/column are **one-based grapheme positions**; mode is `NORMAL`; path is a string or `nil`. Arguments are the remaining command text, without surrounding whitespace. Commands run once regardless of target count; successful text effects collapse to the primary before editing once. The plugin API does not expose selections.
|
||||||
|
|
||||||
The Ruby host and examples use [YARD types](https://solargraph.org/guides/yard) for Solargraph navigation and hover. `Vamp.command` declares typed block arguments (`Vamp::Editor`, `String`); the example also annotates them explicitly. Context readers, configuration methods, and asynchronous callback/effect contracts are annotated in [`src/ruby_host.rb`](../src/ruby_host.rb). These comments do not enforce types at runtime; existing boundary validation still does.
|
The Ruby host and examples use [YARD types](https://solargraph.org/guides/yard) for Solargraph navigation and hover. `Vamp.command` declares typed block arguments (`Vamp::Editor`, `String`); the example also annotates them explicitly. Context readers and configuration methods are annotated in [`src/ruby_config.rb`](../src/ruby_config.rb); asynchronous callback/effect contracts live in [`src/ruby_plugin.rb`](../src/ruby_plugin.rb). These comments do not enforce types at runtime; existing boundary validation still does.
|
||||||
|
|
||||||
`editor.insert(text)`, `editor.replace(text)`, and `editor.message(text)` queue effects. The context remains the pre-command snapshot even after queuing an effect. All effects are validated before the backend changes anything; buffer changes from one successful invocation form one undo action. Exceptions, process exits, timeouts, malformed replies, and invalid effects disable that plugin and discard its effects. Other plugins and built-ins remain available. Plugin `puts`, explicit `STDOUT.write`, and stderr cannot overwrite the TUI; diagnostic output is discarded, while exceptions appear in the status message.
|
`editor.insert(text)`, `editor.replace(text)`, and `editor.message(text)` queue effects. The context remains the pre-command snapshot even after queuing an effect. All effects are validated before the backend changes anything; buffer changes from one successful invocation form one undo action. Exceptions, process exits, timeouts, malformed replies, and invalid effects disable that plugin and discard its effects. Other plugins and built-ins remain available. Plugin `puts`, explicit `STDOUT.write`, and stderr cannot overwrite the TUI; diagnostic output is discarded, while exceptions appear in the status message.
|
||||||
|
|
||||||
|
|||||||
+2
-6
@@ -1,3 +1,5 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
# @param config [Vamp::Config]
|
# @param config [Vamp::Config]
|
||||||
Vamp.configure do |config|
|
Vamp.configure do |config|
|
||||||
config.leader = "space"
|
config.leader = "space"
|
||||||
@@ -7,12 +9,6 @@ Vamp.configure do |config|
|
|||||||
config.file_picker_view = "tree"
|
config.file_picker_view = "tree"
|
||||||
config.bind "ctrl+j", config.core.down, mode: config.modes.selection
|
config.bind "ctrl+j", config.core.down, mode: config.modes.selection
|
||||||
config.bind "ctrl+k", config.core.up, mode: config.modes.selection
|
config.bind "ctrl+k", config.core.up, mode: config.modes.selection
|
||||||
config.bind "h", config.core.left, mode: config.modes.tree
|
|
||||||
config.bind "j", config.core.down, mode: config.modes.tree
|
|
||||||
config.bind "k", config.core.up, mode: config.modes.tree
|
|
||||||
config.bind "l", config.core.right, mode: config.modes.tree
|
|
||||||
config.bind "ctrl+h", config.core.left, mode: config.modes.tree
|
|
||||||
config.bind "ctrl+l", config.core.right, mode: config.modes.tree
|
|
||||||
config.plugin "plugins/greeting.rb"
|
config.plugin "plugins/greeting.rb"
|
||||||
# Native LSP is disabled by default; install a server and explicitly opt in.
|
# Native LSP is disabled by default; install a server and explicitly opt in.
|
||||||
# config.lsp["rust"] = true # built-in rust-analyzer preset
|
# config.lsp["rust"] = true # built-in rust-analyzer preset
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
Vamp.plugin "greeting", requires: ["text"]
|
Vamp.plugin "greeting", requires: ["text"]
|
||||||
|
|
||||||
# @param editor [Vamp::Editor]
|
# @param editor [Vamp::Editor]
|
||||||
|
|||||||
+39
-2
@@ -10,7 +10,15 @@ use std::thread::{self, JoinHandle};
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use std::{collections::HashSet, env};
|
use std::{collections::HashSet, env};
|
||||||
|
|
||||||
const RUNNER: &str = include_str!("ruby_host.rb");
|
const RUNNER: &str = concat!(
|
||||||
|
include_str!("ruby_values.rb"),
|
||||||
|
"\n",
|
||||||
|
include_str!("ruby_config.rb"),
|
||||||
|
"\n",
|
||||||
|
include_str!("ruby_plugin.rb"),
|
||||||
|
"\n",
|
||||||
|
include_str!("ruby_host.rb"),
|
||||||
|
);
|
||||||
const MAX_FRAME: usize = 4 * 1024 * 1024;
|
const MAX_FRAME: usize = 4 * 1024 * 1024;
|
||||||
const MAX_QUEUE: usize = 64;
|
const MAX_QUEUE: usize = 64;
|
||||||
const STARTUP_TIMEOUT: Duration = Duration::from_secs(2);
|
const STARTUP_TIMEOUT: Duration = Duration::from_secs(2);
|
||||||
@@ -1243,6 +1251,27 @@ end
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nonstandard_callback_exceptions_are_reported_without_effects() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
for (callback, expected) in [
|
||||||
|
("exit 19", "SystemExit: exit"),
|
||||||
|
("raise Interrupt, 'interrupted'", "Interrupt: interrupted"),
|
||||||
|
] {
|
||||||
|
let (mut plugin, _) = fixture.plugin(&format!(
|
||||||
|
"Vamp.plugin 'failure'\nVamp.command('fail') {{ |editor, _| editor.insert 'must not escape'; {callback} }}"
|
||||||
|
));
|
||||||
|
let error = plugin.invoke("fail", "", &context()).unwrap_err();
|
||||||
|
assert!(error.contains(expected), "{error}");
|
||||||
|
assert!(
|
||||||
|
plugin
|
||||||
|
.invoke("fail", "", &context())
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("disabled")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn startup_and_config_hangs_are_bounded() {
|
fn startup_and_config_hangs_are_bounded() {
|
||||||
let fixture = Fixture::new();
|
let fixture = Fixture::new();
|
||||||
@@ -1681,7 +1710,15 @@ Vamp.command('flood') { |_, _| 256.times { Vamp.publish [{type: 'message', text:
|
|||||||
.queue(&serde_json::json!({"type": "shutdown", "version": 1}))
|
.queue(&serde_json::json!({"type": "shutdown", "version": 1}))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let deadline = Instant::now() + STARTUP_TIMEOUT;
|
let deadline = Instant::now() + STARTUP_TIMEOUT;
|
||||||
while !marker.exists() {
|
while plugin
|
||||||
|
.host
|
||||||
|
.child
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.try_wait()
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
assert!(Instant::now() < deadline);
|
assert!(Instant::now() < deadline);
|
||||||
thread::sleep(Duration::from_millis(1));
|
thread::sleep(Duration::from_millis(1));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Vamp
|
||||||
|
# Collects and validates the Ruby startup configuration DSL.
|
||||||
|
class Config
|
||||||
|
# @return [Hash{String => Boolean, Hash{String, Symbol => Object}}] language presets/options
|
||||||
|
attr_reader :lsp
|
||||||
|
|
||||||
|
# @return [void]
|
||||||
|
def initialize
|
||||||
|
@plugins = []
|
||||||
|
@lsp = {}
|
||||||
|
@bindings = []
|
||||||
|
@leader = "space"
|
||||||
|
@file_explorer_side = "right"
|
||||||
|
@file_picker_view = "tree"
|
||||||
|
@file_explorer_view = "tree"
|
||||||
|
@clipboard_mode = "system"
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param path [String] relative to the configuration file
|
||||||
|
# @param options [Hash{String, Symbol => Object}] JSON-compatible plugin options
|
||||||
|
# @return [nil]
|
||||||
|
def plugin(path, options: {})
|
||||||
|
path = Vamp.string!(path, "plugin path")
|
||||||
|
raise ArgumentError, "plugin path must not be empty" if path.empty? || path.include?("\0")
|
||||||
|
raise TypeError, "plugin options must be a Hash" unless options.is_a?(Hash)
|
||||||
|
|
||||||
|
@plugins << { "path" => path, "options" => Vamp.json_value!(options) }
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param key [String]
|
||||||
|
# @return [String]
|
||||||
|
def leader=(key)
|
||||||
|
@leader = nonempty_string!(key, "leader key")
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param side [String]
|
||||||
|
# @return [String]
|
||||||
|
def file_explorer_side=(side)
|
||||||
|
@file_explorer_side = nonempty_string!(side, "file explorer side")
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param view [String]
|
||||||
|
# @return [String]
|
||||||
|
def file_picker_view=(view)
|
||||||
|
@file_picker_view = nonempty_string!(view, "file picker view")
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param view [String]
|
||||||
|
# @return [String]
|
||||||
|
def file_explorer_view=(view)
|
||||||
|
@file_explorer_view = nonempty_string!(view, "file explorer view")
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param mode [String]
|
||||||
|
# @return [String]
|
||||||
|
def clipboard_mode=(mode)
|
||||||
|
@clipboard_mode = nonempty_string!(mode, "clipboard mode")
|
||||||
|
end
|
||||||
|
|
||||||
|
# @return [Module<Vamp::Core>] frozen namespace with catalog-generated Action methods
|
||||||
|
def core
|
||||||
|
Core
|
||||||
|
end
|
||||||
|
|
||||||
|
# @return [Module<Vamp::Modes>] frozen namespace whose methods return Mode values
|
||||||
|
def modes
|
||||||
|
Modes
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param key [String] one key or a leader-prefixed sequence
|
||||||
|
# @param command [Vamp::Action, String, nil] core action, external command, or disabled route
|
||||||
|
# @param mode [Vamp::Mode]
|
||||||
|
# @return [nil]
|
||||||
|
def bind(key, command, mode: Modes.normal)
|
||||||
|
key = Vamp.string!(key, "binding key")
|
||||||
|
raise TypeError, "binding mode must be a value from config.modes" unless mode.is_a?(Mode)
|
||||||
|
|
||||||
|
command = binding_target(command)
|
||||||
|
raise ArgumentError, "binding key must not be empty" if key.empty?
|
||||||
|
raise ArgumentError, "binding command must not be empty" if command == ""
|
||||||
|
|
||||||
|
@bindings << { "mode" => mode.name, "key" => key, "command" => command }
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# @return [Hash{String => Object}] validated startup configuration frame
|
||||||
|
def response
|
||||||
|
{ "version" => 1, "type" => "config", "plugins" => @plugins, "lsp" => lsp_options, "bindings" => @bindings,
|
||||||
|
"leader" => @leader, "file_explorer_side" => @file_explorer_side, "file_picker_view" => @file_picker_view,
|
||||||
|
"file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# @param value [String]
|
||||||
|
# @param label [String]
|
||||||
|
# @return [String]
|
||||||
|
def nonempty_string!(value, label)
|
||||||
|
value = Vamp.string!(value, label)
|
||||||
|
raise ArgumentError, "#{label} must not be empty" if value.empty?
|
||||||
|
|
||||||
|
value
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param command [Vamp::Action, String, nil]
|
||||||
|
# @return [String, nil]
|
||||||
|
def binding_target(command)
|
||||||
|
case command
|
||||||
|
when Action
|
||||||
|
command.name
|
||||||
|
when String
|
||||||
|
external_command(command)
|
||||||
|
when nil
|
||||||
|
nil
|
||||||
|
else
|
||||||
|
raise TypeError, "binding command must be an Action, String, or nil"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param command [String]
|
||||||
|
# @return [String]
|
||||||
|
def external_command(command)
|
||||||
|
name = Vamp.string!(command, "binding command")
|
||||||
|
raise TypeError, "use config.core for built-in actions" if name.start_with?("core.")
|
||||||
|
|
||||||
|
name
|
||||||
|
end
|
||||||
|
|
||||||
|
# @return [Hash{String => Boolean, Hash{String => Object}}]
|
||||||
|
def lsp_options
|
||||||
|
@lsp.to_h do |language, value|
|
||||||
|
language = lsp_language(language)
|
||||||
|
[language, lsp_value(value, language)]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param language [String]
|
||||||
|
# @return [String]
|
||||||
|
def lsp_language(language)
|
||||||
|
language = Vamp.string!(language, "LSP language key")
|
||||||
|
invalid = language.empty? || language.match?(/[[:cntrl:]]/)
|
||||||
|
raise ArgumentError, "LSP language keys must not be empty or contain control characters" if invalid
|
||||||
|
|
||||||
|
language
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param value [Boolean, Hash{String, Symbol => Object}]
|
||||||
|
# @param language [String]
|
||||||
|
# @return [Boolean, Hash{String => Object}]
|
||||||
|
def lsp_value(value, language)
|
||||||
|
case value
|
||||||
|
when TrueClass, FalseClass
|
||||||
|
value
|
||||||
|
when Hash
|
||||||
|
Vamp.json_value!(value)
|
||||||
|
else
|
||||||
|
raise TypeError, "LSP language #{language.inspect} must be true, false, or a Hash"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Provides an immutable editor snapshot and queues effects for an invocation.
|
||||||
|
class Editor
|
||||||
|
# @return [String] immutable context text, uppercase mode, or workspace path
|
||||||
|
attr_reader :text, :mode, :root
|
||||||
|
# @return [Integer] one-based grapheme row/column or nonnegative document ID/revision
|
||||||
|
attr_reader :row, :column, :buffer_id, :revision
|
||||||
|
# @return [String, nil] document path; nil for an unnamed buffer
|
||||||
|
attr_reader :path
|
||||||
|
# @return [Integer] positive invocation ID
|
||||||
|
attr_reader :request_id
|
||||||
|
# @return [Array<Hash{String => String}>] queued insert/replace/message effects
|
||||||
|
attr_reader :effects
|
||||||
|
|
||||||
|
# @param context [Hash{String => String, Integer, nil}] primary caret/document snapshot
|
||||||
|
# @param request_id [Integer] positive invocation ID
|
||||||
|
# @return [void]
|
||||||
|
def initialize(context, request_id)
|
||||||
|
validate_context(context)
|
||||||
|
assign_context_strings(context)
|
||||||
|
assign_context_numbers(context, request_id)
|
||||||
|
validate_context_position
|
||||||
|
validate_context_revision
|
||||||
|
@effects = []
|
||||||
|
@effect_bytes = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param text [String]
|
||||||
|
# @return [nil]
|
||||||
|
def insert(text)
|
||||||
|
queue("insert", text)
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param text [String]
|
||||||
|
# @return [nil]
|
||||||
|
def replace(text)
|
||||||
|
queue("replace", text)
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param text [String]
|
||||||
|
# @return [nil]
|
||||||
|
def message(text)
|
||||||
|
queue("message", text)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# @param context [Hash{String => String, Integer, nil}]
|
||||||
|
# @return [void]
|
||||||
|
def validate_context(context)
|
||||||
|
return if context.is_a?(Hash) && context.keys.sort == %w[buffer_id column mode path revision root row text]
|
||||||
|
|
||||||
|
raise ArgumentError, "invalid editor context"
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param context [Hash{String => String, Integer, nil}]
|
||||||
|
# @return [void]
|
||||||
|
def assign_context_strings(context)
|
||||||
|
@text = Vamp.string!(context["text"], "context text").freeze
|
||||||
|
@mode = Vamp.string!(context["mode"], "context mode").freeze
|
||||||
|
@path = context["path"].nil? ? nil : Vamp.string!(context["path"], "context path").freeze
|
||||||
|
@root = Vamp.string!(context["root"], "context root").freeze
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param context [Hash{String => String, Integer, nil}]
|
||||||
|
# @param request_id [Integer]
|
||||||
|
# @return [void]
|
||||||
|
def assign_context_numbers(context, request_id)
|
||||||
|
@buffer_id = context["buffer_id"]
|
||||||
|
@revision = context["revision"]
|
||||||
|
@request_id = request_id
|
||||||
|
@row = context["row"]
|
||||||
|
@column = context["column"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# @return [void]
|
||||||
|
def validate_context_position
|
||||||
|
return if @row.is_a?(Integer) && @row.positive? && @column.is_a?(Integer) && @column.positive?
|
||||||
|
|
||||||
|
raise ArgumentError, "context row and column must be positive integers"
|
||||||
|
end
|
||||||
|
|
||||||
|
# @return [void]
|
||||||
|
def validate_context_revision
|
||||||
|
return if @buffer_id.is_a?(Integer) && @buffer_id >= 0 && @revision.is_a?(Integer) && @revision >= 0
|
||||||
|
|
||||||
|
raise ArgumentError, "context buffer_id and revision must be nonnegative integers"
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param type [String] insert, replace, or message
|
||||||
|
# @param text [String]
|
||||||
|
# @return [nil]
|
||||||
|
def queue(type, text)
|
||||||
|
text = Vamp.string!(text, "effect text")
|
||||||
|
@effect_bytes += text.bytesize
|
||||||
|
raise ArgumentError, "effects exceed 4 MiB" if @effect_bytes > MAX_FRAME
|
||||||
|
|
||||||
|
@effects << { "type" => type, "text" => text }
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
+98
-514
@@ -1,549 +1,133 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require "json"
|
require "json"
|
||||||
|
|
||||||
# Keep protocol IO separate even from explicit STDOUT.write and ordinary puts.
|
|
||||||
# @type [IO]
|
|
||||||
protocol_out = STDOUT.dup
|
|
||||||
protocol_out.binmode
|
|
||||||
protocol_out.sync = true
|
|
||||||
# @type [IO]
|
|
||||||
protocol_in = STDIN.dup
|
|
||||||
protocol_in.binmode
|
|
||||||
STDOUT.reopen(STDERR)
|
|
||||||
$stdout = STDERR
|
|
||||||
Thread.abort_on_exception = true
|
|
||||||
|
|
||||||
module Vamp
|
module Vamp
|
||||||
MAX_FRAME = 4 * 1024 * 1024
|
# Owns the subprocess wire protocol, isolated from user stdout and callbacks.
|
||||||
|
class Protocol
|
||||||
# Validates a boundary value and returns a UTF-8 copy.
|
|
||||||
# @param value [Object]
|
|
||||||
# @param label [String]
|
|
||||||
# @return [String]
|
|
||||||
def self.string!(value, label)
|
|
||||||
raise TypeError, "#{label} must be a String" unless value.is_a?(String)
|
|
||||||
raise ArgumentError, "#{label} must be valid UTF-8" unless value.dup.force_encoding(Encoding::UTF_8).valid_encoding?
|
|
||||||
value.dup.force_encoding(Encoding::UTF_8)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Recursively validates JSON values and normalizes hash keys to strings.
|
|
||||||
# @param value [Object]
|
|
||||||
# @return [Hash{String => Object}, Array<Object>, String, Integer, Float, Boolean, nil]
|
|
||||||
def self.json_value!(value)
|
|
||||||
case value
|
|
||||||
when Hash
|
|
||||||
value.to_h do |key, item|
|
|
||||||
raise TypeError, "option keys must be Strings or Symbols" unless key.is_a?(String) || key.is_a?(Symbol)
|
|
||||||
[string!(key.to_s, "option key"), json_value!(item)]
|
|
||||||
end
|
|
||||||
when Array
|
|
||||||
value.map { |item| json_value!(item) }
|
|
||||||
when String
|
|
||||||
string!(value, "option value")
|
|
||||||
when Integer, TrueClass, FalseClass, NilClass
|
|
||||||
value
|
|
||||||
when Float
|
|
||||||
raise ArgumentError, "option numbers must be finite" unless value.finite?
|
|
||||||
value
|
|
||||||
else
|
|
||||||
raise TypeError, "options must contain only JSON values"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Freezes the supplied value and every nested hash/array member in place.
|
|
||||||
# @param value [Object]
|
|
||||||
# @return [Object] the same value
|
|
||||||
def self.deep_freeze(value)
|
|
||||||
case value
|
|
||||||
when Hash
|
|
||||||
value.each { |key, item| deep_freeze(key); deep_freeze(item) }
|
|
||||||
when Array
|
|
||||||
value.each { |item| deep_freeze(item) }
|
|
||||||
end
|
|
||||||
value.freeze
|
|
||||||
end
|
|
||||||
|
|
||||||
# @!override Vamp::Action#name
|
|
||||||
# @return [String] the catalog's dotted action name
|
|
||||||
Action = Struct.new(:name)
|
|
||||||
# @!override Vamp::Mode#name
|
|
||||||
# @return [String] the lowercase binding mode
|
|
||||||
Mode = Struct.new(:name)
|
|
||||||
|
|
||||||
# Action methods and nested modules come exclusively from the Rust catalog.
|
|
||||||
# @type [Module<Vamp::Core>]
|
|
||||||
Core = Module.new
|
|
||||||
|
|
||||||
# @param names [Array<String>] dotted core action names
|
|
||||||
# @return [Array<Module>] frozen action namespaces
|
|
||||||
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
|
|
||||||
namespaces.each(&:freeze)
|
|
||||||
end
|
|
||||||
|
|
||||||
module Modes
|
|
||||||
# @!method self.normal
|
|
||||||
# @return [Vamp::Mode]
|
|
||||||
# @!method self.insert
|
|
||||||
# @return [Vamp::Mode]
|
|
||||||
# @!method self.command
|
|
||||||
# @return [Vamp::Mode]
|
|
||||||
# @!method self.search
|
|
||||||
# @return [Vamp::Mode]
|
|
||||||
# @!method self.visual
|
|
||||||
# @return [Vamp::Mode]
|
|
||||||
# @!method self.selection
|
|
||||||
# @return [Vamp::Mode]
|
|
||||||
# @!method self.tree
|
|
||||||
# @return [Vamp::Mode]
|
|
||||||
%w[normal insert command search visual selection tree].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
|
|
||||||
# @return [Hash{String => Boolean, Hash{String, Symbol => Object}}] language presets/options
|
|
||||||
attr_reader :lsp
|
|
||||||
|
|
||||||
# @return [void]
|
# @return [void]
|
||||||
def initialize
|
def initialize
|
||||||
@plugins = []
|
# Capture physical streams; globals may be replaced by user code.
|
||||||
@lsp = {}
|
@output = STDOUT.dup # rubocop:disable Style/GlobalStdStream
|
||||||
@bindings = []
|
@output.binmode
|
||||||
@leader = "space"
|
@output.sync = true
|
||||||
@file_explorer_side = "right"
|
@input = STDIN.dup # rubocop:disable Style/GlobalStdStream
|
||||||
@file_picker_view = "tree"
|
@input.binmode
|
||||||
@file_explorer_view = "tree"
|
@mutex = Mutex.new
|
||||||
@clipboard_mode = "system"
|
# Explicit STDOUT.write must also stay off the duplicated protocol pipe.
|
||||||
|
STDOUT.reopen(STDERR) # rubocop:disable Style/GlobalStdStream
|
||||||
|
$stdout = STDERR # rubocop:disable Style/GlobalStdStream
|
||||||
|
Thread.abort_on_exception = true
|
||||||
end
|
end
|
||||||
|
|
||||||
# @param path [String] relative to the configuration file
|
# @param arguments [Array<String>] mode, source path, and startup JSON
|
||||||
# @param options [Hash{String, Symbol => Object}] JSON-compatible plugin options
|
# @return [Object] the shutdown callback's result
|
||||||
# @return [nil]
|
def run(arguments)
|
||||||
def plugin(path, options: {})
|
start(arguments)
|
||||||
path = Vamp.string!(path, "plugin path")
|
send_frame(Vamp.startup)
|
||||||
raise ArgumentError, "plugin path must not be empty" if path.empty? || path.include?("\0")
|
process_requests if @mode == "plugin"
|
||||||
raise TypeError, "plugin options must be a Hash" unless options.is_a?(Hash)
|
Vamp.shutdown
|
||||||
@plugins << { "path" => path, "options" => Vamp.json_value!(options) }
|
ensure
|
||||||
nil
|
# Includes SystemExit/Interrupt, without rescuing or hiding callback errors.
|
||||||
end
|
error = $! # rubocop:disable Style/SpecialGlobalVars -- Native exception state at the process boundary.
|
||||||
|
report_error(error) if error
|
||||||
# @param key [String]
|
|
||||||
# @return [String]
|
|
||||||
def leader=(key)
|
|
||||||
key = Vamp.string!(key, "leader key")
|
|
||||||
raise ArgumentError, "leader key must not be empty" if key.empty?
|
|
||||||
@leader = key
|
|
||||||
end
|
|
||||||
|
|
||||||
# @param side [String]
|
|
||||||
# @return [String]
|
|
||||||
def file_explorer_side=(side)
|
|
||||||
side = Vamp.string!(side, "file explorer side")
|
|
||||||
raise ArgumentError, "file explorer side must not be empty" if side.empty?
|
|
||||||
@file_explorer_side = side
|
|
||||||
end
|
|
||||||
|
|
||||||
# @param view [String]
|
|
||||||
# @return [String]
|
|
||||||
def file_picker_view=(view)
|
|
||||||
view = Vamp.string!(view, "file picker view")
|
|
||||||
raise ArgumentError, "file picker view must not be empty" if view.empty?
|
|
||||||
@file_picker_view = view
|
|
||||||
end
|
|
||||||
|
|
||||||
# @param view [String]
|
|
||||||
# @return [String]
|
|
||||||
def file_explorer_view=(view)
|
|
||||||
view = Vamp.string!(view, "file explorer view")
|
|
||||||
raise ArgumentError, "file explorer view must not be empty" if view.empty?
|
|
||||||
@file_explorer_view = view
|
|
||||||
end
|
|
||||||
|
|
||||||
# @param mode [String]
|
|
||||||
# @return [String]
|
|
||||||
def clipboard_mode=(mode)
|
|
||||||
mode = Vamp.string!(mode, "clipboard mode")
|
|
||||||
raise ArgumentError, "clipboard mode must not be empty" if mode.empty?
|
|
||||||
@clipboard_mode = mode
|
|
||||||
end
|
|
||||||
|
|
||||||
# @return [Module<Vamp::Core>] frozen namespace with catalog-generated Action methods
|
|
||||||
def core
|
|
||||||
Core
|
|
||||||
end
|
|
||||||
|
|
||||||
# @return [Module<Vamp::Modes>] frozen namespace whose methods return Mode values
|
|
||||||
def modes
|
|
||||||
Modes
|
|
||||||
end
|
|
||||||
|
|
||||||
# @param key [String] one key or a leader-prefixed sequence
|
|
||||||
# @param command [Vamp::Action, String, nil] core action, external command, or disabled route
|
|
||||||
# @param mode [Vamp::Mode]
|
|
||||||
# @return [nil]
|
|
||||||
def bind(key, command, mode: Modes.normal)
|
|
||||||
key = Vamp.string!(key, "binding key")
|
|
||||||
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.name, "key" => key, "command" => command }
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
# @return [Hash{String => Object}] validated startup configuration frame
|
|
||||||
def response
|
|
||||||
lsp = @lsp.to_h do |language, value|
|
|
||||||
language = Vamp.string!(language, "LSP language key")
|
|
||||||
if language.empty? || language.match?(/[[:cntrl:]]/)
|
|
||||||
raise ArgumentError, "LSP language keys must not be empty or contain control characters"
|
|
||||||
end
|
|
||||||
value = case value
|
|
||||||
when TrueClass, FalseClass
|
|
||||||
value
|
|
||||||
when Hash
|
|
||||||
Vamp.json_value!(value)
|
|
||||||
else
|
|
||||||
raise TypeError, "LSP language #{language.inspect} must be true, false, or a Hash"
|
|
||||||
end
|
|
||||||
[language, value]
|
|
||||||
end
|
|
||||||
{ "version" => 1, "type" => "config", "plugins" => @plugins,
|
|
||||||
"lsp" => lsp, "bindings" => @bindings, "leader" => @leader,
|
|
||||||
"file_explorer_side" => @file_explorer_side,
|
|
||||||
"file_picker_view" => @file_picker_view,
|
|
||||||
"file_explorer_view" => @file_explorer_view,
|
|
||||||
"clipboard_mode" => @clipboard_mode }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class Editor
|
|
||||||
# @return [String] immutable context text, uppercase mode, or workspace path
|
|
||||||
attr_reader :text, :mode, :root
|
|
||||||
# @return [Integer] one-based grapheme row/column or nonnegative document ID/revision
|
|
||||||
attr_reader :row, :column, :buffer_id, :revision
|
|
||||||
# @return [String, nil] document path; nil for an unnamed buffer
|
|
||||||
attr_reader :path
|
|
||||||
# @return [Integer] positive invocation ID
|
|
||||||
attr_reader :request_id
|
|
||||||
# @return [Array<Hash{String => String}>] queued insert/replace/message effects
|
|
||||||
attr_reader :effects
|
|
||||||
|
|
||||||
# @param context [Hash{String => String, Integer, nil}] primary caret/document snapshot
|
|
||||||
# @param request_id [Integer] positive invocation ID
|
|
||||||
# @return [void]
|
|
||||||
def initialize(context, request_id)
|
|
||||||
unless context.is_a?(Hash) && context.keys.sort == %w[buffer_id column mode path revision root row text]
|
|
||||||
raise ArgumentError, "invalid editor context"
|
|
||||||
end
|
|
||||||
@text = Vamp.string!(context["text"], "context text").freeze
|
|
||||||
@mode = Vamp.string!(context["mode"], "context mode").freeze
|
|
||||||
@path = context["path"].nil? ? nil : Vamp.string!(context["path"], "context path").freeze
|
|
||||||
@root = Vamp.string!(context["root"], "context root").freeze
|
|
||||||
@buffer_id = context["buffer_id"]
|
|
||||||
@revision = context["revision"]
|
|
||||||
@request_id = request_id
|
|
||||||
@row = context["row"]
|
|
||||||
@column = context["column"]
|
|
||||||
unless @row.is_a?(Integer) && @row > 0 && @column.is_a?(Integer) && @column > 0
|
|
||||||
raise ArgumentError, "context row and column must be positive integers"
|
|
||||||
end
|
|
||||||
unless @buffer_id.is_a?(Integer) && @buffer_id >= 0 && @revision.is_a?(Integer) && @revision >= 0
|
|
||||||
raise ArgumentError, "context buffer_id and revision must be nonnegative integers"
|
|
||||||
end
|
|
||||||
@effects = []
|
|
||||||
@effect_bytes = 0
|
|
||||||
end
|
|
||||||
|
|
||||||
# @param text [String]
|
|
||||||
# @return [nil]
|
|
||||||
def insert(text)
|
|
||||||
queue("insert", text)
|
|
||||||
end
|
|
||||||
|
|
||||||
# @param text [String]
|
|
||||||
# @return [nil]
|
|
||||||
def replace(text)
|
|
||||||
queue("replace", text)
|
|
||||||
end
|
|
||||||
|
|
||||||
# @param text [String]
|
|
||||||
# @return [nil]
|
|
||||||
def message(text)
|
|
||||||
queue("message", text)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# @param type [String] insert, replace, or message
|
# @param arguments [Array<String>]
|
||||||
# @param text [String]
|
# @return [Boolean] the source load result
|
||||||
# @return [nil]
|
def start(arguments)
|
||||||
def queue(type, text)
|
@mode, path, encoded_startup = arguments
|
||||||
text = Vamp.string!(text, "effect text")
|
valid = %w[config plugin].include?(@mode) && path && encoded_startup
|
||||||
@effect_bytes += text.bytesize
|
raise ArgumentError, "expected config or plugin mode, path, and startup data" unless valid
|
||||||
raise ArgumentError, "effects exceed 4 MiB" if @effect_bytes > MAX_FRAME
|
raise ArgumentError, "startup frame too large" if encoded_startup.bytesize > MAX_FRAME
|
||||||
@effects << { "type" => type, "text" => text }
|
|
||||||
nil
|
Vamp.setup(@mode, JSON.parse(encoded_startup), method(:send_frame))
|
||||||
end
|
load path
|
||||||
end
|
end
|
||||||
|
|
||||||
# @param mode [String] config or plugin
|
|
||||||
# @param startup [Hash{String => Object}] action catalog and plugin options
|
|
||||||
# @param sender [Proc] writes a Hash{String => Object} protocol frame
|
|
||||||
# @return [Proc] the installed sender
|
|
||||||
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
|
|
||||||
@requires = []
|
|
||||||
@commands = {}
|
|
||||||
@async = false
|
|
||||||
@document_callback = nil
|
|
||||||
@shutdown_callback = nil
|
|
||||||
@options = deep_freeze(json_value!(options))
|
|
||||||
@sender = sender
|
|
||||||
end
|
|
||||||
|
|
||||||
# @return [Hash{String => Object}] deeply frozen plugin options
|
|
||||||
def self.options
|
|
||||||
@options
|
|
||||||
end
|
|
||||||
|
|
||||||
# @yieldparam config [Vamp::Config]
|
|
||||||
# @yieldreturn [Object]
|
|
||||||
# @return [Object] the block result
|
|
||||||
def self.configure
|
|
||||||
raise ArgumentError, "configure is only available in config files" unless @mode == "config"
|
|
||||||
raise ArgumentError, "configure requires a block" unless block_given?
|
|
||||||
yield @config
|
|
||||||
end
|
|
||||||
|
|
||||||
# @param name [String]
|
|
||||||
# @param requires [Array<String>] frontend capabilities
|
|
||||||
# @param async [Boolean]
|
|
||||||
# @return [nil]
|
|
||||||
def self.plugin(name, requires: [], async: false)
|
|
||||||
raise ArgumentError, "plugin is only available in plugin files" unless @mode == "plugin"
|
|
||||||
raise ArgumentError, "plugin already declared" unless @name.nil?
|
|
||||||
name = string!(name, "plugin name")
|
|
||||||
raise ArgumentError, "plugin name must not be empty" if name.empty?
|
|
||||||
raise TypeError, "requires must be an Array of Strings" unless requires.is_a?(Array)
|
|
||||||
@requires = requires.map { |value| string!(value, "capability") }
|
|
||||||
raise ArgumentError, "capability must not be empty" if @requires.any?(&:empty?)
|
|
||||||
raise TypeError, "async must be true or false" unless async == true || async == false
|
|
||||||
@async = async
|
|
||||||
@name = name
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
# @param name [String] external command name
|
|
||||||
# @param callback [Proc]
|
|
||||||
# @yieldparam editor [Vamp::Editor] immutable context and queued effects
|
|
||||||
# @yieldparam args [String] remaining command text
|
|
||||||
# @yieldreturn [Object] ignored
|
|
||||||
# @return [nil]
|
|
||||||
def self.command(name, &callback)
|
|
||||||
raise ArgumentError, "declare a plugin before its commands" unless @mode == "plugin" && @name
|
|
||||||
name = string!(name, "command name")
|
|
||||||
raise ArgumentError, "command name must not be empty" if name.empty?
|
|
||||||
raise ArgumentError, "command requires a block" unless callback
|
|
||||||
raise ArgumentError, "duplicate command #{name}" if @commands.key?(name)
|
|
||||||
@commands[name] = callback
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
# @param callback [Proc]
|
|
||||||
# @yieldparam event [Hash{String => String, Hash{String => String, Integer, nil}}] frozen event/document
|
|
||||||
# @yieldreturn [Object] ignored
|
|
||||||
# @return [nil]
|
|
||||||
def self.on_document(&callback)
|
|
||||||
raise ArgumentError, "document callbacks require an asynchronous plugin" unless @mode == "plugin" && @name && @async
|
|
||||||
raise ArgumentError, "on_document requires a block" unless callback
|
|
||||||
raise ArgumentError, "document callback already registered" if @document_callback
|
|
||||||
@document_callback = callback
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
# @param callback [Proc]
|
|
||||||
# @yieldreturn [Object] ignored
|
|
||||||
# @return [nil]
|
|
||||||
def self.on_shutdown(&callback)
|
|
||||||
raise ArgumentError, "shutdown callbacks require an asynchronous plugin" unless @mode == "plugin" && @name && @async
|
|
||||||
raise ArgumentError, "on_shutdown requires a block" unless callback
|
|
||||||
raise ArgumentError, "shutdown callback already registered" if @shutdown_callback
|
|
||||||
@shutdown_callback = callback
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
# @return [Object, nil] callback result, or nil when no hook is registered
|
|
||||||
def self.shutdown
|
|
||||||
callback, @shutdown_callback = @shutdown_callback, nil
|
|
||||||
callback&.call
|
|
||||||
end
|
|
||||||
|
|
||||||
# @param effects [Array<Hash{String, Symbol => Object}>] JSON-compatible async update effects
|
|
||||||
# @param request_id [Integer, nil] positive unsigned 64-bit invocation ID, or background update
|
|
||||||
# @return [nil]
|
|
||||||
def self.publish(effects, request_id: nil)
|
|
||||||
raise ArgumentError, "publish requires an asynchronous plugin" unless @mode == "plugin" && @name && @async
|
|
||||||
raise TypeError, "published effects must be an Array of Hashes" unless effects.is_a?(Array) && effects.all? { |effect| effect.is_a?(Hash) }
|
|
||||||
unless request_id.nil? || (request_id.is_a?(Integer) && request_id > 0 && request_id <= 18_446_744_073_709_551_615)
|
|
||||||
raise ArgumentError, "request_id must be a positive unsigned 64-bit integer or nil"
|
|
||||||
end
|
|
||||||
@sender.call({ "version" => 1, "type" => "update", "request_id" => request_id,
|
|
||||||
"effects" => json_value!(effects) })
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
# @param request [Hash{String => Object}] incoming document event frame
|
|
||||||
# @return [nil]
|
|
||||||
def self.document(request)
|
|
||||||
unless @async && request.is_a?(Hash) && request.keys.sort == %w[document event type version] &&
|
|
||||||
request["version"].is_a?(Integer) && request["version"] == 1 && request["type"] == "document" &&
|
|
||||||
%w[open change save close].include?(request["event"])
|
|
||||||
raise ArgumentError, "invalid document event frame"
|
|
||||||
end
|
|
||||||
document = request["document"]
|
|
||||||
unless document.is_a?(Hash) && document.keys.sort == %w[id path revision root text] &&
|
|
||||||
document["id"].is_a?(Integer) && document["id"] >= 0 &&
|
|
||||||
document["revision"].is_a?(Integer) && document["revision"] >= 0
|
|
||||||
raise ArgumentError, "invalid document snapshot"
|
|
||||||
end
|
|
||||||
document["text"] = string!(document["text"], "document text")
|
|
||||||
document["root"] = string!(document["root"], "document root")
|
|
||||||
document["path"] = string!(document["path"], "document path") unless document["path"].nil?
|
|
||||||
@document_callback&.call(deep_freeze({ "event" => request["event"], "document" => document }))
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
# @return [Hash{String => Object}] configuration or plugin manifest frame
|
|
||||||
def self.startup
|
|
||||||
return @config.response if @mode == "config"
|
|
||||||
raise ArgumentError, "plugin file must declare Vamp.plugin" unless @name
|
|
||||||
{ "version" => 1, "type" => "manifest", "name" => @name,
|
|
||||||
"requires" => @requires, "commands" => @commands.keys, "async" => @async }
|
|
||||||
end
|
|
||||||
|
|
||||||
# @param request [Hash{String => Object}] invocation frame
|
|
||||||
# @return [Hash{String => Object}] result frame with queued effects
|
|
||||||
def self.invoke(request)
|
|
||||||
unless request.is_a?(Hash) && request.keys.sort == %w[args command context id type version] &&
|
|
||||||
request["version"].is_a?(Integer) && request["version"] == 1 && request["type"] == "invoke" &&
|
|
||||||
request["id"].is_a?(Integer) && request["id"] > 0
|
|
||||||
raise ArgumentError, "invalid invocation frame"
|
|
||||||
end
|
|
||||||
command = string!(request["command"], "command")
|
|
||||||
args = string!(request["args"], "args")
|
|
||||||
callback = @commands[command]
|
|
||||||
raise ArgumentError, "unknown command #{command}" unless callback
|
|
||||||
editor = Editor.new(request["context"], request["id"])
|
|
||||||
callback.call(editor, args)
|
|
||||||
{ "version" => 1, "type" => "result", "id" => request["id"], "effects" => editor.effects }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
protocol_mutex = Mutex.new
|
|
||||||
# @type [Proc]
|
|
||||||
# @param frame [Hash{String => Object}]
|
# @param frame [Hash{String => Object}]
|
||||||
# @return [Integer] bytes written by the terminating newline
|
# @return [Integer] bytes written by the terminating newline
|
||||||
send_frame = lambda do |frame|
|
def send_frame(frame)
|
||||||
protocol_mutex.synchronize do
|
@mutex.synchronize do
|
||||||
payload = JSON.generate(frame)
|
payload = JSON.generate(frame)
|
||||||
raise ArgumentError, "protocol frame exceeds 4 MiB" if payload.bytesize > Vamp::MAX_FRAME
|
raise ArgumentError, "outgoing frame too large" if payload.bytesize > MAX_FRAME
|
||||||
protocol_out.write(payload)
|
|
||||||
protocol_out.write("\n")
|
@output.write(payload)
|
||||||
|
@output.write("\n")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# @type [Integer, nil]
|
# @return [void]
|
||||||
request_id = nil
|
def process_requests
|
||||||
begin
|
|
||||||
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"
|
|
||||||
loop do
|
loop do
|
||||||
request_id = nil
|
@request_id = nil
|
||||||
line = protocol_in.gets(Vamp::MAX_FRAME + 2)
|
line = @input.gets(MAX_FRAME + 2)
|
||||||
break if line.nil?
|
break if line.nil?
|
||||||
unless line.end_with?("\n") && line.bytesize <= Vamp::MAX_FRAME + 1
|
|
||||||
|
request = parse_request(line)
|
||||||
|
remember_request(request)
|
||||||
|
dispatch(request)
|
||||||
|
break if request["type"] == "shutdown"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param line [String]
|
||||||
|
# @return [Object] parsed JSON, validated by the selected request handler
|
||||||
|
def parse_request(line)
|
||||||
|
unless line.end_with?("\n") && line.bytesize - 1 <= MAX_FRAME
|
||||||
raise ArgumentError, "invalid or oversized protocol frame"
|
raise ArgumentError, "invalid or oversized protocol frame"
|
||||||
end
|
end
|
||||||
request = JSON.parse(line)
|
|
||||||
request_id = request["id"] if request.is_a?(Hash) && request["id"].is_a?(Integer) && request["id"] > 0
|
JSON.parse(line)
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param request [Object]
|
||||||
|
# @return [Integer, nil] ID to attach to a failed invocation
|
||||||
|
def remember_request(request)
|
||||||
|
return unless request.is_a?(Hash)
|
||||||
|
|
||||||
|
id = request["id"]
|
||||||
|
@request_id = id if id.is_a?(Integer) && id.positive?
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param request [Object]
|
||||||
|
# @return [Object]
|
||||||
|
def dispatch(request)
|
||||||
case request.is_a?(Hash) && request["type"]
|
case request.is_a?(Hash) && request["type"]
|
||||||
when "invoke"
|
when "invoke"
|
||||||
send_frame.call(Vamp.invoke(request))
|
send_frame(Vamp.invoke(request))
|
||||||
when "document"
|
when "document"
|
||||||
Vamp.document(request)
|
Vamp.document(request)
|
||||||
when "shutdown"
|
when "shutdown"
|
||||||
unless request.keys.sort == %w[type version] && request["version"].is_a?(Integer) && request["version"] == 1
|
validate_shutdown(request)
|
||||||
raise ArgumentError, "invalid shutdown frame"
|
|
||||||
end
|
|
||||||
break
|
|
||||||
else
|
else
|
||||||
raise ArgumentError, "unknown protocol frame"
|
raise ArgumentError, "unknown protocol frame"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @param request [Hash{String => Object}]
|
||||||
|
# @return [nil]
|
||||||
|
def validate_shutdown(request)
|
||||||
|
return if request.keys.sort == %w[type version] && request["version"].is_a?(Integer) && request["version"] == 1
|
||||||
|
|
||||||
|
raise ArgumentError, "invalid shutdown frame"
|
||||||
end
|
end
|
||||||
Vamp.shutdown
|
|
||||||
rescue Exception => error
|
# @param error [Exception]
|
||||||
# No callback effects are emitted when loading or invoking raises.
|
# @return [void]
|
||||||
message = "#{error.class}: #{error.message}".encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
def report_error(error)
|
||||||
message = message.each_char.take(2048).join
|
# No callback effects survive an exception. Bound and sanitize the message.
|
||||||
frame = { "version" => 1, "type" => "error", "message" => message }
|
message = "#{error.class}: #{error.message}".encode("UTF-8", invalid: :replace, undef: :replace)
|
||||||
frame["id"] = request_id unless request_id.nil?
|
frame = { "version" => 1, "type" => "error", "message" => message.each_char.take(2048).join }
|
||||||
begin
|
frame["id"] = @request_id unless @request_id.nil?
|
||||||
send_frame.call(frame)
|
send_frame(frame)
|
||||||
rescue Exception
|
ensure
|
||||||
# Rust observes EOF if even the error cannot be framed.
|
# If even reporting fails, Rust observes EOF and invalidates the host.
|
||||||
end
|
|
||||||
exit 1
|
exit 1
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Vamp::Protocol.new.run(ARGV)
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Ruby DSL and protocol callbacks share the host's singleton state.
|
||||||
|
module Vamp
|
||||||
|
# Checks protocol envelopes and normalizes document snapshots before callbacks.
|
||||||
|
module FrameValidation
|
||||||
|
# @param startup [Object] action catalog and options
|
||||||
|
# @return [void]
|
||||||
|
def self.startup!(startup)
|
||||||
|
return if hash_shape?(startup, %w[core_actions options]) && startup["options"].is_a?(Hash)
|
||||||
|
|
||||||
|
raise TypeError, "invalid Ruby startup catalog/options"
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param request [Object] invocation frame
|
||||||
|
# @return [void]
|
||||||
|
def self.invocation!(request)
|
||||||
|
return if envelope?(request, "invoke", %w[args command context id type version]) &&
|
||||||
|
integer?(request["id"], 1)
|
||||||
|
|
||||||
|
raise ArgumentError, "invalid invocation frame"
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param request [Object] document event frame
|
||||||
|
# @return [void]
|
||||||
|
def self.document!(request)
|
||||||
|
return if envelope?(request, "document", %w[document event type version]) &&
|
||||||
|
%w[open change save close].include?(request["event"])
|
||||||
|
|
||||||
|
raise ArgumentError, "invalid document event frame"
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param document [Object] document snapshot normalized in place
|
||||||
|
# @return [Hash{String => String, Integer, nil}]
|
||||||
|
def self.snapshot!(document)
|
||||||
|
unless hash_shape?(document, %w[id path revision root text]) &&
|
||||||
|
integer?(document["id"], 0) && integer?(document["revision"], 0)
|
||||||
|
raise ArgumentError, "invalid document snapshot"
|
||||||
|
end
|
||||||
|
|
||||||
|
normalize_snapshot_strings(document)
|
||||||
|
document
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param effects [Object] asynchronous effects
|
||||||
|
# @return [void]
|
||||||
|
def self.effects!(effects)
|
||||||
|
return if effects.is_a?(Array) && effects.all?(Hash)
|
||||||
|
|
||||||
|
raise TypeError, "published effects must be an Array of Hashes"
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param request_id [Object] update's optional invocation ID
|
||||||
|
# @return [void]
|
||||||
|
def self.request_id!(request_id)
|
||||||
|
return if request_id.nil? || (integer?(request_id, 1) && request_id <= 18_446_744_073_709_551_615)
|
||||||
|
|
||||||
|
raise ArgumentError, "request_id must be a positive unsigned 64-bit integer or nil"
|
||||||
|
end
|
||||||
|
|
||||||
|
class << self
|
||||||
|
private
|
||||||
|
|
||||||
|
# @param document [Hash{String => String, Integer, nil}]
|
||||||
|
# @return [String, nil]
|
||||||
|
def normalize_snapshot_strings(document)
|
||||||
|
document["text"] = Vamp.string!(document["text"], "document text")
|
||||||
|
document["root"] = Vamp.string!(document["root"], "document root")
|
||||||
|
document["path"] = Vamp.string!(document["path"], "document path") unless document["path"].nil?
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param value [Object]
|
||||||
|
# @param keys [Array<String>] sorted required keys
|
||||||
|
# @return [Boolean]
|
||||||
|
def hash_shape?(value, keys)
|
||||||
|
value.is_a?(Hash) && value.keys.sort == keys
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param value [Object]
|
||||||
|
# @param minimum [Integer] inclusive lower bound
|
||||||
|
# @return [Boolean]
|
||||||
|
def integer?(value, minimum)
|
||||||
|
value.is_a?(Integer) && value >= minimum
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param request [Object]
|
||||||
|
# @param type [String] expected frame kind
|
||||||
|
# @param keys [Array<String>] sorted required keys
|
||||||
|
# @return [Boolean]
|
||||||
|
def envelope?(request, type, keys)
|
||||||
|
hash_shape?(request, keys) && request["version"].is_a?(Integer) &&
|
||||||
|
request["version"] == 1 && request["type"] == type
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param mode [String] config or plugin
|
||||||
|
# @param startup [Hash{String => Object}] action catalog and plugin options
|
||||||
|
# @param sender [Proc, Method] writes a Hash{String => Object} protocol frame
|
||||||
|
# @return [Proc, Method] the installed sender
|
||||||
|
def self.setup(mode, startup, sender)
|
||||||
|
FrameValidation.startup!(startup)
|
||||||
|
install_actions(startup["core_actions"])
|
||||||
|
@mode = mode
|
||||||
|
reset_registrations
|
||||||
|
@options = deep_freeze(json_value!(startup["options"]))
|
||||||
|
@sender = sender
|
||||||
|
end
|
||||||
|
|
||||||
|
# @return [Hash{String => Object}] deeply frozen plugin options
|
||||||
|
def self.options
|
||||||
|
@options
|
||||||
|
end
|
||||||
|
|
||||||
|
# @yieldparam config [Vamp::Config]
|
||||||
|
# @yieldreturn [Object]
|
||||||
|
# @return [Object] the block result
|
||||||
|
def self.configure
|
||||||
|
raise ArgumentError, "configure is only available in config files" unless @mode == "config"
|
||||||
|
raise ArgumentError, "configure requires a block" unless block_given?
|
||||||
|
|
||||||
|
yield @config
|
||||||
|
end
|
||||||
|
|
||||||
|
class << self
|
||||||
|
private
|
||||||
|
|
||||||
|
# Clears declarations and callbacks before loading a script.
|
||||||
|
# @return [nil]
|
||||||
|
def reset_registrations
|
||||||
|
@config = Config.new
|
||||||
|
@name = nil
|
||||||
|
@requires = []
|
||||||
|
@commands = {}
|
||||||
|
@async = false
|
||||||
|
@document_callback = nil
|
||||||
|
@shutdown_callback = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Plugin declarations and hooks operate on the extending host's singleton state.
|
||||||
|
module PluginRegistration
|
||||||
|
# @param name [String]
|
||||||
|
# @param requires [Array<String>] frontend capabilities
|
||||||
|
# @param async [Boolean]
|
||||||
|
# @return [nil]
|
||||||
|
def plugin(name, requires: [], async: false)
|
||||||
|
raise ArgumentError, "plugin is only available in plugin files" unless @mode == "plugin"
|
||||||
|
raise ArgumentError, "plugin already declared" unless @name.nil?
|
||||||
|
|
||||||
|
name = registration_name!(name, "plugin")
|
||||||
|
@requires = capabilities!(requires)
|
||||||
|
raise ArgumentError, "capability must not be empty" if @requires.any?(&:empty?)
|
||||||
|
raise TypeError, "async must be true or false" unless [true, false].include?(async)
|
||||||
|
|
||||||
|
@async = async
|
||||||
|
@name = name
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param name [String] external command name
|
||||||
|
# @param callback [Proc]
|
||||||
|
# @yieldparam editor [Vamp::Editor] immutable context and queued effects
|
||||||
|
# @yieldparam args [String] remaining command text
|
||||||
|
# @yieldreturn [Object] ignored
|
||||||
|
# @return [nil]
|
||||||
|
def command(name, &callback)
|
||||||
|
raise ArgumentError, "declare a plugin before its commands" unless @mode == "plugin" && @name
|
||||||
|
|
||||||
|
name = registration_name!(name, "command")
|
||||||
|
raise ArgumentError, "command requires a block" unless callback
|
||||||
|
raise ArgumentError, "duplicate command #{name}" if @commands.key?(name)
|
||||||
|
|
||||||
|
@commands[name] = callback
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param callback [Proc]
|
||||||
|
# @yieldparam event [Hash{String => String, Hash{String => String, Integer, nil}}] frozen event/document
|
||||||
|
# @yieldreturn [Object] ignored
|
||||||
|
# @return [nil]
|
||||||
|
def on_document(&callback)
|
||||||
|
require_async!("document callbacks require")
|
||||||
|
raise ArgumentError, "on_document requires a block" unless callback
|
||||||
|
raise ArgumentError, "document callback already registered" if @document_callback
|
||||||
|
|
||||||
|
@document_callback = callback
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param callback [Proc]
|
||||||
|
# @yieldreturn [Object] ignored
|
||||||
|
# @return [nil]
|
||||||
|
def on_shutdown(&callback)
|
||||||
|
require_async!("shutdown callbacks require")
|
||||||
|
raise ArgumentError, "on_shutdown requires a block" unless callback
|
||||||
|
raise ArgumentError, "shutdown callback already registered" if @shutdown_callback
|
||||||
|
|
||||||
|
@shutdown_callback = callback
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# @param name [Object]
|
||||||
|
# @param kind [String] plugin or command
|
||||||
|
# @return [String] validated UTF-8 name
|
||||||
|
def registration_name!(name, kind)
|
||||||
|
name = string!(name, "#{kind} name")
|
||||||
|
raise ArgumentError, "#{kind} name must not be empty" if name.empty?
|
||||||
|
|
||||||
|
name
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param requires [Object]
|
||||||
|
# @return [Array<String>] validated UTF-8 capability names
|
||||||
|
def capabilities!(requires)
|
||||||
|
raise TypeError, "requires must be an Array of Strings" unless requires.is_a?(Array)
|
||||||
|
|
||||||
|
requires.map { |value| string!(value, "capability") }
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param operation [String] prefix of the unchanged asynchronous-only error
|
||||||
|
# @return [void]
|
||||||
|
def require_async!(operation)
|
||||||
|
return if @mode == "plugin" && @name && @async
|
||||||
|
|
||||||
|
raise ArgumentError, "#{operation} an asynchronous plugin"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
extend PluginRegistration
|
||||||
|
|
||||||
|
# @return [Object, nil] callback result, or nil when no hook is registered
|
||||||
|
def self.shutdown
|
||||||
|
callback = @shutdown_callback
|
||||||
|
@shutdown_callback = nil
|
||||||
|
callback&.call
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param effects [Array<Hash{String, Symbol => Object}>] JSON-compatible async update effects
|
||||||
|
# @param request_id [Integer, nil] positive unsigned 64-bit invocation ID, or background update
|
||||||
|
# @return [nil]
|
||||||
|
def self.publish(effects, request_id: nil)
|
||||||
|
require_async!("publish requires")
|
||||||
|
FrameValidation.effects!(effects)
|
||||||
|
FrameValidation.request_id!(request_id)
|
||||||
|
@sender.call({ "version" => 1, "type" => "update", "request_id" => request_id,
|
||||||
|
"effects" => json_value!(effects) })
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param request [Hash{String => Object}] incoming document event frame
|
||||||
|
# @return [nil]
|
||||||
|
def self.document(request)
|
||||||
|
raise ArgumentError, "invalid document event frame" unless @async
|
||||||
|
|
||||||
|
FrameValidation.document!(request)
|
||||||
|
document = FrameValidation.snapshot!(request["document"])
|
||||||
|
@document_callback&.call(deep_freeze({ "event" => request["event"], "document" => document }))
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# @return [Hash{String => Object}] configuration or plugin manifest frame
|
||||||
|
def self.startup
|
||||||
|
return @config.response if @mode == "config"
|
||||||
|
|
||||||
|
raise ArgumentError, "plugin file must declare Vamp.plugin" unless @name
|
||||||
|
|
||||||
|
{ "version" => 1, "type" => "manifest", "name" => @name,
|
||||||
|
"requires" => @requires, "commands" => @commands.keys, "async" => @async }
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param request [Hash{String => Object}] invocation frame
|
||||||
|
# @return [Hash{String => Object}] result frame with queued effects
|
||||||
|
def self.invoke(request)
|
||||||
|
FrameValidation.invocation!(request)
|
||||||
|
command = string!(request["command"], "command")
|
||||||
|
args = string!(request["args"], "args")
|
||||||
|
callback = @commands[command]
|
||||||
|
raise ArgumentError, "unknown command #{command}" unless callback
|
||||||
|
|
||||||
|
editor = Editor.new(request["context"], request["id"])
|
||||||
|
callback.call(editor, args)
|
||||||
|
{ "version" => 1, "type" => "result", "id" => request["id"], "effects" => editor.effects }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Shared value validation and frozen editor-owned configuration objects.
|
||||||
|
module Vamp
|
||||||
|
MAX_FRAME = 4 * 1024 * 1024
|
||||||
|
|
||||||
|
# Validates a boundary value and returns a UTF-8 copy.
|
||||||
|
# @param value [Object]
|
||||||
|
# @param label [String]
|
||||||
|
# @return [String]
|
||||||
|
def self.string!(value, label)
|
||||||
|
raise TypeError, "#{label} must be a String" unless value.is_a?(String)
|
||||||
|
|
||||||
|
copy = value.dup.force_encoding(Encoding::UTF_8)
|
||||||
|
raise ArgumentError, "#{label} must be valid UTF-8" unless copy.valid_encoding?
|
||||||
|
|
||||||
|
copy
|
||||||
|
end
|
||||||
|
|
||||||
|
# Recursively validates JSON values and normalizes hash keys to strings.
|
||||||
|
# @param value [Object]
|
||||||
|
# @return [Hash{String => Object}, Array<Object>, String, Integer, Float, Boolean, nil]
|
||||||
|
def self.json_value!(value)
|
||||||
|
case value
|
||||||
|
when Hash
|
||||||
|
json_hash(value)
|
||||||
|
when Array
|
||||||
|
value.map { |item| json_value!(item) }
|
||||||
|
else
|
||||||
|
json_scalar(value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Freezes the supplied value and every nested hash/array member in place.
|
||||||
|
# @param value [Object]
|
||||||
|
# @return [Object] the same value
|
||||||
|
def self.deep_freeze(value)
|
||||||
|
case value
|
||||||
|
when Hash
|
||||||
|
value.each do |key, item|
|
||||||
|
deep_freeze(key)
|
||||||
|
deep_freeze(item)
|
||||||
|
end
|
||||||
|
when Array
|
||||||
|
value.each { |item| deep_freeze(item) }
|
||||||
|
end
|
||||||
|
value.freeze
|
||||||
|
end
|
||||||
|
|
||||||
|
# @!override Vamp::Action#name
|
||||||
|
# @return [String] the catalog's dotted action name
|
||||||
|
Action = Struct.new(:name)
|
||||||
|
# @!override Vamp::Mode#name
|
||||||
|
# @return [String] the lowercase binding mode
|
||||||
|
Mode = Struct.new(:name)
|
||||||
|
|
||||||
|
# Action methods and nested modules come exclusively from the Rust catalog.
|
||||||
|
# @type [Module<Vamp::Core>]
|
||||||
|
Core = Module.new
|
||||||
|
|
||||||
|
# @param names [Array<String>] dotted core action names
|
||||||
|
# @return [Array<Module>] frozen action namespaces
|
||||||
|
def self.install_actions(names)
|
||||||
|
raise TypeError, "core action catalog must be an Array" unless names.is_a?(Array)
|
||||||
|
|
||||||
|
namespaces = [Core]
|
||||||
|
names.each { |name| install_action(name, namespaces) }
|
||||||
|
namespaces.each(&:freeze)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Frozen binding mode values, distinct from a buffer's editing state.
|
||||||
|
module Modes
|
||||||
|
# @!method self.normal
|
||||||
|
# @return [Vamp::Mode]
|
||||||
|
# @!method self.insert
|
||||||
|
# @return [Vamp::Mode]
|
||||||
|
# @!method self.command
|
||||||
|
# @return [Vamp::Mode]
|
||||||
|
# @!method self.search
|
||||||
|
# @return [Vamp::Mode]
|
||||||
|
# @!method self.visual
|
||||||
|
# @return [Vamp::Mode]
|
||||||
|
# @!method self.selection
|
||||||
|
# @return [Vamp::Mode]
|
||||||
|
# @!method self.tree
|
||||||
|
# @return [Vamp::Mode]
|
||||||
|
%w[normal insert command search visual selection tree].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
|
||||||
|
|
||||||
|
# @param value [Hash{String, Symbol => Object}]
|
||||||
|
# @return [Hash{String => Object}]
|
||||||
|
def self.json_hash(value)
|
||||||
|
value.to_h do |key, item|
|
||||||
|
raise TypeError, "option keys must be Strings or Symbols" unless key.is_a?(String) || key.is_a?(Symbol)
|
||||||
|
|
||||||
|
[string!(key.to_s, "option key"), json_value!(item)]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param value [Object]
|
||||||
|
# @return [String, Integer, Float, Boolean, nil]
|
||||||
|
def self.json_scalar(value)
|
||||||
|
case value
|
||||||
|
when String
|
||||||
|
string!(value, "option value")
|
||||||
|
when Integer, TrueClass, FalseClass, NilClass, Float
|
||||||
|
json_literal(value)
|
||||||
|
else
|
||||||
|
raise TypeError, "options must contain only JSON values"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param value [Integer, Float, Boolean, nil]
|
||||||
|
# @return [Integer, Float, Boolean, nil]
|
||||||
|
def self.json_literal(value)
|
||||||
|
raise ArgumentError, "option numbers must be finite" if value.is_a?(Float) && !value.finite?
|
||||||
|
|
||||||
|
value
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param name [String]
|
||||||
|
# @param namespaces [Array<Module>]
|
||||||
|
# @return [Symbol] the installed action method name
|
||||||
|
def self.install_action(name, namespaces)
|
||||||
|
name = action_name(name)
|
||||||
|
parts = name.split(".").drop(1)
|
||||||
|
namespace = parts[0...-1].reduce(Core) { |parent, part| action_namespace(parent, part, namespaces, name) }
|
||||||
|
leaf = parts.last
|
||||||
|
available_action!(namespace, leaf, name)
|
||||||
|
action = Action.send(:new, name.freeze).freeze
|
||||||
|
namespace.define_singleton_method(leaf) { action }
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param name [Object]
|
||||||
|
# @return [String]
|
||||||
|
def self.action_name(name)
|
||||||
|
name = string!(name, "core action name")
|
||||||
|
raise ArgumentError, "invalid core action name: #{name}" unless name.match?(/\Acore(?:\.[a-z_][a-z0-9_]*)+\z/)
|
||||||
|
|
||||||
|
name
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param parent [Module]
|
||||||
|
# @param part [String]
|
||||||
|
# @param namespaces [Array<Module>]
|
||||||
|
# @param name [String]
|
||||||
|
# @return [Module]
|
||||||
|
def self.action_namespace(parent, part, namespaces, name)
|
||||||
|
return new_action_namespace(parent, part, namespaces) unless parent.singleton_methods(false).include?(part.to_sym)
|
||||||
|
|
||||||
|
child = parent.public_send(part)
|
||||||
|
raise ArgumentError, "conflicting core action namespace: #{name}" unless child.is_a?(Module)
|
||||||
|
|
||||||
|
child
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param parent [Module]
|
||||||
|
# @param part [String]
|
||||||
|
# @param namespaces [Array<Module>]
|
||||||
|
# @return [Module]
|
||||||
|
def self.new_action_namespace(parent, part, namespaces)
|
||||||
|
child = Module.new
|
||||||
|
parent.define_singleton_method(part) { child }
|
||||||
|
namespaces << child
|
||||||
|
child
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param namespace [Module]
|
||||||
|
# @param leaf [String]
|
||||||
|
# @param name [String]
|
||||||
|
# @return [nil]
|
||||||
|
def self.available_action!(namespace, leaf, name)
|
||||||
|
return unless namespace.singleton_methods(false).include?(leaf.to_sym)
|
||||||
|
|
||||||
|
raise ArgumentError, "conflicting core action name: #{name}"
|
||||||
|
end
|
||||||
|
|
||||||
|
private_class_method :json_hash, :json_scalar, :json_literal, :install_action, :action_name,
|
||||||
|
:action_namespace, :new_action_namespace, :available_action!
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user