From 5483125adaba2c60ab12d1b3952784c909044a68 Mon Sep 17 00:00:00 2001 From: Roman Rudakov Date: Mon, 12 May 2025 20:57:28 +0200 Subject: [PATCH] Introduce more cycling refactoring commands Added: - clojure-ts-cycle-conditional - clojure-ts-cycle-not --- CHANGELOG.md | 1 + README.md | 7 +++ clojure-ts-mode.el | 91 ++++++++++++++++++++++++++++ test/clojure-ts-mode-cycling-test.el | 78 ++++++++++++++++++++++++ test/samples/refactoring.clj | 7 +++ 5 files changed, 184 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a6b385..059aa14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - [#92](https://github.com/clojure-emacs/clojure-ts-mode/pull/92): Add commands to convert between collections types. - [#93](https://github.com/clojure-emacs/clojure-ts-mode/pull/93): Introduce `clojure-ts-add-arity`. - [#94](https://github.com/clojure-emacs/clojure-ts-mode/pull/94): Add indentation rules and `clojure-ts-align` support for namespaced maps. +- Introduce `clojure-ts-cycle-conditional` and `clojure-ts-cycle-not`. ## 0.3.0 (2025-04-15) diff --git a/README.md b/README.md index 59a8fa2..36aa137 100644 --- a/README.md +++ b/README.md @@ -426,6 +426,11 @@ vice versa. - `clojure-ts-cycle-privacy`: Cycle privacy of `def`s or `defn`s. Use metadata explicitly with setting `clojure-ts-use-metadata-for-defn-privacy` to `t` for `defn`s too. +- `clojure-ts-cycle-conditional`: Change a surrounding conditional form to its + negated counterpart, or vice versa (supports `if`/`if-not` and + `when`/`when-not`). For `if`/`if-not` also transposes the else and then + branches, keeping the semantics the same as before. +- `clojure-ts-cycle-not`: Add or remove a `not` form around the current form. ### Convert collection @@ -461,6 +466,8 @@ multi-arity function or macro. Function can be defined using `defn`, `fn` or | `C-c C-r {` / `C-c C-r C-{` | `clojure-ts-convert-collection-to-map` | | `C-c C-r [` / `C-c C-r C-[` | `clojure-ts-convert-collection-to-vector` | | `C-c C-r #` / `C-c C-r C-#` | `clojure-ts-convert-collection-to-set` | +| `C-c C-r c` / `C-c C-r C-c` | `clojure-ts-cycle-conditional` | +| `C-c C-r o` / `C-c C-r C-o` | `clojure-ts-cycle-not` | | `C-c C-r a` / `C-c C-r C-a` | `clojure-ts-add-arity` | ### Customize refactoring commands prefix diff --git a/clojure-ts-mode.el b/clojure-ts-mode.el index f69082e..84aad83 100644 --- a/clojure-ts-mode.el +++ b/clojure-ts-mode.el @@ -1872,6 +1872,31 @@ functional literal node." (clojure-ts--skip-first-child threading-sexp) (not (treesit-end-of-thing 'sexp 2 'restricted))))) +(defun clojure-ts--raise-sexp () + "Raise current sexp one level higher up the tree. + +The built-in `raise-sexp' function doesn't work well with a few Clojure +nodes (function literals, expressions with metadata etc.), it loses some +parenthesis." + (when-let* ((sexp-node (treesit-thing-at (point) 'sexp)) + (beg (thread-first sexp-node + (clojure-ts--node-start-skip-metadata) + (copy-marker))) + (end (thread-first sexp-node + (treesit-node-end) + (copy-marker)))) + (when-let* ((parent (treesit-node-parent sexp-node)) + ((not (string= (treesit-node-type parent) "source"))) + (parent-beg (thread-first parent + (clojure-ts--node-start-skip-metadata) + (copy-marker))) + (parent-end (thread-first parent + (treesit-node-end) + (copy-marker)))) + (save-excursion + (delete-region parent-beg beg) + (delete-region end parent-end))))) + (defun clojure-ts--pop-out-of-threading () "Raise a sexp up a level to unwind a threading form." (let* ((threading-sexp (clojure-ts--threading-sexp-node)) @@ -2284,6 +2309,66 @@ before DELIM-OPEN." (interactive) (clojure-ts--convert-collection ?{ ?#)) +(defun clojure-ts-cycle-conditional () + "Change a surrounding conditional form to its negated counterpart, or vice versa." + (interactive) + (if-let* ((sym-regex (rx bol + (or "if" "if-not" "when" "when-not") + eol)) + (cond-node (clojure-ts--search-list-form-at-point sym-regex t)) + (cond-sym (clojure-ts--list-node-sym-text cond-node))) + (let ((beg (treesit-node-start cond-node)) + (end-marker (copy-marker (treesit-node-end cond-node))) + (new-sym (pcase cond-sym + ("if" "if-not") + ("if-not" "if") + ("when" "when-not") + ("when-not" "when")))) + (save-excursion + (goto-char (clojure-ts--node-start-skip-metadata cond-node)) + (down-list 1) + (delete-char (length cond-sym)) + (insert new-sym) + (when (member cond-sym '("if" "if-not")) + (forward-sexp 2) + (transpose-sexps 1)) + (indent-region beg end-marker))) + (user-error "No conditional expression found"))) + +(defun clojure-ts--point-outside-node-p (node) + "Return non-nil if point is outside of the actual NODE start. + +Clojure grammar treats metadata as part of an expression, so for example +^boolean (not (= 2 2)) is a single list node, including metadata. This +causes issues for functions that navigate by s-expressions and lists. +This function returns non-nil if point is outside of the outermost +parenthesis." + (let* ((actual-node-start (clojure-ts--node-start-skip-metadata node)) + (node-end (treesit-node-end node)) + (pos (point))) + (or (< pos actual-node-start) + (> pos node-end)))) + +(defun clojure-ts-cycle-not () + "Add or remove a not form around the current form." + (interactive) + (if-let* ((list-node (clojure-ts--parent-until (rx bol "list_lit" eol))) + ((not (clojure-ts--point-outside-node-p list-node)))) + (let ((beg (treesit-node-start list-node)) + (end-marker (copy-marker (treesit-node-end list-node))) + (pos (copy-marker (point) t))) + (goto-char (clojure-ts--node-start-skip-metadata list-node)) + (if-let* ((list-parent (treesit-node-parent list-node)) + ((clojure-ts--list-node-sym-match-p list-parent (rx bol "not" eol)))) + (clojure-ts--raise-sexp) + (insert-pair 1 ?\( ?\)) + (insert "not ")) + (indent-region beg end-marker) + ;; `save-excursion' doesn't work well when point is at the opening + ;; paren. + (goto-char pos)) + (user-error "Must be invoked inside a list"))) + (defvar clojure-ts-refactor-map (let ((map (make-sparse-keymap))) (keymap-set map "C-t" #'clojure-ts-thread) @@ -2306,6 +2391,10 @@ before DELIM-OPEN." (keymap-set map "[" #'clojure-ts-convert-collection-to-vector) (keymap-set map "C-#" #'clojure-ts-convert-collection-to-set) (keymap-set map "#" #'clojure-ts-convert-collection-to-set) + (keymap-set map "C-c" #'clojure-ts-cycle-conditional) + (keymap-set map "c" #'clojure-ts-cycle-conditional) + (keymap-set map "C-o" #'clojure-ts-cycle-not) + (keymap-set map "o" #'clojure-ts-cycle-not) (keymap-set map "C-a" #'clojure-ts-add-arity) (keymap-set map "a" #'clojure-ts-add-arity) map) @@ -2322,6 +2411,8 @@ before DELIM-OPEN." ["Toggle between string & keyword" clojure-ts-cycle-keyword-string] ["Align expression" clojure-ts-align] ["Cycle privacy" clojure-ts-cycle-privacy] + ["Cycle conditional" clojure-ts-cycle-conditional] + ["Cycle not" clojure-ts-cycle-not] ["Add function/macro arity" clojure-ts-add-arity] ("Convert collection" ["Convert to list" clojure-ts-convert-collection-to-list] diff --git a/test/clojure-ts-mode-cycling-test.el b/test/clojure-ts-mode-cycling-test.el index b0d83cb..81eef67 100644 --- a/test/clojure-ts-mode-cycling-test.el +++ b/test/clojure-ts-mode-cycling-test.el @@ -190,5 +190,83 @@ (clojure-ts-cycle-privacy))) +(describe "clojure-cycle-if" + + (when-refactoring-with-point-it "should cycle inner if" + "(if this + (if |that + (then AAA) + (else BBB)) + (otherwise CCC))" + + "(if this + (if-not |that + (else BBB) + (then AAA)) + (otherwise CCC))" + + (clojure-ts-cycle-conditional)) + + (when-refactoring-with-point-it "should cycle outer if" + "(if-not |this + (if that + (then AAA) + (else BBB)) + (otherwise CCC))" + + "(if |this + (otherwise CCC) + (if that + (then AAA) + (else BBB)))" + + (clojure-ts-cycle-conditional))) + +(describe "clojure-cycle-when" + + (when-refactoring-with-point-it "should cycle inner when" + "(when this + (when |that + (aaa) + (bbb)) + (ccc))" + + "(when this + (when-not |that + (aaa) + (bbb)) + (ccc))" + + (clojure-ts-cycle-conditional)) + + (when-refactoring-with-point-it "should cycle outer when" + "(when-not |this + (when that + (aaa) + (bbb)) + (ccc))" + + "(when |this + (when that + (aaa) + (bbb)) + (ccc))" + + (clojure-ts-cycle-conditional))) + +(describe "clojure-cycle-not" + + (when-refactoring-with-point-it "should add a not when missing" + "(ala bala| portokala)" + "(not (ala bala| portokala))" + + (clojure-ts-cycle-not)) + + (when-refactoring-with-point-it "should remove a not when present" + "(not (ala bala| portokala))" + "(ala bala| portokala)" + + (clojure-ts-cycle-not))) + (provide 'clojure-ts-mode-cycling-test) ;;; clojure-ts-mode-cycling-test.el ends here diff --git a/test/samples/refactoring.clj b/test/samples/refactoring.clj index c7547bf..10f12b5 100644 --- a/test/samples/refactoring.clj +++ b/test/samples/refactoring.clj @@ -134,3 +134,10 @@ ^{:bla "meta"} [arg] body) + +(if ^boolean (= 2 2) + true + false) + +(when-not true + (println "Hello world"))