branch: elpa/clojure-ts-mode
commit c8286e23e20ac2bc4d08664578d22ea2c2bb172f
Author: Roman Rudakov <rruda...@fastmail.com>
Commit: Bozhidar Batsov <bozhi...@batsov.dev>

    Support nested indentation rules
---
 CHANGELOG.md                             |   1 +
 README.md                                |  21 +--
 clojure-ts-mode.el                       | 257 ++++++++++++++++++-------------
 test/clojure-ts-mode-indentation-test.el |  16 +-
 test/samples/indentation.clj             |  23 ++-
 5 files changed, 192 insertions(+), 126 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index f41749f904..71cd6551c7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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)
 
diff --git a/README.md b/README.md
index b1aa3cf114..cad93f35aa 100644
--- a/README.md
+++ b/README.md
@@ -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.
@@ -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
 
diff --git a/clojure-ts-mode.el b/clojure-ts-mode.el
index e7198b4d85..1cfa6bdbef 100644
--- a/clojure-ts-mode.el
+++ b/clojure-ts-mode.el
@@ -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
@@ -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
@@ -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.
@@ -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)))
@@ -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)))
@@ -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)
diff --git a/test/clojure-ts-mode-indentation-test.el 
b/test/clojure-ts-mode-indentation-test.el
index 605ec7656a..738f2a069d 100644
--- a/test/clojure-ts-mode-indentation-test.el
+++ b/test/clojure-ts-mode-indentation-test.el
@@ -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"
@@ -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]
@@ -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)
@@ -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)))"))))
diff --git a/test/samples/indentation.clj b/test/samples/indentation.clj
index 79d7809015..53e8269d43 100644
--- a/test/samples/indentation.clj
+++ b/test/samples/indentation.clj
@@ -89,11 +89,13 @@
   (foo [this x]
     x))
 
-(defrecord MyThingR []
+(defrecord MyThingR
+           []
   IProto
-  (foo [this x] x))
+  (foo [this x]
+    x))
 
-(defn foo2 [x]b)
+(defn foo2 [x] b)
 
 (reify
   IProto
@@ -102,7 +104,8 @@
 
 (extend-type MyThing
   clojure.lang.IFn
-  (invoke [this] 1))
+  (invoke [this]
+    1))
 
 (extend-protocol clojure.lang.IFn
   MyThingR
@@ -266,3 +269,15 @@
      (if (= result -1)
        nil
        result))))
+
+;; Nested rules
+
+(letfn [(add [x y]
+          (+ x y))
+        (hello [user]
+          (println "Hello" user))]
+  (let [x 2
+        y 3
+        user "John Doe"]
+    (dotimes [_ (add x y)]
+      (hello user))))

Reply via email to