Files
vamp/src/ruby_config.rb
T

499 lines
16 KiB
Ruby

# 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 [Vamp::BarConfig]
attr_reader :bar
# @return [Vamp::PopupConfig]
attr_reader :popup
# @return [void]
def initialize
@plugins = []
@lsp = {}
@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
@diff_context_lines = 10
@diff_expand_lines = 15
@diff_view_mode = "split"
@diff_split_orientation = "vertical"
@diff_follow_working_copy = true
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 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] 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
# @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,
"git_blame" => @git_blame,
"git_gutter" => @git_gutter,
"scroll_distance" => @scroll_distance,
"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 }
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<Hash{String => 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