Skip to content

Commit 341fd8f

Browse files
committed
[#3973] Add a namespace load-state indicator to the mode line
Surface, right in the `cider-mode` lighter, whether the current buffer's namespace is actually loaded into the REPL and whether it's still in sync with what was loaded. The lighter shows ` not-loaded` for a namespace the runtime doesn't know about and ` stale` once you've edited the buffer since the last load - the common "why is my change not taking effect" trap. State is cached buffer-locally and never queried during redisplay, so the lighter stays cheap. It's refreshed when CIDER loads a file, on demand via `cider-ns-refresh-load-state`, and once per buffer when you focus it (via a buffer-local hook installed by `cider-mode`, so unrelated buffers pay nothing). We deliberately avoid querying at connection time. Without cider-nrepl the `cider/ns-list` op is absent, so the state simply stays unknown (the lighter shows nothing) rather than misreporting every namespace as not loaded.
1 parent a5f2af3 commit 341fd8f

4 files changed

Lines changed: 331 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
- [#3976](https://github.com/clojure-emacs/cider/pull/3976): Underline the further-expandable sub-forms while inline macro stepping and move between them with `n`/`p` in `cider-macrostep-mode` (`cider-macrostep-highlight-expandable`).
1414
- [#3977](https://github.com/clojure-emacs/cider/pull/3977): Colorize the gensyms introduced by an inline macro expansion, each distinct gensym in its own color, so an introduced binding can be tracked (`cider-macrostep-color-gensyms`).
1515
- [#3978](https://github.com/clojure-emacs/cider/pull/3978): Add `cider-macrostep-expand-all` (`a` in `cider-macrostep-mode`) to fully expand the form at point inline in one step, instead of stepping level by level.
16+
- [#3973](https://github.com/clojure-emacs/cider/pull/3973): Show a namespace load-state indicator in the `cider-mode` lighter (` not-loaded` / ` stale`), making it obvious when the current buffer's namespace hasn't been loaded into the REPL or has been edited since it was last loaded (`cider-show-ns-load-state`).
1617

1718
### Bugs fixed
1819

lisp/cider-mode.el

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333

3434
(require 'clojure-mode)
3535
(require 'cider-eval)
36+
(require 'cider-ns-state)
3637
(require 'cider-inspiration)
3738
(require 'cider-test) ; required only for the menu
3839
(require 'cider-eldoc)
@@ -71,7 +72,8 @@ Info contains the connection type, project name and host:port endpoint."
7172

7273
;;;###autoload
7374
(defcustom cider-mode-line
74-
'(:eval (format " cider[%s]" (cider--modeline-info)))
75+
'(:eval (concat (format " cider[%s]" (cider--modeline-info))
76+
(cider-ns-state--lighter)))
7577
"Mode line lighter for cider mode.
7678
7779
The value of this variable is a mode line template as in
@@ -1125,6 +1127,7 @@ property."
11251127
(progn
11261128
(setq-local sesman-system 'CIDER)
11271129
(cider-eldoc-setup)
1130+
(cider-ns-state-setup)
11281131
(add-hook 'completion-at-point-functions #'cider-complete-at-point nil t)
11291132
(font-lock-add-keywords nil cider--static-font-lock-keywords)
11301133
(cider-refresh-dynamic-font-lock)
@@ -1148,6 +1151,7 @@ property."
11481151
(cider-enable-cider-completion-style 1)
11491152
(setq next-error-function #'cider-jump-to-compilation-error))
11501153
;; Mode cleanup
1154+
(cider-ns-state-teardown)
11511155
(mapc #'kill-local-variable '(next-error-function
11521156
x-gtk-use-system-tooltips
11531157
font-lock-fontify-region-function

lisp/cider-ns-state.el

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
;;; cider-ns-state.el --- Namespace load-state tracking -*- lexical-binding: t -*-
2+
3+
;; Copyright © 2026 Bozhidar Batsov and CIDER contributors
4+
5+
;; Author: Bozhidar Batsov <bozhidar@batsov.dev>
6+
7+
;; This program is free software: you can redistribute it and/or modify
8+
;; it under the terms of the GNU General Public License as published by
9+
;; the Free Software Foundation, either version 3 of the License, or
10+
;; (at your option) any later version.
11+
12+
;; This program is distributed in the hope that it will be useful,
13+
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
;; GNU General Public License for more details.
16+
17+
;; You should have received a copy of the GNU General Public License
18+
;; along with this program. If not, see <http://www.gnu.org/licenses/>.
19+
20+
;; This file is not part of GNU Emacs.
21+
22+
;;; Commentary:
23+
24+
;; Track whether a source buffer's namespace has been loaded into the running
25+
;; REPL, and whether the buffer has been edited since it was last loaded. The
26+
;; state is surfaced via the `cider-mode' mode-line lighter, so it's obvious
27+
;; when you're about to operate on a namespace that the runtime doesn't know
28+
;; about (or that is out of sync).
29+
;;
30+
;; This is intentionally coarse (namespace granularity). Per-form staleness
31+
;; (extending the fringe indicators) is a separate, future effort.
32+
33+
;;; Code:
34+
35+
(require 'subr-x)
36+
37+
(require 'cider-client)
38+
(require 'cider-eval)
39+
(require 'nrepl-dict)
40+
41+
(defcustom cider-show-ns-load-state t
42+
"Whether to show the namespace load-state in the `cider-mode' lighter.
43+
When non-nil, a marker appears in the mode line when the current buffer's
44+
namespace has not been loaded into the REPL, or has been edited since it was
45+
last loaded."
46+
:type 'boolean
47+
:group 'cider
48+
:package-version '(cider . "1.23.0"))
49+
50+
(defface cider-ns-load-state-face
51+
'((t :inherit warning))
52+
"Face for the namespace load-state marker in the mode line."
53+
:group 'cider
54+
:package-version '(cider . "1.23.0"))
55+
56+
(defvar-local cider-ns-state--loaded nil
57+
"Cached load state of the current buffer's namespace.
58+
One of nil (unknown / not checked yet), t (loaded), or the symbol
59+
`not-loaded'. Updated asynchronously by `cider-ns-state--refresh' and
60+
on `cider-file-loaded-hook'.")
61+
62+
(defvar-local cider-ns-state--loaded-tick nil
63+
"Value of `buffer-chars-modified-tick' when the buffer was last loaded.
64+
Used to detect that the buffer has been edited since (out of sync). Only set
65+
when CIDER itself loaded the buffer, since otherwise there is no reliable sync
66+
baseline.")
67+
68+
(defun cider-ns-load-state ()
69+
"Return the load/sync state of the current buffer's namespace.
70+
One of `loaded', `not-loaded', `out-of-sync', or nil when the state is unknown
71+
or not applicable (no connection, not a Clojure source buffer, or no
72+
namespace).
73+
74+
Cheap by design (it never queries the REPL) so it is safe to call from the
75+
mode line on every redisplay: when the state hasn't been determined yet
76+
`cider-ns-state--loaded' is nil and we bail out before any connection lookup."
77+
(when (and cider-ns-state--loaded
78+
(derived-mode-p 'clojure-mode 'clojure-ts-mode)
79+
(cider-connected-p))
80+
(cond
81+
((eq cider-ns-state--loaded 'not-loaded) 'not-loaded)
82+
((and cider-ns-state--loaded-tick
83+
(/= cider-ns-state--loaded-tick (buffer-chars-modified-tick)))
84+
'out-of-sync)
85+
(t 'loaded))))
86+
87+
(defun cider-ns-state--lighter ()
88+
"Return the mode-line marker for the current buffer's namespace state.
89+
Empty unless the namespace is not loaded or is out of sync. Any error is
90+
swallowed and rendered as an empty marker, since this runs during redisplay."
91+
(if (not cider-show-ns-load-state)
92+
""
93+
(pcase (ignore-errors (cider-ns-load-state))
94+
('not-loaded
95+
(propertize " not-loaded" 'face 'cider-ns-load-state-face
96+
'help-echo "This namespace has not been loaded into the REPL.
97+
Load the buffer with `cider-load-buffer' (C-c C-k)."))
98+
('out-of-sync
99+
(propertize " stale" 'face 'cider-ns-load-state-face
100+
'help-echo "This buffer has been edited since it was last loaded.
101+
Reload it with `cider-load-buffer' (C-c C-k)."))
102+
(_ ""))))
103+
104+
(defun cider-ns-state--list-async (callback)
105+
"Fetch the list of namespaces loaded in the REPL and pass it to CALLBACK.
106+
CALLBACK is invoked only when the response actually carried a namespace list,
107+
so a failed or unsupported op leaves the load-state unknown rather than
108+
falsely reporting every namespace as not loaded."
109+
(let ((loaded-nss nil))
110+
(cider-nrepl-send-request
111+
`("op" "cider/ns-list"
112+
"exclude-regexps" ,cider-filtered-namespaces-regexps)
113+
(lambda (response)
114+
(nrepl-dbind-response response (ns-list status)
115+
(when ns-list
116+
(setq loaded-nss ns-list))
117+
(when (and loaded-nss (member "done" status))
118+
(funcall callback loaded-nss)))))))
119+
120+
(defun cider-ns-state--set-from-list (buffer loaded-nss)
121+
"Update BUFFER's cached load-state from LOADED-NSS, the loaded namespaces."
122+
(when (buffer-live-p buffer)
123+
(with-current-buffer buffer
124+
(when-let* ((ns (cider-current-ns 'no-default)))
125+
(setq-local cider-ns-state--loaded
126+
(if (member ns loaded-nss) t 'not-loaded))))))
127+
128+
(defun cider-ns-state--refresh (&optional buffer)
129+
"Asynchronously refresh the cached namespace load-state for BUFFER.
130+
BUFFER defaults to the current buffer. Does nothing when there is no
131+
connection or the buffer has no namespace."
132+
(let ((buf (or buffer (current-buffer))))
133+
(when (with-current-buffer buf
134+
(and (cider-connected-p)
135+
(cider-nrepl-op-supported-p "cider/ns-list" nil 'skip-ensure)
136+
(cider-current-ns 'no-default)))
137+
(cider-ns-state--list-async
138+
(lambda (loaded-nss) (cider-ns-state--set-from-list buf loaded-nss))))))
139+
140+
(defun cider-ns-refresh-load-state ()
141+
"Re-check whether the current buffer's namespace is loaded in the REPL."
142+
(interactive)
143+
(cider-ns-state--refresh))
144+
145+
(defun cider-ns-state--refresh-on-focus (&optional _frame)
146+
"Refresh the load-state of the focused Clojure buffer if it's unknown.
147+
Intended for `window-selection-change-functions': when you select a connected
148+
Clojure buffer whose state hasn't been determined yet, check it once. This
149+
deliberately avoids querying at connection time (which is fragile)."
150+
(when cider-show-ns-load-state
151+
(ignore-errors
152+
(let ((buf (window-buffer (selected-window))))
153+
(with-current-buffer buf
154+
(when (and (derived-mode-p 'clojure-mode 'clojure-ts-mode)
155+
(null cider-ns-state--loaded)
156+
(cider-connected-p))
157+
(cider-ns-state--refresh buf)))))))
158+
159+
(defun cider-ns-state--mark-loaded ()
160+
"Mark the current buffer's namespace as loaded and in sync.
161+
Intended for `cider-file-loaded-hook'."
162+
(when (derived-mode-p 'clojure-mode 'clojure-ts-mode)
163+
(setq-local cider-ns-state--loaded t
164+
cider-ns-state--loaded-tick (buffer-chars-modified-tick))))
165+
166+
(defun cider-ns-state-setup ()
167+
"Enable namespace load-state tracking in the current buffer.
168+
Registers a buffer-local focus hook so the state is checked when you switch
169+
to the buffer. Intended to be called when `cider-mode' is enabled."
170+
(add-hook 'window-selection-change-functions
171+
#'cider-ns-state--refresh-on-focus nil 'local))
172+
173+
(defun cider-ns-state-teardown ()
174+
"Disable namespace load-state tracking in the current buffer.
175+
Intended to be called when `cider-mode' is disabled."
176+
(remove-hook 'window-selection-change-functions
177+
#'cider-ns-state--refresh-on-focus 'local)
178+
(kill-local-variable 'cider-ns-state--loaded)
179+
(kill-local-variable 'cider-ns-state--loaded-tick))
180+
181+
;; `cider-file-loaded-hook' is cheap and only fires on CIDER loads, so it stays
182+
;; global; the focus hook is per-buffer (see `cider-ns-state-setup') to avoid
183+
;; running on every window selection change in unrelated buffers.
184+
(add-hook 'cider-file-loaded-hook #'cider-ns-state--mark-loaded)
185+
186+
(provide 'cider-ns-state)
187+
188+
;;; cider-ns-state.el ends here

test/cider-ns-state-tests.el

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
;;; cider-ns-state-tests.el -*- lexical-binding: t; -*-
2+
3+
;; Copyright © 2026 Bozhidar Batsov
4+
5+
;; This file is NOT part of GNU Emacs.
6+
7+
;; This program is free software: you can redistribute it and/or
8+
;; modify it under the terms of the GNU General Public License as
9+
;; published by the Free Software Foundation, either version 3 of the
10+
;; License, or (at your option) any later version.
11+
;;
12+
;; This program is distributed in the hope that it will be useful, but
13+
;; WITHOUT ANY WARRANTY; without even the implied warranty of
14+
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
15+
;; General Public License for more details.
16+
;;
17+
;; You should have received a copy of the GNU General Public License
18+
;; along with this program. If not, see `http://www.gnu.org/licenses/'.
19+
20+
;;; Commentary:
21+
22+
;; Tests for the namespace load-state tracking.
23+
24+
;;; Code:
25+
26+
(require 'buttercup)
27+
(require 'clojure-mode)
28+
(require 'nrepl-dict)
29+
(require 'cider-ns-state)
30+
31+
;; Please, for each `describe', ensure there's an `it' block, so that its execution is visible in CI.
32+
33+
(describe "cider-ns-load-state"
34+
;; stub the connection check so the logic runs offline
35+
(before-each (spy-on 'cider-connected-p :and-return-value t))
36+
37+
(it "is nil when the load state hasn't been determined"
38+
(with-temp-buffer
39+
(clojure-mode)
40+
(insert "(ns foo)")
41+
(expect (cider-ns-load-state) :to-be nil)))
42+
(it "is not-loaded when the namespace is absent from the REPL"
43+
(with-temp-buffer
44+
(clojure-mode)
45+
(insert "(ns foo)")
46+
(setq-local cider-ns-state--loaded 'not-loaded)
47+
(expect (cider-ns-load-state) :to-equal 'not-loaded)))
48+
(it "is loaded when loaded and unchanged since"
49+
(with-temp-buffer
50+
(clojure-mode)
51+
(insert "(ns foo)")
52+
(setq-local cider-ns-state--loaded t
53+
cider-ns-state--loaded-tick (buffer-chars-modified-tick))
54+
(expect (cider-ns-load-state) :to-equal 'loaded)))
55+
(it "is out-of-sync when the buffer was edited since loading"
56+
(with-temp-buffer
57+
(clojure-mode)
58+
(insert "(ns foo)")
59+
(setq-local cider-ns-state--loaded t
60+
cider-ns-state--loaded-tick (buffer-chars-modified-tick))
61+
(insert " ;; edit")
62+
(expect (cider-ns-load-state) :to-equal 'out-of-sync)))
63+
(it "is nil in non-Clojure buffers"
64+
(with-temp-buffer
65+
(fundamental-mode)
66+
(setq-local cider-ns-state--loaded 'not-loaded)
67+
(expect (cider-ns-load-state) :to-be nil))))
68+
69+
(describe "cider-ns-state--lighter"
70+
(before-each (spy-on 'cider-connected-p :and-return-value t))
71+
72+
(it "marks not-loaded and stale, and is empty when loaded/unknown"
73+
(with-temp-buffer
74+
(clojure-mode)
75+
(insert "(ns foo)")
76+
(expect (cider-ns-state--lighter) :to-equal "") ; unknown
77+
(setq-local cider-ns-state--loaded 'not-loaded)
78+
(expect (string-trim (cider-ns-state--lighter)) :to-equal "not-loaded")
79+
(setq-local cider-ns-state--loaded t
80+
cider-ns-state--loaded-tick (buffer-chars-modified-tick))
81+
(expect (cider-ns-state--lighter) :to-equal "") ; loaded
82+
(insert " x")
83+
(expect (string-trim (cider-ns-state--lighter)) :to-equal "stale")))
84+
(it "is empty when disabled via `cider-show-ns-load-state'"
85+
(let ((cider-show-ns-load-state nil))
86+
(with-temp-buffer
87+
(clojure-mode)
88+
(setq-local cider-ns-state--loaded 'not-loaded)
89+
(expect (cider-ns-state--lighter) :to-equal "")))))
90+
91+
(describe "cider-ns-state--refresh"
92+
(it "does nothing when the cider/ns-list op is unsupported (e.g. no cider-nrepl)"
93+
(spy-on 'cider-connected-p :and-return-value t)
94+
(spy-on 'cider-nrepl-op-supported-p :and-return-value nil)
95+
(spy-on 'cider-ns-state--list-async)
96+
(with-temp-buffer
97+
(clojure-mode)
98+
(insert "(ns foo)")
99+
(cider-ns-state--refresh)
100+
(expect 'cider-ns-state--list-async :not :to-have-been-called)
101+
(expect cider-ns-state--loaded :to-be nil))))
102+
103+
(describe "cider-ns-state--list-async"
104+
(it "leaves the state unknown when the response carries no ns-list"
105+
;; e.g. an unknown-op error reply - it must not mark the ns not-loaded
106+
(spy-on 'cider-nrepl-send-request :and-call-fake
107+
(lambda (_req handler &rest _)
108+
(funcall handler (nrepl-dict "status" '("done")))))
109+
(let (called)
110+
(cider-ns-state--list-async (lambda (_) (setq called t)))
111+
(expect called :to-be nil)))
112+
(it "invokes the callback with the namespace list on success"
113+
(spy-on 'cider-nrepl-send-request :and-call-fake
114+
(lambda (_req handler &rest _)
115+
(funcall handler (nrepl-dict "ns-list" '("foo" "bar") "status" '("done")))))
116+
(let (result)
117+
(cider-ns-state--list-async (lambda (nss) (setq result nss)))
118+
(expect result :to-equal '("foo" "bar")))))
119+
120+
(describe "cider-ns-state setup/teardown"
121+
(it "adds a buffer-local focus hook and tears it down with the cached state"
122+
(with-temp-buffer
123+
(clojure-mode)
124+
(cider-ns-state-setup)
125+
(expect (memq #'cider-ns-state--refresh-on-focus
126+
window-selection-change-functions)
127+
:to-be-truthy)
128+
(setq-local cider-ns-state--loaded t)
129+
(cider-ns-state-teardown)
130+
(expect (memq #'cider-ns-state--refresh-on-focus
131+
window-selection-change-functions)
132+
:to-be nil)
133+
(expect (local-variable-p 'cider-ns-state--loaded) :to-be nil))))
134+
135+
(provide 'cider-ns-state-tests)
136+
137+
;;; cider-ns-state-tests.el ends here

0 commit comments

Comments
 (0)