Janet Scripting in Bunny
This document describes the Janet scripting surface available in Bunny today.
Getting Started
Bunny loads Janet in two phases:
- Global bootstrap
~/Library/Application Support/Bunny/init.janet~/Library/Application Support/Bunny/lib/
- Vault bootstrap
<vault>/.vault/init.janet<vault>/.vault/extensions/
Only the two init.janet files are loaded automatically. The lib/ and .vault/extensions/ directories are import roots, not auto-discovery folders.
Minimal global setup
Create ~/Library/Application Support/Bunny/init.janet:
(print "Bunny global init loaded")
(command/register "notes.hello" "Notes: Hello"
(fn []
(print "hello from Bunny")))
Packages placed under ~/Library/Application Support/Bunny/lib/<name>/init.janet can be imported with @bunny-global-lib:
(import @bunny-global-lib/mytools)
Minimal vault setup
Create <vault>/.vault/init.janet:
(import @bunny-ext/todo)
(hook/add "document-did-save"
(fn [doc-id]
(print "saved" doc-id)))
Vault scripts are trust-gated. If Bunny detects .vault/init.janet in a vault that is not trusted yet, it opens the vault without running that file and shows the trust prompt. Trust is stored app-wide and is currently path-based.
Packages placed under <vault>/.vault/extensions/<name>/init.janet can be imported with @bunny-ext/<name>.
Exact file loading
Bunny also exposes:
(bunny/load path)— returns:loadedon success, throws on missing or invalid(bunny/load-if-exists path)— returns:loadedon success,nilwhen file not found, throws on invalid
These execute files directly, resolve relative paths against the calling file, and load definitions into the caller’s environment.
(bunny/load "extra/setup.janet")
(bunny/load-if-exists "local-overrides.janet")
Use import for Janet modules and bunny/load when you want literal file-execution semantics.
Janet’s normal module cache still applies to import. If you need a fresh module reload, use Janet’s standard :fresh true import option.
API Rules
Naming
- Functions ending with
!mutate persisted data, visible UI state, or perform an external side effect. - Functions without
!are reads, pure transforms, registration, lifecycle wiring, or introspection.
Timing
- Document writes (
doc/set-content!,doc/create!,doc/delete!) are deferred: they queue during eval and are applied afterward viadrainWrites. - Style rules (
style/add-rule!,style/remove-rule!) apply immediately. - Status bar mutations (
statusbar/set-left!,statusbar/set-right!,statusbar/set-visible!) apply immediately.
Freshness
doc/contentreads directly from disk (live).doc/list,doc/title,doc/tags,doc/frontmatter,links/*,tags/*, and mode activation are snapshot-backed (refreshed before each eval).
Table keys
- Bunny-owned structural tables (hook payloads, diagnostics) use keyword keys (
:document-id,:tag,:global-init, etc.). - User-originated frontmatter tables use string keys (
"mode","author").
How The Runtime Works
The scripting runtime lives on a dedicated Janet host thread, not on the main UI thread.
- Janet has one shared runtime per Bunny app instance.
- Bunny bindings run on a single designated Janet OS thread.
- Janet EV and NET support are enabled.
- Janet fibers created with
ev/gorun on that host thread and can call Bunny bindings directly. - Swift/UI work is brokered back to
@MainActoror other Swift actors as needed.
The practical model is:
- Bunny boots a Janet VM on the host thread.
- Core bindings are registered.
- Global
init.janetruns. - When a vault opens, vault bindings are registered.
- If trusted, vault
.vault/init.janetruns. - On vault switch, Bunny resets the VM, replays global bootstrap, then replays vault bootstrap.
Janet’s own event loop, timers, fibers, and networking work without blocking the app UI:
(def ch (ev/chan 1))
(ev/go (fn [] (ev/give ch :ok)))
(ev/take ch)
Dynamic roots
Bunny sets these Janet dynamic bindings:
:bunny-global:bunny-global-lib:bunny-vault:bunny-vault-meta:bunny-ext
They back the @bunny-global-lib/... and @bunny-ext/... import roots and are also visible via (dyn ...) or bunny/diagnostics.
Execution lifecycle
The scripting surface is backed by a ScriptingContext snapshot model:
- Refresh index/document snapshot before eval.
- Evaluate Janet — reads come from the snapshot; writes are queued.
- Apply queued document writes after eval via
drainWrites.
This means:
doc/contentreads directly from disk (live read, not snapshot).doc/list,doc/title,doc/tags,doc/frontmatter,links/*,tags/*, and mode activation are snapshot-backed.doc/set-content!,doc/create!, anddoc/delete!queue writes that are applied after eval completes — they are not immediate disk mutations.- Style and status bar mutations are immediate (UI state, not disk).
Common Patterns
Register a command
Commands are parameterless. Read any needed context from Bunny bindings.
(command/register "todo.uppercase" "Todo: Uppercase Current Note"
(fn []
(when-let [doc (doc/current)]
(when-let [content (doc/content doc)]
(doc/set-content! doc (string/ascii-upper content))))))
React to hooks
(hook/add "document-did-save"
(fn [doc-id]
(print "saved" doc-id)))
Define a mode from frontmatter
If a note has:
---
mode: todo
---
then Bunny will activate the matching Janet mode when that document opens:
(mode/define "todo"
{:on-activate
(fn [doc-id]
(style/add-rule! "todo.done"
{:pattern "- \\[x\\] .*"
:color "#888888"
:strikethrough true}))
:on-deactivate
(fn [doc-id]
(style/remove-rule! "todo.done"))})
Customize the status bar
(statusbar/set-left! [:mode " | " :cursor])
(statusbar/set-right!
[:dirty
(fn [ctx]
(string "Words: " (ctx :word-count)))])
Status-bar function items receive a table with:
:row:col:dirty:mode:doc-id:word-count
Return a string to show text, or nil to hide the item.
API Reference
doc/*
Document IDs are vault-relative paths such as "notes/today.md".
(doc/current)→ current document ID string ornil(doc/list)→ tuple of all document ID strings, sorted(doc/title id)→ title string ornil(doc/tags id)→ tuple of sorted tag strings(doc/content id)→ full file content string ornil(doc/frontmatter id)→ table of string keys to string values(doc/set-content! id content)→ queues content write (applied after eval), returnsnil(doc/create! title content)→ queues document creation (applied after eval), returnsnil(doc/delete! id)→ queues deletion (applied after eval), returnsnil(doc/reveal id)→ macOS app binding; reveals the file in Finder and returnstrue
Notes:
doc/create(without!) is a compatibility alias fordoc/create!.doc/frontmatteris stringly typed because indexed frontmatter is stored as[String: String].
links/*
(links/outgoing id)→ tuple of sorted wiki-link target titles from that document(links/backlinks id)→ tuple of sorted document IDs that link toid(links/resolve title)→ resolved document ID ornil
Notes:
- Resolution is case-insensitive.
- Ambiguous title matches return
nil.
tags/*
(tags/all)→ tuple of sorted tag paths(tags/documents tag)→ tuple of sorted document IDs tagged withtag
Notes:
- Hierarchical tags include parent nodes, so
#project/activecontributes both"project"and"project/active".
command/*
(command/register id title fn)→ registers a Janet command, returnsnil(command/execute id)→ executes a command, returnsnil(command/list)→ tuple of sorted command IDs
Notes:
- Commands are parameterless.
- In Bunny proper,
command/executecan invoke both Janet-registered commands and native app commands through the host broker. - In isolated binding tests without a broker, native commands are rejected.
hook/*
(hook/add hook-name fn)→ registers a handler and returns a handle string(hook/remove handle)→ deregisters a handler, returnsnil(hook/list)→ tuple of registered hook handles
Supported hook names:
"document-will-save"→ handler receivesdoc-id"document-did-save"→ handler receivesdoc-id"document-did-open"→ handler receivesdoc-id"document-did-create"→ handler receivesdoc-id"document-will-delete"→ handler receivesdoc-id"index-did-update"→ handler receives no arguments"tag-added"→ handler receives{:document-id "..." :tag "..."}
Only those hook names are currently exposed to Janet.
mode/*
(mode/define name opts)→ defines or replaces a mode, returnsnil(mode/remove name)→ removes a mode, returnsnil(mode/list)→ tuple of sorted mode names(mode/current)→ active mode name ornil
opts accepts a Janet table or struct with:
:on-activaterequired, function of onedoc-id:on-deactivateoptional, function of onedoc-id
Mode behavior:
- Modes are selected from document frontmatter key
"mode". - Mode activation is wired to
document-did-open. - Opening a new document deactivates the current mode first if needed.
- Re-opening the same document with the same active mode is a no-op.
- Opening a document with no mode or an unknown mode clears the current mode.
style/*
(style/add-rule! id opts)→ adds or replaces a style rule, returnsnil(style/remove-rule! id)→ removes a rule, returnsnil(style/list-rules)→ tuple of rule IDs
opts keys:
:patternrequired regex string:coloroptional foreground color#RRGGBBor#RRGGBBAA:bg-coloroptional background color#RRGGBBor#RRGGBBAA:strikethroughoptional boolean:boldoptional boolean:italicoptional boolean:underlineoptional boolean
Style rules apply immediately and are UI/editor state, not deferred vault writes.
statusbar/*
(statusbar/set-left! items)→ sets left layout, returnsnil(statusbar/set-right! items)→ sets right layout, returnsnil(statusbar/get-left)→ tuple describing left layout(statusbar/get-right)→ tuple describing right layout(statusbar/visible?)→ boolean(statusbar/set-visible! flag)→ returnsnil
items may be a Janet array or tuple containing:
- built-in keywords
:cursor,:mode,:dirty - literal strings
- functions of one
ctxargument
get-left and get-right serialize function items as :fn, not as callable values.
cal/*
(cal/authorized?)→ boolean(cal/calendars)→ tuple of calendar tables(cal/events start end [calendar-id])→ tuple of event tables(cal/event id)→ event table ornil(cal/create-event! title start end [calendar-id] [all-day])→ new event ID(cal/delete-event! id)→ boolean
Accepted date formats:
"2026-04-04T09:00:00Z""2026-04-04T09:00:00""2026-04-04"
Calendar table fields: "id", "title", "type", "color"
Event table fields: "id", "title", "start", "end", "all-day", "calendar", "calendar-title", "location", "notes"
Creating or deleting events throws if calendar access is not authorized.
bunny/*
(bunny/load path)→ executes a file, returns:loaded(bunny/load-if-exists path)→ returns:loadedon success,nilwhen file not found(bunny/diagnostics)→ diagnostic table
Notes:
bunny/loadreturns:loadedon success. Throws if the target file is missing or fails.bunny/load-if-existsreturns:loadedon success,nilwhen the file is absent (falsey-safe forwhen/ifpatterns), and throws if the file exists but fails.
bunny/diagnostics includes:
:global-init:vault-init:trust:vm-generation:roots
Status sub-tables use keyword values such as :not-attempted, :missing, :loaded, :failed, :trusted, :untrusted, and :no-vault.
Current Caveats
- The app auto-loads only
init.janetfiles. Extension directories are import roots, not scanners. - Deferred document writes (
doc/set-content!,doc/create!,doc/delete!) are applied after eval, not immediately. - Snapshot-backed bindings are not the same thing as direct live actor access.
- Only a subset of Bunny hook points is exposed to Janet today.
doc/revealis macOS-only.- There is not yet a public Janet-side
bunny/callgateway API for arbitrary Janet threads; the supported model today is one Bunny host thread with Janet fibers and EV/NET on that thread.