Skip to content
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

New namespace to produce pretty annotations on input lines #132

Merged
merged 6 commits into from
Dec 7, 2024
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
4 changes: 2 additions & 2 deletions .github/workflows/clojure.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ jobs:
uses: actions/checkout@v4

- name: Setup Java
uses: actions/setup-java@v4.2.1
uses: actions/setup-java@v4.5.0
with:
java-version: '11'
distribution: 'corretto'

- name: Install clojure tools
uses: DeLaGuardo/setup-clojure@12.5
uses: DeLaGuardo/setup-clojure@13.0
with:
cli: 1.11.2.1446

Expand Down
39 changes: 37 additions & 2 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,38 @@
## 3.3.0 -- UNRELEASED

The new `clj-commons.pretty.annotations` namespace provides functions to help create pretty errors
when parsing or interpretting text:

```text
SELECT DATE, AMT FROM PAYMENTS WHEN AMT > 10000
▲▲▲ ▲▲▲▲
│ │
│ └╴ Unknown token
└╴ Invalid column name
```

Here, the errors (called "annotations") are presented as callouts targetting specific portions of the input line.

The `callouts` function can handle multiple annotations on a single line, with precise control over styling and layout.

The `annotate-lines` function builds on `callouts` to produce output of multiple lines from some source,
interspersed with callouts:

```text
1: SELECT DATE, AMT
▲▲▲
└╴ Invalid column name
2: FROM PAYMENTS WHEN AMT > 10000
▲▲▲▲
└╴ Unknown token
```

The new `clj-commons.pretty.spec` namespace provides type and function specs for the `clj-commons.ansi` and
`clj-commons.pretty.annotations` namespaces.

## 3.2.0 - 20 Sep 2024

Added `clj-commons.ansi/pout` to replace the `pcompose` function; they are identical, but the `pout` name makes more
Expand All @@ -11,7 +46,7 @@ Added `clj-commons.format.exceptions/default-frame-rules` to supply defaults for
which makes it much easier to override the default rules.

Added function `clj-commons.format.exceptions/format-stack-trace-element` which can be used to convert a Java
StackTraceElement into demangled, readable string, using the same logic as `format-exception.`
StackTraceElement into a demangled, readable string, using the same logic as `format-exception.`

