Skip to content

Support nested indentation rules #70

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
Show file tree
Hide file tree
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
Expand Up @@ -15,6 +15,7 @@
- [#61](https://github.com/clojure-emacs/clojure-ts-mode/issues/61): Fix issue with indentation of collection items with metadata.
- Proper syntax highlighting for expressions with metadata.
- Add basic support for dynamic indentation via `clojure-ts-get-indent-function`.
- Add support for nested indentation rules.

## 0.2.3 (2025-03-04)

Expand Down
21 changes: 11 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,8 @@ your personal config. Let's assume you want to indent `->>` and `->` like this:
You can do so by putting the following in your config:

```emacs-lisp
(setopt clojure-ts-semantic-indent-rules '(("->" . (:block 1))
("->>" . (:block 1))))
(setopt clojure-ts-semantic-indent-rules '(("->" . ((:block 1)))
("->>" . ((:block 1)))))
```

This means that the body of the `->`/`->>` is after the first argument.
Expand All @@ -198,16 +198,17 @@ The default set of rules is defined as
`clojure-ts--semantic-indent-rules-defaults`, any rule can be overridden using
customization option.

There are 2 types of rules supported: `:block` and `:inner`, similarly to
cljfmt. If rule is defined as `:block n`, `n` means a number of arguments after
which begins the body. If rule is defined as `:inner n`, each form in the body
is indented with 2 spaces regardless of `n` value (currently all default rules
has 0 value).
Two types of rules are supported: `:block` and `:inner`, mirroring those in
cljfmt. When a rule is defined as `:block n`, `n` represents the number of
arguments preceding the body. When a rule is defined as `:inner n`, each form
within the expression's body, nested `n` levels deep, is indented by two
spaces. These rule definitions fully reflect the [cljfmt rules](https://github.com/weavejester/cljfmt/blob/0.13.0/docs/INDENTS.md).

For example:
- `do` has a rule `:block 0`.
- `when` has a rule `:block 1`.
- `defn` and `fn` have a rule `:inner 0`.
- `do` has a rule `((:block 0))`.
- `when` has a rule `((:block 1))`.
- `defn` and `fn` have a rule `((:inner 0))`.
- `letfn` has a rule `((:block 1) (:inner 2 0))`.

### Font Locking

Expand Down
257 changes: 151 additions & 106 deletions clojure-ts-mode.el
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,12 @@ Default set of rules is defined in
`clojure-ts--semantic-indent-rules-defaults'."
:safe #'listp
:type '(alist :key-type string
:value-type (list (choice (const :tag "Block indentation rule" :block)
(const :tag "Inner indentation rule" :inner))
integer))
:value-type (repeat (choice (list (choice (const :tag "Block indentation rule" :block)
(const :tag "Inner indentation rule" :inner))
integer)
(list (const :tag "Inner indentation rule" :inner)
integer
integer))))
:package-version '(clojure-ts-mode . "0.2.4"))

(defvar clojure-ts-mode-remappings
Expand Down Expand Up @@ -769,74 +772,73 @@ The possible values for this variable are
((parent-is "set_lit") parent 2))))

(defvar clojure-ts--semantic-indent-rules-defaults
'(("alt!" . (:block 0))
("alt!!" . (:block 0))
("comment" . (:block 0))
("cond" . (:block 0))
("delay" . (:block 0))
("do" . (:block 0))
("finally" . (:block 0))
("future" . (:block 0))
("go" . (:block 0))
("thread" . (:block 0))
("try" . (:block 0))
("with-out-str" . (:block 0))
("defprotocol" . (:block 1))
("binding" . (:block 1))
("defprotocol" . (:block 1))
("binding" . (:block 1))
("case" . (:block 1))
("cond->" . (:block 1))
("cond->>" . (:block 1))
("doseq" . (:block 1))
("dotimes" . (:block 1))
("doto" . (:block 1))
("extend" . (:block 1))
("extend-protocol" . (:block 1))
("extend-type" . (:block 1))
("for" . (:block 1))
("go-loop" . (:block 1))
("if" . (:block 1))
("if-let" . (:block 1))
("if-not" . (:block 1))
("if-some" . (:block 1))
("let" . (:block 1))
("letfn" . (:block 1))
("locking" . (:block 1))
("loop" . (:block 1))
("match" . (:block 1))
("ns" . (:block 1))
("struct-map" . (:block 1))
("testing" . (:block 1))
("when" . (:block 1))
("when-first" . (:block 1))
("when-let" . (:block 1))
("when-not" . (:block 1))
("when-some" . (:block 1))
("while" . (:block 1))
("with-local-vars" . (:block 1))
("with-open" . (:block 1))
("with-precision" . (:block 1))
("with-redefs" . (:block 1))
("defrecord" . (:block 2))
("deftype" . (:block 2))
("are" . (:block 2))
("as->" . (:block 2))
("catch" . (:block 2))
("condp" . (:block 2))
("bound-fn" . (:inner 0))
("def" . (:inner 0))
("defmacro" . (:inner 0))
("defmethod" . (:inner 0))
("defmulti" . (:inner 0))
("defn" . (:inner 0))
("defn-" . (:inner 0))
("defonce" . (:inner 0))
("deftest" . (:inner 0))
("fdef" . (:inner 0))
("fn" . (:inner 0))
("reify" . (:inner 0))
("use-fixtures" . (:inner 0)))
'(("alt!" . ((:block 0)))
("alt!!" . ((:block 0)))
("comment" . ((:block 0)))
("cond" . ((:block 0)))
("delay" . ((:block 0)))
("do" . ((:block 0)))
("finally" . ((:block 0)))
("future" . ((:block 0)))
("go" . ((:block 0)))
("thread" . ((:block 0)))
("try" . ((:block 0)))
("with-out-str" . ((:block 0)))
("defprotocol" . ((:block 1) (:inner 1)))
("binding" . ((:block 1)))
("case" . ((:block 1)))
("cond->" . ((:block 1)))
("cond->>" . ((:block 1)))
("doseq" . ((:block 1)))
("dotimes" . ((:block 1)))
("doto" . ((:block 1)))
("extend" . ((:block 1)))
("extend-protocol" . ((:block 1) (:inner 1)))
("extend-type" . ((:block 1) (:inner 1)))
("for" . ((:block 1)))
("go-loop" . ((:block 1)))
("if" . ((:block 1)))
("if-let" . ((:block 1)))
("if-not" . ((:block 1)))
("if-some" . ((:block 1)))
("let" . ((:block 1)))
("letfn" . ((:block 1) (:inner 2 0)))
("locking" . ((:block 1)))
("loop" . ((:block 1)))
("match" . ((:block 1)))
("ns" . ((:block 1)))
("struct-map" . ((:block 1)))
("testing" . ((:block 1)))
("when" . ((:block 1)))
("when-first" . ((:block 1)))
("when-let" . ((:block 1)))
("when-not" . ((:block 1)))
("when-some" . ((:block 1)))
("while" . ((:block 1)))
("with-local-vars" . ((:block 1)))
("with-open" . ((:block 1)))
("with-precision" . ((:block 1)))
("with-redefs" . ((:block 1)))
("defrecord" . ((:block 2) (:inner 1)))
("deftype" . ((:block 2) (:inner 1)))
("are" . ((:block 2)))
("as->" . ((:block 2)))
("catch" . ((:block 2)))
("condp" . ((:block 2)))
("bound-fn" . ((:inner 0)))
("def" . ((:inner 0)))
("defmacro" . ((:inner 0)))
("defmethod" . ((:inner 0)))
("defmulti" . ((:inner 0)))
("defn" . ((:inner 0)))
("defn-" . ((:inner 0)))
("defonce" . ((:inner 0)))
("deftest" . ((:inner 0)))
("fdef" . ((:inner 0)))
("fn" . ((:inner 0)))
("reify" . ((:inner 0) (:inner 1)))
("proxy" . ((:block 2) (:inner 1)))
("use-fixtures" . ((:inner 0))))
"Default semantic indentation rules.

The format reflects cljfmt indentation rules. All the default rules are
Expand Down Expand Up @@ -882,22 +884,87 @@ The returned value is expected to be the same as
`clojure-get-indent-function' from `clojure-mode' for compatibility
reasons.")

(defun clojure-ts--unwrap-dynamic-spec (spec current-depth)
"Recursively unwrap SPEC, incrementally increasing the CURRENT-DEPTH.

This function accepts a list SPEC, like ((:defn)) and produce a proper
indent rule. For example, ((:defn)) is converted to (:inner 2),
and (:defn) is converted to (:inner 1)."
(if (consp spec)
(clojure-ts--unwrap-dynamic-spec (car spec) (1+ current-depth))
(cond
((equal spec :defn) (list :inner current-depth))
(t nil))))

(defun clojure-ts--dynamic-indent-for-symbol (symbol-name)
"Return dynamic indentation spec for SYMBOL-NAME if found.
"Returns the dynamic indentation specification for SYMBOL-NAME, if found.

If the function `clojure-ts-get-indent-function' is defined, call it and
produce a valid indentation specification from its return value.

If function `clojure-ts-get-indent-function' is not nil, call it and
produce a valid indentation spec from the returned value.
The `clojure-ts-get-indent-function' should return an indentation
specification compatible with `clojure-mode', which will then be
converted to a suitable `clojure-ts-mode' specification.

The indentation rules for `clojure-ts-mode' are simpler than for
`clojure-mode' so we only take the first integer N and produce `(:block
N)' rule. If an integer cannot be found, this function returns nil and
the default rule is used."
For example, (1 ((:defn)) nil) is converted to ((:block 1) (:inner 2))."
(when (functionp clojure-ts-get-indent-function)
(let ((spec (funcall clojure-ts-get-indent-function symbol-name)))
(if (consp spec)
`(:block ,(car spec))
(when (integerp spec)
`(:block ,spec))))))
(if (integerp spec)
(list (list :block spec))
(when (sequencep spec)
(thread-last spec
(seq-map (lambda (el)
(cond
((integerp el) (list :block el))
((equal el :defn) (list :inner 0))
((consp el) (clojure-ts--unwrap-dynamic-spec el 0))
(t nil))))
(seq-remove #'null)
;; Always put `:block' to the beginning.
(seq-sort (lambda (spec1 _spec2)
(equal (car spec1) :block)))))))))

(defun clojure-ts--find-semantic-rule (node parent current-depth)
"Returns a suitable indentation rule for NODE, considering the CURRENT-DEPTH.

Attempts to find an indentation rule by examining the symbol name of the
PARENT's first child. If a rule is not found, it navigates up the
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))
(idx (- (treesit-node-index node) 2)))
(if-let* ((rule-set (or (clojure-ts--dynamic-indent-for-symbol symbol-name)
(alist-get symbol-name
(seq-union clojure-ts-semantic-indent-rules
clojure-ts--semantic-indent-rules-defaults
(lambda (e1 e2) (equal (car e1) (car e2))))
nil
nil
#'equal))))
(if (zerop current-depth)
(let ((rule (car rule-set)))
(if (equal (car rule) :block)
rule
(pcase-let ((`(,_ ,rule-depth ,rule-idx) rule))
(when (and (equal rule-depth current-depth)
(or (null rule-idx)
(equal rule-idx idx)))
rule))))
(thread-last rule-set
(seq-filter (lambda (rule)
(pcase-let ((`(,rule-type ,rule-depth ,rule-idx) rule))
(and (equal rule-type :inner)
(equal rule-depth current-depth)
(or (null rule-idx)
(equal rule-idx idx))))))
(seq-first)))
(when-let* ((new-parent (treesit-node-parent parent)))
(clojure-ts--find-semantic-rule parent
new-parent
(1+ current-depth))))))

(defun clojure-ts--match-form-body (node parent bol)
"Match if NODE has to be indented as a for body.
Expand All @@ -907,16 +974,8 @@ indentation rule in `clojure-ts--semantic-indent-rules-defaults' or
`clojure-ts-semantic-indent-rules' check if NODE should be indented
according to the rule. If NODE is nil, use next node after BOL."
(and (clojure-ts--list-node-p parent)
(let* ((first-child (clojure-ts--node-child-skip-metadata parent 0))
(symbol-name (clojure-ts--named-node-text first-child)))
(when-let* ((rule (or (clojure-ts--dynamic-indent-for-symbol symbol-name)
(alist-get symbol-name
(seq-union clojure-ts-semantic-indent-rules
clojure-ts--semantic-indent-rules-defaults
(lambda (e1 e2) (equal (car e1) (car e2))))
nil
nil
#'equal))))
(let* ((first-child (clojure-ts--node-child-skip-metadata parent 0)))
(when-let* ((rule (clojure-ts--find-semantic-rule node parent 0)))
(and (not (clojure-ts--match-with-metadata node))
(let ((rule-type (car rule))
(rule-value (cadr rule)))
Expand All @@ -940,19 +999,6 @@ according to the rule. If NODE is nil, use next node after BOL."
(clojure-ts--keyword-node-p first-child)
(clojure-ts--var-node-p first-child)))))

(defun clojure-ts--match-method-body (_node parent _bol)
"Matches a `NODE' in the body of a `PARENT' method implementation.
A method implementation referes to concrete implementations being defined in
forms like deftype, defrecord, reify, proxy, etc."
(and
(clojure-ts--list-node-p parent)
(let* ((grandparent (treesit-node-parent parent))
;; auncle: gender neutral sibling of parent, aka child of grandparent
(first-auncle (treesit-node-child grandparent 0 t)))
(and (clojure-ts--list-node-p grandparent)
(clojure-ts--symbol-matches-p clojure-ts--type-symbol-regexp
first-auncle)))))

(defvar clojure-ts--threading-macro
(eval-and-compile
(rx (and "->" (? ">") line-end)))
Expand Down Expand Up @@ -1043,7 +1089,6 @@ if NODE has metadata and its parent has type NODE-TYPE."
((parent-is "source") parent-bol 0)
(clojure-ts--match-docstring parent 0)
;; https://guide.clojure.style/#body-indentation
(clojure-ts--match-method-body parent 2)
(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)
Expand Down
16 changes: 10 additions & 6 deletions test/clojure-ts-mode-indentation-test.el
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ DESCRIPTION is a string with the description of the spec."
(when (stringp symbol-name)
(cond
((string-equal symbol-name "my-with-in-str") 1)
((string-equal symbol-name "my-letfn") '(1 ((:defn) (:form)))))))
((string-equal symbol-name "my-letfn") '(1 ((:defn)) :form)))))


(describe "indentation"
Expand Down Expand Up @@ -242,7 +242,7 @@ DESCRIPTION is a string with the description of the spec."
2 3
4 5
6 6)"
(setopt clojure-ts-semantic-indent-rules '(("are" . (:block 1))))
(setopt clojure-ts-semantic-indent-rules '(("are" . ((:block 1)))))
(indent-region (point-min) (point-max))
(expect (buffer-string) :to-equal "
(are [x y]
Expand Down Expand Up @@ -305,8 +305,10 @@ DESCRIPTION is a string with the description of the spec."
[fnspecs & body]
~@body)

(my-letfn [(twice [x] (* x 2))
(six-times [y] (* (twice y) 3))]
(my-letfn [(twice [x]
(* x 2))
(six-times [y]
(* (twice y) 3))]
(println \"Twice 15 =\" (twice 15))
(println \"Six times 15 =\" (six-times 15)))"
(setq-local clojure-ts-get-indent-function #'cider--get-symbol-indent-mock)
Expand All @@ -318,7 +320,9 @@ DESCRIPTION is a string with the description of the spec."
[fnspecs & body]
~@body)

(my-letfn [(twice [x] (* x 2))
(six-times [y] (* (twice y) 3))]
(my-letfn [(twice [x]
(* x 2))
(six-times [y]
(* (twice y) 3))]
(println \"Twice 15 =\" (twice 15))
(println \"Six times 15 =\" (six-times 15)))"))))
Loading
Loading