Skip to content

Commit

Permalink
Add option to sort ns reference blocks
Browse files Browse the repository at this point in the history
The option results in cljfmt sorting the libspecs inside :require,
:require-macros, :use, and :import blocks.

Whitespace and newlines are preserved for most cases, in particular
whether the first libspec element follows :require (or one of the
others) or moves to the next line.

Comments above and behind a libspec are considered part of that libspec,
and they'll move to the new position of the libspec.
This means libspecs commented out won't sorted, e.g.
    [c]
    #_[b]
    [a]
will sort to
    #_[b]
    [a]
    [c]
and similar with `;` comments, because it is treated as a comment for
[a]. This probably is acceptable, as it is an edge case, and there
shouldn't be an expectation that the comment is treated as a libspec.
  • Loading branch information
or committed May 1, 2022
1 parent 3d7e866 commit c347a4c
Show file tree
Hide file tree
Showing 4 changed files with 257 additions and 4 deletions.
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,11 @@ selectively enabled or disabled:
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.

* `:sort-ns-requires?` -
true if cljfmt should sort `ns` blocks `:require`, `:require-macros`, `:use`,
and `:import` by namespace. This will convert `(ns (:require [c] b [a.b.c]))`
to `(ns (:require [a.b.c] b [c]))`. Defaults to false.

You can also configure the behavior of cljfmt:

* `:paths` - determines which directories to include in the
Expand Down Expand Up @@ -220,7 +225,7 @@ Indentation types are:
* `:inner` -
two character indentation applied to form arguments at a depth
relative to a form symbol

* `:block` -
first argument aligned indentation applied to form arguments at form
depth 0 for a symbol
Expand All @@ -235,9 +240,9 @@ Form depth is the nested depth of any element within the form.
A contrived example will help to explain depth:

