Skip to content

Commit 4cf4bd1

Browse files
rrudakovbbatsov
authored andcommitted
Improve semantic indentation rules to be more consistent with cljfmt
1 parent f932fc3 commit 4cf4bd1

File tree

6 files changed

+358
-53
lines changed

6 files changed

+358
-53
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
- Highlight named lambda functions properly.
1111
- Fix syntax highlighting for functions and vars with metadata on the previous
1212
line.
13+
- Improve semantic indentation rules to be more consistent with cljfmt.
14+
- Introduce `clojure-ts-semantic-indent-rules` customization option.
1315

1416
## 0.2.3 (2025-03-04)
1517

README.md

+39
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,45 @@ Set the var `clojure-ts-indent-style` to change it.
170170
>
171171
> You can find [this article](https://metaredux.com/posts/2020/12/06/semantic-clojure-formatting.html) comparing semantic and fixed indentation useful.
172172
173+
#### Customizing semantic indentation
174+
175+
The indentation of special forms and macros with bodies is controlled via
176+
`clojure-ts-semantic-indent-rules`. Nearly all special forms and built-in macros
177+
with bodies have special indentation settings in clojure-ts-mode, which are
178+
aligned with cljfmt indent rules. You can add/alter the indentation settings in
179+
your personal config. Let's assume you want to indent `->>` and `->` like this:
180+
181+
```clojure
182+
(->> something
183+
ala
184+
bala
185+
portokala)
186+
```
187+
188+
You can do so by putting the following in your config:
189+
190+
```emacs-lisp
191+
(setopt clojure-ts-semantic-indent-rules '(("->" . (:block 1))
192+
("->>" . (:block 1))))
193+
```
194+
195+
This means that the body of the `->`/`->>` is after the first argument.
196+
197+
The default set of rules is defined as
198+
`clojure-ts--semantic-indent-rules-defaults`, any rule can be overridden using
199+
customization option.
200+
201+
There are 2 types of rules supported: `:block` and `:inner`, similarly to
202+
cljfmt. If rule is defined as `:block n`, `n` means a number of arguments after
203+
which begins the body. If rule is defined as `:inner n`, each form in the body
204+
is indented with 2 spaces regardless of `n` value (currently all default rules
205+
has 0 value).
206+
207+
For example:
208+
- `do` has a rule `:block 0`.
209+
- `when` has a rule `:block 1`.
210+
- `defn` and `fn` have a rule `:inner 0`.
211+
173212
### Font Locking
174213

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

clojure-ts-mode.el

+147-48
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,23 @@ double quotes on the third column."
125125
:type 'boolean
126126
:package-version '(clojure-ts-mode . "0.2.4"))
127127

128+
(defcustom clojure-ts-semantic-indent-rules nil
129+
"Custom rules to extend default indentation rules for `semantic' style.
130+
131+
Each rule is an alist entry which looks like `(\"symbol-name\"
132+
. (rule-type rule-value))', where rule-type is one either `:block' or
133+
`:inner' and rule-value is an integer. The semantic is similar to
134+
cljfmt indentation rules.
135+
136+
Default set of rules is defined in
137+
`clojure-ts--semantic-indent-rules-defaults'."
138+
:safe #'listp
139+
:type '(alist :key-type string
140+
:value-type (list (choice (const :tag "Block indentation rule" :block)
141+
(const :tag "Inner indentation rule" :inner))
142+
integer))
143+
:package-version '(clojure-ts-mode . "0.2.4"))
144+
128145
(defvar clojure-ts-mode-remappings
129146
'((clojure-mode . clojure-ts-mode)
130147
(clojurescript-mode . clojure-ts-clojurescript-mode)
@@ -182,7 +199,6 @@ Only intended for use at development time.")
182199
table)
183200
"Syntax table for `clojure-ts-mode'.")
184201

185-
186202
(defconst clojure-ts--builtin-dynamic-var-regexp
187203
(eval-and-compile
188204
(concat "^"
@@ -746,34 +762,135 @@ The possible values for this variable are
746762
((parent-is "list_lit") parent 1)
747763
((parent-is "set_lit") parent 2))))
748764

