Skip to content

Commit

Permalink
Add option to sort ns reference forms
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 forms.

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 2, 2022
1 parent da37b7a commit 7730efc
Show file tree
Hide file tree
Showing 4 changed files with 261 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-references?` -
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
147 changes: 147 additions & 0 deletions cljfmt/src/cljfmt/core.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -398,11 +398,158 @@
(defn remove-multiple-non-indenting-spaces [form]
(transform form edit-all non-indenting-whitespace? replace-with-one-space))

(def ^:private ns-reference-symbols
#{:require
:require-macros
:use
:import})

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

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

(defn- update-children [zloc f]
(z/replace zloc (n/replace-children (z/node zloc) (f (z/down zloc)))))

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

(defn- partition-form [zloc]
; Partition children of a reference form, 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.
(loop [[node & remaining] (-> zloc
z/up
z/node
n/children)
current-element []
result []]
(let [t (some-> node n/tag)]
(cond
(nil? node) (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- dedupe-whitespace-nodes [nodes]
; Remove whitespace nodes following directly behind other whitespace nodes.
; This can happen due to rearranging the child 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.
(reduce
(fn [result element]
(if (= :whitespace
(n/tag element)
(some-> result
last
n/tag))
result
(conj result element)))
[]
nodes))

(defn- sort-arguments-by [zloc f]
(let [[head & args] (partition-form zloc)]
(->> args (sort-by f) (cons head) (apply concat) dedupe-whitespace-nodes)))

(defn- reference-sort-key [element]
; The desired order is as follows:
; ( ; first the keywords, tho only one is expected
; :require
;
; ; then reader macros
; #?(:clj [clojure.java.io :as io])
;
; ; then string libspecs ordered lexicographically
; \"x-node-dependency\"
; \"y-node-dependency\"
;
; ; then any others, ordered lexicographically by the namespace,
; ; but it shouldn't matter whether there is an alias, refers, a vector, or a list
; [a]
; [b.c.d :as d]
; c
; [d :refer [some-function]])"
(let [relevant (first (filter libspec? element))
t (some-> relevant n/tag)]
(cond
(#{:reader-macro} t) (let [value (n/sexpr relevant)]
[0 (pr-str value)])
(#{:token} t) (let [value (n/sexpr relevant)]
(cond
(string? value) [1 value]
(symbol? value) [2 (name value)]
:else [3 (str value)]))
(#{:meta} t) (some-> relevant
n/children
;; skip over the metadata itself
rest
reference-sort-key)
(#{:vector
:list} t) (reference-sort-key (n/children relevant))

:else [10000 nil])))

(defn- sort-references [zloc]
(update-children zloc #(sort-arguments-by % reference-sort-key)))

(defn sort-ns-references [form]
(transform form edit-all ns-reference? sort-references))

(defn reformat-form
([form]
(reformat-form form {}))
([form opts]
(-> form
(cond-> (:sort-ns-references? opts false)
sort-ns-references)
(cond-> (:split-keypairs-over-multiple-lines? opts false)
(split-keypairs-over-multiple-lines))
(cond-> (:remove-consecutive-blank-lines? opts true)
Expand Down
4 changes: 4 additions & 0 deletions cljfmt/src/cljfmt/main.clj
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@
:remove-surrounding-whitespace? true
:remove-trailing-whitespace? true
:remove-consecutive-blank-lines? true
:sort-ns-requires? false
:split-keypairs-over-multiple-lines? false
:indents cljfmt/default-indents
:alias-map {}})
Expand Down Expand Up @@ -199,6 +200,9 @@
[nil "--[no-]remove-consecutive-blank-lines"
:default (:remove-consecutive-blank-lines? default-options)
:id :remove-consecutive-blank-lines?]
[nil "--[no-]sort-ns-requires"
:default (:sort-ns-requires? default-options)
:id :sort-ns-requires?]
[nil "--[no-]split-keypairs-over-multiple-lines"
:default (:split-keypairs-over-multiple-lines? default-options)
:id :split-keypairs-over-multiple-lines?]])
Expand Down
103 changes: 102 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-references? 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-references? false})))

(deftest test-parsing
(is (reformats-to?
Expand Down Expand Up @@ -1210,3 +1252,62 @@
(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-references? 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-references? true}))
(is (reformats-to?
["(ns foo.bar"
" (:require"
" [foo]c"
" #?(:clj [clojure.java.io :as io])"
" #?(:clj [a.b.c])"
" ^:keep"
" [a.b :as b] #_[comment behind] #_another"
" #_[comment above]"
" [b]))"]
["(ns foo.bar"
" (:require"
" #?(:clj [a.b.c])"
" #?(:clj [clojure.java.io :as io])"
" ^:keep"
" [a.b :as b] #_[comment behind] #_another"
" #_[comment above]"
" [b]"
" c"
" [foo]))"]
{:sort-ns-references? 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-references? true})))

0 comments on commit 7730efc

Please sign in to comment.