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

    [#16] Implement clojure-ts-align
---
 CHANGELOG.md                             |   2 +
 README.md                                |  32 ++++
 clojure-ts-mode.el                       | 279 ++++++++++++++++++++++++++++---
 test/clojure-ts-mode-indentation-test.el |  68 ++++++++
 test/samples/align.clj                   |  32 ++++
 5 files changed, 394 insertions(+), 19 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3ba2af96fe..41e2a140b1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
 
 ## main (unreleased)
 
+- [#16](https://github.com/clojure-emacs/clojure-ts-mode/issues/16): Introduce 
`clojure-ts-align`.
+
 ## 0.3.0 (2025-04-15)
 
 - [#62](https://github.com/clojure-emacs/clojure-ts-mode/issues/62): Define 
`list` "thing" to improve navigation in Emacs 31.
diff --git a/README.md b/README.md
index d5407bc09f..f44f5839c1 100644
--- a/README.md
+++ b/README.md
@@ -239,6 +239,38 @@ should look like:
 In order to apply directory-local variables to existing buffers, they must be
 reverted.
 
+### Vertical alignment
+
+You can vertically align sexps with `C-c SPC`. For instance, typing this combo
+on the following form:
+
+```clojure
+(def my-map
+  {:a-key 1
+   :other-key 2})
+```
+
+Leads to the following:
+
+```clojure
+(def my-map
+  {:a-key     1
+   :other-key 2})
+```
+
+Forms that can be aligned vertically are configured via the following 
variables:
+
+- `clojure-ts-align-reader-conditionals` - align reader conditionals as if they
+  were maps.
+- `clojure-ts-align-binding-forms` - a customizable list of forms with let-like
+  bindings that can be aligned vertically.
+- `clojure-ts-align-cond-forms` - a customizable list of forms whose body
+  elements can be aligned vertically. These forms respect the block semantic
+  indentation rule (if configured) and align only the body forms, skipping N
+  special arguments.
+- `clojure-ts-align-separator` - determines whether blank lines prevent 
vertical
+  alignment.
+
 ### Font Locking
 
 To highlight entire rich `comment` expression with the comment font face, set
diff --git a/clojure-ts-mode.el b/clojure-ts-mode.el
index 94af72e4a1..7c16c01785 100644
--- a/clojure-ts-mode.el
+++ b/clojure-ts-mode.el
@@ -56,6 +56,7 @@
 ;;; Code:
 
 (require 'treesit)
+(require 'align)
 
 (declare-function treesit-parser-create "treesit.c")
 (declare-function treesit-node-eq "treesit.c")
@@ -126,6 +127,70 @@ double quotes on the third column."
   :type 'boolean
   :package-version '(clojure-ts-mode . "0.3"))
 
+(defcustom clojure-ts-align-reader-conditionals nil
+  "Whether to align reader conditionals, as if they were maps."
+  :package-version '(clojure-ts-mode . "0.4")
+  :safe #'booleanp
+  :type 'boolean)
+
+(defcustom clojure-ts-align-binding-forms
+  '("let"
+    "when-let"
+    "when-some"
+    "if-let"
+    "if-some"
+    "binding"
+    "loop"
+    "doseq"
+    "for"
+    "with-open"
+    "with-local-vars"
+    "with-redefs"
+    "clojure.core/let"
+    "clojure.core/when-let"
+    "clojure.core/when-some"
+    "clojure.core/if-let"
+    "clojure.core/if-some"
+    "clojure.core/binding"
+    "clojure.core/loop"
+    "clojure.core/doseq"
+    "clojure.core/for"
+    "clojure.core/with-open"
+    "clojure.core/with-local-vars"
+    "clojure.core/with-redefs")
+  "List of strings matching forms that have binding forms."
+  :package-version '(clojure-ts-mode . "0.4")
+  :safe #'listp
+  :type '(repeat string))
+
+(defconst clojure-ts--align-separator-newline-regexp "^ *$")
+
+(defcustom clojure-ts-align-separator 
clojure-ts--align-separator-newline-regexp
+  "Separator passed to `align-region' when performing vertical alignment."
+  :package-version '(clojure-ts-mode . "0.4")
+  :type `(choice (const :tag "Make blank lines prevent vertical alignment from 
happening."
+                        ,clojure-ts--align-separator-newline-regexp)
+                 (other :tag "Allow blank lines to happen within a 
vertically-aligned expression."
+                        entire)))
+
+(defcustom clojure-ts-align-cond-forms
+  '("condp"
+    "cond"
+    "cond->"
+    "cond->>"
+    "case"
+    "are"
+    "clojure.core/condp"
+    "clojure.core/cond"
+    "clojure.core/cond->"
+    "clojure.core/cond->>"
+    "clojure.core/case"
+    "clojure.core/are")
+  "List of strings identifying cond-like forms."
+  :package-version '(clojure-ts-mode . "0.4")
+  :safe #'listp
+  :type '(repeat string))
+
 (defvar clojure-ts-mode-remappings
   '((clojure-mode . clojure-ts-mode)
     (clojurescript-mode . clojure-ts-clojurescript-mode)
@@ -1025,6 +1090,18 @@ If NS is defined, then the fully qualified symbol is 
passed to
                        (seq-sort (lambda (spec1 _spec2)
                                    (equal (car spec1) :block)))))))))
 
+(defun clojure-ts--find-semantic-rules-for-node (node)
+  "Return a list of semantic rules for NODE."
+  (let* ((first-child (clojure-ts--node-child-skip-metadata node 0))
+         (symbol-name (clojure-ts--named-node-text first-child))
+         (symbol-namespace (clojure-ts--node-namespace-text first-child)))
+    (or (clojure-ts--dynamic-indent-for-symbol symbol-name symbol-namespace)
+        (alist-get symbol-name
+                   clojure-ts--semantic-indent-rules-cache
+                   nil
+                   nil
+                   #'equal))))
+
 (defun clojure-ts--find-semantic-rule (node parent current-depth)
   "Return a suitable indentation rule for NODE, considering the CURRENT-DEPTH.
 
@@ -1034,16 +1111,8 @@ syntax tree and recursively attempts to find a rule, 
incrementally
 increasing the CURRENT-DEPTH.  If a rule is not found upon reaching the
 root of the syntax tree, it returns nil.  A rule is considered a match
 only if the CURRENT-DEPTH matches the rule's required depth."
-  (let* ((first-child (clojure-ts--node-child-skip-metadata parent 0))
-         (symbol-name (clojure-ts--named-node-text first-child))
-         (symbol-namespace (clojure-ts--node-namespace-text first-child))
-         (idx (- (treesit-node-index node) 2)))
-    (if-let* ((rule-set (or (clojure-ts--dynamic-indent-for-symbol symbol-name 
symbol-namespace)
-                            (alist-get symbol-name
-                                       clojure-ts--semantic-indent-rules-cache
-                                       nil
-                                       nil
-                                       #'equal))))
+  (let* ((idx (- (treesit-node-index node) 2)))
+    (if-let* ((rule-set (clojure-ts--find-semantic-rules-for-node parent)))
         (if (zerop current-depth)
             (let ((rule (car rule-set)))
               (if (equal (car rule) :block)
@@ -1061,7 +1130,9 @@ only if the CURRENT-DEPTH matches the rule's required 
depth."
                                             (or (null rule-idx)
                                                 (equal rule-idx idx))))))
                        (seq-first)))
-      (when-let* ((new-parent (treesit-node-parent parent)))
+      ;; Let's go no more than 3 levels up to avoid performance degradation.
+      (when-let* (((< current-depth 3))
+                  (new-parent (treesit-node-parent parent)))
         (clojure-ts--find-semantic-rule parent
                                         new-parent
                                         (1+ current-depth))))))
@@ -1188,12 +1259,6 @@ if NODE has metadata and its parent has type NODE-TYPE."
   `((clojure
      ((parent-is "source") parent-bol 0)
      (clojure-ts--match-docstring 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
-     (clojure-ts--match-threading-macro-arg prev-sibling 0)
-     ;; https://guide.clojure.style/#vertically-align-fn-args
-     (clojure-ts--match-function-call-arg (nth-sibling 2 nil) 0)
      ;; Collections items with metadata.
      ;;
      ;; This should be before `clojure-ts--match-with-metadata', otherwise they
@@ -1208,10 +1273,17 @@ if NODE has metadata and its parent has type NODE-TYPE."
      ;; All other forms with metadata.
      (clojure-ts--match-with-metadata parent 0)
      ;; Literal Sequences
-     ((parent-is "list_lit") parent 1) ;; 
https://guide.clojure.style/#one-space-indent
      ((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 "set_lit") parent 2)
+     ;; 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
+     (clojure-ts--match-threading-macro-arg prev-sibling 0)
+     ;; https://guide.clojure.style/#vertically-align-fn-args
+     (clojure-ts--match-function-call-arg (nth-sibling 2 nil) 0)
+     ;; https://guide.clojure.style/#one-space-indent
+     ((parent-is "list_lit") parent 1))))
 
 (defun clojure-ts--configured-indent-rules ()
   "Gets the configured choice of indent rules."
@@ -1277,9 +1349,177 @@ If JUSTIFY is non-nil, justify as well as fill the 
paragraph."
       (markdown-inline
        (sexp ,(regexp-opt clojure-ts--markdown-inline-sexp-nodes))))))
 
+;;; Vertical alignment
+
+(defun clojure-ts--beginning-of-defun-pos ()
+  "Return the point that represents the beginning of the current defun."
+  (treesit-node-start (treesit-defun-at-point)))
+
+(defun clojure-ts--end-of-defun-pos ()
+  "Return the point that represends the end of the current defun."
+  (treesit-node-end (treesit-defun-at-point)))
+
+(defun clojure-ts--search-whitespace-after-next-sexp (root-node bound)
+  "Move the point after all whitespace following the next s-expression.
+
+Set match data group 1 to this region of whitespace and return the
+point.
+
+To move over the next s-expression, fetch the next node after the
+current cursor position that is a direct child of ROOT-NODE and navigate
+to its end.  The most complex aspect here is handling nodes with
+metadata.  Some forms are represented in the syntax tree as a single
+s-expression (for example, ^long my-var or ^String (str \"Hello\"
+\"world\")), while other forms are two separate s-expressions (for
+example, ^long 123 or ^String \"Hello\").  Expressions with two nodes
+share some common features:
+
+- The top-level node type is usually sym_lit
+
+- They do not have value children, or they have an empty name.
+
+Regular expression and syntax analysis code is borrowed from
+`clojure-mode.'
+
+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))))
+    (when (and bound (> (point) bound))
+      (goto-char bound))))
+
+(defun clojure-ts--get-nodes-to-align (region-node beg end)
+  "Return a plist of nodes data for alignment.
+
+The search is limited by BEG, END and REGION-NODE.
+
+Possible node types are: map, bindings-vec, cond or read-cond.
+
+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
+                                       (append
+                                        `(((map_lit) @map)
+                                          ((list_lit
+                                            ((sym_lit) @sym
+                                             (:match 
,(clojure-ts-symbol-regexp clojure-ts-align-binding-forms) @sym))
+                                            (vec_lit) @bindings-vec))
+                                          ((list_lit
+                                            ((sym_lit) @sym
+                                             (:match 
,(clojure-ts-symbol-regexp clojure-ts-align-cond-forms) @sym)))
+                                           @cond))
+                                        (when 
clojure-ts-align-reader-conditionals
+                                          '(((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
+                 ;; outdated.  Executing the entire query everytime is very
+                 ;; expensive, instead we use markers for every captured node 
to
+                 ;; retrieve only a single node later.
+                 (seq-map (lambda (elt)
+                            (let* ((sexp-type (car elt))
+                                   (node (cdr elt))
+                                   (beg-marker (copy-marker 
(treesit-node-start node) t))
+                                   (end-marker (copy-marker (treesit-node-end 
node))))
+                              (list :sexp-type sexp-type
+                                    :node node
+                                    :beg-marker beg-marker
+                                    :end-marker end-marker)))))))
+
+(defun clojure-ts--point-to-align-position (sexp-type node)
+  "Move point to the appropriate position to align NODE.
+
+For NODE with SEXP-TYPE map or bindings-vec, the appropriate
+position is after the first opening brace.
+
+For NODE with SEXP-TYPE cond, we need to skip the first symbol and the
+subsequent special arguments based on block indentation rules."
+  (goto-char (treesit-node-start node))
+  (when-let* ((cur-sexp (treesit-node-first-child-for-pos node (point) t)))
+    (goto-char (treesit-node-start cur-sexp))
+    ;; For cond forms we need to skip first n + 1 nodes according to block
+    ;; indentation rules.  First node to skip is the symbol itself.
+    (when (equal sexp-type 'cond)
+      (if-let* ((rule-set (clojure-ts--find-semantic-rules-for-node node))
+                (rule (car rule-set))
+                ((equal (car rule) :block)))
+          (treesit-beginning-of-thing 'sexp (1- (- (cadr rule))) 'restrict)
+        (treesit-beginning-of-thing 'sexp -1)))))
+
+(defun clojure-ts-align (beg end)
+  "Vertically align the contents of the sexp around point.
+
+If region is active, align it.  Otherwise, align everything in the
+current \"top-level\" sexp.  When called from lisp code align everything
+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)))))
+  (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))))
+    (save-excursion
+      (indent-region beg (marker-position 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))
+               (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)))
+                                         (group . 1)
+                                         (separate . 
,clojure-ts-align-separator)
+                                         (repeat . t)))
+                        nil)
+          ;; 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))))))))
+
+
 (defvar clojure-ts-mode-map
   (let ((map (make-sparse-keymap)))
     ;;(set-keymap-parent map clojure-mode-map)
+    (keymap-set map "C-c SPC" #'clojure-ts-align)
     map))
 
 (defvar clojure-ts-clojurescript-mode-map
@@ -1347,6 +1587,7 @@ function can also be used to upgrade the grammars if they 
are outdated."
 (defun clojure-ts-mode-variables (&optional markdown-available)
   "Initialize buffer-local variables for `clojure-ts-mode'.
 See `clojure-ts--font-lock-settings' for usage of MARKDOWN-AVAILABLE."
+  (setq-local indent-tabs-mode nil)
   (setq-local comment-add 1)
   (setq-local comment-start ";")
 
diff --git a/test/clojure-ts-mode-indentation-test.el 
b/test/clojure-ts-mode-indentation-test.el
index e6bbd9837b..75ceb6d6df 100644
--- a/test/clojure-ts-mode-indentation-test.el
+++ b/test/clojure-ts-mode-indentation-test.el
@@ -326,3 +326,71 @@ DESCRIPTION is a string with the description of the spec."
                       (* (twice y) 3))]
   (println \"Twice 15 =\" (twice 15))
   (println \"Six times 15 =\" (six-times 15)))"))))
+
+(describe "clojure-ts-align"
+  (it "should handle improperly indented content"
+    (with-clojure-ts-buffer-point "
+(let [a-long-name 10
+b |20])"
+        (call-interactively #'clojure-ts-align)
+      (expect (buffer-string) :to-equal "
+(let [a-long-name 10
+      b           20])"))
+
+    (with-clojure-ts-buffer-point "
+(let [^long my-map {:hello \"World\" ;Hello
+ :foo
+       ^String (str \"Foo\" \"Bar\")
+ :number ^long 132
+                    :zz \"hello\"}
+      another| {:this ^{:hello \"world\"} \"is\"
+                    :a    #long \"1234\"
+ :b {:this \"is\"
+ :nested \"map\"}}])"
+        (call-interactively #'clojure-ts-align)
+      (expect (buffer-string) :to-equal "
+(let [^long my-map {:hello  \"World\" ;Hello
+                    :foo
+                    ^String (str \"Foo\" \"Bar\")
+                    :number ^long 132
+                    :zz     \"hello\"}
+      another      {:this ^{:hello \"world\"} \"is\"
+                    :a    #long \"1234\"
+                    :b    {:this   \"is\"
+                           :nested \"map\"}}])"))
+
+    (with-clojure-ts-buffer-point "
+(condp = 2
+|123 \"Hello\"
+99999 \"World\"
+234 nil)"
+        (call-interactively #'clojure-ts-align)
+      (expect (buffer-string) :to-equal "
+(condp = 2
+  123   \"Hello\"
+  99999 \"World\"
+  234   nil)")))
+
+  (it "should not align reader conditionals by defaul"
+    (with-clojure-ts-buffer-point "
+#?(:clj 2
+   |:cljs 2)"
+        (call-interactively #'clojure-ts-align)
+      (expect (buffer-string) :to-equal "
+#?(:clj 2
+   :cljs 2)")))
+
+  (it "should align reader conditionals when 
clojure-ts-align-reader-conditionals is true"
+    (with-clojure-ts-buffer-point "
+#?(:clj 2
+   |:cljs 2)"
+        (setq-local clojure-ts-align-reader-conditionals t)
+      (call-interactively #'clojure-ts-align)
+      (expect (buffer-string) :to-equal "
+#?(:clj  2
+   :cljs 2)")))
+
+  (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}"))))
diff --git a/test/samples/align.clj b/test/samples/align.clj
new file mode 100644
index 0000000000..cf361cb23a
--- /dev/null
+++ b/test/samples/align.clj
@@ -0,0 +1,32 @@
+(ns align)
+
+(let [^long my-map {:hello  "World"   ;Hello
+                    :foo
+                    ^String (str "Foo" "Bar")
+                    :number ^long 132
+                    :zz     "hello"}
+      another      {:this ^{:hello "world"} "is"
+                    :a    #long "1234"
+                    :b    {:this   "is"
+                           :nested "map"}}])
+
+
+{:foo "bar", :baz "Hello"
+ :a   "b"    :c   "d"}
+
+
+(clojure.core/with-redefs [hello "world"
+                           foo   "bar"]
+  (println hello foo))
+
+(condp = 2
+  123   "Hello"
+  99999 "World"
+  234   nil)
+
+(let [a-long-name 10
+      b           20])
+
+
+#?(:clj  2
+   :cljs 2)

Reply via email to