Skip to content

Identify repeating sequences of stack frames #150

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Aug 4, 2025
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 CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
* New column option :formatter will format a column value to a string or composed string
* New :row-deprecator option decorates all columns of a row
* Column widths may be calculated even from a composed string
* `clj-commons.format.exceptions`
* Previously, individual stack frames that repeated were identified; Pretty can now identify _sequences_ of repeating stack frames

## 3.5.0 -- 9 Jul 2025

Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,14 @@ Or, same thing, but with Pretty enabled:

The point is, you can scan down to see things in chronological order; the important parts are highlighted, the names are the same (or closer) to your source code, unnecessary details are omitted, and it's much easier to pick out other essential parts easily, such as file names and line numbers.

Pretty can even identify the parts of your stack traces that repeat (so common with Clojure code), and
omit the repetition:

![Repeats](docs/images/exception-repeat.png)

### Enabling Pretty

Pretty exceptions are enabled by invoking the fucntion `clj-commons.pretty.repl/install-pretty-exceptions`. This
Pretty exceptions are enabled by invoking the function `clj-commons.pretty.repl/install-pretty-exceptions`. This
redefines a number of Vars to replace the default implementations with prettier ones. This is something you could
set up in your `user.clj` namespace.

Expand Down
Binary file added docs/images/exception-repeat.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
49 changes: 21 additions & 28 deletions src/clj_commons/format/exceptions.clj
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
[clojure.set :as set]
[clojure.string :as str]
[clj-commons.ansi :refer [compose perr]]
[clj-commons.pretty-impl :refer [padding]])
[clj-commons.pretty-impl :refer [padding repetitions]])
(:refer-clojure :exclude [*print-level* *print-length*])
(:import (java.lang StringBuilder StackTraceElement)
(clojure.lang Compiler ExceptionInfo Named)
Expand Down Expand Up @@ -295,26 +295,6 @@
this-frame
rest))))))

(defn- is-repeat?
[left-frame right-frame]
(= (:id left-frame)
(:id right-frame)))

(defn- repeating-frame-reducer
[output-frames frame]
(let [output-count (count output-frames)
last-output-index (dec output-count)]
(cond
(zero? output-count)
(conj output-frames frame)

(is-repeat? (output-frames last-output-index) frame)
(update-in output-frames [last-output-index :repeats]
(fnil inc 1))

:else
(conj output-frames frame))))

(def ^:private stack-trace-warning
(delay
(perr
Expand Down Expand Up @@ -457,8 +437,7 @@
frame-limit (:frame-limit options)
elements' (->> elements
remove-direct-link-frames
(apply-frame-filter frame-filter)
(reduce repeating-frame-reducer []))]
(apply-frame-filter frame-filter))]
(if frame-limit
(take frame-limit elements')
elements'))))
Expand Down Expand Up @@ -557,7 +536,8 @@
;; Allow for the colon in frames w/ a line number (this assumes there's at least one)
max-file-width (inc (max-from rows #(-> % :file length)))
max-line-width (max-from rows #(-> % :line length))
f (fn [{:keys [name file line repeats]}]
*lines (volatile! (transient []))
format-single-frame (fn [{:keys [name file line]} repeat-count frame-index frame-count]
(list
[{:width max-name-width} name]
" "
Expand All @@ -566,10 +546,23 @@
(when line ":")
" "
[{:width max-line-width} line]
(when repeats
[(:source *fonts*)
(format " (repeats %,d times)" repeats)])))]
(interpose "\n" (map f rows))))
(when (> repeat-count 1)
(cond
(= frame-index 0)
(format " %s (repeats %,d times)"
(if (= 1 frame-count) "─" "┐")
repeat-count)

(= frame-index (dec frame-count))
" ┘"

:else
" │"))))]
(doseq [[repeat-count frames] (repetitions :id stack-trace)
[frame-index frame] (map-indexed vector frames)]
(vswap! *lines
conj! (format-single-frame frame repeat-count frame-index (count frames))))
(interpose "\n" (-> *lines deref persistent!))))

