diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..bf8f220 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,9 @@ +AllCops: + TargetRubyVersion: 2.6 + NewCops: enable + +Style/StringLiterals: + EnforcedStyle: double_quotes + +Style/StringLiteralsInInterpolation: + EnforcedStyle: double_quotes diff --git a/docs/development.md b/docs/development.md index 46a4bba..21ee97b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 diff --git a/docs/plugins.md b/docs/plugins.md index 83ad6fb..3a0740b 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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. diff --git a/examples/config.rb b/examples/config.rb index 2e46ebe..00a7132 100644 --- a/examples/config.rb +++ b/examples/config.rb @@ -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 diff --git a/examples/plugins/greeting.rb b/examples/plugins/greeting.rb index be82cda..37aa554 100644 --- a/examples/plugins/greeting.rb +++ b/examples/plugins/greeting.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + Vamp.plugin "greeting", requires: ["text"] # @param editor [Vamp::Editor] diff --git a/src/ruby.rs b/src/ruby.rs index 5cc276a..a34ed70 100644 --- a/src/ruby.rs +++ b/src/ruby.rs @@ -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)); } diff --git a/src/ruby_config.rb b/src/ruby_config.rb new file mode 100644 index 0000000..e14a621 --- /dev/null +++ b/src/ruby_config.rb @@ -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] frozen namespace with catalog-generated Action methods + def core + Core + end + + # @return [Module] 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 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 diff --git a/src/ruby_host.rb b/src/ruby_host.rb index 38fd036..b376d12 100644 --- a/src/ruby_host.rb +++ b/src/ruby_host.rb @@ -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, 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] - Core = Module.new - - # @param names [Array] dotted core action names - # @return [Array] 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] frozen namespace with catalog-generated Action methods - def core - Core - end - - # @return [Module] 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 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] 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 + # @param arguments [Array] + # @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 + + Vamp.setup(@mode, JSON.parse(encoded_startup), method(:send_frame)) + 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" + # @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 - 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 + # @return [void] + def process_requests + loop do + @request_id = nil + line = @input.gets(MAX_FRAME + 2) + break if line.nil? - # @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] 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 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" + request = parse_request(line) + remember_request(request) + dispatch(request) + break if request["type"] == "shutdown" + end 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) - load path - send_frame.call(Vamp.startup) - if mode == "plugin" - loop do - request_id = nil - line = protocol_in.gets(Vamp::MAX_FRAME + 2) - break if line.nil? - unless line.end_with?("\n") && line.bytesize <= Vamp::MAX_FRAME + 1 + # @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 + + # @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 - 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 - exit 1 end + +Vamp::Protocol.new.run(ARGV) diff --git a/src/ruby_plugin.rb b/src/ruby_plugin.rb new file mode 100644 index 0000000..1ac346d --- /dev/null +++ b/src/ruby_plugin.rb @@ -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] 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] 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] 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] 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 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 diff --git a/src/ruby_values.rb b/src/ruby_values.rb new file mode 100644 index 0000000..6741823 --- /dev/null +++ b/src/ruby_values.rb @@ -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, 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] + Core = Module.new + + # @param names [Array] dotted core action names + # @return [Array] 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] + # @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] + # @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] + # @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