Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 67 additions & 4 deletions legmacs/buffers.lg
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,12 @@
;; realized result through a (fn [state outcome] -> state). Goroutines
;; never touch buffers. The loop drains *before* painting so a just-finished
;; job is visible without a keystroke, then only blocks on read-chord when
;; both queues are empty; while jobs are in flight it peeks
;; term/key-pending? and sleeps a tick. C-g discards in-flight jobs
;; (results dropped, processes not killed).
;; both queues are empty *and* no user go/future is still live; while
;; jobs (or those spawns) are in flight it peeks term/key-pending? and
;; sleeps a tick. C-g discards in-flight jobs (results dropped, processes
;; not killed). A scratch `(go ...)`/`(future ...)` is not a job --
;; install-spawn-tracking! wraps the natives so the loop still polls
;; instead of parking in read-chord until the user types.
;;
;; Windows (split panes, see legmacs.windows) ride the exact same
;; mechanism. The workspace holds one window tree; each window shows a
Expand Down Expand Up @@ -480,18 +483,78 @@
state (assoc state :async-jobs (as-jobs (:async-jobs state)))]
(put-current workspace state))))))))

;; --- user go/future tracking ---------------------------------------------
;; spawn-task is the blessed async path, but C-x C-e of (go ...) / (future ...)
;; from *scratch* is the one people actually type. Those natives return at
;; once; without a job on :async-jobs the loop would then park in
;; read-chord, so a background println fights the TTY and a live modeline
;; never repaints. Counting live spawns lets loop-wait keep polling.

(def ^:private live-spawns (atom 0))
(def ^:private spawn-tracking-installed? (atom false))

(defn live-spawns?
"True when a go*/future* started after install-spawn-tracking! is still
running. Independent of :async-jobs -- a scratch (go (loop ...)) never
becomes a job."
[]
(pos? @live-spawns))

(defn- tracked-body
"Thunk wrapper: run `thunk`, then drop the live-spawn count. Reifies the
outcome so a throw still decrements -- let-go's finally cannot both
clean up and propagate (see CLAUDE.md)."
[thunk]
(fn []
(let [outcome (try {:ok (thunk)} (catch e {:err e}))]
(swap! live-spawns dec)
(if (contains? outcome :err)
(throw (:err outcome))
(:ok outcome)))))

(defn- make-tracked-spawn
"A (fn [thunk] ...) that incs the live count and calls `orig` (the real
go*/future*) with a wrapper that decs when the body returns. Own named
fn so install-spawn-tracking!'s `when-not` contains calls, not capturing
fn literals (let-go AOT hoist, nooga/let-go#766)."
[orig]
(fn [thunk]
(swap! live-spawns inc)
(orig (tracked-body thunk))))

(defn- replace-spawn-var!
"Point `v` at a tracking wrapper around `orig`. constantly + named
make-tracked-spawn, not an fn form in a conditional."
[v orig]
(alter-var-root v (constantly (make-tracked-spawn orig))))