(defmulti exception-dispatch
"The pretty print dispatch function used when formatting exception output (specifically, when
Expand Down
45 changes: 45 additions & 0 deletions src/clj_commons/pretty_impl.clj
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,48 @@
"The control sequence initiator: `ESC [`"
"\u001b[")

(defn- matches-count
[i window-width v]
(let [[subs & more-subs] (partition window-width (subvec v i))]
#_(prn :i i :w window-width :subs subs)
(reduce (fn [c next-subs]
#_(prn :c c :next next-subs)
(if (= subs next-subs)
(inc c)
(reduced c)))
1
more-subs)))

(defn- find-subs
[i n v]
(let [remaining (- n i)
max-width (Math/floorDiv ^long remaining 2)]
;; The maximium length of a subsequence is half of the remaining values
(loop [window-width 1]
(if (> window-width max-width)
[1 (subvec v i (inc i))]
(let [c (matches-count i window-width v)]
(if (> c 1)
[c (subvec v i (+ i window-width))]
(recur (inc window-width))))))))

(defn repetitions
"Identifies repetitions in a finite collection. Returns a series of tuples of [count sub-seq].
Values from the coll are uniquely identified by k."
[k coll]
(let [id->val (reduce (fn [m v]
(assoc m (k v) v))
{}
coll)
v (mapv k coll)
n (count v)]
(loop [i 0
result (transient [])]
(if (>= i n)
(persistent! result)
(let [subs (find-subs i n v)
[^long sub-count sub-ids] subs
subs' [sub-count (mapv id->val sub-ids)]
total-matches (* sub-count (count sub-ids))]
(recur (+ i total-matches)
(conj! result subs')))))))
33 changes: 33 additions & 0 deletions test/clj_commons/pretty/impl_test.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
(ns clj-commons.pretty.impl-test
(:require [clojure.test :refer [deftest is]]
[clj-commons.pretty-impl :refer [repetitions]]))

(deftest repetitions-basic
(is (= [[1 [:d]]
[3 [:a :b :c]]
[2 [:f :g]]
[1 [:z]]]
(repetitions identity [:d :a :b :c :a :b :c :a :b :c :f :g :f :g :z]))))

(deftest repetitions-via-key-id
(let [a {:id 'a}
b {:id 'b}
c {:id 'c}]
(is (= [[2 [a b]]
[2 [c a b]]
[2 [c]]
[1 [b]]
[1 [a]]]
(repetitions :id [a b a b c a b c a b c c b a])))))

(deftest repetitions-when-empty
(is (= []
(repetitions :id []))))

(deftest repetitions-short-sequence
(is (= [[1 [:a]]
[1 [:b]]
[15 [:c]]
[1 [:d]]]
(repetitions identity
[:a :b :c :c :c :c :c :c :c :c :c :c :c :c :c :c :c :d]))))
21 changes: 21 additions & 0 deletions test/demo.clj
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,20 @@
(throw (RuntimeException. "Boom!"))
(countdown (dec n))))

(defn nested-interloper
[f arg]
(f arg))

(defn interloper
[f arg]
(nested-interloper f arg))

(defn countdown-alt
[n]
(if (zero? n)
(throw (RuntimeException. "Big Boom"))
(interloper countdown-alt (dec n))))

(defn test-failure
[]
(report {:type :error :expected nil :actual (make-ex-info)}))
Expand Down Expand Up @@ -114,6 +128,13 @@
(println "\nTesting reporting of repeats:")
(try (countdown 20)
(catch Throwable t (e/print-exception t)))

(println)

(try (countdown-alt 20)
(catch Throwable t (e/print-exception t)))


(println "\nBinary output:\n")
(-> (io/file "test/tiny-clojure.gif")
.toPath
Expand Down