# 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" @diff_context_lines = 10 @diff_expand_lines = 15 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 # @param lines [Integer] nonnegative context lines around each diff change # @return [Integer] def diff_context_lines=(lines) @diff_context_lines = diff_line_count!(lines, "diff_context_lines", 0) end # @param lines [Integer] positive lines revealed per diff expansion # @return [Integer] def diff_expand_lines=(lines) @diff_expand_lines = diff_line_count!(lines, "diff_expand_lines", 1) end # @return [Module] frozen namespace with catalog-generated Action methods def core Core end # @return [Module] 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, "diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines } end private # @param value [Integer] # @param label [String] # @param minimum [Integer] # @return [Integer] def diff_line_count!(value, label, minimum) raise TypeError, "#{label} must be an Integer" unless value.is_a?(Integer) maximum = (1 << 64) - 1 unless value.between?(minimum, maximum) raise ArgumentError, "#{label} must be between #{minimum} and #{maximum}" end value end # @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 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