diff --git a/README.md b/README.md index be0a4b0e..3226612f 100644 --- a/README.md +++ b/README.md @@ -413,6 +413,40 @@ In order to load the standard configuration file from Leiningen, add the true if cljfmt should break hashmaps onto multiple lines. This will convert `{:a 1 :b 2}` to `{:a 1\n:b 2}`. Defaults to false. +* `:max-line-length` - + when set to a **positive integer** (for example `80`), cljfmt runs a + **structural max line length** pass: on certain **single-line** forms it + inserts newlines only at **safe boundaries** between **direct children**, + using the same width accounting as the rest of the formatter. Eligible + shapes include lists (including `ns` `:import` / `:use` lists), vectors, + maps, sets, function literals `#()`, namespaced maps `#:prefix{:a 1}`, + and inner collections inside **reader conditionals** `#?` / `#?@`. + **Tagged reader literals** (for example `#foo/bar [...]`) are not + rewritten inside the tag. In `ns` forms, top-level `:require` **libspec** + vectors (each standard require entry as a vector, for example + `[my.ns :as m]` or `[my.ns :refer [f]]`) are only broken **between** whole + vectors (never inside one such vector). `nil` (the default) disables this pass. + + **Map keypairs and `:max-line-length`:** if only + `:split-keypairs-over-multiple-lines?` is true, every qualifying map key + still gets a newline before it (unchanged). If `:max-line-length` is set, + keypair breaks are **width-gated** (a newline is inserted before a key + only when the line would exceed the limit). When **both** are true, + keypair behavior follows the width-gated rule so short maps are not + over-split. + + **Non-goals (experimental):** it does not reflow strings, docstrings, or + `;;` comments; it does not split a single token that alone exceeds the + limit; it skips forms that already contain line breaks among direct + children. **Alignment** (`:align-map-columns?` / `:align-form-columns?`) + runs afterward and may add spaces so some lines can still be longer than + `:max-line-length`. Order in `reformat-form` is + `:sort-ns-references?` → keypair / map layout (split and/or width-gated) → + structural `:max-line-length` → indentation and the rest. On the + standalone `cljfmt` CLI, pass the limit with `=`, for example + `--max-line-length=80` (same as `--max-column-alignment-gap=` and other + numeric CLI flags). + [indents.md]: docs/INDENTS.md [community style recommendation]: https://guide.clojure.style/#one-space-indent [no-blank-lines]: https://guide.clojure.style/#no-blank-lines-within-def-forms diff --git a/cljfmt/src/cljfmt/core.cljc b/cljfmt/src/cljfmt/core.cljc index 75151940..a643f70b 100644 --- a/cljfmt/src/cljfmt/core.cljc +++ b/cljfmt/src/cljfmt/core.cljc @@ -278,6 +278,12 @@ (defn- reader-conditional? [zloc] (and (reader-macro? zloc) (#{"?" "?@"} (-> zloc z/down token-value str)))) +(defn- structural-wrap-under-forbidden-reader-macro? [zloc] + (some (fn [a] + (and (reader-macro? a) + (not (reader-conditional? a)))) + (take-while some? (iterate z/up* (z/up* zloc))))) + (defn- find-next-keyword [zloc] (z/find zloc z/right #(n/keyword-node? (z/node %)))) @@ -397,7 +403,8 @@ :remove-surrounding-whitespace? true :remove-trailing-whitespace? true :sort-ns-references? false - :split-keypairs-over-multiple-lines? false}) + :split-keypairs-over-multiple-lines? false + :max-line-length nil}) (defmulti ^:private indenter-fn (fn [_sym _context [type & _args]] type)) @@ -484,9 +491,6 @@ (defn- insert-newline-left [zloc] (z/insert-left* zloc (n/newlines 1))) -(defn split-keypairs-over-multiple-lines [form] - (transform form edit-all map-key-without-line-break? insert-newline-left)) - (defn reindent ([form] (indent (unindent form))) @@ -630,6 +634,108 @@ (map count)) max 0 (str/split lines #"\r?\n")))) +(defn- map-key-needs-split-for-layout? [zloc opts] + (when (map-key-without-line-break? zloc) + (let [split? (:split-keypairs-over-multiple-lines? opts) + max-len (:max-line-length opts) + max-on? (pos-int? max-len)] + (cond + (and split? (not max-on?)) true + max-on? (> (node-end-position zloc) max-len) + :else false)))) + +(defn- split-map-keypairs-for-layout [form opts] + (transform form edit-all + #(map-key-needs-split-for-layout? % opts) + insert-newline-left)) + +(defn split-keypairs-over-multiple-lines [form] + (split-map-keypairs-for-layout form {:split-keypairs-over-multiple-lines? true})) + +(defn- ns-import-or-use-list-zloc? [zloc] + (and (ns-reference? zloc) + (#{:import :use} + (try (first (z/sexpr zloc)) + (catch #?(:clj Exception :cljs :default) _ nil))))) + +(defn- ns-require-libspec-vector-zloc? [zloc] + (and (z/vector? zloc) + (ns-reference? (z/up zloc)) + (= :require (try (first (z/sexpr (z/up zloc))) + (catch #?(:clj Exception :cljs :default) _ nil))))) + +(defn- max-len-structural-wrap-target? [zloc] + (and (not (ns-require-libspec-vector-zloc? zloc)) + (or (#{:vector :map :set :fn} (z/tag zloc)) + (= :namespaced-map (z/tag zloc)) + (and (z/list? zloc) + (or (not (ns-reference? zloc)) + (ns-import-or-use-list-zloc? zloc)))))) + +(defn- single-line-direct-children? [zloc] + (when zloc + (loop [c (z/down zloc)] + (cond + (nil? c) true + (z/linebreak? c) false + :else (recur (z/right* c)))))) + +(defn- libspec-vector-not-first-after-require? [zloc] + (when (ns-require-libspec-vector-zloc? zloc) + (loop [p (z/left* zloc)] + (cond + (nil? p) false + (z/whitespace-or-comment? p) (recur (z/left* p)) + (and (token? p) (= :require (z/sexpr p))) false + :else true)))) + +(defn- wrap-ns-require-libspec-rows-to-max-length [form max-len] + (transform form edit-all + (fn [zloc] + (and (libspec-vector-not-first-after-require? zloc) + (single-line-direct-children? (z/up zloc)) + (> (node-end-position zloc) max-len))) + insert-newline-left)) + +(defn- wrap-max-line-length-at-child-boundaries [zloc max-len] + (cond + (= :namespaced-map (z/tag zloc)) + (if-let [inner (some-> zloc z/down z/right)] + (if (= :map (z/tag inner)) + (let [wrapped (wrap-max-line-length-at-child-boundaries inner max-len)] + (or (some-> wrapped z/up) zloc)) + zloc) + zloc) + (not (max-len-structural-wrap-target? zloc)) + zloc + (not (single-line-direct-children? zloc)) + zloc + :else + (let [first-el (-> zloc z/down (skip-whitespace-and-commas z/right*))] + (if-not first-el + zloc + (let [last-zloc + (loop [current first-el] + (if-some [nxt (-> current z/right* (skip-whitespace-and-commas z/right*))] + (recur (if (> (node-end-position nxt) max-len) + (insert-newline-left nxt) + nxt)) + current))] + (z/up last-zloc)))))) + +(defn- apply-max-line-length-structural-wrap [form opts] + (let [max-len (:max-line-length opts)] + (if (pos-int? max-len) + (-> form + (wrap-ns-require-libspec-rows-to-max-length max-len) + (transform edit-all + (fn [zloc] + (and (max-len-structural-wrap-target? zloc) + (single-line-direct-children? zloc) + (not (structural-wrap-under-forbidden-reader-macro? zloc)))) + #(wrap-max-line-length-at-child-boundaries % max-len))) + form))) + (defn- single-column-line? [zloc] (and (let [zloc (skip-whitespace-and-commas (z/right* zloc) z/right*)] (or (nil? zloc) (line-break? zloc))) @@ -661,8 +767,8 @@ zloc)) (defn- pad-node [zloc padding] - (-> (update-space-left zloc padding) - (z/subedit-> (pad-inside-node padding)))) + (z/subedit-node (update-space-left zloc padding) + #(pad-inside-node % padding))) (defn- count-spaces [zloc] (if (space? zloc) (node-str-length zloc) 0)) @@ -887,8 +993,11 @@ (-> form (cond-> (:sort-ns-references? opts) sort-ns-references) - (cond-> (:split-keypairs-over-multiple-lines? opts) - split-keypairs-over-multiple-lines) + (cond-> (or (:split-keypairs-over-multiple-lines? opts) + (pos-int? (:max-line-length opts))) + (split-map-keypairs-for-layout opts)) + (cond-> (pos-int? (:max-line-length opts)) + (apply-max-line-length-structural-wrap opts)) (cond-> (:remove-consecutive-blank-lines? opts) remove-consecutive-blank-lines) (cond-> (:remove-surrounding-whitespace? opts) diff --git a/cljfmt/src/cljfmt/main.clj b/cljfmt/src/cljfmt/main.clj index 1a3852fa..83ebc11a 100644 --- a/cljfmt/src/cljfmt/main.clj +++ b/cljfmt/src/cljfmt/main.clj @@ -30,6 +30,12 @@ :default (:max-column-alignment-gap defaults) :parse-fn #(cond-> % (string? %) parse-long) :id :max-column-alignment-gap] + [nil "--max-line-length" + :default (:max-line-length defaults) + :parse-fn #(cond-> % (string? %) parse-long) + :validate [#(or (nil? %) (pos-int? %)) + "Must be a positive integer when set"] + :id :max-line-length] [nil "--[no-]ansi" :default (:ansi? defaults) :id :ansi?] diff --git a/cljfmt/test/cljfmt/core_test.cljc b/cljfmt/test/cljfmt/core_test.cljc index a3d70d56..c8f2d848 100644 --- a/cljfmt/test/cljfmt/core_test.cljc +++ b/cljfmt/test/cljfmt/core_test.cljc @@ -1838,6 +1838,143 @@ 'another.lib/overridden [[:block 1]] ;; As this one overrides. 'some.lib/block2 [[:block 2]]}})))) +(deftest test-max-line-length + (testing "disabled by default" + (is (reformats-to? + ["(f aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb)"] + ["(f aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb)"] + {}))) + (testing "wraps single-line list when a line would exceed limit" + (is (reformats-to? + ["(f aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb)"] + ["(f aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + " bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb)"] + {:max-line-length 40}))) + (testing "wraps single-line vector" + (is (reformats-to? + ["[aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb]"] + ["[aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + " bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb]"] + {:max-line-length 40}))) + (testing "wraps single-line map when a line would exceed limit" + (is (reformats-to? + ["{:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 1 :b 2}"] + ["{:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 1" + " :b 2}"] + {:max-line-length 40}))) + (testing "cannot split when only one element exceeds limit" + (is (reformats-to? + ["(fooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo)"] + ["(fooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo)"] + {:max-line-length 40}))) + (testing "nested single-line lists wrap inner collection" + (is (reformats-to? + ["((aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb))"] + ["((aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + " bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb))"] + {:max-line-length 40}))) + (testing "does not modify collections that already contain line breaks" + (is (reformats-to? + ["(f" + " a" + " b)"] + ["(f" + " a" + " b)"] + {:max-line-length 10 + :indentation? false}))) + (testing "idempotent second pass" + (let [s "(f aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb)" + opts {:max-line-length 40} + once (reformat-string s opts)] + (is (= once (reformat-string once opts))))) + (testing "stable with align-map-columns when both enabled" + (is (reformats-to? + ["{:x 1}" + "(ffffffffffffffffffffffffffffff gggggggggggggggggggggggggggggg)"] + ["{:x 1}" + "(ffffffffffffffffffffffffffffff" + " gggggggggggggggggggggggggggggg)"] + {:max-line-length 30 + :align-map-columns? true}))) + (testing "with :split-keypairs-over-multiple-lines? and :max-line-length, map keypair breaks are width-gated" + (is (reformats-to? + ["(x {:a 1 :bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 2} yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy)"] + ["(x" + " {:a 1 :bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 2}" + " yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy)"] + {:split-keypairs-over-multiple-lines? true + :max-line-length 55}))) + (testing "with :sort-ns-references? require vector may reflow after sorting" + (is (reformats-to? + ["(ns foo (:require [bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb :as b] [aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa :as a]))"] + ["(ns foo" + " (:require [aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa :as a]" + " [bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb :as b]))"] + {:sort-ns-references? true + :max-line-length 50}))) + (testing "with :insert-missing-whitespace? false wrapping still applies" + (is (reformats-to? + ["(f aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb)"] + ["(f aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + " bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb)"] + {:max-line-length 40 + :insert-missing-whitespace? false}))) + (testing "namespaced map wraps inner entries like a map" + (is (reformats-to? + ["#:foo{:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 1 :b 2}"] + ["#:foo{:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 1" + " :b 2}"] + {:max-line-length 40}))) + (testing "set and function literal wrap like other collections" + (is (reformats-to? + ["#{aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb}"] + ["#{aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + " bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb}"] + {:max-line-length 40})) + (is (reformats-to? + ["#(aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb)"] + ["#(aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + " bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb)"] + {:max-line-length 40}))) + (testing "reader conditional allows wrapping inner collection" + (is (reformats-to? + ["#?(:clj [aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb])"] + ["#?(:clj" + " [aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + " bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb])"] + {:max-line-length 40}))) + (testing ":max-line-length without split does not break short map keypairs" + (is (reformats-to? + ["{:a 1 :b 2}"] + ["{:a 1 :b 2}"] + {:max-line-length 20}))) + #?(:clj + (testing "non-conditional reader macro does not structurally wrap inner vector" + (is (reformats-to? + ["#foo/bar [aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb]"] + ["#foo/bar [aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb]"] + {:max-line-length 40})))) + #?(:clj + (testing "ns :import list wraps long single-line import forms" + (is (reformats-to? + ["(ns com.example.core" + " (:import [java.time LocalDate LocalDateTime Instant ZoneOffset Clock]))"] + ["(ns com.example.core" + " (:import" + " [java.time LocalDate LocalDateTime Instant" + " ZoneOffset Clock]))"] + {:max-line-length 50})))) + #?(:clj + (testing "ns :require breaks between top-level libspec vectors only" + (is (reformats-to? + ["(ns com.example.core" + " (:require [aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa :as a] [bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb :as b]))"] + ["(ns com.example.core" + " (:require [aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa :as a]" + " [bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb :as b]))"] + {:max-line-length 50}))))) + (deftest test-parsing (is (reformats-to? [";foo"]