# 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] 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"], 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] 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] 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