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
21 changes: 19 additions & 2 deletions docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,13 +263,15 @@ whatever) are caught and shown in the echo area rather than crashing the
editor.

`C-c C-e` puts the current namespace back when it's done. It has to:
`load-string`ing a buffer that opens with `(ns foo)` -- which every real
evaluating a buffer that opens with `(ns foo)` -- which every real
`.lg` file does -- relocates the running namespace permanently, and since
that namespace *is* the editor's own, one `C-c C-e` on an ordinary file
would otherwise leave `buf/`, `km/`, `dispatch/` and `vibe` unresolvable
for the rest of the session. `C-x C-e` and `C-j` are deliberately left
alone, as is the `*repl*` buffer: an `(in-ns 'foo)` you typed and evaluated
on purpose should still move you.
on purpose should still move you. A buffer with no ns form (*scratch*)
evals in the editor ns, so lg-isms like `(now)` resolve the same as
`C-x C-e`.

`C-c C-z` opens (or switches to) `*repl*` -- a dedicated, persistent REPL
buffer rather than a transcript you build by hand with `C-j`. It's plain
Expand Down Expand Up @@ -346,6 +348,15 @@ file to splice requires into. JVM-Clojure prefixes in the reply
(`clojure.string/join`) are rewritten to the canonical names before
anything is inserted.

The catalog also includes the live editor's scripting surface
(`legmacs.keymap`, `legmacs.modeline`, `legmacs.theme`, `legmacs.modes`,
`legmacs.buffer`, `legmacs.minibuffer`, `legmacs.modes.prog`) — the same
APIs `legmacs-init.lg` uses. A `(vibe "clock on the modeline")` can
therefore emit `register-modeline-segment!` / `bind-key!` / `set-theme!`
code that fits the file. Those calls take effect when you evaluate them
(`C-x C-e` / `C-c C-e`), the same as any other init form; vibe only
splices the source.

Setup is `~/.config/legmacs/vibe.edn` (or `$LEGMACS_CONFIG_DIR/vibe.edn` --
the same variable `bin/legmacs` already honours for `legmacs_init.lg`):

