268 lines
8.2 KiB
Ruby
268 lines
8.2 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module Vamp
|
|
# Collects and validates the Ruby startup configuration DSL.
|
|
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)
|
|
@leader = nonempty_string!(key, "leader key")
|
|
end
|
|
|
|
# @param side [String]
|
|
# @return [String]
|
|
def file_explorer_side=(side)
|
|
@file_explorer_side = nonempty_string!(side, "file explorer side")
|
|
end
|
|
|
|
# @param view [String]
|
|
# @return [String]
|
|
def file_picker_view=(view)
|
|
@file_picker_view = nonempty_string!(view, "file picker view")
|
|
end
|
|
|
|
# @param view [String]
|
|
# @return [String]
|
|
def file_explorer_view=(view)
|
|
@file_explorer_view = nonempty_string!(view, "file explorer view")
|
|
end
|
|
|
|
# @param mode [String]
|
|
# @return [String]
|
|
def clipboard_mode=(mode)
|
|
@clipboard_mode = nonempty_string!(mode, "clipboard mode")
|
|
end
|
|
|
|
# @return [Module<Vamp::Core>] frozen namespace with catalog-generated Action methods
|
|
def core
|
|
Core
|
|
end
|
|
|
|
# @return [Module<Vamp::Modes>] 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 = binding_target(command)
|
|
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
|
|
{ "version" => 1, "type" => "config", "plugins" => @plugins, "lsp" => lsp_options, "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
|
|
|
|
private
|
|
|
|
# @param value [String]
|
|
# @param label [String]
|
|
# @return [String]
|
|
def nonempty_string!(value, label)
|
|
value = Vamp.string!(value, label)
|
|
raise ArgumentError, "#{label} must not be empty" if value.empty?
|
|
|
|
value
|
|
end
|
|
|
|
# @param command [Vamp::Action, String, nil]
|
|
# @return [String, nil]
|
|
def binding_target(command)
|
|
case command
|
|
when Action
|
|
command.name
|
|
when String
|
|
external_command(command)
|
|
when nil
|
|
nil
|
|
else
|
|
raise TypeError, "binding command must be an Action, String, or nil"
|
|
end
|
|
end
|
|
|
|
# @param command [String]
|
|
# @return [String]
|
|
def external_command(command)
|
|
name = Vamp.string!(command, "binding command")
|
|
if name.start_with?("core.") || Vamp.builtin_command?(name)
|
|
raise TypeError, "use config.core for built-in actions"
|
|
end
|
|
|
|
name
|
|
end
|
|
|
|
# @return [Hash{String => Boolean, Hash{String => Object}}]
|
|
def lsp_options
|
|
@lsp.to_h do |language, value|
|
|
language = lsp_language(language)
|
|
[language, lsp_value(value, language)]
|
|
end
|
|
end
|
|
|
|
# @param language [String]
|
|
# @return [String]
|
|
def lsp_language(language)
|
|
language = Vamp.string!(language, "LSP language key")
|
|
invalid = language.empty? || language.match?(/[[:cntrl:]]/)
|
|
raise ArgumentError, "LSP language keys must not be empty or contain control characters" if invalid
|
|
|
|
language
|
|
end
|
|
|
|
# @param value [Boolean, Hash{String, Symbol => Object}]
|
|
# @param language [String]
|
|
# @return [Boolean, Hash{String => Object}]
|
|
def lsp_value(value, language)
|
|
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
|
|
end
|
|
end
|
|
|
|
# Provides an immutable editor snapshot and queues effects for an invocation.
|
|
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<Hash{String => 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)
|
|
validate_context(context)
|
|
assign_context_strings(context)
|
|
assign_context_numbers(context, request_id)
|
|
validate_context_position
|
|
validate_context_revision
|
|
@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 context [Hash{String => String, Integer, nil}]
|
|
# @return [void]
|
|
def validate_context(context)
|
|
return if context.is_a?(Hash) && context.keys.sort == %w[buffer_id column mode path revision root row text]
|
|
|
|
raise ArgumentError, "invalid editor context"
|
|
end
|
|
|
|
# @param context [Hash{String => String, Integer, nil}]
|
|
# @return [void]
|
|
def assign_context_strings(context)
|
|
@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
|
|
end
|
|
|
|
# @param context [Hash{String => String, Integer, nil}]
|
|
# @param request_id [Integer]
|
|
# @return [void]
|
|
def assign_context_numbers(context, request_id)
|
|
@buffer_id = context["buffer_id"]
|
|
@revision = context["revision"]
|
|
@request_id = request_id
|
|
@row = context["row"]
|
|
@column = context["column"]
|
|
end
|
|
|
|
# @return [void]
|
|
def validate_context_position
|
|
return if @row.is_a?(Integer) && @row.positive? && @column.is_a?(Integer) && @column.positive?
|
|
|
|
raise ArgumentError, "context row and column must be positive integers"
|
|
end
|
|
|
|
# @return [void]
|
|
def validate_context_revision
|
|
return if @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
|
|
|
|
# @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
|
|
end
|