From 6f673511b90a81dc21c9e3ceadc0407f3ff2dfa6 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Fri, 6 Nov 2026 11:49:45 +0200 Subject: [PATCH 1/2] Lay the profile summary out in columns of its own cider-nrepl shows the summary in orchard's table view, which CIDER's inspector can go into: a column with each row's number, one with a few of the samples, :name and the like for headings and the vars as neorepl now takes the cells from the view and lays them out as its other tables are, without the number and the samples, with headings and names written as they are elsewhere. Every cider-nrepl since 0.55, the first with these ops, shows the summary that way. --- CHANGELOG.md | 1 + PLAN.md | 4 +-- integration/neorepl/real_servers_test.clj | 9 +++---- src/neorepl/render.clj | 16 ++++++++---- test/neorepl/features_test.clj | 3 ++- test/neorepl/main_test.clj | 6 +++-- test/neorepl/render_test.clj | 6 +++++ test/neorepl/repl_test.clj | 2 +- test/neorepl/test_server.clj | 31 ++++++++++++++++++----- 9 files changed, 56 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a012525..82de7f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### 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. ### Bug fixes diff --git a/PLAN.md b/PLAN.md index 2bf3c05..3751d4e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 `#'`. 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. @@ -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 diff --git a/integration/neorepl/real_servers_test.clj b/integration/neorepl/real_servers_test.clj index 468e639..7f77da9 100644 --- a/integration/neorepl/real_servers_test.clj +++ b/integration/neorepl/real_servers_test.clj @@ -150,11 +150,10 @@ (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))) (is (= "Stopped profiling demo.core/greet\n" (:out (run :clojure "unprofile" "demo.core/greet")))) (finally (run :clojure "unprofile" "demo.core/greet") diff --git a/src/neorepl/render.clj b/src/neorepl/render.clj index 81770cc..860a016 100644 --- a/src/neorepl/render.clj +++ b/src/neorepl/render.clj @@ -343,13 +343,19 @@ (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." + features/profile-summary has it, its Contents section, in columns, + without the row numbers and the samples, which are for the inspector, + and with n for :n and names without #', or nil when it has no rows." [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))) + 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 :as table] (keep #(not-empty (keep (fn [{:keys [text index]}] (when index text)) %)) contents)] + (when rows + (str/join "\n" (cli/columns (for [row table] + (for [[column cell] (map vector header row) + :when (not (#{"#" ":samples"} column))] + (str/replace cell #"^(:|#')" "")))))))) (defn profiling-text "What profile and unprofile did: the :results of profiling or stopping, a diff --git a/test/neorepl/features_test.clj b/test/neorepl/features_test.clj index 28e91ba..d54301a 100644 --- a/test/neorepl/features_test.clj +++ b/test/neorepl/features_test.clj @@ -1091,7 +1091,8 @@ (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 (= (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 forgetting it" (is (= {:cleared true} (features/clear-profile client))) diff --git a/test/neorepl/main_test.clj b/test/neorepl/main_test.clj index b883aab..7163e8c 100644 --- a/test/neorepl/main_test.clj +++ b/test/neorepl/main_test.clj @@ -709,8 +709,10 @@ (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"))) + (is (= {:summary summary} (edn/read-string (:out (run-command "profile" "--format" "edn"))))))) (testing "forgetting it" (is (= {:status 0 :out "Forgot what profiling measured\n" :err ""} (run-command "profile" "--clear"))) (is (= 1 (:status (run-command "profile")))) diff --git a/test/neorepl/render_test.clj b/test/neorepl/render_test.clj index 15bd37d..258b753 100644 --- a/test/neorepl/render_test.clj +++ b/test/neorepl/render_test.clj @@ -248,3 +248,9 @@ 59999 " 59.9s" 60000 " 1m0s" 125400 " 2m5s"))) + +(deftest profile-summary-text-test + (testing "nothing, for a table with no rows, as cider-nrepl could show one" + (is (nil? (render/profile-summary-text [[{:text "--- Contents:" :heading? true}] + [{:text "| "} {:text "#" :index 1} {:text " | "} {:text ":name" :index 2} {:text " | "}] + [{:text "|---+---|"}]]))))) diff --git a/test/neorepl/repl_test.clj b/test/neorepl/repl_test.clj index 4ba8052..44e0015 100644 --- a/test/neorepl/repl_test.clj +++ b/test/neorepl/repl_test.clj @@ -1061,7 +1061,7 @@ (type "(profiled-in-repl 1)\r") (is (screen #"(?m)^2\nuser=> $")) (type ",profile\r") - (is (screen #"0\. \{:name \"user/profiled-in-repl\", :n 1\}\nuser=> $")) + (is (screen #"\nname +n +mean +std +sum +min +max +med\nuser/profiled-in-repl +1 +1 us +\u00b10 ns( +1 us){4}\nuser=> $")) (type ",profile-clear\r") (is (screen #"Forgot what profiling measured\nuser=> $")) (type ",unprofile\r") diff --git a/test/neorepl/test_server.clj b/test/neorepl/test_server.clj index 7834e7b..14603e9 100644 --- a/test/neorepl/test_server.clj +++ b/test/neorepl/test_server.clj @@ -356,14 +356,30 @@ (apply f args)))) "profiled"))) +(defn- table-view + "How cider-nrepl's inspector shows rows, maps with the same keys in the + same order, in its table view, as cider/profile-summary has it: a line + of the keys, a divider and a line of each row's values, each line with + the row's number first, every cell a value to inspect, between bars." + [rows] + (let [index (atom -1) + value #(list :value % (swap! index inc)) + line #(concat [" " "| "] (interpose " | " (map value %)) [" | " '(:newline)])] + (pr-str (concat ["Class: " (value "clojure.lang.ArraySeq") '(:newline) + "Count: " (str (count rows)) '(:newline) '(:newline) "--- Contents:" '(:newline) '(:newline)] + (line (cons "#" (map pr-str (keys (first rows))))) + [" " "|---+---|" '(:newline)] + (mapcat #(line (cons (str %1) (vals %2))) (range) rows) + ['(:newline) "--- View mode:" '(:newline) " " "normal ●table object pretty sort-maps"])))) + (def ^:private profile-ops #{"cider/profile-toggle-var" "cider/profile-toggle-ns" "cider/profile-summary" "cider/profile-clear"}) (defn wrap-fake-profiler "Profiles functions the way cider-nrepl does, roughly, with its profile - ops, counting calls rather than timing them. Its summary is a list of - maps on show in the session's inspector, which fails when it's empty, as - cider-nrepl 0.64.0's does." + ops, counting calls rather than timing them. Its summary is the table + cider-nrepl 0.64.0's inspector shows, with the columns it has and made + up times, and fails when it's empty, as that one does." [handler] (fn [{:keys [op ns sym] :as msg}] (case op @@ -389,9 +405,12 @@ :status ["done" "cider/profile-toggle-ns-error"])) "cider/profile-summary" (if-let [calls (seq @profiled-calls)] - (do (swap! inspected assoc (:session msg) [(vec (for [[v n] (sort-by (comp str key) calls)] - {:name (str (symbol v)) :n n}))]) - (show msg)) + (let [rows (for [[v n] (sort-by (comp str key) calls)] + (array-map :name (str "#'" (symbol v)) :n (str n) :mean "1 us" :std "±0 ns" + :sum (str n " us") :min "1 us" :max "1 us" :med "1 us" :samples "[1000 ...]"))] + ;; In the session's inspector, as cider-nrepl's is. + (swap! inspected assoc (:session msg) [(vec rows)]) + (transport/respond-to msg :status "done" :value (table-view rows))) (transport/respond-to msg :ex "class clojure.lang.ExceptionInfo" :err "clojure.lang.ExceptionInfo: Precondition failed: (view-mode-supported? inspector mode)\n" :status ["done" "cider/profile-summary-error"])) From 409a6d67d1aff7a2410aa67079e37bbd05db2826 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Fri, 6 Nov 2026 12:26:27 +0200 Subject: [PATCH 2/2] Give the profile summary as data in edn and JSON neorepl profile --format edn or json had the summary as the text table, which a script or an agent has to split back into columns. features/profile-summary now takes the rows out of cider-nrepl's table, a map for each function, with :n as a number and the times as orchard writes them, like "4 us", and render only lays them out. orchard cuts a cell at 70 characters, a long var's name too, which the data can't undo. --- CHANGELOG.md | 1 + PLAN.md | 2 +- integration/neorepl/real_servers_test.clj | 6 ++++ src/neorepl/features.clj | 42 +++++++++++++++++------ src/neorepl/lookups.clj | 2 +- src/neorepl/main.clj | 6 ++-- src/neorepl/render.clj | 22 ++++-------- test/neorepl/features_test.clj | 6 ++++ test/neorepl/main_test.clj | 4 ++- test/neorepl/render_test.clj | 9 ++--- 10 files changed, 66 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82de7f8..6df287e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - [#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 diff --git a/PLAN.md b/PLAN.md index 3751d4e..a39fc33 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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. 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 `#'`. 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. +- 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. diff --git a/integration/neorepl/real_servers_test.clj b/integration/neorepl/real_servers_test.clj index 7f77da9..3ab260a 100644 --- a/integration/neorepl/real_servers_test.clj +++ b/integration/neorepl/real_servers_test.clj @@ -154,6 +154,12 @@ (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") diff --git a/src/neorepl/features.clj b/src/neorepl/features.clj index 321fc08..7232b90 100644 --- a/src/neorepl/features.clj +++ b/src/neorepl/features.clj @@ -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." diff --git a/src/neorepl/lookups.clj b/src/neorepl/lookups.clj index 5fe520d..c061f9b 100644 --- a/src/neorepl/lookups.clj +++ b/src/neorepl/lookups.clj @@ -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?} diff --git a/src/neorepl/main.clj b/src/neorepl/main.clj index b258e59..325ee69 100644 --- a/src/neorepl/main.clj +++ b/src/neorepl/main.clj @@ -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 diff --git a/src/neorepl/render.clj b/src/neorepl/render.clj index 860a016..2a0a216 100644 --- a/src/neorepl/render.clj +++ b/src/neorepl/render.clj @@ -342,20 +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, in columns, - without the row numbers and the samples, which are for the inspector, - and with n for :n and names without #', or nil when it has no rows." - [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 :as table] (keep #(not-empty (keep (fn [{:keys [text index]}] (when index text)) %)) contents)] - (when rows - (str/join "\n" (cli/columns (for [row table] - (for [[column cell] (map vector header row) - :when (not (#{"#" ":samples"} column))] - (str/replace cell #"^(:|#')" "")))))))) + "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 @@ -373,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 diff --git a/test/neorepl/features_test.clj b/test/neorepl/features_test.clj index d54301a..382a60b 100644 --- a/test/neorepl/features_test.clj +++ b/test/neorepl/features_test.clj @@ -1091,9 +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 (= [{: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 {}))))) diff --git a/test/neorepl/main_test.clj b/test/neorepl/main_test.clj index 7163e8c..46be1db 100644 --- a/test/neorepl/main_test.clj +++ b/test/neorepl/main_test.clj @@ -712,7 +712,9 @@ (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"))) - (is (= {:summary summary} (edn/read-string (:out (run-command "profile" "--format" "edn"))))))) + (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")))) diff --git a/test/neorepl/render_test.clj b/test/neorepl/render_test.clj index 258b753..528cc9c 100644 --- a/test/neorepl/render_test.clj +++ b/test/neorepl/render_test.clj @@ -250,7 +250,8 @@ 125400 " 2m5s"))) (deftest profile-summary-text-test - (testing "nothing, for a table with no rows, as cider-nrepl could show one" - (is (nil? (render/profile-summary-text [[{:text "--- Contents:" :heading? true}] - [{:text "| "} {:text "#" :index 1} {:text " | "} {:text ":name" :index 2} {:text " | "}] - [{:text "|---+---|"}]]))))) + (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")]))))