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
4 changes: 2 additions & 2 deletions .github/workflows/clojure.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@ jobs:
uses: actions/checkout@v7

- name: Setup Java
uses: actions/setup-java@v5
uses: actions/setup-java@v6
with:
java-version: '17'
distribution: 'corretto'

- name: Install clojure tools
uses: DeLaGuardo/setup-clojure@13.6.1
with:
cli: 1.12.5.1664
cli: 1.12.6.1673

- name: Cache clojure dependencies
uses: actions/cache@v6
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,21 @@ jobs:

steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7

- name: Setup Java
uses: actions/setup-java@v5
uses: actions/setup-java@v6
with:
java-version: '17'
distribution: 'corretto'

- name: Install clojure tools
uses: DeLaGuardo/setup-clojure@13.6.0
uses: DeLaGuardo/setup-clojure@13.6.1
with:
cli: 1.12.5.1645
cli: 1.12.6.1673

- name: Cache clojure dependencies
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: |
~/.m2/repository
Expand Down
4 changes: 4 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# 1.1.0 -- UNRELEASED

cli-tools now will word wrap tool docs, command docs, and option/argument docs to fit the terminal width.

# 1.0.3 -- 3 Sep 2026

* fix: Nested group completions used multi-word names that zsh could not parse; also `#compdef` and `--version` [#72](https://github.com/hlship/cli-tools/issues/72)
Expand Down
8 changes: 5 additions & 3 deletions deps.edn
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{:paths ["src"
"resources"]

:deps {org.clj-commons/pretty {:mvn/version "3.8.0"}
:deps {org.clj-commons/pretty {:mvn/version "3.9.0"}
org.clj-commons/humanize {:mvn/version "1.2"}}

:net.lewisship.build/scm
Expand All @@ -17,9 +17,11 @@
io.github.hlship/trace {:mvn/version "1.5"}
io.github.tonsky/clj-reload {:mvn/version "1.0.0"}
nubank/matcher-combinators {:mvn/version "3.11.0"}
org.slf4j/slf4j-nop {:mvn/version "2.0.19"}
babashka/babashka {:mvn/version "1.13.219"}}
:exec-fn cognitect.test-runner.api/test
:jvm-opts ["-Dclj-commons.ansi.enabled=true"]
:jvm-opts ["-Dclj-commons.ansi.enabled=true"
"--enable-native-access=ALL-UNNAMED"]
:exec-args
{:patterns [".*-tests?$"]}}

Expand All @@ -28,7 +30,7 @@
babashka/fs {:mvn/version "0.5.34" :optional true}
babashka/process {:mvn/version "0.6.25" :optional true}
selmer/selmer {:mvn/version "1.13.5" :optional true}
org.babashka/cli {:mvn/version "0.12.86" :optional true}}}
org.babashka/cli {:mvn/version "0.12.88" :optional true}}}

