diff --git a/.gitignore b/.gitignore index de38f4e2..23375176 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ pom.xml.asc .lein-* .nrepl-port reports +*.cpcache diff --git a/README.md b/README.md index d5a0211d..d746639f 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,17 @@ selectively enabled or disabled: other references in the `ns` forms at the top of your namespaces. Defaults to false. +* `:align-maps?` - + true if cljfmt should left align the values of maps + This will convert `{:foo 1\n:barbaz 2}` to `{:foo 1\n :barbaz 2}` + Defaults to false. + +* `:align-bindings?` - + true if cljfmt should left align the values of bindings + This will convert `(let [foo 1\n barbaz 2])` to `(let [foo 1\n barbaz 2])`. + + Defaults to false. + You can also configure the behavior of cljfmt: * `:paths` - determines which directories to include in the @@ -193,6 +204,35 @@ You can also configure the behavior of cljfmt: :cljfmt {:indents ^:replace {#".*" [[:inner 0]]}} ``` +* `:align-bindings-args` - + a map of var symbols to arguments positions that require binding alignment + i.e. `{symbol #{1 2}`. Argument positions start at 0. + See the next section for a detailed explanation. + + Unqualified symbols in the indents map will apply to any symbol with a + matching "name" - so `foo` would apply to both `org.me/foo` and + `com.them/foo`. If you want finer-grained control, you can use a fully + qualified symbol in the align-bindings-args map to configure binding alignment that + applies only to `org.me/foo`: + + ```clojure + :cljfmt {:align-bindings-args {org.me/foo #{2 3}} + ``` + + Configured this way, `org.me/foo` will align only argument positions 2 3 (starting from 0). + + Note that `cljfmt` currently doesn't resolve symbols brought into a + namespace using `:refer` or `:use` - they can only be controlled by an + unqualified align rule. + + As with Leiningen profiles, you can add metadata hints. If you want to + override all existing aligns, instead of just supplying new aligns + that are merged with the defaults, you can use the `:replace` hint: + + ```clojure + :cljfmt {:align-bindings-args ^:replace {#".*" #{0}} + ``` + * `:alias-map` - a map of namespace alias strings to fully qualified namespace names. This option is unnecessary in almost all cases, because diff --git a/cljfmt/resources/cljfmt/align_bindings/clojure.clj b/cljfmt/resources/cljfmt/align_bindings/clojure.clj new file mode 100644 index 00000000..627e52bd --- /dev/null +++ b/cljfmt/resources/cljfmt/align_bindings/clojure.clj @@ -0,0 +1,9 @@ +{let #{0} + doseq #{0} + go-loop #{0} + binding #{0} + with-open #{0} + loop #{0} + for #{0} + with-local-vars #{0} + with-redefs #{0}} diff --git a/cljfmt/src/cljfmt/core.cljc b/cljfmt/src/cljfmt/core.cljc index 6a0fd7fc..ecc43be5 100644 --- a/cljfmt/src/cljfmt/core.cljc +++ b/cljfmt/src/cljfmt/core.cljc @@ -71,9 +71,89 @@ (not (namespaced-map? (z/up* zloc))) (element? (z/right* zloc)))) +(defn ks->max-length [ks] + (if (empty? ks) + 0 + (->> ks + (apply max-key (comp count str)) + str + count))) + +(def default-align-bindings-args + (read-resource "cljfmt/align_bindings/clojure.clj")) + +(defn- aligner [zloc max-length align?] + (cond + (zero? max-length) (z/up zloc) + (z/rightmost? zloc) (z/up zloc) + align? (let [clean-zloc (-> zloc + z/right* + (z/replace (n/whitespace-node " ")) + z/left) + to-add (->> clean-zloc + z/sexpr + str + count + (- max-length)) + new-zloc (z/insert-space-right clean-zloc to-add)] + (aligner (z/right new-zloc) max-length false)) + :else (aligner (z/right zloc) max-length true))) + +(defn- align-binding [zloc] + (let [se (z/sexpr zloc) + ks (take-nth 2 se) + max-length (ks->max-length ks) + bindings (z/down zloc)] + (if bindings + (aligner bindings max-length true) + zloc))) + +(defn- align-map [zloc] + (let [se (z/sexpr zloc) + ks (keys se) + max-length (ks->max-length ks) + kvs (z/down zloc)] + (if kvs + (aligner kvs max-length true) + zloc))) + +(defn- sibling-distance [left right] + (if (= left right) + 0 + (if (z/rightmost? left) + nil + (when-let [d (sibling-distance (z/right left) right)] + (+ 1 d))))) + +(defn- binding? [zloc align-bindings-args] + (and (z/vector? zloc) + (-> zloc z/sexpr count even?) + (let [sexpr-type (-> zloc + z/leftmost + z/value) + zloc-pos (-> zloc + z/leftmost + (sibling-distance zloc) + dec) + align-args (align-bindings-args sexpr-type)] + (and align-args + (align-args zloc-pos))))) + +(defn- align-map? [zloc] + (z/map? zloc)) + (defn insert-missing-whitespace [form] (transform form edit-all missing-whitespace? z/insert-space-right)) +(defn align-bindings + ([form] + (align-bindings form default-align-bindings-args)) + ([form align-bindings-args] + (transform form edit-all #(binding? % align-bindings-args) align-binding))) + +(defn align-maps [form] + (transform form edit-all align-map? align-map)) + (defn- space? [zloc] (= (z/tag zloc) :whitespace)) @@ -493,7 +573,10 @@ :remove-trailing-whitespace? true :split-keypairs-over-multiple-lines? false :sort-ns-references? false - :indents default-indents + :align-bindings? false + :align-maps? false + :indents default-indents + :align-bindings-args default-align-bindings-args :alias-map {}}) (defn reformat-form @@ -512,6 +595,10 @@ remove-surrounding-whitespace) (cond-> (:insert-missing-whitespace? opts) insert-missing-whitespace) + (cond-> (:align-maps? opts) + align-maps) + (cond-> (:align-bindings? opts) + (align-bindings (:align-bindings-args opts))) (cond-> (:remove-multiple-non-indenting-spaces? opts) remove-multiple-non-indenting-spaces) (cond-> (:indentation? opts) diff --git a/cljfmt/test/cljfmt/core_test.cljc b/cljfmt/test/cljfmt/core_test.cljc index 9bed12bb..4502f073 100644 --- a/cljfmt/test/cljfmt/core_test.cljc +++ b/cljfmt/test/cljfmt/core_test.cljc @@ -1336,3 +1336,168 @@ " ^{:x 1} b" " [c]))"] {:sort-ns-references? true}))) + +(deftest test-align-bindings + (testing "straightforward test cases" + (testing "sanity" + (is (reformats-to? + ["(def x 1)"] + ["(def x 1)"] + {:align-bindings? true}))) + (testing "no op 2" + (is (reformats-to? + ["(let [x 1" + " y 2])"] + ["(let [x 1" + " y 2])"] + {:align-bindings? true}))) + (testing "no op 1" + (is (reformats-to? + ["(let [x 1])"] + ["(let [x 1])"] + {:align-bindings? true}))) + (testing "empty" + (is (reformats-to? + ["(let [])"] + ["(let [])"] + {:align-bindings? true}))) + (testing "simple" + (is (reformats-to? + ["(let [x 1" + " longer 2])"] + ["(let [x 1" + " longer 2])"] + {:align-bindings? true}))) + (testing "nested align" + (is (reformats-to? + ["(let [x (let [x 1" + " longer 2])" + " longer 2])"] + ["(let [x (let [x 1" + " longer 2])" + " longer 2])"] + {:align-bindings? true}))) + (testing "preserves comments" + (is (reformats-to? + ["(let [a 1 ;; comment" + " longer 2])"] + ["(let [a 1 ;; comment" + " longer 2])"] + {:align-bindings? true}))) + (testing "align args" + (testing "simple" + (is (reformats-to? + ["(special something [a 1" + " longer 2])"] + ["(special something [a 1" + " longer 2])"] + {:align-bindings? true + :align-bindings-args {'special #{1}}}))) + (testing "don't mixup args" + (is (reformats-to? + ["(special [a 1" + " longer 2]" + " [a 1" + " longer 2])"] + ["(special [a 1" + " longer 2]" + " [a 1" + " longer 2])"] + {:align-bindings? true + :align-bindings-args {'special #{1}}})))))) + +(deftest test-align-maps + (testing "straightforward test cases" + (testing "sanity" + (is (reformats-to? + ["(def x 1)"] + ["(def x 1)"] + {:align-maps? true}))) + (testing "no op 1" + (is (reformats-to? + ["{:a 1}"] + ["{:a 1}"] + {:align-maps? true}))) + (testing "no op 2" + (is (reformats-to? + ["{:a 1" + " :b 2}"] + ["{:a 1" + " :b 2}"] + {:align-maps? true}))) + (testing "empty" + (is (reformats-to? + ["{}"] + ["{}"] + {:align-maps? true}))) + (testing "simple" + (is (reformats-to? + ["{:x 1" + " :longer 2}"] + ["{:x 1" + " :longer 2}"] + {:align-maps? true}))) + (testing "nested simple" + (is (reformats-to? + ["{:x {:x 1}" + " :longer 2}"] + ["{:x {:x 1}" + " :longer 2}"] + {:align-maps? true}))) + (testing "nested align" + (is (reformats-to? + ["{:x {:x 1" + " :longer 2}" + " :longer 2}"] + ["{:x {:x 1" + " :longer 2}" + " :longer 2}"] + {:align-maps? true}))) + (testing "align many" + (is (reformats-to? + ["{:a 1" + " :longer 2" + " :b 3}"] + ["{:a 1" + " :longer 2" + " :b 3}"] + {:align-maps? true}))) + (testing "preserves comments" + (is (reformats-to? + ["{:a 1 ;; comment" + " :longer 2}"] + ["{:a 1 ;; comment" + " :longer 2}"] + {:align-maps? true}))))) + +(deftest test-align-associative-abnormal + (testing "abnormal test cases" + (testing "indentation off #1" + (is (reformats-to? + ["{ :a 1" + " :longer 2}"] + ["{:a 1" + " :longer 2}"] + {:align-maps? true}))) + (testing "indentation off #2" + (is (reformats-to? + ["{ :a 1" + " :longer 2}"] + ["{:a 1" + " :longer 2}"] + {:align-maps? true}))) + (testing "indentation off #3" + (is (reformats-to? + ["{:a 1" + " :longer 2}"] + ["{:a 1" + " :longer 2}"] + {:align-maps? true}))) + (testing "future effort?" + (testing "multi-value line" + (is (reformats-to? + ["{:a 1 :b 2" + " :longer 2}"] + ["{:a 1 :b 2" + " :longer 2}"] + {:align-maps? true}))))))