From 5a863c35433c0635192d4ba367ec8184d8d09d89 Mon Sep 17 00:00:00 2001 From: Matt Spencer Date: Fri, 24 Jul 2026 15:29:50 -0400 Subject: [PATCH] fix max-column-alignment-gap to demote anchor when gap limit exceeded --- README.md | 32 +++++++++++++++++++ cljfmt/src/cljfmt/core.cljc | 51 ++++++++++++++++++++----------- cljfmt/test/cljfmt/core_test.cljc | 41 +++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 43e52f32..f7b9cc27 100644 --- a/README.md +++ b/README.md @@ -316,6 +316,38 @@ In order to load the standard configuration file from Leiningen, add the :as everything} ;; 27 spaces, falls back to 1 space ``` +* `:max-column-anchor-gap` - + a positive integer that controls anchor selection during column + alignment. Instead of using the longest key as the alignment anchor, + cljfmt selects the largest key for which every shorter key would need + no more than N spaces to align. Keys longer than the chosen anchor + are not padded. Defaults to nil (no limit). **Experimental.** + + This differs from `:max-column-alignment-gap` in where the limit is + applied: `:max-column-alignment-gap` checks each row individually + after the anchor is determined; `:max-column-anchor-gap` demotes the + anchor itself so that all shorter keys fit within the limit. + + ```clojure + ;; Input: + {:a 1 + :abcde 2 + :abcdefg 3 + :long-name 4} + + ;; With :align-map-columns? true and :max-column-anchor-gap 5: + ;; long-name (gap 7) and abcdefg (gap 5) exceed the limit from :a; + ;; anchor demotes to :abcde (gap for :a = 4 ≤ 5) + {:a 1 ;; 4-space gap, aligns + :abcde 2 ;; anchor, 1 space + :abcdefg 3 ;; longer than anchor, 1 space + :long-name 4} ;; longer than anchor, 1 space + ``` + + Both flags can be used together. `:max-column-anchor-gap` runs first + to select the anchor; `:max-column-alignment-gap` then trims any + individual rows whose gap still exceeds its limit. + * `: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..ec7552ce 100644 --- a/cljfmt/src/cljfmt/core.cljc +++ b/cljfmt/src/cljfmt/core.cljc @@ -383,6 +383,7 @@ :align-single-column-lines? false :aligned-forms default-aligned-forms :max-column-alignment-gap nil + :max-column-anchor-gap nil :blank-line-forms blank-line-forms :blank-lines-separate-alignment? false :extra-aligned-forms {} @@ -670,14 +671,18 @@ (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}] - {: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))) - (pad-node zloc delta)))) +(defn- pad-to-position [zloc start-position {max-gap :max-column-alignment-gap + anchor-gap :max-column-anchor-gap}] + {:pre [(or (nil? max-gap) (pos-int? max-gap)) + (or (nil? anchor-gap) (pos-int? anchor-gap))]} + (let [old-gap (count-spaces (z/left* zloc)) + delta (- start-position (margin zloc)) + new-gap (+ old-gap delta)] + (cond + (< new-gap 1) (pad-node zloc (- 1 old-gap)) + (and max-gap + (> new-gap max-gap)) (pad-node zloc (- 1 old-gap)) + :else (pad-node zloc delta)))) (defn- edit-column [zloc column f] (loop [zloc zloc, col 0] @@ -708,16 +713,26 @@ (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) - reduce-column-group - reduce-columns) - maximizer (fn [zloc c max-pos] - (if (and (= c (dec col)) - (or (:align-single-column-lines? opts) - (not (single-column-line? zloc)))) - (max max-pos (node-end-position zloc)) - max-pos))] - (inc (reduce-fn zloc maximizer 0)))) + (let [reduce-fn (if (:blank-lines-separate-alignment? opts) + reduce-column-group + reduce-columns) + anchor-gap (:max-column-anchor-gap opts) + collector (fn [zloc c positions] + (if (and (= c (dec col)) + (or (:align-single-column-lines? opts) + (not (single-column-line? zloc)))) + (conj positions (node-end-position zloc)) + positions)) + positions (reduce-fn zloc collector []) + anchor (when (seq positions) + (if anchor-gap + (let [min-pos (apply min positions) + threshold (+ min-pos anchor-gap)] + (->> positions + (filter #(<= % threshold)) + (apply max))) + (apply max positions)))] + (inc (or anchor 0)))) (defn- align-one-column [zloc col {:keys [blank-lines-separate-alignment?] :as opts}] diff --git a/cljfmt/test/cljfmt/core_test.cljc b/cljfmt/test/cljfmt/core_test.cljc index 5bdc3c57..c42d73fb 100644 --- a/cljfmt/test/cljfmt/core_test.cljc +++ b/cljfmt/test/cljfmt/core_test.cljc @@ -3193,6 +3193,47 @@ {:align-map-columns? true :max-column-alignment-gap 5})))) +(deftest test-max-column-anchor-gap + (testing "anchor demoted until all shorter keys fit within gap — form columns" + (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-anchor-gap 5}))) + (testing "anchor demoted until all shorter keys fit within gap — map columns" + (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-anchor-gap 5}))) + (testing "both flags compose: anchor-gap selects anchor, alignment-gap trims rows" + (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-anchor-gap 5 + :max-column-alignment-gap 3})))) + (deftest test-realign-form (is (= " {:x 1