diff --git a/README.md b/README.md index 43e52f32..fd9ffeef 100644 --- a/README.md +++ b/README.md @@ -316,6 +316,51 @@ In order to load the standard configuration file from Leiningen, add the :as everything} ;; 27 spaces, falls back to 1 space ``` +* `:max-column-alignment-width` - + a positive integer that limits which keys participate in column + alignment. Keys whose end column is at or within N are aligned + together; keys that end past column N are not padded and receive a + single space before their value. Defaults to nil (no limit). + **Experimental.** + + ```clojure + ;; Input: + {:a 1 + :abcde 2 + :abcdefg 3 + :long-name 4} + + ;; With :align-map-columns? true and :max-column-alignment-width 7: + ;; :a and :abcde end at or within column 7 and align together; + ;; :abcdefg and :long-name end past column 7 and are not padded + {:a 1 ;; ends within column 7, padded to value column + :abcde 2 ;; ends at column 7, sets the value column + :abcdefg 3 ;; ends past column 7, 1 space + :long-name 4} ;; ends past column 7, 1 space + ``` + +* `:break-on-max-column-alignment-width?` - + true if cljfmt should move a value onto its own line, indented under + the keys, when its key ends past the column set by + `:max-column-alignment-width`, rather than falling back to a single + space on the same line. Has no effect unless + `:max-column-alignment-width` is also set. Defaults to false. + **Experimental.** + + ```clojure + ;; Input: + {:a 1 + :bb 2 + :ccccccc 3} + + ;; With :align-map-columns? true, :max-column-alignment-width 4, + ;; and :break-on-max-column-alignment-width? true: + {:a 1 ;; ends within column 4, padded to value column + :bb 2 ;; ends at column 4, sets the value column + :ccccccc ;; ends past column 4, value moves to its own line + 3} + ``` + * `:blank-lines-separate-alignment?` - true if cljfmt should treat blank lines as separators when aligning columns. When enabled, alignment groups are separated by blank lines, diff --git a/cljfmt/src/cljfmt/core.cljc b/cljfmt/src/cljfmt/core.cljc index 914622f3..649c5037 100644 --- a/cljfmt/src/cljfmt/core.cljc +++ b/cljfmt/src/cljfmt/core.cljc @@ -383,8 +383,10 @@ :align-single-column-lines? false :aligned-forms default-aligned-forms :max-column-alignment-gap nil + :max-column-alignment-width nil :blank-line-forms blank-line-forms :blank-lines-separate-alignment? false + :break-on-max-column-alignment-width? false :extra-aligned-forms {} :extra-blank-line-forms {} :extra-indents {} @@ -670,13 +672,31 @@ (defn- count-spaces [zloc] (if (space? zloc) (node-str-length zloc) 0)) -(defn- pad-to-position [zloc start-position {max-gap :max-column-alignment-gap}] +(defn- break-to-own-line [zloc] + (let [indent (coll-indent zloc)] + (-> zloc + z/left* + (z/replace* (n/newlines 1)) + z/right* + (z/insert-left* (whitespace indent))))) + +(defn- pad-to-position [zloc {:keys [position threshold]} + {max-gap :max-column-alignment-gap + break? :break-on-max-column-alignment-width?}] {:pre [(or (nil? max-gap) (pos-int? max-gap))]} - (let [delta (- start-position (margin zloc))] - (if max-gap - (let [old-gap (count-spaces (z/left* zloc)) - new-gap (+ old-gap delta)] - (pad-node zloc (if (> new-gap max-gap) (- 1 old-gap) delta))) + (let [old-gap (count-spaces (z/left* zloc)) + delta (- position (margin zloc)) + new-gap (+ old-gap delta) + key-end (- (margin zloc) old-gap)] + (cond + (and break? threshold (> key-end threshold)) + (break-to-own-line zloc) + + (or (< new-gap 1) + (and max-gap (> new-gap max-gap))) + (pad-node zloc (- 1 old-gap)) + + :else (pad-node zloc delta)))) (defn- edit-column [zloc column f] @@ -707,17 +727,25 @@ (recur (z/right* zloc) 0 acc) (recur (z/right* zloc) (inc col) (f zloc col acc))))))) -(defn- column-start-position [zloc col opts] - (let [reduce-fn (if (:blank-lines-separate-alignment? opts) +(defn- column-start-position + [zloc col {align-width :max-column-alignment-width + align-singles? :align-single-column-lines? + separate? :blank-lines-separate-alignment?}] + (let [reduce-fn (if separate? reduce-column-group reduce-columns) - maximizer (fn [zloc c max-pos] + collector (fn [zloc c positions] (if (and (= c (dec col)) - (or (:align-single-column-lines? opts) + (or align-singles? (not (single-column-line? zloc)))) - (max max-pos (node-end-position zloc)) - max-pos))] - (inc (reduce-fn zloc maximizer 0)))) + (conj positions (node-end-position zloc)) + positions)) + positions (reduce-fn zloc collector []) + threshold (when align-width (+ (apply min 0 positions) align-width))] + {:position (inc (apply max 0 (if threshold + (filter #(<= % threshold) positions) + positions))) + :threshold threshold})) (defn- align-one-column [zloc col {:keys [blank-lines-separate-alignment?] :as opts}] diff --git a/cljfmt/src/cljfmt/main.clj b/cljfmt/src/cljfmt/main.clj index f61447ee..d46ce770 100644 --- a/cljfmt/src/cljfmt/main.clj +++ b/cljfmt/src/cljfmt/main.clj @@ -26,6 +26,9 @@ [nil "--[no-]blank-lines-separate-alignment" :default (:blank-lines-separate-alignment? defaults) :id :blank-lines-separate-alignment?] + [nil "--[no-]break-on-max-column-alignment-width" + :default (:break-on-max-column-alignment-width? defaults) + :id :break-on-max-column-alignment-width?] [nil "--max-column-alignment-gap" :default (:max-column-alignment-gap defaults) :parse-fn #(cond-> % (string? %) parse-long) diff --git a/cljfmt/test/cljfmt/core_test.cljc b/cljfmt/test/cljfmt/core_test.cljc index 5bdc3c57..4b9835e9 100644 --- a/cljfmt/test/cljfmt/core_test.cljc +++ b/cljfmt/test/cljfmt/core_test.cljc @@ -3193,6 +3193,97 @@ {:align-map-columns? true :max-column-alignment-gap 5})))) +(deftest test-max-column-alignment-width + (testing "form: anchor demoted until all keys fit within width" + (is (reformats-to? + ["(let [a 1" + " abcde 2" + " abcdefg 3" + " long-name 4]" + " [a abcde abcdefg long-name])"] + ["(let [a 1" + " abcde 2" + " abcdefg 3" + " long-name 4]" + " [a abcde abcdefg long-name])"] + {:align-form-columns? true + :max-column-alignment-width 11}))) + (testing "map: anchor demoted until all keys fit within width" + (is (reformats-to? + ["{:a \"x\"" + " :abcde \"y\"" + " :abcdefg \"z\"" + " :long-key \"w\"}"] + ["{:a \"x\"" + " :abcde \"y\"" + " :abcdefg \"z\"" + " :long-key \"w\"}"] + {:align-map-columns? true + :max-column-alignment-width 7}))) + (testing "composing both flags refines alignment further" + (is (reformats-to? + ["{:x \"a\"" + " :abc \"b\"" + " :abcde \"c\"" + " :long-key \"d\"}"] + ["{:x \"a\"" + " :abc \"b\"" + " :abcde \"c\"" + " :long-key \"d\"}"] + {:align-map-columns? true + :max-column-alignment-width 7 + :max-column-alignment-gap 3})))) + +(deftest test-break-on-max-column-alignment-width + (testing "form: the one outlier value is moved to its own line, indented under the bindings" + (is (reformats-to? + ["(let [a 1" + " bb 2" + " ccccccc 3]" + " body)"] + ["(let [a 1" + " bb 2" + " ccccccc" + " 3]" + " body)"] + {:align-form-columns? true + :max-column-alignment-width 8 + :break-on-max-column-alignment-width? true}))) + (testing "map: the one outlier value is moved to its own line, indented under the keys" + (is (reformats-to? + ["{:a \"x\"" + " :bb \"y\"" + " :ccccccc \"z\"}"] + ["{:a \"x\"" + " :bb \"y\"" + " :ccccccc" + " \"z\"}"] + {:align-map-columns? true + :max-column-alignment-width 4 + :break-on-max-column-alignment-width? true}))) + (testing "no effect when :max-column-alignment-width is not set" + (is (reformats-to? + ["{:x 1" + " :longer 2}"] + ["{:x 1" + " :longer 2}"] + {:align-map-columns? true + :max-column-alignment-gap 5 + :break-on-max-column-alignment-width? true}))) + (testing "value already on its own line is left alone (idempotent)" + (is (reformats-to? + ["{:a 1" + " :bb 2" + " :ccccccc" + " 3}"] + ["{:a 1" + " :bb 2" + " :ccccccc" + " 3}"] + {:align-map-columns? true + :max-column-alignment-width 4 + :break-on-max-column-alignment-width? true})))) + (deftest test-realign-form (is (= " {:x 1