A minimal Emacs in Clojure.
Everything is a buffer, every action is a state transition.
xmas.buf — Buffer data type
A buffer is an immutable map: {:name :text :point :mark :file :modified :mode :undo} — a string, a cursor position (integer offset), and metadata.
Three operations define the type:
make— Constructor. Name, optional text, optional file path.edit— The single edit primitive. Splicesreplintotext[from..to], adjusts point, records undo, marks modified. Every mutation (insert, delete, kill) bottoms out here.undo— Pops the last edit. Returnsnilwhen empty.
set-point clamps a movement function's result to valid range — all cursor movement goes through it.
xmas.text — Text primitives
Pure functions on strings. No editor state, no buffers — just text and positions.
next-pos/prev-pos— Step by one Unicode codepoint (handles surrogate pairs).line-start/line-end— Find newline boundaries around a position.word-forward/word-backward— Scan to next/previous word boundary.char-width— Terminal display width of a codepoint (2 for CJK/fullwidth, 0 for combining marks, 1 otherwise).display-width— Total terminal columns for a text range.pos-at-col— Find the position at a target display column.
xmas.ed — Editor core
A single atom holds the entire editor state:
{:buf "*scratch*" ;; name of active buffer
:bufs {"*scratch*" {...}} ;; all buffers by name
:kill [] ;; kill ring (clipboard stack)
:msg nil ;; echo area message
:mini nil ;; minibuffer state (when prompting)
:scroll 0, :rows R, :cols C, :last-key K}cur and update-cur bridge editor state to the current buffer. edit and set-point delegate to buf/ through this bridge.
Commands are pure state -> state functions. forward-char, kill-line, yank, save-buffer — they all take editor state and return new state. No side effects except save-buffer (which spits to disk).
Minibuffer — Prompts (find-file, switch-buffer) reuse the buffer abstraction. mini-start creates a temporary buffer named " *mini*" and swaps it in as active. Normal editing commands just work inside it. mini-accept reads the input and calls the callback.
Keybindings — A nested map. Simple keys map to command functions. [:ctrl \x] maps to a sub-map for prefix sequences (C-x C-f, C-x C-s, etc.). handle-key dispatches, with chars falling through to self-insert.
xmas.term — Terminal I/O
Raw terminal control without JLine. Two halves:
-
Input:
enter-raw-mode!viastty, then reads bytes fromSystem.in.read-keydecodes raw bytes into semantic keys — chars,[:ctrl \f],[:meta \b],:up,:backspace, etc. Multi-byte UTF-8 is decoded into codepoints. CSI escape sequences are parsed for arrow keys and special keys. -
Output: Thin wrappers over ANSI escape codes —
move,cls,clreol,sg(set graphics/colors). Everything writes toSystem.outdirectly.
xmas.view — Screen rendering
Converts editor state into terminal output:
cursor-pos— Maps a text offset to a[row, col]screen position relative to scroll.render— Draws the full screen: text lines, a mode line (Emacs-style-- *scratch* (fundamental)), and the minibuffer/echo area. Returns the new scroll position.
xmas.log — Debug logging
Append-only file logger to ~/.xmas/debug.log. Every keypress byte and decoded key is logged.
xmas starts an nREPL server on port 7888. Connect from Emacs/CIDER:
M-x cider-connect RET localhost RET 7888
Or from the command line:
emacsclient -e '(cider-connect-clj (list :host "localhost" :port 7888))'
Once connected, the running editor is fully inspectable and modifiable:
;; inspect state
(keys @xmas.ed/editor)
(:text (xmas.ed/cur @xmas.ed/editor))
;; inject text into the live editor
(swap! xmas.ed/editor xmas.ed/edit 0 0 "hello from emacs!\n")
;; add a keybinding at runtime
(alter-var-root #'xmas.ed/bindings assoc [:ctrl \t] xmas.ed/next-line)
;; define a new command and bind it
(defn my-cmd [s] (xmas.ed/msg s "it works"))
(alter-var-root #'xmas.ed/bindings assoc [:meta \z] my-cmd)
;; open a file programmatically
(swap! xmas.ed/editor xmas.ed/find-file "/tmp/test.txt")
;; switch buffer
(swap! xmas.ed/editor xmas.ed/switch-buffer "*scratch*")Every command is a pure state -> state function. swap! applies it atomically. Changes appear on the next redisplay cycle (any keypress in xmas).
read-key -> handle-key(state, key) -> new state -> render(state) -> terminal
^ |
'----------------------- command-loop ----------------------------'
The main loop is tight: read a key, dispatch to a command, get new state, render, repeat. The atom is only reset!'d at the loop boundary — commands themselves are pure.