Extension protocol, version 1

How a program becomes a rhun extension. Also as Markdown for agents, with its grammar and the manifest's JSON Schema.

Status: v1, the protocol of rhun 0.18.0. Everything below is what rhun does. The manifest's description and [settings], the setting event and on defaulting to startup were added before 0.18.0 shipped; from its release on, a later rhun adds to the protocol only as v1.1 and later, announced by fields after hello 1 and by RHUN_PROTOCOL, and never changes what is written here. The canonical copy is v1/v1.md in rhun-editor/extension-protocol, next to the line grammar v1/v1.abnf and the manifest schema v1/v1.json; rhun's repository carries a copy at docs/extensions/v1.md.

An extension is a program. rhun starts it when it is needed, talks to it over its standard input and output with one text line per message, and stops it when rhun quits. The program may be written in any language. rhun never waits for it: everything the extension does is applied when its reply arrives, and only if the document it refers to has not changed meanwhile.

1. Files

An installed extension is a folder ~/.config/rhun/extensions/OWNER/NAME/ (on Windows %APPDATA%\rhun\extensions\OWNER\NAME\). rhun --extensions DIR uses DIR instead of the folder, for development and tests. Inside:

PathPurpose
extension.inithe manifest, required
the programnamed by the manifest's run.* entries or run with runtime
themes/*.theme, syntax/*.syndata files: themes join the list after the user's; grammars go after the user's and before the built-in ones, so an extension can refine a built-in language and the user still wins
anything elsethe extension's own; the folder is read-only to the extension once installed

The extension's writable folder is its state folder, ~/.local/state/rhun/extensions/OWNER--NAME/ (%LOCALAPPDATA%\rhun\extensions\OWNER--NAME\), created before the first start. HOME, TMPDIR and the common cache variables point into it (section 4).

2. Manifest

INI, UTF-8, key = value. A line whose first character is # or ; is a comment; there are no comments after a value (color = #2a9d8f keeps its #). Unknown keys are ignored. Required keys are marked.

# required: [a-z0-9][a-z0-9-]*, equal to the folder name
name = format
# required: the GitHub owner, equal to the parent folder name
owner = rhun-editor
# required: shown on the Extensions page and after command titles
title = Format
# one line, optional
description = Formats the open file with the formatter you already have.
# optional: a PNG in the folder, square, 128 pixels recommended
icon = icon.png
# optional: the badge's background, #rrggbb
color = #2a9d8f
# required
version = 0.1.0
# required: the protocol version this extension speaks
protocol = 1
# the program for that target, relative to the folder
run.linux-x86_64 = bin/format-linux-x86_64
run.macos-arm64 = bin/format-macos-arm64
run.windows-x64 = bin/format-windows-x64.exe
# or, for a script: runtime, a program found on PATH; run, the script, passed as the runtime's
# first argument; requires, shown when the runtime is missing or too old (not checked by rhun)
# runtime = python3
# run = main.py
# requires = python3 >= 3.10
# extra arguments, space separated, optional
args =
# when to start: startup | command | lang:LANG | open:GLOB | project
# several, space separated; "startup" is the default: an installed
# extension runs. Invoking one of the manifest's commands always
# starts it, whatever `on` says
on = startup

[commands]
format_document = Format Document | ctrl+shift+i
# key = Title | default keys (space separated, as in [keys] of the config; "|" and keys optional)

# its own settings, shown on its page; optional (section 6d)
[settings]
# string (default) | bool | int | choice
indent.type = int
indent.default = 2
indent.min = 1
indent.max = 8
indent.title = Indent
indent.description = Spaces per level for JSON

# which events to send, with name patterns: * for all, *.go a suffix, Makefile an exact name,
# matched against the file's name (not its folder), case-sensitively; the default is "no". A
# document is reported at all only when `opened` names it; the other keys select among those.
# An untitled document matches only *. ready, active, closed (of a reported document), project,
# run, resync and bye are always sent
[subscribe]
opened = *
saved = *.go *.json
changed = no
cursor = no
selection = no
# every change as deltas (section 6a)
edits = *.go
# the pointer resting on a position
hover = *.go

# section 12: the language features it answers for these files (completion with the characters
# that ask at once, signature help, definition, references, symbols, code actions, rename,
# format); a provider gets opened and edits for them as if subscribed (it cannot opt out), and
# the first of them to open starts it, whatever `on` says
[provides]
complete = *.go
complete.triggers = . :
signature = *.go
signature.triggers = ( ,
definition = *.go
references = *.go
symbols = *.go
actions = *.go
rename = *.go
format = *.go

# section 5
[needs]
documents = write
project = read
home = none
network = no
terminal = no
run = gofmt rustfmt

The badge that stands for the extension in the title bar, its dropdown and the Extensions page is icon drawn on color. Without icon it is the title's first letter on color, and without color on one of eight colors picked by the name. rhun reads the icon the first time a badge needs it and keeps it scaled to the sizes it is drawn at, its corners rounded as the badge's are; a file over 64 KB, over 256 pixels a side, not a PNG, or outside the folder leaves the letter. Transparent parts of the icon show the color.

A manifest that fails to parse, lacks a required key, has a name or owner other than its folders' names, names no run entry for this target and no runtime, names a runtime that is not on PATH, or a program that is missing or not executable, is recorded with its reason and the extension is not started; Settings shows the reason. Commands are registered from the manifest before the process exists, so the palette shows them and the keys work from startup.

Command names in the palette and in [keys] are NAME.command, for example format.format_document; titles are shown as "Format Document (Format)". The user's [keys] win over an extension's default keys, and a key a built-in command has stays with it; between two extensions the one found first in the folder listing keeps a contested key (Settings > Extensions shows the conflict). A command invoked while its program is still starting is delivered after ready and the catch-up; a second one invoked before that replaces it.

Activation

on lists conditions; the extension starts when any holds:

ConditionStarts when
startuprhun has drawn its first frame (the default)
commandone of its commands is invoked (always, whatever on says)
lang:LANGa document whose grammar name is LANG is open or opened (names as print-state shows them)
open:PATTERNa document whose file name matches the pattern (*.ext or an exact name) is open or opened
projecta project folder is open
([provides])a document one of its [provides] patterns matches is open or opened

One process per extension; a start in progress absorbs further triggers.

3. Transport

Messages are lines of UTF-8 text ending in LF (CR before it is ignored), at most 65536 bytes. Fields are separated by single spaces. The last field of a message may contain spaces: it runs to the end of the line. A message whose last numeric field is a length LEN is followed by a body: exactly LEN bytes after the LF, which may contain anything; the next message starts at the byte after the body. Bodies are capped per message (section 7). On Windows, write standard output in binary mode: a text stream (the C runtime's default, Python's sys.stdout) turns each LF into CR LF, so a body no longer has the length its message gives and rhun drops what it carried (a format on save reports "Formatting failed").

Three kinds of lines:

  • Requests: ID verb args…, ID a decimal integer from 1 to 2147483647, unique among the

sender's requests that have not been answered. Both sides send requests.

  • Replies: ok ID args… or error ID CODE text…, in any order relative to other replies,

always to a request the other side sent. A reply may carry a body, as ok ID VERSION LEN.

  • Events: 0 name args…, id 0, never answered.

Error codes: syntax (the line could not be parsed, or a request's arguments could not), unknown (verb), needs (section 5; the text names the capability, as needs documents=write), nodoc (no such document, or not a text document), nofile (open: no such file), readonly (the document cannot be edited: a diff view), stale (the version is not current; the text is the current version), badpos (a position is outside the document or inside a multibyte character), toobig (more diagnostics, items or commands than the cap), busy (a prompt is open, or the palette), cancelled (the user dismissed the prompt, or it was closed), refused (a cmd not on the list of section 8a, a register of a name in use). After a syntax error the message is dropped; three syntax errors in a row, with no well-formed request between them, stop the extension. A line of 64 KB with no LF in it, or a body over its cap, stops it at once: rhun does not read megabytes to discard them.

Requests are handled in the order they arrive and each is complete before the next is read, except the prompts (choose, ask, term), which are answered when the user acts: cancel of an open prompt closes it and answers it cancelled; cancel of anything else is ok and does nothing.

An extension may answer a request from rhun at its leisure, or ignore events, but it must keep reading: rhun's outgoing queue per extension is 1 MB, and an extension that leaves it full for 10 seconds is stopped.

4. Lifecycle

  1. rhun creates the state folder, opens the log file ~/.local/state/rhun/extensions/OWNER--NAME.log for the extension's standard error (cut back to empty when it passes 1 MB: the program keeps writing to it, so it cannot be rotated), and starts the program with its folder as the working directory and this environment and nothing else:

| Variable | Value | | --- | --- | | PATH | rhun's own PATH | | HOME | the state folder | | TMPDIR | STATE/tmp, created | | XDG_CACHE_HOME, GOCACHE, GOMODCACHE, GOPATH, npm_config_cache | under the state folder | | LANG, LC_ALL, TZ | rhun's, when set | | xcrun_db, DEVELOPER_DIR | macOS: xcrun's cache in the state folder (Xcode's /usr/bin tools look themselves up there, not in the user's shared cache; when it is missing, rhun fills it for the tools run names from outside the sandbox while the extension starts, so lookups do not run xcodebuild inside it); rhun's DEVELOPER_DIR, when set | | RHUN_VERSION | rhun's version | | RHUN_PROTOCOL | the highest protocol revision rhun speaks: 1.1 in rhun 0.18 (v1.1 says what it adds) | | RHUN_EXTENSION | OWNER/NAME | | RHUN_TARGET | linux-x86_64, macos-arm64 or windows-x64 | | RHUN_STATE | the state folder | | RHUN_PROJECT | the project folder, or unset | | RHUN_SOCKET | the control socket path, only in a --extensions DIR session with --control | | Windows only: SYSTEMROOT, WINDIR, COMSPEC, PATHEXT, SYSTEMDRIVE, PROGRAMDATA, the PROGRAMFILES and COMMONPROGRAMFILES family, NUMBER_OF_PROCESSORS, PROCESSOR_ARCHITECTURE, OS | rhun's, when set (Windows programs need several of these to start) | | Windows only: TEMP, TMP | STATE/tmp | | Windows only: USERPROFILE; APPDATA; LOCALAPPDATA | the state folder; STATE/appdata; STATE/localappdata |

On Windows a manifest's runtime = python3 is looked for as python3, then python, then py (the Python launcher), on PATH; the Microsoft Store's stubs in WindowsApps are used only when nothing else is there.

In a --extensions DIR session the extension also inherits rhun's own environment (tests pass their settings that way); an installed extension gets only the table above.

  1. The extension writes hello 1 (no id; further fields, if any, name capabilities of later protocol versions and are ignored by a rhun that does not know them) within 5 seconds. Anything else first, or nothing, ends the process ("the first line was not hello 1", "no hello within 5 s"). A hello with another version: "protocol version not supported (rhun speaks 1)".
  2. When hello named features (section 12) rhun knows, it first sends 0 features NAME…, the ones it will use. Then it answers with the event 0 ready PROJECT (- without a project) and then catch-up, a snapshot taken between frames: opened for every open document the subscription matches, active, and cursor/selection for the active one when subscribed. The extension sees the same world as one started at launch.
  3. Normal operation until rhun quits, the user stops the extension, or a limit trips.
  4. Stop: rhun sends 0 bye, waits 1 second, sends SIGTERM to the process group, waits 1 second, SIGKILL. When rhun itself quits it sends bye, waits up to 300 ms for the programs to leave, and sends SIGTERM to the rest. (Windows: the job object ends the process.) Everything the extension registered at runtime (its commands, their keys), its marks, status text, per-document options and open prompt is removed. A program that closes its own input or output is stopped at once.
  5. A process that exits on its own is "crashed" with its exit status; its manifest commands stay in the palette and invoking one starts it again. Three crashes within a minute leave it stopped: invoking a command then shows the reason, and only Settings restarts it. The same holds for every stop a limit caused. A failed start (no such file, permission) is "failed to start: reason".

If rhun dies, the extension dies: on Linux through the parent-death signal, on Windows through the job object; on macOS the extension's standard input reaches end of file, and an extension should exit when it does. The signal reaches the extension's own process, not programs it started: an extension that runs a language server must stop it when its input ends.

A program that cannot be executed (a bad interpreter line, a binary for another system) is "failed to start: the program could not be executed".

At most 16 extensions run at once; the seventeenth waits, idle, and starts when a slot frees.

5. Capabilities: [needs]

KeyValuesGates in the protocol
documentsnone (default), read, writetext, range, diagnostics, hover need read (write includes it); replace, insert need write
projectnone (default), read, writethe project folder on disk, enforced by the kernel sandbox on Linux and macOS (Windows relies on the declaration)
homenone (default), readthe user's home outside the project (the same)
networkno (default), yes(the same)
terminalno (default), yesterm
runprogram names, space separatedprograms the extension may start (the same); the state folder $RHUN_STATE is always writable. On macOS a name that is one of Xcode's tools in /usr/bin (git, clangd, clang, swift, sourcekit-lsp) also lets it read Xcode (or the command line tools) and run the tool there, as that /usr/bin program does

A request without its capability is answered error ID needs documents=write and nothing happens. The install dialog and the extension's page show the block in plain words ("read and change the files you have open, read the files in your project, run gofmt"). On Linux (Landlock) and macOS (the sandbox) the kernel holds the program to project, home, network and run; where it cannot (Windows, a Linux kernel older than 6.2), the extension still runs and its page says that this system cannot hold it to these permissions.

6. Documents and positions

  • A document id DOC is an integer, unique for the session, given in opened. A closed

and reopened file gets a new id; a reloaded file keeps its id and gets a new version.

  • A version is an integer that increases with every change the user or an extension makes;

by how much is not specified (a replace that deletes and inserts counts twice). Equal versions mean identical text. opened, saved, changed, text and range carry it; replace and insert require the current one.

  • A position is LINE COL: 1-based line, 1-based byte column within the line's UTF-8

bytes. COL must not fall inside a multibyte sequence (badpos). COL may be one past the line's last byte, which is the position of the line's LF (or the end of the document on the last line). A range L1 C1 L2 C2 is half-open: from the first position up to, not including, the second; a range that ends at L+1 1 includes line L's LF. Documents with CRLF endings report positions on the stored bytes.

  • The text of a document is its bytes as rhun stores them: lines end in LF. A file with CRLF

endings is converted when read and written back with CRLF; the protocol never sees CR.

  • An extension that subscribes to edits receives every change as deltas (the edits event)

and keeps its own copy of the text in step; resync tells it that deltas were lost and it must fetch text again. An extension that subscribes only to changed fetches what it needs when it needs it. Choose changed + text for a whole-document pass (a checker, a formatter), edits when a copy of the text must stay in step cheaply (a language server). Both report the extension's own replace and insert too.

6a. The edits body

One record per change, in the order they happened, each applying to the text as the previous record left it:

START DEL INS
<INS bytes>

START is a byte offset from the start of the document, DEL the number of bytes deleted there, INS the number of bytes inserted there (the bytes follow the record's LF; the next record starts right after them). A keystroke is one record; a paste one; an extension's replace is a delete record and an insert record. Undo and redo are records too. The event's VERSION is the document's version after the last record; an extension that applies them to its copy of the text at the previous version has the text at VERSION. Positions in records are byte offsets because that is what the editor has; converting them to lines is the extension's job, and it has the text to do it with.

6b. Diagnostics

Each line of a diagnostics body is L1 C1 L2 C2 SEVERITY MESSAGE: a half-open range of the document at the given version, a severity among error, warning, info and hint, and the message to show (the rest of the line; no line feeds). rhun draws a dot in the gutter and an underline under the range, counts errors and warnings in the status bar, and shows the message when the pointer rests on the range. Marks move with later edits (a mark whose range is deleted disappears); an extension that wants exact marks sends them again after changed. Marks belong to the extension that sent them: another extension's marks on the same document are untouched, and they are removed when the extension stops. A range ending at L+1 1 marks the line's end.

6c. Per-document options

option DOC KEY VALUE overrides one of the user's settings for one document: tab_width (1 to 16), insert_spaces, trim_trailing_whitespace, final_newline (yes or no); VALUE - removes this extension's override of that key. Precedence is extension over user over default; when two extensions set the same key the last writer wins; an override is removed when its extension stops or the document closes, and the user's setting applies again. Options never reach the config file. An unknown key or value is syntax.

6d. The extension's own settings

[settings] declares settings the user changes on the extension's page, each KEY.FIELD = VALUE: type (string, the default; bool, shown as a switch, yes or no; int, a whole number from 0 to 2147483647 shown with − and +, within min and max; choice, a row of options, space separated), default, title, description. Keys are [a-z0-9_]. rhun keeps the values in ~/.config/rhun/extensions.ini (setting.KEY = VALUE under [owner/name], only those that differ from their defaults) and sends each as 0 setting KEY VALUE right after ready, before the catch-up, and again whenever the user changes it. A value always fits its type: a default that does not is made to (a switch's is no unless it says yes, a number is brought into its range, a choice's is its first option).

7. Limits

WhatLimitOn overflow
extension.ini65536 bytesdiscovery records a failed extension; installation rejects the package before the permission dialog
a line65536 bytesthe extension is stopped ("a line over 64 KB without an end")
replace, insert body4 MBthe extension is stopped ("a body over its cap"): rhun will not read megabytes to discard them
diagnostics2000 lines; a message 200 bytestoobig; messages are cut
hover body4000 bytescut
pending edits per document per frame1 MBthe records are dropped and resync is sent
toast body200 bytestruncated at a character boundary
choose1000 items; the prompt and each item 200 bytestoobig; cut at a character boundary
ask prompt200 bytescut
term body4096 bytes; plain text onlytoobig; syntax
register64 commands per extension; NAME 40 bytes, TITLE 80 bytestoobig; syntax; cut
status body80 bytestruncated at a character boundary
rhun's outgoing queue per extension1 MB; stopped after 10 s full; 8 MB stops it at oncestopped: "not reading"
one parsing run2 msthe rest waits for the next window
messages from one extension per 16 ms window500 messages or 256 KBthe rest waits for the next window; 30 windows in a row with 100 messages or 64 KB or more is "flooding" and stops it
views (12a)8 per extension, 32 in all; a picture 1 MB, 20000 shapes, 200000 points; a rendering covers at most 64 times the view's areatoobig; syntax line N; the shapes past it are left out
a completion list (12b)1 MB and 4096 items; rhun keeps the best 50 for what was typedthe list is dropped whole
signature help (12c)64 KBhidden
locations, symbols (12d, 12e)1 MB and 10000 recordsthe answer is dropped whole
code actions (12f)4 MB (their edits included) and 1000 actionsthe answer is dropped whole
a workspace edit (12g, 12h)4 MB, 1000 files, 100000 editsnothing changes, and rhun says why
any answer's body (ok ID LEN)4 MBthe extension is stopped ("a body over its cap")
requests rhun waits on, all extensions64none sent until answers come

8. Messages

Events from rhun (id 0)

EventFieldsWhen
readyPROJECTafter hello; - without a project
openedDOC VERSION LANG PATHa document the opened subscription matches is opened, and at catch-up; LANG is the grammar name with spaces as _ (Visual_Basic) or -; PATH absolute or - for an untitled document; PATH is the last field and may contain spaces (a path with a line feed in it is not supported)
activeDOCthe active tab changed, and at catch-up (0 when no document is active)
savedDOC VERSIONthe document was written to disk
changedDOC VERSIONthe document changed, by the user or by any extension's replace/insert (this one's included); at most once per frame per document
cursorDOC LINE COLthe cursor moved, with or without a selection; once per frame; also for the document that just became active, so a subscriber always knows the active document's cursor
selectionDOC L1 C1 L2 C2a selection exists and changed, or just collapsed (then both positions are equal); once per frame; L1 C1 is the anchor, L2 C2 the cursor
closedDOCthe tab closed
projectPATHthe project changed (- when closed); running extensions restart so RHUN_PROJECT and disk permissions describe the new project; on = project extensions start for the new one
runCOMMAND DOCone of the extension's commands was invoked, by its full name NAME.command; DOC is the active document or 0. It follows the frame's other events (opened, active, cursor, …), and the catch-up when the command started the extension, so what it names is known by then. There is no request for the cursor: an extension that needs it subscribes to cursor
byerhun is about to stop the extension
settingKEY VALUEone of its own settings (section 6d): each after ready, and on every change; VALUE is the rest of the line
editsDOC VERSION LEN + bodythe changes since the last frame to a document the edits subscription names, as records that lead from the text the extension has to the text at VERSION (section 6a); not sent for the frame in which the document was reported opened
resyncDOC VERSIONdeltas were lost (more than 1 MB of edits piled up in one frame): fetch text. A reload from disk arrives as deltas
hoverDOC LINE COLthe pointer has rested half a second on that position of a document the hover subscription names; answer with a hover request to show something, or not at all
actionLEN + bodythe user chose one of its code actions that has DATA (section 12f): the DATA, after the action's edit was made

Requests from the extension

RequestReplyNeedsEffect
ID text DOCok ID VERSION LEN + bodydocuments readthe whole text
ID range DOC L1 C1 L2 C2ok ID VERSION LEN + bodydocuments readthe bytes of the range
ID replace DOC VERSION L1 C1 L2 C2 LEN + bodyok ID NEWVERSIONdocuments writereplaces the range with the body as one undo step, if VERSION is current (stale otherwise); the cursor inside the range moves to the end of the new text; the body is inserted as given (bytes, no conversion)
ID insert DOC VERSION LEN + bodyok ID NEWVERSIONdocuments writeinserts at the cursor, replacing the selection if any
ID apply LEN + bodyok IDdocuments writea workspace edit (section 12g) across documents and files: checked whole, then each document changed as one undo step; files that are not open require read access through project or home (or belong to the extension or its state folder), and open in tabs, unsaved. Nothing changes on needs (a file is outside those permissions), syntax, nofile (a path that is not absolute, or a file that cannot be opened), nodoc (a document it names closed), stale, readonly, badpos (a range outside its text, or two edits that overlap) or toobig; the TEXT is a sentence (for stale too, not a version: several documents may be named). For a language server's workspace/applyEdit
ID select DOC L1 C1 L2 C2ok IDselects the range with the cursor at L2 C2; equal positions place the cursor; a document that is not on screen keeps the positions for when it is
ID toast LEN + bodyok IDa message on screen for two seconds
ID status LEN + bodyok IDthe extension's status-bar text; an empty body clears it
ID open LINE COL PATHok ID DOCopens an existing text file in a tab (or activates its tab) with the cursor at the position, clamped to the document; 0 0 keeps the file's cursor; a relative PATH is inside the project; a closed file requires read access through project or home (or must belong to the extension or its state folder), checked after resolving symbolic links; needs if the path is outside those permissions; nofile for a missing file, nodoc for an image; the opened event follows at the next frame
ID cancel ID2ok IDwithdraws request ID2 if rhun has not started on it
ID register NAME KEYS TITLEok IDa command for this session: EXTENSION.NAME in the palette as "TITLE (Extension)", with the default key KEYS (one combination with a modifier, ctrl+alt+k; - for none; the user's [keys] and keys already bound win, and a bare key is ignored); it is invoked like a manifest command (the run event) and removed when the extension stops; NAME is [a-z0-9_]+; a name in use is refused
ID option DOC KEY VALUEok IDdocuments write for trim_trailing_whitespace and final_newline (they change what is saved)a per-document setting (section 6c), until the document closes or the extension stops; VALUE - removes this extension's override
ID choose LEN + bodyok ID Na pick list in the palette: the body's first line is the prompt, the other lines the items; the user filters and picks, and N is the 1-based number of the item picked; error ID cancelled when the user dismisses it; busy while a prompt or the palette is open
ID ask LEN + bodyok ID LEN + bodya text prompt with the body as its label; the answer comes back as the reply's body (empty when the user entered nothing); cancelled, busy as for choose
ID term LEN + bodyok IDterminalthe body, one line of plain text (valid UTF-8; no control, C1, bidi-control or zero-width characters: syntax), shown from its start in rhun's prompt; the user edits it, and Enter (not within the first half second) types it into the terminal panel's shell (opened if needed), after Ctrl-U clears the shell's line, and answers ok; Esc or an empty line answers cancelled; busy as for choose. The command never reaches the shell without that step
ID cmd NAMEok IDper command (section 8a)runs one of rhun's own commands, among those of section 8a, as if the user had invoked it; any other name is refused
ID diagnostics DOC VERSION N LEN + bodyok IDdocuments readreplaces this extension's marks on the document with the N lines of the body (section 6b) if VERSION is current (stale otherwise); N is 0 to clear; at most 2000
ID hover DOC LINE COL LEN + bodyok IDdocuments readthe text to show at that position; shown only while the pointer still rests there; at most 4000 bytes. Positions refer to the current text (a reply that arrives after an edit may be off; rhun does not check). When a diagnostic's range covers the position, its message is shown instead

8a. The commands cmd may run

Editing, with documents = write: undo, redo, duplicate_line, delete_line, move_line_up, move_line_down, indent, outdent, toggle_comment, newline_below, newline_above. Selection: select_all, select_line, select_next. Navigation and search: find, replace, find_next, find_prev, find_in_files, goto_line, quick_open, command_palette, next_tab, prev_tab, reveal_file, focus_explorer. View: toggle_sidebar, toggle_agents, toggle_word_wrap, toggle_whitespace, toggle_line_numbers, zoom_in, zoom_out, zoom_reset. Terminal, with terminal = yes: toggle_terminal, new_terminal, clear_terminal. A command whose capability the manifest lacks is needs. Nothing that saves, closes, opens or deletes files, quits, changes settings or the theme, touches the clipboard (cut, copy, paste: a paste can reach the terminal), git, the agents or updates, or kills a terminal.

Prompts are one at a time for the whole editor: a second choose, ask or term while one is open, or while the user has the palette open, is busy. The user opening the palette, the extension stopping, or cancel end an open prompt with cancelled.

Requests from rhun

None without features: everything rhun sends is an event (section 12 adds requests). The request form from rhun (ID verb …, answered by the extension with ok ID or error ID CODE) is reserved for later versions; an extension that receives a line starting with a non-zero number should answer error ID unknown.

9. An exchange

→ hello 1
← 0 ready /home/me/app
← 0 opened 1 3 Go /home/me/app/main.go
← 0 active 1
← 0 run format.format_document 1
→ 7 text 1
← ok 7 3 41
← package main

func  main() { println("x") }
→ 8 replace 1 3 1 1 4 1 38
→ package main

func main() { println("x") }
← ok 8 4
← 0 changed 1 4

10. Testing an extension

rhun PROJECT --headless 1000x600 --scale 1 --extensions DIR --script test.txt runs rhun without a display, with the extensions found under DIR (laid out as installed: DIR/OWNER/NAME/extension.ini; such a session passes rhun's own environment on to the programs, so a test can reach them through variables). The state folder is $XDG_STATE_HOME/rhun/extensions/OWNER--NAME (~/.local/state when the variable is unset, on every system), so a test sets XDG_STATE_HOME to a folder of its own and seeds files there. The script is one command per line; what a command prints goes to standard output. The ones an extension's test needs:

CommandDoes
wait MSruns the event loop for that long. The frame that follows a step sends its events; the extension's replies land in a later poll: use two wait 300 lines after a step before reading its effect
open PATHopens a file in a tab
cmd NAME.commandinvokes a command by name (an extension's starts it)
type TEXTtypes text into the focused field or document (trailing spaces are dropped: key space for those)
key COMBOone key or combination per line, as in [keys]: Return, Escape, Tab, space, ctrl+End, ctrl+Tab, ctrl+alt+9
move X Ymoves the pointer to a window pixel (a hover event follows after half a second)
print-text-cell LINE COLX Y, the pixel of that cell of the active document, for move
wait-ext NAME STATEwaits until an extension is running, stopped or crashed; prints ext=NAME state=STATE
print-extensionsone line per extension: `OWNER/NAME state=S pid=P cmds=a,b [status=TEXT] confined=01 (registered commands included; confined 1 when the system holds it to its [needs]`)
print-statetabs=N active=NAME line=L col=C sel=BYTES dirty=D lang=LANG …
print-docthe active document's text, then <eod>
print-marksone line per mark of the active document, L1 C1 L2 C2 SEVERITY OWNER/NAME MESSAGE, then errors=N warnings=N
print-hoverhover=DOC LINE COL sent=S text=T for the position the pointer rests on (sent 1 once the event went out, text the extension's answer), or hover=none
print-tipthe tooltip on screen (a mark's message or a hover text), tip= when none
print-toasttoast=TEXT, or toast=
print-palettechoose=PROMPT or prompt=LABEL when an extension's prompt is open, field=TEXT, then the rows with > on the selected one; none when closed
print-optionstab_width=N insert_spaces=B trim_trailing_whitespace=B final_newline=B for the active document
wait-term TEXT, print-termwaits until the terminal's screen shows the text (10 s at most); prints the screen
print-viewsone line per view (12a): view=ID title=T shapes=N size=WxH at=X,Y (at is the window pixel of its top left, for move and click)
click X Y, tap X Ya press and release at a window pixel (tap: in one frame)
shot PATHthe window as a binary PPM picture, to look at a view or a list
print-complete`complete open=O items=N shown=S sel=I labels=AB… (open 0 closed, 1 asked, 2 shown; labels best first) and signature open=O from=F to=T text=SIGNATURE`
quitends the session

For the Extensions page, installing and settings:

CommandDoes
cmd extensions, ext-page NAME, ext-page listthe Extensions page, an extension's details, the list
print-ext-pagethe list (`Title version onoff [problem=…] a line each) or the details (pinned=, can: …, held=10, setting KEY=VALUE`)
cmd extensions_menu, print-extbarthe title bar's dropdown; `menu=openclosed and pinned=a,b`
ext-menu NAME, menu-pick LABEL, print-menuwhat a click on the extension's button does (a menu of its commands when it has several); the open menu's item with that label runs; the open menu's labels
`ext-set NAME pinneddisabled 01, ext-set NAME stoprestart`what the page's switches and buttons do
ext-setting NAME KEY VALUEchanges one of its settings as its page does
ext-install OWNER/NAME, wait-installInstall from GitHub with that answer; waits for the download: `install=askingfaileddone`
print-dialog, `dialog yesno`the Add or Remove dialog's question, line and button; its button or Cancel
ext-remove NAMERemove (the dialog asks)

rhun-ext-check DIR (built from conformance/ext in rhun-editor/extension-protocol) plays rhun's side against an installed-layout extension folder without rhun: grammar, ids, positions, versions, bodies, limits, and that the program survives an unknown event, an error reply, fragmented input, a burst of events, bye and the end of its input. The template's tests/run.sh shows a headless test from start to end.

11. Releases and installing

Install from GitHub (the extensions button, then Install from GitHub…, with owner/name or the repository's URL) takes the repository's latest release. Its assets must be, for each target the manifest names:

AssetWhat
NAME-TARGET.tar.gzthe extension folder's contents at its top level: extension.ini, the program (bin/NAME-TARGET as the manifest's run.TARGET says), themes/, syntax/, anything else; plain files and folders only
sha256sums.txtSHA256 FILE lines covering every archive

rhun downloads the archive and the sums, refuses a mismatch, an archive with links, devices, absolute paths or .., one without extension.ini, and one whose manifest names another owner or name than the repository (letter case aside: the manifest's spelling names the folders); then shows the manifest's title (cut after 120 bytes), version and permissions and asks. Add puts it in ~/.config/rhun/extensions/OWNER/NAME/, replacing an earlier version, and starts it at once; Remove stops it and deletes that folder and its state folder. RHUN_EXT_BASE points rhun at another host than https://github.com with the same layout (BASE/OWNER/NAME/releases/latest/download/ASSET), which is how tests serve a release from disk. On Windows the download and the checks run in PowerShell with the curl.exe and tar.exe that come with Windows 10 1803 and later. The template's release workflow produces exactly these assets.

12. Features

Features are protocol additions an extension asks for by name, so that one built for a later rhun still works with an earlier one and the other way round. They ship with rhun 0.18.0; sections 1 to 11 are what an extension sees when it names none.

  • The extension lists the features it understands after the version: hello 1 views complete.
  • The agreed set is the intersection of those names and the features rhun supports. rhun sends it

as 0 features NAME… before ready; without a features event before ready the set is empty. The set is fixed for the life of the process.

  • Neither side sends a message of a feature that was not agreed; rhun answers such a request

error ID unknown (after reading its body, which its verb defines).

  • A feature's grammar never changes under its name: an incompatible change is a new name

(complete2). Optional parts of a body are length-delimited records a reader skips when it does not know them.

FeatureWhat it adds
viewspictures the extension draws in tabs of their own (12a)
completecompletion lists while the user types (12b)
signaturethe signature of the call the cursor is in (12c)
definitionwhere the symbol under the cursor is defined, for Go to Definition (12d)
referenceswhere it is used, for Find All References (12d)
symbolsthe document's outline, for Go to Symbol (12e)
actionscode actions and quick fixes for a range (12f)
renamerenaming a symbol across files (12g)
formatformatting a document (12h)

Requests from rhun. With one of the provider features agreed (all but views), rhun sends requests ID VERB … with ids it picks: counting up from 1, never reused while the process runs. The extension answers each at most once, with ok ID LEN followed by exactly LEN bytes, or with error ID CODE TEXT (no body). This holds for every request rhun sends, so a reply can be framed without knowing what it answers. rhun may send 0 cancel ID when the answer is no longer wanted (the user typed on, moved away); the extension may still answer, and rhun reads and drops a late or unknown answer. An extension answers rhun's requests in any order and goes on reading meanwhile; the rule of section 3 that requests are handled in the order they arrive is about rhun handling the extension's.

12a. Views

A view is an editor tab whose content the extension draws: charts, graphs, diagrams, previews. The extension describes a picture; rhun keeps that description and renders it into the tab's own pixels, again after the tab is resized, the scale changes or the theme does (the coordinates stay as they were until the next picture). No extension code runs while rhun draws a frame, and nothing a picture holds reaches outside the view.

RequestReplyEffect
ID view TITLEok ID VIEWopens a tab titled TITLE (rest of the line, at most 80 bytes) and shows it; at most 8 open per extension (error ID toobig 8 views)
ID draw VIEW LEN + bodyok IDthe picture, replacing the last one once all of it is read; error ID syntax line N names the first line it cannot read, and nothing changes
ID unview VIEWok IDcloses the tab; no closed event follows

VIEW is a number rhun hands out, never reused in a session; another extension's view or an unknown one is error ID noview. Views are not documents: they appear in no document event, and while one is the active tab active reports 0. When the extension stops, its views close.

EventFieldsWhen
viewVIEW size W Hthe drawable size in logical pixels: after the view opens (after the ok) and whenever it changes
viewVIEW metrics small ADV LH normal ADV LH large ADV LH mono ADV LHthe character advance and line height of each text size, logical pixels; after the view opens and when the scale or font changes
viewVIEW hover X Ythe pointer moved over the view, no button down (at most once per frame)
viewVIEW down X Ythe left button was pressed over the view
viewVIEW drag X Ythe pointer moved with the left button down (at most once per frame)
viewVIEW up X Ythe left button was released
viewVIEW click X Ya press and release in the same place (4 pixels either way): after up
viewVIEW leavethe pointer left the view
viewVIEW wheel X Y DYthe wheel turned over the view: DY logical pixels, positive is down
viewVIEW closedthe user closed the tab

Events describe the pointer, not a picture: they arrive in order, and the extension hit-tests them against the picture it last drew.

The picture is one shape a line, drawn in order over the editor's background, in logical pixels with 0 0 at the top left, clipped to the view (coordinates are never moved). A number is [-]DIGITS[.DIGITS], at most 7 digits before the point and 9 after; widths, heights, radii and stroke widths are not negative (a zero draws nothing). Colors are #rrggbb, #rrggbbaa (alpha blended over what is below), or a name that follows the theme: fg, muted, bg, panel, border, accent, error, warning, success, and c1 to c8 for the series of a chart.

ShapeFields
rectX Y W H COLOR: a filled rectangle
rrectX Y W H R COLOR: rounded corners of radius R
lineX1 Y1 X2 Y2 WIDTH COLOR: round ends
polyWIDTH COLOR X1 Y1 X2 Y2 …: a polyline, round joins and ends, 2 to 10000 points
fillCOLOR X1 Y1 X2 Y2 X3 Y3 …: a filled polygon, closed, nonzero rule, 3 to 10000 points
circleX Y R COLOR: filled
ringX Y R WIDTH COLOR: an outline centered on the radius
pieX Y R FROM TO COLOR: a filled wedge from angle FROM to TO, degrees, 0 at the top, clockwise; 0 360 is a full disc
textX Y SIZE ALIGN COLOR TEXT…: SIZE is small, normal, large or mono; ALIGN is left, center or right about X; Y is the top of the line (its height is the size's LH); the text is the rest of the line after one space
clipX Y W H: what follows is drawn only inside this rectangle (within the view), until unclip or the next clip, which replaces it
unclipthe whole view again

rhun's interface font is monospaced: a text's width is its characters times the size's ADV (a wide character, as in Chinese, counts twice). A body is at most 1 MB, 20000 shapes and 200000 points; an unknown shape is skipped (a later rhun may draw it). Only the active view keeps its pixels; the others are rendered again when shown. The SDKs turn data into shapes: the Rust crate and the C header include charts (lines, bars, pies, axes, legends).

Planned for a later feature: keyboard focus and keys, modifiers on pointer events, images, curves and transforms, and accessible descriptions of what a view shows.

12b. Completion

The manifest says for which files, with section 2's patterns: [provides] complete = *.rs *.go, and which characters ask at once: complete.triggers = . :. A provider gets opened and edits for those files as if it subscribed to them, and is started by the first one that opens. The list is otherwise asked for after the user has typed two characters of a word (letters, digits, _ and any non-ASCII character) and paused for 150 ms, or on ctrl+space.

ID complete DOC VERSION LINE COL KIND CHAR: KIND is typed, trigger (CHAR is the character typed), invoked (the key) or incomplete (asking again for a list marked incomplete); CHAR is - unless KIND is trigger. Every event about the document up to VERSION has been sent before.

The answer is ok ID LEN + body. The body's first line is N MORE (MORE 1: the list is incomplete and typing should ask again); then N items, each

L1 C1 L2 C2 KIND LABEL_LEN INSERT_LEN DETAIL_LEN EXTRA_LEN LF
<LABEL bytes><INSERT bytes><DETAIL bytes><EXTRA bytes>

with nothing between them and the next item's header. L1 C1 L2 C2 is the range the insertion replaces, in the document at VERSION; LABEL is what the list shows and what typing filters on (one line); INSERT is the text put in (it may hold LF); DETAIL is shown beside the label (one line: the type, the package). KIND is one of text, method, function, field, variable, class, struct, interface, module, property, enum, keyword, snippet, constant, value, file, folder (another is shown as text). EXTRA holds optional records, each a header line and its bytes, skipped when unknown:

  • edit L1 C1 L2 C2 LEN + LEN bytes: another replacement made with the item (an import), in

the document at VERSION, not overlapping the item's range or another edit;

  • doc LEN + LEN bytes: documentation shown beside the list when the item is selected;
  • cursor OFFSET 0 (no bytes follow): where the cursor goes, OFFSET bytes into INSERT (a

snippet's $0, between parentheses); one past INSERT or inside a character is ignored, and without it the cursor goes after INSERT.

The body holds exactly N items. At most 1 MB and 4096 items; a body that breaks these rules is dropped whole. rhun shows the list while the cursor stays in the word it was asked for and the document changes only by typing in that word. It filters and orders the items itself: an item stays when the letters of the text from its range's start to the cursor appear in its label in order, in any case (ap keeps grape); a match at the start, at word starts (_, ., a capital) and in a row ranks higher, a shorter label breaks ties, and items that rank the same keep the provider's order; the best 50 are kept. So answer with every candidate for the word, not those that match what was typed so far: typing narrows the list, deleting widens it again, and neither asks again. When MORE was 1, every change in the word (typing or deleting) asks again, with KIND incomplete. Moving away, Escape, or an edit elsewhere closes the list; an answer for a document or cursor that has moved on is dropped. Enter or Tab puts the selected item in: its range, extended to the cursor by what was typed since, is replaced, with its extra edits, as one undo step. A provider with documents = read only may suggest: the user choosing the item is the edit.

A provider that reads the text with text may get a newer version than the request's, the user having typed on in the word meanwhile: the text before the request's cursor is the same in both, so ranges that end at the request's cursor can be computed on either. An empty list is the answer 0 0 and no items.

12c. Signature help

[provides] signature = *.rs *.go and signature.triggers = ( ,. When the user types a trigger, or presses ctrl+shift+space, rhun asks ID signature DOC VERSION LINE COL KIND CHAR (KIND trigger, invoked or update); while help is shown it asks again (update) after each edit or cursor move.

The answer is ok ID LEN + body: the first line FROM TO, the active parameter's bytes in the signature (zero-based, half-open, -1 -1 for none); the second line the signature; then documentation, plain text, any number of lines. ok ID 0 hides it. At most 64 KB. rhun shows the signature above the cursor (below it on the editor's first lines), the active parameter in the accent color, its documentation under it, until an empty answer, Escape, another document, or the document closing. One provider answers per document: the first found that agreed signature and whose patterns match.

Completion asks every provider whose patterns match (for a trigger character, those that list it) and merges their lists; each answer replaces what that provider said before for the same word. The keys ctrl+space and ctrl+shift+space are the commands complete and signature_help, which [keys] can rebind.

12d. Definition and references

The requests of 12d to 12h are answered like those of 12b: ok ID LEN and the body, or error ID CODE TEXT, which counts as an empty answer (nothing found, nothing to change); only a rename's error ID refused TEXT is shown to the user (TEXT, cut at 200 bytes; keep it to a sentence). Versions are those of the opened and edits events (0 or more). rhun waits 5 s for definitions and symbols, 10 s for references and format, 3 s for code actions and 15 s for a rename; an answer after that is dropped (and the request withdrawn with 0 cancel ID). Numbers in answers have at most 10 digits; a path is at most 4096 bytes, a name, title or detail at most 1000, a kind word at most 40.

[provides] definition = *.go and references = *.go. The commands definition (F12, Go to Definition) and references (shift+F12, Find All References) ask ID definition DOC VERSION LINE COL invoked - and ID references DOC VERSION LINE COL invoked - of every provider whose patterns match the active document. The answer is ok ID LEN and a locations body:

N LF
then N records: L1 C1 L2 C2 PATH_LEN TEXT_LEN LF <PATH bytes><TEXT bytes>

PATH is an absolute path, or #DOC for a document by its id (an untitled one); L1 C1 L2 C2 is the range there, in the open document when the file is open, else in the file on disk. TEXT is a one-line preview (its line's text, trimmed), or empty. At most 1 MB and 10000 records; a body that breaks the rules is dropped whole. rhun merges the providers' answers: one location opens at once (the range selected), several make a list in the palette, nearest first (this file, then by path), and choosing one opens it. The body 0 LF (ok ID 2) says there is none.

12e. Symbols

[provides] symbols = *.go. The command symbols (ctrl+r, Go to Symbol in File) asks ID symbols DOC VERSION LINE COL invoked - (the cursor's position, for providers that rank by it). The answer is N LF and N records:

L1 C1 L2 C2 KIND DEPTH NAME_LEN DETAIL_LEN LF <NAME bytes><DETAIL bytes>

in document order. KIND is one of file module namespace package class method property field constructor enum interface function variable constant string number boolean array object key null enum_member struct event operator type_parameter (another is shown as given); DEPTH is 0 for a top-level symbol and one more for each level it is nested in; DETAIL is shown beside it (a signature, a type). At most 1 MB and 10000 records. rhun shows them in the palette, indented by depth and filtered by what is typed, and puts the cursor at the chosen one's start. One provider answers: the first that agreed symbols and whose patterns match.

12f. Code actions

[provides] actions = *.go. The command code_actions (ctrl+., Show Code Actions) asks ID actions DOC VERSION L1 C1 L2 C2, the selection (an empty range at the cursor without one), of every matching provider. The diagnostics there are the extension's to look up: it published them. The answer is N LF and N records:

KIND TITLE_LEN EDIT_LEN DATA_LEN LF <TITLE bytes><EDIT bytes><DATA bytes>

KIND is quickfix, refactor, source or another word (shown beside the title); TITLE one line; EDIT a workspace edit (12g) applied when the action is chosen, or empty; DATA bytes the extension gets back when the action is chosen, as the event 0 action LEN and the bytes (after the edit, if there is one; an edit rhun refuses sends nothing): an action that has to run something does it then. At most 4 MB (the edits included) and 1000 actions. rhun lists the providers' actions in the palette, quick fixes first; with none it says so.

12g. Rename and workspace edits

[provides] rename = *.go. F2 (the command rename_file) in a document that has a rename provider asks for the new name in the palette, the word under the cursor filled in, then sends ID rename DOC VERSION LINE COL invoked NAME (NAME without spaces) to the first matching provider. The answer is a workspace edit; error ID refused TEXT shows TEXT (nothing can be renamed there). F2 elsewhere renames the file, as before.

A workspace edit is N LF and N groups, one per file:

PATH_LEN VERSION M LF <PATH bytes>
then M edits: L1 C1 L2 C2 LEN LF <LEN bytes of new text>

PATH as in 12d; VERSION the version of the open document the edits were made for, or - for a file that is not open (its text on disk; for a document that is open, - is accepted only while it has no unsaved changes). #DOC names an open document, untitled or not; a path names a file by its absolute path; a file appears in one group only. The edits of a group all refer to that one text and must not overlap; they need not be in order: rhun sorts them by start (an insertion before a replacement that starts at the same place), and insertions at one position keep their order in the list. rhun checks everything before it changes anything: each open document at its version, each range inside its text, no overlaps, a file that is not open an existing, readable text file. Then it opens the files that are not open in tabs of their own and applies each group as one undo step; nothing is saved, so the user reviews and saves. A check that fails changes nothing and says so. At most 4 MB, 1000 files and 100000 edits. The cursor and selection of an edited document move with the text around them.

12h. Format

[provides] format = *.go. The command format_document (shift+alt+f, Format Document) sends ID format DOC VERSION TAB_WIDTH SPACES (SPACES is yes or no, the document's own settings) to the first matching provider. The answer is a workspace edit for that document (usually one group, any number of edits), applied as one undo step; an empty one (0) changes nothing.