Skip to content
Open
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
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
125 changes: 117 additions & 8 deletions cljfmt/src/cljfmt/core.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -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 %))))

Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)))
Expand Down Expand Up @@ -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))
Comment on lines +637 to +650

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain why the split-keypairs-over-multiple-lines function needs to be changed in this way? I think there needs to be a very good reason to add complexity here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we keep the old behavior when both are on, the keypair pass always explodes maps onto many lines before the line-length pass runs. That makes :max-line-length almost meaningless for maps (it all stems from my approach being dependent on the outer form which I agree is not generic)

My intent with this change: with a positive max and keypair, breaks on maps are width-gated; split-only (no max) stays exactly the old “always split qualifying keys” behavior.


(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))
Comment on lines +692 to +698

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain why there's a separate function for libspecs? Why is breaking the lines here different to other forms?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

General structural wrap inserts newlines between direct children of lists/vectors. Breaking “at the first child boundary” inside [my.ns :as m] splits the same require entry across lines, which is not ideal. If we later get configurable break rules (#413), this could become one configured rule among others.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we want to attempt this feature in two steps:

  1. Get a basic line-breaking feature
  2. Create some sort of per-form syntax for preferring line break locations

I don't think we should try to hard code specific behavior, as (a) we'll have to eventually remove it in favor of a configurable system, and (b) we don't want to break things when we switch over systems.


(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))))))

Comment on lines +700 to +725

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looks like the logic for line breaks depends on the form, but why is that the case? Why not add line breaks at the first possible place prior to the max-line-length?

If certain forms are going to need specific line break, that sounds like something that should be configurable (see #413).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My thinking was hard wrap at max-line-length is going to be unsafe e.g.
very-long-namespace/foo or keyword-with-dashes must stay intact.
String literals cannot be broken without changing semantics etc

I will think about not being dependent on the form and make this more generic

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By "the first possible place" I mean the first possible place that doesn't change the semantic meaning.

(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)))
Comment on lines +726 to +737

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should be aiming for a function like:

(defn limit-line-length [form {:keys [max-line-length]}]
  (transform edit-all #(needs-line-break? % max-line-length) add-line-break))

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will take this feedback and apply it for my next iteration of this PR


(defn- single-column-line? [zloc]
(and (let [zloc (skip-whitespace-and-commas (z/right* zloc) z/right*)]
(or (nil? zloc) (line-break? zloc)))
Expand Down Expand Up @@ -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)))
Comment on lines +770 to +771

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why was this change made?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my bad, this should be reset to the way it was.. Initial drafts of my change were reporting an error for this when I ran bb lint


(defn- count-spaces [zloc]
(if (space? zloc) (node-str-length zloc) 0))
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions cljfmt/src/cljfmt/main.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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?]
Expand Down
137 changes: 137 additions & 0 deletions cljfmt/test/cljfmt/core_test.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Loading