Jonquil Architecture

Part of the architecture guide. This page covers the layering, the GTK front-end, the inspector and the eval engine.

The full design record, with the reasoning and what was ruled out, is kept in docs/history/architecture/index.md.

Design

Jonquil is a port of DrClojure's idea to Jolt: a Clojure IDE with a Definitions editor, an Interactions console, and Run and Stop that actually stop.

flowchart TD
  gtk["jonquil.gtk.*: front-end<br/>window, two TextViews, toolbar, status bar,<br/>key routing, tag painting, file dialogs"]
  session["jonquil.session: per-window state<br/>file, dirty flag, eval context, font size"]
  syntax["jonquil.syntax + paint<br/>tokenize, format<br/>(pure)"]
  edit["jonquil.edit<br/>pure edit ops<br/>(pure)"]
  eval["jonquil.eval<br/>run, in, out, stop, history<br/>(threads)"]
  docs["jonquil.docs<br/>docs and completion in the eval ns<br/>(pure + runtime)"]
  connection["jonquil.nrepl.connection<br/>Attached protocol: cljd, clj and cljs share one attach lifecycle"]
  gtk -- "snapshots in, edits out" --> edit
  gtk -- "paint plans" --> syntax
  gtk -- "Run, Stop, the REPL" --> session
  gtk -- "attach dialects: satisfies? Attached" --> connection
  session --> eval
  session --> docs