```clojure
(foo
(foo
bar
(baz
(baz
(qux plugh)
corge)
(grault
Expand Down
18 changes: 18 additions & 0 deletions cljfmt/src/cljfmt/core.cljc
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
(ns cljfmt.core
#?(:clj (:refer-clojure :exclude [reader-conditional?]))
(:require #?(:clj [clojure.java.io :as io])
[cljfmt.namespace :as ns]
[clojure.string :as str]
[rewrite-clj.node :as n]
[rewrite-clj.parser :as p]
Expand Down Expand Up @@ -398,13 +399,27 @@
(defn remove-multiple-non-indenting-spaces [form]
(transform form edit-all non-indenting-whitespace? replace-with-one-space))

(defn ns-reference-block? [zloc]
(and (z/list? zloc)
(some-> zloc
z/up
ns-form?)
(-> zloc
z/sexpr
first
ns/reference-block-keyword?)))

(defn sort-ns-requires [form]
(transform form edit-all ns-reference-block? ns/sort-reference-block))

(def default-options
{:indentation? true
:insert-missing-whitespace? true
:remove-consecutive-blank-lines? true
:remove-multiple-non-indenting-spaces? false
:remove-surrounding-whitespace? true
:remove-trailing-whitespace? true
:sort-ns-requires? false
:split-keypairs-over-multiple-lines? false})

(defn reformat-form
Expand All @@ -413,6 +428,9 @@
([form opts]
(let [opts (merge default-options opts)]
(cond-> form
(:sort-ns-requires? opts)
sort-ns-requires

(:split-keypairs-over-multiple-lines? opts)
split-keypairs-over-multiple-lines

Expand Down
133 changes: 133 additions & 0 deletions cljfmt/src/cljfmt/namespace.cljc
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
(ns cljfmt.namespace
(:require [rewrite-clj.node :as n]
[rewrite-clj.zip :as z]))

(def reference-block-keyword?
#{:require
:require-macros
:use
:import})

(def libspec?
(comp not
#{:comment
:uneval
:newline
:whitespace
:comma}
n/tag))

(defn contain-newline? [elements]
(->> elements
(apply concat)
(some (comp #{:newline
:comment}
n/tag))))

(defn partition-reference-block-children
"Partition children of a reference block, considering one element to look like:
(<whitespace>|<comment>)*<libspec>(<whitespace>|<comment>)*
This ensures that comments above a libspec and comments behind it will be grouped
and moved together with it."
[zloc]
(let [children (-> zloc
z/node
n/children)
; the first element is the keyword :require, :use, etc. and surrounding whitespace,
; but it shouldn't be sorted, so preserve it as is
first-element? #(or (-> % n/tag #{:newline
:whitespace
:comma})
(and (-> % n/keyword-node?)
(-> % n/sexpr reference-block-keyword?)))
first-element (vec (take-while first-element? children))
relevant-children (drop-while first-element? children)]
(loop [[node & remaining] relevant-children
current-element []
result []]
(let [t (some-> node n/tag)]
(cond
(nil? node) [first-element
(if (seq current-element)
(conj result (conj
current-element
(if (contain-newline? result)
(n/newlines 1)
(n/whitespace-node " "))))
result)]

(#{:whitespace
:comma
:uneval} t) (recur remaining
(conj current-element node)
result)

(#{:comment
:newline} t) (if (some libspec? current-element)
(recur remaining
[]
(conj result (conj current-element node)))
(recur remaining
(conj current-element node)
result))

; otherwise it'll be a libspec node, which should be added if it is the first
; in this element, otherwise close the current element and start a new one; an example
; for this is "[a] b" without a newline, which should result in elements "[a]" and "b"
:else (if (some libspec? current-element)
(recur remaining
[node]
(conj result current-element))
(recur remaining
(conj current-element node)
result)))))))

(defn element-sort-key [element]
(let [relevant (first (filter libspec? element))
t (some-> relevant n/tag)]
(cond
(#{:token} t) (let [value (n/sexpr relevant)]
(cond
(string? value) [0 value]
(symbol? value) [1 (name value)]
:else [2 (str value)]))
(#{:meta} t) (some-> relevant
n/children
;; skip over the metadata itself
rest
element-sort-key)
(#{:vector
:list} t) (element-sort-key (n/children relevant))

:else [10000 nil])))

(defn dedupe-whitespace-nodes
"Remove whitespace nodes following directly behind other whitespace nodes.
This can happen due to rearranging the reference block elements, which may begin and/or
end with whitespace nodes. The rest of the code might get confused by two consecutive
whitespace nodes, as the parser would never allow that situation.
No attempt is made to combine the nodes, just keeping the first one should be fine."
[nodes]
(reduce
(fn [result element]
(if (= :whitespace
(n/tag element)
(some-> result
last
n/tag))
result
(conj result element)))
[]
nodes))

(defn sort-reference-block [zloc]
(let [[first-element
elements] (partition-reference-block-children zloc)
sorted-elements (->> (sort-by element-sort-key elements)
(apply concat first-element)
dedupe-whitespace-nodes)]
(z/replace zloc (-> zloc
z/node
(n/replace-children sorted-elements)))))
99 changes: 98 additions & 1 deletion cljfmt/test/cljfmt/core_test.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -1117,7 +1117,49 @@
":three four}"]
["{:one two #_comment"
" :three four}"]
{:split-keypairs-over-multiple-lines? true})))
{:split-keypairs-over-multiple-lines? true}))
(is (reformats-to?
["(ns foo.bar"
" (:require c"
" [a.b :as b] ;; comment behind"
" ;; comment above"
" [b]"
" \"d\"))"]
["(ns foo.bar"
" (:require \"d\""
" [a.b :as b] ;; comment behind"
" ;; comment above"
" [b]"
" c))"]
{:sort-ns-requires? true}))
(is (reformats-to?
["(ns foo.bar"
" (:require c"
" [a.b :as b] ;; comment behind"
" ;; comment above"
" [b]"
" \"d\"))"]
["(ns foo.bar"
" (:require c"
" [a.b :as b] ;; comment behind"
" ;; comment above"
" [b]"
" \"d\"))"]
{}))
(is (reformats-to?
["(ns foo.bar"
" (:require c"
" [a.b :as b] ;; comment behind"
" ;; comment above"
" [b]"
" \"d\"))"]
["(ns foo.bar"
" (:require c"
" [a.b :as b] ;; comment behind"
" ;; comment above"
" [b]"
" \"d\"))"]
{:sort-ns-requires? false})))

(deftest test-parsing
(is (reformats-to?
Expand Down Expand Up @@ -1210,3 +1252,58 @@
(is (= ((wrap-normalize-newlines identity) "foo\nbar\nbaz") "foo\nbar\nbaz"))
(is (= ((wrap-normalize-newlines identity) "foo\r\nbar\r\nbaz") "foo\r\nbar\r\nbaz"))
(is (= ((wrap-normalize-newlines identity) "foobarbaz") "foobarbaz")))

(deftest test-namespace-sorting
(is (reformats-to?
["(ns foo.bar"
" (:require c"
" [a.b :as b] ;; comment behind"
" ;; comment above"
" [b]"
" \"d\"))"]
["(ns foo.bar"
" (:require \"d\""
" [a.b :as b] ;; comment behind"
" ;; comment above"
" [b]"
" c))"]
{:sort-ns-requires? true}))
(is (reformats-to?
["(ns foo.bar"
" (:require"
" c"
" \"d\""
" [a.b :as b] ;; comment behind"
" ;; comment above"
" [b]))"]
["(ns foo.bar"
" (:require"
" \"d\""
" [a.b :as b] ;; comment behind"
" ;; comment above"
" [b]"
" c))"]
{:sort-ns-requires? true}))
(is (reformats-to?
["(ns foo.bar"
" (:require"
" [foo]c"
" ^:keep"
" [a.b :as b] #_[comment behind] #_another"
" #_[comment above]"
" [b]))"]
["(ns foo.bar"
" (:require"
" ^:keep"
" [a.b :as b] #_[comment behind] #_another"
" #_[comment above]"
" [b]"
" c"
" [foo]))"]
{:sort-ns-requires? true}))
(is (reformats-to?
["(ns foo.bar"
" (:require [foo] c [a.b :as b] #_[comment behind] [b]))"]
["(ns foo.bar"
" (:require [a.b :as b] #_[comment behind] [b] c [foo]))"]
{:sort-ns-requires? true})))

0 comments on commit c347a4c

Please sign in to comment.