Example plugins
Each example is a whole plugin in the approved API, beside the closest idiomatic Emacs code for the same job. They were written first against the prototype, where each ran in the tests, and then rewritten with the verbs; the rewritten versions have not run, because nothing of the API is built yet. The plugins they call (buffers, keys, commands, modes, faces, display, layout, minibuffer, completion, processes) are Tuna’s default plugins as planned; their contracts are listed in Verbs plugins offer.
In none of them does a table, a table key, an owner or a tie key appear. That is the point of the verbs.
Hello: the smallest useful plugin
What it shows: a plugin is a file that runs top to bottom; it imports contracts and calls them with a prefix; it defines a command and binds a key through verbs; and it needs no clean-up code, because both go when it turns off.
(plugin hello
(import (buffers) (commands) (keys))
(commands:define (say-hello)
"Insert a greeting at point."
(buffers:insert! "Hello, world!"))
(keys:bind! "C-c h" 'say-hello))(defun say-hello ()
"Insert a greeting at point."
(interactive)
(insert "Hello, world!"))
(keymap-global-set "C-c h" #'say-hello)How it reads:
(plugin hello …): the folder must be calledhello. Noexport: other plugins cannot use anything of it.(import (buffers) (commands) (keys)): now names writtenbuffers:…,commands:…andkeys:…may be used. Anything else, such asfiles:find-file, is refused at build with its line.commands:definedefines the commandsay-helloand registers it withcommands, as this plugin’s entry.buffers:insert!inserts at point in the current buffer, as Emacs’sinsertdoes.keys:bind!adds this plugin’s binding. WhenkeysseesC-c h, it runs the command namedsay-hello.
To use it: put the folder among your plugins, add (use-plugin hello) to init.ss, run reload-config. To remove it: delete the line and reload-config; the command and the binding are gone, as if it had never been on. Text it inserted stays: it is in your buffer, which is your data.
Where Tuna differs. The Emacs file, once loaded, cannot be taken back: unload-feature removes the function but leaves the key bound to a missing command. The Tuna plugin is one line shorter because the interactive form is not needed: every command is a procedure of no arguments that reads what it needs.
A syntax highlighter: a worker and faces
What it shows: heavy work on a worker; one job per buffer, each change replacing the last; faces given to a buffer through a verb, so they go when the plugin goes.
(plugin highlight
(import (buffers) (faces))
(faces:define highlight '(bold #t) "Words worth noticing.")
(define-worker (find-hello text)
(post (let loop ([i 0] [acc '()])
(if (> (+ i 5) (string-length text))
(reverse acc)
(loop (+ i 1)
(if (string=? (substring text i (+ i 5)) "hello")
(cons (list i (+ i 5) 'highlight) acc)
acc))))))
(define (highlight! id)
(start-job find-hello (buffers:text id)
(lambda (spans) (buffers:set-faces! id spans))
for: id))
(add-hook! buffers:after-change-hook (lambda (id . _) (highlight! id)))
(for-each highlight! (buffers:list)))(defface my-hello '((t :weight bold))
"Words worth noticing.")
(font-lock-add-keywords nil '(("hello" . 'my-hello)))How it works:
- When the plugin starts, the last line starts a scan of every buffer. From then on, its entry in
after-change-hookstarts one after every edit. highlight!hands the worker the buffer’s text, a read-only string, so it is shared without a copy. The job isfor: id: if you type fast, each new job for the same buffer cancels the previous one, so only the newest text’s spans are ever applied.find-helloruns on its own thread. It can usestring-length,substringandpost; if it tried(buffers:text id)the build would refuse it, because a worker cannot reach other plugins.- Its result comes back to
on-resulton the editor thread: spans ready to apply, each(from to face).buffers:set-faces!replaces this plugin’s faces on the buffer; on a buffer killed meanwhile it does nothing. - When
highlightturns off, its faces on every buffer, its hook entry, its face declaration and any running job all go. The next frame is drawn without the colours. The buffers are untouched.
Where Tuna differs. Emacs is shorter because font-lock is a highlighting engine and this plugin is the engine. A Tuna language mode would use a highlighting plugin’s verb in the same way, (highlight:add-keywords! 'scheme-mode '(("hello" . highlight))); this example shows what such a plugin does with a worker. Emacs’s font-lock runs on the main thread, so a slow pattern freezes typing; Tuna’s scan never blocks typing.
Flymake-style diagnostics
What it shows: a checker on a worker; a minor mode tied to the plugin; faces and margin marks on a buffer; a per-buffer value; a mode-line item; a command with a key.
(plugin flymake
(export diagnostics)
(import (buffers) (modes) (faces) (display) (commands) (keys))
(faces:define flymake-error '(underline (wave "#dc322f")) "Text a checker found an error in.")
(modes:define-minor flymake-mode "Checks the buffer as you type.")
(define-option found '() "The current buffer's errors, each (from to line)." list?)
;; Each FIXME is an error.
(define-worker (find-fixmes text)
(post (let loop ([i 0] [line 0] [acc '()])
(cond [(> (+ i 5) (string-length text)) (reverse acc)]
[(string=? (substring text i (+ i 5)) "FIXME")
(loop (+ i 5) line (cons (list i (+ i 5) line) acc))]
[else (loop (+ i 1) (if (char=? (string-ref text i) #\newline) (+ line 1) line) acc)]))))
(define (diagnostics id) (buffers:with id found))
(define (check! id)
(start-job find-fixmes (buffers:text id)
(lambda (errors)
(modes:enable-minor! id 'flymake-mode)
(buffers:with id (set-local! found errors))
(buffers:set-faces! id (map (lambda (d) (list (car d) (cadr d) 'flymake-error)) errors))
(buffers:set-margin! id (map (lambda (d) (list (caddr d) "!" 'flymake-error)) errors)))
for: id))
(add-hook! buffers:after-change-hook
(lambda (id . _) (unless (buffers:internal? id) (check! id))))
(display:add-mode-line!
(lambda (id) (let ([n (length (diagnostics id))]) (and (> n 0) (format "FlyM:~a" n)))))
(commands:define (flymake-goto-next-error)
"Move point to the next error."
(let ([d (find (lambda (d) (> (car d) (buffers:point))) found)])
(when d (buffers:goto! (car d)))))
(keys:bind! "C-c ! n" 'flymake-goto-next-error)
(for-each check! (remp buffers:internal? (buffers:list))))(defface flymake-error '((t :underline (:style wave :color "Red1")))
"Face used for marking error regions.")
(defvar-local flymake--found nil "This buffer's errors.")
(define-minor-mode flymake-mode "Checks the buffer as you type."
:lighter (:eval (format " FlyM:%d" (length flymake--found)))
(if flymake-mode
(add-hook 'after-change-functions #'flymake-after-change nil t)
(remove-hook 'after-change-functions #'flymake-after-change t)))
(defun my-fixme-backend (report-fn &rest _)
(let (found)
(save-excursion
(goto-char (point-min))
(while (search-forward "FIXME" nil t)
(push (flymake-make-diagnostic (current-buffer) (- (point) 5) (point) :error "FIXME")
found)))
(funcall report-fn found)))
(add-hook 'flymake-diagnostic-functions #'my-fixme-backend nil t)
(keymap-set flymake-mode-map "C-c ! n" #'flymake-goto-next-error)How it reads:
foundis an option whose value in each buffer is a layer scoped to that buffer:(buffers:with id (set-local! found errors))sets it, and readingfoundgives the current buffer’s errors. It is Emacs’sdefvar-local. It goes when the buffer is killed and when flymake turns off.- Line numbers are counted on the worker, not on the editor thread.
modes:enable-minor!turns onflymake-modein the buffer and ties the mode toflymake(it callson-caller-endinside), so the mode goes when flymake does.display:add-mode-line!adds this plugin’s mode-line item;keys:bind!its key.- A pause before checking, as Emacs’s
flymake-no-changes-timeout, would be arun-with-idle-timer.
Where Tuna differs. Emacs splits flymake into an interface and backends, each backend a hook function; the Tuna plugin here is both. Splitting it the same way, with checkers in their own plugins registering a worker through a verb such as (flymake:add-checker! find-fixmes), is a natural next step, not part of the core. Emacs runs the backend on the main thread or in a subprocess; Tuna on a worker.
A status view, like magit
What it shows: a plugin that shows its output in a buffer of its own, a view, written with no lifetime code at all; and, on the provider’s side, how buffers makes a view go with the plugin that asked for it.
The problem it solves: if the status plugin made its buffer with buffers:create, the buffer would be the user’s and would stay open after the plugin turned off, with keys that do nothing. If buffers closed every buffer a plugin made, your shell would close when the terminal plugin reloaded. So the provider offers two procedures and says, per procedure, whose the thing is.
(plugin gitstat
(import (buffers) (layout) (modes) (faces) (commands) (keys))
(faces:define git-added '(foreground "green") "Margin mark of a changed file.")
(modes:define-major git-status-mode "Shows the repository's status.")
(define (render) "On branch main\nmodified: notes.txt\n")
(define (changed-file? id) (equal? (buffers:file id) "notes.txt"))
(commands:define (magit-status)
"Show the repository's status."
(let ([view (buffers:view "*git-status*")])
(modes:set-major! view 'git-status-mode)
(buffers:with view
(buffers:erase!)
(buffers:insert! (render)))
(for-each (lambda (id) (buffers:set-margin! id '((0 "+" git-added))))
(filter changed-file? (buffers:list)))
(layout:show! view)))
(keys:bind! "g" 'magit-status 'git-status-mode))(define-derived-mode git-status-mode special-mode "Git-Status"
"Shows the repository's status.")
(keymap-set git-status-mode-map "g" #'magit-status)
(defun magit-status ()
"Show the repository's status."
(interactive)
(with-current-buffer (get-buffer-create "*git-status*")
(git-status-mode)
(let ((inhibit-read-only t))
(erase-buffer)
(insert (my-render)))
(pop-to-buffer (current-buffer))))render stands for running git; changed-file? for reading its output.
What you see: M-x magit-status opens *git-status* in the selected window. Edit and reload gitstat: the view stays open, in its window. Turn gitstat off: the view closes, the window shows what it showed before, and the + in notes.txt’s margin goes. If you had a buffer of your own called *git-status*, it is untouched; the view is *git-status*<2>. After a crash the view comes back still tied to gitstat, because ties are saved.
The provider’s side. The parts of buffers this view uses:
(define-plain-record buffer
([name string?] [text] [point fixnum?] [mark (maybe fixnum?)] [modified? boolean?]))
(define-saved all (pmap) "Every buffer by id." (map-of fixnum? buffer?))
(define-saved views (pmap) "Each view's id -> the name it was asked for." (map-of fixnum? string?))
(define-dynamic current #f "The buffer text procedures act on.")
(define-hook after-change-hook "Called with id, position, deleted text and inserted text.")
(define-hook killed-hook "Called with a killed buffer's id.")
(define-hook kill-query-hook "Called with an id; an entry returning #f refuses the kill.")
(define-table faces "Buffer id -> each owner's list of (from to face).")
(define-table margins "Buffer id -> each owner's list of (line text face).")
;; A plugin's view goes when the plugin that asked for it turns off, and
;; stays across its reloads; a buffer the user made with that name is never taken.
(define (view name)
(let ([id (or (find (lambda (id) (and (live? id) (string=? (pmap-ref views id) name)))
(pmap-keys views))
(let ([id (generate name)]) (set! views (pmap-set views id name)) id))])
(on-caller-end (buffers:kill! id))
id))
(define (set-faces! id spans) (when (live? id) (as-caller (table-set! faces id spans))))
(define (set-margin! id marks) (when (live? id) (as-caller (table-set! margins id marks))))
(define (kill! id)
(and (live? id)
(run-hook-until-failure kill-query-hook id)
(begin
(set! all (pmap-delete all id))
(when (pmap-contains? views id)
(set! views (pmap-delete views id))
(forget-caller-end (buffers:kill! id)))
(table-clear! faces id)
(table-clear! margins id)
(run-hook killed-hook id)
#t)))
(define (insert! s)
(let ([b (rec current)])
(put! current (buffer-with-point
(buffer-with-text b (text-store:insert (buffer-text b) (buffer-point b) s))
(+ (buffer-point b) (string-length s))))
(run-hook after-change-hook current (buffer-point b) "" s)));; In Emacs all of this is C: buffer objects,
;; get-buffer-create, kill-buffer (which runs
;; kill-buffer-query-functions and kill-buffer-hook,
;; and deletes the buffer's overlays and local variables),
;; and insert.
(get-buffer-create "*git-status*")
(kill-buffer "*git-status*")What to notice:
- Every buffer is one entry of the saved map
all, a plain record, so buffers survive reloads, swaps and crashes with no code for it. viewremembers its views in the saved mapviews, so asking again for the same name after a reload or a crash finds the same buffer and ties it again. Tying the same call again replaces the old tie.- The tie names
buffers:kill!rather than this plugin’s ownkill!: when it runs, perhaps afterbuffershas been swapped for another implementation, it reaches whichever provider is current. kill!drops every plugin’s faces and margin marks on the dead buffer withtable-clear!, and the buffer’s scoped option layers and hook entries go with it, so no decorating plugin needs a killed-hook entry.set-faces!andset-margin!are verbs:as-callermakes each entry the calling plugin’s.
A terminal, using the process service
What it shows: your processes owned by a service, not by the plugin that shows them; output followed through a verb, every piece once and in order; restarting work at the top of the file.
Why it is built this way. Processes you start are your work: they keep running when the terminal plugin turns off, reloads or is swapped, and across a clean exit of Tuna. Three facts force the shape. A port (an open pipe) belongs to the run that opened it, and Chez closes ports nobody refers to, so a pipe cannot survive a reload inside Tuna. A worker blocked reading a pipe cannot be cancelled from inside Tuna. And output must not be lost or repeated across a reload. So each process lives in a small helper program that owns its pipes and keeps its output with offsets, and the process service offers follow!, which delivers each piece once.
(plugin terminal
(import (buffers) (modes) (keys) (commands) (layout) (processes))
(modes:define-major term-mode "A shell in a buffer.")
;; Output goes at the end, wherever point is.
(define (follow! id)
(processes:follow! id
(lambda (text)
(buffers:with id
(buffers:goto! (buffers:size))
(buffers:insert! text)))))
(commands:define (shell)
"Run a shell in the buffer *shell*."
(let ([id (buffers:create "*shell*")])
(unless (processes:running? id)
(modes:set-major! id 'term-mode)
(processes:start! id "bash -i")
(follow! id))
(layout:show! id)))
(commands:define (term-send-input)
"Send the current line to the shell."
(processes:send! buffers:current (string-append (buffers:line) "\n")))
(keys:bind! "RET" 'term-send-input 'term-mode)
;; Shells run on while the terminal is off; follow them again.
(for-each follow!
(filter (lambda (id) (and (eq? (modes:major id) 'term-mode) (processes:running? id)))
(buffers:list))))(defun my-shell ()
"Run a shell in the buffer *shell*."
(interactive)
(let ((buf (get-buffer-create "*shell*")))
(unless (get-buffer-process buf)
(with-current-buffer buf (my-term-mode))
(make-process :name "shell" :buffer buf :command '("bash" "-i")
:filter (lambda (proc s)
(with-current-buffer (process-buffer proc)
(goto-char (point-max))
(insert s)))))
(pop-to-buffer buf)))How the pieces fit:
M-x shellmakes*shell*withbuffers:create(your buffer, which no plugin closes) and asksprocesses:start!for a process for it.follow!starts the service’s reader as the terminal’s job. Each piece of output arrives once, in order; the terminal inserts it at the end.- When the terminal’s run ends for any reason, the reader is cancelled and released by the service, because it was started on the terminal’s receipt. The shell keeps running.
- The last form runs at every start: for each shell still running, follow it again. The service resumes from the offset it already delivered, so output printed while the terminal was off appears, and nothing is repeated.
- Killing
*shell*asks “Buffer has a running process; kill it?” (the service’skill-query-hookentry), then ends the shell, whether or not the terminal is on.
The terminal keeps no saved data: the buffer and its process (kept by the service under the buffer’s id) are the state, as in Emacs. While the terminal is off, a shell buffer shows as Fundamental mode, and its mode returns with the terminal.
The service’s side (part):
(define-plain-record process ([pid fixnum?] [dir string?] [command string?] [delivered fixnum?]))
(define-saved all (pmap)
"Each process by key: a buffer id for the user's, a name for a plugin's own."
(map-of (lambda (k p) (and (or (fixnum? k) (string? k)) (process? p)))))
(define (follow! id on-output)
(as-caller
(start-job read-output (vector (process-dir (entry id)) (process-delivered (entry id)))
(lambda (piece)
(let* ([p (entry id)] [have (process-delivered p)]
[off (car piece)] [text (cdr piece)] [end (+ off (string-length text))])
(when (> end have)
(on-output (substring text (max 0 (- have off)) (string-length text)))
(set-entry! id (process-with-delivered p end)))))
for: id)
(on-end (disconnect! id))))
(define (start-own name command)
(on-caller-end (processes:stop! name))
(if (running? name) name (launch! name command)))
(add-hook! buffers:kill-query-hook
(lambda (id)
(or (not (running? id)) (not (provided? 'minibuffer))
(minibuffer:yes-or-no? "Buffer has a running process; kill it? "))))
(add-hook! buffers:killed-hook stop!);; Emacs's equivalents are built in:
(add-hook 'kill-buffer-query-functions #'process-kill-buffer-query-function)
(set-process-filter proc #'my-filter)
(process-send-string proc "ls\n")The offset is the service’s saved data, written in the same command as the insert, so nothing is lost or repeated across reloads and crashes. A process a plugin starts for itself is the other kind:
(plugin lsp
(export server)
(import (processes))
(define server (processes:start-own "lsp" "clangd")))(defvar my-lsp-server (make-process :name "lsp" :command '("clangd")))start-own ties the process to lsp: the same process across a reload of lsp, ended when lsp turns off.
Where Tuna differs. The Tuna version is longer than Emacs’s because it also sends input and reattaches its shells at start. In exchange the shell outlives the plugin, its reloads and a clean exit, and no output is lost or repeated. Emacs kills a buffer’s process with its plugin’s buffer at exit. The prototype used pipes, not a pseudo-terminal; a real terminal needs one, from a general library in tuna/lib/.
A completion popup
What it shows: fuzzy matching on a worker, each keystroke’s job replacing the last; code shared between a plugin and its worker with include; hooks another plugin offers; private state in plain variables, and what should be drawn handed to the display.
(plugin cpop
(import (minibuffer) (completion) (buffers) (display) (commands) (keys))
(define-worker (match in)
(include "fuzzy.ss")
(post (best-matches (vector-ref in 0) (vector-ref in 1) 50)))
(define items '())
(define selected 0)
(define (show!) (display:show-popup! items selected))
(define (close!) (set! items '()) (display:hide-popup!))
(define (refresh!)
(let ([kind (minibuffer:current-prompt)])
(if kind
(start-job match (vector (list->vector (completion:complete kind ""))
(buffers:text (minibuffer:prompt-buffer)))
(lambda (found)
(when (eq? kind (minibuffer:current-prompt))
(set! items found)
(set! selected 0)
(show!)))
for: 'match)
(close!))))
(define (move! by)
(unless (null? items)
(set! selected (modulo (+ selected by) (length items)))
(show!)))
(commands:define (cpop-next) "Select the next candidate." (move! 1))
(commands:define (cpop-prev) "Select the previous candidate." (move! -1))
(commands:define (cpop-accept)
"Answer the prompt with the selected candidate."
(unless (null? items)
(let ([choice (list-ref items selected)])
(close!)
(minibuffer:answer! choice))))
(add-hook! minibuffer:opened-hook (lambda (kind) (refresh!)))
(add-hook! minibuffer:closed-hook (lambda (kind) (close!)))
(add-hook! buffers:after-change-hook
(lambda (id . _) (when (eqv? id (minibuffer:prompt-buffer)) (refresh!))))
(keys:bind! "C-n" 'cpop-next 'minibuffer-mode)
(keys:bind! "C-p" 'cpop-prev 'minibuffer-mode)
(keys:bind! "TAB" 'cpop-accept 'minibuffer-mode))(add-hook 'completion-at-point-functions #'my-capf nil t)
(keymap-set corfu-map "C-n" #'corfu-next)
(keymap-set corfu-map "C-p" #'corfu-previous)
(keymap-set corfu-map "TAB" #'corfu-insert)
(add-hook 'minibuffer-setup-hook #'my-popup-refresh)
(add-hook 'minibuffer-exit-hook #'my-popup-close);; A smaller score is a better match: the span the query's characters cover.
(define (score name q)
(let ([n (string-length name)] [m (string-length q)])
(let loop ([i 0] [j 0] [start #f])
(cond
[(fx= j m) (fx- i (or start 0))]
[(fx= i n) #f]
[(char=? (string-ref name i) (string-ref q j)) (loop (fx+ i 1) (fx+ j 1) (or start i))]
[else (loop (fx+ i 1) j start)]))))
(define (best-matches names q k)
(let loop ([i 0] [acc '()])
(if (fx= i (vector-length names))
(let ([sorted (list-sort (lambda (a b) (fx< (car a) (car b))) acc)])
(map cdr (if (> (length sorted) k) (list-head sorted k) sorted)))
(let ([s (score (vector-ref names i) q)])
(loop (fx+ i 1) (if s (cons (cons s (vector-ref names i)) acc) acc))))))How it works: when a prompt opens, or you type in it, refresh! collects every candidate from the completion sources on the editor thread (the worker cannot ask them) and starts match with the candidates and your input, for: 'match, so the previous keystroke’s match is cancelled. The worker includes fuzzy.ss and returns the best 50. The result is applied only if the same prompt is still open: closing a prompt starts no new job, so this check is needed here. The popup’s state is two plain variables; display:show-popup! tells the display what to draw, which also makes the frame due. Turning cpop off removes its keys, hook entries, commands and popup; the prompt it decorated stays open and can be answered. Measured in the prototype: typing stayed at about 0.1 ms per key while each key started a match of 100,000 names, each match taking about 9.5 ms on its worker.
Where Tuna differs. An Emacs completion popup (company, corfu) is a third-party package of thousands of lines, because Emacs has no popup primitive and no worker; its matching runs on the main thread and is cut short when you type. The registrations read the same.
A theme
(plugin solarized
(import (faces))
(faces:set! 'default '(background "#002b36" foreground "#839496"))
(faces:set! 'highlight '(foreground "#b58900"))
(faces:set! 'flymake-error '(underline (wave "#dc322f")))
(faces:set! 'git-added '(foreground "#859900")))(deftheme solarized)
(custom-theme-set-faces 'solarized
'(default ((t :background "#002b36" :foreground "#839496")))
'(highlight ((t :foreground "#b58900")))
'(flymake-error ((t :underline (:style wave :color "#dc322f"))))
'(git-added ((t :foreground "#859900"))))A theme is a plugin that gives faces its looks. Turning it off returns each face to the look below it: another theme’s, or the default its plugin declared. The theme writes plain attributes, not the browser’s CSS, so it does not depend on the display.
Where Tuna differs. In Emacs only themes layer face settings; a set-face-attribute elsewhere is permanent. In Tuna every faces:set! is a layer.
The user’s init.ss
The configuration the prototype’s scenario tests used, in the approved API.
(use-plugin journal-store commands settings keys rope-store buffers)
(use-plugin undo limit: 5000)
(use-plugin modes tree-layout faces minibuffer completion files registers)
(use-plugin highlight processes terminal web)
(set! settings:fill-column 70)
(keys:bind! "C-c n" (lambda () (files:find-file "~/notes.txt")))
(keys:unbind! "C-z")
(redefine (english::helper n) (string-append "Yo, " n))
(advice-add dabbrev:expand filter-return (lambda (s) (string-append s "+i")))(use-package undo-tree :custom (undo-limit 5000))
(setq-default fill-column 70)
(keymap-global-set "C-c n" (lambda () (interactive) (find-file "~/notes.txt")))
(keymap-global-unset "C-z")
(defun english--helper (n) (concat "Yo, " n))
(advice-add 'dabbrev-expand :filter-return (lambda (s) (concat s "+i")))How it reads:
journal-storefills(storage): it reads saved data at start and writes it after every command. Leave it out and Tuna runs but saves nothing. Its optionfresh: #tstarts Tuna without restoring anything, leaving the file untouched: the way back if restored state is itself the problem.rope-storepicks the(text-store)implementation;tree-layoutpicks(layout);webpicks(display).use-plugintakes several names when none has options;undo limit: 5000sets the undo plugin’s option, a layer belonging to that line.- Order does not matter for correctness:
buffersneeds(text-store)while starting, and ifrope-store’s line came later it would simply be started first. - Every line after the
use-pluginlines belongs to its line: remove one andreload-config, and that setting, binding, redefinition or advice is gone. - With the proposed
defaults.ss, the first four lines would be(include "defaults.ss")and the plugins you add.
A contract with two implementations, and a swap
What it shows: an interface declared apart from any implementation; a second plugin standing in for it; a plugin using the interface without knowing which implementation runs; swapping implementations while Tuna runs; saved data that belongs to the contract and passes to the replacement.
buffers stores each buffer’s text through the (text-store) contract. One implementation might be a plain string, another a rope (a tree of chunks, faster for big files). You choose in init.ss; buffers does not change.
(contract (text-store)
(procedure make (s) "A text holding the string s.")
(procedure insert (t pos s) "The text t with s inserted at pos.")
(procedure delete (t from to) "The text t without the characters from..to.")
(procedure kind () "A symbol naming the implementation."))(plugin rope-store
;; Texts cross the contract as strings; the rope is this provider's own business.
(define (chunks s)
(let loop ([i 0] [acc '()])
(if (>= i (string-length s))
(reverse acc)
(loop (+ i 4096) (cons (substring s i (min (string-length s) (+ i 4096))) acc)))))
(define (make s) (string-copy s))
(define (insert t pos s)
(let ([c (apply string-append (chunks t))])
(string-append (substring c 0 pos) s (substring c pos (string-length c)))))
(define (delete t from to) (string-append (substring t 0 from) (substring t to (string-length t))))
(define (kind) 'rope))(plugin string-store
(implements (text-store))
(define (make s) (string-copy s))
(define (insert t pos s) (string-append (substring t 0 pos) s (substring t pos (string-length t))))
(define (delete t from to) (string-append (substring t 0 from) (substring t to (string-length t))))
(define (kind) 'string));; before
(use-plugin rope-store)
;; after: the line replaced, then M-x reload-config
(use-plugin string-store);; Emacs has one buffer text, in C, and nothing to swap.
;; Its nearest swap is a function variable:
(setq completing-read-function #'ivy-completing-read)How it reads:
rope-storeholds thecontract.ss, so its interface is(text-store), not(rope-store). It implements it by holding it; it does not writeimplements.string-storesays(implements (text-store)). The build checks that it definesmake,insert,deleteandkindas procedures; the load checks thatinserttakes three arguments, and so on. If it lacked two names, one message would list both.bufferswrites(import (text-store))and calls(text-store:insert t pos str). It never names either implementation.- The swap:
reload-configsees the line choosingrope-storego and a line choosingstring-store, which fills the same contract, come.rope-storeturns off;string-storestarts;buffers, which used(text-store)while starting, reruns with the new provider. Ifstring-storefailed to start,rope-storewould start again and you would be told. Step through it in What happens when you swap.
Saved data across a swap. (text-store) keeps no saved data. The minibuffer’s contract does, and declares it whole:
(contract (minibuffer)
(saved history '() "Answers to prompts by kind, newest first."
(list-of (pair-of symbol? (list-of string?))))
(procedure read-string (prompt kind) "Ask the user for a string and return it."))(plugin ivy
(implements (minibuffer))
(import (buffers) (layout) (modes) (keys) (commands) (files))
(define-option height 10 "Lines the prompt shows." fixnum?)
(define (remember! kind answer)
(set! history (cons (list kind answer) history))))ivy uses history by its short name and does not declare it: the build writes the declaration from the contract into every implementation, so the shape cannot drift between them. It is stored as minibuffer:history, under the contract’s name, so after a swap from minibuffer to ivy, ivy starts with the history you built in the minibuffer.
Where Tuna differs. Emacs’s ivy-mode replaces completing-read-function and keeps its own history variables, so switching back and forth splits your history between packages. In Tuna the history is the contract’s.