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 # @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) 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) 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 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] 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" 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 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 case request.is_a?(Hash) && request["type"] when "invoke" send_frame.call(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 else raise ArgumentError, "unknown protocol frame" end 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