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
+1
View File
@@ -9,6 +9,7 @@ By default, vamp loads `$XDG_CONFIG_HOME/vamp/config.rb`, or `~/.config/vamp/con
Rejected configuration opens a centered floating **Configuration error** window, sized like the floating file explorer. It shows the selected config path, full failure reason, and explains that default settings and bindings are in use. The diagnostic remains visible even if background messages arrive; editing keys and paste are ignored until **Enter** or **Escape** dismisses it. Use arrows (or Ctrl+h/j/k/l) to scroll long paths/reasons horizontally or vertically, and **Home** to reset the view. Correct the config and restart vamp to apply it. Invalid individual preferences/bindings that do not reject the whole configuration still report ordinary notices. Rejected configuration opens a centered floating **Configuration error** window, sized like the floating file explorer. It shows the selected config path, full failure reason, and explains that default settings and bindings are in use. The diagnostic remains visible even if background messages arrive; editing keys and paste are ignored until **Enter** or **Escape** dismisses it. Use arrows (or Ctrl+h/j/k/l) to scroll long paths/reasons horizontally or vertically, and **Home** to reset the view. Correct the config and restart vamp to apply it. Invalid individual preferences/bindings that do not reject the whole configuration still report ordinary notices.
```ruby ```ruby
# @param config [Vamp::Config]
Vamp.configure do |config| Vamp.configure do |config|
config.leader = "space" config.leader = "space"
config.clipboard_mode = "system" config.clipboard_mode = "system"
+6
View File
@@ -13,6 +13,12 @@ cargo fmt --check
ruby -c src/ruby_host.rb ruby -c src/ruby_host.rb
``` ```
## Ruby types
Use YARD `@param`, `@return`, and `@yieldparam` annotations for Ruby APIs, and `@type` for locals where inference needs help. Examples annotate DSL block parameters before the call, without adding runtime assignments or dependencies. `Vamp::Action` / `Vamp::Mode` field types and the dynamically defined mode methods use Solargraph-supported directives.
Core action methods are generated from Rust's authoritative catalog at host startup. Their namespace is annotated, but static Solargraph analysis cannot enumerate those generated members. Do not maintain a second action list in Ruby comments; use the [configuration action reference](configuration.md#action-reference).
## Test organization ## Test organization
Rust tests live alongside their modules; native LSP integration checks are in [`tests/lsp.rs`](../tests/lsp.rs). Rust tests live alongside their modules; native LSP integration checks are in [`tests/lsp.rs`](../tests/lsp.rs).
+4
View File
@@ -7,6 +7,8 @@ Writing trusted Ruby extensions. Load plugins through [Ruby configuration](confi
```ruby ```ruby
Vamp.plugin "greeting", requires: ["text"] Vamp.plugin "greeting", requires: ["text"]
# @param editor [Vamp::Editor]
# @param args [String]
Vamp.command "greet" do |editor, args| Vamp.command "greet" do |editor, args|
name = args.empty? ? "world" : args name = args.empty? ? "world" : args
editor.insert "Hello, #{name}!\n" editor.insert "Hello, #{name}!\n"
@@ -18,6 +20,8 @@ Run `:greet vamp`, or press `g` with the example configuration. [`examples/plugi
Callbacks receive an immutable primary-only context snapshot: `editor.text`, `editor.row`, `editor.column`, `editor.mode`, `editor.path`, `editor.buffer_id`, `editor.revision`, `editor.root`, and `editor.request_id`. Row/column are **one-based grapheme positions**; mode is `NORMAL`; path is a string or `nil`. Arguments are the remaining command text, without surrounding whitespace. Commands run once regardless of target count; successful text effects collapse to the primary before editing once. The plugin API does not expose selections. Callbacks receive an immutable primary-only context snapshot: `editor.text`, `editor.row`, `editor.column`, `editor.mode`, `editor.path`, `editor.buffer_id`, `editor.revision`, `editor.root`, and `editor.request_id`. Row/column are **one-based grapheme positions**; mode is `NORMAL`; path is a string or `nil`. Arguments are the remaining command text, without surrounding whitespace. Commands run once regardless of target count; successful text effects collapse to the primary before editing once. The plugin API does not expose selections.
The Ruby host and examples use [YARD types](https://solargraph.org/guides/yard) for Solargraph navigation and hover. `Vamp.command` declares typed block arguments (`Vamp::Editor`, `String`); the example also annotates them explicitly. Context readers, configuration methods, and asynchronous callback/effect contracts are annotated in [`src/ruby_host.rb`](../src/ruby_host.rb). These comments do not enforce types at runtime; existing boundary validation still does.
`editor.insert(text)`, `editor.replace(text)`, and `editor.message(text)` queue effects. The context remains the pre-command snapshot even after queuing an effect. All effects are validated before the backend changes anything; buffer changes from one successful invocation form one undo action. Exceptions, process exits, timeouts, malformed replies, and invalid effects disable that plugin and discard its effects. Other plugins and built-ins remain available. Plugin `puts`, explicit `STDOUT.write`, and stderr cannot overwrite the TUI; diagnostic output is discarded, while exceptions appear in the status message. `editor.insert(text)`, `editor.replace(text)`, and `editor.message(text)` queue effects. The context remains the pre-command snapshot even after queuing an effect. All effects are validated before the backend changes anything; buffer changes from one successful invocation form one undo action. Exceptions, process exits, timeouts, malformed replies, and invalid effects disable that plugin and discard its effects. Other plugins and built-ins remain available. Plugin `puts`, explicit `STDOUT.write`, and stderr cannot overwrite the TUI; diagnostic output is discarded, while exceptions appear in the status message.
This is crash isolation, **not a security sandbox**: Ruby configuration and plugins have your user account's filesystem/process permissions. Load only trusted scripts. On Unix, hosts run in their own process groups and their groups are killed on shutdown. This is crash isolation, **not a security sandbox**: Ruby configuration and plugins have your user account's filesystem/process permissions. Load only trusted scripts. On Unix, hosts run in their own process groups and their groups are killed on shutdown.
+1
View File
@@ -1,3 +1,4 @@
# @param config [Vamp::Config]
Vamp.configure do |config| Vamp.configure do |config|
config.leader = "space" config.leader = "space"
config.clipboard_mode = "system" # "separate" keeps ordinary y/d/c/p/P internal config.clipboard_mode = "system" # "separate" keeps ordinary y/d/c/p/P internal
+5
View File
@@ -1,11 +1,16 @@
Vamp.plugin "greeting", requires: ["text"] Vamp.plugin "greeting", requires: ["text"]
# @param editor [Vamp::Editor]
# @param args [String]
Vamp.command "greet" do |editor, args| Vamp.command "greet" do |editor, args|
# @type [String]
name = args.empty? ? "world" : args name = args.empty? ? "world" : args
editor.insert "Hello, #{name}!\n" editor.insert "Hello, #{name}!\n"
editor.message "Greeting inserted by Ruby" editor.message "Greeting inserted by Ruby"
end end
# @param editor [Vamp::Editor]
# @param _args [String]
Vamp.command "upper" do |editor, _args| Vamp.command "upper" do |editor, _args|
editor.replace editor.text.upcase editor.replace editor.text.upcase
editor.message "Buffer uppercased by Ruby" editor.message "Buffer uppercased by Ruby"
+116 -1
View File
@@ -1,9 +1,11 @@
require "json" require "json"
# Keep protocol IO separate even from explicit STDOUT.write and ordinary puts. # Keep protocol IO separate even from explicit STDOUT.write and ordinary puts.
# @type [IO]
protocol_out = STDOUT.dup protocol_out = STDOUT.dup
protocol_out.binmode protocol_out.binmode
protocol_out.sync = true protocol_out.sync = true
# @type [IO]
protocol_in = STDIN.dup protocol_in = STDIN.dup
protocol_in.binmode protocol_in.binmode
STDOUT.reopen(STDERR) STDOUT.reopen(STDERR)
@@ -13,12 +15,19 @@ Thread.abort_on_exception = true
module Vamp module Vamp
MAX_FRAME = 4 * 1024 * 1024 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) def self.string!(value, label)
raise TypeError, "#{label} must be a String" unless value.is_a?(String) 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? 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) value.dup.force_encoding(Encoding::UTF_8)
end 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) def self.json_value!(value)
case value case value
when Hash when Hash
@@ -40,6 +49,9 @@ module Vamp
end end
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) def self.deep_freeze(value)
case value case value
when Hash when Hash
@@ -50,11 +62,19 @@ module Vamp
value.freeze value.freeze
end end
# @!override Vamp::Action#name
# @return [String] the catalog's dotted action name
Action = Struct.new(:name) Action = Struct.new(:name)
# @!override Vamp::Mode#name
# @return [String] the lowercase binding mode
Mode = Struct.new(:name) Mode = Struct.new(:name)
# Action methods and nested modules come exclusively from the Rust catalog.
# @type [Module<Vamp::Core>]
Core = Module.new Core = Module.new
# @param names [Array<String>] dotted core action names
# @return [Array<Module>] frozen action namespaces
def self.install_actions(names) def self.install_actions(names)
raise TypeError, "core action catalog must be an Array" unless names.is_a?(Array) raise TypeError, "core action catalog must be an Array" unless names.is_a?(Array)
namespaces = [Core] namespaces = [Core]
@@ -85,6 +105,20 @@ module Vamp
end end
module Modes 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| %w[normal insert command search visual selection tree].each do |name|
mode = Mode.new(name.freeze).freeze mode = Mode.new(name.freeze).freeze
define_singleton_method(name) { mode } define_singleton_method(name) { mode }
@@ -95,8 +129,10 @@ module Vamp
Mode.private_class_method :new Mode.private_class_method :new
class Config class Config
# @return [Hash{String => Boolean, Hash{String, Symbol => Object}}] language presets/options
attr_reader :lsp attr_reader :lsp
# @return [void]
def initialize def initialize
@plugins = [] @plugins = []
@lsp = {} @lsp = {}
@@ -108,6 +144,9 @@ module Vamp
@clipboard_mode = "system" @clipboard_mode = "system"
end 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: {}) def plugin(path, options: {})
path = Vamp.string!(path, "plugin path") path = Vamp.string!(path, "plugin path")
raise ArgumentError, "plugin path must not be empty" if path.empty? || path.include?("\0") raise ArgumentError, "plugin path must not be empty" if path.empty? || path.include?("\0")
@@ -116,44 +155,60 @@ module Vamp
nil nil
end end
# @param key [String]
# @return [String]
def leader=(key) def leader=(key)
key = Vamp.string!(key, "leader key") key = Vamp.string!(key, "leader key")
raise ArgumentError, "leader key must not be empty" if key.empty? raise ArgumentError, "leader key must not be empty" if key.empty?
@leader = key @leader = key
end end
# @param side [String]
# @return [String]
def file_explorer_side=(side) def file_explorer_side=(side)
side = Vamp.string!(side, "file explorer side") side = Vamp.string!(side, "file explorer side")
raise ArgumentError, "file explorer side must not be empty" if side.empty? raise ArgumentError, "file explorer side must not be empty" if side.empty?
@file_explorer_side = side @file_explorer_side = side
end end
# @param view [String]
# @return [String]
def file_picker_view=(view) def file_picker_view=(view)
view = Vamp.string!(view, "file picker view") view = Vamp.string!(view, "file picker view")
raise ArgumentError, "file picker view must not be empty" if view.empty? raise ArgumentError, "file picker view must not be empty" if view.empty?
@file_picker_view = view @file_picker_view = view
end end
# @param view [String]
# @return [String]
def file_explorer_view=(view) def file_explorer_view=(view)
view = Vamp.string!(view, "file explorer view") view = Vamp.string!(view, "file explorer view")
raise ArgumentError, "file explorer view must not be empty" if view.empty? raise ArgumentError, "file explorer view must not be empty" if view.empty?
@file_explorer_view = view @file_explorer_view = view
end end
# @param mode [String]
# @return [String]
def clipboard_mode=(mode) def clipboard_mode=(mode)
mode = Vamp.string!(mode, "clipboard mode") mode = Vamp.string!(mode, "clipboard mode")
raise ArgumentError, "clipboard mode must not be empty" if mode.empty? raise ArgumentError, "clipboard mode must not be empty" if mode.empty?
@clipboard_mode = mode @clipboard_mode = mode
end end
# @return [Module<Vamp::Core>] frozen namespace with catalog-generated Action methods
def core def core
Core Core
end end
# @return [Module<Vamp::Modes>] frozen namespace whose methods return Mode values
def modes def modes
Modes Modes
end 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) def bind(key, command, mode: Modes.normal)
key = Vamp.string!(key, "binding key") key = Vamp.string!(key, "binding key")
raise TypeError, "binding mode must be a value from config.modes" unless mode.is_a?(Mode) raise TypeError, "binding mode must be a value from config.modes" unless mode.is_a?(Mode)
@@ -175,6 +230,7 @@ module Vamp
nil nil
end end
# @return [Hash{String => Object}] validated startup configuration frame
def response def response
lsp = @lsp.to_h do |language, value| lsp = @lsp.to_h do |language, value|
language = Vamp.string!(language, "LSP language key") language = Vamp.string!(language, "LSP language key")
@@ -201,8 +257,20 @@ module Vamp
end end
class Editor 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) def initialize(context, request_id)
unless context.is_a?(Hash) && context.keys.sort == %w[buffer_id column mode path revision root row text] unless context.is_a?(Hash) && context.keys.sort == %w[buffer_id column mode path revision root row text]
raise ArgumentError, "invalid editor context" raise ArgumentError, "invalid editor context"
@@ -226,20 +294,29 @@ module Vamp
@effect_bytes = 0 @effect_bytes = 0
end end
# @param text [String]
# @return [nil]
def insert(text) def insert(text)
queue("insert", text) queue("insert", text)
end end
# @param text [String]
# @return [nil]
def replace(text) def replace(text)
queue("replace", text) queue("replace", text)
end end
# @param text [String]
# @return [nil]
def message(text) def message(text)
queue("message", text) queue("message", text)
end end
private private
# @param type [String] insert, replace, or message
# @param text [String]
# @return [nil]
def queue(type, text) def queue(type, text)
text = Vamp.string!(text, "effect text") text = Vamp.string!(text, "effect text")
@effect_bytes += text.bytesize @effect_bytes += text.bytesize
@@ -249,6 +326,10 @@ module Vamp
end end
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) def self.setup(mode, startup, sender)
unless startup.is_a?(Hash) && startup.keys.sort == %w[core_actions options] && startup["options"].is_a?(Hash) unless startup.is_a?(Hash) && startup.keys.sort == %w[core_actions options] && startup["options"].is_a?(Hash)
raise TypeError, "invalid Ruby startup catalog/options" raise TypeError, "invalid Ruby startup catalog/options"
@@ -267,16 +348,24 @@ module Vamp
@sender = sender @sender = sender
end end
# @return [Hash{String => Object}] deeply frozen plugin options
def self.options def self.options
@options @options
end end
# @yieldparam config [Vamp::Config]
# @yieldreturn [Object]
# @return [Object] the block result
def self.configure def self.configure
raise ArgumentError, "configure is only available in config files" unless @mode == "config" raise ArgumentError, "configure is only available in config files" unless @mode == "config"
raise ArgumentError, "configure requires a block" unless block_given? raise ArgumentError, "configure requires a block" unless block_given?
yield @config yield @config
end end
# @param name [String]
# @param requires [Array<String>] frontend capabilities
# @param async [Boolean]
# @return [nil]
def self.plugin(name, requires: [], async: false) def self.plugin(name, requires: [], async: false)
raise ArgumentError, "plugin is only available in plugin files" unless @mode == "plugin" raise ArgumentError, "plugin is only available in plugin files" unless @mode == "plugin"
raise ArgumentError, "plugin already declared" unless @name.nil? raise ArgumentError, "plugin already declared" unless @name.nil?
@@ -291,6 +380,12 @@ module Vamp
nil nil
end 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) def self.command(name, &callback)
raise ArgumentError, "declare a plugin before its commands" unless @mode == "plugin" && @name raise ArgumentError, "declare a plugin before its commands" unless @mode == "plugin" && @name
name = string!(name, "command name") name = string!(name, "command name")
@@ -301,6 +396,10 @@ module Vamp
nil nil
end 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) def self.on_document(&callback)
raise ArgumentError, "document callbacks require an asynchronous plugin" unless @mode == "plugin" && @name && @async raise ArgumentError, "document callbacks require an asynchronous plugin" unless @mode == "plugin" && @name && @async
raise ArgumentError, "on_document requires a block" unless callback raise ArgumentError, "on_document requires a block" unless callback
@@ -309,6 +408,9 @@ module Vamp
nil nil
end end
# @param callback [Proc]
# @yieldreturn [Object] ignored
# @return [nil]
def self.on_shutdown(&callback) def self.on_shutdown(&callback)
raise ArgumentError, "shutdown callbacks require an asynchronous plugin" unless @mode == "plugin" && @name && @async raise ArgumentError, "shutdown callbacks require an asynchronous plugin" unless @mode == "plugin" && @name && @async
raise ArgumentError, "on_shutdown requires a block" unless callback raise ArgumentError, "on_shutdown requires a block" unless callback
@@ -317,11 +419,15 @@ module Vamp
nil nil
end end
# @return [Object, nil] callback result, or nil when no hook is registered
def self.shutdown def self.shutdown
callback, @shutdown_callback = @shutdown_callback, nil callback, @shutdown_callback = @shutdown_callback, nil
callback&.call callback&.call
end 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) def self.publish(effects, request_id: nil)
raise ArgumentError, "publish requires an asynchronous plugin" unless @mode == "plugin" && @name && @async 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) } 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 nil
end end
# @param request [Hash{String => Object}] incoming document event frame
# @return [nil]
def self.document(request) def self.document(request)
unless @async && request.is_a?(Hash) && request.keys.sort == %w[document event type version] && 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" && request["version"].is_a?(Integer) && request["version"] == 1 && request["type"] == "document" &&
@@ -352,6 +460,7 @@ module Vamp
nil nil
end end
# @return [Hash{String => Object}] configuration or plugin manifest frame
def self.startup def self.startup
return @config.response if @mode == "config" return @config.response if @mode == "config"
raise ArgumentError, "plugin file must declare Vamp.plugin" unless @name raise ArgumentError, "plugin file must declare Vamp.plugin" unless @name
@@ -359,6 +468,8 @@ module Vamp
"requires" => @requires, "commands" => @commands.keys, "async" => @async } "requires" => @requires, "commands" => @commands.keys, "async" => @async }
end end
# @param request [Hash{String => Object}] invocation frame
# @return [Hash{String => Object}] result frame with queued effects
def self.invoke(request) def self.invoke(request)
unless request.is_a?(Hash) && request.keys.sort == %w[args command context id type version] && 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" && request["version"].is_a?(Integer) && request["version"] == 1 && request["type"] == "invoke" &&
@@ -376,6 +487,9 @@ module Vamp
end end
protocol_mutex = Mutex.new protocol_mutex = Mutex.new
# @type [Proc]
# @param frame [Hash{String => Object}]
# @return [Integer] bytes written by the terminating newline
send_frame = lambda do |frame| send_frame = lambda do |frame|
protocol_mutex.synchronize do protocol_mutex.synchronize do
payload = JSON.generate(frame) payload = JSON.generate(frame)
@@ -385,6 +499,7 @@ send_frame = lambda do |frame|
end end
end end
# @type [Integer, nil]
request_id = nil request_id = nil
begin begin
mode, path, encoded_startup = ARGV mode, path, encoded_startup = ARGV