require "json" # Keep protocol IO separate even from explicit STDOUT.write and ordinary puts. protocol_out = STDOUT.dup protocol_out.binmode protocol_out.sync = true protocol_in = STDIN.dup protocol_in.binmode STDOUT.reopen(STDERR) $stdout = STDERR module Vamp MAX_FRAME = 4 * 1024 * 1024 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 Action = Struct.new(:name) Mode = Struct.new(:name) module Core %w[ insert insert_first_nonblank append append_line_end open_below open_above left right down up join_lines line_start line_end delete undo redo command normal newline backspace submit palette search search_next search_previous word_next word_previous word_end big_word_next big_word_previous big_word_end select copy cut change paste_before paste_after select_lines clone_above clone_below select_next skip_occurrence select_all ].each do |name| action = Action.new("core.#{name}".freeze).freeze define_singleton_method(name) { action } end end module Modes %w[normal insert command search visual].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 def initialize @plugins = [] @bindings = [] end def plugin(path) path = Vamp.string!(path, "plugin path") raise ArgumentError, "plugin path must not be empty" if path.empty? @plugins << path nil end def core Core end def modes Modes end 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 def response { "version" => 1, "type" => "config", "plugins" => @plugins, "bindings" => @bindings } end end class Editor attr_reader :text, :row, :column, :mode, :path, :effects def initialize(context) unless context.is_a?(Hash) && context.keys.sort == %w[column mode path 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 @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 @effects = [] @effect_bytes = 0 end def insert(text) queue("insert", text) end def replace(text) queue("replace", text) end def message(text) queue("message", text) end private 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 def self.setup(mode) @mode = mode @config = Config.new @name = nil @requires = [] @commands = {} end 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 def self.plugin(name, requires: []) 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?) @name = name nil end 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 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 } end 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"]) callback.call(editor, args) { "version" => 1, "type" => "result", "id" => request["id"], "effects" => editor.effects } end end send_frame = lambda do |frame| 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 request_id = nil begin mode, path = ARGV raise ArgumentError, "expected config or plugin mode and a path" unless %w[config plugin].include?(mode) && path Vamp.setup(mode) 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 send_frame.call(Vamp.invoke(request)) end end 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