Files
vamp/src/ruby_plugin.rb
T

289 lines
9.8 KiB
Ruby

# 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[command_aliases 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"], startup["command_aliases"])
@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, "built-in command #{name} cannot be replaced" if Vamp.builtin_command?(name)
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