branch: elpa/clojure-ts-mode commit e960a905ab9ae6c77101ca1e65dd76e59c7f4009 Author: Roman Rudakov <rruda...@fastmail.com> Commit: Bozhidar Batsov <bozhi...@batsov.dev>
[#16] Add support for automatic aligning forms --- CHANGELOG.md | 1 + README.md | 4 + clojure-ts-mode.el | 145 ++++++++++++++++-------- test/clojure-ts-mode-indentation-test.el | 188 ++++++++++++++++++++++++++++++- test/samples/align.clj | 27 ++++- 5 files changed, 318 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c11acd343..281c42581a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index 02e598a878..251effc071 100644 --- a/README.md +++ b/README.md @@ -259,6 +259,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 diff --git a/clojure-ts-mode.el b/clojure-ts-mode.el index f88f342158..f1de91d015 100644 --- a/clojure-ts-mode.el +++ b/clojure-ts-mode.el @@ -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,32 +1466,56 @@ 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. @@ -1480,7 +1523,10 @@ 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,38 +1585,29 @@ 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))) @@ -1577,8 +1615,20 @@ between BEG and END." ;; 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)) diff --git a/test/clojure-ts-mode-indentation-test.el b/test/clojure-ts-mode-indentation-test.el index 75ceb6d6df..fe181f9c63 100644 --- a/test/clojure-ts-mode-indentation-test.el +++ b/test/clojure-ts-mode-indentation-test.el @@ -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)")) diff --git a/test/samples/align.clj b/test/samples/align.clj index cf361cb23a..f70e767103 100644 --- a/test/samples/align.clj +++ b/test/samples/align.clj @@ -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)