refactor: align Ruby host with RuboCop while retaining double quotes

This commit is contained in:
2026-10-09 20:41:12 +02:00
parent bbb61c2fdd
commit 2c3c73e5b9
10 changed files with 904 additions and 529 deletions
+9
View File
@@ -0,0 +1,9 @@
AllCops:
TargetRubyVersion: 2.6
NewCops: enable
Style/StringLiterals:
EnforcedStyle: double_quotes
Style/StringLiteralsInInterpolation:
EnforcedStyle: double_quotes
+11 -2
View File
@@ -10,14 +10,23 @@ Local verification and implementation constraints. See [architecture](architectu
cargo test
cargo clippy --all-targets -- -D warnings
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
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
+1 -1
View File
@@ -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.
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.
+2 -6
View File
@@ -1,3 +1,5 @@
# frozen_string_literal: true
# @param config [Vamp::Config]
Vamp.configure do |config|
config.leader = "space"
@@ -7,12 +9,6 @@ Vamp.configure do |config|
config.file_picker_view = "tree"
config.bind "ctrl+j", config.core.down, 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"
# Native LSP is disabled by default; install a server and explicitly opt in.
# config.lsp["rust"] = true # built-in rust-analyzer preset
+2
View File
@@ -1,3 +1,5 @@
# frozen_string_literal: true
Vamp.plugin "greeting", requires: ["text"]
# @param editor [Vamp::Editor]
+39 -2
View File
@@ -10,7 +10,15 @@ use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
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_QUEUE: usize = 64;
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]
fn startup_and_config_hangs_are_bounded() {
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}))
.unwrap();
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);
thread::sleep(Duration::from_millis(1));
}
+265
View File
@@ -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
+104 -520
View File
@@ -1,549 +1,133 @@
# frozen_string_literal: true
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
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)
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
# Owns the subprocess wire protocol, isolated from user stdout and callbacks.
class Protocol
# @return [void]
def initialize
@plugins = []
@lsp = {}
@bindings = []
@leader = "space"
@file_explorer_side = "right"
@file_picker_view = "tree"
@file_explorer_view = "tree"
@clipboard_mode = "system"
# Capture physical streams; globals may be replaced by user code.
@output = STDOUT.dup # rubocop:disable Style/GlobalStdStream
@output.binmode
@output.sync = true
@input = STDIN.dup # rubocop:disable Style/GlobalStdStream
@input.binmode
@mutex = Mutex.new
# 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
# @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)
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)
# @param arguments [Array<String>] mode, source path, and startup JSON
# @return [Object] the shutdown callback's result
def run(arguments)
start(arguments)
send_frame(Vamp.startup)
process_requests if @mode == "plugin"
Vamp.shutdown
ensure
# Includes SystemExit/Interrupt, without rescuing or hiding callback errors.
error = $! # rubocop:disable Style/SpecialGlobalVars -- Native exception state at the process boundary.
report_error(error) if error
end
private
# @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
# @param arguments [Array<String>]
# @return [Boolean] the source load result
def start(arguments)
@mode, path, encoded_startup = arguments
valid = %w[config plugin].include?(@mode) && path && encoded_startup
raise ArgumentError, "expected config or plugin mode, path, and startup data" unless valid
raise ArgumentError, "startup frame too large" if encoded_startup.bytesize > MAX_FRAME
# @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}]
# @return [Integer] bytes written by the terminating newline
send_frame = lambda do |frame|
protocol_mutex.synchronize do
payload = JSON.generate(frame)
raise ArgumentError, "protocol frame exceeds 4 MiB" if payload.bytesize > Vamp::MAX_FRAME
protocol_out.write(payload)
protocol_out.write("\n")
end
end
# @type [Integer, nil]
request_id = nil
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)
Vamp.setup(@mode, JSON.parse(encoded_startup), method(:send_frame))
load path
send_frame.call(Vamp.startup)
if mode == "plugin"
end
# @param frame [Hash{String => Object}]
# @return [Integer] bytes written by the terminating newline
def send_frame(frame)
@mutex.synchronize do
payload = JSON.generate(frame)
raise ArgumentError, "outgoing frame too large" if payload.bytesize > MAX_FRAME
@output.write(payload)
@output.write("\n")
end
end
# @return [void]
def process_requests
loop do
request_id = nil
line = protocol_in.gets(Vamp::MAX_FRAME + 2)
@request_id = nil
line = @input.gets(MAX_FRAME + 2)
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"
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"]
when "invoke"
send_frame.call(Vamp.invoke(request))
send_frame(Vamp.invoke(request))
when "document"
Vamp.document(request)
when "shutdown"
unless request.keys.sort == %w[type version] && request["version"].is_a?(Integer) && request["version"] == 1
raise ArgumentError, "invalid shutdown frame"
end
break
validate_shutdown(request)
else
raise ArgumentError, "unknown protocol frame"
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
Vamp.shutdown
rescue Exception => error
# No callback effects are emitted when loading or invoking raises.
message = "#{error.class}: #{error.message}".encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
message = message.each_char.take(2048).join
frame = { "version" => 1, "type" => "error", "message" => message }
frame["id"] = request_id unless request_id.nil?
begin
send_frame.call(frame)
rescue Exception
# Rust observes EOF if even the error cannot be framed.
end
# @param error [Exception]
# @return [void]
def report_error(error)
# No callback effects survive an exception. Bound and sanitize the message.
message = "#{error.class}: #{error.message}".encode("UTF-8", invalid: :replace, undef: :replace)
frame = { "version" => 1, "type" => "error", "message" => message.each_char.take(2048).join }
frame["id"] = @request_id unless @request_id.nil?
send_frame(frame)
ensure
# If even reporting fails, Rust observes EOF and invalidates the host.
exit 1
end
end
end
Vamp::Protocol.new.run(ARGV)
+287
View File
@@ -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
+186
View File
@@ -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