Rules, Emacs comparisons, limits and open points
Rules a plugin author must know
The design states six rules; the rest are the idioms that follow from them.
- Your
plugin.ssruns top to bottom when your plugin turns on. Everything it sets up through the core (entries made through verbs, hook entries, option layers, timers, jobs, ties,on-endactions) is yours and is undone, newest first, when it turns off or reloads. - Your ordinary variables start fresh each run. Your own working state belongs in them. What must outlive a reload (a reader, a drawing worker) is started again from the top level of your file, from saved data.
- You change another plugin’s state only by calling its contract. You cannot write its data; the build refuses
set!on another plugin’s saved data. - What the user cares about is saved data; settings are options; what you add to another plugin’s things goes through its contract’s verbs. Keep in saved data only data whose shape your plugin (or a contract you implement) defines, as plain data, a persistent map when keyed, a
define-plain-recordfor a fixed shape. Never change saved lists in place. - Hold other plugins’ things by id. Ids come from
new-idand are never reused; checklive?(or the provider’s equivalent) before use. - An exported variable is a constant.
- Your plugin reruns by itself when something it used while starting changes (a provider reloaded, swapped or turned off). You write nothing for it.
- Heavy work goes to a worker:
define-workerat the top level,start-jobto run it. A worker sees only its input, standard Scheme, a listed set of Chez procedures, the plain-data library,post,next-inputandsleep. Code it shares with your plugin goes in a file both include. - Start a job for each change,
for:what it is about, and you never see a stale result. Work not restarted on every change checks inon-resultthat what it was for is still current. - A worker that waits on input needs its own way out (closing what it reads), because a worker blocked in a system call cannot be stopped.
- A worker that keeps results across iterations keeps them in a few large flat objects (vectors, strings), not many small lists; this keeps garbage collection short.
- A start does no blocking work and cannot wait; it starts a worker instead. An end cannot wait either.
- A command paused inside a plugin that changes is quit, as by
C-g. After any pause, get values from other plugins again: they may have reloaded with a new shape. - A procedure of yours that makes something for its caller says whose it is: the user’s (it stays) or the caller’s (tie it with
on-caller-end, andforget-caller-endwhen it goes by other means). A verb of yours makes its entries withas-caller, so they are the caller’s. The caller is the code that contains the call. - What you want drawn goes through the display’s verbs, because a frame is due only when core state changes.
- Use a contract you can do without under
(provided? 'c). - A process the user starts is
processes:start!; one your plugin needs for itself isprocesses:start-own. - A contract meant to be implemented by others has a
contract.ss, and declares its saved data there, whole. - A macro use must not expand to definitions followed by an expression (it is refused with its line). Macros and record types change only by reloading.
- Do not use
eval(reported: it reaches around imports) orfork-thread(refused: use a worker). - Public procedures check their arguments at entry (
assert,assertion-violation) and name themselves in the error; code inside trusts its arguments. - Names: a procedure called for its effect on something that exists ends in
!; one that makes something new does not; nor does syntax. A contract’s procedures take optional positional arguments or property lists, not keywords: keywords are the core’s.
Rules a user must know
init.ssruns once, top to bottom.(use-plugin p key: value …)picks a plugin and its options;(use-plugin a b c)picks several. A plugin starts at its line, or earlier when something needs it. A plugin not listed never starts.- A change belongs to where you wrote it: a plugin, an
init.ssline, or your session (M-:). Removing the line removes the change; ending the session removes the session’s changes. reload-configapplies only what changed. If a changed line fails, what was running stays.C-M-xon a definition takes effect at once and lasts until that plugin is reloaded from its file.- Your data is saved after every command, when a storage plugin is on. After a crash you lose at most what an unfinished command did.
- Turning a plugin off leaves Tuna as if it had never been on, except your data.
- Your shells keep running when the terminal goes and when you quit; killing a shell’s buffer ends it;
list-processesshows them all.
Emacs comparisons that matter
| Tuna | Emacs | Same or different, and why |
|---|---|---|
| Plugin: a folder whose file runs when it turns on | Package: a file required once | Different: what a plugin sets up is owned and undone when it turns off or reloads. |
Contract, import, implements | require, the foo--helper naming convention | Different: names are checked at build; another plugin can stand in. |
Verbs, made for the caller with as-caller | keymap-set, add-to-list, set-face-attribute | Different: the same lines, but each entry leaves with the plugin that made it. |
| Turning a plugin off | unload-feature | Different. Emacs refuses if others depend on it, and leaves anonymous hook functions, timers, buffers, and changes to other packages’ variables. Tuna turns dependents off with a message and back on with it, and removes everything except your data. |
| A buffer whose mode’s plugin is off is treated as Fundamental mode | unload--set-major-mode | Same. |
| Hooks with owners | hook variables | Different: an entry is keyed by its owner, not its function, so evaluating a lambda again replaces it; a failing entry is removed and reported. |
| Options with one layer per owner | defcustom, setq, Custom themes | Different: in Emacs the last setq wins and removing it changes nothing until restart. |
Scoped layers, set-local!, modes:set! | setq-local, defvar-local, mode hooks | Different: a scoped layer has an owner and goes with it; a mode-scoped layer applies at once to every buffer in the mode. |
Faces and margin marks set with buffers:set-faces! | overlays with a face property | Different: a caller’s whole set at once, removed with the caller and the buffer. |
| Text procedures on the current buffer | the same | Same, with positions from 0. |
| Saved data, written after every command | desktop-save-mode, savehist-mode, auto-save files | Different: Emacs saves only if asked, at exit or on a timer. |
define-plain-record | cl-defstruct | Different: never changed in place, and checked, so it can be saved and sent to a worker. |
| Undo history saved, with a cap | undo-limit, in memory only | Different: it survives a restart. |
| Workers | Lisp threads (one at a time), timers, subprocesses | Different: workers run truly in parallel but cannot touch editor state. |
A worker’s results applied in order by on-result | a process filter | Similar. |
| A command paused at a prompt | read-from-minibuffer, recursive edit | Same for you; a continuation underneath, so paused commands do not nest. |
C-g, request-quit! | keyboard-quit, quit-flag | Same, plus a forced quit after a grace period. |
| Your processes, owned by the process service | make-process with a buffer | Different: they outlive the terminal plugin and a clean exit; Emacs ends them at exit, asking first. |
A plugin’s view (buffers:view) | get-buffer-create for *Help* | Different: it closes when its plugin turns off and stays across a reload. |
M-: | eval-expression | Different: its changes belong to the session, which can be ended. |
C-M-x | eval-defun | Same: lasts until the file is loaded again. |
| Reloading a plugin | load-file, eval-buffer | Different: Emacs runs the file again over the old definitions; Tuna stops the old version and starts the new, and restarts the old code if the new fails. |
reload-config | evaluating init.el again | Different: Tuna undoes removed lines. |
redefine, advice-add | defun, advice-add | Same, but each change is owned and can be taken back. |
dabbrev::helper | every name is reachable | Different: private names are reached on purpose, by the user only. |
| Drawing: extraction on the editor thread, the page on a worker per tab | redisplay on the main thread | Different. |
Known limits
None blocked approval. Each is a thing the design cannot do, or does only by a rule an author must follow.
- Code blocked in a system call cannot be stopped from inside Tuna, whether in a worker or in a plugin’s start. A cancelled worker is reported after 3 s; a start blocked this way ignores its time limit. Racket can interrupt more of these cases; Chez cannot.
- Old data after a pause. A command that got data from another plugin, paused at a prompt, and resumed after that plugin reloaded with a new shape, calls new code with old data. Quitting more commands would break other cases, so it is a rule: get values again after a pause.
- Lists are always mutable in Chez, so a plugin could change a saved list in place and the core would not see it.
- A hand-written check must follow the data’s shape. If a plugin changes the shape of its saved data but not its check, old data is accepted in the wrong shape. Data declared with
define-plain-recordgets its check from its fields, so this applies only to hand-written checks. - Only persistent maps are undone key by key after a failed start. A whole value written by two starts, one of which fails, keeps the failing start’s write, and this is reported.
- The worker’s list of Chez procedures is kept by hand. A missing one is refused with a clear message; one wrongly present could let a worker reach shared state. The owner reviews the list once before it is fixed.
- Nested calls charge the plugin whose code made the call. If plugin B makes a view for its caller A through plugin C’s procedure, the view is B’s, not A’s.
- Saved data about another plugin’s things can go stale while that plugin is off: a buffer killed while
modesis off leaves an entry inmodes’ data. Plugins clean such entries when they start, as the process service does. - While the process service is off, killing a shell’s buffer ends the shell only when the service is on again.
- Without a storage plugin, nothing is saved. That is by design (storage is a policy), but removing
journal-storefrominit.sssilently stops saving. - Measured only with small worker results: whether a worker posting large lists slows garbage collection in the editor is not verified; vectors and strings are the cheap form.
- The cost of reading an option with scopes is to be measured when it is built; the design reads the scope procedures at each read.
Open and proposed
For the owner to decide:
- proposed
defaults.ss: Tuna ships one file of the defaultuse-pluginlines, whichinit.ssincludes in one line. See the entry. - proposed Reusing the existing build and the branch names. The branch
corealready has a build (it refuses any Chez but threaded 10.4.1; a compile warning fails it), aMakefile, a small test library and the single-executable packaging, made before the design and independent of it. The proposal: rename the oldcoreandstage-2branchesold-coreandold-stage-2(kept until you delete them), start a newcorefrom that build’s last commit, trim three file-header comments that break the comment rule, and use it as the branch each stage merges into.
Decided by the primary agent (tell him if you disagree):
- Saved data is written by a storage plugin,
journal-store, filling the(storage)contract, not by the core. The core keeps saved data and decides when changed entries must be written; where and how they reach disk is a policy. - Ties are plain data,
(on-caller-end (buffers:kill! id))recorded as the call’s name and arguments, so they are saved with the session and never run an old provider’s code after a swap. - Input from the browser reaches the editor through the core’s input hand-over, which takes plain data from any thread.
- The author’s API of this guide: verbs and
as-caller, hooks, text procedures on the current buffer,define-saved,on-end, dynamic values, plain records and checks, scoped layers and hook entries, and the renames.
Not yet named or fixed (small; settled while building): the names of the commands for turning off, reloading, swapping and ending the session; where init.ss and your own plugins live on disk; the grace period before a forced quit; the start time limit (5 s in the prototype); the persistent map library’s name.
The prototype and this guide
The design was proven with a prototype that used older names. This guide follows the approved API; where the prototype differs, the guide is right.
| Point | Prototype | This guide |
|---|---|---|
| Registrations | table-set! on another plugin’s table, with a key the author invented | verbs, made for the caller with as-caller |
| Hooks | tables, with a key per entry | define-hook, add-hook!, keyed by owner |
| Text editing | (buffers:insert! id (buffers:point id) "Hello") | (buffers:insert! "Hello") on the current buffer |
| Saved data | define-kept, predicates written by hand, vectors read by index | define-saved with a doc, list-of and friends, define-plain-record |
| Per-buffer values | a settings plugin with a hard-coded list | scoped option layers, set-local! |
| Lifetimes | defer! with a procedure; (on-caller-end key 'proc arg); forget-tie! | on-end; (on-caller-end (proc arg)); forget-caller-end |
| Workers | start-job! with a positional key; receive; sleep taking a time object | start-job with for:; next-input; sleep in seconds |
| Ties | procedures, not saved | plain data, saved with the session |
| A buffer whose major mode’s plugin is off | kept its mode name, with no bindings | treated as Fundamental mode until the plugin returns |
| The terminal | made fifos and spoke the helper’s protocol itself | the process service’s follow! and send! |
| Timers | a simulated clock for tests | real timers; the loop sleeps until the next one is due |
| Drawing | HTML strings, one client per test, no browser | the web plugin’s, in the Browser milestone |