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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
### Changes

- [#84](https://github.com/nrepl/neorepl/pull/84): Locations in text, as `where`, `refs`, `deps` and `check` print them, are relative to the directory neorepl runs in, when they're in it.
- [#85](https://github.com/nrepl/neorepl/pull/85): The profile summary fits in a terminal, without the columns only CIDER's inspector needs.
- [#85](https://github.com/nrepl/neorepl/pull/85): `neorepl profile --format edn` and `json` give the summary as a list of maps, one for each function, rather than the text table.

### Bug fixes

Expand Down
4 changes: 2 additions & 2 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ Runtime deps: `org.clojure/clojure`, `nrepl/nrepl`, `org.jline/jline` 4.x. That'
- Gotcha: nREPL never drops a session's forwarding, not even once the session's closed ([nrepl#513](https://github.com/nrepl/nrepl/issues/513)), so every later write goes to a connection that's gone, which fails without a word, and the request it keeps holds on to the session, with its `*1` and `*e`. So the REPL asks in a session of its own, which never evaluates anything, and closes it when it ends. And a client in the server's process gets sent what it prints, and prints it again, forever, so `forward-system-output` doesn't ask a server in neorepl's process, nor one neorepl started, whose output the REPL shows already.
- Taps (2026-11-03, cider-nrepl 0.64.0). `cider/tap-subscribe` answers with the subscription's id and `done`, then sends a response for each value tapped, on that message's id, with the status `cider/tap-value` and a `:cider/tap-value`: the value printed short, 8 items, 4 levels and 200 characters at most, as the `:summary`, its `:type`, its `:count` when it's counted, and an `:idx`. `cider/tap-inspect`, with that `:idx` as a string, starts the session's inspector on the value, which cider-nrepl keeps for the last 100 tapped, and says `tap-value-not-retained` for older ones. A subscription from a ClojureScript session polls the runtime's taps instead, which come without an `:idx`, so the REPL subscribes outside its session, for Clojure's. bb 1.13.229 has none of the tap ops. cider-nrepl keeps those 100 values in the server's memory for as long as it runs, subscribed to or not, and once for each subscription, so with CIDER connected too it's the last 50 ([cider-nrepl#1076](https://github.com/clojure-emacs/cider-nrepl/issues/1076)).
- Gotcha: cider-nrepl 0.64.0 takes any exception sending a tap for the client being gone, printing the value's summary included, so `(tap> (map #(/ 1 %) [0]))` ends the subscription without a word, and the REPL shows no more taps ([cider-nrepl#1075](https://github.com/clojure-emacs/cider-nrepl/issues/1075)).
- Profiling (2026-11-03, cider-nrepl 0.64.0, orchard 0.48.0). `cider/profile-toggle-var` takes the `:ns` and `:sym` and answers with the `:value` `profiled` or `unprofiled`, or the status `profile-invalid-var` for a macro or a value that can't be called, `profile-no-such-var`, or `cider/profile-toggle-var-error` when the namespace isn't there. It doesn't name the var, so neorepl asks `info` first, as for the namespaces it won't trace. `cider/profile-toggle-ns` answers the same way, for every function in the namespace. Both only toggle, as the trace ops do, and there's no op saying what's profiled, so asking for what's so already toggles twice ([cider-nrepl#1073](https://github.com/clojure-emacs/cider-nrepl/issues/1073)). `cider/profile-summary` puts a table in the session's inspector, a row a function, 32 to a page unless the request says otherwise, and answers with its view, so neorepl asks outside any session, with every row on one page, and keeps the table. `cider/profile-clear` forgets what was measured. Before 0.55 the ops had other names, `toggle-profile` among them.
- Profiling (2026-11-03, cider-nrepl 0.64.0, orchard 0.48.0). `cider/profile-toggle-var` takes the `:ns` and `:sym` and answers with the `:value` `profiled` or `unprofiled`, or the status `profile-invalid-var` for a macro or a value that can't be called, `profile-no-such-var`, or `cider/profile-toggle-var-error` when the namespace isn't there. It doesn't name the var, so neorepl asks `info` first, as for the namespaces it won't trace. `cider/profile-toggle-ns` answers the same way, for every function in the namespace. Both only toggle, as the trace ops do, and there's no op saying what's profiled, so asking for what's so already toggles twice ([cider-nrepl#1073](https://github.com/clojure-emacs/cider-nrepl/issues/1073)). `cider/profile-summary` puts a table in the session's inspector, a row a function, 32 to a page unless the request says otherwise, and answers with its view, so neorepl asks outside any session, with every row on one page. The table is orchard's table view, which cider-nrepl sets with `:max-coll-size 1` over whatever the request says, so CIDER's users can go into the samples: a heading line of the keys, `#`, `:name`, `:n`, `:mean`, `:std`, `:sum`, `:min`, `:max`, `:med` and `:samples`, then a line a function, its number, the var as `#'ns/name`, and orchard's times, like `4 us`, every cell a value to inspect between bars (2026-11-06, orchard 0.48.0). That's about 130 columns, so neorepl lays the cells out in columns of its own, without the number and the samples, which read like `[41 ...]` there, the keys without colons and the names without `#'`, and in edn and JSON as a map a function, the times as orchard writes them and `:n` as a number. cider-nrepl has shown the table that way since 0.55, the first with these ops. orchard cuts a cell at 70 characters, a long var's name too, and cider-nrepl doesn't let a request say otherwise. `cider/profile-clear` forgets what was measured. Before 0.55 the ops had other names, `toggle-profile` among them.
- Gotcha: cider-nrepl 0.64.0's `cider/profile-summary` fails when no profiled function has been called, since the inspector's table view doesn't take an empty list: `cider/profile-summary-error`, with `view-mode-supported?` in the precondition on `:err` ([cider-nrepl#1072](https://github.com/clojure-emacs/cider-nrepl/issues/1072)). neorepl takes that for nothing to show.
- Gotcha: orchard leaves `clojure.core` alone, but `cider/profile-toggle-ns` answers `profiled` for it anyway, each time.
- Gotcha: orchard profiles a var whose value can be called, which a map, a keyword and a multimethod can, so it'd hold a function from then on, and `(:port config)` would answer nil ([orchard#445](https://github.com/clojure-emacs/orchard/issues/445)). neorepl checks it's a function first. Namespaces are safe, orchard taking only their functions.
Expand Down Expand Up @@ -339,7 +339,7 @@ Upstream, alongside 0.4. The 0.1 announcement promised the first three.
- An eldoc line under the input, in place of the tip after the cursor: JLine's `post`, which rebel-readline and bb's console REPL use, and which bb 1.13.229 reaches. The arglists of the call the cursor's in, the argument at the cursor in bold, past the last one the `& rest` it goes into, and the arities that can't take that many arguments faint, with special forms and macros matched by their shape, so `defn`'s docstring and attr-map line up. The lookup moves off the highlighter onto a thread of its own, which takes away the wait of up to 500ms typing has now while a lookup's out, and the line gets cut to the terminal's width, the namespace going first, never wrapping. JLine clears `post` before its last redisplay, so nothing stays in the scrollback, and JLine's suggestion, freed from eldoc, is there for history suggestions later. A status area with the doc's first sentence too takes `Status`, which bb has only from 1.13.227, the oldest supported since 2026-11-03. Done (2026-11-01, JLine 4.4.7, bb 1.13.225 and 1.13.229): eldoc goes under the input on JLine's `post`, set through reflection on both runtimes, wherever the cursor is, with the parameter the argument at the cursor goes into in bold, the arglists that can't take it faint, and macros' and special forms' arguments matched by their shape, so `defn`'s `doc-string?` and `attr-map?` line up. It's cut to the terminal's width, without the namespace first, and looked up in the background, redrawn through JLine's `redisplay` widget when the lookup comes back. Past `features-max-buffer-size`, without the highlighter, the line keeps the last one shown, see the gotchas.
- Taps in the REPL, which CIDER shows since cider-nrepl 0.64.0. Done (2026-11-03): each value tapped shows up above the prompt, and `,tap` inspects the last one. Clojure's only, for now: ClojureScript's take a subscription in a ClojureScript session, and cider-nrepl can't inspect them.
- What the server's own threads print, like a web server's handlers, in the REPL, which CIDER gets from nREPL's `forward-system-output`, or cider-nrepl's `out-subscribe` before nREPL 1.5. Done (2026-11-03): the REPL asks for the former, except from a server it started, whose output it shows already, see the gotcha above.
- Profiling, as CIDER's `cider-profile-*` commands do: `neorepl profile` and `unprofile`, and `,profile`, `,unprofile` and `,profile-clear` in the REPL. Done (2026-11-03): on cider-nrepl's profile ops, with the summary as the table cider-nrepl's inspector shows, see the gotchas above.
- Profiling, as CIDER's `cider-profile-*` commands do: `neorepl profile` and `unprofile`, and `,profile`, `,unprofile` and `,profile-clear` in the REPL. Done (2026-11-03): on cider-nrepl's profile ops, with the summary from the table cider-nrepl's inspector shows, in columns without the ones only the inspector needs (2026-11-06), see the gotchas above.
- Settings, and keys that toggle them, as rebel-readline's `:repl/toggle-*` commands do: a config file, `$XDG_CONFIG_HOME/neorepl/config.edn`, with what they start as, `,set` to list them and change them, and widgets the inputrc can bind. Done (2026-11-03): `eldoc`, `color`, `quiet`, `server-output`, `taps` and `autoreload`, each on or off, in one atom the line editor and the REPL loop read as they go. C-x C-h toggles eldoc and C-x C-q quiet, and every setting but autoreload, whose turning on reloads, has a `neorepl-toggle-` widget. The file's mistakes are warnings, and `NO_COLOR` wins over it. Turning `server-output` off only leaves out what comes, since nREPL can't stop forwarding ([nrepl#513](https://github.com/nrepl/nrepl/issues/513)), and the REPL asks for it only once it's on. Then (2026-11-04) `pretty`, on C-x C-p, and `print-length` and `print-level`, a number or off, which go to nREPL's printers as their options, `nrepl.util.print/pprint`'s or `pr`'s; off leaves the session's own vars. Still to come: a theme for the colors.

0.6, distribution and Windows
Expand Down
15 changes: 10 additions & 5 deletions integration/neorepl/real_servers_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,16 @@
(is (= {:status 1 :out "" :err "neorepl: No profiled function has been called yet\n"}
(run :clojure "profile"))))
(run :clojure "eval" "(demo.core/greet \"p\")")
(let [[header separator row :as lines] (str/split-lines (:out (run :clojure "profile")))]
(is (= 3 (count lines)))
(is (re-find #"^\| # \| +:name \| :n \| :mean \|" header))
(is (str/starts-with? separator "|---+"))
(is (re-find #"^\| 0 \| #'demo\.core/greet \| 1 \| " row)))
(let [[header row :as lines] (str/split-lines (:out (run :clojure "profile")))]
(is (= 2 (count lines)))
(is (re-find #"^name +n +mean +std +sum +min +max +med$" header))
(is (re-find #"^demo\.core/greet +1 +\d+([.,]\d)? [num]?s +\u00b1" row)))
(testing "and as data, in JSON"
(let [[{:keys [name n mean] :as row} :as rows] (:summary (json/read-str (:out (run :clojure "profile" "--format" "json"))))]
(is (= 1 (count rows)))
(is (= ["demo.core/greet" 1] [name n]))
(is (re-find #"^\d+([.,]\d)? [num]?s$" mean))
(is (= #{:name :n :mean :std :sum :min :max :med} (set (keys row))))))
(is (= "Stopped profiling demo.core/greet\n" (:out (run :clojure "unprofile" "demo.core/greet"))))
(finally
(run :clojure "unprofile" "demo.core/greet")
Expand Down
42 changes: 32 additions & 10 deletions src/neorepl/features.clj
Original file line number Diff line number Diff line change
Expand Up @@ -1438,19 +1438,41 @@
(check-profiler client)
(mapv #(profile-one client % ns on? opts) names)))

(defn- summary-rows
"The rows of the table in lines, an inspector view's, as profile-summary
has them, or nil when it has none."
[lines]
(let [heading? #(:heading? (first %))
contents (->> lines (drop-while (complement heading?)) rest (take-while (complement heading?)))
;; Each cell is a value to inspect, the bars around them aren't.
[header & rows] (keep #(not-empty (keep (fn [{:keys [text index]}] (when index text)) %)) contents)]
(when rows
(mapv (fn [row]
(apply array-map (mapcat (fn [column cell]
(case column
("#" ":samples") nil
":name" [:name (var-name cell)]
":n" [:n (cond-> cell (re-matches #"\d+" cell) Long/parseLong)]
[(keyword (str/replace column #"^:" "")) cell]))
header row)))
rows))))

(defn profile-summary
"What profiling measured, the :lines of cider-nrepl's inspector showing
it, a table with a row for each function, all on a page, or nil when no
function profiled has been called. It asks outside any session, since it
takes over the inspector of the session it's asked in."
"What profiling measured, all on a page: a map for each function from
the table cider-nrepl's inspector shows, in its columns' order, with :n
a number and the times as orchard writes them, like \"4 us\", without
the row numbers and samples. orchard cuts a :name past 70 characters.
nil when no profiled function has been called. It asks outside any
session, since it takes over that session's inspector."
[client opts]
(check-profiler client)
(try
(:lines (inspector client nil {:op "cider/profile-summary" :page-size Integer/MAX_VALUE} opts))
(catch clojure.lang.ExceptionInfo e
;; cider-nrepl 0.64.0 can't show an empty table (cider-nrepl#1072).
(when-not (str/includes? (str (ex-message e)) "view-mode-supported?")
(throw e)))))
(summary-rows
(try
(:lines (inspector client nil {:op "cider/profile-summary" :page-size Integer/MAX_VALUE} opts))
(catch clojure.lang.ExceptionInfo e
;; cider-nrepl 0.64.0 can't show an empty table (cider-nrepl#1072).
(when-not (str/includes? (str (ex-message e)) "view-mode-supported?")
(throw e))))))

(defn clear-profile
"Forgets what profiling measured so far."
Expand Down
2 changes: 1 addition & 1 deletion src/neorepl/lookups.clj
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@
:find (fn [client ctx syms]
(if (seq syms)
{:results (features/profile client syms ctx true)}
(some->> (features/profile-summary client {}) render/profile-summary-text (hash-map :summary))))
(some->> (features/profile-summary client {}) (hash-map :summary))))
:text render/profiling-text
:none (fn [_ _] "No profiled function has been called yet")
:failed? untraceable?}
Expand Down
6 changes: 4 additions & 2 deletions src/neorepl/main.clj
Original file line number Diff line number Diff line change
Expand Up @@ -891,8 +891,10 @@ server to talk to."
"Usage: neorepl profile [options] [SYM...]"
"Profiles the functions SYM, or every function in a namespace SYM, timing
each call. Without SYM it shows what's been measured: how many calls each
function had and how long they took, from the shortest to the longest. It
takes cider-nrepl on the server."
function had and how long they took, from the shortest to the longest. In
edn and JSON that's a list of maps, one for each function, with its :name,
:n calls and the times as the server writes them, like \"4 us\". It takes
cider-nrepl on the server."
profile-options
discovery-note
"Exit status: 0 when everything SYM names is profiled, or there's something
Expand Down
16 changes: 7 additions & 9 deletions src/neorepl/render.clj
Original file line number Diff line number Diff line change
Expand Up @@ -342,14 +342,12 @@
(cond-> help key (str " (C-x C-" (str/lower-case (str key)) ")"))]))))

(defn profile-summary-text
"The table in the inspector's view of what profiling measured, as
features/profile-summary has it: its Contents section, without the rest."
[lines]
(let [heading? #(:heading? (first %))
table (->> lines (drop-while (complement heading?)) rest (take-while (complement heading?)))]
(-> (inspector-text table false)
(str/replace #"(?m)^ " "")
str/trim)))
"What profiling measured, the rows of features/profile-summary, in
columns under their names."
[rows]
(let [columns (keys (first rows))]
(str/join "\n" (cli/columns (cons (map name columns)
(for [row rows] (map #(str (get row %)) columns)))))))

(defn profiling-text
"What profile and unprofile did: the :results of profiling or stopping, a
Expand All @@ -367,7 +365,7 @@
"refused" (str "Won't profile " what ", which the server's own tools call: profiling it"
" would take the server down")
"not-found" (str "No var or namespace called " name))))
summary summary
summary (profile-summary-text summary)
cleared "Forgot what profiling measured"))

(defn trace-event-text
Expand Down
9 changes: 8 additions & 1 deletion test/neorepl/features_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -1091,8 +1091,15 @@
(testing "what's measured, once something's called"
(is (nil? (features/profile-summary client {})))
(nrepl/request client {:op "eval" :code "(profile.me/g 1) (profile.me/g 2)"})
(is (= "0. {:name \"profile.me/f\", :n 2}"
(is (= [{:name "profile.me/f" :n 2 :mean "1 us" :std "\u00b10 ns" :sum "2 us" :min "1 us" :max "1 us" :med "1 us"}]
(features/profile-summary client {})))
(is (= (str "name n mean std sum min max med\n"
"profile.me/f 2 1 us \u00b10 ns 2 us 1 us 1 us 1 us")
(render/profile-summary-text (features/profile-summary client {}))))
(testing "and nothing, from a table with no rows, as a cider-nrepl that can show one would"
(is (nil? (#'features/summary-rows [[{:text "--- Contents:" :heading? true}]
[{:text "| "} {:text "#" :index 1} {:text " | "}]
[{:text "|---|"}]]))))
(testing "and forgetting it"
(is (= {:cleared true} (features/clear-profile client)))
(is (nil? (features/profile-summary client {})))))
Expand Down
8 changes: 6 additions & 2 deletions test/neorepl/main_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -709,8 +709,12 @@
(testing "what's measured"
(is (= {:status 1 :out "" :err "neorepl: No profiled function has been called yet\n"} (run-command "profile")))
(run "-s" "profiling" "(profiled.cli/f 1)")
(is (= {:status 0 :out "0. {:name \"profiled.cli/f\", :n 1}\n" :err ""} (run-command "profile")))
(is (= {:summary "0. {:name \"profiled.cli/f\", :n 1}"} (edn/read-string (:out (run-command "profile" "--format" "edn"))))))
(let [summary (str "name n mean std sum min max med\n"
"profiled.cli/f 1 1 us \u00b10 ns 1 us 1 us 1 us 1 us")]
(is (= {:status 0 :out (str summary "\n") :err ""} (run-command "profile")))
(let [summary {:summary [{:name "profiled.cli/f" :n 1 :mean "1 us" :std "\u00b10 ns" :sum "1 us" :min "1 us" :max "1 us" :med "1 us"}]}]
(is (= summary (edn/read-string (:out (run-command "profile" "--format" "edn")))))
(is (= summary (json/read-str (:out (run-command "profile" "--format" "json"))))))))
(testing "forgetting it"
(is (= {:status 0 :out "Forgot what profiling measured\n" :err ""} (run-command "profile" "--clear")))
(is (= 1 (:status (run-command "profile"))))
Expand Down
7 changes: 7 additions & 0 deletions test/neorepl/render_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -248,3 +248,10 @@
59999 " 59.9s"
60000 " 1m0s"
125400 " 2m5s")))

(deftest profile-summary-text-test
(is (= (str "name n mean\n"
"profile.me/f 2 1 us\n"
"other/g 10 2 ms")
(render/profile-summary-text [(array-map :name "profile.me/f" :n 2 :mean "1 us")
(array-map :name "other/g" :n 10 :mean "2 ms")]))))
Loading
Loading