(defn install-spawn-tracking!
"Wrap go* and future* so loop-wait can poll while they run. Idempotent.
async/go* is a copy of the native taken at boot, not the core var, so
it is wrapped separately with the same original."
[]
(when-not @spawn-tracking-installed?
(replace-spawn-var! #'future* future*)
(let [orig-go* go*]
(replace-spawn-var! #'go* orig-go*)
(when-let [av (find-var 'async/go*)]
(replace-spawn-var! av orig-go*)))
(reset! spawn-tracking-installed? true))
true)

(defn loop-wait
"What the editor should do after painting `workspace`. Drain realized
jobs *before* asking this -- otherwise a just-finished job stays on
screen until the next keystroke (paint, drain, then block on read-chord).

:quit -- leave the loop
:pending-task -- run the main-thread queue head (already painted)
:poll -- jobs still in flight; peek stdin or sleep
:poll -- jobs or user go/future still in flight; peek stdin or sleep
:read-key -- idle; block on the next chord"
[workspace]
(cond
(:quit? workspace) :quit
(:pending-task workspace) :pending-task
(jobs-running? workspace) :poll
(live-spawns?) :poll
:else :read-key))
123 changes: 96 additions & 27 deletions legmacs/modes/letgo.lg
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@
;; free. `load-string`ing a buffer that opens with `(ns foo)` -- every real
;; .lg file does -- relocates the current namespace permanently, and one
;; C-c C-e would otherwise cost you every alias above for the rest of the
;; session -- the next C-x C-e fails with "Can't resolve ... in this
;; context". eval-buffer puts it back; C-x C-e deliberately doesn't, so an
;; explicit `(in-ns 'foo)` still moves you.
;; session. eval-buffer always restores the editor ns after the load.
;; C-x C-e / C-j then eval in that buffer's `(ns ...)` (CIDER-style) so
;; `@a` after a C-c C-e still sees `foo/a`, and restore afterwards so the
;; editor doesn't move. No ns form: eval in the current *ns*, so an
;; explicit `(in-ns 'foo)` still sticks. The *repl* buffer is the same.

(ns legmacs.modes.letgo
(:require [string]
Expand Down Expand Up @@ -285,20 +287,109 @@
(defn- format-result [v]
(try (pr-str v) (catch e (str "#<unprintable " (ex-message e) ">"))))

(defn- capturing-out
"Run `thunk` with *out* and *err* rebound to a capture buffer.

The editor paints the TTY through *out* (term/write). A scratch
(go (println ...)) inherits the caller's *out* via future*/go*
snapshotting the spawning context -- without this, those bytes land
on the alternate screen and the next frame never comes until a key.
Spawned goroutines keep the capture handle after this returns; the
editor's own term/write still uses the root stdout binding."
[thunk]
(let [result (atom nil)]
(with-out-str
(binding [*err* *out*]
(reset! result (thunk))))
@result))

(defn eval-form
"eval `form` with *out*/*err* captured (see capturing-out)."
[form]
(capturing-out (fn [] (eval form))))

(defn load-in-editor
"load-string `s` with *out*/*err* captured (see capturing-out). The
*repl* buffer uses this so a typed (go (println ...)) cannot clobber
the frame either."
[s]
(capturing-out (fn [] (load-string s))))

(defn- last-sexp-before-point [state]
(let [text (buf/text-before-cursor state)]
(try
(let [forms (read-all-string text)]
(if (seq forms) [:ok (last forms)] [:none nil]))
(catch e [:error (ex-message e)]))))

(defn preserving-ns
"Run `thunk`, then put the current namespace back where it was.

Loading a file that opens with an `(ns ...)` form *switches the running
namespace and never switches back* -- and since legmacs evaluates in its
own live namespace, that would leave every alias the editor set up
(`buf/`, `km/`, `dispatch/`, `render/`) unresolvable for the rest of the
session: one C-c C-e on any ordinary .lg file and C-x C-e stops
working. Emacs's load-file doesn't relocate you either.

C-x C-e / C-j wrap this around an (in-ns buffer-ns) when the buffer
has an ns form, so a def interned by eval-buffer is visible without
leaving the editor stuck in that ns. No ns form: they eval in the
current *ns*, and an explicit `(in-ns 'foo)` still moves you.

Written the long way round because let-go's `finally` can't propagate an
exception -- see CLAUDE.md."
[thunk]
(let [saved (ns-name *ns*)
outcome (try {:ok (thunk)} (catch e {:err e}))]
(in-ns saved)
(if (contains? outcome :err) (throw (:err outcome)) (:ok outcome))))

(defn- ns-form-name
"The namespace symbol in a `(ns ...)` form, or nil."
[form]
(when (and (seq? form) (= 'ns (first form)))
(let [n (second form)]
(cond
(symbol? n) n
(and (seq? n) (= 'with-meta (first n))) (second n)
:else nil))))

(defn- buffer-ns-name
"The name in the first top-level `(ns ...)` form, or nil if the buffer
has none (or can't be read)."
[state]
(try
(loop [forms (seq (read-all-string (buf/buffer-text state)))]
(when forms
(or (ns-form-name (first forms))
(recur (next forms)))))
(catch e
(try (ns-form-name (read-string (buf/buffer-text state)))
(catch e2 nil)))))

(defn- eval-form-in-ns
"eval `form` after (in-ns ns-sym), then restore the editor ns.
Own named fn so eval-form-in-state's if-let contains a call, not a
capturing fn literal (let-go AOT hoist, nooga/let-go#766)."
[ns-sym form]
(preserving-ns (fn [] (in-ns ns-sym) (eval-form form))))

(defn- eval-form-in-state
"eval `form` in the buffer's ns when it has an ns form, then restore
the editor ns. Without an ns form, eval in whatever *ns* is now."
[state form]
(if-let [ns-sym (buffer-ns-name state)]
(eval-form-in-ns ns-sym form)
(eval-form form)))

(defn- run-eval [state f]
(let [[status form] (last-sexp-before-point state)]
(case status
:none (assoc state :message "No preceding sexp")
:error (assoc state :message (str "Read error: " form))
(try
(f state form (eval form))
(f state form (eval-form-in-state state form))
(catch e (assoc state :message (str "Eval error: " (ex-message e))))))))

(defcommand eval-last-sexp
Expand All @@ -316,33 +407,11 @@
(run-eval state (fn [state _form result]
(buf/insert-string state (str "\n" (format-result result))))))

(defn preserving-ns
"Run `thunk`, then put the current namespace back where it was.

Loading a file that opens with an `(ns ...)` form *switches the running
namespace and never switches back* -- and since legmacs evaluates in its
own live namespace, that would leave every alias the editor set up
(`buf/`, `km/`, `dispatch/`, `render/`) unresolvable for the rest of the
session: one C-c C-e on any ordinary .lg file and C-x C-e stops
working. Emacs's load-file doesn't relocate you either.

Deliberately *not* applied to C-x C-e / C-j: `(in-ns 'foo)` typed and
evaluated on purpose should still move you, and so should the *repl*
buffer, where relocating is the point.

Written the long way round because let-go's `finally` can't propagate an
exception -- see CLAUDE.md."
[thunk]
(let [saved (ns-name *ns*)
outcome (try {:ok (thunk)} (catch e {:err e}))]
(in-ns saved)
(if (contains? outcome :err) (throw (:err outcome)) (:ok outcome))))

(defcommand eval-buffer
"Evaluate the entire buffer, top to bottom, as one `load-string`."
[state]
(try
(let [result (preserving-ns (fn [] (load-string (buf/buffer-text state))))]
(let [result (preserving-ns (fn [] (load-in-editor (buf/buffer-text state))))]
(assoc state :message (str "=> " (format-result result))))
(catch e (assoc state :message (str "Eval error: " (ex-message e))))))

Expand Down
2 changes: 1 addition & 1 deletion legmacs/modes/repl.lg
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@
:goal-col nil)))

(defn- repl-eval-text [text]
(try [:ok (format-result (load-string text))]
(try [:ok (format-result (letgo/load-in-editor text))]
(catch e [:error (ex-message e)])))

(defcommand repl-return
Expand Down
22 changes: 21 additions & 1 deletion legmacs/vibe.lg
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
;;
;; (vibe "a fn that reverses a vector") ; an ordinary function. Returns
;; ; the generated code as a string,
;; ; so C-x C-e works on it too.
;; ; so C-x C-e works on it too --
;; ; including after `(ns foo)`,
;; ; because vibe is interned into
;; ; let-go.core (a live baseline
;; ; refer every ns already has).
;;
;; C-c C-v ; vibe-replace: find the (vibe ...)
;; ; form around point, run it, and
Expand Down Expand Up @@ -972,6 +976,22 @@
(strip-fences (request-completion cfg provider
(user-message request *vibe-context*)))))))

(defn- export-vibe-unqualified!
"Intern `vibe` into let-go.core so it resolves in every namespace.

`(ns weather)` leaves legmacs.main's `:refer [vibe]` behind, and C-c C-v
then `eval`s `(vibe ...)` in that new ns -- \"Can't resolve vibe\".
let-go.core is a live baseline refer (Lookup walks it at compile time),
so one intern here covers namespaces that already exist *and* ones a
later `(ns ...)` creates. Referring all of legmacs.vibe would leak
vibe-replace, marker, config, and the rest."
[]
(let [exported (intern 'let-go.core 'vibe vibe)]
(alter-meta! exported merge (or (meta #'vibe) {}))
exported))

(export-vibe-unqualified!)

;; --- finding the (vibe ...) form ----------------------------------------

(defn vibe-head-at?
Expand Down
14 changes: 9 additions & 5 deletions main.lg
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
[legmacs.modes.help]
;; :refer, not just :as -- (vibe "...") typed into a buffer is
;; read and evaluated *in this namespace* (see legmacs.modes.letgo's
;; docstring), so the name has to resolve here unqualified.
;; docstring). vibe.lg also intern's `vibe` into let-go.core so a
;; later `(ns foo)` still resolves it unqualified (C-c C-v / C-x C-e).
[legmacs.vibe :refer [vibe]]
[legmacs.dispatch :as dispatch]
[legmacs.render :as render]
Expand Down Expand Up @@ -124,10 +125,12 @@
is painted, so a splice shows up without waiting for a keystroke. A
:pending-task still takes the place of reading a key for one iteration
-- main-thread work that asked to paint first. While :async-jobs are
in flight the loop peeks term/key-pending? instead of parking in
read-chord, so typing continues; a short sleep avoids a busy-spin
until the next completion or keystroke. Idle (no jobs, no pending-task)
is a blocking read-chord, same as before."
in flight -- or a scratch go/future is still live -- the loop peeks
term/key-pending? instead of parking in read-chord, so typing continues
and the frame keeps being painted (a background println cannot sit on
the TTY until the next key). A short sleep avoids a busy-spin until
the next completion or keystroke. Idle (no jobs, no live spawns, no
pending-task) is a blocking read-chord."
[workspace]
(loop [workspace workspace]
(let [workspace (bufs/drain-jobs workspace dispatch/run-after-commands)
Expand Down Expand Up @@ -160,6 +163,7 @@

(defn main []
(bindings/install!)
(bufs/install-spawn-tracking!)
(load-user-config!)
(let [workspace (build-workspace *command-line-args*)]
(init-terminal!)
Expand Down
32 changes: 32 additions & 0 deletions test/buffers_test.lg
Original file line number Diff line number Diff line change
Expand Up @@ -449,3 +449,35 @@
(let [ws (bufs/new-workspace (buf/new-state nil ["x"]))
ws (bufs/put-current ws (bufs/arm-task (bufs/current ws) (fn [s] s)))]
(is (= :pending-task (bufs/loop-wait ws))))))

(defn- wait-until
"Spin until `pred` is true or `n` 10ms ticks elapse. Returns whether it
became true -- tests that start a future need this because the dec
happens on the other goroutine."
[pred n]
(loop [i n]
(cond
(pred) true
(zero? i) false
:else (do (sleep 10) (recur (dec i))))))

(deftest a-scratch-future-keeps-the-loop-polling
;; Regression: C-x C-e of (go ...) / (future ...) is not a spawn-task job,
;; so loop-wait used to return :read-key and park in term/read-key. A
;; background println then wrote onto the TTY with no frame coming to
;; paint over it, which looked like a hard freeze.
(testing "install-spawn-tracking! is idempotent"
(is (true? (bufs/install-spawn-tracking!)))
(is (true? (bufs/install-spawn-tracking!))))

(testing "an in-flight future is live-spawns? and loop-wait is :poll, not :read-key"
(bufs/install-spawn-tracking!)
(let [p (promise)
fut (future (deref p))
ws (bufs/new-workspace (buf/new-state nil ["x"]))]
(is (bufs/live-spawns?))
(is (= :poll (bufs/loop-wait ws)))
(deliver p :ok)
(is (true? (wait-until (fn [] (not (bufs/live-spawns?))) 50)))
(is (= :ok @fut))
(is (= :read-key (bufs/loop-wait ws))))))
Loading
Loading