# frozen_string_literal: true module Vamp # @param name [String, Symbol] # @return [String] def self.chrome_name!(name) name = name.to_s if name.is_a?(Symbol) name = string!(name, "label/segment name") unless /\A[A-Za-z_][A-Za-z0-9_]*\z/.match?(name) raise ArgumentError, "label/segment name must match [A-Za-z_][A-Za-z0-9_]*" end name end # @param value [Hash{String, Symbol => Object}] # @return [Hash{String => Object}] def self.chrome_hash!(value) raise TypeError, "chrome options must be a Hash" unless value.is_a?(Hash) value.each_with_object({}) do |(key, item), result| key = key.to_s if key.is_a?(Symbol) key = string!(key, "chrome key") raise ArgumentError, "duplicate chrome key: #{key}" if result.key?(key) result[key] = item end end # @param options [Hash{String, Symbol => Object}] # @return [Hash{String => Object}] def self.chrome_line!(options) chrome_hash!(options).to_h do |name, value| if %w[left center right].include?(name) raise TypeError, "#{name} must be an Array" unless value.is_a?(Array) [name, value.map { |item| chrome_name!(item) }] else chrome_name!(name) style = chrome_hash!(value).to_h do |option, item| case option when "padding", "margin", "leading_margin", "trailing_margin" unless item.is_a?(Integer) && item.between?(0, 64) raise ArgumentError, "#{option} must be an Integer between 0 and 64" end when "title", "leading_edge", "trailing_edge", "first_leading_edge" item = string!(item, option) else raise ArgumentError, "unknown chrome style option: #{option}" end [option, item] end [name, style] end end end # Collects options for the independently configured top and bottom bars. class BarConfig # @return [Hash{String => Object}] attr_reader :top, :bottom # @return [void] def initialize @top = {} @bottom = {} end # @param options [Hash{String, Symbol => Object}] # @return [Hash{String => Object}] def top=(options) @top = Vamp.chrome_line!(options) end # @param options [Hash{String, Symbol => Object}] # @return [Hash{String => Object}] def bottom=(options) @bottom = Vamp.chrome_line!(options) end end # Collects anchor options for the independently positioned command and search popups. class PopupConfig # @return [Hash{String => String}] attr_reader :command, :search # @return [void] def initialize @command = {} @search = {} end # @param options [Hash{String, Symbol => String, Symbol}] anchor in the workspace's 3x3 grid # @return [Hash{String => String}] def command=(options) @command = options!(options) end # @param options [Hash{String, Symbol => String, Symbol}] anchor in the workspace's 3x3 grid # @return [Hash{String => String}] def search=(options) @search = options!(options) end private # @param options [Hash{String, Symbol => String, Symbol}] # @return [Hash{String => String}] def options!(options) Vamp.chrome_hash!(options).to_h do |option, value| raise ArgumentError, "unknown popup option: #{option}" unless option == "anchor" value = value.to_s if value.is_a?(Symbol) value = Vamp.string!(value, "popup anchor") unless %w[top_left top_center top_right center_left center center_right bottom_left bottom_center bottom_right].include?(value) raise ArgumentError, "unknown popup anchor: #{value}" end [option, value] end end end # 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 [Hash{String => Integer, nil}] LSP-style language IDs to Tab widths attr_reader :language_tab_spaces # @return [Vamp::BarConfig] attr_reader :bar # @return [Vamp::PopupConfig] attr_reader :popup # @return [void] def initialize @plugins = [] @lsp = {} @review_directory = nil @bindings = [] @leader = "space" @file_explorer_side = "right" @file_picker_view = "tree" @file_explorer_view = "tree" @clipboard_mode = "system" @git_blame = true @git_gutter = "bar" @bar = BarConfig.new @popup = PopupConfig.new @labels = {} @scroll_distance = 0 @scroll_step_lines = nil @highlight_trailing_whitespace = false @whitespace_characters = {} @whitespace_scope = "all" @tab_spaces = nil @language_tab_spaces = {} @diff_context_lines = 10 @diff_expand_lines = 15 @diff_view_mode = "split" @diff_split_orientation = "vertical" @diff_follow_working_copy = true @pull_request_review_requested_symbol = "🙏" @pull_request_approved_symbol = "👍" @pull_request_sort = nil @pull_request_filters = nil @resolve_history_layout = "columns" 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 path [String, nil] relative to the configuration file; nil uses the XDG data directory # @return [String, nil] def review_directory=(path) path = nonempty_string!(path, "review_directory") unless path.nil? raise ArgumentError, "review_directory must not contain NUL" if path&.include?("\0") @review_directory = path 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 enabled [Boolean] # @return [Boolean] def git_blame=(enabled) raise TypeError, "git_blame must be true or false" unless [true, false].include?(enabled) @git_blame = enabled end # @param style [String] "bar", "symbols", "line_numbers", or "none" def git_gutter=(style) style = nonempty_string!(style, "git gutter") raise ArgumentError, "git gutter must be bar, symbols, line_numbers, or none" unless %w[bar symbols line_numbers none].include?(style) @git_gutter = style end # @param values [Hash{String, Symbol => String}] # @return [Hash] def labels=(values) @labels = Vamp.chrome_hash!(values).to_h do |name, text| [Vamp.chrome_name!(name), Vamp.string!(text, "label text")] end end # @param distance [Integer, nil] nonnegative cursor margin, or nil to center # @return [Integer, nil] def scroll_distance=(distance) @scroll_distance = distance.nil? ? nil : diff_line_count!(distance, "scroll_distance", 0) end # @param lines [Integer, nil] positive screen rows, or nil for half the viewport # @return [Integer, nil] def scroll_step_lines=(lines) @scroll_step_lines = lines.nil? ? nil : diff_line_count!(lines, "scroll_step_lines", 1) end # @param enabled [Boolean] # @return [Boolean] def highlight_trailing_whitespace=(enabled) raise TypeError, "highlight_trailing_whitespace must be true or false" unless [true, false].include?(enabled) @highlight_trailing_whitespace = enabled end # @param characters [Hash{String => String}] whitespace character to single-cell marker # @return [Hash{String => String}] def whitespace_characters=(characters) raise TypeError, "whitespace_characters must be a Hash" unless characters.is_a?(Hash) characters.each do |whitespace, marker| unless [whitespace, marker].all? { |value| value.is_a?(String) && value.length == 1 } raise TypeError, "whitespace_characters keys and values must be single-character Strings" end end @whitespace_characters = characters.dup end # @param scope [String] "all" or "leading" # @return [String] def whitespace_scope=(scope) scope = Vamp.string!(scope, "whitespace_scope") raise ArgumentError, "whitespace_scope must be all or leading" unless %w[all leading].include?(scope) @whitespace_scope = scope end # @param spaces [Integer, nil] spaces per Tab, or nil for a literal tab # @return [Integer, nil] def tab_spaces=(spaces) @tab_spaces = tab_spaces!(spaces) end # @param languages [Hash{String => Integer, nil}] LSP-style language IDs to Tab widths # @return [Hash{String => Integer, nil}] def language_tab_spaces=(languages) @language_tab_spaces = language_tab_spaces!(languages) 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 # @param mode [String] "split" or "unified" # @return [String] def diff_view_mode=(mode) mode = nonempty_string!(mode, "diff view mode") raise ArgumentError, "diff view mode must be split or unified" unless %w[split unified].include?(mode) @diff_view_mode = mode end # @param orientation [String] "vertical" or "horizontal" # @return [String] def diff_split_orientation=(orientation) orientation = nonempty_string!(orientation, "diff split orientation") unless %w[vertical horizontal].include?(orientation) raise ArgumentError, "diff split orientation must be vertical or horizontal" end @diff_split_orientation = orientation end # @param enabled [Boolean] # @return [Boolean] def diff_follow_working_copy=(enabled) raise TypeError, "diff_follow_working_copy must be true or false" unless [true, false].include?(enabled) @diff_follow_working_copy = enabled end # @param symbol [String] empty to hide the review-request indicator # @return [String] def pull_request_review_requested_symbol=(symbol) @pull_request_review_requested_symbol = pull_request_symbol!(symbol, "pull_request_review_requested_symbol") end # @param symbol [String] empty to hide the approval indicator # @return [String] def pull_request_approved_symbol=(symbol) @pull_request_approved_symbol = pull_request_symbol!(symbol, "pull_request_approved_symbol") end # @param fields [Hash{String, Symbol => Hash}, nil] explicit ranks and sort directions # @return [Hash, nil] def pull_request_sort=(fields) return @pull_request_sort = nil if fields.nil? raise TypeError, "pull_request_sort must be a Hash or nil" unless fields.is_a?(Hash) ranks = [] normalized = Vamp.chrome_hash!(fields).to_h do |field, options| unless %w[requested updated_at approved created_at].include?(field) raise ArgumentError, "unknown pull_request_sort field: #{field}" end raise TypeError, "pull_request_sort #{field} must be a Hash" unless options.is_a?(Hash) options = Vamp.chrome_hash!(options) unknown = options.keys - %w[precedence direction] raise ArgumentError, "unknown pull_request_sort option: #{unknown.first}" unless unknown.empty? rank = options["precedence"] unless rank.nil? || (rank.is_a?(Integer) && rank.between?(1, 4)) raise ArgumentError, "pull_request_sort precedence must be between 1 and 4" end raise ArgumentError, "duplicate pull_request_sort precedence: #{rank}" if rank && ranks.include?(rank) ranks << rank if rank if options.key?("direction") direction = options["direction"] direction = direction.to_s if direction.is_a?(Symbol) unless %w[asc desc].include?(direction) raise ArgumentError, "pull_request_sort direction must be asc or desc" end options["direction"] = direction end [field, options] end @pull_request_sort = normalized end # @param filters [Array, nil] selected personal review buckets # @return [Array, nil] def pull_request_filters=(filters) return @pull_request_filters = nil if filters.nil? raise TypeError, "pull_request_filters must be an Array or nil" unless filters.is_a?(Array) normalized = filters.map do |filter| filter = filter.to_s if filter.is_a?(Symbol) unless %w[requested approved other].include?(filter) raise ArgumentError, "unknown pull_request_filters value: #{filter.inspect}" end filter end unless normalized.uniq.length == normalized.length raise ArgumentError, "duplicate pull_request_filters value" end @pull_request_filters = normalized end # @param layout [String] "columns" or "base_below" # @return [String] def resolve_history_layout=(layout) layout = nonempty_string!(layout, "resolve history layout") unless %w[columns base_below].include?(layout) raise ArgumentError, "resolve history layout must be columns or base_below" end @resolve_history_layout = layout 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, "review_directory" => @review_directory, "leader" => @leader, "file_explorer_side" => @file_explorer_side, "file_picker_view" => @file_picker_view, "file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode, "git_blame" => @git_blame, "git_gutter" => @git_gutter, "scroll_distance" => @scroll_distance, "scroll_step_lines" => @scroll_step_lines, "highlight_trailing_whitespace" => @highlight_trailing_whitespace, "whitespace_characters" => @whitespace_characters, "whitespace_scope" => @whitespace_scope, "tab_spaces" => @tab_spaces, "language_tab_spaces" => language_tab_spaces!(@language_tab_spaces), "top_bar" => @bar.top, "bottom_bar" => @bar.bottom, "labels" => @labels, "popup" => { "command" => @popup.command, "search" => @popup.search }, "diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines, "diff_view_mode" => @diff_view_mode, "diff_split_orientation" => @diff_split_orientation, "diff_follow_working_copy" => @diff_follow_working_copy, "pull_request_review_requested_symbol" => @pull_request_review_requested_symbol, "pull_request_approved_symbol" => @pull_request_approved_symbol, "pull_request_sort" => @pull_request_sort, "pull_request_filters" => @pull_request_filters, "resolve_history_layout" => @resolve_history_layout } end private def pull_request_symbol!(symbol, label) symbol = Vamp.string!(symbol, label) raise ArgumentError, "#{label} must not contain control characters" if symbol.match?(/[[:cntrl:]]/) symbol end # @param spaces [Integer, nil] # @return [Integer, nil] def tab_spaces!(spaces) unless spaces.nil? || (spaces.is_a?(Integer) && spaces.between?(1, 255)) raise ArgumentError, "tab_spaces must be nil or an Integer between 1 and 255" end spaces end # @param languages [Hash{String => Integer, nil}] # @return [Hash{String => Integer, nil}] def language_tab_spaces!(languages) raise TypeError, "language_tab_spaces must be a Hash" unless languages.is_a?(Hash) languages.to_h do |language, spaces| language = nonempty_string!(language, "Tab language") raise ArgumentError, "Tab language must not contain control characters" if language.match?(/[[:cntrl:]]/) [language, tab_spaces!(spaces)] end end # @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 text and presentation 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 # @param name [String, Symbol] # @param text [String] named session-wide plain text; empty clears it # @return [nil] def label(name, text) name = Vamp.chrome_name!(name) queue("label", text, name) 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, message, or label # @param text [String] # @param name [String, nil] # @return [nil] def queue(type, text, name = nil) text = Vamp.string!(text, "effect text") @effect_bytes += text.bytesize + (name ? name.bytesize : 0) raise ArgumentError, "effects exceed 4 MiB" if @effect_bytes > MAX_FRAME effect = { "type" => type, "text" => text } effect["name"] = name if name @effects << effect nil end end end