Architecture¶
loom uses a layered design, but the boundary is behavioral rather than a rule that every package dependency must point only downward. The ASDF component order is the load-order constraint; code calls a usable package directly when that package owns the operation.
src/
├── domain/ Shared editor vocabulary and invariants, such as keymaps.
├── application/ Shared editor-state, minibuffer, command registry, and
│ keybinding composition.
├── infrastructure/ Shared terminal rendering boundary.
├── presentation/ Shared screen composition from editor state.
└── main.lisp Composition root: CLI definition and raw-terminal loop.
packages/
├── core/editor/ Reusable buffer storage and movement/editing vocabulary.
└── feature/<name>/ Complete feature slices, each with DDD-role filenames:
domain-*, application-*, infrastructure-*, and
presentation-*.
The root src/<DDD> directories are the shared composition route. The
package-by-feature slices under packages/ hold the feature-owned code, while
loom.asd is the explicit composition root that loads the files in dependency
order. Feature-owned operations are exported from their loom/feature/<name>
packages; loom contains only the editor kernel and composition-root API. No
compatibility facade is maintained.
The piece-table representation and pure mutation/range helpers live in
packages/core/editor/src/domain-buffer-storage.lisp;
packages/core/editor/src/domain-buffer.lisp exposes the buffer protocol and
position/span types over that state. Keeping these responsibilities separate
makes the representation invariants independently testable without introducing
an I/O boundary into the domain layer.
The buffer protocol keeps the existing Emacs-style undo ring and an explicit redo history. Undo/redo replay uses storage primitives without clearing redo; ordinary edits clear redo so a new edit starts a new branch.
Read-only state is owned by the buffer domain. Public mutation methods and
undo/redo pass through the same writable guard, while the file infrastructure
marks buffers loaded from non-writable real files read-only and refuses to save
read-only buffers. C-x C-q changes the state explicitly at the command layer.
The window feature follows the same split: domain-window.lisp contains the
tree model, layout calculation, and serialization, while
domain-window-operations.lisp contains split, selection, buffer assignment,
and resize mutations. The operation file depends on the model but not on the
terminal renderer.
Boundaries¶
src/domain/ owns shared editor invariants and does not perform terminal or
filesystem I/O. Feature-specific domain invariants live beside their use cases
under packages/feature/<feature>/src/. Search and replacement matching uses
the pure computation provided by cl-regex-kit.
src/infrastructure/ owns the shared terminal-rendering boundary. Filesystem
access, trusted Lisp evaluation in LOOM-USER, session persistence, and the LSP
process boundary live in their feature packages. Normal filesystem operations
use the cl-boundary-kit filesystem object *loom-filesystem*; tests rebind it
to an in-memory fake. Two operations call cl-host-kit directly because their
correctness depends on behavior that the boundary object does not provide:
directory-entry classification and symlink-safe recursive deletion.
Session persistence also uses cl-host-kit's overwrite-safe move after a
temporary sibling is complete, and user-init configuration reads the
environment through the same package. The native pathname helpers, native
mutations, native file I/O, and symlink-safe native deletion are isolated in
infrastructure-filesystem-native-{paths,mutations,io,delete}.lisp; the
disk-backed file-tree child lister lives in
infrastructure-file-tree-directory-listing.lisp, the file-tree mutation
methods live in infrastructure-file-tree-filesystem.lisp, and the
buffer-facing boundary methods live in
infrastructure-buffer-filesystem.lisp.
src/application/ owns the shared editor-state struct, the
*editor-state* special variable, minibuffer state, command registry, and
keybinding composition. The editor state also owns the bounded canonical
recent-file list and the named-bookmark table: file-tree commands update the
recent-file list when files are opened or saved, while the shared misc
commands create, resolve, list, and delete bookmarks. Feature application
files orchestrate their own domain and infrastructure boundaries. Minibuffer code calls cl-tty-kit and
cl-history-kit directly, and the M-x registry uses the local command
specifications directly. There is no wrapper layer whose only purpose is to
hide a package that already provides the required operation. make-editor-state
also establishes the workspace invariant: when a caller does not supply a
manager, the initial window tree is installed as the named main workspace.
Commands therefore require a manager instead of reconstructing one through a
legacy fallback.
The session feature serializes those editor-state collections alongside buffers, named workspaces, and each workspace's window layout and selection. Its canonical v5 format preserves recent paths, bookmark positions, and M-x/minibuffer command history. The reader accepts only the v5 envelope and does not provide a compatibility reader for pre-v5 session layouts.
The LSP slice deliberately keeps its dependency boundary small:
application-commands-lsp.lisp owns interactive commands,
application-lsp-session-state.lisp owns session state plus URI/language
helpers, application-lsp-session-sync.lisp owns document synchronization
and diagnostic lookup, application-lsp-session-lifecycle.lisp owns startup,
refresh, and shutdown orchestration, application-lsp-protocol-send.lisp
owns JSON-RPC encoding, application-lsp-protocol-initialize.lisp owns
initialize/capability messages, and the helper/receive slices own
response/diagnostic parsing plus nonblocking dispatch.
infrastructure-lsp-transport.lisp owns child-process lifecycle and stdio
transport. Pure UTF-8 and Content-Length framing is isolated in
infrastructure-lsp-framing.lisp, and JSON messages are parsed and
constructed through cl-json-kit.
lsp-discover-command, in infrastructure-lsp-discovery.lisp, searches the
current path's ancestor directories for the nearest .loom-lsp; its first
non-empty, non-comment line is the trusted server command. lsp-start presents that command as the default, while an
explicit prompt value overrides it. Dynamic registration and requests beyond
the current diagnostics/document-sync slice remain outside this boundary.
The shell feature keeps process execution and presentation data separate:
domain-shell.lisp defines the captured command result,
infrastructure-shell.lisp runs a command through UIOP while preserving
standard output, standard error, the working directory, and the exit code, and
application-commands-shell.lisp implements M-!/M-x pipe-command. The
interactive command runs in the selected file's parent directory and appends
the rendered result to *Loom-Pipe-Command*; a non-zero exit status is
displayed as result data rather than treated as an editor error.
The format feature builds on the shell result boundary without coupling the
buffer domain to process execution. format-buffer-with-command sends the
complete buffer text to an external command and commits stdout only after a
zero exit status. The command runs in the file's directory when available and
preserves point and mark offsets; read-only and narrowed buffers are rejected
before process execution. The replacement is recorded as one undo group.
The Git feature reuses project-root discovery and the shell result boundary.
C-x g runs concise branch-aware status at the current project root and
renders the captured output in a generated read-only *Loom-Git-Status* buffer.
M-x git-diff and M-x git-diff-staged use the same boundary for working-tree
and index patches, rendering both in the read-only *Loom-Git-Diff* buffer.
M-x git-stage-file and M-x git-unstage-file reuse the same project-root
lookup to run index mutations after a minibuffer path prompt. Paths are quoted
as POSIX shell words before they reach the shell command boundary. Non-zero Git
exits remain visible as result data or minibuffer status, so an uncommitted or
non-repository directory does not turn process status into an editor failure.
The terminal feature owns PTY-backed child-process sessions. Its domain object
domain-terminal.lisp owns the bounded terminal-screen model and ANSI
rendering parser, while domain-terminal-session.lisp owns the running PTY
process, selected buffer, raw output, liveness, and exit state. The
infrastructure layer starts, reads, writes, resizes, and closes the PTY; the
application layer translates key events and exposes terminal and
terminal-stop. The event loop polls output and resize state alongside normal
rendering. The presentation renders the screen model, which covers common
cursor movement, erasure, cursor save/restore, and minimal alternate-screen
switching; it is not a full VT terminal emulator.
The auto-save feature keeps its sidecar naming and eligibility rules in
domain-auto-save.lisp; infrastructure-auto-save.lisp writes the complete
buffer text without changing the buffer's normal modified state, and
application-commands-auto-save.lisp owns global/per-buffer mode and interval
gating. The event loop invokes the pass after input dispatch, so auto-save does
not introduce a second writer thread into the editor state.
The bounded asynchronous directory-listing runtime is split across
packages/feature/file-tree/src/infrastructure-concurrent-runtime-state.lisp
(the runtime struct, directory cache, and generation bookkeeping),
infrastructure-concurrent-runtime-prefetch.lisp (submitting uncached
directories to cl-concurrent-kit workers), and
infrastructure-concurrent-runtime-results.lisp (draining and applying
worker results, plus shutdown). It uses cl-concurrent-kit workers, a bounded
result channel, cache/pending/error tables, and per-directory generations.
Workers perform listing only; loom-concurrent-runtime-drain applies results
on the render/event-loop lane, and invalidation advances a generation so stale
results cannot overwrite newer state. The default runtime is four workers with
a queue capacity of 64, so the in-flight bound is 68.
Commands in the shared application route and in
packages/feature/<feature>/src/application-*.lisp are zero-argument
functions that read and mutate *editor-state*, which lets keymaps store bare
function designators. src/application/command-definitions.lisp is the
declarative command catalogue; src/application/command-registry.lisp owns
specification storage, completion candidates, and M-x lookup.
commands-internal.lisp supplies the continuation-based with-prompts flow
used by prompts, while commands-keybindings.lisp installs the default
bindings separately from the prompt protocol.
packages/feature/mode/src/domain-major-mode-definitions.lisp keeps built-in
mode metadata alongside the extension-defined mode registry state;
domain-major-mode.lisp holds the read-side lookup API over that state, and
domain-major-mode-registry.lisp holds registration and validation. The pure
domain-major-mode-path.lisp module normalizes paths and performs basename,
extension, registry, and default matching without filesystem access.
Extensions register file associations, syntax metadata, parent modes, and
local keybindings with register-major-mode. application-major-mode.lisp
materializes those local bindings as keymaps whose parents are the registered
parent mode and then the editor's global keymap. input-dispatch.lisp refreshes
this layered map when dispatching, so changing a buffer's mode reroutes
subsequent input while preserving global fallback. A local first chord shadows
the matching parent subtree; unrelated parent bindings continue to resolve.
The workspace feature keeps an ordered workspace-manager in editor state.
Each named workspace owns an independent window tree while buffers remain in
the session-wide registry. Workspace commands synchronize the active tree
before switching or deletion; creation appends a named workspace in the domain
layer and the application layer explicitly activates it afterward. The
presentation layer includes the active workspace name in the shortcut/status
line. Session v5 persists every workspace's layout and selected window.
The multiple-cursors feature keeps a transient multiple-cursor-set in
editor state. It stores sorted buffer offsets plus one primary offset, so the
line-oriented commands can add cursors without changing buffer text. The
self-insert path edits from right to left and translates every stored offset
afterward; the layout draws non-primary cursors as reverse-video cells. The
dispatcher preserves the set only for cursor-management commands and
self-insert, clearing it when another editing command takes over. Multiple
cursors are intentionally transient and are not serialized into session v5.
src/presentation/layout.lisp composes the current state into screen regions.
src/application/startup.lisp is the composition root for argv parsing,
editor-state construction, terminal-session setup, and asynchronous resource
shutdown. src/application/event-loop.lisp owns frame rendering, resize
polling, and the event-loop boundary. src/application/input-dispatch.lisp
owns raw-octet reading, event decoding, and dispatch to minibuffer,
self-insert, or the global keymap. src/main.lisp is only the executable
trampoline.
Toolkit Foundation¶
- cl-tty-kit provides raw-mode terminal sessions, event decoding, and the double-buffered renderer used by the event loop and the terminal-renderer infrastructure files.
- cl-boundary-kit provides the filesystem object used by buffer load/save and most file-tree mutations. Its test filesystem keeps those tests independent of a real directory.
- cl-host-kit is used directly for directory-entry classification, symlink-safe recursive deletion, overwrite-safe session-file moves, and user-init environment lookup.
- cl-history-kit stores minibuffer history for find-file, save-buffer, and file-tree prompts.
- cl-prolog-kit evaluates the
with-prolog-queryrulebase used by quit-prompt resolution. Thedefine-command-specsmacro also emits an inspectable command rulebase, but runtime M-x name lookup intentionally uses its explicit registry. - cl-regex-kit supplies the regular-expression engine used by search-forward and replace-string, bounded by the editor's regex search timeout.
- cl-cli parses the root
positional argument and the
--help/--versionoptions inmain.lisp. - cl-json-kit parses and constructs the JSON objects exchanged by the LSP service.
- cl-concurrent-kit supplies the executor, bounded channel, promises, and non-blocking submit operation used by the concurrent file-tree runtime.
The main ASDF system declares these direct runtime dependencies: cl-tty-kit,
cl-host-kit, cl-history-kit, cl-prolog-kit, cl-cli, cl-regex-kit,
cl-boundary-kit, cl-json-kit, and cl-concurrent-kit. The test system adds
cl-weave and cl-date-kit; the latter is used by the concurrency tests and
benchmark timeouts. The Nix flake pins the runtime and test inputs and supplies
the development shell used by the commands below.
The development shell also supplies paredit, the structural Common Lisp
inspector and rewriter. Its paredit-lint flake check parses the source set
before packaging, keeping syntax validation separate from formatter and
behavioral test checks.
The LSP process transport intentionally uses UIOP and Common Lisp binary stream
primitives directly because it needs unsigned-byte Content-Length framing;
cl-json-kit supplies only the message representation and JSON boundary. This
keeps the process boundary explicit instead of introducing a character-stream
wrapper that cannot represent the protocol's framing bytes safely.
Test Suite¶
The loom/test ASDF system is run by the same script locally and in Nix:
The loom/test ASDF component order is declared in loom.asd, which is the
source of truth as unit and integration coverage evolves. The current suite
includes focused tests for buffers, keymaps, rendering, filesystems,
minibuffers, syntax highlighting, major modes, projects, evaluation, shell
command results, formatting, Git status and diff, terminal sessions, auto-save, the CLI,
registers, keyboard macros, prefixes, multiple cursors, and the file tree.
Integration coverage includes commands, LSP, editing and movement, major
modes, projects, layout, multiple-cursor rendering, sessions, user
initialization, shell command registration, the concurrent runtime, and editor
flows.
The suite uses cl-weave's ordinary assertions and its advanced registrations:
it-each, it-property, it-fuzz, with-continuation-values,
with-soft-assertions, and with-replaced-function. The process-level CLI/PTY
E2E suite remains separate because it launches the built executable as an
external process:
Coverage is measured separately and written outside the checkout when using the documented command:
The cl-weave runner writes coverage.data and an HTML report under the selected
coverage directory. It rejects a no-test run and an empty source-expression
selection. The report's SB-COVER expression and branch totals are recorded
separately. The report is restricted to Loom's src/ and packages/ trees, so
the package-by-feature implementation is included in the measurement. Branch
coverage does not establish full expression coverage; the generated report is
the source of truth for uncovered forms. SB-COVER is process-local,
so top-level declarations and the child-process-only loom:main path can
remain unexecuted in the report; those forms are reported rather than hidden.
nix flake check --all-systems --print-build-logs additionally runs the test
(checks.default), package (checks.build), formatter (checks.formatting),
paredit structural syntax (checks.paredit-lint), coverage (checks.coverage),
and strict MkDocs documentation (checks.docs) checks.
The concurrency benchmark compares synchronous directory listing with the runtime's bounded prefetch/drain path over eight synthetic directory paths. It prints synchronous time, asynchronous submit time, asynchronous total time, accepted task count, and the derived speedup:
The benchmark is observational and does not establish a performance target; the 2026 refactor objective is the bounded, race-safe file-tree runtime and its test coverage, not an unimplemented feature claim.