diff --git a/.github/workflows/clojure.yml b/.github/workflows/clojure.yml index 1a5c488..acdffd7 100644 --- a/.github/workflows/clojure.yml +++ b/.github/workflows/clojure.yml @@ -18,7 +18,7 @@ jobs: uses: actions/checkout@v7 - name: Setup Java - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: java-version: '17' distribution: 'corretto' @@ -26,7 +26,7 @@ jobs: - 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7d02d8e..9aeb28c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/CHANGES.md b/CHANGES.md index 0fec599..f396ef3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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) diff --git a/deps.edn b/deps.edn index a6babb6..03bb816 100644 --- a/deps.edn +++ b/deps.edn @@ -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 @@ -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?$"]}} @@ -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"}}} diff --git a/resources/clj-kondo.exports/io.github.hlship/cli-tools/config.edn b/resources/clj-kondo.exports/io.github.hlship/cli-tools/config.edn index 8e80ae7..6c624da 100644 --- a/resources/clj-kondo.exports/io.github.hlship/cli-tools/config.edn +++ b/resources/clj-kondo.exports/io.github.hlship/cli-tools/config.edn @@ -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}} diff --git a/src/net/lewisship/cli_tools/impl.clj b/src/net/lewisship/cli_tools/impl.clj index abc9f8a..dacad76 100644 --- a/src/net/lewisship/cli_tools/impl.clj +++ b/src/net/lewisship/cli_tools/impl.clj @@ -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] @@ -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] @@ -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) @@ -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)))))) @@ -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 @@ -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, @@ -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) @@ -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? @@ -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)) diff --git a/src/net/lewisship/cli_tools/terminal.clj b/src/net/lewisship/cli_tools/terminal.clj index 120aaa3..8585ea8 100644 --- a/src/net/lewisship/cli_tools/terminal.clj +++ b/src/net/lewisship/cli_tools/terminal.clj @@ -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") @@ -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)) diff --git a/test-resources/command-help-word-wrapped.txt b/test-resources/command-help-word-wrapped.txt new file mode 100644 index 0000000..4c2216a --- /dev/null +++ b/test-resources/command-help-word-wrapped.txt @@ -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 diff --git a/test-resources/tool-help-word-wrap.txt b/test-resources/tool-help-word-wrap.txt new file mode 100644 index 0000000..cc8ba16 --- /dev/null +++ b/test-resources/tool-help-word-wrap.txt @@ -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 diff --git a/test/net/lewisship/abort_test.clj b/test/net/lewisship/abort_test.clj index 5f56f9d..cea5cee 100644 --- a/test/net/lewisship/abort_test.clj +++ b/test/net/lewisship/abort_test.clj @@ -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]] diff --git a/test/net/lewisship/cli_tools/bb_test.clj b/test/net/lewisship/cli_tools/bb_test.clj index 02e703c..b59d317 100644 --- a/test/net/lewisship/cli_tools/bb_test.clj +++ b/test/net/lewisship/cli_tools/bb_test.clj @@ -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]])) diff --git a/test/net/lewisship/cli_tools/completions_test.clj b/test/net/lewisship/cli_tools/completions_test.clj index ba4fec5..ae6f915 100644 --- a/test/net/lewisship/cli_tools/completions_test.clj +++ b/test/net/lewisship/cli_tools/completions_test.clj @@ -3,6 +3,7 @@ (:require [clojure.java.io :as io] [clojure.string :as string] [clojure.test :refer [deftest is]] + [matcher-combinators.test :refer [match?]] [net.lewisship.cli-tools.aux :refer [dispatch-with-result]])) (defn- expected [file] diff --git a/test/net/lewisship/cli_tools/custom_tool_handler_test.clj b/test/net/lewisship/cli_tools/custom_tool_handler_test.clj index 23aa540..5d9097f 100644 --- a/test/net/lewisship/cli_tools/custom_tool_handler_test.clj +++ b/test/net/lewisship/cli_tools/custom_tool_handler_test.clj @@ -1,5 +1,6 @@ (ns net.lewisship.cli-tools.custom-tool-handler-test (:require [clojure.test :refer [deftest is]] + [matcher-combinators.test :refer [match?]] [net.lewisship.cli-tools :as cli-tools] [net.lewisship.cli-tools.alt-handler :as alt-handler] [net.lewisship.cli-tools.test :refer [capture-result]])) diff --git a/test/net/lewisship/cli_tools/messy_test.clj b/test/net/lewisship/cli_tools/messy_test.clj index a6d7218..f4093ce 100644 --- a/test/net/lewisship/cli_tools/messy_test.clj +++ b/test/net/lewisship/cli_tools/messy_test.clj @@ -3,6 +3,7 @@ [clojure.string :as string] [net.lewisship.cli-tools :as cli] [net.lewisship.cli-tools.test :refer [capture-result]] + [matcher-combinators.test :refer [match?]] [clojure.test :refer [deftest is]])) (defn- dispatch [& args] diff --git a/test/net/lewisship/cli_tools/test_test.clj b/test/net/lewisship/cli_tools/test_test.clj index 5211e0b..0ea4bb8 100644 --- a/test/net/lewisship/cli_tools/test_test.clj +++ b/test/net/lewisship/cli_tools/test_test.clj @@ -1,5 +1,6 @@ (ns net.lewisship.cli-tools.test-test (:require [clojure.test :refer [deftest is use-fixtures]] + [matcher-combinators.test :refer [match?]] [net.lewisship.cli-tools :as cli] [net.lewisship.cli-tools.test :refer [with-split-out with-split-err diff --git a/test/net/lewisship/cli_tools_test.clj b/test/net/lewisship/cli_tools_test.clj index 41a4010..1f3986d 100644 --- a/test/net/lewisship/cli_tools_test.clj +++ b/test/net/lewisship/cli_tools_test.clj @@ -6,9 +6,11 @@ net.lewisship.cli-tools.builtins net.lewisship.group-ns net.lewisship.conflict + [net.lewisship.cli-tools.terminal :refer [*terminal-width*]] [net.lewisship.cli-tools.impl :as impl] [net.lewisship.cli-tools.test :refer [with-err-str capture-result]] [net.lewisship.cli-tools.aux :refer [with-exit-errors dispatch-with-result]] + [matcher-combinators.test :refer [match?]] [clojure.repl :as repl]) (:import (java.io BufferedReader StringReader))) @@ -17,7 +19,10 @@ :once (fn [f] (binding [impl/*tool-options* {:tool-name "harness" - :cache-dir nil}] + :cache-dir nil} + ;; Use very long terminal width because who knows what we'll get in + ;; CI/CD or some rando's terminal. + *terminal-width* 100] (f)))) #_{:clj-kondo/ignore [:unused-private-var]} @@ -179,6 +184,18 @@ :out (slurp "test-resources/help-with-no-color.txt")} (invoke-command "-N" "-h")))) +(deftest command-help-word-wrapped + (is (match? {:status 0 + :out (slurp "test-resources/command-help-word-wrapped.txt")} + (binding [*terminal-width* 30] + (invoke-command "-N" "configure" "-h"))))) + +(deftest tool-help-word-wrap + (is (match? {:status 0 + :out (slurp "test-resources/tool-help-word-wrap.txt")} + (binding [*terminal-width* 30] + (invoke-command "-N" "-h"))))) + (deftest help-with-color-enabled (binding [ansi/*color-enabled* false] (is (match? {:status 0 diff --git a/test/user.clj b/test/user.clj index d378bfe..eaffb9f 100644 --- a/test/user.clj +++ b/test/user.clj @@ -1,7 +1,5 @@ (ns user - (:require [net.lewisship.trace :as trace] - ;; Enable (is (match? ..)): - matcher-combinators.clj-test)) + (:require [net.lewisship.trace :as trace])) (trace/setup-default)