diff --git a/README.md b/README.md index d9259f00..9c96146e 100644 --- a/README.md +++ b/README.md @@ -239,28 +239,75 @@ prefixed with some ns (or ns alias): (put-clojure-indent 'my-ns/do 1) ``` -The bodies of certain more complicated macros and special forms -(e.g. `letfn`, `deftype`, `extend-protocol`, etc) are indented using -a contextual backtracking indentation method, require more sophisticated -indent specifications. Here are a few examples: +##### Backtracking (contextual) indentation + +Certain macros and special forms (e.g. `letfn`, `deftype`, +`extend-protocol`) contain *nested* sub-forms that each need their +own indentation style. For these, `clojure-mode` uses **backtracking +indentation**: when indenting a line, it walks up the sexp tree to +find a parent form with an indent spec, then uses the current +position within that spec to decide how to indent. + +A backtracking indent spec is a **quoted list** where each element +controls indentation at the corresponding argument position +(0-indexed). The allowed elements are: + +| Element | Meaning | +|---------|---------| +| An integer N | First N args are "special" (indented further); rest are body | +| `:defn` | Indent like a function/macro body | +| `:form` | Indent like a regular form | +| `nil` | Use default indentation rules | +| A list `(SPEC)` | This position holds a **list of forms**, each indented according to SPEC | + +For example, `letfn` uses `'(1 ((:defn)) nil)`: + +```clojure +(letfn [(twice [x] ;; pos 0 → spec 1 (1 special arg = the binding vector) + (* x 2)) ;; inside binding → spec ((:defn)) applies: + (thrice [x] ;; each binding is a list of :defn-style forms + (* x 3))] ;; so function bodies get :defn indentation + (+ (twice 5) ;; pos 1+ → spec nil (default → body indentation) + (thrice 5))) +``` + +And `defrecord` uses `'(2 nil nil (:defn))`: + +```clojure +(defrecord MyRecord ;; pos 0 → spec 2 (2 special args: name + fields) + [field1 field2] ;; pos 1 → spec nil (within special args zone) + SomeProtocol ;; pos 2 → spec nil + (some-method [this] ;; pos 3+ → spec (:defn) — each method gets :defn-style + (do-stuff this))) +``` + +Here are the built-in backtracking specs: ```el (define-clojure-indent - (implement '(1 (1))) - (letfn '(1 ((:defn)) nil)) - (proxy '(2 nil nil (1))) - (reify '(:defn (1))) - (deftype '(2 nil nil (1))) - (defrecord '(2 nil nil (1))) - (specify '(1 (1))) - (specify '(1 (1)))) + (letfn '(1 ((:defn)) nil)) + (deftype '(2 nil nil (:defn))) + (defrecord '(2 nil nil (:defn))) + (defprotocol '(1 (:defn))) + (definterface '(1 (:defn))) + (reify '(:defn (1))) + (proxy '(2 nil nil (:defn))) + (extend-protocol '(1 :defn)) + (extend-type '(1 :defn)) + (specify '(1 :defn)) + (specify! '(1 :defn))) ``` These follow the same rules as the `:style/indent` metadata specified by [cider-nrepl][]. -For instructions on how to write these specifications, see +For more details on writing indent specifications, see [this document](https://docs.cider.mx/cider/indent_spec.html). The only difference is that you're allowed to use lists instead of vectors. +Backtracking is controlled by `clojure-use-backtracking-indent` +(default `t`) and limited to `clojure-max-backtracking` levels +(default 3). Disabling backtracking will break indentation for +all forms with list-based specs. + The indentation of [special arguments](https://docs.cider.mx/cider/indent_spec.html#special-arguments) is controlled by `clojure-special-arg-indent-factor`, which by default indents special arguments a further `lisp-body-indent` when compared to ordinary arguments. @@ -724,13 +771,16 @@ See [this ticket](https://github.com/clojure-emacs/clojure-mode/issues/270) for ### Indentation Performance -`clojure-mode`'s indentation engine is a bit slow. You can speed things up -significantly by disabling `clojure-use-backtracking-indent`, but this will -break the indentation of complex forms like `deftype`, `defprotocol`, `reify`, -`letfn`, etc. +`clojure-mode`'s indentation engine is a bit slow due to the +[backtracking indentation](#backtracking-contextual-indentation) logic +that walks up the sexp tree for context. You can speed things up +significantly by setting `clojure-use-backtracking-indent` to `nil`, +but this will break the indentation of forms with list-based specs +(`deftype`, `defrecord`, `defprotocol`, `definterface`, `reify`, +`proxy`, `letfn`, `extend-type`, `extend-protocol`, `specify`, +`specify!`). Simple integer and `:defn` specs will continue to work. -We should look into ways to optimize the performance of the backtracking -indentation logic. See [this ticket](https://github.com/clojure-emacs/clojure-mode/issues/606) for more +See [this ticket](https://github.com/clojure-emacs/clojure-mode/issues/606) for more details. ### Font-locking Implementation diff --git a/clojure-mode.el b/clojure-mode.el index ed8d49c9..adceb4a4 100644 --- a/clojure-mode.el +++ b/clojure-mode.el @@ -197,12 +197,19 @@ to indent keyword invocation forms. :package-version '(clojure-mode . "5.19.0")) (defcustom clojure-use-backtracking-indent t - "When non-nil, enable context sensitive indentation." + "When non-nil, enable context-sensitive indentation. +When indenting a line, walk up the sexp tree to find a parent +form with an indent spec, then use the current position within +that spec to determine indentation. This is required for forms +with list-based indent specs like `letfn', `deftype', `defrecord', +`reify', `proxy', etc. Disabling this speeds up indentation but +breaks those forms." :type 'boolean :safe 'booleanp) (defcustom clojure-max-backtracking 3 - "Maximum amount to backtrack up a list to check for context." + "Maximum number of levels to walk up the sexp tree for indent context. +Only relevant when `clojure-use-backtracking-indent' is non-nil." :type 'integer :safe 'integerp) @@ -1694,8 +1701,15 @@ symbol properties." (defvar clojure--current-backtracking-depth 0) (defun clojure--find-indent-spec-backtracking () - "Return the indent sexp that applies to the sexp at point. -Implementation function for `clojure--find-indent-spec'." + "Return the indent spec that applies to the sexp at point. +Walk up the sexp tree (up to `clojure-max-backtracking' levels) +to find a parent form with an indent spec, then use the current +position within that parent to index into its spec. + +For a list spec like (1 ((:defn)) nil), position 0 yields 1, +position 1 yields ((:defn)), and position 2+ yields nil. A +sub-spec wrapped in a list like ((:defn)) means \"this position +holds a list of forms, each indented with :defn style\"." (when (and (>= clojure-max-backtracking clojure--current-backtracking-depth) (not (looking-at "^"))) (let ((clojure--current-backtracking-depth (1+ clojure--current-backtracking-depth)) @@ -1836,14 +1850,23 @@ the indentation. The property value can be -- `:defn', meaning indent `defn'-style; +- `:defn', meaning indent `defn'-style (body indentation); - an integer N, meaning indent the first N arguments specially - like ordinary function arguments and then indent any further - arguments like a body; + (further indented) and then indent any further arguments like + a body; - a function to call just as this function was called. If that function returns nil, that means it doesn't specify - the indentation. -- a list, which is used by `clojure-backtracking-indent'. + the indentation; +- a list, used for backtracking indentation of complex forms. + Each element controls indentation at the corresponding argument + position. Elements can be integers, `:defn', `:form', nil, or + a nested list like ((:defn)) meaning \"a list of :defn-style + forms\". See `clojure--find-indent-spec-backtracking' for + details. + +When no indent spec is found, forms starting with `def' or `with-' +get body-style indentation, and forms starting with `:' use +`clojure-indent-keyword-style'. This function also returns nil meaning don't specify the indentation." ;; Goto to the open-paren. @@ -1904,7 +1927,19 @@ This function also returns nil meaning don't specify the indentation." ;;; Setting indentation (defun put-clojure-indent (sym indent) - "Instruct `clojure-indent-function' to indent the body of SYM by INDENT." + "Set the indentation spec of SYM to INDENT. + +INDENT can be: +- `:defn' — indent like a function/macro body +- an integer N — N special args, then body +- a function — custom indentation function +- a quoted list — positional backtracking spec (see + `clojure--find-indent-spec-backtracking') + +Examples: + (put-clojure-indent \\='when 1) + (put-clojure-indent \\='>defn :defn) + (put-clojure-indent \\='letfn \\='(1 ((:defn)) nil))" (put sym 'clojure-indent-function indent)) (defun clojure--maybe-quoted-symbol-p (x) diff --git a/test/clojure-mode-indentation-test.el b/test/clojure-mode-indentation-test.el index 9537dd54..d4a49199 100644 --- a/test/clojure-mode-indentation-test.el +++ b/test/clojure-mode-indentation-test.el @@ -512,6 +512,349 @@ DESCRIPTION is a string with the description of the spec." (with-out-binding [out messages] (.flush out))))") + (when-indenting-it "should handle defmethod" + " +(defmethod foo :bar + [x] + (println x))" + + " +(defmethod foo :default + [x y] + (+ x y))") + + (when-indenting-it "should handle multi-arity defn" + " +(defn foo + ([x] + (foo x 1)) + ([x y] + (+ x y)))" + + " +(defn foo + \"docstring\" + ([x] + (foo x 1)) + ([x y] + (+ x y)))" + + " +(defn ^:private foo + ([x] + (foo x 1)) + ([x y] + (+ x y)))") + + (when-indenting-it "should handle nested letfn" + " +(letfn [(foo [x] + (let [y (inc x)] + (* y y))) + (bar [a] + (letfn [(baz [b] + (+ a b))] + (baz 1)))] + (foo (bar 10)))") + + (when-indenting-it "should handle try/catch/finally" + " +(try + (dangerous) + (catch Exception e + (println e)) + (finally + (cleanup)))") + + (when-indenting-it "should handle as->" + " +(as-> x $ + (inc $) + (* $ 2))") + + (when-indenting-it "should indent with-* forms like body even without explicit specs" + " +(with-open [f (io/reader \"x\")] + (slurp f))" + + " +(with-redefs [foo bar] + (test-stuff))" + + " +(with-custom-thing [x y] + (body x y))") + + (when-indenting-it "should handle condp" + " +(condp = x + 1 \"one\" + 2 \"two\" + \"other\")") + + (when-indenting-it "should handle namespace-qualified special forms" + " +(clojure.core/let [x 1] + (inc x))" + + " +(clojure.core/when true + (do-stuff))" + + " +(clojure.core/defn foo + [x] + (inc x))") + + (when-indenting-it "should indent unknown def forms like body" + " +(defwhatever my-thing + :some-option true + :another false)" + + " +(my.ns/defwhatever my-thing + :some-option true + :another false)") + + (when-indenting-it "should handle fn" + " +(fn [x] + (inc x))" + + " +(fn my-fn [x] + (inc x))" + + " +(fn + ([x] + (inc x)) + ([x y] + (+ x y)))") + + (when-indenting-it "should handle def" + " +(def x + (+ 1 2))" + + " +(def ^:dynamic *x* + 42)") + + (when-indenting-it "should handle bound-fn" + " +(bound-fn [x] + (inc x))") + + (when-indenting-it "should handle if" + " +(if (even? x) + (inc x) + (dec x))") + + (when-indenting-it "should handle if-not" + " +(if-not (nil? x) + (use x) + (default))") + + (when-indenting-it "should handle case" + " +(case x + :a 1 + :b 2 + 3)") + + (when-indenting-it "should handle when" + " +(when (pos? x) + (println x) + (inc x))") + + (when-indenting-it "should handle when-not" + " +(when-not (nil? x) + (println x))") + + (when-indenting-it "should handle when-first" + " +(when-first [x xs] + (println x))") + + (when-indenting-it "should handle while" + " +(while (pos? @counter) + (swap! counter dec))") + + (when-indenting-it "should handle do" + " +(do + (println 1) + (println 2))") + + (when-indenting-it "should handle delay" + " +(delay + (expensive-computation))") + + (when-indenting-it "should handle future" + " +(future + (long-running-task))") + + (when-indenting-it "should handle comment" + " +(comment + (foo 1 2) + (bar 3 4))") + + (when-indenting-it "should handle binding" + " +(binding [*out* writer] + (println \"hello\"))") + + (when-indenting-it "should handle loop" + " +(loop [i 0] + (when (< i 10) + (recur (inc i))))") + + (when-indenting-it "should handle for" + " +(for [x (range 10) + :when (even? x)] + (* x x))") + + (when-indenting-it "should handle doseq" + " +(doseq [x xs] + (println x))") + + (when-indenting-it "should handle dotimes" + " +(dotimes [i 10] + (println i))") + + (when-indenting-it "should handle when-let" + " +(when-let [x (foo)] + (bar x))") + + (when-indenting-it "should handle if-let" + " +(if-let [x (foo)] + (bar x) + (baz))") + + (when-indenting-it "should handle when-some" + " +(when-some [x (foo)] + (bar x))") + + (when-indenting-it "should handle if-some" + " +(if-some [x (foo)] + (bar x) + (baz))") + + (when-indenting-it "should handle doto" + " +(doto (java.util.HashMap.) + (.put \"a\" 1) + (.put \"b\" 2))") + + (when-indenting-it "should handle locking" + " +(locking obj + (alter-state! obj))") + + (when-indenting-it "should handle fdef" + " +(fdef my-fn + :args (s/cat :x int?) + :ret int?)") + + (when-indenting-it "should handle this-as" + " +(this-as self + (.method self))") + + ;; clojure.test + (when-indenting-it "should handle testing" + " +(testing \"some feature\" + (is (= 1 1)))") + + (when-indenting-it "should handle deftest" + " +(deftest my-test + (is (= 1 1)))") + + (when-indenting-it "should handle are" + " +(are [x y] (= x y) + 1 1 + 2 2)") + + (when-indenting-it "should handle use-fixtures" + " +(use-fixtures :each + my-fixture)") + + (when-indenting-it "should handle async" + " +(async done + (do-stuff) + (done))") + + ;; core.logic + (when-indenting-it "should handle run" + " +(run [q] + (== q 1))") + + (when-indenting-it "should handle run*" + " +(run* [q] + (== q 1))") + + (when-indenting-it "should handle fresh" + " +(fresh [a b] + (== a 1) + (== b 2))") + + ;; core.async + (when-indenting-it "should handle go" + " +(go + (! ch x) + (recur (inc x)))") + + (when-indenting-it "should handle thread" + " +(thread + (blocking-op))") + + (when-indenting-it "should handle alt!" + " +(alt! + ch1 ([v] (println v)) + ch2 ([v] (println v)))") + + (when-indenting-it "should handle alt!!" + " +(alt!! + ch1 ([v] (println v)) + ch2 ([v] (println v)))") + (when-indenting-it "should handle reader conditionals" "#?@ (:clj [] :cljs [])") @@ -871,6 +1214,124 @@ x '(put-clojure-indent 'foo "bar")) :to-throw))) +(describe "clojure-indent-keyword-style" + (it "should align keyword forms with always-align (default)" + (let ((clojure-indent-keyword-style 'always-align)) + ;; Case A: arg on same line → align with it + (with-clojure-buffer "\n(ns foo\n(:require\n[bar]))" + (indent-region (point-min) (point-max)) + (expect (buffer-string) :to-equal "\n(ns foo\n (:require\n [bar]))")) + ;; Case B: no arg on same line → align with keyword + (with-clojure-buffer "\n(ns foo\n(:require [bar]\n[baz]))" + (indent-region (point-min) (point-max)) + (expect (buffer-string) :to-equal "\n(ns foo\n (:require [bar]\n [baz]))")))) + + (it "should indent keyword forms with always-indent" + (let ((clojure-indent-keyword-style 'always-indent)) + ;; Case A: arg on same line → still indented like body + (with-clojure-buffer "\n(ns foo\n(:require [bar]\n[baz]))" + (indent-region (point-min) (point-max)) + (expect (buffer-string) :to-equal "\n(ns foo\n (:require [bar]\n [baz]))")) + ;; Case B: no arg on same line → indented like body + (with-clojure-buffer "\n(ns foo\n(:require\n[bar]))" + (indent-region (point-min) (point-max)) + (expect (buffer-string) :to-equal "\n(ns foo\n (:require\n [bar]))")))) + + (it "should indent keyword forms with align-arguments" + (let ((clojure-indent-keyword-style 'align-arguments)) + ;; Case A: arg on same line → align with first arg + (with-clojure-buffer "\n(ns foo\n(:require [bar]\n[baz]))" + (indent-region (point-min) (point-max)) + (expect (buffer-string) :to-equal "\n(ns foo\n (:require [bar]\n [baz]))")) + ;; Case B: no arg on same line → indented like body + (with-clojure-buffer "\n(ns foo\n(:require\n[bar]))" + (indent-region (point-min) (point-max)) + (expect (buffer-string) :to-equal "\n(ns foo\n (:require\n [bar]))"))))) + +(describe "clojure-use-backtracking-indent" + (it "should still indent simple specs correctly when disabled" + (let ((clojure-use-backtracking-indent nil)) + ;; Integer spec (when has spec 1) + (with-clojure-buffer "\n(when true\nbody)" + (indent-region (point-min) (point-max)) + (expect (buffer-string) :to-equal "\n(when true\n body)")) + ;; :defn spec + (with-clojure-buffer "\n(defn foo\n[x]\nx)" + (indent-region (point-min) (point-max)) + (expect (buffer-string) :to-equal "\n(defn foo\n [x]\n x)")))) + + (it "should lose context for complex specs when disabled" + (let ((clojure-use-backtracking-indent nil)) + ;; Without backtracking, the body of a letfn binding won't get + ;; :defn-style indentation because the backtracking that walks + ;; up to letfn's spec (1 ((:defn)) nil) is disabled. + (with-clojure-buffer "\n(letfn [(foo [x]\n(+ x 1))]\n(foo 1))" + (indent-region (point-min) (point-max)) + ;; The body of foo should NOT get :defn-style (2-space) indent + ;; relative to foo — instead it gets default alignment. + (let ((result (buffer-string))) + ;; Just verify it differs from the backtracking result + (expect result :not :to-equal "\n(letfn [(foo [x]\n (+ x 1))]\n (foo 1))")))))) + +(describe "clojure-max-backtracking" + (it "should limit how far up the sexp tree backtracking goes" + ;; With max-backtracking = 0, even one level of backtracking is + ;; disabled, so letfn bindings lose :defn-style indentation. + (let ((clojure-max-backtracking 0)) + (with-clojure-buffer "\n(letfn [(foo [x]\n(+ x 1))]\n(foo 1))" + (indent-region (point-min) (point-max)) + (let ((result (buffer-string))) + (expect result :not :to-equal "\n(letfn [(foo [x]\n (+ x 1))]\n (foo 1))"))))) + + (it "should indent correctly with sufficient backtracking depth" + ;; With the default depth (3), letfn works fine. + (let ((clojure-max-backtracking 3)) + (with-clojure-buffer "\n(letfn [(foo [x]\n(+ x 1))]\n(foo 1))" + (indent-region (point-min) (point-max)) + (expect (buffer-string) :to-equal "\n(letfn [(foo [x]\n (+ x 1))]\n (foo 1))"))))) + +(describe "clojure-enable-indent-specs" + (it "should use uniform indentation when disabled" + (let ((clojure-enable-indent-specs nil)) + ;; let normally gets spec 1, but with specs disabled it should + ;; indent like a regular function call. + (with-clojure-buffer "\n(let [x 1]\nx)" + (indent-region (point-min) (point-max)) + (expect (buffer-string) :to-equal "\n(let [x 1]\n x)")) + ;; when normally gets spec 1 + (with-clojure-buffer "\n(when true\nbody)" + (indent-region (point-min) (point-max)) + (expect (buffer-string) :to-equal "\n(when true\n body)")))) + + (it "should still indent def*/with-* forms like body when specs are disabled" + ;; The def*/with-* fallback in clojure-indent-function fires + ;; regardless of clojure-enable-indent-specs. + (let ((clojure-enable-indent-specs nil)) + (with-clojure-buffer "\n(defn foo\n[x]\nx)" + (indent-region (point-min) (point-max)) + (expect (buffer-string) :to-equal "\n(defn foo\n [x]\n x)"))))) + +(describe "clojure-get-indent-function" + (it "should use custom function to look up indent specs" + (let ((clojure-get-indent-function + (lambda (name) + (when (string= name "my-custom-macro") + 1)))) + ;; my-custom-macro has no built-in spec, but our custom function + ;; provides spec 1 (one special arg, then body). + (with-clojure-buffer "\n(my-custom-macro binding\nbody)" + (indent-region (point-min) (point-max)) + (expect (buffer-string) :to-equal "\n(my-custom-macro binding\n body)")))) + + (it "should fall back to built-in specs when custom function returns nil" + (let ((clojure-get-indent-function + (lambda (_name) nil))) + ;; let has a built-in spec, and the custom function returns nil, + ;; so the built-in spec should still apply. + (with-clojure-buffer "\n(let [x 1]\nx)" + (indent-region (point-min) (point-max)) + (expect (buffer-string) :to-equal "\n(let [x 1]\n x)"))))) + (provide 'clojure-mode-indentation-test) ;;; clojure-mode-indentation-test.el ends here