clj-string-layout ships a command-line formatter that reads CSV, TSV, or
whitespace-separated data from stdin or a file and writes a formatted table
to stdout. The same entry point is exposed three ways: through the Clojure
CLI, through a Babashka task that shells out to the JVM, and through a
Babashka task that runs natively under SCI with no JVM startup at all.
clojure -M:cli -- [options] [file]
bb format -- [options] [file]
bb bb-format [options] [file]
Reads input from FILE, "-", or stdin. Writes a formatted table to stdout.
Exits 0 on success, 2 on argument or input error.
# CSV with a header row, rendered as Markdown
clojure -M:cli -- --from csv --to markdown --headers data.csv
# Tab-separated input, ASCII-grid output, via Babashka (no JVM)
bb bb-format --from tsv --to ascii-grid < data.tsv
# Whitespace-separated input, box-drawing output, expanded to 60 columns
bb bb-format --from whitespace --to box --headers --width 60 --fill < data.txt
# Pipe through a Unix tool, then back through the formatter
printf 'item,qty\npear,4\napple,12\nkiwi,8\n' \
| bb bb-format --from csv --to csv --headers \
| sort \
| bb bb-format --from csv --to box
| Option | Meaning |
|---|---|
--input FORMAT, --from FORMAT | Input format. One of csv, tsv, whitespace. Defaults to csv. |
--format FORMAT, --to FORMAT | Output format. Any value returned by clj-string-layout.table/formats. Defaults to plain. |
--headers | Treat the first input row as headers. |
--no-headers | Treat every input row as data (the default). |
--no-escape | Disable output-format escaping. Use this if the input is already escaped for the target format. |
--width N | Target total width. Only takes effect when paired with --fill on a generated format (plain, the markdown variants, the box variants, ascii-grid). |
--fill | Expand cell padding to consume --width. Has no effect without --width. |
-h, --help | Print built-in help and exit. |
--help is generated from the live cli/formats registry, so it always
reflects the formats currently available.
| Form | Behaviour |
|---|---|
clojure -M:cli -- data.csv | Read data.csv. |
clojure -M:cli -- - | Read stdin (the - is explicit). |
clojure -M:cli -- | No file given — read stdin. |
clojure -M:cli -- a.csv b.csv | Error — only one input file is supported. |
The same rules apply to bb format and bb bb-format.
--from csv)RFC 4180-style parser. Handles quoted fields (including separators and
doubled quotes inside quoted fields) and CR / LF / CRLF row
separators. The parser is intentionally lenient about text after a
closing quote, so imperfect CSV exports usually still load.
$ printf 'name,note\nalice,"a, b"\nbob,plain text\n' \
| bb bb-format --from csv --to box --headers
┌───────┬────────────┐
│ name │ note │
├───────┼────────────┤
│ alice │ a, b │
├───────┼────────────┤
│ bob │ plain text │
└───────┴────────────┘
Doubled quotes inside a quoted field unescape to a single ":
$ printf 'name,quote\nalice,"she said ""hi"""\nbob,plain\n' \
| bb bb-format --from csv --to box --headers
┌───────┬───────────────┐
│ name │ quote │
├───────┼───────────────┤
│ alice │ she said "hi" │
├───────┼───────────────┤
│ bob │ plain │
└───────┴───────────────┘
Embedded newlines inside quoted fields are preserved literally. That
matches the RFC 4180 spec for the parsing side, but visual formats like
:box and :ascii-grid have no way to lay out a multi-line cell — the
output will break mid-row when it hits the embedded \n. If your data
has newlines in cells, either preprocess them out (tr '\n' ' ') or
target a format whose escaper handles them: :markdown converts them
to <br>, :tsv and :org to visible escape sequences, :html keeps
them inside <td>.
--from tsv)Tab-separated values. One row per line. No quoting — every tab is a
separator. Use --no-escape if your data already contains backslash
escape sequences that match the TSV escaper's output.
$ printf 'name\tqty\tprice\napple\t12\t1.50\npear\t4\t2.00\n' \
| bb bb-format --from tsv --to markdown --headers
| name | qty | price |
|:----- |:--- |:----- |
| apple | 12 | 1.50 |
| pear | 4 | 2.00 |
--from whitespace)Splits each non-blank line on runs of whitespace. Trims leading and
trailing whitespace. Blank lines are skipped. Useful for reformatting
output from ps, df, ls -l, or similar.
$ printf ' name qty price\n apple 12 1.50\n pear 4 2.00\n' \
| bb bb-format --from whitespace --to ascii-grid --headers
+-------+-----+-------+
| name | qty | price |
+-------+-----+-------+
| apple | 12 | 1.50 |
+-------+-----+-------+
| pear | 4 | 2.00 |
+-------+-----+-------+
The CLI accepts every keyword in clj-string-layout.table/formats. Same
input, four representative outputs:
$ INPUT='item,qty,price\napple,12,1.50\npear,4,2.00\n'
$ printf "$INPUT" | bb bb-format --headers # --to plain (default)
item qty price
apple 12 1.50
pear 4 2.00
$ printf "$INPUT" | bb bb-format --to markdown --headers
| item | qty | price |
|:----- |:--- |:----- |
| apple | 12 | 1.50 |
| pear | 4 | 2.00 |
$ printf "$INPUT" | bb bb-format --to psql --headers
item | qty | price
--------+-----+------
apple | 12 | 1.50
pear | 4 | 2.00
$ printf "$INPUT" | bb bb-format --to box --headers
┌───────┬─────┬───────┐
│ item │ qty │ price │
├───────┼─────┼───────┤
│ apple │ 12 │ 1.50 │
├───────┼─────┼───────┤
│ pear │ 4 │ 2.00 │
└───────┴─────┴───────┘
The other available output formats are markdown-left, markdown-center,
markdown-right, double-box, unicode-box, unicode-double-box,
ascii-box, ascii-double-box, ascii-grid, csv, tsv, pipe, org,
rst, and html. See the examples gallery for the
same data rendered through every named format.
By default, every named format auto-sizes to its content. To produce
fixed-width output, pair --width with --fill:
$ printf 'name,qty\napple,12\n' | bb bb-format --to box --headers --width 40 --fill
┌───────────────────┬──────────────────┐
│ name │ qty │
├───────────────────┼──────────────────┤
│ apple │ 12 │
└───────────────────┴──────────────────┘
--fill is what makes the columns expand. Without it --width is silently
ignored for the box/markdown/ascii-grid/plain formats (they have no fill
markers to expand into). For formats that have no width semantics at all
(csv, tsv, pipe, html), --width and --fill are both ignored.
The CLI is a well-behaved Unix filter: stdin in, stdout out, exit 0 on success. That makes composition straightforward.
# pipe from curl
curl -s https://example.com/data.csv | bb bb-format --to box --headers
# reformat then sort by the first column, then re-render as a box
printf 'item,qty\npear,4\napple,12\nkiwi,8\n' \
| bb bb-format --to csv --headers \
| sort \
| bb bb-format --to box
# read CSV, emit Markdown, copy to the clipboard (macOS)
bb bb-format --to markdown --headers data.csv | pbcopy
The --to csv round-trip is especially handy: it normalises the input,
escapes CSV-unsafe characters, and gives you a known-good intermediate
representation for downstream Unix tools.
| Code | Meaning |
|---|---|
0 | Success. Output written to stdout. |
2 | Argument error or input error. Diagnostic on stderr. |
Examples of 2:
$ bb bb-format --bogus
Unsupported CLI option
$ echo $?
2
$ echo 'a,b' | bb bb-format --to notarealformat
Unsupported --to value
$ echo $?
2
The CLI never throws an uncaught exception in normal operation — any
ex-info with a known :type is rendered as a one-line diagnostic and
the process exits 2.
| Path | Cold start | Setup |
|---|---|---|
clojure -M:cli -- | ~700 ms | requires JVM and Clojure deps cache |
bb format -- | ~700 ms | identical to above; just shells through bb |
bb bb-format | ~50 ms | runs natively under SCI, no JVM |
For one-off pipes the difference is the cost of inhaling a JVM. For
repeated runs (e.g. tight shell loops) it dominates everything else, so
prefer bb bb-format. The output is identical.
cli/render is the same entry point invoked by -main, but returns a
vector of output lines instead of printing or exiting. It accepts every
key the command-line flags produce, plus optional :width (integer),
:fill? (boolean), and :display-width (a function from string to
display width, useful when the caller passes ANSI- or wide-glyph data
that the layout engine needs to measure correctly):
(require '[clj-string-layout.cli :as cli])
(cli/render {:input :csv :format :markdown :headers? true}
"Name,Note\nalice,\"a,b\"\n")
;; => ["| Name | Note |"
;; "|:----- |:---- |"
;; "| alice | a,b |"]
(cli/render {:input :csv :format :box :headers? true
:width 40 :fill? true}
"item,qty\napple,12\n")
;; => ["┌───────────────────┬──────────────────┐"
;; "│ item │ qty │"
;; "├───────────────────┼──────────────────┤"
;; "│ apple │ 12 │"
;; "└───────────────────┴──────────────────┘"]
cli/parse-args parses a string sequence into the same options map and
is useful when you want CLI-flavoured argument parsing inside your own
program:
(cli/parse-args ["--from" "tsv" "--to" "box" "--headers" "--width" "60" "--fill"])
;; => {:input :tsv :format :box :headers? true :escape? true
;; :width 60 :fill? true}
cli/parse-input exposes the CSV/TSV/whitespace parsers on their own so
you can reuse them outside the rendering path:
(cli/parse-input :csv "name,note\nalice,\"a,b\"\n")
;; => [["name" "note"] ["alice" "a,b"]]
The CLI is not safe for huge inputs. It slurps the entire input via
slurp, parses it to a vector, then renders eagerly through
table/table. Empirically this means the CLI tops out somewhere between
a few hundred thousand and a million rows depending on the JVM heap and
the output format. On a 1 M-row CSV (~37 MB), clojure -M:cli -- needs
several gigabytes of heap and runs ~6 s; the same input OOMs at
-Xmx 512m.
For inputs that won't fit in heap, drop the CLI and use the streaming primitives from a small Clojure script:
(require '[clj-string-layout.core :as core])
(defn split-lines [^java.io.BufferedReader r]
(lazy-seq
(when-let [line (.readLine r)]
(cons (clojure.string/split line #",") (split-lines r)))))
(with-open [r (java.io.BufferedReader. (java.io.FileReader. "big.csv"))
w (clojure.java.io/writer "out.txt")]
(let [rows (drop 1 (split-lines r))] ; skip the header line
(core/layout-into! w rows
{:col-widths [10 20 8] ; required for streaming
:layout {:cols ["[L] [L] [R]"]}})))
That runs 1 M rows in ~2 s with -Xmx 256m and constant memory. The
recipe book has the full Large Data section
with measured numbers, the row-layout case, and the two-pass /
sample-then-stream patterns for when widths aren't known up front.
A few caveats specific to this approach:
clojure.data.csv works well and reads lazily).:col-widths to stream. Without them,
it does a full input scan to compute widths and you're back to
eager memory use.| Symptom | Likely cause |
|---|---|
Input contains no rows (exit 2) | File or stdin produced zero rows. Check the input format actually matches the data. |
Unsupported --to value (exit 2) | Typo in the output format name. Run --help for the live list. |
Unsupported --from value (exit 2) | Same, for input format. |
--width must be a non-negative integer (exit 2) | Pass a positive integer, e.g. --width 60. |
Only one input file may be supplied (exit 2) | Pass exactly one file path, or - for stdin. |
| Numeric columns end up left-aligned | CSV/TSV/whitespace input is always read as strings; the CLI has no per-column alignment knob. For aligned numerics, use the table API programmatically with :columns [{:from :qty :align :right}]. |
| Embedded newlines split a cell across lines | Quoted-CSV newlines stay literal in the output. Preprocess to strip or replace them if your renderer can't handle a multi-line cell. |
--width 30 does nothing | Pair it with --fill. Without --fill, only the explicitly fill-aware presets respect :width, and the CLI's default formats are not those presets. |
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |