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.
```ruby
# @param config [Vamp::Config]
Vamp.configure do |config|
config.leader = "space"
config.clipboard_mode = "system"
+6
View File
@@ -13,6 +13,12 @@ cargo fmt --check
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
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
Vamp.plugin "greeting", requires: ["text"]
# @param editor [Vamp::Editor]
# @param args [String]
Vamp.command "greet" do |editor, args|
name = args.empty? ? "world" : args
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.
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.
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|
config.leader = "space"
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"]
# @param editor [Vamp::Editor]
# @param args [String]
Vamp.command "greet" do |editor, args|
# @type [String]
name = args.empty? ? "world" : args
editor.insert "Hello, #{name}!\n"
editor.message "Greeting inserted by Ruby"
end
# @param editor [Vamp::Editor]
# @param _args [String]
Vamp.command "upper" do |editor, _args|
editor.replace editor.text.upcase
editor.message "Buffer uppercased by Ruby"
+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