749-
(defvar clojure-ts--symbols-with-body-expressions-regexp
750-
(eval-and-compile
751-
(rx (or
752-
;; Match def* symbols,
753-
;; we also explicitly do not match symbols beginning with
754-
;; "default" "deflate" and "defer", like cljfmt
755-
(and line-start "def")
756-
;; Match with-* symbols
757-
(and line-start "with-")
758-
;; Exact matches
759-
(and line-start
760-
(or "alt!" "alt!!" "are" "as->"
761-
"binding" "bound-fn"
762-
"case" "catch" "comment" "cond" "condp" "cond->" "cond->>"
763-
"delay" "do" "doseq" "dotimes" "doto"
764-
"extend" "extend-protocol" "extend-type"
765-
"fdef" "finally" "fn" "for" "future"
766-
"go" "go-loop"
767-
"if" "if-let" "if-not" "if-some"
768-
"let" "letfn" "locking" "loop"
769-
"match" "ns" "proxy" "reify" "struct-map"
770-
"testing" "thread" "try"
771-
"use-fixtures"
772-
"when" "when-first" "when-let" "when-not" "when-some" "while")
773-
line-end))))
774-
"A regex to match symbols that are functions/macros with a body argument.
775-
Taken from cljfmt:
776-
https://github.com/weavejester/cljfmt/blob/fb26b22f569724b05c93eb2502592dfc2de898c3/cljfmt/resources/cljfmt/indents/clojure.clj")
765+
(defvar clojure-ts--semantic-indent-rules-defaults
766+
'(("alt!" . (:block 0))
767+
("alt!!" . (:block 0))
768+
("comment" . (:block 0))
769+
("cond" . (:block 0))
770+
("delay" . (:block 0))
771+
("do" . (:block 0))
772+
("finally" . (:block 0))
773+
("future" . (:block 0))
774+
("go" . (:block 0))
775+
("thread" . (:block 0))
776+
("try" . (:block 0))
777+
("with-out-str" . (:block 0))
778+
("defprotocol" . (:block 1))
779+
("binding" . (:block 1))
780+
("defprotocol" . (:block 1))
781+
("binding" . (:block 1))
782+
("case" . (:block 1))
783+
("cond->" . (:block 1))
784+
("cond->>" . (:block 1))
785+
("doseq" . (:block 1))
786+
("dotimes" . (:block 1))
787+
("doto" . (:block 1))
788+
("extend" . (:block 1))
789+
("extend-protocol" . (:block 1))
790+
("extend-type" . (:block 1))
791+
("for" . (:block 1))
792+
("go-loop" . (:block 1))
793+
("if" . (:block 1))
794+
("if-let" . (:block 1))
795+
("if-not" . (:block 1))
796+
("if-some" . (:block 1))
797+
("let" . (:block 1))
798+
("letfn" . (:block 1))
799+
("locking" . (:block 1))
800+
("loop" . (:block 1))
801+
("match" . (:block 1))
802+
("ns" . (:block 1))
803+
("struct-map" . (:block 1))
804+
("testing" . (:block 1))
805+
("when" . (:block 1))
806+
("when-first" . (:block 1))
807+
("when-let" . (:block 1))
808+
("when-not" . (:block 1))
809+
("when-some" . (:block 1))
810+
("while" . (:block 1))
811+
("with-local-vars" . (:block 1))
812+
("with-open" . (:block 1))
813+
("with-precision" . (:block 1))
814+
("with-redefs" . (:block 1))
815+
("defrecord" . (:block 2))
816+
("deftype" . (:block 2))
817+
("are" . (:block 2))
818+
("as->" . (:block 2))
819+
("catch" . (:block 2))
820+
("condp" . (:block 2))
821+
("bound-fn" . (:inner 0))
822+
("def" . (:inner 0))
823+
("defmacro" . (:inner 0))
824+
("defmethod" . (:inner 0))
825+
("defmulti" . (:inner 0))
826+
("defn" . (:inner 0))
827+
("defn-" . (:inner 0))
828+
("defonce" . (:inner 0))
829+
("deftest" . (:inner 0))
830+
("fdef" . (:inner 0))
831+
("fn" . (:inner 0))
832+
("reify" . (:inner 0))
833+
("use-fixtures" . (:inner 0)))
834+
"Default semantic indentation rules.
835+
836+
The format reflects cljfmt indentation rules. All the default rules are
837+
aligned with
838+
https://github.com/weavejester/cljfmt/blob/0.13.0/cljfmt/resources/cljfmt/indents/clojure.clj")
839+
840+
(defun clojure-ts--match-block-0-body (bol first-child)
841+
"Match if expression body is not at the same line as FIRST-CHILD.
842+
843+
If there is no body, check that BOL is not at the same line."
844+
(let* ((body-pos (if-let* ((body (treesit-node-next-sibling first-child)))
845+
(treesit-node-start body)
846+
bol)))
847+
(< (line-number-at-pos (treesit-node-start first-child))
848+
(line-number-at-pos body-pos))))
849+
850+
(defun clojure-ts--node-pos-match-block (node parent bol block)
851+
"Return TRUE if NODE index in the PARENT matches requested BLOCK.
852+
853+
NODE might be nil (when we insert an empty line for example), in this
854+
case we look for next available child node in the PARENT after BOL
855+
position.
856+
857+
The first node in the expression is usually an opening paren, the last
858+
node is usually a closing paren (unless some automatic parens mode is
859+
not enabled). If requested BLOCK is 1, the NODE index should be at
860+
least 3 (first node is opening paren, second node is matched symbol,
861+
third node is first argument, and the rest is body which should be
862+
indented.)"
863+
(if node
864+
(> (treesit-node-index node) (1+ block))
865+
(when-let* ((node-after-bol (treesit-node-first-child-for-pos parent bol)))
866+
(> (treesit-node-index node-after-bol) (1+ block)))))
867+
868+
(defun clojure-ts--match-form-body (node parent bol)
869+
"Match if NODE has to be indented as a for body.
870+
871+
PARENT not should be a list. If first symbol in the expression has an
872+
indentation rule in `clojure-ts--semantic-indent-rules-defaults' or
873+
`clojure-ts-semantic-indent-rules' check if NODE should be indented
874+
according to the rule. If NODE is nil, use next node after BOL."
875+
(and (clojure-ts--list-node-p parent)
876+
(let ((first-child (clojure-ts--node-child-skip-metadata parent 0)))
877+
(when-let* ((rule (alist-get (clojure-ts--named-node-text first-child)
878+
(seq-union clojure-ts-semantic-indent-rules
879+
clojure-ts--semantic-indent-rules-defaults
880+
(lambda (e1 e2) (equal (car e1) (car e2))))
881+
nil
882+
nil
883+
#'equal)))
884+
(and (not (clojure-ts--match-with-metadata node))
885+
(let ((rule-type (car rule))
886+
(rule-value (cadr rule)))
887+
(if (equal rule-type :block)
888+
(if (zerop rule-value)
889+
;; Special treatment for block 0 rule.
890+
(clojure-ts--match-block-0-body bol first-child)
891+
(clojure-ts--node-pos-match-block node parent bol rule-value))
892+
;; Return true for any inner rule.
893+
t)))))))
777894

778895
(defun clojure-ts--match-function-call-arg (node parent _bol)
779896
"Match NODE if PARENT is a list expressing a function or macro call."
@@ -787,24 +904,6 @@ https://github.com/weavejester/cljfmt/blob/fb26b22f569724b05c93eb2502592dfc2de89
787904
(clojure-ts--keyword-node-p first-child)
788905
(clojure-ts--var-node-p first-child)))))
789906

790-
(defun clojure-ts--match-expression-in-body (node parent _bol)
791-
"Match NODE if it is an expression used in a body argument.
792-
PARENT is expected to be a list literal.
793-
See `treesit-simple-indent-rules'."
794-
(and
795-
(clojure-ts--list-node-p parent)
796-
(let ((first-child (clojure-ts--node-child-skip-metadata parent 0)))
797-
(and
798-
(not
799-
(clojure-ts--symbol-matches-p
800-
;; Symbols starting with this are false positives
801-
(rx line-start (or "default" "deflate" "defer"))
802-
first-child))
803-
(not (clojure-ts--match-with-metadata node))
804-
(clojure-ts--symbol-matches-p
805-
clojure-ts--symbols-with-body-expressions-regexp
806-
first-child)))))
807-
808907
(defun clojure-ts--match-method-body (_node parent _bol)
809908
"Matches a `NODE' in the body of a `PARENT' method implementation.
810909
A method implementation referes to concrete implementations being defined in
@@ -885,7 +984,7 @@ forms like deftype, defrecord, reify, proxy, etc."
885984
(clojure-ts--match-docstring parent 0)
886985
;; https://guide.clojure.style/#body-indentation
887986
(clojure-ts--match-method-body parent 2)
888-
(clojure-ts--match-expression-in-body parent 2)
987+
(clojure-ts--match-form-body parent 2)
889988
;; https://guide.clojure.style/#threading-macros-alignment
890989
(clojure-ts--match-threading-macro-arg prev-sibling 0)
891990
;; https://guide.clojure.style/#vertically-align-fn-args

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

+100-1
Original file line numberDiff line numberDiff line change
@@ -140,4 +140,103 @@ DESCRIPTION is a string with the description of the spec."
140140
(when-indenting-it "should support function calls via vars"
141141
"
142142
(#'foo 5
143-
6)"))
143+
6)")
144+
145+
(when-indenting-it "should support block-0 expressions"
146+
"
147+
(do (aligned)
148+
(vertically))"
149+
150+
"
151+
(do
152+
(indented)
153+
(with-2-spaces))"
154+
155+
"
156+
(future
157+
(body is indented))"
158+
159+
"
160+
(try
161+
(something)
162+
;; A bit of block 2 rule
163+
(catch Exception e
164+
\"Third argument is indented with 2 spaces.\")
165+
(catch ExceptionInfo
166+
e-info
167+
\"Second argument is aligned vertically with the first one.\"))")
168+
169+
(when-indenting-it "should support block-1 expressions"
170+
"
171+
(case x
172+
2 (print 2)
173+
3 (print 3)
174+
(print \"Default\"))"
175+
176+
"
177+
(cond-> {}
178+
:always (assoc :hello \"World\")
179+
false (do nothing))"
180+
181+
"
182+
(with-precision 32
183+
(/ (bigdec 20) (bigdec 30)))"
184+
185+
"
186+
(testing \"Something should work\"
187+
(is (something-working?)))")
188+
189+
(when-indenting-it "should support block-2 expressions"
190+
"
191+
(are [x y]
192+
(= x y)
193+
2 3
194+
4 5
195+
6 6)"
196+
197+
"
198+
(as-> {} $
199+
(assoc $ :hello \"World\"))"
200+
201+
"
202+
(as-> {}
203+
my-map
204+
(assoc my-map :hello \"World\"))"
205+
206+
"
207+
(defrecord MyThingR []
208+
IProto
209+
(foo [this x] x))")
210+
211+
(when-indenting-it "should support inner-0 expressions"
212+
"
213+
(fn named-lambda [x]
214+
(+ x x))"
215+
216+
"
217+
(defmethod hello :world
218+
[arg1 arg2]
219+
(+ arg1 arg2))"
220+
221+
"
222+
(reify
223+
AutoCloseable
224+
(close
225+
[this]
226+
(is properly indented)))")
227+
228+
(it "should prioritize custom semantic indentation rules"
229+
(with-clojure-ts-buffer "
230+
(are [x y]
231+
(= x y)
232+
2 3
233+
4 5
234+
6 6)"
235+
(setopt clojure-ts-semantic-indent-rules '(("are" . (:block 1))))
236+
(indent-region (point-min) (point-max))
237+
(expect (buffer-string) :to-equal "
238+
(are [x y]
239+
(= x y)
240+
2 3
241+
4 5
242+
6 6)"))))

0 commit comments

Comments
 (0)