:1.11
{:override-deps {org.clojure/clojure ^:antq/exclude {:mvn/version "1.11.4"}}}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
{:linters
{:unresolved-symbol {:exclude [(clojure.test/is [match?])]}}
{
;; For some reason, clj-kondo triggers :unresolved-symbol and :unresolved-namespace for this NS,
;; maybe because of the use of #?
:config-in-ns {net.lewisship.cli-tools.cache {:ignore true}}
Expand Down
133 changes: 97 additions & 36 deletions src/net/lewisship/cli_tools/impl.clj
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
(ns ^:no-doc net.lewisship.cli-tools.impl
"Private namespace for implementation details for new.lewisship.cli-tools, subject to change."
(:require [clojure.string :as string]
[clj-commons.ansi :refer [compose pout perr]]
[clj-commons.ansi :as ansi :refer [compose pout perr]]
[net.lewisship.cli-tools.styles :refer [style]]
[net.lewisship.cli-tools.terminal :refer [*terminal-width*]]
[clojure.tools.cli :as cli]
[clj-commons.humanize :as h]
[clj-commons.humanize.inflect :as inflect]
Expand Down Expand Up @@ -161,21 +162,74 @@
(str (apply str (repeat (- indent strip-chars) " "))
text)))

(defn- cleanup-docstring
(defn- extend-result
[result line current]
(conj (cond-> result current (conj current)) line))

(defn- combine-lines
"Combines consecutive non-indented lines into a single line, so that
the lines can be word-wrapped."
[lines]
(loop [result []
current nil
[line & more-lines] lines]
(cond
(nil? line)
(cond-> result current (conj current))

(= "" line)
(recur (extend-result result line current)
nil
more-lines)


;; Indented lines are complete an assembled line then are added.
(string/starts-with? line " ")
(recur (extend-result result line current)
nil
more-lines)

current
(recur result
(str current " " line)
more-lines)

:else
(recur result line more-lines))))


(defn- rebuild-docstring
"Breaks a docstring into individual lines, strips out common indent, then rebuilds
consecutive lines into long lines ready for word-wrapping."
[docstring]
(let [docstring' (string/trim docstring)
lines (->> docstring'
string/split-lines
(map indentation-of-line))
non-zero-indents (->> lines
indent+lines (->> docstring'
string/split-lines
(map indentation-of-line))
non-zero-indents (->> indent+lines
(map first)
(remove zero?))]
(if (empty? non-zero-indents)
docstring'
(let [indentation (reduce min non-zero-indents)]
(->> lines
(mapv #(strip-indent indentation %))
(string/join "\n"))))))
(remove zero?))
lines' (if (empty? non-zero-indents)
(map second indent+lines) ; just the individual lines
(let [indentation (reduce min non-zero-indents)]
(map #(strip-indent indentation %) indent+lines)))]
(->> lines'
combine-lines
;; Add hard breaks after each long line
(interpose "\n"))))

(defn- wrap-and-indent
"Splits the line, indenting subsequent lines by the indentation amount.
If the terminal width less the indent is below 1, just returns the lines
separated by newlines."
[indent & lines]
(let [width (- *terminal-width* indent)
width' (if (pos? width)
width
*terminal-width*)
indent-block (apply str "\n" (repeat indent " "))]
(->> (apply ansi/wrap width' lines)
(interpose indent-block))))

(defn- print-summary
[command-doc command-map]
Expand All @@ -184,20 +238,20 @@
{:keys [command-name positional-specs summary]} command-map
arg-strs (map arg-spec->str positional-specs)]
(pout
"Usage: "
"Usage: "
;; A stand-alone tool doesn't have a tool-name (*options* will be nil)
(when tool-name
[(style :tool-name) tool-name " "])
(when tool-name
[(style :tool-name) tool-name " "])
;; A stand-alone tool will use its command-name, a command within
;; a multi-command tool will have a command-path.
[(style :command-path)
(if command-path
(string/join " " command-path)
command-name)]
" [OPTIONS]"
(map list (repeat " ") arg-strs))
[(style :command-path)
(if command-path
(string/join " " command-path)
command-name)]
" [OPTIONS]"
(map list (repeat " ") arg-strs))
(when command-doc
(-> command-doc cleanup-docstring pout))
(->> command-doc rebuild-docstring (wrap-and-indent 0) pout))

;; There's always at least -h/--help:
(pout "\nOptions:\n" summary)
Expand All @@ -211,10 +265,10 @@
(+ 2))
lines (for [{:keys [label doc]} positional-specs]
(list
[{:width max-label-width}
[{:width max-label-width}
[(style :option-label) label]]
": "
doc))]
": "
(wrap-and-indent (+ max-label-width 2) doc)))]
(pout "\nArguments:")
(pout (interpose \newline lines))))))

Expand All @@ -235,7 +289,9 @@

(defn- format-option-summary
[max-option-width max-default-width summary-part]
(let [{:keys [opt-label default opt-desc]} summary-part]
(let [{:keys [opt-label default opt-desc]} summary-part
indent (cond-> (+ max-option-width max-default-width 3)
(pos? max-default-width) inc)]
(list
" "
[{:width max-option-width
Expand All @@ -245,7 +301,8 @@
:align :left} default]
(when (pos? max-default-width)
" ")
opt-desc)))
(wrap-and-indent indent
opt-desc))))

