Skip to content

Commit 862d391

Browse files
committed
[#16] Implement clojure-ts-align
1 parent cec1d32 commit 862d391

File tree

5 files changed

+394
-19
lines changed

5 files changed

+394
-19
lines changed

Diff for: CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## main (unreleased)
44

5+
- [#16](https://github.com/clojure-emacs/clojure-ts-mode/issues/16): Introduce `clojure-ts-align`.
6+
57
## 0.3.0 (2025-04-15)
68

79
- [#62](https://github.com/clojure-emacs/clojure-ts-mode/issues/62): Define `list` "thing" to improve navigation in Emacs 31.

Diff for: README.md

+32
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,38 @@ should look like:
239239
In order to apply directory-local variables to existing buffers, they must be
240240
reverted.
241241

242+
### Vertical alignment
243+
244+
You can vertically align sexps with `C-c SPC`. For instance, typing this combo
245+
on the following form:
246+
247+
```clojure
248+
(def my-map
249+
{:a-key 1
250+
:other-key 2})
251+
```
252+
253+
Leads to the following:
254+
255+
```clojure
256+
(def my-map
257+
{:a-key 1
258+
:other-key 2})
259+
```
260+
261+
Forms that can be aligned vertically are configured via the following variables:
262+
263+
- `clojure-ts-align-reader-conditionals` - align reader conditionals as if they
264+
were maps.
265+
- `clojure-ts-align-binding-forms` - a customizable list of forms with let-like
266+
bindings that can be aligned vertically.
267+
- `clojure-ts-align-cond-forms` - a customizable list of forms whose body
268+
elements can be aligned vertically. These forms respect the block semantic
269+
indentation rule (if configured) and align only the body forms, skipping N
270+
special arguments.
271+
- `clojure-ts-align-separator` - determines whether blank lines prevent vertical
272+
alignment.
273+
242274
### Font Locking
243275

244276
To highlight entire rich `comment` expression with the comment font face, set

Diff for: clojure-ts-mode.el

+260-19
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
;;; Code:
5757

5858
(require 'treesit)
59+
(require 'align)
5960

6061
(declare-function treesit-parser-create "treesit.c")
6162
(declare-function treesit-node-eq "treesit.c")
@@ -126,6 +127,70 @@ double quotes on the third column."
126127
:type 'boolean
127128
:package-version '(clojure-ts-mode . "0.3"))
128129

130+
(defcustom clojure-ts-align-reader-conditionals nil
131+
"Whether to align reader conditionals, as if they were maps."
132+
:package-version '(clojure-ts-mode . "0.4")
133+
:safe #'booleanp
134+
:type 'boolean)
135+
136+
(defcustom clojure-ts-align-binding-forms
137+
'("let"
138+
"when-let"
139+
"when-some"
140+
"if-let"
141+
"if-some"
142+
"binding"
143+
"loop"
144+
"doseq"
145+
"for"
146+
"with-open"
147+
"with-local-vars"
148+
"with-redefs"
149+
"clojure.core/let"
150+
"clojure.core/when-let"
151+
"clojure.core/when-some"
152+
"clojure.core/if-let"
153+
"clojure.core/if-some"
154+
"clojure.core/binding"
155+
"clojure.core/loop"
156+
"clojure.core/doseq"
157+
"clojure.core/for"
158+
"clojure.core/with-open"
159+
"clojure.core/with-local-vars"
160+
"clojure.core/with-redefs")
161+
"List of strings matching forms that have binding forms."
162+
:package-version '(clojure-ts-mode . "0.4")
163+
:safe #'listp
164+
:type '(repeat string))
165+
166+
(defconst clojure-ts--align-separator-newline-regexp "^ *$")
167+
168+
(defcustom clojure-ts-align-separator clojure-ts--align-separator-newline-regexp
169+
"Separator passed to `align-region' when performing vertical alignment."
170+
:package-version '(clojure-ts-mode . "0.4")
171+
:type `(choice (const :tag "Make blank lines prevent vertical alignment from happening."
172+
,clojure-ts--align-separator-newline-regexp)
173+
(other :tag "Allow blank lines to happen within a vertically-aligned expression."
174+
entire)))
175+
176+
(defcustom clojure-ts-align-cond-forms
177+
'("condp"
178+
"cond"
179+
"cond->"
180+
"cond->>"
181+
"case"
182+
"are"
183+
"clojure.core/condp"
184+
"clojure.core/cond"
185+
"clojure.core/cond->"
186+
"clojure.core/cond->>"
187+
"clojure.core/case"
188+
"clojure.core/are")
189+
"List of strings identifying cond-like forms."
190+
:package-version '(clojure-ts-mode . "0.4")
191+
:safe #'listp
192+
:type '(repeat string))
193+
129194
(defvar clojure-ts-mode-remappings
130195
'((clojure-mode . clojure-ts-mode)
131196
(clojurescript-mode . clojure-ts-clojurescript-mode)
@@ -1025,6 +1090,18 @@ If NS is defined, then the fully qualified symbol is passed to
10251090
(seq-sort (lambda (spec1 _spec2)
10261091
(equal (car spec1) :block)))))))))
10271092

1093+
(defun clojure-ts--find-semantic-rules-for-node (node)
1094+
"Return a list of semantic rules for NODE."
1095+
(let* ((first-child (clojure-ts--node-child-skip-metadata node 0))
1096+
(symbol-name (clojure-ts--named-node-text first-child))
1097+
(symbol-namespace (clojure-ts--node-namespace-text first-child)))
1098+
(or (clojure-ts--dynamic-indent-for-symbol symbol-name symbol-namespace)
1099+
(alist-get symbol-name
1100+
clojure-ts--semantic-indent-rules-cache
1101+
nil
1102+
nil
1103+
#'equal))))
1104+
10281105
(defun clojure-ts--find-semantic-rule (node parent current-depth)
10291106
"Return a suitable indentation rule for NODE, considering the CURRENT-DEPTH.
10301107
@@ -1034,16 +1111,8 @@ syntax tree and recursively attempts to find a rule, incrementally
10341111
increasing the CURRENT-DEPTH. If a rule is not found upon reaching the
10351112
root of the syntax tree, it returns nil. A rule is considered a match
10361113
only if the CURRENT-DEPTH matches the rule's required depth."
1037-
(let* ((first-child (clojure-ts--node-child-skip-metadata parent 0))
1038-
(symbol-name (clojure-ts--named-node-text first-child))
1039-
(symbol-namespace (clojure-ts--node-namespace-text first-child))
1040-
(idx (- (treesit-node-index node) 2)))
1041-
(if-let* ((rule-set (or (clojure-ts--dynamic-indent-for-symbol symbol-name symbol-namespace)
1042-
(alist-get symbol-name
1043-
clojure-ts--semantic-indent-rules-cache
1044-
nil
1045-
nil
1046-
#'equal))))
1114+
(let* ((idx (- (treesit-node-index node) 2)))
1115+
(if-let* ((rule-set (clojure-ts--find-semantic-rules-for-node parent)))
10471116
(if (zerop current-depth)
10481117
(let ((rule (car rule-set)))
10491118
(if (equal (car rule) :block)
@@ -1061,7 +1130,9 @@ only if the CURRENT-DEPTH matches the rule's required depth."
10611130
(or (null rule-idx)
10621131
(equal rule-idx idx))))))
10631132
(seq-first)))
1064-
(when-let* ((new-parent (treesit-node-parent parent)))
1133+
;; Let's go no more than 3 levels up to avoid performance degradation.
1134+
(when-let* (((< current-depth 3))
1135+
(new-parent (treesit-node-parent parent)))
10651136
(clojure-ts--find-semantic-rule parent
10661137
new-parent
10671138
(1+ current-depth))))))
@@ -1188,12 +1259,6 @@ if NODE has metadata and its parent has type NODE-TYPE."
11881259
`((clojure
11891260
((parent-is "source") parent-bol 0)
11901261
(clojure-ts--match-docstring parent 0)
1191-
;; https://guide.clojure.style/#body-indentation
1192-
(clojure-ts--match-form-body clojure-ts--anchor-parent-skip-metadata 2)
1193-
;; https://guide.clojure.style/#threading-macros-alignment
1194-
(clojure-ts--match-threading-macro-arg prev-sibling 0)
1195-
;; https://guide.clojure.style/#vertically-align-fn-args
1196-
(clojure-ts--match-function-call-arg (nth-sibling 2 nil) 0)
11971262
;; Collections items with metadata.
11981263
;;
11991264
;; 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."
12081273
;; All other forms with metadata.
12091274
(clojure-ts--match-with-metadata parent 0)
12101275
;; Literal Sequences
1211-
((parent-is "list_lit") parent 1) ;; https://guide.clojure.style/#one-space-indent
12121276
((parent-is "vec_lit") parent 1) ;; https://guide.clojure.style/#bindings-alignment
12131277
((parent-is "map_lit") parent 1) ;; https://guide.clojure.style/#map-keys-alignment
1214-
((parent-is "set_lit") parent 2))))
1278+
((parent-is "set_lit") parent 2)
1279+
;; https://guide.clojure.style/#body-indentation
1280+
(clojure-ts--match-form-body clojure-ts--anchor-parent-skip-metadata 2)
1281+
;; https://guide.clojure.style/#threading-macros-alignment
1282+
(clojure-ts--match-threading-macro-arg prev-sibling 0)
1283+
;; https://guide.clojure.style/#vertically-align-fn-args
1284+
(clojure-ts--match-function-call-arg (nth-sibling 2 nil) 0)
1285+
;; https://guide.clojure.style/#one-space-indent
1286+
((parent-is "list_lit") parent 1))))
12151287

12161288
(defun clojure-ts--configured-indent-rules ()
12171289
"Gets the configured choice of indent rules."
@@ -1277,9 +1349,177 @@ If JUSTIFY is non-nil, justify as well as fill the paragraph."
12771349
(markdown-inline
12781350
(sexp ,(regexp-opt clojure-ts--markdown-inline-sexp-nodes))))))
12791351

1352+
;;; Vertical alignment
1353+
1354+
(defun clojure-ts--beginning-of-defun-pos ()
1355+
"Return the point that represents the beginning of the current defun."
1356+
(treesit-node-start (treesit-defun-at-point)))
1357+
1358+
(defun clojure-ts--end-of-defun-pos ()
1359+
"Return the point that represends the end of the current defun."
1360+
(treesit-node-end (treesit-defun-at-point)))
1361+
1362+
(defun clojure-ts--search-whitespace-after-next-sexp (root-node bound)
1363+
"Move the point after all whitespace following the next s-expression.
1364+
1365+
Set match data group 1 to this region of whitespace and return the
1366+
point.
1367+
1368+
To move over the next s-expression, fetch the next node after the
1369+
current cursor position that is a direct child of ROOT-NODE and navigate
1370+
to its end. The most complex aspect here is handling nodes with
1371+
metadata. Some forms are represented in the syntax tree as a single
1372+
s-expression (for example, ^long my-var or ^String (str \"Hello\"
1373+
\"world\")), while other forms are two separate s-expressions (for
1374+
example, ^long 123 or ^String \"Hello\"). Expressions with two nodes
1375+
share some common features:
1376+
1377+
- The top-level node type is usually sym_lit
1378+
1379+
- They do not have value children, or they have an empty name.
1380+
1381+
Regular expression and syntax analysis code is borrowed from
1382+
`clojure-mode.'
1383+
1384+
BOUND bounds the whitespace search."
1385+
(unwind-protect
1386+
(when-let* ((cur-sexp (treesit-node-first-child-for-pos root-node (point) t)))
1387+
(goto-char (treesit-node-start cur-sexp))
1388+
(if (and (string= "sym_lit" (treesit-node-type cur-sexp))
1389+
(clojure-ts--metadata-node-p (treesit-node-child cur-sexp 0 t))
1390+
(and (not (treesit-node-child-by-field-name cur-sexp "value"))
1391+
(string-empty-p (clojure-ts--named-node-text cur-sexp))))
1392+
(treesit-end-of-thing 'sexp 2 'restricted)
1393+
(treesit-end-of-thing 'sexp 1 'restrict))
1394+
(when (looking-at ",")
1395+
(forward-char))
1396+
;; Move past any whitespace or comment.
1397+
(search-forward-regexp "\\([,\s\t]*\\)\\(;+.*\\)?" bound)
1398+
(pcase (syntax-after (point))
1399+
;; End-of-line, try again on next line.
1400+
(`(12) (clojure-ts--search-whitespace-after-next-sexp root-node bound))
1401+
;; Closing paren, stop here.
1402+
(`(5 . ,_) nil)
1403+
;; Anything else is something to align.
1404+
(_ (point))))
1405+
(when (and bound (> (point) bound))
1406+
(goto-char bound))))
1407+
1408+
(defun clojure-ts--get-nodes-to-align (region-node beg end)
1409+
"Return a plist of nodes data for alignment.
1410+
1411+
The search is limited by BEG, END and REGION-NODE.
1412+
1413+
Possible node types are: map, bindings-vec, cond or read-cond.
1414+
1415+
The returned value is a list of property lists. Each property list
1416+
includes `:sexp-type', `:node', `:beg-marker', and `:end-marker'.
1417+
Markers are necessary to fetch the same nodes after their boundaries
1418+
have changed."
1419+
(let* ((query (treesit-query-compile 'clojure
1420+
(append
1421+
`(((map_lit) @map)
1422+
((list_lit
1423+
((sym_lit) @sym
1424+
(:match ,(clojure-ts-symbol-regexp clojure-ts-align-binding-forms) @sym))
1425+
(vec_lit) @bindings-vec))
1426+
((list_lit
1427+
((sym_lit) @sym
1428+
(:match ,(clojure-ts-symbol-regexp clojure-ts-align-cond-forms) @sym)))
1429+
@cond))
1430+
(when clojure-ts-align-reader-conditionals
1431+
'(((read_cond_lit) @read-cond)))))))
1432+
(thread-last (treesit-query-capture region-node query beg end)
1433+
(seq-remove (lambda (elt) (eq (car elt) 'sym)))
1434+
;; When first node is reindented, all other nodes become
1435+
;; outdated. Executing the entire query everytime is very
1436+
;; expensive, instead we use markers for every captured node to
1437+
;; retrieve only a single node later.
1438+
(seq-map (lambda (elt)
1439+
(let* ((sexp-type (car elt))
1440+
(node (cdr elt))
1441+
(beg-marker (copy-marker (treesit-node-start node) t))
1442+
(end-marker (copy-marker (treesit-node-end node))))
1443+
(list :sexp-type sexp-type
1444+
:node node
1445+
:beg-marker beg-marker
1446+
:end-marker end-marker)))))))
1447+
1448+
(defun clojure-ts--point-to-align-position (sexp-type node)
1449+
"Move point to the appropriate position to align NODE.
1450+
1451+
For NODE with SEXP-TYPE map or bindings-vec, the appropriate
1452+
position is after the first opening brace.
1453+
1454+
For NODE with SEXP-TYPE cond, we need to skip the first symbol and the
1455+
subsequent special arguments based on block indentation rules."
1456+
(goto-char (treesit-node-start node))
1457+
(when-let* ((cur-sexp (treesit-node-first-child-for-pos node (point) t)))
1458+
(goto-char (treesit-node-start cur-sexp))
1459+
;; For cond forms we need to skip first n + 1 nodes according to block
1460+
;; indentation rules. First node to skip is the symbol itself.
1461+
(when (equal sexp-type 'cond)
1462+
(if-let* ((rule-set (clojure-ts--find-semantic-rules-for-node node))
1463+
(rule (car rule-set))
1464+
((equal (car rule) :block)))
1465+
(treesit-beginning-of-thing 'sexp (1- (- (cadr rule))) 'restrict)
1466+
(treesit-beginning-of-thing 'sexp -1)))))
1467+
1468+
(defun clojure-ts-align (beg end)
1469+
"Vertically align the contents of the sexp around point.
1470+
1471+
If region is active, align it. Otherwise, align everything in the
1472+
current \"top-level\" sexp. When called from lisp code align everything
1473+
between BEG and END."
1474+
(interactive (if (use-region-p)
1475+
(list (region-beginning) (region-end))
1476+
(save-excursion
1477+
(let ((start (clojure-ts--beginning-of-defun-pos))
1478+
(end (clojure-ts--end-of-defun-pos)))
1479+
(list start end)))))
1480+
(setq end (copy-marker end))
1481+
(let* ((root-node (treesit-buffer-root-node 'clojure))
1482+
;; By default `treesit-query-capture' captures all nodes that cross the
1483+
;; range. We need to restrict it to only nodes inside of the range.
1484+
(region-node (treesit-node-descendant-for-range root-node beg (marker-position end) t))
1485+
(sexps-to-align (clojure-ts--get-nodes-to-align region-node beg (marker-position end))))
1486+
(save-excursion
1487+
(indent-region beg (marker-position end))
1488+
(dolist (sexp sexps-to-align)
1489+
;; After reindenting a node, all other nodes in the `sexps-to-align'
1490+
;; list become outdated, so we need to fetch updated nodes for every
1491+
;; iteration.
1492+
(let* ((new-root-node (treesit-buffer-root-node 'clojure))
1493+
(new-region-node (treesit-node-descendant-for-range new-root-node
1494+
beg
1495+
(marker-position end)
1496+
t))
1497+
(sexp-beg (marker-position (plist-get sexp :beg-marker)))
1498+
(sexp-end (marker-position (plist-get sexp :end-marker)))
1499+
(node (treesit-node-descendant-for-range new-region-node
1500+
sexp-beg
1501+
sexp-end
1502+
t))
1503+
(sexp-type (plist-get sexp :sexp-type))
1504+
(node-end (treesit-node-end node)))
1505+
(clojure-ts--point-to-align-position sexp-type node)
1506+
(align-region (point) node-end nil
1507+
`((clojure-align (regexp . ,(lambda (&optional bound _noerror)
1508+
(clojure-ts--search-whitespace-after-next-sexp node bound)))
1509+
(group . 1)
1510+
(separate . ,clojure-ts-align-separator)
1511+
(repeat . t)))
1512+
nil)
1513+
;; After every iteration we have to re-indent the s-expression,
1514+
;; otherwise some can be indented inconsistently.
1515+
(indent-region (marker-position (plist-get sexp :beg-marker))
1516+
(marker-position (plist-get sexp :end-marker))))))))
1517+
1518+
12801519
(defvar clojure-ts-mode-map
12811520
(let ((map (make-sparse-keymap)))
12821521
;;(set-keymap-parent map clojure-mode-map)
1522+
(keymap-set map "C-c SPC" #'clojure-ts-align)
12831523
map))
12841524

12851525
(defvar clojure-ts-clojurescript-mode-map
@@ -1347,6 +1587,7 @@ function can also be used to upgrade the grammars if they are outdated."
13471587
(defun clojure-ts-mode-variables (&optional markdown-available)
13481588
"Initialize buffer-local variables for `clojure-ts-mode'.
13491589
See `clojure-ts--font-lock-settings' for usage of MARKDOWN-AVAILABLE."
1590+
(setq-local indent-tabs-mode nil)
13501591
(setq-local comment-add 1)
13511592
(setq-local comment-start ";")
13521593

0 commit comments

Comments
 (0)