diff --git a/legmacs/buffers.lg b/legmacs/buffers.lg index 663d0b6..c1bc25d 100644 --- a/legmacs/buffers.lg +++ b/legmacs/buffers.lg @@ -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 @@ -480,6 +483,65 @@ 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 @@ -487,11 +549,12 @@ :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)) diff --git a/legmacs/modes/letgo.lg b/legmacs/modes/letgo.lg index f956e1a..e6c0581 100644 --- a/legmacs/modes/letgo.lg +++ b/legmacs/modes/letgo.lg @@ -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] @@ -285,6 +287,34 @@ (defn- format-result [v] (try (pr-str v) (catch e (str "#")))) +(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 @@ -292,13 +322,74 @@ (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 @@ -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)))))) diff --git a/legmacs/modes/repl.lg b/legmacs/modes/repl.lg index 55078b4..1b06c71 100644 --- a/legmacs/modes/repl.lg +++ b/legmacs/modes/repl.lg @@ -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 diff --git a/legmacs/vibe.lg b/legmacs/vibe.lg index 081fe8b..389c264 100644 --- a/legmacs/vibe.lg +++ b/legmacs/vibe.lg @@ -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 @@ -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? diff --git a/main.lg b/main.lg index 8569872..c11e714 100644 --- a/main.lg +++ b/main.lg @@ -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] @@ -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) @@ -160,6 +163,7 @@ (defn main [] (bindings/install!) + (bufs/install-spawn-tracking!) (load-user-config!) (let [workspace (build-workspace *command-line-args*)] (init-terminal!) diff --git a/test/buffers_test.lg b/test/buffers_test.lg index 9db05d4..1a015e9 100644 --- a/test/buffers_test.lg +++ b/test/buffers_test.lg @@ -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)))))) diff --git a/test/letgo_mode_test.lg b/test/letgo_mode_test.lg index 16f5199..621ac6c 100644 --- a/test/letgo_mode_test.lg +++ b/test/letgo_mode_test.lg @@ -13,6 +13,12 @@ (let [row (dec (count (:lines state)))] (assoc state :cursor-row row :cursor-col (count (nth (:lines state) row))))) +(defn- cursor-end-of-row [state row] + (assoc state :cursor-row row :cursor-col (count (nth (:lines state) row)))) + +(defn- eval-last-at-row [state row] + (lg-mode/eval-last-sexp (cursor-end-of-row state row))) + (deftest eval-last-sexp-shows-result-in-message (testing "evaluates the sexp before point, leaves the buffer untouched" (let [s (at-end (buf/new-state nil ["(+ 1 2 3)"])) @@ -67,6 +73,88 @@ (is (string/includes? (:message s) "Eval error")) (is (= before (ns-name *ns*)))))) +(deftest eval-last-sexp-sees-defs-after-an-ns-form + ;; Regression: C-x C-e / C-j evaluate one already-read form via `eval`, + ;; while C-c C-e `load-string`s the whole buffer. load-string compiles + ;; and runs each form in order, so `(ns foo)` is in force for later + ;; forms; a C-x C-e of `(x)` after separately evaluating `(ns foo)` and + ;; `(defn x [] 1)` has to land in the same namespace or it reports + ;; "Can't resolve x in this context" even though eval-buffer of the + ;; same three lines returns 1. + (testing "sequential C-x C-e of ns, defn, then a call finds the defn" + (lg-mode/preserving-ns + (fn [] + (let [lines ["(ns letgo-mode-test-seq-eval)" + "(defn x [] 1)" + "(x)"] + s (buf/new-state nil lines) + s (eval-last-at-row s 0) + s (eval-last-at-row s 1) + s (eval-last-at-row s 2)] + (is (= "=> 1" (:message s))) + (is (fn? @(ns-resolve 'letgo-mode-test-seq-eval 'x))) + (is (= 1 ((ns-resolve 'letgo-mode-test-seq-eval 'x)))))))) + + (testing "C-j on the call after C-x C-e of ns and defn inserts 1" + (lg-mode/preserving-ns + (fn [] + (let [lines ["(ns letgo-mode-test-seq-eval-j)" + "(defn x [] 1)" + "(x)"] + s (buf/new-state nil lines) + s (eval-last-at-row s 0) + s (eval-last-at-row s 1) + s (lg-mode/eval-print-last-sexp (at-end s))] + (is (= "1" (last (:lines s)))))))) + + (testing "the same sequence through dispatch's C-x C-e, like a real session" + (lg-mode/preserving-ns + (fn [] + (let [cxce (fn [state] + (-> state + (modes/switch-to-mode :let-go) + (dispatch/dispatch "C-x") + (dispatch/dispatch "C-e"))) + lines ["(ns letgo-mode-test-seq-eval-dispatch)" + "(defn x [] 1)" + "(x)"] + s (buf/new-state nil lines) + s (cxce (cursor-end-of-row s 0)) + s (cxce (cursor-end-of-row s 1)) + s (cxce (cursor-end-of-row s 2))] + (is (= "=> 1" (:message s))))))) + + (testing "eval-buffer of the same three lines still works, and is the control" + (let [s (lg-mode/eval-buffer + (buf/new-state nil ["(ns letgo-mode-test-seq-eval-buf)" + "(defn x [] 1)" + "(x)"]))] + (is (= "=> 1" (:message s)))))) + +(deftest eval-last-sexp-after-eval-buffer-uses-the-buffer-ns + ;; C-c C-e load-string's the buffer (defs land in foo) then preserving-ns + ;; puts *ns* back to the editor. C-x C-e of @a used to compile in the + ;; editor ns and report "Can't resolve a". Eval last-sexp in the + ;; buffer's (ns ...) instead, like CIDER. + (testing "C-c C-e then C-x C-e of @a sees the atom interned in the buffer ns" + (let [before (ns-name *ns*) + lines ["(ns letgo-mode-test-eval-buf-atom)" + "(def a (atom 7))" + "@a"] + s (buf/new-state nil lines) + s (lg-mode/eval-buffer s) + s (eval-last-at-row s 2)] + (is (= "=> 7" (:message s))) + (is (= before (ns-name *ns*))))) + + (testing "C-x C-e of a bare name after eval-buffer also finds it" + (let [lines ["(ns letgo-mode-test-eval-buf-var)" + "(def a 42)" + "a"] + s (lg-mode/eval-buffer (buf/new-state nil lines)) + s (eval-last-at-row s 2)] + (is (= "=> 42" (:message s)))))) + (deftest let-go-mode-is-registered-for-dot-lg-files (testing ".lg is in the auto-mode-alist, pointing at :let-go" (is (= :let-go (modes/mode-for-filename "foo.lg"))))) @@ -159,15 +247,15 @@ (testing "typing a closer that's already right under point moves over it instead of doubling it" (let [s (reduce dispatch/dispatch - (modes/switch-to-mode (buf/new-state nil [""]) :let-go) - ["(" "f" "o" "o" ")"])] + (modes/switch-to-mode (buf/new-state nil [""]) :let-go) + ["(" "f" "o" "o" ")"])] (is (= ["(foo)"] (:lines s))) (is (= 5 (:cursor-col s))))) (testing "nested pairs skip-over correctly all the way out" (let [s (reduce dispatch/dispatch - (modes/switch-to-mode (buf/new-state nil [""]) :let-go) - ["(" "(" ")" ")"])] + (modes/switch-to-mode (buf/new-state nil [""]) :let-go) + ["(" "(" ")" ")"])] (is (= ["(())"] (:lines s))) (is (= 4 (:cursor-col s))))) @@ -184,14 +272,14 @@ (testing "backspacing when point isn't between an empty pair is a normal delete" (let [s (reduce dispatch/dispatch - (modes/switch-to-mode (buf/new-state nil [""]) :let-go) - ["a" "b" "DEL"])] + (modes/switch-to-mode (buf/new-state nil [""]) :let-go) + ["a" "b" "DEL"])] (is (= ["a"] (:lines s))))) (testing "inside an (unterminated) string, an opener doesn't pair" (let [s (reduce dispatch/dispatch - (modes/switch-to-mode (buf/new-state nil [""]) :let-go) - ["\"" "("])] + (modes/switch-to-mode (buf/new-state nil [""]) :let-go) + ["\"" "("])] (is (= ["\"("] (:lines s)))))) (deftest lisp-indentation-aligns-to-the-open-form @@ -351,3 +439,21 @@ (testing "a line with no quote on it can't change anything" (is (= :string (lg-mode/highlight-carry :string "(no quotes here)"))) (is (nil? (lg-mode/highlight-carry nil "(no quotes here)"))))) + +(deftest eval-last-sexp-does-not-wait-for-a-future + ;; C-x C-e of (future (sleep ...)) must return the pending promise, not + ;; block the editor thread until the body finishes. @(future ...) on + ;; the main thread is a different form and still blocks, like Emacs. + (testing "eval-last-sexp of a long sleep future returns immediately" + (let [t0 (System/nanoTime) + s (lg-mode/eval-last-sexp (at-end (buf/new-state nil ["(future (sleep 3000))"]))) + ms (/ (- (System/nanoTime) t0) 1000000.0)] + (is (< ms 500)) + (is (string/includes? (:message s) "pending")))) + + (testing "eval-last-sexp of a go block returns a chan, not the body's value" + (let [t0 (System/nanoTime) + s (lg-mode/eval-last-sexp (at-end (buf/new-state nil ["(go (sleep 3000))"]))) + ms (/ (- (System/nanoTime) t0) 1000000.0)] + (is (< ms 500)) + (is (string/includes? (:message s) "chan"))))) diff --git a/test/vibe_test.lg b/test/vibe_test.lg index 49f95a4..d063f9d 100644 --- a/test/vibe_test.lg +++ b/test/vibe_test.lg @@ -188,6 +188,34 @@ (is (string/includes? (str (or (ex-message err) err)) "unknown vibe provider"))) (finally (reset! vibe/config saved)))))) +(defn- with-ns [ns-sym f] + (let [saved (ns-name *ns*) + outcome (try {:ok (do (in-ns ns-sym) (f))} (catch e {:err e}))] + (in-ns saved) + (if (contains? outcome :err) (throw (:err outcome)) (:ok outcome)))) + +(deftest vibe-resolves-after-switching-namespace + ;; Regression: C-c C-v evals the (vibe ...) form in whatever *ns* is + ;; current. After `(ns weather)` that is no longer legmacs.main, so + ;; unqualified `vibe` used to fail with "Can't resolve vibe". + (testing "a freshly created ns still resolves unqualified vibe" + (is (some? (with-ns 'vibe-test-fresh-ns (fn [] (resolve 'vibe)))))) + + (testing "eval-vibe-form in that ns fails on the provider, not on resolving vibe" + (let [saved @vibe/config] + (try + (swap! vibe/config assoc :provider :nope) + (let [err (with-ns 'vibe-test-eval-ns + (fn [] + (try (vibe/eval-vibe-form "(vibe \"a thing\")" {}) + nil + (catch e e))))] + (is (some? err)) + (let [msg (str (or (ex-message err) err))] + (is (string/includes? msg "unknown vibe provider")) + (is (not (string/includes? msg "Can't resolve vibe"))))) + (finally (reset! vibe/config saved)))))) + (deftest context-is-cleared-after-use (testing "with-context restores *vibe-context* even when the body throws" (try (vibe/with-context {:text "x"} (fn [] (throw "boom"))) (catch e nil))