Skip to content

Introduce more cycling refactoring commands #95

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 1 commit into from
May 15, 2025
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 @@ -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)

Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
91 changes: 91 additions & 0 deletions clojure-ts-mode.el
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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]
Expand Down
78 changes: 78 additions & 0 deletions test/clojure-ts-mode-cycling-test.el
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions test/samples/refactoring.clj
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,10 @@
^{:bla "meta"}
[arg]
body)

(if ^boolean (= 2 2)
true
false)

(when-not true
(println "Hello world"))