Skip to content

Commit b9d8ae1

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

File tree

5 files changed

+352
-10
lines changed

5 files changed

+352
-10
lines changed

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.

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

clojure-ts-mode.el

+218-10
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)
@@ -1277,9 +1346,147 @@ If JUSTIFY is non-nil, justify as well as fill the paragraph."
12771346
(markdown-inline
12781347
(sexp ,(regexp-opt clojure-ts--markdown-inline-sexp-nodes))))))
12791348

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

12851492
(defvar clojure-ts-clojurescript-mode-map
@@ -1347,6 +1554,7 @@ function can also be used to upgrade the grammars if they are outdated."
13471554
(defun clojure-ts-mode-variables (&optional markdown-available)
13481555
"Initialize buffer-local variables for `clojure-ts-mode'.
13491556
See `clojure-ts--font-lock-settings' for usage of MARKDOWN-AVAILABLE."
1557+
(setq-local indent-tabs-mode nil)
13501558
(setq-local comment-add 1)
13511559
(setq-local comment-start ";")
13521560

test/clojure-ts-mode-indentation-test.el

+68
Original file line numberDiff line numberDiff line change
@@ -326,3 +326,71 @@ DESCRIPTION is a string with the description of the spec."
326326
(* (twice y) 3))]
327327
(println \"Twice 15 =\" (twice 15))
328328
(println \"Six times 15 =\" (six-times 15)))"))))
329+
330+
(describe "clojure-ts-align"
331+
(it "should handle improperly indented content"
332+
(with-clojure-ts-buffer-point "
333+
(let [a-long-name 10
334+
b |20])"
335+
(call-interactively #'clojure-ts-align)
336+
(expect (buffer-string) :to-equal "
337+
(let [a-long-name 10
338+
b 20])"))
339+
340+
(with-clojure-ts-buffer-point "
341+
(let [^long my-map {:hello \"World\" ;Hello
342+
:foo
343+
^String (str \"Foo\" \"Bar\")
344+
:number ^long 132
345+
:zz \"hello\"}
346+
another| {:this ^{:hello \"world\"} \"is\"
347+
:a #long \"1234\"
348+
:b {:this \"is\"
349+
:nested \"map\"}}])"
350+
(call-interactively #'clojure-ts-align)
351+
(expect (buffer-string) :to-equal "
352+
(let [^long my-map {:hello \"World\" ;Hello
353+
:foo
354+
^String (str \"Foo\" \"Bar\")
355+
:number ^long 132
356+
:zz \"hello\"}
357+
another {:this ^{:hello \"world\"} \"is\"
358+
:a #long \"1234\"
359+
:b {:this \"is\"
360+
:nested \"map\"}}])"))
361+
362+
(with-clojure-ts-buffer-point "
363+
(condp = 2
364+
|123 \"Hello\"
365+
99999 \"World\"
366+
234 nil)"
367+
(call-interactively #'clojure-ts-align)
368+
(expect (buffer-string) :to-equal "
369+
(condp = 2
370+
123 \"Hello\"
371+
99999 \"World\"
372+
234 nil)")))
373+
374+
(it "should not align reader conditionals by defaul"
375+
(with-clojure-ts-buffer-point "
376+
#?(:clj 2
377+
|:cljs 2)"
378+
(call-interactively #'clojure-ts-align)
379+
(expect (buffer-string) :to-equal "
380+
#?(:clj 2
381+
:cljs 2)")))
382+
383+
(it "should align reader conditionals when clojure-ts-align-reader-conditionals is true"
384+
(with-clojure-ts-buffer-point "
385+
#?(:clj 2
386+
|:cljs 2)"
387+
(setq-local clojure-ts-align-reader-conditionals t)
388+
(call-interactively #'clojure-ts-align)
389+
(expect (buffer-string) :to-equal "
390+
#?(:clj 2
391+
:cljs 2)")))
392+
393+
(it "should remove extra commas"
394+
(with-clojure-ts-buffer-point "{|:a 2, ,:c 4}"
395+
(call-interactively #'clojure-ts-align)
396+
(expect (buffer-string) :to-equal "{:a 2, :c 4}"))))

0 commit comments

Comments
 (0)