Files
vamp/docs/plugins.md
T
2026-10-12 14:02:04 +02:00

8.5 KiB

Ruby plugins

README

Writing trusted Ruby extensions. Load plugins through Ruby configuration; see architecture for module ownership.

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"
  editor.message "Greeting inserted by Ruby"
end

Run :greet vamp, or press g with the example configuration. examples/plugins/greeting.rb also provides :upper to uppercase the buffer.

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 for Solargraph navigation and hover. Vamp.command declares typed block arguments (Vamp::Editor, String); the example also annotates them explicitly. Context readers and configuration methods are annotated in src/ruby_config.rb; asynchronous callback/effect contracts live in src/ruby_plugin.rb. These comments do not enforce types at runtime; existing boundary validation still does.

editor.insert(text), editor.replace(text), editor.message(text), and editor.label(name, 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.

Disabling a plugin removes its commands and bindings until the next launch. There is no hot reload. See development limits for host deadlines and frame bounds.

Capabilities and registration

Built-in Ex commands register as the protected core feature in the same command registry used by plugins. External plugins cannot claim core status, replace existing commands, or disable core. User configuration can override any keybinding without replacing the core implementation. :plugins lists active features and frontend capabilities.

A feature declares all capabilities it needs. Registration is atomic: if any requirement is unavailable or any command conflicts, none of its commands are installed. For example, Vamp.plugin "canvas", requires: ["graphics"] will be refused by this TUI. Capability names are extensible strings, not a terminal-vs-GUI switch. Capability declarations express compatibility; they are not security permissions.

Asynchronous plugins

config.plugin "plugin.rb", options: { ... } passes immutable JSON-compatible options as Vamp.options. Declare Vamp.plugin "name", requires: ["text", "diagnostics"], async: true to opt into asynchronous command results and document notifications. Existing plugins remain synchronous.

Vamp.on_document { |event| ... } receives a deeply frozen hash with "event" ("open", "change", "save", or "close") and "document" ("id", "revision", "path", "root", "text"). Already-open documents are delivered at registration; changes include hidden-buffer edits, undo/redo, and plugin edits. Save-as sends close/open for the changed path. Vamp.on_shutdown { ... } supplies a shutdown hook. Callbacks must enqueue slow work rather than blocking host input.

Worker threads call Vamp.publish(effects, request_id: nil). Background effects are message, label, revision-tagged diagnostics, or request-correlated navigate/hover; they cannot edit text. Diagnostics carry buffer_id, revision, and items, each with end-exclusive start/end positions, severity (error, warning, information, hint), and message. Positions are zero-based grapheme coordinates, not bytes, terminal cells, or UTF-16. Empty item lists clear the owner's annotations.

Navigation carries the source buffer_id/revision, destination path/row/column, and the invocation's editor.request_id passed to publish. Only the latest still-current source request may move the primary. Closed/stale revisions are ignored; malformed current updates retire their owner. Failed navigation reports an error without discarding buffer edits. Hosts use bounded queues/frames; protocol failure or host exit retires commands, bindings, pending requests, and owned diagnostics.

hover carries source buffer_id/revision and multiline text, with the invocation's editor.request_id passed to publish. Only applicable current requests in Normal mode can show a popup; stale results and replies while another modal list is focused are ignored. Text validates before a batch changes state; empty text clears the popup, and owner retirement dismisses its content.

Named label updates

editor.label(name, text) sets a session-wide named value initialized by config.labels. Place that name in a top or bottom bar anchor group to display it. This presentation effect does not edit text, move carets, change undo history, or dismiss completion. It also works in read-only diff views.

Vamp.plugin "build_label"

# @param editor [Vamp::Editor]
# @param args [String]
Vamp.command "build_label" do |editor, args|
  editor.label("build_status", args)
end

Place :build_status in a bar and load this plugin with config.plugin, then use :build_label passed; :build_label clears the value and hides its segment. For asynchronous data, an async: true plugin's worker publishes a named value without a request ID:

Vamp.publish([{ type: "label", name: "build_status", text: "passed" }])

Collect custom data and choose its refresh interval in the plugin; the builtin :battery already refreshes from native OS data. There are no render callbacks. Stop worker threads in Vamp.on_shutdown. Changed values redraw while idle and persist across tabs, without depending on the originating buffer or revision. Publishing an unchanged value does not request a redraw. The last value remains until overwritten or cleared, including after plugin retirement.

Names match [A-Za-z_][A-Za-z0-9_]* and text must be a string. Control characters become replacement glyphs; an empty string clears the value. Updates share the existing 4 MiB protocol/text bounds; a rejected batch cannot partially change labels or other state.

Messaging

Rust and Ruby exchange UTF-8, newline-delimited JSON objects with version: 1. Startup returns manifest (name, required capabilities, commands, async opt-in) or config (plugin paths/options, bindings, top_bar and bottom_bar chrome maps, popup anchor options, and named labels). invoke requests carry an ID, command, arguments, and context; result replies carry the matching ID and typed effects (insert, replace, message, label). A label effect carries name and text strings. Async hosts also receive document/shutdown and send update frames with optional request IDs and typed presentation/navigation effects. Errors use error frames. Unknown fields, wrong versions, and mismatched replies are rejected. Frames and aggregate effect text are bounded to 4 MiB.

Before loading a script, Rust supplies a bounded startup JSON envelope containing core_actions and options. The action catalog initializes the Ruby interface; only the separate user-options object is exposed through Vamp.options, including when user options contain a key named core_actions.

JSON uses Ruby's standard library. Protocol Buffers is not installed or required; there is no frontend/network wire protocol yet. The frontend currently calls the Rust library directly.