fedit

plugin api · v1

Extend the editor in F#.

Plugins register commands and keybindings. 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.

Quick start Browse examples

examples/wordcount/Plugin.fs
module Plugin =
    let register (host: IPluginHost) =
        host.RegisterCommand
            { Name = "wc"
              Usage = "wc"
              Summary = "Count words..."
              Run = fun ctx ->
                  let n = ctx.ActiveBuffer.Text.Split(...).Length
                  [ Notify(Info, $"{n} words") ] }
fedit . — :wc
┌─ src/Main.fs ──────────────────────────────────────┐
   1  module Main                                    
   2                                                  
   3  let main argv = printfn "hello, world"    
├────────────────────────────────────────────────────┤
 ^ src/Main.fs · Ln 1 · 247 words                
└────────────────────────────────────────────────────┘
6
reference plugins
16
lines for the smallest
0
IPC or message schemas
1
.fs file required

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.

  1. 01

    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.

  2. 02

    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 — ok, disabled, or a build failure with the exact compiler output. Edited a plugin since launch? plugin reload rebuilds anything stale and reloads.

  3. 03

    Run your plugin's command

    Type wc and 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. Numbered callouts on the right point to each piece's role.

examples/wordcount/Plugin.fs
namespace Wordcount1
open Fedit.PluginApi2
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
  1. 1
    Namespace. Becomes the prefix in entryType. Matches plugin.json's "entryType": "Wordcount.Plugin".
  2. 2
    Open the contract. All API types live in Fedit.PluginApi. Single import — no transitive editor types leak.
  3. 3
    Entry module. The host loads this module by name from the manifest.
  4. 4
    register. Called once at plugin load. Synchronous; must not throw. Exceptions mark the plugin as Failed and the host continues.
  5. 5
    Command name. Users invoke as :wc. Tab-completes alongside built-ins. Collisions with built-ins drop the plugin's command.
  6. 6
    Handler. Runs each invocation with a fresh PluginContext snapshot — active buffer text, cursor, all open buffers, workspace root. Never live state.
  7. 7
    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.

  1. 01

    SCAN

    Plugins.discover

    List every <name>/ under ~/.config/fedit/plugins/ and parse its plugin.json.

  2. 02

    BUILD

    Plugins.build

    If the DLL is missing or older than any .fs, run dotnet build -c Release.

  3. 03

    LOAD

    AssemblyLoadContext

    Each plugin gets its own ALC so it can't poison the host's type identity.

  4. 04

    REGISTER

    register(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.

Notify Severity * string

Report a result; no buffer change.

[ Notify(Info, $"{n} words") ]
InsertText string

Add text at the cursor — timestamps, snippets, UUIDs.

[ InsertText "[\(stamp)\] " ]
ReplaceSelection string

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 } ]
OpenFile string

Open a file relative to the workspace root.

[ OpenFile "src/Main.fs" ]
SaveActiveBuffer

Trigger the same save path as :write.

[ SaveActiveBuffer ]
RunCommand string

Chain into a built-in command by name.

[ RunCommand "open foo.fs" ]
SetClipboard string

Copy text to the system clipboard.

[ SetClipboard buffer.Text ]
SelectRange CursorPosition * CursorPosition

Select between two positions — the anchor pins one end, the caret lands on the cursor, like a shift+motion selection.

[ SelectRange(anchor, cursor) ]
OpenFilePreview string

Open a file into the preview slot — the sidebar's Space behavior. Already-open files are activated instead.

[ OpenFilePreview "docs/plan.md" ]
RevealPath string

Expand and select a path in the sidebar without stealing focus. Paths outside the workspace are a no-op.

[ RevealPath "src/Main.fs" ]
ReplaceRange CursorPosition * 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 ]
SwitchBuffer int

Activate a buffer by its BufferView.Id. Unknown ids raise an error notification.

[ SwitchBuffer buffer.Id ]
NewBuffer string * string

Create a scratch buffer holding text and make it active. Later actions in the list target it.

[ NewBuffer("todos", report) ]
SetBufferActivation string

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" ]
OpenFileAt string * { 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.

:wc wordcount

Count words in the active buffer.

let n =
    ctx.ActiveBuffer.Text.Split(
        [| ' '; '\t'; '\n'; '\r' |], ...)
    |> Array.length
[ Notify(Info, $"{n} words") ]
:journal journal

Insert a [YYYY-MM-DD HH:MM] stamp at the cursor; the sidebar follows the stamped file.

let stamp =
    DateTime.Now.ToString("yyyy-MM-dd HH:mm")
[ InsertText $"[\(stamp)\] "
  RevealPath path
  Notify(Info, $"Stamped \(stamp)") ]
:todocount todo-count

Walk the workspace, count lines containing TODO:.

for line in File.ReadLines file do
    if line.Contains "TODO:" then
        count <- count + 1
[ Notify(Info, $"{count} TODOs") ]
:todolist todo-list

List every TODO: as path:line in an editable todos scratch buffer (cap 50).

for rel in ctx.Workspace.Files do
    // read the file, collect hits
    results.Add $"{rel}:{lineNo}  {line.Trim()}"
[ NewBuffer("todos", report)
  Notify(Info, $"{n} todos in {files} files") ]
:todonext todo-next

Jump cursor to the next TODO:; wraps, then continues into other open buffers.

[ SwitchBuffer buffer.Id
  MoveCursor { Line = lineNo; Column = col }
  Notify(Info, $"TODO in {buffer.Name}") ]
// at register time:
host.RegisterKeybinding(KeyChord.Ctrl 't', "todonext")
:jot jot

Session scratchpad: jot code locations, check them off, jump back.

let entry = $"[ ] {location}:{line}  {text}"
[ SwitchBuffer notes.Id
  MoveCursor { Line = 999999; Column = 999999 }
  InsertText(entry + "\n")
  SwitchBuffer ctx.ActiveBuffer.Id ]

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.