Repository: clojure-emacs/clojure-ts-mode
Pull request #90: Introduce cycle privacy refactoring command
Author: login: rrudakov
Created: 2025-05-04T18:39:15Z | Merged: 2025-05-04T20:33:12Z

Before submitting a PR mark the checkboxes for the items you've done (if you
think a checkbox does not apply, then leave it unchecked):

- [x] The commits are consistent with our [contribution guidelines][1].
- [x] You've added tests (if possible) to cover your change(s). Bugfix, indentation, and font-lock tests are extremely important!
- [x] You've run `M-x checkdoc` and fixed any warnings in the code you've written.
- [x] You've updated the changelog (if adding/changing user-visible functionality).
- [x] You've updated the readme (if adding/changing user-visible functionality).

Thanks!

[1]: https://github.com/clojure-emacs/clojure-ts-mode/blob/master/CONTRIBUTING.md



Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3a45d82..c8fc91b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@
 - [#88](https://github.com/clojure-emacs/clojure-ts-mode/pull/88): Introduce `clojure-ts-unwind` and `clojure-ts-unwind-all`.
 - [#89](https://github.com/clojure-emacs/clojure-ts-mode/pull/89): Introduce `clojure-ts-thread`, `clojure-ts-thread-first-all` and
   `clojure-ts-thread-last-all`.
+- [#90](https://github.com/clojure-emacs/clojure-ts-mode/pull/90): Introduce `clojure-ts-cycle-privacy`.
 
 ## 0.3.0 (2025-04-15)
 
diff --git a/README.md b/README.md
index c7b8e40..bf14a33 100644
--- a/README.md
+++ b/README.md
@@ -376,20 +376,26 @@ following customization:
 
 ### Threading macros related features
 
-`clojure-thread`: Thread another form into the surrounding thread. Both
+`clojure-ts-thread`: Thread another form into the surrounding thread. Both
 `->>`/`some->>` and `->`/`some->` variants are supported.
 
-`clojure-unwind`: Unwind a threaded expression. Supports both `->>`/`some->>`
+`clojure-ts-unwind`: Unwind a threaded expression. Supports both `->>`/`some->>`
 and `->`/`some->`.
 
-`clojure-thread-first-all`: Introduce the thread first macro (`->`) and rewrite
-the entire form. With a prefix argument do not thread the last form.
+`clojure-ts-thread-first-all`: Introduce the thread first macro (`->`) and
+rewrite the entire form. With a prefix argument do not thread the last form.
 
-`clojure-thread-last-all`: Introduce the thread last macro and rewrite the
+`clojure-ts-thread-last-all`: Introduce the thread last macro and rewrite the
 entire form. With a prefix argument do not thread the last form.
 
-`clojure-unwind-all`: Fully unwind a threaded expression removing the threading
-macro.
+`clojure-ts-unwind-all`: Fully unwind a threaded expression removing the
+threading macro.
+
+### Cycling things
+
+`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.
 
 ### Default keybindings
 
@@ -400,6 +406,7 @@ macro.
 | `C-c C-r u` / `C-c C-r C-u` | `clojure-ts-unwind`           |
 | `C-c C-r f` / `C-c C-r C-f` | `clojure-ts-thread-first-all` |
 | `C-c C-r l` / `C-c C-r C-l` | `clojure-ts-thread-last-all`  |
+| `C-c C-r p` / `C-c C-r C-p` | `clojure-ts-cycle-privacy`    |
 
 ### Customize refactoring commands prefix
 
diff --git a/clojure-ts-mode.el b/clojure-ts-mode.el
index 45dcc62..a110d2f 100644
--- a/clojure-ts-mode.el
+++ b/clojure-ts-mode.el
@@ -160,6 +160,14 @@ current sexp."
   :safe #'booleanp
   :type 'boolean)
 
+(defcustom clojure-ts-use-metadata-for-defn-privacy nil
+  "If nil, `clojure-ts-cycle-privacy' will use (defn- f []).
+
+If t, it will use (defn ^:private f [])."
+  :package-version '(clojure-ts-mode . "0.4.0")
+  :safe #'booleanp
+  :type 'boolean)
+
 (defcustom clojure-ts-align-reader-conditionals nil
   "Whether to align reader conditionals, as if they were maps."
   :package-version '(clojure-ts-mode . "0.4")
@@ -1480,6 +1488,21 @@ If JUSTIFY is non-nil, justify as well as fill the paragraph."
     "map_lit" "ns_map_lit" "vec_lit" "set_lit")
   "A regular expression that matches nodes that can be treated as lists.")
 
+(defun clojure-ts--defun-node-p (node)
+  "Return TRUE if NODE is a function or a var definition."
+  (and (clojure-ts--list-node-p node)
+       (let ((sym (clojure-ts--node-child-skip-metadata node 0)))
+         (string-match-p (rx bol
+                             (or "def"
+                                 "defn"
+                                 "defn-"
+                                 "definline"
+                                 "defrecord"
+                                 "defmacro"
+                                 "defmulti")
+                             eol)
+                         (clojure-ts--named-node-text sym)))))
+
 (defconst clojure-ts--markdown-inline-sexp-nodes
   '("inline_link" "full_reference_link" "collapsed_reference_link"
     "uri_autolink" "email_autolink" "shortcut_link" "image"
@@ -1490,7 +1513,8 @@ If JUSTIFY is non-nil, justify as well as fill the paragraph."
   `((clojure
      (sexp ,(regexp-opt clojure-ts--sexp-nodes))
      (list ,(regexp-opt clojure-ts--list-nodes))
-     (text ,(regexp-opt '("comment"))))
+     (text ,(regexp-opt '("comment")))
+     (defun ,#'clojure-ts--defun-node-p))
     (when clojure-ts-use-markdown-inline
       (markdown-inline
        (sexp ,(regexp-opt clojure-ts--markdown-inline-sexp-nodes))))))
@@ -1991,6 +2015,23 @@ value is `clojure-ts-thread-all-but-last'."
   (interactive "P")
   (clojure-ts--thread-all "->> " but-last))
 
+(defun clojure-ts-cycle-privacy ()
+  "Make a definition at point public or private."
+  (interactive)
+  (if-let* ((node-at-point (treesit-node-at (point) 'clojure t))
+            (defun-node (treesit-parent-until node-at-point 'defun t)))
+      (save-excursion
+        (goto-char (treesit-node-start defun-node))
+        (search-forward-regexp (rx "def" (* letter) (? (group (or "-" " ^:private")))))
+        (if (match-string 1)
+            (replace-match "" nil nil nil 1)
+          (goto-char (match-end 0))
+          (insert (if (or clojure-ts-use-metadata-for-defn-privacy
+                          (not (string= (match-string 0) "defn")))
+                      " ^:private"
+                    "-"))))
+    (user-error "No defun at point")))
+
 (defvar clojure-ts-refactor-map
   (let ((map (make-sparse-keymap)))
     (keymap-set map "C-t" #'clojure-ts-thread)
@@ -2001,6 +2042,8 @@ value is `clojure-ts-thread-all-but-last'."
     (keymap-set map "f" #'clojure-ts-thread-first-all)
     (keymap-set map "C-l" #'clojure-ts-thread-last-all)
     (keymap-set map "l" #'clojure-ts-thread-last-all)
+    (keymap-set map "C-p" #'clojure-ts-cycle-privacy)
+    (keymap-set map "p" #'clojure-ts-cycle-privacy)
     map)
   "Keymap for `clojure-ts-mode' refactoring commands.")
 
@@ -2012,6 +2055,7 @@ value is `clojure-ts-thread-all-but-last'."
     (easy-menu-define clojure-ts-mode-menu map "Clojure[TS] Mode Menu"
       '("Clojure"
         ["Align expression" clojure-ts-align]
+        ["Cycle privacy" clojure-ts-cycle-privacy]
         ("Refactor -> and ->>"
          ["Thread once more" clojure-ts-thread]
          ["Fully thread a form with ->" clojure-ts-thread-first-all]
diff --git a/test/clojure-ts-mode-cycling-test.el b/test/clojure-ts-mode-cycling-test.el
new file mode 100644
index 0000000..d0e8130
--- /dev/null
+++ b/test/clojure-ts-mode-cycling-test.el
@@ -0,0 +1,163 @@
+;;; clojure-ts-mode-cycling-test.el --- Clojure[TS] Mode: cycling things tests  -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2025  Roman Rudakov
+
+;; Author: Roman Rudakov <rrudakov@fastmail.com>
+
+;; This program is free software; you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; The code is adapted from `clojure-mode'.
+
+;;; Code:
+
+(require 'clojure-ts-mode)
+(require 'buttercup)
+(require 'test-helper "test/test-helper")
+
+(describe "clojure-ts-cycle-privacy"
+
+  (when-refactoring-it "should turn a public defn into a private defn"
+    "(defn add [a b]
+  (+ a b))"
+
+    "(defn- add [a b]
+  (+ a b))"
+
+    (clojure-ts-cycle-privacy))
+
+  (when-refactoring-it "should also work from the beginning of a sexp"
+     "(defn- add [a b]
+  (+ a b))"
+
+     "(defn add [a b]
+  (+ a b))"
+
+     (backward-sexp)
+     (clojure-ts-cycle-privacy))
+
+  (when-refactoring-it "should use metadata when clojure-use-metadata-for-privacy is set to true"
+    "(defn add [a b]
+  (+ a b))"
+
+    "(defn ^:private add [a b]
+  (+ a b))"
+
+    (let ((clojure-ts-use-metadata-for-defn-privacy t))
+      (clojure-ts-cycle-privacy)))
+
+  (when-refactoring-it "should turn a private defn into a public defn"
+    "(defn- add [a b]
+  (+ a b))"
+
+    "(defn add [a b]
+  (+ a b))"
+
+    (clojure-ts-cycle-privacy))
+
+  (when-refactoring-it "should turn a private defn with metadata into a public defn"
+    "(defn ^:private add [a b]
+  (+ a b))"
+
+    "(defn add [a b]
+  (+ a b))"
+
+    (let ((clojure-ts-use-metadata-for-defn-privacy t))
+      (clojure-ts-cycle-privacy)))
+
+  (when-refactoring-it "should also work with pre-existing metadata"
+    "(def ^:dynamic config
+  \"docs\"
+  {:env \"staging\"})"
+
+    "(def ^:private ^:dynamic config
+  \"docs\"
+  {:env \"staging\"})"
+
+    (clojure-ts-cycle-privacy))
+
+  (when-refactoring-it "should turn a private def with metadata into a public def"
+    "(def ^:private config
+  \"docs\"
+  {:env \"staging\"})"
+
+    "(def config
+  \"docs\"
+  {:env \"staging\"})"
+
+    (clojure-ts-cycle-privacy))
+
+  (when-refactoring-it "should turn a public defmulti into a private defmulti"
+    "(defmulti service-charge (juxt account-level :tag))"
+
+    "(defmulti ^:private service-charge (juxt account-level :tag))"
+
+    (clojure-ts-cycle-privacy))
+
+  (when-refactoring-it "should turn a private defmulti into a public defmulti"
+    "(defmulti ^:private service-charge (juxt account-level :tag))"
+
+    "(defmulti service-charge (juxt account-level :tag))"
+
+    (clojure-ts-cycle-privacy))
+
+  (when-refactoring-it "should turn a public defmacro into a private defmacro"
+    "(defmacro unless [pred a b]
+  `(if (not ~pred) ~a ~b))"
+
+    "(defmacro ^:private unless [pred a b]
+  `(if (not ~pred) ~a ~b))"
+
+    (clojure-ts-cycle-privacy))
+
+  (when-refactoring-it "should turn a private defmacro into a public defmacro"
+    "(defmacro ^:private unless [pred a b]
+  `(if (not ~pred) ~a ~b))"
+
+    "(defmacro unless [pred a b]
+  `(if (not ~pred) ~a ~b))"
+
+    (clojure-ts-cycle-privacy))
+
+  (when-refactoring-it "should turn a private definline into a public definline"
+    "(definline bad-sqr [x] `(* ~x ~x))"
+
+    "(definline ^:private bad-sqr [x] `(* ~x ~x))"
+
+    (clojure-ts-cycle-privacy))
+
+  (when-refactoring-it "should turn a public definline into a private definline"
+    "(definline ^:private bad-sqr [x] `(* ~x ~x))"
+
+    "(definline bad-sqr [x] `(* ~x ~x))"
+
+    (clojure-ts-cycle-privacy))
+
+  (when-refactoring-it "should turn a private defrecord into a public defrecord"
+    "(defrecord Person [fname lname address])"
+
+    "(defrecord ^:private Person [fname lname address])"
+
+    (clojure-ts-cycle-privacy))
+
+  (when-refactoring-it "should turn a public defrecord into a private defrecord"
+    "(defrecord ^:private Person [fname lname address])"
+
+    "(defrecord Person [fname lname address])"
+
+    (clojure-ts-cycle-privacy)))
+
+(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 e6f24b8..109243d 100644
--- a/test/samples/refactoring.clj
+++ b/test/samples/refactoring.clj
@@ -66,7 +66,19 @@
 
 (->> (map square (filter even? [1 2 3 4 5])))
 
+(-> (dissoc (assoc {} :key "value") :lock))
+
 (deftask dev []
          (comp (serve)
                (cljs (lala)
                      10)))
+
+(def my-name "Roma")
+
+(defn say-hello
+  []
+  (println "Hello" my-name))
+
+(definline bad-sqr [x] `(* ~x ~x))
+
+(defmulti service-charge (juxt account-level :tag))


Reviews:
user: login: bbatsov
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-04T19:43:06Z
user: login: bbatsov
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-04T19:44:54Z
user: login: rrudakov
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-04T19:45:22Z
user: login: bbatsov
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-04T20:21:07Z
user: login: rrudakov
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-04T20:31:08Z
user: login: bbatsov
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-04T20:33:06Z

Review comments:
diff_hunk: @@ -391,6 +391,10 @@ entire form. With a prefix argument do not thread the last form.
 `clojure-unwind-all`: Fully unwind a threaded expression removing the threading
 macro.
 
+`clojure-cycle-privacy`: Cycle privacy of `def`s or `defn`s. Use metadata
+explicitly with setting `clojure-use-metadata-for-privacy` to `t` for `defn`s
path: README.md
user: login: bbatsov
body: clojure-ts-...
created_at: 2025-05-04T19:43:06Z
author_association: MEMBER
diff_hunk: @@ -160,6 +160,14 @@ current sexp."
   :safe #'booleanp
   :type 'boolean)
 
+(defcustom clojure-ts-use-metadata-for-privacy nil
path: clojure-ts-mode.el
user: login: bbatsov
body: Probably I'd make this a bit more explicit - e.g. `...-for-defn-privacy`. (as it's the only form with the alternate syntax)
created_at: 2025-05-04T19:44:54Z
author_association: MEMBER
diff_hunk: @@ -391,6 +391,10 @@ entire form. With a prefix argument do not thread the last form.
 `clojure-unwind-all`: Fully unwind a threaded expression removing the threading
 macro.
 
+`clojure-cycle-privacy`: Cycle privacy of `def`s or `defn`s. Use metadata
+explicitly with setting `clojure-use-metadata-for-privacy` to `t` for `defn`s
path: README.md
user: login: rrudakov
body: Oh, I need to fix it for all refactoring commands in README :) Copy-paste :)
created_at: 2025-05-04T19:45:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -391,6 +391,10 @@ entire form. With a prefix argument do not thread the last form.
 `clojure-unwind-all`: Fully unwind a threaded expression removing the threading
 macro.
 
+`clojure-cycle-privacy`: Cycle privacy of `def`s or `defn`s. Use metadata
+explicitly with setting `clojure-use-metadata-for-privacy` to `t` for `defn`s
path: README.md
user: login: bbatsov
body: I figured as much - btw, you forgot to update `clojure-use-metadata-for-privacy`. 
created_at: 2025-05-04T20:21:07Z
author_association: MEMBER
diff_hunk: @@ -391,6 +391,10 @@ entire form. With a prefix argument do not thread the last form.
 `clojure-unwind-all`: Fully unwind a threaded expression removing the threading
 macro.
 
+`clojure-cycle-privacy`: Cycle privacy of `def`s or `defn`s. Use metadata
+explicitly with setting `clojure-use-metadata-for-privacy` to `t` for `defn`s
path: README.md
user: login: rrudakov
body: Fixed.
created_at: 2025-05-04T20:31:08Z
author_association: CONTRIBUTOR

PR comments:
user: login: bbatsov
created_at: 2025-05-04T19:50:45Z
body: What about other var defining forms like:

- definline
- defrecord
- defmacro
- defmulti

I think they all work with private metadata.
author_association: MEMBER
user: login: rrudakov
created_at: 2025-05-04T19:56:41Z
body: > What about other var defining forms like:
> 
> * definline
> * defrecord
> * defmacro
> * defmulti
> 
> I think they all work with private metadata.

Hmm, I didn't think about it, honestly, since `clojure-mode` supports only `def` and `defn`. Let me try to add those as well.
author_association: CONTRIBUTOR
user: login: rrudakov
created_at: 2025-05-04T20:17:58Z
body: > What about other var defining forms like:
> 
> * definline
> * defrecord
> * defmacro
> * defmulti
> 
> I think they all work with private metadata.

Done.
author_association: CONTRIBUTOR