Expand Down Expand Up @@ -702,6 +713,12 @@ privileged built-in path here either.
(ml/set-modeline-order! [:mode :buffer :position])
```

A segment fn that throws, or that returns a non-string `:text` (the usual
`(string/trim (os/sh ...))` mistake — `os/sh` returns a map), is shown as a
red `id!` badge instead of taking down the frame. `:text` must be a string;
for a live clock, `(.Format (now) "15:04")` is cheap enough to run every
paint, `os/sh` is not.

A minor mode's `:status`/`:status-right` lighters (see keycast above) still
work unchanged: they're aggregated into the built-in `:modes-status`/
`:modes-status-right` segments, which you can recolor, move, or remove the
Expand Down
45 changes: 42 additions & 3 deletions legmacs/modeline.lg
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
;; with its own colors is a "block"
;; that pops out of the bar
;;
;; :text must be a string. A throw, or a non-string :text (e.g. the map
;; os/sh returns), becomes a red "id!" badge instead of escaping into
;; render and taking down the frame.
;;
;; Segments render left-to-right in registration order unless
;; set-modeline-order! has changed it, each side joined by `separator`.

Expand Down Expand Up @@ -82,14 +86,40 @@
(cond
(nil? s) nil
(string? s) (when (seq s) {:text s})
(map? s) (when (seq (:text s)) s)
(map? s) (when (and (string? (:text s)) (seq (:text s))) s)
:else nil))

(defn- seg-label [id]
(if (keyword? id) (name id) (str id)))

(defn- broken-seg
"A visible stand-in when a segment fn throws or returns a non-string
:text, so a bad user/vibe segment cannot take down the frame."
[id]
{:text (str " " (seg-label id) "! ")
:fg [245 246 250]
:bg [204 90 90]})

(defn- call-seg
"Run a segment fn. Throws and illegal :text become a broken-seg badge
rather than escaping into render. nil and \"\" still omit the segment."
[id f state]
(let [outcome (try {:ok (f state)} (catch e {:err e}))]
(if (contains? outcome :err)
(broken-seg id)
(let [v (:ok outcome)]
(cond
(nil? v) nil
(and (string? v) (not (seq v))) nil
(and (map? v) (or (nil? (:text v)) (and (string? (:text v))
(not (seq (:text v)))))) nil
:else (or (normalize-seg v) (broken-seg id)))))))

(defn- ordered [side state]
(->> @segments
(filter (fn [[_ seg]] (= side (:side seg))))
(sort-by (fn [[_ seg]] (:order seg)))
(keep (fn [[_ seg]] (normalize-seg ((:fn seg) state))))))
(keep (fn [[id seg]] (call-seg id (:fn seg) state)))))

(defn left-segments
"Every :left segment visible this frame, as {:text :fg :bg} maps, in
Expand Down Expand Up @@ -129,11 +159,20 @@
:fg [30 36 30]
:bg [152 195 121]}))

(defn- call-lighter
"Run a mode :status / :status-right fn. A throw omits that lighter
this frame rather than killing the mode line."
[f state]
(let [outcome (try {:ok (f state)} (catch e {:err e}))]
(when-not (contains? outcome :err)
(let [s (:ok outcome)]
(when (and (string? s) (seq s)) s)))))

(defn- mode-lighters [state accessor]
(->> (modes/active-modes state)
(keep (fn [mode-kw]
(when-let [f (accessor mode-kw)]
(let [s (f state)] (when (seq s) s)))))
(call-lighter f state))))
(string/join " ")))

(defn- modes-status-segment [state] (mode-lighters state modes/mode-status))
Expand Down
40 changes: 25 additions & 15 deletions legmacs/modes/letgo.lg
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
;; legmacs.modes.letgo — a small REPL built into the editor: evaluate
;; let-go code straight out of the buffer, live, in the same process.
;;
;; This works because let-go's `eval`/`read-string`/`load-string` (see
;; pkg/compiler/eval.go) compile and run an already-read form *in whatever
;; namespace is current* -- and that's legmacs.main the whole time legmacs
;; is running, so code typed here sees exactly the aliases main.lg set up:
;; `buf/`, `keys/`, `km/`, `dispatch/`, `render/`, plus all of clojure.core
;; unqualified. It's not a sandboxed toy evaluator -- it's the running
;; editor's own namespace.
;; This works because let-go's `eval`/`read-string` compile and run an
;; already-read form *in whatever namespace is current* -- and that's
;; legmacs.main the whole time legmacs is running, so code typed here sees
;; exactly the aliases main.lg set up: `buf/`, `keys/`, `km/`, `dispatch/`,
;; `render/`, plus clojure.core and let-go.core (now, spy, …) unqualified.
;; It's not a sandboxed toy evaluator -- it's the running editor's own
;; namespace. C-c C-e / *repl* eval form-by-form the same way (let-go's
;; load-string compiles in clojure.core, which does not refer let-go.core).
;;
;; Which is also why `preserving-ns` below exists: that invariant isn't
;; free. `load-string`ing a buffer that opens with `(ns foo)` -- every real
;; free. Evaluating 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. eval-buffer always restores the editor ns after the load.
Expand Down Expand Up @@ -308,12 +309,20 @@
[form]
(capturing-out (fn [] (eval form))))

(defn- eval-each-form
"eval every form in `forms` in the current *ns*, returning the last
result. let-go's load-string compiles in clojure.core, which does not
refer let-go.core, so lg-isms like `now` fail and defs intern into
core -- that is the wrong home for *scratch* / C-c C-e / *repl*."
[forms]
(reduce (fn [_ form] (eval form)) nil forms))

(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."
"Read `s` and eval each form in the current *ns*, 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))))
(capturing-out (fn [] (eval-each-form (read-all-string s)))))

(defn- last-sexp-before-point [state]
(let [text (buf/text-before-cursor state)]
Expand Down Expand Up @@ -390,7 +399,7 @@
:error (assoc state :message (str "Read error: " form))
(try
(f state form (eval-form-in-state state form))
(catch e (assoc state :message (str "Eval error: " (ex-message e))))))))
(catch e (assoc state :message (str "Eval error: " (or (ex-message e) (str e)))))))))

(defcommand eval-last-sexp
"Evaluate the s-expression immediately before point; show the result in
Expand All @@ -408,12 +417,13 @@
(buf/insert-string state (str "\n" (format-result result))))))

(defcommand eval-buffer
"Evaluate the entire buffer, top to bottom, as one `load-string`."
"Evaluate the entire buffer, top to bottom, form by form in the current
namespace (or the buffer's ns form, restored afterwards)."
[state]
(try
(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))))))
(catch e (assoc state :message (str "Eval error: " (or (ex-message e) (str e)))))))

;; --- auto-indent --------------------------------------------------------
;;
Expand Down
7 changes: 4 additions & 3 deletions legmacs/modes/repl.lg
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
;; It's plain let-go-mode (major mode :let-go) for content -- highlighting,
;; auto-pairing, paren-matching, auto-indent are exactly what a normal .lg
;; buffer gets, for free -- plus one minor mode, :repl, that shadows RET:
;; - a complete top-level form -> evaluate it (load-string, so a form
;; spanning several typed expressions works the same as eval-buffer),
;; print the result, and open a fresh prompt right after it
;; - a complete top-level form -> evaluate it (same as eval-buffer:
;; each form in the current *ns*, so a multi-expression paste works
;; and lg-isms like `now` resolve), print the result, and open a
;; fresh prompt right after it
;; - an incomplete one (an open bracket -- ls/depth-at > 0) -> insert a
;; newline and auto-indent instead, exactly like ordinary let-go-mode,
;; so a multi-line form can keep being typed
Expand Down
5 changes: 4 additions & 1 deletion legmacs/render.lg
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,9 @@

(defn sanitize
"Replace control characters in `s` with caret notation (^[ for ESC, ^M
for CR, ^? for DEL). Everything headed for the echo area goes through
for CR, ^? for DEL). Tab is indent, not a control glyph -- it becomes
four spaces so a CompileError's \"\\tcaused by\" chain is readable
instead of `^I`. Everything headed for the echo area goes through
this: a message can carry control bytes from sources the key decoder
never sees (an eval result containing (char 27), a swiper label from a
file with control characters) and writing them raw would let the
Expand All @@ -123,6 +125,7 @@
(apply str (map (fn [ch]
(let [c (int ch)]
(cond
(= c 9) " "
(< c 32) (str "^" (char (+ c 64)))
(= c 127) "^?"
:else (str ch))))
Expand Down
46 changes: 45 additions & 1 deletion legmacs/vibe.lg
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,41 @@
"edit the ns form; missing requires are added for you.\n"
"- json/read-json takes an optional {:keywords? true}. json/write-json "
"accepts keyword or string map keys.\n"
"- os/sh returns {:exit n :out s :err s}, not a string. Use "
"(:out (os/sh \"date\" \"+%H:%M\")), then string/trim that.\n"
"- (throw \"msg\") is a plain string. Catch with (or (ex-message e) (str e)).\n"
"- Do not use finally for cleanup that must run on throw; reify the outcome "
"then clean up.\n"
"- No top-level forward references: a defn body may only refer to names "
"defined above it.\n"
"- count, subs, and string/index-of on a string are O(n) (rune-indexed). "
"Do not call them per character in a scan; convert to a char vector once.\n\n"
"This process is the live editor. When the request is about this editor "
"(keys, modeline, theme, modes, commands), use the Editor scripting catalog "
"and these shapes:\n"
"- Commands: (defcommand name \"doc\" [state] ...) then "
"(km/bind-key! \"C-c x\" :name). A command is (fn [state] -> state); "
"return new state, do not mutate a buffer atom.\n"
"- Modeline: (legmacs.modeline/register-modeline-segment! :id :right "
"(fn [state] {:text \"...\" :fg [r g b] :bg [r g b]})). Built-in ids: "
":buffer, :mode, :position, :pending-keys, :async-jobs, :modes-status, "
":modes-status-right. nil or \"\" omits the segment that frame. "
"The fn runs every frame: it must be cheap, must not throw, must not "
"do I/O (no os/sh, http), and :text must be a string. For a clock, "
"(.Format (now) \"15:04\"). now is let-go.core/now, auto-referred; "
"do not require it. Do not add an (ns ...) form to a file that does "
"not already have one.\n"
"- Theme: theme/set-theme! merges faces; theme/load-theme! replaces. "
"The :modeline face is the bar's default colors; a segment's :fg/:bg "
"override per block.\n"
"- Modes: register-mode! / register-minor-mode!. A :status / :status-right "
"lighter on a minor mode is the other way to put text on the bar.\n"
"- Prefer an existing :as alias; otherwise fully-qualify "
"(legmacs.modeline/register-modeline-segment!). Never (require 'legmacs.main). "
"Session-lasting config belongs in a legmacs-init ns at "
"~/.config/legmacs/legmacs_init.lg, not a random project file.\n"
"- Do not return a capturing fn literal from if/when/cond (AOT hoist); "
"name the closure.\n\n"
"You get a source file with the marker " marker " at exactly one spot, "
"and a request. Reply with the code that should replace that marker.\n\n"
"Rules:\n"
Expand Down Expand Up @@ -109,6 +137,13 @@
"pprint" "test" "math" "net" "hash" "graph" "transit" "bencode"
"storage" "unix" "syscall" "pods" "xxh3"])

(def editor-libs
"Editor namespaces we always offer in the catalog when loaded.
Curated: the scripting surface (keys, modeline, theme, modes, buffer
primitives, minibuffer, language-pack hook), not every legmacs.* ns."
["legmacs.keymap" "legmacs.modeline" "legmacs.theme" "legmacs.modes"
"legmacs.buffer" "legmacs.minibuffer" "legmacs.modes.prog"])

(def skip-catalog-ns
#{"user" "core" "clojure.core" "gogen" "resolver" "rt" "ir" "ir.data"
"disasm" "profile" "js" "let-go.core" "let-go.types" "let-go.semver"})
Expand Down Expand Up @@ -577,6 +612,9 @@
(defn- available-stdlib []
(filter (fn [nm] (find-ns (symbol nm))) stdlib-libs))

(defn- available-editor []
(filter (fn [nm] (find-ns (symbol nm))) editor-libs))

(defn- parse-libspec [spec]
(try
(cond
Expand Down Expand Up @@ -673,6 +711,7 @@
(map (fn [lib]
(let [alias (some (fn [[a l]] (when (= l lib) a)) aliases)
extra (when (and (not (contains? (set stdlib-libs) lib))
(not (contains? (set editor-libs) lib))
(find-ns (symbol lib)))
(str ": " (format-var-names (symbol lib))))]
(str " " lib
Expand All @@ -682,13 +721,18 @@
"\n"))))

(defn namespace-catalog
"Live stdlib (and this file's requires) for the user message."
"Live stdlib, editor scripting surface, and this file's requires."
([] (namespace-catalog nil))
([text]
(str "Loaded stdlib (canonical names — never clojure.string, always string/):\n"
(string/join "\n" (map (fn [nm] (str " " (format-ns-line nm)))
(available-stdlib)))
"\n"
"Editor scripting (this process is the live editor; C-x C-e applies "
"registry mutations):\n"
(string/join "\n" (map (fn [nm] (str " " (format-ns-line nm)))
(available-editor)))
"\n"
(when (seq text)
(format-file-requires text)))))

Expand Down
30 changes: 25 additions & 5 deletions test/letgo_mode_test.lg
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,9 @@

(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
;; while C-c C-e evals every form in order. `(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"
Expand Down Expand Up @@ -132,7 +131,7 @@
(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
;; C-c C-e evals 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.
Expand All @@ -155,6 +154,27 @@
s (eval-last-at-row s 2)]
(is (= "=> 42" (:message s))))))

(deftest eval-buffer-scratch-resolves-lg-isms
;; let-go's load-string compiles in clojure.core, which does not refer
;; let-go.core -- so `(now)` in *scratch* (no ns form) used to fail
;; "Can't resolve now" on C-c C-e. Form-by-form eval in the current
;; *ns* is what C-x C-e already did.
(testing "C-c C-e of a no-ns-form buffer can call now"
(let [s (lg-mode/eval-buffer
(buf/new-state nil ["(.Format (now) \"15:04\")"]))]
(is (string/starts-with? (:message s) "=> "))
(is (not (string/includes? (:message s) "Can't resolve")))))

(testing "a defn using now interned in the current ns, not core"
(let [s (lg-mode/eval-buffer
(buf/new-state nil ["(defn letgo-mode-test-clock [state]"
" {:text (.Format (now) \"15:04\")})"
"(letgo-mode-test-clock nil)"]))]
(is (string/starts-with? (:message s) "=> "))
(is (string/includes? (:message s) ":text"))
(is (fn? @(resolve 'letgo-mode-test-clock)))
(is (nil? (ns-resolve (find-ns 'core) 'letgo-mode-test-clock))))))

(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")))))
Expand Down
Loading
Loading