docs: annotate Ruby configuration and plugin APIs with YARD types
This commit is contained in:
+116
-1
@@ -1,9 +1,11 @@
|
||||
require "json"
|
||||
|
||||
# Keep protocol IO separate even from explicit STDOUT.write and ordinary puts.
|
||||
# @type [IO]
|
||||
protocol_out = STDOUT.dup
|
||||
protocol_out.binmode
|
||||
protocol_out.sync = true
|
||||
# @type [IO]
|
||||
protocol_in = STDIN.dup
|
||||
protocol_in.binmode
|
||||
STDOUT.reopen(STDERR)
|
||||
@@ -13,12 +15,19 @@ Thread.abort_on_exception = true
|
||||
module Vamp
|
||||
MAX_FRAME = 4 * 1024 * 1024
|
||||
|
||||
# Validates a boundary value and returns a UTF-8 copy.
|
||||
# @param value [Object]
|
||||
# @param label [String]
|
||||
# @return [String]
|
||||
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
|
||||
|
||||
# Recursively validates JSON values and normalizes hash keys to strings.
|
||||
# @param value [Object]
|
||||
# @return [Hash{String => Object}, Array<Object>, String, Integer, Float, Boolean, nil]
|
||||
def self.json_value!(value)
|
||||
case value
|
||||
when Hash
|
||||
@@ -40,6 +49,9 @@ module Vamp
|
||||
end
|
||||
end
|
||||
|
||||
# Freezes the supplied value and every nested hash/array member in place.
|
||||
# @param value [Object]
|
||||
# @return [Object] the same value
|
||||
def self.deep_freeze(value)
|
||||
case value
|
||||
when Hash
|
||||
@@ -50,11 +62,19 @@ module Vamp
|
||||
value.freeze
|
||||
end
|
||||
|
||||
# @!override Vamp::Action#name
|
||||
# @return [String] the catalog's dotted action name
|
||||
Action = Struct.new(:name)
|
||||
# @!override Vamp::Mode#name
|
||||
# @return [String] the lowercase binding mode
|
||||
Mode = Struct.new(:name)
|
||||
|
||||
# Action methods and nested modules come exclusively from the Rust catalog.
|
||||
# @type [Module<Vamp::Core>]
|
||||
Core = Module.new
|
||||
|
||||
# @param names [Array<String>] dotted core action names
|
||||
# @return [Array<Module>] frozen action namespaces
|
||||
def self.install_actions(names)
|
||||
raise TypeError, "core action catalog must be an Array" unless names.is_a?(Array)
|
||||
namespaces = [Core]
|
||||
@@ -85,6 +105,20 @@ module Vamp
|
||||
end
|
||||
|
||||
module Modes
|
||||
# @!method self.normal
|
||||
# @return [Vamp::Mode]
|
||||
# @!method self.insert
|
||||
# @return [Vamp::Mode]
|
||||
# @!method self.command
|
||||
# @return [Vamp::Mode]
|
||||
# @!method self.search
|
||||
# @return [Vamp::Mode]
|
||||
# @!method self.visual
|
||||
# @return [Vamp::Mode]
|
||||
# @!method self.selection
|
||||
# @return [Vamp::Mode]
|
||||
# @!method self.tree
|
||||
# @return [Vamp::Mode]
|
||||
%w[normal insert command search visual selection tree].each do |name|
|
||||
mode = Mode.new(name.freeze).freeze
|
||||
define_singleton_method(name) { mode }
|
||||
@@ -95,8 +129,10 @@ module Vamp
|
||||
Mode.private_class_method :new
|
||||
|
||||
class Config
|
||||
# @return [Hash{String => Boolean, Hash{String, Symbol => Object}}] language presets/options
|
||||
attr_reader :lsp
|
||||
|
||||
# @return [void]
|
||||
def initialize
|
||||
@plugins = []
|
||||
@lsp = {}
|
||||
@@ -108,6 +144,9 @@ module Vamp
|
||||
@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")
|
||||
@@ -116,44 +155,60 @@ module Vamp
|
||||
nil
|
||||
end
|
||||
|
||||
# @param key [String]
|
||||
# @return [String]
|
||||
def leader=(key)
|
||||
key = Vamp.string!(key, "leader key")
|
||||
raise ArgumentError, "leader key must not be empty" if key.empty?
|
||||
@leader = key
|
||||
end
|
||||
|
||||
# @param side [String]
|
||||
# @return [String]
|
||||
def file_explorer_side=(side)
|
||||
side = Vamp.string!(side, "file explorer side")
|
||||
raise ArgumentError, "file explorer side must not be empty" if side.empty?
|
||||
@file_explorer_side = side
|
||||
end
|
||||
|
||||
# @param view [String]
|
||||
# @return [String]
|
||||
def file_picker_view=(view)
|
||||
view = Vamp.string!(view, "file picker view")
|
||||
raise ArgumentError, "file picker view must not be empty" if view.empty?
|
||||
@file_picker_view = view
|
||||
end
|
||||
|
||||
# @param view [String]
|
||||
# @return [String]
|
||||
def file_explorer_view=(view)
|
||||
view = Vamp.string!(view, "file explorer view")
|
||||
raise ArgumentError, "file explorer view must not be empty" if view.empty?
|
||||
@file_explorer_view = view
|
||||
end
|
||||
|
||||
# @param mode [String]
|
||||
# @return [String]
|
||||
def clipboard_mode=(mode)
|
||||
mode = Vamp.string!(mode, "clipboard mode")
|
||||
raise ArgumentError, "clipboard mode must not be empty" if mode.empty?
|
||||
@clipboard_mode = 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)
|
||||
@@ -175,6 +230,7 @@ module Vamp
|
||||
nil
|
||||
end
|
||||
|
||||
# @return [Hash{String => Object}] validated startup configuration frame
|
||||
def response
|
||||
lsp = @lsp.to_h do |language, value|
|
||||
language = Vamp.string!(language, "LSP language key")
|
||||
@@ -201,8 +257,20 @@ module Vamp
|
||||
end
|
||||
|
||||
class Editor
|
||||
attr_reader :text, :row, :column, :mode, :path, :effects, :buffer_id, :revision, :root, :request_id
|
||||
# @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)
|
||||
unless context.is_a?(Hash) && context.keys.sort == %w[buffer_id column mode path revision root row text]
|
||||
raise ArgumentError, "invalid editor context"
|
||||
@@ -226,20 +294,29 @@ module Vamp
|
||||
@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 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
|
||||
@@ -249,6 +326,10 @@ module Vamp
|
||||
end
|
||||
end
|
||||
|
||||
# @param mode [String] config or plugin
|
||||
# @param startup [Hash{String => Object}] action catalog and plugin options
|
||||
# @param sender [Proc] writes a Hash{String => Object} protocol frame
|
||||
# @return [Proc] the installed sender
|
||||
def self.setup(mode, startup, sender)
|
||||
unless startup.is_a?(Hash) && startup.keys.sort == %w[core_actions options] && startup["options"].is_a?(Hash)
|
||||
raise TypeError, "invalid Ruby startup catalog/options"
|
||||
@@ -267,16 +348,24 @@ module Vamp
|
||||
@sender = sender
|
||||
end
|
||||
|
||||
# @return [Hash{String => Object}] deeply frozen plugin options
|
||||
def self.options
|
||||
@options
|
||||
end
|
||||
|
||||
# @yieldparam config [Vamp::Config]
|
||||
# @yieldreturn [Object]
|
||||
# @return [Object] the block result
|
||||
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
|
||||
|
||||
# @param name [String]
|
||||
# @param requires [Array<String>] frontend capabilities
|
||||
# @param async [Boolean]
|
||||
# @return [nil]
|
||||
def self.plugin(name, requires: [], async: false)
|
||||
raise ArgumentError, "plugin is only available in plugin files" unless @mode == "plugin"
|
||||
raise ArgumentError, "plugin already declared" unless @name.nil?
|
||||
@@ -291,6 +380,12 @@ module Vamp
|
||||
nil
|
||||
end
|
||||
|
||||
# @param name [String] external command name
|
||||
# @param callback [Proc]
|
||||
# @yieldparam editor [Vamp::Editor] immutable context and queued effects
|
||||
# @yieldparam args [String] remaining command text
|
||||
# @yieldreturn [Object] ignored
|
||||
# @return [nil]
|
||||
def self.command(name, &callback)
|
||||
raise ArgumentError, "declare a plugin before its commands" unless @mode == "plugin" && @name
|
||||
name = string!(name, "command name")
|
||||
@@ -301,6 +396,10 @@ module Vamp
|
||||
nil
|
||||
end
|
||||
|
||||
# @param callback [Proc]
|
||||
# @yieldparam event [Hash{String => String, Hash{String => String, Integer, nil}}] frozen event/document
|
||||
# @yieldreturn [Object] ignored
|
||||
# @return [nil]
|
||||
def self.on_document(&callback)
|
||||
raise ArgumentError, "document callbacks require an asynchronous plugin" unless @mode == "plugin" && @name && @async
|
||||
raise ArgumentError, "on_document requires a block" unless callback
|
||||
@@ -309,6 +408,9 @@ module Vamp
|
||||
nil
|
||||
end
|
||||
|
||||
# @param callback [Proc]
|
||||
# @yieldreturn [Object] ignored
|
||||
# @return [nil]
|
||||
def self.on_shutdown(&callback)
|
||||
raise ArgumentError, "shutdown callbacks require an asynchronous plugin" unless @mode == "plugin" && @name && @async
|
||||
raise ArgumentError, "on_shutdown requires a block" unless callback
|
||||
@@ -317,11 +419,15 @@ module Vamp
|
||||
nil
|
||||
end
|
||||
|
||||
# @return [Object, nil] callback result, or nil when no hook is registered
|
||||
def self.shutdown
|
||||
callback, @shutdown_callback = @shutdown_callback, nil
|
||||
callback&.call
|
||||
end
|
||||
|
||||
# @param effects [Array<Hash{String, Symbol => Object}>] JSON-compatible async update effects
|
||||
# @param request_id [Integer, nil] positive unsigned 64-bit invocation ID, or background update
|
||||
# @return [nil]
|
||||
def self.publish(effects, request_id: nil)
|
||||
raise ArgumentError, "publish requires an asynchronous plugin" unless @mode == "plugin" && @name && @async
|
||||
raise TypeError, "published effects must be an Array of Hashes" unless effects.is_a?(Array) && effects.all? { |effect| effect.is_a?(Hash) }
|
||||
@@ -333,6 +439,8 @@ module Vamp
|
||||
nil
|
||||
end
|
||||
|
||||
# @param request [Hash{String => Object}] incoming document event frame
|
||||
# @return [nil]
|
||||
def self.document(request)
|
||||
unless @async && request.is_a?(Hash) && request.keys.sort == %w[document event type version] &&
|
||||
request["version"].is_a?(Integer) && request["version"] == 1 && request["type"] == "document" &&
|
||||
@@ -352,6 +460,7 @@ module Vamp
|
||||
nil
|
||||
end
|
||||
|
||||
# @return [Hash{String => Object}] configuration or plugin manifest frame
|
||||
def self.startup
|
||||
return @config.response if @mode == "config"
|
||||
raise ArgumentError, "plugin file must declare Vamp.plugin" unless @name
|
||||
@@ -359,6 +468,8 @@ module Vamp
|
||||
"requires" => @requires, "commands" => @commands.keys, "async" => @async }
|
||||
end
|
||||
|
||||
# @param request [Hash{String => Object}] invocation frame
|
||||
# @return [Hash{String => Object}] result frame with queued effects
|
||||
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" &&
|
||||
@@ -376,6 +487,9 @@ module Vamp
|
||||
end
|
||||
|
||||
protocol_mutex = Mutex.new
|
||||
# @type [Proc]
|
||||
# @param frame [Hash{String => Object}]
|
||||
# @return [Integer] bytes written by the terminating newline
|
||||
send_frame = lambda do |frame|
|
||||
protocol_mutex.synchronize do
|
||||
payload = JSON.generate(frame)
|
||||
@@ -385,6 +499,7 @@ send_frame = lambda do |frame|
|
||||
end
|
||||
end
|
||||
|
||||
# @type [Integer, nil]
|
||||
request_id = nil
|
||||
begin
|
||||
mode, path, encoded_startup = ARGV
|
||||
|
||||
Reference in New Issue
Block a user