;;; elpy-refactor.el --- Refactoring mode for Elpy

;; Copyright (C) 2013-2019  Jorgen Schaefer

;; Author: Jorgen Schaefer <contact@jorgenschaefer.de>
;; 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 <http://www.gnu.org/licenses/>.

;;; Commentary:

;; This file provides an interface, including a major mode, to use
;; refactoring options provided by the Rope library.

;;; Code:

;; We require elpy, but elpy loads us, so we shouldn't load it back.
;; (require 'elpy)

(defvar elpy-refactor-changes nil
  "Changes that will be commited on \\[elpy-refactor-commit].")
(make-variable-buffer-local 'elpy-refactor-current-changes)

(defvar elpy-refactor-window-configuration nil
  "The old window configuration. Will be restored after commit.")
(make-variable-buffer-local 'elpy-refactor-window-configuration)

(make-obsolete
 'elpy-refactor
 "Refactoring has been unstable and flakey, support will be dropped in the future."
 "elpy 1.5.0")
(defun elpy-refactor ()
  "Run the Elpy refactoring interface for Python code."
  (interactive)
  (save-some-buffers)
  (let* ((selection (elpy-refactor-select
                     (elpy-refactor-rpc-get-options)))
         (method (car selection))
         (args (cdr selection)))
    (when method
      (elpy-refactor-create-change-buffer
       (elpy-refactor-rpc-get-changes method args)))))

(defun elpy-refactor-select (options)
  "Show the user the refactoring options and let her choose one.

Depending on the chosen option, ask the user for further
arguments and build the argument.

Return a cons cell of the name of the option and the arg list
created."
  (let ((buf (get-buffer-create "*Elpy Refactor*"))
        (pos (vector (1- (point))
                     (ignore-errors
                       (1- (region-beginning)))
                     (ignore-errors
                       (1- (region-end)))))
        (inhibit-read-only t)
        (options (sort options
                       (lambda (a b)
                         (let ((cata (cdr (assq 'category a)))
                               (catb (cdr (assq 'category b))))
                           (if (equal cata catb)
                               (string< (cdr (assq 'description a))
                                        (cdr (assq 'description b)))
                             (string< cata catb))))))
        (key ?a)
        last-category
        option-alist)
    (with-current-buffer buf
      (erase-buffer)
      (dolist (option options)
        (let ((category (cdr (assq 'category option)))
              (description (cdr (assq 'description option)))
              (name (cdr (assq 'name option)))
              (args (cdr (assq 'args option))))
          (unless (equal category last-category)
            (when last-category
              (insert "\n"))
            (insert (propertize category 'face 'bold) "\n")
            (setq last-category category))
          (insert " (" key ") " description "\n")
          (setq option-alist (cons (list key name args)
                                   option-alist))
          (setq key (1+ key))))
      (let ((window-conf (current-window-configuration)))
        (unwind-protect
            (progn
              (with-selected-window (display-buffer buf)
                (goto-char (point-min)))
              (fit-window-to-buffer (get-buffer-window buf))
              (let* ((key (read-key "Refactoring action? "))
                     (entry (cdr (assoc key option-alist))))
                (kill-buffer buf)
                (cons (car entry)       ; name
                      (elpy-refactor-build-arguments (cadr entry)
                                                     pos))))
          (set-window-configuration window-conf))))))

(defun elpy-refactor-build-arguments (args pos)
  "Translate an argument list specification to an argument list.

POS is a vector of three elements, the current offset, the offset
of the beginning of the region, and the offset of the end of the
region.

ARGS is a list of triples, each triple containing the name of an
argument (ignored), the type of the argument, and a possible
prompt string.

Available types:

  offset       - The offset in the buffer, (1- (point))
  start_offset - Offset of the beginning of the region
  end_offset   - Offset of the end of the region
  string       - A free-form string
  filename     - A non-existing file name
  directory    - An existing directory name
  boolean      - A boolean question"
  (mapcar (lambda (arg)
            (let ((type (cadr arg))
                  (prompt (cl-caddr arg)))
              (cond
               ((equal type "offset")
                (aref pos 0))
               ((equal type "start_offset")
                (aref pos 1))
               ((equal type "end_offset")
                (aref pos 2))
               ((equal type "string")
                (read-from-minibuffer prompt))
               ((equal type "filename")
                (expand-file-name
                 (read-file-name prompt)))
               ((equal type "directory")
                (expand-file-name
                 (read-directory-name prompt)))
               ((equal type "boolean")
                (y-or-n-p prompt)))))
          args))

(defun elpy-refactor-create-change-buffer (changes)
  "Show the user a buffer of changes.

The user can review the changes and confirm them with
\\[elpy-refactor-commit]."
  (unless changes
    (error "No changes for this refactoring action."))
  (with-current-buffer (get-buffer-create "*Elpy Refactor*")
    (elpy-refactor-mode)
    (setq elpy-refactor-changes changes
          elpy-refactor-window-configuration (current-window-configuration))
    (let ((inhibit-read-only t))
      (erase-buffer)
      (elpy-refactor-insert-changes changes))
    (select-window (display-buffer (current-buffer)))
    (goto-char (point-min))))

(defun elpy-refactor-insert-changes (changes)
  "Format and display the changes described in CHANGES."
  (insert (propertize "Use C-c C-c to apply the following changes."
                      'face 'bold)
          "\n\n")
  (dolist (change changes)
    (let ((action (cdr (assq 'action change))))
      (cond
       ((equal action "change")
        (insert (cdr (assq 'diff change))
                "\n"))
       ((equal action "create")
        (let ((type (cdr (assq 'type change))))
          (if (equal type "file")
              (insert "+++ " (cdr (assq 'file change)) "\n"
                      "Create file " (cdr (assq 'file change)) "\n"
                      "\n")
            (insert "+++ " (cdr (assq 'path change)) "\n"
                    "Create directory " (cdr (assq 'path change)) "\n"
                    "\n"))))
       ((equal action "move")
        (insert "--- " (cdr (assq 'source change)) "\n"
                "+++ " (cdr (assq 'destination change)) "\n"
                "Rename " (cdr (assq 'type change)) "\n"
                "\n"))
       ((equal action "delete")
        (let ((type (cdr (assq 'type change))))
          (if (equal type "file")
              (insert "--- " (cdr (assq 'file change)) "\n"
                      "Delete file " (cdr (assq 'file change)) "\n"
                      "\n")
            (insert "--- " (cdr (assq 'path change)) "\n"
                    "Delete directory " (cdr (assq 'path change)) "\n"
                    "\n"))))))))

(defvar elpy-refactor-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "C-c C-c") 'elpy-refactor-commit)
    (define-key map (kbd "q") 'bury-buffer)
    (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-commit ()
  "Commit the changes in the current buffer."
  (interactive)
  (unless elpy-refactor-changes
    (error "No changes to commit."))
  ;; Restore the window configuration as the first thing so that
  ;; changes below are visible to the user. Especially the point
  ;; change in possible buffer changes.
  (set-window-configuration elpy-refactor-window-configuration)
  (dolist (change elpy-refactor-changes)
    (let ((action (cdr (assq 'action change))))
      (cond
       ((equal action "change")
        (with-current-buffer (find-file-noselect (cdr (assq 'file change)))
          ;; This would break for save-excursion as the buffer is
          ;; truncated, so all markets now point to position 1.
          (let ((old-point (point)))
            (undo-boundary)
            (erase-buffer)
            (insert (cdr (assq 'contents change)))
            (undo-boundary)
            (goto-char old-point))))
       ((equal action "create")
        (if (equal (cdr (assq 'type change))
                   "file")
            (find-file-noselect (cdr (assq 'file change)))
          (make-directory (cdr (assq 'path change)))))
       ((equal action "move")
        (let* ((source (cdr (assq 'source change)))
               (dest (cdr (assq 'destination change)))
               (buf (get-file-buffer source)))
          (when buf
            (with-current-buffer buf
              (setq buffer-file-name dest)
              (rename-buffer (file-name-nondirectory dest) t)))
          (rename-file source dest)))
       ((equal action "delete")
        (if (equal (cdr (assq 'type change)) "file")
            (let ((name (cdr (assq 'file change))))
              (when (y-or-n-p (format "Really delete %s? " name))
                (delete-file name t)))
          (let ((name (cdr (assq 'directory change))))
            (when (y-or-n-p (format "Really delete %s? " name))
              (delete-directory name nil t))))))))
  (kill-buffer (current-buffer)))

(defun elpy-refactor-rpc-get-options ()
  "Get a list of refactoring options from the Elpy RPC."
  (if (use-region-p)
      (elpy-rpc "get_refactor_options"
                (list (buffer-file-name)
                      (1- (region-beginning))
                      (1- (region-end))))
    (elpy-rpc "get_refactor_options"
              (list (buffer-file-name)
                    (1- (point))))))

(defun elpy-refactor-rpc-get-changes (method args)
  "Get a list of changes from the Elpy RPC after applying METHOD with ARGS."
  (elpy-rpc "refactor"
            (list (buffer-file-name)
                  method args)))

(defun elpy-refactor-options (option)
  "Show available refactor options and let user choose one."
  (interactive "c[i]: importmagic-fixup [p]: autopep8-fix-code [r]: refactor")
  (let ((choice (char-to-string option)))
    (cond
     ((string-equal choice "i")
      (elpy-importmagic-fixup))
     ((string-equal choice "p")
      (elpy-autopep8-fix-code))
     ((string-equal choice "r")
      (elpy-refactor)))))

(provide 'elpy-refactor)
;;; elpy-refactor.el ends here
