;;; rebase-mode -- edit git rebase files.

;; Copyright (C) 2010  Phil Jackson
;; Copyright (C) 2011  Peter J Weisberg
;;
;; Magit 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, or (at your option)
;; any later version.
;;
;; Magit 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 Magit.  If not, see <http://www.gnu.org/licenses/>.

;;; Commentary:

;; Allows the editing of a git rebase file (which you might get when
;; using 'git rebase -i' or hitting 'E' in Magit). Assumes editing is
;; happening in a server.

;;; Code:

(require 'server)

(defgroup rebase-mode nil
  "Customize Rebase Mode"
  :group 'faces)

(defface rebase-mode-killed-action-face
  '((((class color))
     :inherit font-lock-comment-face
     :strike-through t))
  "Action lines in the rebase TODO list that have been commented out."
  :group 'rebase-mode)

(defface rebase-mode-description-face
  '((t :inherit font-lock-comment-face))
  "Face for one-line commit descriptions"
  :group 'rebase-mode)

(defconst rebase-mode-action-line-re
  (rx
   line-start
   (? "#")
   (group
    (|
     (any "presf")
     "pick"
     "reword"
     "edit"
     "squash"
     "fixup"))
   (char space)
   (group
    (** 4 40 hex-digit)) ;sha1
   (char space)
   (group
    (* not-newline)))
  "Regexp that matches an action line in a rebase buffer.")

(defconst rebase-mode-exec-line-re
  (rx
   line-start
   (? "#")
   (group
    (| "x"
       "exec"))
   (char space)
   (group
    (* not-newline)))
  "Regexp that matches an exec line in a rebase buffer.")

(defconst rebase-mode-dead-line-re
  (rx-to-string `(and line-start
                      (char ?#)
                      (or (regexp ,(substring rebase-mode-action-line-re 1))
                          (regexp ,(substring rebase-mode-exec-line-re 1)))) t)
  "Regexp that matches a commented-out exec or action line in a rebase buffer.")

(defvar rebase-mode-font-lock-keywords
  (list
   (list rebase-mode-action-line-re
         '(1 font-lock-keyword-face)
         '(2 font-lock-builtin-face)
         '(3 'rebase-mode-description-face))
   (list rebase-mode-exec-line-re
         '(1 font-lock-keyword-face))
   (list (rx line-start (char "#") (* not-newline)) 0 font-lock-comment-face)
   (list rebase-mode-dead-line-re 0 ''rebase-mode-killed-action-face t))
  "Font lock keywords for `rebase-mode'.")

(defvar key-to-action-map
  '(("c" . "pick")
    ("r" . "reword")
    ("e" . "edit")
    ("s" . "squash")
    ("f" . "fixup"))
  "Mapping from key to action.")

(defvar rebase-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "q") 'server-edit)
    (define-key map (kbd "C-c C-c") 'server-edit)

    (define-key map (kbd "a") 'rebase-mode-abort)
    (define-key map (kbd "C-c C-k") 'rebase-mode-abort)

    (define-key map (kbd "M-p") 'rebase-mode-move-line-up)
    (define-key map (kbd "M-n") 'rebase-mode-move-line-down)
    (define-key map (kbd "k") 'rebase-mode-kill-line)
    (define-key map (kbd "x") 'rebase-mode-exec)

    (define-key map (kbd "n") 'forward-line)
    (define-key map (kbd "p") '(lambda(n)
                                 (interactive "p")
                                 (forward-line (* n -1))))
    (define-key map [remap undo] 'rebase-mode-undo)
    map)
  "Keymap for rebase-mode.  Note this will be added to by the
top-level code which defines the edit functions.")

(require 'easymenu)
(easy-menu-define rebase-mode-menu rebase-mode-map
  "Rebase-mode menu"
  '("Rebase"
    ["Pick" rebase-mode-pick t]
    ["Reword" rebase-mode-reword t]
    ["Edit" rebase-mode-edit t]
    ["Squash" rebase-mode-squash t]
    ["Fixup" rebase-mode-fixup t]
    ["Kill" rebase-mode-kill-line t]
    ["Move Down" rebase-mode-move-line-down t]
    ["Move Up" rebase-mode-move-line-up t]
    ["Execute" rebase-mode-exec t]
    "---"
    ["Abort" rebase-mode-abort t]
    ["Done" server-edit t]))

;; create the functions which edit the action lines themselves (based
;; on `key-to-action-map' above)
(mapc (lambda (key-action)
        (let ((fun-name (intern (concat "rebase-mode-" (cdr key-action)))))
          ;; define the function
          (eval `(defun ,fun-name ()
                   (interactive)
                   (rebase-mode-edit-line ,(cdr key-action))))

          ;; bind the function in `rebase-mode-map'
          (define-key rebase-mode-map (car key-action) fun-name)))
      key-to-action-map)

(defun rebase-mode-edit-line (change-to)
  "Change the keyword at the start of the current action line to
that of CHANGE-TO."
  (when (rebase-mode-looking-at-action)
    (let ((buffer-read-only nil)
          (start (point)))
      (goto-char (point-at-bol))
      (delete-region (point) (progn (forward-word 1) (point)))
      (insert change-to)
      (goto-char start))))

(defun rebase-mode-looking-at-action ()
  "Return non-nil if looking at an action line."
  (save-excursion
    (goto-char (point-at-bol))
    (looking-at rebase-mode-action-line-re)))

(defun rebase-mode-looking-at-action-or-exec ()
  "Return non-nil if looking at an action line or exec line."
  (save-excursion
    (goto-char (point-at-bol))
    (or (looking-at rebase-mode-action-line-re)
        (looking-at rebase-mode-exec-line-re))))

(defun rebase-mode-looking-at-exec ()
  "Return non-nil if cursor is on an exec line."
  (string-match rebase-mode-exec-line-re (thing-at-point 'line)))

(defun rebase-mode-looking-at-killed-exec ()
  "Return non-nil if looking at an exec line that has been commented out"
  (let ((line (thing-at-point 'line)))
    (and (eq (aref line 0) ?#)
         (string-match rebase-mode-exec-line-re line))))

(defun rebase-mode-move-line-up ()
  "Move the current action line up."
  (interactive)
  (when (rebase-mode-looking-at-action-or-exec)
    (let ((buffer-read-only nil)
          (col (current-column)))
      (transpose-lines 1)
      (forward-line -2)
      (move-to-column col))))

(defun rebase-mode-move-line-down ()
  "Assuming the next line is also an action line, move the current line down."
  (interactive)
  ;; if we're on an action and the next line is also an action
  (when (and (rebase-mode-looking-at-action-or-exec)
             (save-excursion
               (forward-line)
               (rebase-mode-looking-at-action-or-exec)))
    (let ((buffer-read-only nil)
          (col (current-column)))
      (forward-line 1)
      (transpose-lines 1)
      (forward-line -1)
      (move-to-column col))))

(defun rebase-mode-abort ()
  "Abort this rebase (by emptying the buffer, saving and closing
server connection)."
  (interactive)
  (when (or (not (buffer-modified-p))
            (y-or-n-p "Abort this rebase? "))
    (let ((buffer-read-only nil))
      (delete-region (point-min) (point-max))
      (save-buffer)
      (server-edit))))

(defun rebase-mode-kill-line ()
  "Kill the current action line."
  (interactive)
  (when (and (not (eq (char-after (point-at-bol)) ?#))
             (rebase-mode-looking-at-action-or-exec))
    (beginning-of-line)
    (let ((buffer-read-only nil))
      (insert "#"))
    (forward-line)))

(defun rebase-mode-exec (edit)
  "Prompt the user for a shell command to be executed, and add it to
the todo list.

If the cursor is on a commented-out exec line, uncomment the
current line instead of prompting.

When the prefix argument EDIT is non-nil and the cursor is on an
exec line, edit that line instead of inserting a new one.  If the
exec line was commented out, also uncomment it."
  (interactive "P")
  (cond
   ((and edit (rebase-mode-looking-at-exec))
    (let ((new-line (rebase-mode-read-exec-line
                     (match-string-no-properties 2 (thing-at-point 'line))))
          (inhibit-read-only t))
      (delete-region (point-at-bol) (point-at-eol))
      (if (not (equal "" new-line))
          (insert "exec " new-line)
        (delete-char -1)
        (forward-line))
      (move-beginning-of-line nil)))
   ((rebase-mode-looking-at-killed-exec)
    (save-excursion
      (beginning-of-line)
      (let ((buffer-read-only nil))
        (delete-char 1))))
   (t
    (let ((inhibit-read-only t)
          (line (rebase-mode-read-exec-line)))
      (unless (equal "" line)
        (move-end-of-line nil)
        (newline)
        (insert (concat "exec " line))))
    (move-beginning-of-line nil))))

(defun rebase-mode-read-exec-line (&optional initial-line)
  (read-shell-command "Execute: " initial-line))

(defun rebase-mode-undo (&optional arg)
  "A thin wrapper around `undo', which allows undoing in
read-only buffers."
  (interactive "P")
  (let ((inhibit-read-only t))
    (undo arg)))

;;;###autoload
(define-derived-mode rebase-mode special-mode "Rebase"
  "Major mode for editing of a Git rebase file.

Rebase files are generated when you run 'git rebase -i' or run
`magit-interactive-rebase'.  They describe how Git should perform
the rebase.  See the documentation for git-rebase (e.g., by
running 'man git-rebase' at the command line) for details."
  (setq font-lock-defaults '(rebase-mode-font-lock-keywords t t)))

(defun rebase-mode-show-keybindings ()
  "Modify the \"Commands:\" section of the comment Git generates
at the bottom of the file so that in place of the one-letter
abbreviation for the command, it shows the command's keybinding.
By default, this is the same except for the \"pick\" command."
  (save-excursion
    (goto-char (point-min))
    (while (search-forward-regexp "^#  \\(.\\), \\([[:alpha:]]+\\) = " nil t)
      (let ((start (match-beginning 1))
            (end (match-end 1))
            (command (intern (concat "rebase-mode-" (match-string 2)))))
        (when (fboundp command)
          (let ((overlay (make-overlay start end)))
            (overlay-put overlay
                         'display
                         (key-description (where-is-internal command nil t)))))))))

(add-hook 'rebase-mode-hook 'rebase-mode-show-keybindings t)

(defun rebase-mode-disable-before-save-hook ()
  (set (make-local-variable 'before-save-hook) nil))

(add-hook 'rebase-mode-hook 'rebase-mode-disable-before-save-hook)

;;;###autoload
(add-to-list 'auto-mode-alist
             '("git-rebase-todo" . rebase-mode))

(provide 'rebase-mode)

;;; rebase-mode.el ends here
