diff --git a/.gitignore b/.gitignore index 02ca798d..e7b78371 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ pom.xml.asc reports .cpcache .clj-kondo +.lsp +.idea +*.iml diff --git a/README.md b/README.md index bdebb86c..abd701df 100644 --- a/README.md +++ b/README.md @@ -288,6 +288,38 @@ In order to load the standard configuration file from Leiningen, add the Defaults to `:community` +* `:align-maps?` - + True if cljfmt should left align the values of maps. + + This will convert: + ```clojure + {:foo 1 + :barbaz 2} + ``` + To: + ```clojure + {:foo 1 + :barbaz 2} + ``` + Defaults to `false`. + +* `:align-forms?` - + true if cljfmt should left align the values of specified forms + This will convert: + ```clojure + (let [foo 1 + barbaz 2]) + ``` + To: + ```clojure + (let [foo 1 + barbaz 2]) + ``` + + Defaults to false. + +You can also configure the behavior of cljfmt: + [indents.md]: docs/INDENTS.md [community style recommendation]: https://guide.clojure.style/#one-space-indent @@ -309,9 +341,47 @@ In order to load the standard configuration file from Leiningen, add the Paths can also be passed as command line arguments. If the path is `-`, then the input is STDIN, and the output STDOUT. +* `:aligns` - + a map of var symbols to arguments' positions that require alignment + i.e. `{myform #{1 2}}` will align `[a 1]` and `[b 2]` forms like `(myform 0 [a 1] [b 2])`. + Argument positions 0-indexed. + See the next section for a detailed explanation. + + Unqualified symbols in the align 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 aligns map to configure form alignment that + applies only to `org.me/foo`: + + ```clojure + :cljfmt {:aligns {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 {:aligns ^:replace {#".*" #{0}} + ``` + +## Compile binary locally + +1. Download GraalVM for Java SDK, eg `sdk install java 24.0.2-graal` +2. Use in current shell with `sdk use java 24.0.2-graal` +3. `cd cljfmt` +4. Run `lein native-image` +5. Try binary with `./target/cljfmt --help` + ## License -Copyright © 2024 James Reeves +Copyright © 2025 James Reeves Distributed under the Eclipse Public License either version 1.0 or (at your option) any later version. diff --git a/cljfmt.edn b/cljfmt.edn index a1403530..bca5f209 100644 --- a/cljfmt.edn +++ b/cljfmt.edn @@ -3,4 +3,7 @@ "cljfmt/test" "cljfmt/project.clj" "lein-cljfmt/src"] - :sort-ns-references? true} + :sort-ns-references? true + :align-forms? true + :align-maps? true + } diff --git a/cljfmt/resources/cljfmt/align/clojure.clj b/cljfmt/resources/cljfmt/align/clojure.clj new file mode 100644 index 00000000..627e52bd --- /dev/null +++ b/cljfmt/resources/cljfmt/align/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 d22d75f0..530e5796 100644 --- a/cljfmt/src/cljfmt/core.cljc +++ b/cljfmt/src/cljfmt/core.cljc @@ -336,6 +336,11 @@ (read-resource "cljfmt/indents/compojure.clj") (read-resource "cljfmt/indents/fuzzy.clj"))) + +(def ^:private default-aligns + (read-resource "cljfmt/align/clojure.clj")) + + (def default-options {:indentation? true :insert-missing-whitespace? true @@ -346,9 +351,12 @@ :split-keypairs-over-multiple-lines? false :sort-ns-references? false :function-arguments-indentation :community - :indents default-indents - :extra-indents {} - :alias-map {}}) + :indents default-indents + :extra-indents {} + :align-maps? false + :align-forms? false + :aligns default-aligns + :alias-map {}}) (defmulti ^:private indenter-fn (fn [_sym _context [type & _args]] type)) @@ -555,6 +563,130 @@ (defn sort-ns-references [form] (transform form edit-all ns-reference? sort-arguments)) +(defn- node-width [zloc] + (-> zloc z/node n/string count)) + +(defn- node-column [zloc] + (loop [zloc (z/left* zloc), n 0] + (if (or (nil? zloc) (line-break? zloc)) + n + (recur (z/left* zloc) + (if (clojure-whitespace? zloc) n (inc n)))))) + +(defn- group-separator? [zloc] + (= (z/string zloc) "\n\n")) + +(defn- node-group [zloc] + (loop [zloc (z/left* zloc), n 0] + (if (nil? zloc) + n + (recur (z/left* zloc) + (if (group-separator? zloc) (inc n) n))))) + +(defn- comma-after? [zloc] + (let [right (z/right* zloc)] + (or (comma? right) + (and (z/whitespace? right) (comma? (z/right* right)))))) + +(defn- max-group-column-widths [zloc] + (loop [zloc (z/down zloc), max-widths {}] + (if (nil? zloc) + max-widths + (let [width (if (comma-after? zloc) + (inc (node-width zloc)) + (node-width zloc)) + column (node-column zloc) + group (node-group zloc)] + (recur (z/right zloc) + (update-in max-widths [group column] (fnil max 0) width)))))) + +(defn- quote? [zloc] + (-> zloc + z/node + n/tag + (= :quote))) + +(defn- remove-space-right [zloc] + (let [right (z/right* zloc)] + (if (space? right) + (if (quote? zloc) + (z/up (z/remove* right)) + (z/remove* right)) + zloc))) + +(defn- insert-space-right [zloc n] + (let [right (z/right* zloc)] + (if (comma? right) + (insert-space-right (remove-space-right right) (dec n)) + (z/insert-right* zloc (whitespace n))))) + +(defn- set-spacing-right [zloc n] + (-> zloc (remove-space-right) (insert-space-right n))) + +(defn- map-children [zloc f] + (if-let [zloc (z/down zloc)] + (loop [zloc zloc] + (let [zloc (f zloc)] + (if-let [zloc (z/right zloc)] + (recur zloc) + (z/up zloc)))) + zloc)) + +(defn- composite-node? [zloc] + (#{:map :vector :set :list} (z/tag zloc))) + + +(defn- pad-node [zloc width] + (let [padding-needed (- width (node-width zloc))] + (if (and (composite-node? zloc) (> padding-needed 0)) + ;; For composite nodes, remove existing space and insert correct amount + (let [right (z/right* zloc)] + (if (space? right) + ;; Remove space - z/remove* returns zloc at previous position + ;; which for a space after a composite is inside the composite + (let [after-remove (z/remove* right) + ;; Navigate back up to the composite level + at-composite (if (z/up* after-remove) + (loop [loc after-remove] + (if (= (z/node (z/up* loc)) (z/node zloc)) + loc + (if-let [up (z/up* loc)] + (recur up) + loc))) + after-remove)] + ;; Go up one level to be at the composite node level + (if (z/up* at-composite) + (z/insert-right* (z/up* at-composite) (whitespace padding-needed)) + (z/insert-right* at-composite (whitespace padding-needed)))) + ;; No space to remove, just insert + (z/insert-right* zloc (whitespace padding-needed)))) + ;; For non-composite nodes, use the standard approach + (set-spacing-right zloc padding-needed)))) + +(defn- end-of-line? [zloc] + (line-break? (skip-whitespace-and-commas (z/right* zloc)))) + +(defn- align-form-columns [zloc] + (let [max-widths (max-group-column-widths zloc)] + (map-children zloc #(cond-> % + (and (z/right %) (not (end-of-line? %))) + (pad-node (inc (get-in max-widths [(node-group %) (node-column %)]))))))) + +(defn align-maps [form] + (transform form edit-all z/map? align-form-columns)) + + +(defn alignable? [aligns form] + (let [zloc form] + (when (and (not (z/whitespace-or-comment? zloc)) + (z/list? (z/up zloc))) + (let [form-type (some-> zloc z/up z/down z/string symbol)] + (when-let [alignable-indices (aligns form-type)] + (contains? alignable-indices (-> zloc index-of dec))))))) + +(defn align-forms [form aligns] + (transform form edit-all (partial alignable? aligns) align-form-columns)) + (defn reformat-form ([form] (reformat-form form {})) @@ -573,6 +705,10 @@ insert-missing-whitespace) (cond-> (:remove-multiple-non-indenting-spaces? opts) remove-multiple-non-indenting-spaces) + (cond-> (:align-maps? opts) + align-maps) + (cond-> (:align-forms? opts) + (align-forms (:aligns opts))) (cond-> (:indentation? opts) (reindent (merge (:indents opts) (:extra-indents opts)) (:alias-map opts) diff --git a/cljfmt/test/cljfmt/core_test.cljc b/cljfmt/test/cljfmt/core_test.cljc index 404cbe87..8ffbfa84 100644 --- a/cljfmt/test/cljfmt/core_test.cljc +++ b/cljfmt/test/cljfmt/core_test.cljc @@ -1845,3 +1845,280 @@ (deftest test-clojure-12-syntax (is (reformats-to? ["^Long/1 a"] ["^Long/1 a"]))) + +(deftest test-align-forms + (testing "straightforward test cases" + (testing "sanity" + (is (reformats-to? + ["(def x 1)"] + ["(def x 1)"] + {:align-forms? true}))) + (testing "no op 2" + (is (reformats-to? + ["(let [x 1" + " y 2])"] + ["(let [x 1" + " y 2])"] + {:align-forms? true}))) + (testing "no op 1" + (is (reformats-to? + ["(let [x 1])"] + ["(let [x 1])"] + {:align-forms? true}))) + (testing "empty" + (is (reformats-to? + ["(let [])"] + ["(let [])"] + {:align-forms? true}))) + (testing "simple" + (is (reformats-to? + ["(let [x 1" + " longer 2])"] + ["(let [x 1" + " longer 2])"] + {:align-forms? 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-forms? true}))) + (testing "preserves comments" + (is (reformats-to? + ["(let [a 1 ;; comment" + " longer 2])"] + ["(let [a 1 ;; comment" + " longer 2])"] + {:align-forms? true}))) + (testing "align args" + (testing "simple" + (is (reformats-to? + ["(special something [a 1" + " longer 2])"] + ["(special something [a 1" + " longer 2])"] + {:align-forms? true + :aligns {'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-forms? true + :aligns {'special #{1}}})))) + (testing "binding forms" + (testing "simple symbol binding forms" + (is (reformats-to? + ["(let [is-user? (node/type? node/user entry)" + " admin? true])"] + ["(let [is-user? (node/type? node/user entry)" + " admin? true])"] + {:align-forms? true}))) + (testing "map binding forms" + (is (reformats-to? + ["(let [{:keys [id]} @(simplechat.tx/create-chat cluster org account)])"] + ["(let [{:keys [id]} @(simplechat.tx/create-chat cluster org account)])"] + {:align-forms? true}))) + (testing "vector binding forms" + (is (reformats-to? + ["(let [[value set-value!] (ls/use-state \"foo\" \"\")" + " [other thing] (something-else)])"] + ["(let [[value set-value!] (ls/use-state \"foo\" \"\")" + " [other thing] (something-else)])"] + {:align-forms? true}))) + (testing "mixed binding forms with complex case" + (is (reformats-to? + ["(let [user (get-user)" + " [value set-value!] (ls/use-state \"foo\" \"\")" + " {:keys [id name]} (get-data)" + " [a b] (something)" + " simple-var 42])"] + ["(let [user (get-user)" + " [value set-value!] (ls/use-state \"foo\" \"\")" + " {:keys [id name]} (get-data)" + " [a b] (something)" + " simple-var 42])"] + {:align-forms? true})))))) + + +(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})))) + (testing "non-trivial test cases" + (testing "idnentation after align" + (is (reformats-to? + ["(def m {{:a 1" + ":b 2} [x" + "y]" + ":d [z]})"] + ["(def m {{:a 1" + " :b 2} [x" + " y]" + " :d [z]})"]))) + (testing "cljs map values" + (is (reformats-to? + ["{:indents {'thing.core/defthing [[:inner 0]]" + "'let [[:inner 0]]}" + "#?@(:cljs [:alias-map {}])}"] + ["{:indents {'thing.core/defthing [[:inner 0]]" + " 'let [[:inner 0]]}" + " #?@(:cljs [:alias-map {}])}"] + {:align-maps? true}))) + (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 "columns" + (testing "multi-value line" + (is (reformats-to? + ["{:a 1 :b 2" + " :longer 3}"] + ["{:a 1 :b 2" + " :longer 3}"] + {:align-maps? true}))) + (testing "multi-value line" + (is (reformats-to? + ["{:a 1 :longer-a 2" + " :longer-b 3 :c 4}"] + ["{:a 1 :longer-a 2" + " :longer-b 3 :c 4}"] + {:align-maps? true}))) + (testing "multi-value commas" + (is (reformats-to? + ["{:a 1, :longer-a 2" + " :longer-b 3 , :c 4}"] + ["{:a 1, :longer-a 2" + " :longer-b 3, :c 4}"] + {:align-maps? true}))) + (testing "multi-value uneven" + (is (reformats-to? + ["{:a 1 :longer-a 2 :c 3" + " :longer-b 4 :d 5}"] + ["{:a 1 :longer-a 2 :c 3" + " :longer-b 4 :d 5}"] + {:align-maps? true}))) + (testing "multi-value groups 1" + (is (reformats-to? + ["{:a 1 :longer-a 2" + " :longer-b 3 :c 4" + "" + " :d 5 :e 6" + " :fg 7 :h 8}"] + ["{:a 1 :longer-a 2" + " :longer-b 3 :c 4" + "" + " :d 5 :e 6" + " :fg 7 :h 8}"] + {:align-maps? true}))) + (testing "multi-value groups 2" + (is (reformats-to? + ["{:a 1 :longer-a 2" + " :longer-b 3 :c 4" + "" + "" + " :d 5 :e 6" + " :fg 7 :h 8" + "" + " :i 9 :jklmno 10" + " :p 11 :q :value}"] + ["{:a 1 :longer-a 2" + " :longer-b 3 :c 4" + "" + " :d 5 :e 6" + " :fg 7 :h 8" + "" + " :i 9 :jklmno 10" + " :p 11 :q :value}"] + {:align-maps? true}))) + (testing "multi-value partial commas" + (is (reformats-to? + ["{:a 1 :longer-a 2" + " :longer-b 3 , :c 4}"] + ["{:a 1 :longer-a 2" + " :longer-b 3, :c 4}"] + {:align-maps? true})))))) diff --git a/install.sh b/install.sh index 576a8cca..73a77528 100755 --- a/install.sh +++ b/install.sh @@ -37,6 +37,7 @@ echo -n "Downloading cljfmt binaries... " curl -o /tmp/cljfmt.tar.gz -sL "$URL" echo "Done!" +sudo mkdir -p /usr/local/bin sudo tar -xzf /tmp/cljfmt.tar.gz -C /usr/local/bin echo "Extracted cljfmt into /usr/local/bin"