branch: elpa/clojure-ts-mode
commit 61041c82be52faf7f931b62b7b300742b04f76a3
Author: Roman Rudakov <[email protected]>
Commit: Bozhidar Batsov <[email protected]>
[#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)