diff --git a/docs/configuration.md b/docs/configuration.md index 0ef1021..6f6abcd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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" diff --git a/docs/development.md b/docs/development.md index b4fa2c1..46a4bba 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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). diff --git a/docs/plugins.md b/docs/plugins.md index 360b7e5..83ad6fb 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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. diff --git a/examples/config.rb b/examples/config.rb index 5009187..2e46ebe 100644 --- a/examples/config.rb +++ b/examples/config.rb @@ -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 diff --git a/examples/plugins/greeting.rb b/examples/plugins/greeting.rb index 23c238b..be82cda 100644 --- a/examples/plugins/greeting.rb +++ b/examples/plugins/greeting.rb @@ -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" diff --git a/src/ruby_host.rb b/src/ruby_host.rb index 06e3c8c..38fd036 100644 --- a/src/ruby_host.rb +++ b/src/ruby_host.rb @@ -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, 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] Core = Module.new + # @param names [Array] dotted core action names + # @return [Array] 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] frozen namespace with catalog-generated Action methods def core Core end + # @return [Module] 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 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] 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 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