diff --git a/README.md b/README.md index 19452bb9..2057b73c 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 diff --git a/cljfmt/src/cljfmt/core.cljc b/cljfmt/src/cljfmt/core.cljc index 901c50f0..c951acc1 100644 --- a/cljfmt/src/cljfmt/core.cljc +++ b/cljfmt/src/cljfmt/core.cljc @@ -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] @@ -398,6 +399,19 @@ (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 @@ -405,6 +419,7 @@ :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 @@ -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 diff --git a/cljfmt/src/cljfmt/namespace.cljc b/cljfmt/src/cljfmt/namespace.cljc new file mode 100644 index 00000000..12d9012a --- /dev/null +++ b/cljfmt/src/cljfmt/namespace.cljc @@ -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: + (|)*(|)* + + 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))))) diff --git a/cljfmt/test/cljfmt/core_test.cljc b/cljfmt/test/cljfmt/core_test.cljc index bcb0cde8..15d54ed4 100644 --- a/cljfmt/test/cljfmt/core_test.cljc +++ b/cljfmt/test/cljfmt/core_test.cljc @@ -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? @@ -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})))