docs: annotate Ruby configuration and plugin APIs with YARD types

This commit is contained in:
2026-10-09 20:19:44 +02:00
parent 9629655206
commit bbb61c2fdd
6 changed files with 133 additions and 1 deletions
+116 -1
View File
@@ -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