branch: elpa/clojure-ts-mode
commit e960a905ab9ae6c77101ca1e65dd76e59c7f4009
Author: Roman Rudakov <rruda...@fastmail.com>
Commit: Bozhidar Batsov <bozhi...@batsov.dev>

    [#16] Add support for automatic aligning forms
---
 CHANGELOG.md                             |   1 +
 README.md                                |   4 +
 clojure-ts-mode.el                       | 145 ++++++++++++++++--------
 test/clojure-ts-mode-indentation-test.el | 188 ++++++++++++++++++++++++++++++-
 test/samples/align.clj                   |  27 ++++-
 5 files changed, 318 insertions(+), 47 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8c11acd343..281c42581a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
 
 - [#16](https://github.com/clojure-emacs/clojure-ts-mode/issues/16): Introduce 
`clojure-ts-align`.
 - [#11](https://github.com/clojure-emacs/clojure-ts-mode/issues/11): Enable 
regex syntax highlighting.
+- [#16](https://github.com/clojure-emacs/clojure-ts-mode/issues/16): Add 
support for automatic aligning forms.
 
 ## 0.3.0 (2025-04-15)
 
diff --git a/README.md b/README.md
index 02e598a878..251effc071 100644
--- a/README.md
+++ b/README.md
@@ -259,6 +259,10 @@ Leads to the following:
    :other-key 2})
 ```
 
+This can also be done automatically (as part of indentation) by turning on
+`clojure-ts-align-forms-automatically`. This way it will happen whenever you
+select some code and hit `TAB`.
+
 Forms that can be aligned vertically are configured via the following 
variables:
 
 - `clojure-ts-align-reader-conditionals` - align reader conditionals as if they
diff --git a/clojure-ts-mode.el b/clojure-ts-mode.el
index f88f342158..f1de91d015 100644
--- a/clojure-ts-mode.el
+++ b/clojure-ts-mode.el
@@ -197,6 +197,22 @@ double quotes on the third column."
   :safe #'listp
   :type '(repeat string))
 
+(defcustom clojure-ts-align-forms-automatically nil
+  "If non-nil, vertically align some forms automatically.
+
+Automatically means it is done as part of indenting code.  This applies
+to binding forms (`clojure-ts-align-binding-forms'), to cond
+forms (`clojure-ts-align-cond-forms') and to map literals.  For
+instance, selecting a map a hitting
+\\<clojure-ts-mode-map>`\\[indent-for-tab-command]' will align the
+values like this:
+
+{:some-key 10
+ :key2     20}"
+  :package-version '(clojure-ts-mode . "0.4")
+  :safe #'booleanp
+  :type 'boolean)
+
 (defvar clojure-ts-mode-remappings
   '((clojure-mode . clojure-ts-mode)
     (clojurescript-mode . clojure-ts-clojurescript-mode)
@@ -1340,6 +1356,9 @@ if NODE has metadata and its parent has type NODE-TYPE."
      ((parent-is "vec_lit") parent 1) ;; 
https://guide.clojure.style/#bindings-alignment
      ((parent-is "map_lit") parent 1) ;; 
https://guide.clojure.style/#map-keys-alignment
      ((parent-is "set_lit") parent 2)
+     ((parent-is "splicing_read_cond_lit") parent 4)
+     ((parent-is "read_cond_lit") parent 3)
+     ((parent-is "tagged_or_ctor_lit") parent 0)
      ;; https://guide.clojure.style/#body-indentation
      (clojure-ts--match-form-body clojure-ts--anchor-parent-skip-metadata 2)
      ;; https://guide.clojure.style/#threading-macros-alignment
@@ -1447,32 +1466,56 @@ Regular expression and syntax analysis code is borrowed 
from
 
 BOUND bounds the whitespace search."
   (unwind-protect
-      (when-let* ((cur-sexp (treesit-node-first-child-for-pos root-node 
(point) t)))
-        (goto-char (treesit-node-start cur-sexp))
-        (if (and (string= "sym_lit" (treesit-node-type cur-sexp))
-                 (clojure-ts--metadata-node-p (treesit-node-child cur-sexp 0 
t))
-                 (and (not (treesit-node-child-by-field-name cur-sexp "value"))
-                      (string-empty-p (clojure-ts--named-node-text cur-sexp))))
-            (treesit-end-of-thing 'sexp 2 'restricted)
-          (treesit-end-of-thing 'sexp 1 'restrict))
-        (when (looking-at ",")
-          (forward-char))
-        ;; Move past any whitespace or comment.
-        (search-forward-regexp "\\([,\s\t]*\\)\\(;+.*\\)?" bound)
-        (pcase (syntax-after (point))
-          ;; End-of-line, try again on next line.
-          (`(12) (clojure-ts--search-whitespace-after-next-sexp root-node 
bound))
-          ;; Closing paren, stop here.
-          (`(5 . ,_) nil)
-          ;; Anything else is something to align.
-          (_ (point))))
+      (let ((regex "\\([,\s\t]*\\)\\(;+.*\\)?"))
+        ;; If we're on an empty line, we should return match, otherwise
+        ;; `clojure-ts-align-separator' setting won't work.
+        (if (and (bolp) (looking-at-p "[[:blank:]]*$"))
+            (progn
+              (search-forward-regexp regex bound)
+              (point))
+          (when-let* ((cur-sexp (treesit-node-first-child-for-pos root-node 
(point) t)))
+            (goto-char (treesit-node-start cur-sexp))
+            (if (and (string= "sym_lit" (treesit-node-type cur-sexp))
+                     (clojure-ts--metadata-node-p (treesit-node-child cur-sexp 
0 t))
+                     (and (not (treesit-node-child-by-field-name cur-sexp 
"value"))
+                          (string-empty-p (clojure-ts--named-node-text 
cur-sexp))))
+                (treesit-end-of-thing 'sexp 2 'restricted)
+              (treesit-end-of-thing 'sexp 1 'restrict))
+            (when (looking-at ",")
+              (forward-char))
+            ;; Move past any whitespace or comment.
+            (search-forward-regexp regex bound)
+            (pcase (syntax-after (point))
+              ;; End-of-line, try again on next line.
+              (`(12) (progn
+                       (forward-char 1)
+                       (clojure-ts--search-whitespace-after-next-sexp 
root-node bound)))
+              ;; Closing paren, stop here.
+              (`(5 . ,_) nil)
+              ;; Anything else is something to align.
+              (_ (point))))))
     (when (and bound (> (point) bound))
       (goto-char bound))))
 
-(defun clojure-ts--get-nodes-to-align (region-node beg end)
+(defun clojure-ts--region-node (beg end)
+  "Return the smallest node that covers buffer positions BEG to END."
+  (let* ((root-node (treesit-buffer-root-node 'clojure)))
+    (treesit-node-descendant-for-range root-node beg end t)))
+
+(defun clojure-ts--node-from-sexp-data (beg end sexp)
+  "Return updated node using SEXP data in the region between BEG and END."
+  (let* ((new-region-node (clojure-ts--region-node beg end))
+         (sexp-beg (marker-position (plist-get sexp :beg-marker)))
+         (sexp-end (marker-position (plist-get sexp :end-marker))))
+    (treesit-node-descendant-for-range new-region-node
+                                       sexp-beg
+                                       sexp-end
+                                       t)))
+
+(defun clojure-ts--get-nodes-to-align (beg end)
   "Return a plist of nodes data for alignment.
 
-The search is limited by BEG, END and REGION-NODE.
+The search is limited by BEG, END.
 
 Possible node types are: map, bindings-vec, cond or read-cond.
 
@@ -1480,7 +1523,10 @@ The returned value is a list of property lists.  Each 
property list
 includes `:sexp-type', `:node', `:beg-marker', and `:end-marker'.
 Markers are necessary to fetch the same nodes after their boundaries
 have changed."
-  (let* ((query (treesit-query-compile 'clojure
+  ;; By default `treesit-query-capture' captures all nodes that cross the 
range.
+  ;; We need to restrict it to only nodes inside of the range.
+  (let* ((region-node (clojure-ts--region-node beg end))
+         (query (treesit-query-compile 'clojure
                                        (append
                                         `(((map_lit) @map)
                                           ((list_lit
@@ -1492,7 +1538,8 @@ have changed."
                                              (:match 
,(clojure-ts-symbol-regexp clojure-ts-align-cond-forms) @sym)))
                                            @cond))
                                         (when 
clojure-ts-align-reader-conditionals
-                                          '(((read_cond_lit) @read-cond)))))))
+                                          '(((read_cond_lit) @read-cond)
+                                            ((splicing_read_cond_lit) 
@read-cond)))))))
     (thread-last (treesit-query-capture region-node query beg end)
                  (seq-remove (lambda (elt) (eq (car elt) 'sym)))
                  ;; When first node is reindented, all other nodes become
@@ -1538,38 +1585,29 @@ between BEG and END."
   (interactive (if (use-region-p)
                    (list (region-beginning) (region-end))
                  (save-excursion
-                   (let ((start (clojure-ts--beginning-of-defun-pos))
-                         (end (clojure-ts--end-of-defun-pos)))
-                     (list start end)))))
+                   (if (not (treesit-defun-at-point))
+                       (user-error "No defun at point")
+                     (let ((start (clojure-ts--beginning-of-defun-pos))
+                           (end (clojure-ts--end-of-defun-pos)))
+                       (list start end))))))
   (setq end (copy-marker end))
-  (let* ((root-node (treesit-buffer-root-node 'clojure))
-         ;; By default `treesit-query-capture' captures all nodes that cross 
the
-         ;; range.  We need to restrict it to only nodes inside of the range.
-         (region-node (treesit-node-descendant-for-range root-node beg 
(marker-position end) t))
-         (sexps-to-align (clojure-ts--get-nodes-to-align region-node beg 
(marker-position end))))
+  (let* ((sexps-to-align (clojure-ts--get-nodes-to-align beg (marker-position 
end)))
+         ;; We have to disable it here to avoid endless recursion.
+         (clojure-ts-align-forms-automatically nil))
     (save-excursion
-      (indent-region beg (marker-position end))
+      (indent-region beg end)
       (dolist (sexp sexps-to-align)
         ;; After reindenting a node, all other nodes in the `sexps-to-align'
         ;; list become outdated, so we need to fetch updated nodes for every
         ;; iteration.
-        (let* ((new-root-node (treesit-buffer-root-node 'clojure))
-               (new-region-node (treesit-node-descendant-for-range 
new-root-node
-                                                                   beg
-                                                                   
(marker-position end)
-                                                                   t))
-               (sexp-beg (marker-position (plist-get sexp :beg-marker)))
-               (sexp-end (marker-position (plist-get sexp :end-marker)))
-               (node (treesit-node-descendant-for-range new-region-node
-                                                        sexp-beg
-                                                        sexp-end
-                                                        t))
+        (let* ((node (clojure-ts--node-from-sexp-data beg (marker-position 
end) sexp))
                (sexp-type (plist-get sexp :sexp-type))
                (node-end (treesit-node-end node)))
           (clojure-ts--point-to-align-position sexp-type node)
           (align-region (point) node-end nil
                         `((clojure-align (regexp . ,(lambda (&optional bound 
_noerror)
-                                                      
(clojure-ts--search-whitespace-after-next-sexp node bound)))
+                                                      (let ((updated-node 
(clojure-ts--node-from-sexp-data beg (marker-position end) sexp)))
+                                                        
(clojure-ts--search-whitespace-after-next-sexp updated-node bound))))
                                          (group . 1)
                                          (separate . 
,clojure-ts-align-separator)
                                          (repeat . t)))
@@ -1577,8 +1615,20 @@ between BEG and END."
           ;; After every iteration we have to re-indent the s-expression,
           ;; otherwise some can be indented inconsistently.
           (indent-region (marker-position (plist-get sexp :beg-marker))
-                         (marker-position (plist-get sexp :end-marker))))))))
+                         (plist-get sexp :end-marker))))
+      ;; If `clojure-ts-align-separator' is used, `align-region' leaves 
trailing
+      ;; whitespaces on empty lines.
+      (delete-trailing-whitespace beg (marker-position end)))))
+
+(defun clojure-ts-indent-region (beg end)
+  "Like `indent-region', but also maybe align forms.
 
+Forms between BEG and END are aligned according to
+`clojure-ts-align-forms-automatically'."
+  (prog1 (let ((indent-region-function #'treesit-indent-region))
+           (indent-region beg end))
+    (when clojure-ts-align-forms-automatically
+      (clojure-ts-align beg end))))
 
 (defvar clojure-ts-mode-map
   (let ((map (make-sparse-keymap)))
@@ -1717,6 +1767,11 @@ REGEX-AVAILABLE."
 
       (treesit-major-mode-setup)
 
+      ;; We should assign this after calling `treesit-major-mode-setup',
+      ;; otherwise it will be owerwritten.
+      (when clojure-ts-align-forms-automatically
+        (setq-local indent-region-function #'clojure-ts-indent-region))
+
       ;; Initial indentation rules cache calculation.
       (setq clojure-ts--semantic-indent-rules-cache
             (clojure-ts--compute-semantic-indentation-rules-cache 
clojure-ts-semantic-indent-rules))
diff --git a/test/clojure-ts-mode-indentation-test.el 
b/test/clojure-ts-mode-indentation-test.el
index 75ceb6d6df..fe181f9c63 100644
--- a/test/clojure-ts-mode-indentation-test.el
+++ b/test/clojure-ts-mode-indentation-test.el
@@ -75,6 +75,38 @@ DESCRIPTION is a string with the description of the spec."
                  forms))))
 
 
+(defmacro when-aligning-it (description &rest forms)
+  "Return a buttercup spec.
+
+Check that all FORMS correspond to properly indented sexps.
+
+DESCRIPTION is a string with the description of the spec."
+  (declare (indent defun))
+  `(it ,description
+     (let ((clojure-ts-align-forms-automatically t)
+           (clojure-ts-align-reader-conditionals t))
+       ,@(mapcar (lambda (form)
+                   `(with-temp-buffer
+                      (clojure-ts-mode)
+                      (insert "\n" ,(replace-regexp-in-string " +" " " form))
+                      (indent-region (point-min) (point-max))
+                      (should (equal (buffer-substring-no-properties 
(point-min) (point-max))
+                                     ,(concat "\n" form)))))
+                 forms))
+     (let ((clojure-ts-align-forms-automatically nil))
+       ,@(mapcar (lambda (form)
+                   `(with-temp-buffer
+                      (clojure-ts-mode)
+                      (insert "\n" ,(replace-regexp-in-string " +" " " form))
+                      ;; This is to check that we did NOT align anything. Run
+                      ;; `indent-region' and then check that no extra spaces
+                      ;; where inserted besides the start of the line.
+                      (indent-region (point-min) (point-max))
+                      (goto-char (point-min))
+                      (should-not (search-forward-regexp "\\([^\s\n]\\)  +" 
nil 'noerror))))
+                 forms))))
+
+
 ;; Provide font locking for easier test editing.
 
 (font-lock-add-keywords
@@ -393,4 +425,158 @@ b |20])"
   (it "should remove extra commas"
     (with-clojure-ts-buffer-point "{|:a 2, ,:c 4}"
         (call-interactively #'clojure-ts-align)
-      (expect (buffer-string) :to-equal "{:a 2, :c 4}"))))
+        (expect (buffer-string) :to-equal "{:a 2, :c 4}"))))
+
+(describe "clojure-ts-align-forms-automatically"
+  ;; Copied from `clojure-mode'
+  (when-aligning-it "should basic forms"
+    "
+{:this-is-a-form b
+ c               d}"
+
+    "
+{:this-is b
+ c        d}"
+
+    "
+{:this b
+ c     d}"
+
+    "
+{:a b
+ c  d}"
+
+    "
+(let [this-is-a-form b
+      c              d])"
+
+    "
+(let [this-is b
+      c       d])"
+
+    "
+(let [this b
+      c    d])"
+
+    "
+(let [a b
+      c d])")
+
+  (when-aligning-it "should handle a blank line"
+    "
+(let [this-is-a-form b
+      c              d
+
+      another form
+      k       g])"
+
+    "
+{:this-is-a-form b
+ c               d
+
+ :another form
+ k        g}")
+
+  (when-aligning-it "should handle basic forms (reversed)"
+    "
+{c               d
+ :this-is-a-form b}"
+  "
+{c        d
+ :this-is b}"
+  "
+{c     d
+ :this b}"
+  "
+{c  d
+ :a b}"
+
+  "
+(let [c              d
+      this-is-a-form b])"
+
+  "
+(let [c       d
+      this-is b])"
+
+  "
+(let [c    d
+      this b])"
+
+  "
+(let [c d
+      a b])")
+
+  (when-aligning-it "should handle multiple words"
+    "
+(cond this     is    just
+      a        test  of
+      how      well
+      multiple words will work)")
+
+  (when-aligning-it "should handle nested maps"
+    "
+{:a    {:a    :a
+        :bbbb :b}
+ :bbbb :b}")
+
+  (when-aligning-it "should regard end as a marker"
+    "
+{:a {:a                                :a
+     :aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa :a}
+ :b {:a  :a
+     :aa :a}}")
+
+  (when-aligning-it "should handle trailing commas"
+    "
+{:a {:a  :a,
+     :aa :a},
+ :b {:a  :a,
+     :aa :a}}")
+
+  (when-aligning-it "should handle standard reader conditionals"
+    "
+#?(:clj  2
+   :cljs 2)")
+
+  (when-aligning-it "should handle splicing reader conditional"
+    "
+#?@(:clj  [2]
+    :cljs [2])")
+
+  (when-aligning-it "should handle sexps broken up by line comments"
+    "
+(let [x  1
+      ;; comment
+      xx 1]
+  xx)"
+
+    "
+{:x   1
+ ;; comment
+ :xxx 2}"
+
+    "
+(case x
+  :aa 1
+  ;; comment
+  :a  2)")
+
+  (when-aligning-it "should work correctly when margin comments appear after 
nested, multi-line, non-terminal sexps"
+    "
+(let [x  {:a 1
+          :b 2} ; comment
+      xx 3]
+  x)"
+
+    "
+{:aa {:b  1
+      :cc 2} ;; comment
+ :a  1}}"
+
+    "
+(case x
+  :a  (let [a  1
+            aa (+ a 1)]
+        aa); comment
+  :aa 2)"))
diff --git a/test/samples/align.clj b/test/samples/align.clj
index cf361cb23a..f70e767103 100644
--- a/test/samples/align.clj
+++ b/test/samples/align.clj
@@ -27,6 +27,31 @@
 (let [a-long-name 10
       b           20])
 
-
 #?(:clj  2
    :cljs 2)
+
+#?@(:clj  [2]
+    :cljs [4])
+
+(let [this-is-a-form b
+      c              d
+
+      another form
+      k       g])
+
+{:this-is-a-form b
+ c               d
+
+ :another form
+ k        g}
+
+(let [x  {:a 1
+          :b 2} ; comment
+      xx 3]
+  x)
+
+(case x
+  :a  (let [a  1
+            aa (+ a 1)]
+        aa); comment
+  :aa 2)

Reply via email to