Files
vamp/src/ruby_host.rb
T
2026-10-09 14:57:27 +02:00

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