(defn- make-summary-part
"Given a single compiled option spec, into a compose-compatible label, a width for that label,
Expand Down Expand Up @@ -758,13 +815,16 @@
command-name-width' (or command-name-width
(->> sorted-commands
(map #(-> % :command count))
(reduce max 0)))]
(reduce max 0)))
indent (+ command-name-width' 4)]
(when container-map
;; Don't need to use ansi/wrap because this text is not indented at all
;; so the terminal will do a better job wrapping.
(pout (when recurse? "\n")
(compose-command-path (:tool-name *tool-options*)
(:command-path container-map))
" - "
(or (some-> container-map :group-doc cleanup-docstring)
(or (some-> container-map :group-doc rebuild-docstring)
(missing-doc))))

(when (seq sorted-commands)
Expand All @@ -773,11 +833,12 @@
;; Commands (including sub-groups) inside this command
(doseq [{:keys [fn command] :as command-map} sorted-commands]
(pout
" "
[{:width command-name-width'} [(style :command-path) command]]
": "
[(when-not fn (style :subgroup-label))
(extract-command-title command-map)]))
" "
[{:width command-name-width'} [(style :command-path) command]]
": "
[(when-not fn (style :subgroup-label))
(wrap-and-indent indent
(extract-command-title command-map))]))

;; Recurse and print sub-groups
(when recurse?
Expand Down Expand Up @@ -823,7 +884,7 @@
(pout "Usage: " [(style :tool-name) tool-name] " [OPTIONS] COMMAND ...")
(when tool-doc
(pout "\n"
(cleanup-docstring tool-doc)))
(rebuild-docstring tool-doc)))
(pout "\nOptions:\n"
(-> *tool-options* :tool-summary deref))

Expand Down
8 changes: 7 additions & 1 deletion src/net/lewisship/cli_tools/terminal.clj
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
"A wrapper around the `/usr/bin/tput` command, used to obtain terminal control sequences for moving the cursor,
clearing lines, and so forth."
{:added "0.11"}
(:require [babashka.process :as p]))
(:require [babashka.cli :as cli]
[babashka.process :as p]))

(def ^:dynamic *terminal-type*
(or (System/getenv "TERM")
Expand All @@ -22,3 +23,8 @@
"Runs the `tput` command to convert the opcodes and values to a terminal
command string. Results are memoized."
(memoize (fn [& args] (tput* args))))

(def ^:dynamic ^{:added "1.1.0"}
*terminal-width*
"Terminal width as defined by Babashka CLI."
(or (cli/default-width-fn nil) 80))
21 changes: 21 additions & 0 deletions test-resources/command-help-word-wrapped.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
Usage: harness configure [OPTIONS] HOST KV-DATA+
Configures the system for some
thing.

This is more detail.

This is indented.

This is not indented.

Options:
-v, --verbose Enable verbose
logging
-h, --help This command
summary

Arguments:
HOST: System
configuration URL
KV-DATA: Data to configure
as KEY=VALUE
41 changes: 41 additions & 0 deletions test-resources/tool-help-word-wrap.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
Usage: harness [OPTIONS] COMMAND ...

Options:
-C, --color Enable ANSI
color output
-N, --no-color Disable ANSI
color output
-h, --help This command
summary

Commands:
collect: Collect
key and
value
configure: Configures
the system
for some
thing
default-variants: Different
option
defaults
help: List
available
commands
in-order: Execute
remote
command
pass-thru: For
testing
:pass-through
option
set-mode: Sets the
execution
mode
tool-info: Echoes the
tool name
and root
command
map keys
validate: validate
command
1 change: 1 addition & 0 deletions test/net/lewisship/abort_test.clj
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
(ns net.lewisship.abort-test
(:require [clojure.test :refer [deftest is use-fixtures]]
[matcher-combinators.test :refer [match?]]
[clj-commons.ansi :as ansi :refer [compose]]
[net.lewisship.cli-tools :refer [abort command-path]]
[net.lewisship.cli-tools.test :refer [capture-result]]
Expand Down
1 change: 1 addition & 0 deletions test/net/lewisship/cli_tools/bb_test.clj
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
(ns net.lewisship.cli-tools.bb-test
(:require [clj-commons.ansi :as ansi]
[matcher-combinators.test :refer [match?]]
[clojure.test :refer [deftest is]]
[net.lewisship.cli-tools :as cli]
[net.lewisship.cli-tools.test :refer [capture-result]]))
Expand Down
Loading