[Closed Issues](https://github.com/clj-commons/pretty/milestone/52?closed=1)

Expand Down Expand Up @@ -44,7 +79,7 @@ Other changes:
## 2.6.0 - 25 Apr 2024

- Font declaration in `compose` can now be a vector of individual terms, rather than a single keyword; e.g. `[:bold :red]`
rather than `:bold.red`.
as an alternative to `:bold.red`. This can be useful when the font is computed, rather than a static literal.

[Closed Issues](https://github.com/clj-commons/pretty/milestone/49?closed=1)

Expand Down
2 changes: 1 addition & 1 deletion deps.edn
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
{:jvm-opts ["-Dclj-commons.ansi.enabled=false"]}

:nrepl
{:extra-deps {nrepl/nrepl {:mvn/version "1.1.1"}}
{:extra-deps {nrepl/nrepl {:mvn/version "1.3.0"}}
:main-opts ["-m" "nrepl.cmdline" ]}

:repl
Expand Down
2 changes: 2 additions & 0 deletions src/clj_commons/ansi.clj
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
"Help with generating textual output that includes ANSI escape codes for formatting.
The [[compose]] function is the best starting point.

Specs for types and functions are in the [[spec]] namespace.

Reference: [ANSI Escape Codes @ Wikipedia](https://en.wikipedia.org/wiki/ANSI_escape_code#SGR)."
(:require [clojure.string :as str]
[clj-commons.pretty-impl :refer [csi padding]]))
Expand Down
220 changes: 220 additions & 0 deletions src/clj_commons/pretty/annotations.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
(ns clj-commons.pretty.annotations
"Tools to annotate a line of source code, in the form of callouts (lines and arrows) connected to a message.

SELECT DATE, AMT FROM PAYMENTS WHEN AMT > 10000
▲▲▲ ▲▲▲▲
│ │
│ └╴ Unknown token
└╴ Invalid column name

This kind of output is common with various kinds of parsers or interpreters.

Specs for types and functions are in the [[spec]] namespace."
{:added "3.3.0"})

(def default-style
"The default style used when generating callouts.

Key | Default | Description
--- |--- |---
:font | :yellow | Default font characteristics if not overrided by annotation
:spacing | :tall | One of :tall, :compact, or :minimal
:marker | \"▲\" | The marker character used to identify the offset/length of an annotation
:bar | \"│\" | Character used as the vertical bar in the callout
:nib | \"└╴ \" | String used just before the annotation's message

When :spacing is :minimal, only the lines with markers or error messages appear
(the lines with just vertical bars are omitted). :compact spacing is the same, but
one line of bars appears between the markers and the first annotation message.

Note: rendering of Unicode characters in HTML often uses incorrect fonts or adds unwanted
character spacing; the annotations look proper in console output."
{:font :yellow
:spacing :tall
:marker "▲"
:bar "│"
:nib "└╴ "})

(def ^:dynamic *default-style*
"The default style used when no style is provided; some applications may bind or
override this."
default-style)

(defn- nchars
[n ch]
(apply str (repeat n ch)))

(defn- markers
[style annotations]
(let [{:keys [font marker]} style]
(loop [output-offset 0
annotations annotations
result [font]]
(if-not annotations
result
(let [{:keys [offset length font]
:or {length 1}} (first annotations)
spaces-needed (- offset output-offset)
result' (conj result
(nchars spaces-needed \space)
[font (nchars length marker)])]
(recur (+ offset length)
(next annotations)
result'))))))

(defn- bars
[style annotations]
(let [{:keys [font bar]} style]
(loop [output-offset 0
annotations annotations
result [font]]
(if-not annotations
result
(let [{:keys [offset font]} (first annotations)
spaces-needed (- offset output-offset)
result' (conj result
(nchars spaces-needed \space)
[font bar])]
(recur (+ offset 1)
(next annotations)
result'))))))

(defn- bars+message
[style annotations]
(let [{:keys [font bar nib]} style]
(loop [output-offset 0
[annotation & more-annotations] annotations
result [font]]
(let [{:keys [offset font message]} annotation
spaces-needed (- offset output-offset)
last? (not (seq more-annotations))
result' (conj result
(nchars spaces-needed \space)
[font
(if last?
nib
bar)
(when last?
message)])]
(if last?
result'
(recur (+ offset 1)
more-annotations
result'))))))

(defn callouts
"Creates callouts (the marks, bars, and messages from the example) from annotations.

Each annotation is a map:

Key | Description
--- |---
:message | Composed string of the message to present
:offset | Integer position (from 0) to mark on the line
:length | Number of characters in the marker (min 1, defaults to 1)
:font | Override of the style's font; used for marker, bars, nib, and message

The leftmost column has offset 0; some frameworks may report this as column 1
and an adjustment is necessary before invoking callouts.

At least one annotation is required; they will be sorted into an appropriate order.
Annotation's ranges should not overlap.

The messages should be relatively short, and not contain any line breaks.

Returns a sequence of composed strings, one for each line of output.

The calling code is responsible for any output; even the line being annotated;
this might look something like:

(ansi/perr source-line)
(run! ansi/perr (annotations/annotate annotations))

Uses the style defined by [[*default-style*]] if no style is provided."
([annotations]
(callouts *default-style* annotations))
([style annotations]
;; TODO: Check for overlaps
(let [expanded (sort-by :offset annotations)
{:keys [spacing]} style
marker-line (markers style expanded)]
(loop [annotations expanded
first? true
result [marker-line]]
(let [include-bars? (or (= spacing :tall)
(and first? (= spacing :compact)))
result' (conj result
(when include-bars?
(bars style annotations))
(bars+message style annotations))
annotations' (butlast annotations)]
(if (seq annotations')
(recur annotations' false result')
(remove nil? result')))))))

(defn annotate-lines
"Intersperses numbered lines with callouts to form a new sequence
of composable strings where input lines are numbered, and
callout lines are indented beneath the input lines.

Example:

```
1: SELECT DATE, AMT
▲▲▲
└╴ Invalid column name
2: FROM PAYMENTS WHEN AMT > 10000
▲▲▲▲
└╴ Unknown token
```
Each line is a map:

Key | Value
--- |---
:line | Composed string for a single line of input (usually, just a string)
:annotations | Optional, a seq of annotation maps (used to create the callouts)

Option keys are all optional:

Key | Value
--- |---
:style | style map (for callouts), defaults to [*default-style*]
:start-line | Defaults to 1
:line-number-width | Width for the line numbers column

The :line-number-width option is usually computed from the maximum line number
that will be output.

Returns a seq of composed strings."
([lines]
(annotate-lines nil lines))
([opts lines]
(let [{:keys [style start-line]
:or {style *default-style*
start-line 1}} opts
max-line-number (+ start-line (count lines) -1)
;; inc by one to account for the ':'
line-number-width (inc (or (:line-number-width opts)
(-> max-line-number str count)))
callout-indent (repeat (nchars (inc line-number-width) " "))]
(loop [[line-data & more-lines] lines
line-number start-line
result []]
(if-not line-data
result
(let [{:keys [line annotations]} line-data
callout-lines (when (seq annotations)
(callouts style annotations))
result' (cond-> (conj result
(list
[{:width line-number-width}
line-number ":"]
" "
line))
callout-lines (into
(map list callout-indent callout-lines)))]
(recur more-lines (inc line-number) result')))))))

Loading
Loading