diff --git a/CHANGELOG.md b/CHANGELOG.md index 628515ab..d4cac5ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## master (unreleased) +### New features + +* [#708](https://github.com/clojure-emacs/clojure-mode/pull/708): Add `clojure-remove-unused-keys` (`C-c C-r k`), which removes the names the top-level form at point destructures from maps but never uses, along with their `:or` defaults. + ### Changes * [#705](https://github.com/clojure-emacs/clojure-mode/pull/705): Rename the `#_` toggle commands to `clojure-toggle-discard`, `clojure-toggle-discard-surrounding-form` and `clojure-toggle-discard-defun`, matching Clojure's "discard" terminology (the old `clojure-toggle-ignore*` names remain as obsolete aliases). diff --git a/README.md b/README.md index 542c6ad6..d4a8943f 100644 --- a/README.md +++ b/README.md @@ -489,6 +489,7 @@ Here's a summary of the keybindings available in `clojure-mode`: | `C-c C-r _` | `clojure-toggle-discard-surrounding-form` | | `C-c C-r P` | `clojure-promote-fn-literal` | | `C-c C-r a` | `clojure-add-arity` | +| `C-c C-r k` | `clojure-remove-unused-keys` | | `C-c C-r (` | `clojure-convert-collection-to-list` | | `C-c C-r '` | `clojure-convert-collection-to-quoted-list` | | `C-c C-r {` | `clojure-convert-collection-to-map` | @@ -645,6 +646,30 @@ without affecting the namespace declaration. +### Remove unused destructured keys + +`clojure-remove-unused-keys`: Remove what the top-level form at point +destructures from maps but doesn't use: names in `:keys`, `:strs` and `:syms`, +`:as` names and names bound to keys, along with their `:or` defaults. That +covers arglists (`defn`, `fn`, `defmethod`, `letfn`, `reify` and the like) and +the bindings of `let`, `loop`, `for`, `doseq` and the like. A map with nothing +left becomes `_`, and one with only its `:as` name left becomes that name. + +```clojure +(defn handle [{:keys [id user] :or {user "anon"} :as req}] + (process id)) + +;; becomes + +(defn handle [{:keys [id]}] + (process id)) +``` + +A name stays when a symbol with that name appears anywhere in its scope, even +if it's a local that shadows it, and so does one starting with `_`, which marks +it unused on purpose. Positional args and vector destructuring are left alone, +as removing one would change what the others bind. + ## Related packages - [clojure-mode-extra-font-locking][] provides additional font-locking diff --git a/clojure-mode.el b/clojure-mode.el index d3fce93f..b71f87c0 100644 --- a/clojure-mode.el +++ b/clojure-mode.el @@ -334,6 +334,8 @@ containing that specific file." (define-key map (kbd "s b") #'clojure-let-backward-slurp-sexp) (define-key map (kbd "C-a") #'clojure-add-arity) (define-key map (kbd "a") #'clojure-add-arity) + (define-key map (kbd "C-k") #'clojure-remove-unused-keys) + (define-key map (kbd "k") #'clojure-remove-unused-keys) (define-key map (kbd "-") #'clojure-toggle-discard) (define-key map (kbd "C--") #'clojure-toggle-discard) (define-key map (kbd "_") #'clojure-toggle-discard-surrounding-form) @@ -363,6 +365,7 @@ containing that specific file." ["Toggle #_ discard form" clojure-toggle-discard] ["Toggle #_ discard of surrounding form" clojure-toggle-discard-surrounding-form] ["Add function arity" clojure-add-arity] + ["Remove unused destructured keys" clojure-remove-unused-keys] ["Promote #() fn literal" clojure-promote-fn-literal] ("ns forms" ["Insert ns form at the top" clojure-insert-ns-form] @@ -3643,6 +3646,525 @@ With universal argument \\[universal-argument], act on the \"top-level\" form." 'clojure-toggle-discard-defun "5.24.0") +;;; Removing unused destructured keys + +(cl-defstruct (clojure--form (:constructor clojure--make-form) + (:copier nil)) + "A form `clojure--read-form' read. +TYPE is `symbol', `keyword', `list', `vector', `map', `ns-map', `quote', +`syntax-quote', `discard' (a form after #_), `splice' (a splicing reader +conditional), `other' (anything else with forms in it), `atom' or +`comment' (in a collection). META-START is where the metadata in front +of it starts." + type start end children name namespace meta-start) + +(defun clojure--skip-whitespace-and-comments () + "Move past the whitespace and the comments after point." + (while (progn (skip-chars-forward " \t\n\r\f,") + (when (eq (char-after) ?\;) + (forward-line 1) + t)))) + +(defun clojure--read-forms (end) + "Read the forms after point, up to a closing bracket or END. +The comments among them count, as `comment' forms." + (let (forms form) + (while (setq form (if (progn (skip-chars-forward " \t\n\r\f," end) + (and (< (point) end) (eq (char-after) ?\;))) + (let ((start (point))) + (forward-line 1) + (clojure--make-form :type 'comment :start start :end (point))) + (clojure--read-form end))) + (push form forms)) + (nreverse forms))) + +(defun clojure--read-coll (type start end &optional prefix-length) + "Read the collection of TYPE at START, after its PREFIX-LENGTH characters. +Don't go past END." + (goto-char (+ start (or prefix-length 0))) + (skip-chars-forward "^([{" end) + (forward-char) + (let ((children (clojure--read-forms end))) + (when (< (point) end) + (forward-char)) + (clojure--make-form :type type :start start :end (point) :children children))) + +(defun clojure--read-form (end) + "Read the form after point, move past it and return it. +Return nil at a closing bracket or at END. Discarded forms count, as +`discard' forms, but comments and metadata don't." + (clojure--skip-whitespace-and-comments) + (let ((start (point))) + (when (and (< start end) (not (memq (char-after) '(?\) ?\] ?\})))) + (cond + ((looking-at-p "#_") + (forward-char 2) + (clojure--make-form :type 'discard :start start + :children (delq nil (list (clojure--read-form end))) + :end (point))) + ((looking-at "#?\\^") + (goto-char (match-end 0)) + (clojure--read-form end) + (let ((form (clojure--read-form end))) + ;; Like comments, discarded forms between the metadata and its + ;; form go with the form. + (while (and form (eq (clojure--form-type form) 'discard)) + (setq form (clojure--read-form end))) + (when form + (setf (clojure--form-meta-start form) start)) + form)) + ((looking-at "'\\|\\(`\\)\\|~@?\\|@\\|#'\\|#=") + (let ((type (cond ((match-beginning 1) 'syntax-quote) + ((eq (char-after) ?') 'quote) + (t 'other)))) + (goto-char (match-end 0)) + (clojure--make-form :type type :start start + :children (delq nil (list (clojure--read-form end))) + :end (point)))) + ((looking-at-p "[([{]") + (clojure--read-coll (pcase (char-after) (?\( 'list) (?\[ 'vector) (_ 'map)) + start end)) + ((looking-at-p "#:") + (clojure--read-coll 'ns-map start end 2)) + ((looking-at-p "#\\?@") + (clojure--read-coll 'splice start end 1)) + ((looking-at-p "#[({?]") + (clojure--read-coll 'other start end 1)) + ((looking-at-p "#?\"") + (skip-chars-forward "#") + (forward-sexp) + (clojure--make-form :type 'atom :start start :end (point))) + ((looking-at "#[^#]") + ;; A tagged literal + (forward-char) + (clojure--read-form end) + (clojure--make-form :type 'other :start start + :children (delq nil (list (clojure--read-form end))) + :end (point))) + ((eq (char-after) ?\\) + (forward-char 2) + (when (memq (char-syntax (char-before)) '(?w ?_)) + (skip-chars-forward "a-zA-Z0-9")) + (clojure--make-form :type 'atom :start start :end (point))) + (t + (skip-chars-forward "^ \t\n\r\f,\";@^`~()[]{}\\\\" end) + (when (= (point) start) + (forward-char)) + (let ((token (buffer-substring-no-properties start (point)))) + (cond + ((string-match-p "\\`[+-]?[0-9]" token) + (clojure--make-form :type 'atom :start start :end (point))) + (t + (let* ((keyword (string-match "\\`::?" token)) + (token (if keyword (substring token (match-end 0)) token)) + (qualified (string-match "\\`\\([^/]+\\)/\\(.+\\)\\'" token))) + (clojure--make-form :type (if keyword 'keyword 'symbol) + :start start :end (point) + :name (if qualified (match-string 2 token) token) + :namespace (and qualified (match-string 1 token)))))))))))) + +(defun clojure--form-values (form) + "Return the forms in FORM, leaving out comments and discarded forms." + (seq-remove (lambda (child) (memq (clojure--form-type child) '(discard comment))) + (clojure--form-children form))) + +(defun clojure--form-pairs (form) + "Return the forms in FORM in pairs, or nil if it splices any in. +A splicing reader conditional would throw the pairs off." + (let ((children (clojure--form-values form)) + pairs) + (unless (seq-some (lambda (child) (eq (clojure--form-type child) 'splice)) + children) + (while children + (push (list (pop children) (pop children)) pairs)) + (nreverse pairs)))) + +(defun clojure--form-start-with-metadata (form) + "Return the start of FORM, including the metadata in front of it." + (or (clojure--form-meta-start form) (clojure--form-start form))) + +(defun clojure--form-descendants (form type &optional skip-quoted) + "Return the forms of TYPE in FORM, FORM included. +With SKIP-QUOTED, leave out the ones in quoted and syntax-quoted forms." + (unless (and skip-quoted (memq (clojure--form-type form) '(quote syntax-quote))) + (let ((found (mapcan (lambda (child) + (clojure--form-descendants child type skip-quoted)) + (clojure--form-children form)))) + (if (eq (clojure--form-type form) type) + (cons form found) + found)))) + +(defun clojure--core-form-name (form) + "Return the name of the clojure.core form FORM is, or nil." + (when-let* (((eq (clojure--form-type form) 'list)) + (head (car (clojure--form-values form))) + ((eq (clojure--form-type head) 'symbol)) + ((member (clojure--form-namespace head) + '(nil "clojure.core" "cljs.core")))) + (clojure--form-name head))) + +(defun clojure--parse-pattern (form) + "Return the destructuring pattern FORM as a list, or nil. +That's (sym FORM), (vec FORM PATTERN...) or (map FORM UNIT...), each +UNIT being a key of the map and its value: + + (keys KEY VEC ((NAME . FORM)...)) for :keys, :strs and :syms, NAME + being nil for what doesn't bind a name + (as KEY SYM NAME) + (or KEY MAP ((NAME KEY DEFAULT)...)) + (pair KEY VAL NAME) for a symbol bound to the value of a key + (nested KEY VAL PATTERN) for a map or a vector bound to it + (other KEY VAL) for anything else." + (pcase (clojure--form-type form) + ('symbol (list 'sym form)) + ('vector + (cons 'vec (cons form (delq nil (mapcar #'clojure--parse-pattern + (clojure--form-values form)))))) + ((and (or 'map 'ns-map) + (let pairs (clojure--form-pairs form)) + (guard pairs)) + (cons 'map + (cons form + (mapcar + (pcase-lambda (`(,key ,val)) + (let* ((type (clojure--form-type key)) + (name (and (eq type 'keyword) + (not (clojure--form-namespace key)) + (clojure--form-name key)))) + (cond + ((null val) (list 'other key val)) + ((and (eq type 'keyword) + (member (clojure--form-name key) '("keys" "strs" "syms")) + (eq (clojure--form-type val) 'vector)) + (list 'keys key val + (mapcar (lambda (elem) + (cons (and (memq (clojure--form-type elem) + '(symbol keyword)) + (clojure--form-name elem)) + elem)) + (clojure--form-values val)))) + ((and (equal name "as") (eq (clojure--form-type val) 'symbol)) + (list 'as key val (clojure--form-name val))) + ((and (equal name "or") (eq (clojure--form-type val) 'map)) + (list 'or key val + (delq nil + (mapcar (pcase-lambda (`(,entry-key ,default)) + (and default + (eq (clojure--form-type entry-key) 'symbol) + (list (clojure--form-name entry-key) + entry-key default))) + (clojure--form-pairs val))))) + ((eq type 'symbol) + (list 'pair key val (clojure--form-name key))) + ((memq type '(map ns-map vector)) + (list 'nested key val (clojure--parse-pattern key))) + (t (list 'other key val))))) + pairs)))))) + +(defun clojure--pattern-declarations (pattern) + "Return the forms of the names PATTERN binds, and of its :or keys." + (pcase pattern + (`(sym ,form) (list form)) + (`(vec ,_ . ,patterns) (mapcan #'clojure--pattern-declarations patterns)) + (`(map ,_ . ,units) + (mapcan (lambda (unit) + (pcase unit + (`(keys ,_ ,_ ,elems) (mapcar #'cdr (seq-filter #'car elems))) + (`(as ,_ ,sym ,_) (list sym)) + (`(or ,_ ,_ ,entries) (mapcar #'cadr entries)) + (`(pair ,key ,_ ,_) (list key)) + (`(nested ,_ ,_ ,sub) (clojure--pattern-declarations sub)))) + units)))) + +(defun clojure--let-binding-forms (bindings) + "Return the binding forms of the BINDINGS vector of a let-like form. +The bindings of a :let in a for or doseq count too." + (mapcan (pcase-lambda (`(,form ,init)) + (if (eq (clojure--form-type form) 'keyword) + (and (equal (clojure--form-name form) "let") + (not (clojure--form-namespace form)) + init + (eq (clojure--form-type init) 'vector) + (clojure--let-binding-forms init)) + (list form))) + (clojure--form-pairs bindings))) + +(defun clojure--fn-tail-sites (children end) + "Return the binding sites of the arities of a fn. +CHILDREN are the forms of the fn that come after its name, and END is +the end of the fn." + (when-let* ((tail (seq-drop-while (lambda (child) + (not (memq (clojure--form-type child) '(vector list)))) + children))) + (if (eq (clojure--form-type (car tail)) 'vector) + (list (list (car tail) (clojure--parse-pattern (car tail)) end)) + (delq nil + (mapcar (lambda (arity) + (when-let* (((eq (clojure--form-type arity) 'list)) + (arglist (car (clojure--form-values arity))) + ((eq (clojure--form-type arglist) 'vector))) + (list arglist (clojure--parse-pattern arglist) + (clojure--form-end arity)))) + tail))))) + +(defun clojure--binding-sites (top-level) + "Return the places in the TOP-LEVEL form that bind names. +Each is a list (VEC PATTERN END) of an arglist or the bindings of a +let-like form, the vector pattern of what it binds, and the end of the +form, the names being in scope from VEC to END. Quoted and +syntax-quoted forms don't count, as they're data or a macro's template." + (let ((methods (make-hash-table)) + sites) + (dolist (form (clojure--form-descendants top-level 'list t)) + (when-let* (((not (gethash (clojure--form-start form) methods))) + (name (clojure--core-form-name form)) + (args (cdr (clojure--form-values form))) + (end (clojure--form-end form))) + (pcase name + ((or "let" "loop" "if-let" "when-let" "if-some" "when-some" "when-first" + "for" "doseq") + (when (eq (clojure--form-type (car args)) 'vector) + (push (list (car args) + `(vec ,(car args) + ,@(delq nil (mapcar #'clojure--parse-pattern + (clojure--let-binding-forms (car args))))) + end) + sites))) + ((or "letfn" "reify" "proxy" "deftype" "defrecord" "extend-type" + "extend-protocol" "defprotocol" "definterface") + ;; A method named like a core form, as in (letfn [(for [x] x)]), + ;; isn't one. + (dolist (method (if (equal name "letfn") + (and (eq (clojure--form-type (car args)) 'vector) + (clojure--form-values (car args))) + args)) + (when-let* (((eq (clojure--form-type method) 'list)) + (children (clojure--form-values method)) + ((eq (clojure--form-type (car children)) 'symbol))) + (puthash (clojure--form-start method) t methods) + ;; Protocol and interface methods have no bodies. + (unless (member name '("defprotocol" "definterface")) + (setq sites (append (clojure--fn-tail-sites + (cdr children) (clojure--form-end method)) + sites)))))) + ;; The dispatch value can be a vector too. + ("defmethod" + (setq sites (append (clojure--fn-tail-sites (nthcdr 2 args) end) sites))) + ((or "fn" "bound-fn" "defn" "defn-" "defmacro") + (setq sites (append (clojure--fn-tail-sites args end) sites)))))) + sites)) + +(defun clojure--symbol-names (top-level declared beg end) + "Return the names the symbols in the TOP-LEVEL form use from BEG to END. +DECLARED is a hash table of the positions of the symbols that bind a +name rather than use one." + (mapcan (lambda (sym) + (let ((name (clojure--form-name sym)) + (start (clojure--form-start sym))) + (unless (or (< start beg) + (>= start end) + (gethash start declared) + (clojure--form-namespace sym)) + ;; ClojureScript reads obj.prop as the prop of the local obj. + (if (string-match "\\`\\([^.]+\\)\\." name) + (list name (match-string 1 name)) + (list name))))) + (clojure--form-descendants top-level 'symbol))) + +(defun clojure--unused-pattern-edits (pattern used-p) + "Return how to remove what PATTERN binds from maps but doesn't use. +USED-P tells if a name is used. The value is a list of (remove COLL +FORM...) and (replace FORM TEXT) edits, or `empty' for a map with +nothing left, which it's for the caller to replace or remove. Comments +and discarded forms count as something left, but an empty :keys and an +:or with nothing but defaults don't, as they bind nothing." + (pcase pattern + (`(vec ,_ . ,subs) + (mapcan (lambda (sub) + (let ((edits (clojure--unused-pattern-edits sub used-p))) + (if (eq edits 'empty) `((replace ,(cadr sub) "_")) edits))) + subs)) + (`(map ,map . ,units) + (let ((droppable 0) removed edits) + (dolist (unit units) + (pcase unit + (`(keys ,key ,vec ,elems) + (let ((unused (seq-filter (lambda (elem) + (and (car elem) (not (funcall used-p (car elem))))) + elems)) + (children (clojure--form-children vec))) + (cond + ((null children) (setq droppable (+ droppable 2))) + ((null unused)) + ((= (length unused) (length children)) + (setq removed `(,key ,vec ,@removed))) + (t (push `(remove ,vec ,@(mapcar #'cdr unused)) edits))))) + ((or `(as ,key ,val ,name) `(pair ,key ,val ,name)) + (unless (funcall used-p name) + (setq removed `(,key ,val ,@removed)))) + (`(nested ,key ,val ,sub) + (let ((sub-edits (clojure--unused-pattern-edits sub used-p))) + (if (eq sub-edits 'empty) + (setq removed `(,key ,val ,@removed)) + (setq edits (append sub-edits edits))))) + ;; The :or defaults of the names removed go with them. + (`(or ,key ,val ,entries) + (let ((unused (seq-remove (lambda (entry) (funcall used-p (car entry))) + entries)) + (children (clojure--form-children val))) + (if (and unused (= (length children) (* 2 (length unused)))) + (setq removed `(,key ,val ,@removed)) + (when unused + (push `(remove ,val ,@(seq-mapcat #'cdr unused)) edits)) + (when (= (length children) (length (clojure--form-values val))) + (setq droppable (+ droppable 2)))))))) + (cond + ((and removed + (= (length (clojure--form-children map)) (+ (length removed) droppable))) + 'empty) + (removed (cons `(remove ,map ,@removed) edits)) + (t edits)))))) + +(defun clojure--removal-regions (coll forms) + "Return the regions to delete to remove FORMS from the collection COLL. +The whitespace between them and the elements left goes too, but the +comments and discarded forms among them stay, and the closing bracket +never ends up on the line of a comment." + (let (regions run) + (dolist (child (append (clojure--form-children coll) (list nil))) + (if (and child (memq child forms)) + (push child run) + (when run + (let ((beg (clojure--form-start-with-metadata (car (last run)))) + (end (clojure--form-end (car run)))) + (save-excursion + (if child + (progn (goto-char end) + (skip-chars-forward " \t\n,") + (push (cons beg (point)) regions)) + (goto-char beg) + (skip-chars-backward " \t\n,") + (when (nth 4 (syntax-ppss)) + (forward-line 1)) + (push (cons (point) end) regions)))) + (setq run nil)))) + regions)) + +(defun clojure--remove-unused-keys-once (top-level) + "Remove what the TOP-LEVEL form destructures from maps but doesn't use. +Return the names removed." + (let ((sites (clojure--binding-sites top-level)) + (declared (make-hash-table)) + changes names vecs) + (pcase-dolist (`(,_ ,pattern ,_) sites) + (dolist (form (clojure--pattern-declarations pattern)) + (puthash (clojure--form-start form) t declared))) + (pcase-dolist (`(,vec ,pattern ,end) sites) + (let* ((in-scope (clojure--symbol-names top-level declared + (clojure--form-start vec) end)) + (edits (clojure--unused-pattern-edits + pattern (lambda (name) + ;; A leading _ marks a name unused on purpose. + (or (string-prefix-p "_" name) + (member name in-scope) + (ignore (push name names))))))) + (when edits + (push (cons (copy-marker (clojure--form-start vec)) + (copy-marker (clojure--form-end vec))) + vecs)) + (pcase-dolist (`(,kind ,form . ,args) edits) + (if (eq kind 'replace) + (push (list (clojure--form-start form) (clojure--form-end form) (car args)) + changes) + (pcase-dolist (`(,beg . ,end) (clojure--removal-regions form args)) + (push (list beg end "") changes)))))) + ;; A change inside another one, like in an :or default that goes, + ;; goes with it. + (let (kept) + (dolist (change (sort changes (lambda (a b) + (if (= (car a) (car b)) + (> (cadr a) (cadr b)) + (< (car a) (car b)))))) + (cond + ((or (null kept) (>= (car change) (cadr (car kept)))) + (push change kept)) + ((> (cadr change) (cadr (car kept))) + (error "Overlapping edits at %d and %d" (caar kept) (car change))))) + (setq changes kept)) + ;; From the end, so that the positions of the changes still to make + ;; don't move. + (pcase-dolist (`(,beg ,end ,text) changes) + (goto-char beg) + (delete-region beg end) + (insert text)) + (pcase-dolist (`(,beg . ,end) vecs) + ;; The line the vector starts on is the form's, which wasn't edited. + (goto-char beg) + (forward-line 1) + (when (< (point) end) + (indent-region (point) (marker-position end))) + (set-marker beg nil) + (set-marker end nil)) + (nreverse names))) + +(defun clojure--top-level-form-start () + "Return the start of the top-level form at point, or nil. +Point can be right after it too, or on the metadata in front of it, +which is then where it starts." + (save-excursion + (let ((ppss (syntax-ppss))) + (when (cond + ((nth 9 ppss) (goto-char (car (nth 9 ppss)))) + ((nth 3 ppss) (goto-char (nth 8 ppss))) + ((nth 4 ppss) nil) + ((looking-at-p "[([{\"]")) + ((memq (char-before) '(?\) ?\] ?\} ?\")) + (goto-char (or (ignore-errors (scan-sexps (point) -1)) + (user-error "The form at point doesn't parse")))) + ;; The ^ of metadata, or the keyword or the symbol after it + (t (skip-syntax-backward "w_") + (looking-at-p "\\s'\\|\\sw\\|\\s_"))) + (backward-prefix-chars) + (point))))) + +(defun clojure-remove-unused-keys () + "Remove what the top-level form at point destructures from maps but doesn't use. +That's the names of :keys, :strs and :syms, :as names and names bound +to keys, in the arglists of fn, defn, defmethod, letfn, reify and the +like, and in the bindings of let, loop, for, doseq and the like. The +:or defaults of the names removed go too, and a map with nothing left +becomes _. + +A name stays when a symbol with that name appears anywhere in its scope, +even one that shadows it, and so does one starting with _, which marks +it unused on purpose. A macro that uses a local without its name +appearing in the code, through &env, isn't something this can tell." + (interactive) + (let ((start (copy-marker (or (clojure--top-level-form-start) + (user-error "No form at point")))) + (tick nil) + names) + (unless (save-excursion + (goto-char start) + (when-let* ((form (ignore-errors (clojure--read-form (point-max)))) + (state (parse-partial-sexp start (clojure--form-end form)))) + (and (zerop (car state)) (not (nth 3 state))))) + (user-error "The form at point doesn't parse")) + (save-excursion + ;; Removing a name can leave another one unused, like one that only + ;; the :or default of the first used. + (while (not (eql tick (buffer-chars-modified-tick))) + (setq tick (buffer-chars-modified-tick)) + (setq names (append names + (clojure--remove-unused-keys-once + (progn (goto-char start) + (clojure--read-form (point-max)))))))) + (set-marker start nil) + (if names + (message "Removed %s" (string-join (delete-dups names) ", ")) + (message "Nothing unused to remove")))) + + ;;; ClojureScript (defconst clojurescript-font-lock-keywords (eval-when-compile diff --git a/test/clojure-mode-refactor-remove-unused-keys-test.el b/test/clojure-mode-refactor-remove-unused-keys-test.el new file mode 100644 index 00000000..f73b587a --- /dev/null +++ b/test/clojure-mode-refactor-remove-unused-keys-test.el @@ -0,0 +1,320 @@ +;;; clojure-mode-refactor-remove-unused-keys-test.el --- Clojure Mode: remove unused keys test -*- lexical-binding: t; -*- + +;; Copyright © 2026 Bozhidar Batsov + +;; This file is not part of GNU Emacs. + +;; 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 . + +;;; Commentary: + +;; Tests for `clojure-remove-unused-keys'. + +;;; Code: + +(require 'clojure-mode) +(require 'buttercup) +(require 'test-helper "test/utils/test-helper") + +(describe "clojure-remove-unused-keys" + (before-each + (spy-on 'message)) + + (when-refactoring-it "removes the unused names of :keys, :strs and :syms" + "(defn f [{:keys [a b] :strs [c d] :syms [e g]}] (+ b d g))" + "(defn f [{:keys [b] :strs [d] :syms [g]}] (+ b d g))" + (clojure-remove-unused-keys)) + + (when-refactoring-it "goes by the name of a qualified symbol or a keyword" + "(defn f [{:keys [x/a x/b :c :d] :foo/keys [e g]} #:bar{:keys [h i]}] (+ b d g i))" + "(defn f [{:keys [x/b :d] :foo/keys [g]} #:bar{:keys [i]}] (+ b d g i))" + (clojure-remove-unused-keys)) + + (when-refactoring-it "removes a name bound to a key, and an :as name" + "(defn f [{x :x y :y :keys [a] :as m}] (+ x a))" + "(defn f [{x :x :keys [a]}] (+ x a))" + (clojure-remove-unused-keys)) + + (when-refactoring-it "removes the :or defaults of the names it removes" + "(defn f [{:keys [a b c] :or {a 1 b 2}}] (+ b c))" + "(defn f [{:keys [b c] :or {b 2}}] (+ b c))" + (clojure-remove-unused-keys)) + + (when-refactoring-it "turns a map with nothing used into _, but keeps a used :as" + "(defn f [{:keys [a b] :or {a 1}} {:keys [c] :as m} & {:keys [d] :as opts}] [m opts])" + "(defn f [_ {:as m} & {:as opts}] [m opts])" + (clojure-remove-unused-keys)) + + (when-refactoring-it "drops what binds nothing along with the rest of a map" + "(defn f [{:keys [] :as m} {:keys [a] :or {}} {:keys [b] :or {b 1 c 2}}] c)" + "(defn f [_ _ _] c)" + (clojure-remove-unused-keys)) + + (when-refactoring-it "leaves alone what binds nothing to begin with" + "(defn f [{} {:as m}] m)" + "(defn f [{} {:as m}] m)" + (clojure-remove-unused-keys)) + + (when-refactoring-it "removes a name another one's :or default used once that one goes" + "(defn f [{:keys [a b] :or {b a}} {:keys [c d] :or {d c}}] d)" + "(defn f [_ {:keys [c d] :or {d c}}] d)" + (clojure-remove-unused-keys)) + + (when-refactoring-it "removes a binding form in an :or default along with it" + "(defn f [{:keys [a b] :or {a (fn [{:keys [x y]}] y)}}] b)" + "(defn f [{:keys [b]}] b)" + (clojure-remove-unused-keys)) + + (when-refactoring-it "handles nested maps" + "(defn f [{{:keys [a b]} :x {:keys [c]} :y {:keys [d] :as z} :z}] (+ b z))" + "(defn f [{{:keys [b]} :x {:as z} :z}] (+ b z))" + (clojure-remove-unused-keys)) + + (when-refactoring-it "handles the maps in vectors, but not the rest of the vectors" + "(defn f [[a {:keys [b c]}] & {:keys [d e]}] (+ c e))" + "(defn f [[a {:keys [c]}] & {:keys [e]}] (+ c e))" + (clojure-remove-unused-keys)) + + (when-refactoring-it "handles each arity on its own" + "(defn f + ([{:keys [a b]}] b) + ([{:keys [a b]} c] (+ a c)))" + "(defn f + ([{:keys [b]}] b) + ([{:keys [a]} c] (+ a c)))" + (clojure-remove-unused-keys)) + + (when-refactoring-it "handles the bindings of let-like forms" + "(let [{:keys [a b]} (g) + {:keys [c]} (h)] + (for [x b + :let [{:keys [d e]} x] + :when e] + (loop [{:keys [f g]} e] + (recur g))))" + "(let [{:keys [b]} (g) + _ (h)] + (for [x b + :let [{:keys [e]} x] + :when e] + (loop [{:keys [g]} e] + (recur g))))" + (clojure-remove-unused-keys)) + + (when-refactoring-it "handles if-let, when-first and the like" + "(if-let [{:keys [a b]} (g)] b (when-first [{:keys [c d]} xs] d))" + "(if-let [{:keys [b]} (g)] b (when-first [{:keys [d]} xs] d))" + (clojure-remove-unused-keys)) + + (when-refactoring-it "handles defmethod, whose dispatch value can be a vector" + "(defmethod f [:a :b] [{:keys [a b]}] b)" + "(defmethod f [:a :b] [{:keys [b]}] b)" + (clojure-remove-unused-keys)) + + (when-refactoring-it "handles the methods of reify, defrecord and the like, and letfn" + "(defrecord R [x] + P + (m [this {:keys [a b]}] (+ x b)) + (n [this {:keys [c]}] + (letfn [(g [{:keys [d e]}] e)] + (reify Q (o [_ {:keys [h i]}] (g i))))))" + "(defrecord R [x] + P + (m [this {:keys [b]}] (+ x b)) + (n [this _] + (letfn [(g [{:keys [e]}] e)] + (reify Q (o [_ {:keys [i]}] (g i))))))" + (clojure-remove-unused-keys)) + + (when-refactoring-it "doesn't take a method named like a core form for one" + "(letfn [(loop [{:keys [a b]}] b) + (for [{:keys [c d] :as m}] d)] + (reify P (fn [this {:keys [e g]}] (loop (for g)))))" + "(letfn [(loop [{:keys [b]}] b) + (for [{:keys [d]}] d)] + (reify P (fn [this {:keys [g]}] (loop (for g)))))" + (clojure-remove-unused-keys)) + + (when-refactoring-it "leaves alone protocol signatures, quoted forms and macro templates" + "(defmacro m [{:keys [a b]}] + '(let [{:keys [c]} x] 1) + `(let [{:keys [d]} x] ~b)) +(defprotocol P (n [this {:keys [e]}]) (fn [this {:keys [f]}]))" + "(defmacro m [{:keys [b]}] + '(let [{:keys [c]} x] 1) + `(let [{:keys [d]} x] ~b)) +(defprotocol P (n [this {:keys [e]}]) (fn [this {:keys [f]}]))" + (goto-char (point-min)) + (clojure-remove-unused-keys) + (goto-char (point-max)) + (clojure-remove-unused-keys)) + + (when-refactoring-it "keeps a name that appears anywhere in its scope" + "(defn f [{:keys [a b c d]}] + {:pre [(pos? a)]} + (let [b 1] (+ b (.-x c.y))) + #(d %))" + "(defn f [{:keys [a b c d]}] + {:pre [(pos? a)]} + (let [b 1] (+ b (.-x c.y))) + #(d %))" + (clojure-remove-unused-keys)) + + (when-refactoring-it "leaves alone a name marked unused with a leading _" + "(defn f [{:keys [a _b] :as _m} {_c :c}] a)" + "(defn f [{:keys [a _b] :as _m} {_c :c}] a)" + (clojure-remove-unused-keys)) + + (when-refactoring-it "doesn't count a name in a keyword, a string, a comment or a qualified symbol" + "(defn f [{:keys [a b c d]}] + ;; a + [:b \"c\" x/d])" + "(defn f [_] + ;; a + [:b \"c\" x/d])" + (clojure-remove-unused-keys)) + + (when-refactoring-it "keeps discarded forms and reader conditionals" + "(defn f [{:keys [a #_b #?(:clj c)]}] 1)" + "(defn f [{:keys [#_b #?(:clj c)]}] 1)" + (clojure-remove-unused-keys)) + + (when-refactoring-it "keeps the discarded forms and comments among the names it removes" + "(defn f [{:keys [a #_x c d]} {:keys [e + ;; TODO + g h]}] + (+ d h))" + "(defn f [{:keys [#_x d]} {:keys [;; TODO + h]}] + (+ d h))" + (clojure-remove-unused-keys)) + + (when-refactoring-it "doesn't take what's left with a discarded form or a comment for empty" + "(defn f [{:keys [a #_b]} + {:keys [c] :or {c 1 #_d}} + {:keys [e] ; e is next + }] + 1)" + "(defn f [{:keys [#_b]} + {:or {#_d}} + {; e is next + }] + 1)" + (clojure-remove-unused-keys)) + + (when-refactoring-it "leaves alone bindings it can't pair up, because of #?@" + "(defn f [{:keys [a] #?@(:clj [:as m])}] + (let [#?@(:clj [x 1]) {:keys [b]} {:b 2}] 1))" + "(defn f [{:keys [a] #?@(:clj [:as m])}] + (let [#?@(:clj [x 1]) {:keys [b]} {:b 2}] 1))" + (clojure-remove-unused-keys)) + + (when-refactoring-it "removes the metadata of a name with it" + "(defn f [{:keys [^String a ^long b]}] b)" + "(defn f [{:keys [^long b]}] b)" + (clojure-remove-unused-keys)) + + (when-refactoring-it "removes the comments and discarded forms after a name's metadata with it" + "(defn f [{:keys [x a ^long ;; c + b]} + {:keys [y c ^long #_z d]} + {:keys [e ^long #_z g h]}] + (+ x y h))" + "(defn f [{:keys [x]} + {:keys [y]} + {:keys [h]}] + (+ x y h))" + (clojure-remove-unused-keys)) + + (when-refactoring-it "keeps the layout, and comments" + "(defn f [{:keys [a + b] ; the bs + :as m + :or {a 1 + b 2}}] + b)" + "(defn f [{:keys [b] ; the bs + :or {b 2}}] + b)" + (clojure-remove-unused-keys)) + + (when-refactoring-it "doesn't put a closing bracket in a comment" + "(defn f [{:keys [a ; the as + b]}] + a)" + "(defn f [{:keys [a ; the as + ]}] + a)" + (clojure-remove-unused-keys)) + + (when-refactoring-it "doesn't reindent the line the bindings start on" + "(defn f [x] + (foo x) + (let [{:keys [a b]} x] b))" + "(defn f [x] + (foo x) + (let [{:keys [b]} x] b))" + (clojure-remove-unused-keys)) + + (when-refactoring-with-point-it "works on the top-level form at point, and keeps point" + "(defn f [{:keys [a b]}] b) +(defn g [{:keys [c d]}] (+ |d 1))" + "(defn f [{:keys [a b]}] b) +(defn g [{:keys [d]}] (+ |d 1))" + (clojure-remove-unused-keys)) + + (when-refactoring-with-point-it "works with point at the start of a form after a comment" + "(defn f [{:keys [a b]}] b) +;; g +|(defn g [{:keys [c d]}] d)" + "(defn f [{:keys [a b]}] b) +;; g +|(defn g [{:keys [d]}] d)" + (clojure-remove-unused-keys)) + + (when-refactoring-with-point-it "works with point on the metadata of the top-level form" + "|^:private ^{:a 1} #_x +;; why +(defn f [{:keys [a b]}] b)" + "|^:private ^{:a 1} #_x +;; why +(defn f [{:keys [b]}] b)" + (clojure-remove-unused-keys)) + + (it "says what it removed" + (with-clojure-buffer "(defn f [{:keys [a b] :as m}] 1)" + (clojure-remove-unused-keys) + (expect 'message :to-have-been-called-with "Removed %s" "a, b, m") + (clojure-remove-unused-keys) + (expect 'message :to-have-been-called-with "Nothing unused to remove"))) + + (it "refuses a form that doesn't parse" + (dolist (text '("(defn f [{:keys [a b]}] (g b)" + "^:private (defn f [{:keys [a b]}] (g b)" + "#_(defn f [{:keys [a b]}] (g b)")) + (with-clojure-buffer text + (goto-char 1) + (expect (clojure-remove-unused-keys) :to-throw 'user-error) + (expect (buffer-string) :to-equal text))) + (with-clojure-buffer "(defn f [{:keys [a b]}] b))" + (expect (clojure-remove-unused-keys) :to-throw 'user-error))) + + (it "signals an error when point isn't on a form" + (with-clojure-buffer "(defn f [{:keys [a]}] 1)\n\n" + (expect (clojure-remove-unused-keys) :to-throw 'user-error)) + (with-clojure-buffer ";; c\n(defn f [{:keys [a]}] 1)" + (goto-char 2) + (expect (clojure-remove-unused-keys) :to-throw 'user-error)))) + +;;; clojure-mode-refactor-remove-unused-keys-test.el ends here