429 lines
15 KiB
Ruby
429 lines
15 KiB
Ruby
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
|
|
Thread.abort_on_exception = true
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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 repeat_last_edit
|
|
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
|
|
copy_system paste_system_before paste_system_after
|
|
select_lines clone_above clone_below select_next skip_occurrence select_all
|
|
file_picker file_explorer
|
|
].each do |name|
|
|
action = Action.new("core.#{name}".freeze).freeze
|
|
define_singleton_method(name) { action }
|
|
end
|
|
|
|
module Lsp
|
|
%w[definition diagnostics hover].each do |name|
|
|
action = Action.new("core.lsp_#{name}".freeze).freeze
|
|
define_singleton_method(name) { action }
|
|
end
|
|
end
|
|
Lsp.freeze
|
|
|
|
def self.lsp
|
|
Lsp
|
|
end
|
|
end
|
|
|
|
module Modes
|
|
%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
|
|
attr_reader :lsp
|
|
|
|
def initialize
|
|
@plugins = []
|
|
@lsp = {}
|
|
@bindings = []
|
|
@leader = "space"
|
|
@file_explorer_side = "right"
|
|
@file_picker_view = "tree"
|
|
@file_explorer_view = "tree"
|
|
@clipboard_mode = "system"
|
|
end
|
|
|
|
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
|
|
|
|
def leader=(key)
|
|
key = Vamp.string!(key, "leader key")
|
|
raise ArgumentError, "leader key must not be empty" if key.empty?
|
|
@leader = key
|
|
end
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
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
|
|
attr_reader :text, :row, :column, :mode, :path, :effects, :buffer_id, :revision, :root, :request_id
|
|
|
|
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
|
|
|
|
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, options, sender)
|
|
raise TypeError, "plugin options must be a Hash" unless options.is_a?(Hash)
|
|
@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
|
|
|
|
def self.options
|
|
@options
|
|
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: [], 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
|
|
|
|
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.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
|
|
|
|
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
|
|
|
|
def self.shutdown
|
|
callback, @shutdown_callback = @shutdown_callback, nil
|
|
callback&.call
|
|
end
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
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
|
|
|
|
request_id = nil
|
|
begin
|
|
mode, path, encoded_options = ARGV
|
|
raise ArgumentError, "expected config or plugin mode, path, and options" unless %w[config plugin].include?(mode) && path && encoded_options
|
|
raise ArgumentError, "plugin options exceed 4 MiB" if encoded_options.bytesize > Vamp::MAX_FRAME
|
|
Vamp.setup(mode, JSON.parse(encoded_options), 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
|