feat: implement modular modal editor and isolated Ruby plugins
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
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
|
||||
|
||||
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 bind(key, command)
|
||||
key = Vamp.string!(key, "binding key")
|
||||
command = Vamp.string!(command, "binding command")
|
||||
raise ArgumentError, "binding key must be one character" unless key.length == 1
|
||||
raise ArgumentError, "binding command must not be empty" if command.empty?
|
||||
@bindings << { "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
|
||||
Reference in New Issue
Block a user