plugins
Plugins register commands and keybindings in F#. They live in ~/.config/fedit/plugins/, build lazily on first launch, and run out-of-process in a plugin host against a read-only
snapshot of the workspace. No JSON manifests beyond the bare minimum, no IPC to wire up, no
schema — just a function.
Trust model. Plugins are full .NET code with no sandbox. They can read any file, open any socket, and run any process. Treat installation like installing a shell tool — only run plugins from sources you trust.
quick start
From zero to running plugin in three commands and three keystrokes.
- Drop a plugin into the plugins directory.
$ mkdir -p ~/.config/fedit/plugins $ cp -R examples/wordcount ~/.config/fedit/plugins/ $ ./fedit .
On launch the host scans this directory, compiles anything stale via
dotnet build -c Release, and caches the resulting DLL alongside the source. - Confirm it loaded.
The plugin already built and loaded on launch. To check: Ctrl+P → type
plugin list. The dock shows each plugin's status and surfaces any build failures with the exact compiler output. Edited a plugin since launch?plugin reloadrebuilds anything stale and reloads. - Run your plugin's command.
Type
wcand hit Enter. The active buffer's word count appears in the notification dock. That's it.
anatomy of a plugin
The whole authoring surface fits in one short file. The numbered callouts below point to each piece's role.
namespace Wordcount 1 open Fedit.PluginApi 2 module Plugin = 3 let register (host: IPluginHost) = 4 host.RegisterCommand { Name = "wc" 5 Usage = "wc" Summary = "Count words..." Run = fun ctx -> 6 let n = ctx.ActiveBuffer.Text.Split(...).Length [ Notify(Info, $"{n} words") ] } 7
- Namespace. Becomes the prefix in
entryType. Matchesplugin.json's"entryType": "Wordcount.Plugin". - Open the contract. All API types live in
Fedit.PluginApi. Single import — no transitive editor types leak. - Entry module. The host loads this module by name from the manifest.
- register. Called once at plugin load. Synchronous; must not throw. Exceptions
mark the plugin as
Failedand the host continues. - Command name. Users invoke as
:wc. Tab-completes alongside built-ins. Collisions with built-ins drop the plugin's command. - Handler. Runs each invocation with a fresh
PluginContextsnapshot — active buffer text, cursor, all open buffers, workspace root. Never live state. - Return value. A
PluginAction list. The host translates each action into a core editor effect (notification, buffer mutation, file open, …).
lifecycle
What happens between ./fedit . and your register function getting called.
Runs automatically on startup, and on demand via :plugin reload.
-
01 SCANPlugins.discoverList every <name>/ under ~/.config/fedit/plugins/ and parse its plugin.json.
-
02 BUILDPlugins.buildIf the DLL is missing or older than any .fs, run dotnet build -c Release.
-
03 LOADAssemblyLoadContextEach plugin gets its own ALC so it can't poison the host's type identity.
-
04 REGISTERregister(collector)Call the plugin's entry point with a collector implementing IPluginHost.
actions
A plugin's Run function returns a list of these. The host applies them in order — pick
the right one for the effect you want.
-
NotifySeverity * string -
Report a result; no buffer change.
[ Notify(Info, $"{n} words") ] -
InsertTextstring -
Add text at the cursor — timestamps, snippets, UUIDs.
[ InsertText "[\(stamp)\] " ]
-
ReplaceSelectionstring -
Replace selected text (inserts if no selection).
[ ReplaceSelection "kebab-cased" ]
-
MoveCursor{ Line; Column } -
Jump the cursor to a 1-based position.
[ MoveCursor { Line = 42; Column = 7 } ] -
OpenFilestring -
Open a file relative to the workspace root.
[ OpenFile "src/Main.fs" ]
-
SaveActiveBuffer— -
Trigger the same save path as :write.
[ SaveActiveBuffer ]
-
RunCommandstring -
Chain into a built-in command by name.
[ RunCommand "open foo.fs" ]
-
SetClipboardstring -
Copy text to the system clipboard.
[ SetClipboard buffer.Text ]
-
SelectRangeCursorPosition * CursorPosition -
Select between two positions — the anchor pins one end, the caret lands on the cursor, like a shift+motion selection.
[ SelectRange(anchor, cursor) ]
-
OpenFilePreviewstring -
Open a file into the preview slot — the sidebar's Space behavior. Already-open files are activated instead.
[ OpenFilePreview "docs/plan.md" ]
-
RevealPathstring -
Expand and select a path in the sidebar without stealing focus. Paths outside the workspace are a no-op.
[ RevealPath "src/Main.fs" ]
-
ReplaceRangeCursorPosition * CursorPosition * string -
Replace the span between two 1-based positions as one undo entry. Ends swap if reversed; coordinates clamp.
[ ReplaceRange(from, to_, "text") ]
-
ClearSelection— -
Collapse the selection to a caret. No-op without a selection.
[ ClearSelection ]
-
DeleteSelection— -
Delete the selected text as one undo entry. No-op without a selection.
[ DeleteSelection ]
-
SwitchBufferint -
Activate a buffer by its BufferView.Id. Unknown ids raise an error notification.
[ SwitchBuffer buffer.Id ]
-
NewBufferstring * string -
Create a scratch buffer holding text and make it active. Later actions in the list target it.
[ NewBuffer("todos", report) ] -
SetBufferActivationstring -
Run a registered command when a line of the active buffer is activated (Enter or left-click). Place it after the NewBuffer it targets.
[ SetBufferActivation "todo-jump" ]
-
OpenFileAtstring * { Line; Column } * bool -
Open a file and move the cursor to a 1-based position once it loads; the target survives the async open and applies if the file is already open. preview picks the preview slot.
[ OpenFileAt(path, { Line = 42; Column = 7 }, false) ]
six reference plugins
Each demonstrates a different combination of actions. Source under examples/ — copy
any folder to ~/.config/fedit/plugins/ and it'll build on the next launch.
-
:wcwordcount -
Count words in the active buffer.
uses Notify · source →
-
:journaljournal -
Insert a [YYYY-MM-DD HH:MM] stamp at the cursor; the sidebar follows the stamped file.
uses InsertText + RevealPath + Notify · source →
-
:todocounttodo-count -
Walk the workspace, count lines containing TODO:.
uses Notify · source →
-
:todolisttodo-list -
List every TODO: as path:line in a clickable todos buffer; Enter or click jumps to the source (cap 50).
uses NewBuffer + SetBufferActivation + OpenFileAt + Notify · source →
-
:todonexttodo-next -
Jump cursor to the next TODO:; wraps, then continues into other open buffers.
uses MoveCursor + SwitchBuffer + RegisterKeybinding · source →
-
:jotjot -
Session scratchpad: jot code locations, check them off, jump back.
uses NewBuffer + SwitchBuffer + ReplaceRange + RevealPath + OpenFilePreview · source →
the :plugin command
One built-in command with verb dispatch. Tab completion suggests verbs first, then arguments.
-
:plugin list -
Show plugins with status (ok / disabled / FAIL).
-
:plugin enable <name> -
Re-enable a disabled plugin; persists to config.
-
:plugin disable <name> -
Disable a plugin without removing it; persists to config.
-
:plugin install <url-or-path> -
Folder, git URL, or .zip — auto-detected.
-
:plugin remove <name> -
Delete the plugin folder and rescan.
-
:plugin reload -
Rescan disk; rebuilds anything stale.
-
:plugin validate <path> -
Dry-run: parse the manifest, report what would register.
further reading
- docs/plugins.md — the author guide: manifest reference, every type, conflict policy, debugging, distribution.
- examples/ — six plugins from 16 to ~100 lines. Copy any one as your starting skeleton; all MIT-licensed.
- src/Fedit.PluginApi/ — the contract itself. Two short files; read it in five minutes.