Skip to content

Add support for automatic aligning forms #85

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)

4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -258,6 +258,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
145 changes: 100 additions & 45 deletions clojure-ts-mode.el
Original file line number Diff line number Diff line change
@@ -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,40 +1466,67 @@ 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.

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,47 +1585,50 @@ 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)))
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))))))))
(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))
188 changes: 187 additions & 1 deletion test/clojure-ts-mode-indentation-test.el
Original file line number Diff line number Diff line change
@@ -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)"))
27 changes: 26 additions & 1 deletion test/samples/align.clj
Original file line number Diff line number Diff line change
@@ -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)