;;; elpy-refactor.el --- Refactoring mode for Elpy ;; Copyright (C) 2020 Gaby Launay ;; Author: Gaby Launay ;; URL: https://github.com/jorgenschaefer/elpy ;; 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: ;; This file provides an interface, including a major mode, to use ;; refactoring options provided by the Jedi library. ;;; Code: ;; We require elpy, but elpy loads us, so we shouldn't load it back. ;; (require 'elpy) (require 'diff-mode) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;; Refactor mode (for applying diffs) (defvar elpy-refactor--saved-window-configuration nil "Saved windows configuration, so that we can restore it after `elpy-refactor' has done its thing.") (defvar elpy-refactor--saved-pos nil "Line and column number of the position we were at before starting refactoring.") (defvar elpy-refactor--modified-buffers '() "Keep track of the buffers modified by the current refactoring sessions.") (defun elpy-refactor--apply-diff (proj-path diff) "Apply DIFF, looking for the files in PROJ-PATH." (let ((current-line (line-number-at-pos (point))) (current-col (- (point) (line-beginning-position)))) (with-current-buffer (get-buffer-create " *Elpy Refactor*") (elpy-refactor-mode) (let ((inhibit-read-only t)) (erase-buffer) (insert diff)) (setq default-directory proj-path) (goto-char (point-min)) (elpy-refactor--apply-whole-diff)) (condition-case nil (progn (goto-char (point-min)) (forward-line (- current-line 1)) (beginning-of-line) (forward-char current-col)) (error)) )) (defun elpy-refactor--display-diff (proj-path diff) "Display DIFF in a `diff-mode' window. DIFF files should be relative to PROJ-PATH." (setq elpy-refactor--saved-window-configuration (current-window-configuration) elpy-refactor--saved-pos (list (line-number-at-pos (point) t) (- (point) (line-beginning-position))) elpy-refactor--modified-buffers '()) (with-current-buffer (get-buffer-create "*Elpy Refactor*") (elpy-refactor-mode) (let ((inhibit-read-only t)) (erase-buffer) (insert (propertize (substitute-command-keys (concat "\\[diff-file-next] and \\[diff-file-prev] -- Move between files\n" "\\[diff-hunk-next] and \\[diff-hunk-prev] -- Move between hunks\n" "\\[diff-split-hunk] -- Split the current hunk at point\n" "\\[elpy-refactor--apply-hunk] -- Apply the current hunk\n" "\\[diff-kill-hunk] -- Kill the current hunk\n" "\\[elpy-refactor--apply-whole-diff] -- Apply the whole diff\n" "\\[elpy-refactor--quit] -- Quit\n")) 'face 'bold) "\n\n") (align-regexp (point-min) (point-max) "\\(\\s-*\\) -- ") (goto-char (point-min)) (while (search-forward " -- " nil t) (replace-match " " nil t)) (goto-char (point-max)) (insert diff)) (setq default-directory proj-path) (goto-char (point-min)) (if (diff--some-hunks-p) (progn (select-window (display-buffer (current-buffer))) (diff-hunk-next)) ;; quit if not diff at all... (message "No differences to validate") (kill-buffer (current-buffer))))) (defvar elpy-refactor-mode-map (let ((map (make-sparse-keymap))) (define-key map (kbd "C-c C-c") 'elpy-refactor--apply-hunk) (define-key map (kbd "C-c C-a") 'elpy-refactor--apply-whole-diff) (define-key map (kbd "C-c C-x") 'diff-kill-hunk) (define-key map (kbd "q") 'elpy-refactor--quit) (define-key map (kbd "C-c C-k") 'elpy-refactor--quit) (define-key map (kbd "h") 'describe-mode) (define-key map (kbd "?") 'describe-mode) map) "The key map for `elpy-refactor-mode'.") (define-derived-mode elpy-refactor-mode diff-mode "Elpy Refactor" "Mode to display refactoring actions and ask confirmation from the user. \\{elpy-refactor-mode-map}" :group 'elpy (view-mode 1)) (defun elpy-refactor--apply-hunk () "Apply the current hunk." (interactive) (save-excursion (diff-apply-hunk)) ;; keep track of modified buffers (let ((buf (find-buffer-visiting (diff-find-file-name)))) (when buf (add-to-list 'elpy-refactor--modified-buffers buf))) ;; (diff-hunk-kill) (unless (diff--some-hunks-p) (elpy-refactor--quit))) (defun elpy-refactor--apply-whole-diff () "Apply the whole diff and quit." (interactive) (goto-char (point-min)) (diff-hunk-next) (while (diff--some-hunks-p) (let ((buf (find-buffer-visiting (diff-find-file-name)))) (when buf (add-to-list 'elpy-refactor--modified-buffers buf))) (condition-case nil (progn (save-excursion (diff-apply-hunk)) (diff-hunk-kill)) (error (diff-hunk-next)))) ;; if a hunk fail, switch to the next one ;; quit (elpy-refactor--quit)) (defun elpy-refactor--quit () "Quit the refactoring session." (interactive) ;; save modified buffers (dolist (buf elpy-refactor--modified-buffers) (with-current-buffer buf (basic-save-buffer))) (setq elpy-refactor--modified-buffers '()) ;; kill refactoring buffer (kill-buffer (current-buffer)) ;; Restore window configuration (when elpy-refactor--saved-window-configuration (set-window-configuration elpy-refactor--saved-window-configuration) (setq elpy-refactor--saved-window-configuration nil)) ;; Restore cursor position (when elpy-refactor--saved-pos (goto-char (point-min)) (forward-line (- (car elpy-refactor--saved-pos) 1)) (forward-char (car (cdr elpy-refactor--saved-pos))) (setq elpy-refactor--saved-pos nil))) ;;;;;;;;;;;;;;;;; ;; User functions (defun elpy-refactor-rename (new-name &optional dontask) "Rename the symbol at point to NEW-NAME. With a prefix argument (or if DONTASK is non-nil), do not display the diff before applying." (interactive (list (let ((old-name (thing-at-point 'symbol))) (if (or (not old-name) (not (elpy-refactor--is-valid-symbol-p old-name))) (error "No symbol at point") (read-string (format "New name for '%s': " (thing-at-point 'symbol)) (thing-at-point 'symbol)))))) (unless (and new-name (elpy-refactor--is-valid-symbol-p new-name)) (error "'%s' is not a valid python symbol")) (message "Gathering occurences of '%s'..." (thing-at-point 'symbol)) (let* ((elpy-rpc-timeout 10) ;; refactoring can be long... (diff (elpy-rpc-get-rename-diff new-name)) (proj-path (alist-get 'project_path diff)) (success (alist-get 'success diff)) (diff (alist-get 'diff diff))) (cond ((not success) (error "Refactoring failed for some reason")) ((string= success "Not available") (error "This functionnality needs jedi > 0.17.0, please update")) ((or dontask current-prefix-arg) (message "Replacing '%s' with '%s'..." (thing-at-point 'symbol) new-name) (elpy-refactor--apply-diff proj-path diff) (message "Done")) (t (elpy-refactor--display-diff proj-path diff))))) (defun elpy-refactor-extract-variable (new-name) "Extract the current region to a new variable NEW-NAME." (interactive "sNew name: ") (let ((beg (if (region-active-p) (region-beginning) (car (or (bounds-of-thing-at-point 'symbol) (error "No symbol at point"))))) (end (if (region-active-p) (region-end) (cdr (bounds-of-thing-at-point 'symbol))))) (when (or (elpy-refactor--is-valid-symbol-p new-name) (y-or-n-p "'%s' does not appear to be a valid python symbol. Are you sure you want to use it? ")) (let* ((line-beg (save-excursion (goto-char beg) (line-number-at-pos))) (line-end (save-excursion (goto-char end) (line-number-at-pos))) (col-beg (save-excursion (goto-char beg) (- (point) (line-beginning-position)))) (col-end (save-excursion (goto-char end) (- (point) (line-beginning-position)))) (diff (elpy-rpc-get-extract-variable-diff new-name line-beg line-end col-beg col-end)) (proj-path (alist-get 'project_path diff)) (success (alist-get 'success diff)) (diff (alist-get 'diff diff))) (cond ((not success) (error "We could not extract the selection as a variable")) ((string= success "Not available") (error "This functionnality needs jedi > 0.17.0, please update")) (t (deactivate-mark) (elpy-refactor--apply-diff proj-path diff))))))) (defun elpy-refactor-extract-function (new-name) "Extract the current region to a new function NEW-NAME." (interactive "sNew function name: ") (unless (region-active-p) (error "No selection")) (when (or (elpy-refactor--is-valid-symbol-p new-name) (y-or-n-p "'%s' does not appear to be a valid python symbol. Are you sure you want to use it? ")) (let* ((line-beg (save-excursion (goto-char (region-beginning)) (line-number-at-pos))) (line-end (save-excursion (goto-char (region-end)) (line-number-at-pos))) (col-beg (save-excursion (goto-char (region-beginning)) (- (point) (line-beginning-position)))) (col-end (save-excursion (goto-char (region-end)) (- (point) (line-beginning-position)))) (diff (elpy-rpc-get-extract-function-diff new-name line-beg line-end col-beg col-end)) (proj-path (alist-get 'project_path diff)) (success (alist-get 'success diff)) (diff (alist-get 'diff diff))) (cond ((not success) (error "We could not extract the selection as a function")) ((string= success "Not available") (error "This functionnality needs jedi > 0.17.0, please update")) (t (deactivate-mark) (elpy-refactor--apply-diff proj-path diff)))))) (defun elpy-refactor-inline () "Inline the variable at point." (interactive) (let* ((diff (elpy-rpc-get-inline-diff)) (proj-path (alist-get 'project_path diff)) (success (alist-get 'success diff)) (diff (alist-get 'diff diff))) (cond ((not success) (error "We could not inline the variable '%s'" (thing-at-point 'symbol))) ((string= success "Not available") (error "This functionnality needs jedi > 0.17.0, please update")) (t (elpy-refactor--apply-diff proj-path diff))))) ;;;;;;;;;;;; ;; Utilities (defun elpy-refactor--is-valid-symbol-p (symbol) "Return t if SYMBOL is a valid python symbol." (eq 0 (string-match "^[a-zA-Z_][a-zA-Z0-9_]*$" symbol))) ;;;;;;;;;;;; ;; Compatibility (unless (fboundp 'diff--some-hunks-p) (defun diff--some-hunks-p () (save-excursion (goto-char (point-min)) (re-search-forward diff-hunk-header-re nil t)))) (provide 'elpy-refactor) ;;; elpy-refactor.el ends here