Other pure namespaces, one line each:

  • jonquil.commands: run-selection text, Run Form's form, the pre-Run bracket check, find/replace steps, format operations, zoom sizes.
  • jonquil.keymap, jonquil.command-registry, jonquil.plugin*: see Commands, keys and plugins.
  • jonquil.theme: the four palettes as data, the five choices, resolve-choice (turns auto into light or dark), code-css.
  • jonquil.settings: load-settings and save-settings! (atomic); neither throws.
  • jonquil.last-session: tabs to reopen, per tree root, in sessions.edn (kept apart from the hand-edited settings.edn), and write-atomically!, the temp-file-and-rename write it shares with the replay's record.
  • jonquil.replay: the last session's record per project ({:version 1 :previous [...] :current [...] :history [...]} in replays/<folder>-<hash>.edn), turning one evaluation into entries, compacting them into files, definitions and other forms, and writing the replay script. Pure apart from load-record and save-record!.
  • jonquil.help: the pure decisions behind completion, quick doc, jump and rename.
  • jonquil.console: what Return does at the prompt, output trimming, result display, the unclosed-bracket message.
  • jonquil.file-tree: the tree's model as plain data, {:root :expanded :ignore}, flattened to rows.
  • jonquil.tabs: tab labels, finding an open file, and runnable-here? (whether a tab may run in the window's evaluator).
  • jonquil.source: where a definition outside the buffer lives and its text (a path or a jar:file:…!/entry URL), plus the source panel's history.
  • jonquil.save-path: where a save really writes, and its temp file.

The front-end and core exchange plain data. An edit sends a snapshot and gets edits back:

{:text "..." :caret 42 :sel-start 40 :sel-end 45}

{:edits [[start end "replacement"] ...]
 :caret 44
 :sel   [44 44]}

The front-end applies the edits in reverse order inside one gtk_text_buffer_begin/end_user_action, so undo sees one step.

  • Saves write a temp file in the resolved target's directory, copy the target's permissions, then rename with ATOMIC_MOVE. A failed write never leaves a half-written file, a symlink is updated rather than replaced, and the tab counts as saved only after the rename. resolve-target follows symlinks by hand because .getCanonicalPath on this Jolt returns a dangling link's own path.
  • The replay's recorder hangs off hooked-opts' completion chain in jonquil.gtk.window.run, so only evaluations that started are seen, and only the call sites that build a :record map (never MCP's). The tree root is read when the evaluation is submitted, and the recorder itself runs on the GTK thread through on-gui!, so it never races open-root!. Writes are coalesced to one per 300 ms and flushed on quit and before a re-root. The record is written owner-only: the temp file is created rw------- and filled with Files/write, since on Linux Jolt's spit replaces the file and drops its mode.
  • Reopening tabs waits for the window's first painted frame, then opens every saved path in one main-loop turn through the same open-file! as Open. A path that fails is named on the console and the rest still open. A quit before the reopen saves nothing.

GTK is not everywhere

The GTK layer is required only from jonquil.main and jonquil.mcp.window (which takes gtk.callbacks, gtk.editor and gtk.ffi, never gtk.window). The pure core runs without GTK, so it can be tested alone and reused by another front-end.

Nothing enforces this automatically. Jolt's build scanner follows require forms statically, so a jonquil.gtk namespace required from anywhere else would compile GTK into every binary built from that namespace, headless tools included.

The GTK layer

One job per jonquil.gtk.* namespace:

  • ffi: raw GTK4/GLib/GObject/pango bindings and the GtkTextIter and mark helpers.
  • callbacks: the callable macro (retained, :collect-safe foreign-callables), every callback, and on-gui!. A throw never reaches GTK, where it would end the process: it is reported on stderr and the callback answers a safe value.
  • editor: the Definitions editor. handle-key! yields during IME preedit, then dispatches through command-registry/editor-answer. Edit ops go through apply-op! so each is one user action. Repaints diff against what the tags show now and apply only the changed range, a chunk per main-loop turn.
  • find-bar, console-view, window: the find bar; the console (below); and start!, the application's "open" handler and activate!, which threads one map of the window's bindings through the builders below. The window-level chords (through window-answer) and the context menus live in jonquil.gtk.window.keys. The file dialogs and opening and saving files live in jonquil.gtk.window.files. Quick doc, jump and the source panel, rename and completion live in jonquil.gtk.window.help. The tabs (switching, opening a file into one, closing one, and wire-tab!, which connects a tab to the window) live in jonquil.gtk.window.tabs. wire-tab! runs as named steps, one per thing it connects. The file tree's New File, New Folder, Rename and Move to Trash live in jonquil.gtk.window.tree-ops, and reopening the last session's tabs at startup in jonquil.gtk.window.reopen. The status bar is built and placed in jonquil.gtk.window.status-bar, and the console with its completion in jonquil.gtk.window.console. The toolbar, the window's frame widgets, their placement and the toolbar's and keys' connects live in jonquil.gtk.window.layout, and the window's command fns, the parts' context, the console's context menu and the window's key args in jonquil.gtk.window.context. The window's first bindings, the startup (the first tab, the tree, the runtime, the reopen and the plugins' start) and the quit connect live in jonquil.gtk.window.startup, and the ui map the self-test and the MCP server read in jonquil.gtk.window.ui.
  • tab: one tab's widgets. dispose-popovers! must run before the window removes the page, or GTK spins in a warning loop.
  • popover, help-popover, palette, completion: caret-anchored GtkPopovers on a shared base.
  • file-tree-pane: a GtkListBox rebuilt from the model on every change. A click decides its target at once and rebuilds on the next main-loop turn, because a rebuild removes the row whose signal is running.
  • source-pane: the read-only panel for jar entries and files outside the project (a project file opens in its tab; Cmd/Ctrl+[ walks the jump stack). It shares the right-hand slot with the inspector. It paints with paint-async!, which keeps an 8,000-line file off the GTK thread.
  • inspector-pane: the inspector widget.

Tree file operations (jonquil.file-ops) return {:error ...} instead of throwing, rename never overwrites, and jonquil.gtk.trash uses g_file_trash, so nothing is deleted permanently.

on-gui! and the worker-to-GTK-thread path

The eval engine's callbacks run on the worker thread, and GTK may only be touched from its main thread. So a callback enqueues and returns: never call GTK directly, never block. console-view/append! queues an output chunk and callbacks/on-gui! queues any thunk. Both are safe from any thread and coalesce, so a flood of output becomes one drain per tick.

The console buffer layout

flowchart LR
  hist["output history<br/>(read-only)"] --- prompt["#quot;user=> #quot;<br/>(read-only)"] --- input["input ..."]
  m0(["0"]) -.-> hist
  mout(["out-end mark<br/>right gravity"]) -.-> prompt
  min(["input-start mark<br/>left gravity"]) -.-> input
  • History and the prompt carry a read-only tag, which stops typing, pasting or Backspace from reaching old output.
  • out-end has right gravity, so append! inserts at it and new output lands before the prompt. input-start has left gravity, so output inserted earlier pushes it forward. Committing an entry moves both marks explicitly.
  • set-prompt! rewrites the prompt in place (keeping input and caret) with the evaluator's prompt-ns, after every evaluation, on an evaluator switch, and when an attach connection comes or goes.
  • The console reads busy and waiting state live from the engine rather than mirroring it, because a mirror could lose a race against a fast eval's completion and strand the console busy.

jonquil.session and the O1 reset rule

InProcess holds one window's in-process state: the eval context, the mode (:reset or :accumulate), the prompt namespace, and a map from owner to the namespaces it created. It is the default Evaluator. Run and the REPL both go through evaluate! into jonquil.eval/eval-async.

  • Project roots: jonquil.project-roots/use-project! appends the project's absolute deps.edn :paths after Jolt's own roots, so a project can't shadow the IDE's namespaces in this shared process. Absolute roots make :file absolute, which jump to definition opens.
  • Dependencies resolve on the eval worker (:before-eval), so a first fetch never holds the GTK thread. A failure leaves the roots at :paths with a console line.
  • When the file on disk matches the buffer, a Run evaluates it as that file: jolt.host/eval-string takes the path as its source (:source-path, on a Jolt with jolt-lang/jolt#1476), so the vars get the file and line from the reader and *file* is the path. On an older Jolt the evaluated text has no file, so mark-run-file! (in :before-idle, before any REPL entry can interleave) gives the file to vars the Run redefined instead. Either way F12 works from another tab.
  • Reset: in :reset mode run-definitions! removes the namespaces its owner created, then the REPL's, then user, and evaluates into a fresh user. Loaded libraries are never owned, so they stay.
  • An owner is the :owner option: a tab's id for Run, Run Selection, Run Form and Inspect Form, and :repl for console entries. A closing tab hands its set to :repl (release-owner!).
  • Unchecking Reset on Run switches to :accumulate, and Run stops clearing.
  • The out-of-process nREPL runtime applies the same rule over the wire, snapshotting the child's namespaces around each evaluation and restoring *1, *2 and *3, and it refers doc, find-doc, apropos, dir, pprint and pp into the session namespace. So Reset on Run, the history vars and the helpers behave alike under either evaluator.

Self-test harnesses

jolt gtk:selftest drives the real GTK front end from inside the process (ten windows, against fakes where a toolchain would be needed), and jolt docs:shots captures docs/images/* with one fresh process per scene. Both poll against a deadline instead of sleeping and hand blocking subprocess calls to a future. The smoke tasks (smoke:binary, smoke:cljd, smoke:cljs) drive a built ./jonquil with cgevent. All use temp settings (:settings-path or XDG_CONFIG_HOME), never the user's. See Development.

Themes

Applying a theme rebuilds nothing: each component's apply-theme! retints its tags in place, so text and undo survive, and the window reloads its one CSS provider and sets gtk-interface-color-scheme. theme-name is the palette on screen; theme-choice is the saved pick (including auto). Every change goes through apply-choice!. For auto, jonquil.gtk.appearance/system-dark? asks AppKit for the effective appearance (GTK reports none on macOS) and returns nil off macOS or on failure, which resolves to light. A 1-second timeout polls only while the choice is auto and dies on its next tick otherwise.

Help: completion, quick doc, jump and rename

jonquil.help never touches GTK or the running program; it works on the editor snapshot shape. target (the selection if exactly one symbol, else the symbol at the caret) is shared by quick doc, jump and rename.

  • Jump to definition: help/jump-target does a scope search first (enclosing let-family, for/doseq, fn/defn/defmacro params and letfn, with destructuring), because upstream's find-definition gets shadowed locals wrong. Only then does it fall back to top-level def* forms, then to an external var's docs.
  • jonquil.docs resolves in the namespace the program runs in, falling back to user when Reset on Run removed it. Lookups catch every exception and return nil or []. The symbol-doc memo is cleared after every completed evaluation.
  • Popovers are parented on the view's GtkScrolledWindow, never the GtkTextView, which hangs on window close. The window registers every popover and unparents them on real quit, hiding an open one first.
  • Context menus come from command-registry/menu-items, so each item runs its chord's command. Every action callback is wrapped in try/catch, since a throw from a GSimpleAction kills the process.
  • editor/dispatch-key! and the window key handler catch throws, report them in the status bar and still consume the key.
  • Server help (ev/ServerHelp: attach evaluators, out-of-process Jolt, jank) never blocks the GTK thread. ev/ask-help! waits ev/help-grace-ms (25 ms) and a late answer arrives through on-gui!. It applies only if it is the newest request (:gen), the evaluator is unchanged and the text and caret haven't moved. Jump checks the buffer first, so a local definition never waits.

Window parts

A window feature that owns its own state, commands and startup work is a part, so it doesn't have to grow jonquil.gtk.window. A part has two namespaces:

  • jonquil.parts.<name> is GTK-free and holds spec: {:id :<name> :commands {cmd-id {:doc "..." :scope :window :call [ctx-key & args]}} :panels [{:id :<panel> :label "..."}] :bindings {:window [binding ...] :editor [binding ...]}}. A command names its context function as data (:call), never as a :run fn, so a spec needs nothing from jonquil.command-registry, which requires it. :panels and :bindings are optional. A panel's label is what Switch to panel shows, and a binding has the shape of an entry in keymap.edn.
  • jonquil.gtk.parts.<name> holds part, an install fn tagged ^{:part/id :<name>}. (part wctx) returns {:fns :ui :hooks :exports}.

jonquil.parts/specs and jonquil.gtk.parts/installs list the parts in install order, and a test checks that both name the same ones. A part may read another part's :exports only if that part comes earlier.

command_registry's public tables are the core commands plus every spec's commands. An id claimed twice, in any scope, throws when the registry loads and names the id and both owners.

wctx gives a part the core's bindings by name: :win :status-label :cv :ev* :sel* :tabs :active-tab :settings-path* :tab-ctx* :tab-by-id :select-tab! :new-tab! :set-status! :right :tree-pane :tree-paned :paned :files-btn :panel-focus-fn :showing-panel :show-right-panel! :hide-right-panel! :alert-pending? :show-alert! :make-eval-opts :refuse-foreign!. :set-status! takes one argument, the text. A part never requires the window namespace. It reads :tab-ctx* when it runs, not when it installs, since the tab map doesn't exist yet at install time.

The contribution keys:

  • :fns are context functions the spec's :call entries name (:replay-open-fn).
  • :ui are entries in the window's ui map, which the self-tests and MCP read.
  • :exports are values for parts installed later.
  • :panels is {panel-id {:root :focus! :open!}} for a right-hand panel, and every id must be declared in the part's own spec :panels (a panel the spec doesn't declare is refused). :root is the panel's widget, which the window appends to the right-hand slot, hidden, and treats like the core panels when it shows and hides them. :focus! is a function of no arguments that puts the keyboard focus inside the panel once it is showing. :open! is a function of no arguments that opens it, called when the panel isn't showing (a panel already showing is focused instead). The spec's panel/<id> command reaches both through [:panel-focus-fn :<id>].
  • :hooks maps a hook kind to a function. The kinds that exist are :on-start (once, when startup finishes), :on-reroot (Open Folder, before the new root opens, called with {:path p}) and :on-quit (a real quit only). Three tab kinds run once per tab, called with [ctx tab] in part order: :on-tab-removed, :on-tab-wired and :on-switch. See Tab hooks below.

jonquil.parts/install merges strictly. A duplicate :fns, :ui or :exports key throws and names both owners, and a key already in the core's tables is refused the same way. A part whose install throws contributes nothing, and the status bar says ; part <id>: <message>. Hooks run in part order, and one that throws is reported the same way while the rest still run.

Spec bindings are appended after every binding in keymap.edn (km/shipped does it), so a shipped key always wins over a spec key. A broad shipped binding can also shadow a spec one: the Cmd+digit tab bindings ignore Alt, and spec bindings always come after the shipped ones, so a spec binding on Cmd+Alt+digit is always shadowed by them. unreachable-bindings finds these, and a test runs it over the merged keymap, so a shadowed spec binding fails jolt test. A binding that uses Alt needs :physical? true to fire on macOS, because Option changes the key's value (Option+P types another character), and that check can't see it. The names :review and :history are core panel ids with no palette label, so a spec can't take them, and a panel id two specs share throws when the registry loads.

jonquil.parts.extra exists only so the window self-test can add a probe part (dev/gtk_selftest_window/probe_part.clj) to specs and installs before they load. Production never touches it, and it stays empty. The probe is the proof that a feature adds a command, a panel, a key, a ui entry and a quit hook without a change to window.clj or command_registry.clj.

The parts install in this order: Messages, disk polling, layouts, worlds, Time Form, Processes, Trace, live results, Locations, the debugger, the command palette, the file tree, plugins, replay. Messages goes first because it registers the status log, so a status said while the other parts install still reaches Messages. It answers the log function as :log-entry!, and disk polling reads that export, so it refuses to install without it. Messages holds the pane and the log, fills in the unread count (the core still builds that label and places it in the status bar) and handles its click. Disk polling has no commands and holds the 2 s timer that makes open tabs follow their files. Trace holds the Trace panel, its handlers and the refresh after each evaluation. Live results holds the four result verbs, the right-click caret move and the click-to-inspect gesture. Locations holds the Locations list and Compare with disk or HEAD, with their state. The debugger holds the Frames panel, the break and the failure restarts, and the gutter's line counts. It retries a failure through the core service :rerun-failure! in the window ctx, because the evaluation machinery stays in the window core, in jonquil.gtk.window.run. The command palette holds the palette's entries, the console's ! line and the describe-keys commands, with the pending flag and the recent keys that the window's key handler reads from its context. The file tree holds Show/Hide Files, the tree's saved width and visibility, its Escape keys' context, the highlight of the active tab's file and the width save at quit. The tree's pane is made in jonquil.gtk.window.tree-ops and its Files toggle and outer paned in jonquil.gtk.window.layout; its root loading, Open Folder and the re-root are in jonquil.gtk.window.startup. The core calls the part's :tree-start! function at startup, and a window whose file-tree part failed shows no tree: the files button stays but nothing is connected to it. The plugins part holds the behaviour of the review, plugin list and history panels, the three :plugins/* commands (review, list and reload), the plugin ui and MCP entries and the plugin effects. The three panes are made in jonquil.gtk.window.right-slot, and the pending label is made in jonquil.gtk.window.status-bar's build, and the process-global state (the agent and the console and reviews callbacks) lives in jonquil.gtk.window.plugin-agent. The save hook, on-saved, lives in jonquil.gtk.window.files, and the part sets it. The core calls the part's :plugins-start! function at the end of jonquil.gtk.window.startup's startup!, which points the console, the reviews callback, the effects and the save hook at this window and loads the user's plugins, so the window that started last owns them. A window whose plugins part failed loads no plugins. Layouts (LM8) holds the saved-layout commands and their timer. Worlds (LM7) holds Save World and Restore World with the helpers they share. Time Form holds the timed-form command and its confirmation, and puts :time-ask* and :time-confirmed? in ui for the self-test. Processes (LM4) holds the Processes panel, its two palette commands and its refresh timer, and is the first part to contribute a panel. Its panel key stays in keymap.edn. Replay goes last because its re-root hook loads the new root's record and its quit hook flushes it, and both have to run after every other part's hooks.

The window core

The code that stays in jonquil.gtk.window is split into jonquil.gtk.window.* namespaces. Twenty-two exist: jonquil.gtk.window.status (the status line and the Messages log it feeds), jonquil.gtk.window.alerts (the alert dialogs and the one pending-alert guard), jonquil.gtk.window.files (the file dialogs, opening and saving, and the window title), jonquil.gtk.window.plugin-agent (the plugin agent and its hooks), jonquil.gtk.window.right-slot (the right-hand panel slot), jonquil.gtk.window.theme (theme and zoom), jonquil.gtk.window.runtime (picking a file's dialect and runtime, and starting and switching the evaluator), jonquil.gtk.window.session (saving the open tabs for the next launch), jonquil.gtk.window.quit (closing the window, Save All and the real quit), jonquil.gtk.window.run (Run and Stop, a console entry and its history, Run Selection, Run Form and Run Changed, and the MCP server's notifying Run and eval), jonquil.gtk.window.keys (the window's key handler and the per-key context, the focus checks, the context menus, F6 and the console clears), jonquil.gtk.window.inspector (the inspector panel, Inspect Last Result and Inspect Form), jonquil.gtk.window.help (the popover registry, quick doc, jump and the source panel, rename and completion), jonquil.gtk.window.tabs (the tabs: switching, opening a file into one, closing one, and wiring a tab's editor to the window, which wire-tab! does as named steps), jonquil.gtk.window.reopen (reopening the last session's tabs once the window is up), jonquil.gtk.window.tree-ops (the file tree's New File, New Folder, Rename and Move to Trash), jonquil.gtk.window.status-bar (the status bar's labels, the key hints toggle, the Parinfer mode and label, and the row they sit in), jonquil.gtk.window.console (the console and its completion), jonquil.gtk.window.context (the window's command fns, the parts' install, the console's context menu and the window's key args), jonquil.gtk.window.layout (the toolbar and the window's frame, where each widget goes, and the toolbar's and key controller's connects), jonquil.gtk.window.startup (the window's first bindings, the startup and the quit connect) and jonquil.gtk.window.ui (the ui map, by topic). right-slot, theme, run, inspector, help, tabs, tree-ops, status-bar, console, context, layout, startup and quit also hold builders: functions that take a map of the window bindings they read and return it with theirs added, which activate! calls where those bindings used to be. quit's connect! holds two of activate!'s statements, the close-request connect and the quit action, status-bar's place! the status bar's placement, and layout's place! and connect! the rest of the placement (as four named steps) and the toolbar's and key controller's connects, theme's and help's connect! the theme and help wiring, and startup's startup! the startup, as two builders and six steps, each in its old place, as the builders hold bindings. They are internal to the window. None of them requires jonquil.gtk.window, and each loads after the ones it calls, which window_layers_test checks. activate! threads one map through the builders in order and hands it to each of these; jonquil.gtk.window keeps start!, the "open" handler and activate!.

Tab hooks

A tab hook is called as (f ctx tab) for every tab, the first one included, and jonquil.parts/run-tab-hooks! runs the parts' hooks of a kind in part order. A throwing hook is reported on the status line and the work around it goes on.

  • :on-tab-removed runs as the first step of remove-tab!, while the tab is still in the tab list and its buffer is alive, so a part can delete its marks from the buffer.
  • :on-tab-wired runs as the first statement of wire-tab!. A hook's buffer "changed" handler therefore connects after the core editor's and the find bar's, and before the changed-form gutter's.
  • :on-switch runs in on-switch!, after the open popovers close and before the title, Ln/Col label and focus follow the new tab. It runs when a tab is shown, including a neighbour that becomes active when a tab closes. The file tree uses it to highlight the active tab's file.

Replay (the last session's record, LM7) is the worked example: jonquil.parts.replay holds the two palette commands, and jonquil.gtk.parts.replay holds the recorder, the record's state, the offer at open, and the three hooks that load the record at start and re-root and flush it at quit.

The inspector: Inspector, jonquil.inspect and jonquil.inspect.view

jonquil.evaluator has a second, optional protocol, Inspector: inspect! (an op, its args, and an on-view callback called exactly once, off the GTK thread) and inspectable?. The window checks (satisfies? ev/Inspector e) as it does Attached, so a ClojureDart evaluator gets the unavailable sentence.

flowchart LR
  ev["jonquil.evaluator<br/>Inspector protocol<br/>inspect!, inspectable?"]
  view["jonquil.inspect.view<br/>lifted from clojure-cli.repl<br/>(pure)"]
  inspect["jonquil.inspect<br/>pane state, key to op<br/>(pure)"]
  pane["jonquil.gtk.inspector-pane<br/>the panel widget"]
  session["jonquil.session<br/>InProcess"]
  jvm["jonquil.nrepl.jvm<br/>Jvm"]
  nrepl["jonquil.nrepl.evaluator<br/>NRepl"]
  mw["jonquil.nrepl.inspect-middleware<br/>in the Jolt child"]
  fake["dev/fake_jvm_nrepl.clj<br/>test double"]
  win["jonquil.gtk.window"]
  session -- "implements" --> ev
  jvm -- "implements" --> ev
  nrepl -- "implements" --> ev
  nrepl -- "inspect-* ops" --> mw
  mw -- "uses" --> view
  session -- "uses" --> view
  fake -- "uses" --> view
  win -- "uses" --> inspect
  win -- "uses" --> pane
  • inspect.view is lifted from clojure-cli.repl (UPSTREAM.md has the provenance). Every function takes a view map and returns one; run-op, Jonquil's own addition, wraps any of them and adds a :note on a throw. Nothing owns a global atom.
  • InProcess and the test double call run-op directly. Jvm and NRepl send wire ops (jonquil.inspect/request) and parse the :view string back, so the view code lives on the server.
  • jonquil.inspect is the shared pane state: the view, the selected row, a :pending marker, and key-step, which makes a keypress either a local move or a request. inspector-pane only renders. The window owns the round trip, the one-request-at-a-time guard, and folds replies back through on-reply.

Middleware in the out-of-process Jolt child

Four nREPL middleware namespaces run in the child. Jonquil embeds their text at compile time (the installed binary has no source tree) and writes them to a hash-named folder under $XDG_CACHE_HOME/jonquil/nrepl-middleware (or ~/.cache), which jonquil.nrepl.child adds as a :local/root dep. If the child can't load them, it restarts without them and the start lines name jolt's error.

  • inspect-middleware (last): middleware ops rather than evals, because jolt.nrepl/evaluate shifts *1 *2 *3 after every eval. Ops are answered one at a time on their own future, and view state is one atom per child, shared by its clients.
  • stdin-middleware (first): binds *in* around clone so every eval in the session reads Jonquil's stdin reader, which sends need-input and blocks on a queue the stdin op feeds. It also wakes a blocked read on interrupt. A plain server gets the inspector alone.
  • break-middleware (first, beside stdin's): Break on error. It binds jolt.nrepl/*eval-wrapper* to a per-session wrapper around clone, so the session's worker runs each evaluation's thunk through it inside evaluate's own try, where the break sees a throw first; only an eval the client marks with jonquil-break can pause, and the pause goes out on that eval's reply. A Jolt without the hook can't pause, and jonquil-break-check says so. inspect-middleware also answers the Frames panel's ops (jonquil-frames, jonquil-frame-eval, inspect-local, jonquil-debug-mode) over the child's *e.
  • info-middleware (last): the jonquil-info op reads a var's metadata (file and line), which jolt-lang/nrepl's lookup lacks, so jump to definition can leave the buffer. A bare file name is never resolved against the directory. The "Jump to definition stays in this buffer" limit shows only when a live child doesn't offer the op.

The debugger

jonquil.debugger holds the Frames panel's data and has no GTK, so the Jolt child loads it too. frames-data reads a throwable's frames through jolt.debug/frames, cut where the evaluator's own frames start, and says so when none is the user's; eval-in-frame compiles a form as a fn of the frame's locals in the frame's namespace. Its break (break-wrapper) runs an evaluation under jolt.debug/call-with-break-handler: on a throw, the handler publishes the pause and serves a queue on the throwing thread (evaluate in a frame, Continue, Stop) until it ends. The in-process session passes it to the engine as :wrap-eval; in the child, break-middleware does. jonquil.restarts decides which restarts a failure offers, and the window records failures from hooked-opts. A JVM server gets frames from jonquil.nrepl.jvm-frames, a template evaluated there that prints the stack trace's Clojure frames.

The eval engine

Each window has an eval context. Run and the REPL both use eval-async with an :ns-name option; the engine doesn't distinguish them. Results carry :printed: the value under pr-str with *print-length* 1000, printed inside the interruptible region so a lazy value can be stopped. The GUI shows :printed and never re-prints :value.

  • Stop uses jolt.host/run-interruptible with a token. interrupt! aborts busy loops at back-edges, and the engine tracks worker exit before returning :idle.
  • A program blocked on read-line gets an EOF sentinel on the input queue. Once Stop is pressed the result is :interrupted whatever the program then does.
  • Callbacks run on the worker, and on-status-change runs under the context's lock. A callback must enqueue and return, never wait on a thread that may call eval-async or stop-eval! on the same context, because that deadlocks.

This page is docs/guide/architecture/index.md in the Jonquil repository.

Enter to open · Esc to close