;;; git-gutter.el --- Port of Sublime Text 2 plugin GitGutter

;; Copyright (C) 2013 by Syohei YOSHIDA

;; Author: Syohei YOSHIDA <syohex@gmail.com>
;; URL: https://github.com/syohex/emacs-git-gutter
;; Version: 0.19

;; 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:
;;
;; Port of GitGutter which is a plugin of Sublime Text2

;;; Code:

(eval-when-compile
  (require 'cl))

(defgroup git-gutter nil
  "Port GitGutter"
  :prefix "git-gutter:"
  :group 'vc)

(defcustom git-gutter:window-width nil
  "Character width of gutter window. Emacs mistakes width of some characters.
It is better to explicitly assign width to this variable, if you use full-width
character for signs of changes"
  :type 'integer
  :group 'git-gutter)

(defcustom git-gutter:diff-option ""
  "Option of 'git diff'"
  :type 'string
  :group 'git-gutter)

(defcustom git-gutter:modified-sign "="
  "Modified sign"
  :type 'string
  :group 'git-gutter)

(defcustom git-gutter:added-sign "+"
  "Added sign"
  :type 'string
  :group 'git-gutter)

(defcustom git-gutter:deleted-sign "-"
  "Deleted sign"
  :type 'string
  :group 'git-gutter)

(defcustom git-gutter:unchanged-sign nil
  "Deleted sign"
  :type 'string
  :group 'git-gutter)

(defcustom git-gutter:always-show-gutter nil
  "Always show gutter"
  :type 'boolean
  :group 'git-gutter)

(defcustom git-gutter:lighter " GitGutter"
  "Minor mode lighter in mode-line"
  :type 'string
  :group 'git-gutter)

(defface git-gutter:modified
    '((t (:foreground "magenta" :weight bold)))
  "Face of modified"
  :group 'git-gutter)

(defface git-gutter:added
    '((t (:foreground "green" :weight bold)))
  "Face of added"
  :group 'git-gutter)

(defface git-gutter:deleted
    '((t (:foreground "red" :weight bold)))
  "Face of deleted"
  :group 'git-gutter)

(defface git-gutter:unchanged
    '((t (:background "yellow")))
  "Face of unchanged"
  :group 'git-gutter)

(defvar git-gutter:view-diff-function #'git-gutter:view-diff-infos
  "Function of viewing changes")

(defvar git-gutter:clear-function #'git-gutter:clear-overlays
  "Function of clear changes")

(defvar git-gutter:enabled nil)
(defvar git-gutter:overlays nil)
(defvar git-gutter:diffinfos nil)

(defun git-gutter:in-git-repository-p ()
  (with-temp-buffer
    (let ((cmd "git rev-parse --is-inside-work-tree"))
      (when (zerop (call-process-shell-command cmd nil t))
        (goto-char (point-min))
        (string= "true" (buffer-substring-no-properties
                         (point) (line-end-position)))))))

(defun git-gutter:root-directory ()
  (with-temp-buffer
    (let* ((cmd "git rev-parse --show-toplevel")
           (ret (call-process-shell-command cmd nil t)))
      (unless (zerop ret)
        (error "Here is not git repository!!"))
      (goto-char (point-min))
      (let ((root (buffer-substring-no-properties (point) (line-end-position))))
        (when (string= root "")
          (error "You maybe be in '.git/' directory"))
        (file-name-as-directory root)))))

(defun git-gutter:changes-to-number (str)
  (if (string= str "")
      1
    (string-to-number str)))

(defun git-gutter:make-diffinfo (type start &optional end)
  (list :type type :start-line start :end-line end))

(defun git-gutter:diff (curfile)
  (let ((cmd (format "git diff --no-ext-diff -U0 %s %s" git-gutter:diff-option curfile))
        (regexp "^@@ -\\([0-9]+\\),?\\([0-9]*\\) \\+\\([0-9]+\\),?\\([0-9]*\\) @@"))
    (with-temp-buffer
      (let ((ret (call-process-shell-command cmd nil t)))
        (unless (or (zerop ret))
          (error (format "Failed '%s'" cmd))))
      (goto-char (point-min))
      (loop while (re-search-forward regexp nil t)
            for orig-line = (string-to-number (match-string 1))
            for new-line  = (string-to-number (match-string 3))
            for orig-changes = (git-gutter:changes-to-number (match-string 2))
            for new-changes = (git-gutter:changes-to-number (match-string 4))
            for end-line = (1- (+ new-line new-changes))
            collect
            (cond ((zerop orig-changes)
                   (git-gutter:make-diffinfo 'added new-line end-line))
                  ((zerop new-changes)
                   (git-gutter:make-diffinfo 'deleted (1- orig-line)))
                  (t
                   (git-gutter:make-diffinfo 'modified new-line end-line)))))))

(defun git-gutter:line-to-pos (line)
  (save-excursion
    (goto-char (point-min))
    (forward-line (1- line))
    (point)))

(defmacro git-gutter:before-string (sign)
  `(propertize " " 'display `((margin left-margin) ,sign)))

(defun git-gutter:select-face (type)
  (case type
    (added 'git-gutter:added)
    (modified 'git-gutter:modified)
    (deleted 'git-gutter:deleted)))

(defun git-gutter:select-sign (type)
  (case type
    (added git-gutter:added-sign)
    (modified git-gutter:modified-sign)
    (deleted git-gutter:deleted-sign)))

(defun git-gutter:propertized-sign (type)
  (let ((sign (git-gutter:select-sign type))
        (face (git-gutter:select-face type)))
    (propertize sign 'face face)))

(defun git-gutter:view-region (sign start-line end-line)
  (let ((beg (git-gutter:line-to-pos start-line)))
    (goto-char beg)
    (while (and (<= (line-number-at-pos) end-line) (not (eobp)))
      (git-gutter:view-at-pos sign (point))
      (forward-line 1))))

(defun git-gutter:view-at-pos (sign pos)
  (let ((ov (make-overlay pos pos)))
    (overlay-put ov 'before-string (git-gutter:before-string sign))
    (push ov git-gutter:overlays)))

(defun git-gutter:view-diff-info (diffinfo)
  (let* ((start-line (plist-get diffinfo :start-line))
         (end-line (plist-get diffinfo :end-line))
         (type (plist-get diffinfo :type))
         (sign (git-gutter:propertized-sign type)))
    (case type
      ((modified added) (git-gutter:view-region sign start-line end-line))
      (deleted (git-gutter:view-at-pos
                sign (git-gutter:line-to-pos start-line))))))

(defun git-gutter:sign-width (sign)
  (loop for s across sign
        sum (char-width s)))

(defun git-gutter:longest-sign-width ()
  (let ((signs (list git-gutter:modified-sign
                     git-gutter:added-sign
                     git-gutter:deleted-sign)))
    (when git-gutter:unchanged-sign
      (add-to-list 'signs git-gutter:unchanged-sign))
    (apply #'max (mapcar #'git-gutter:sign-width signs))))

(defun git-gutter:view-for-unchanged ()
  (save-excursion
    (let ((sign (propertize git-gutter:unchanged-sign
                            'face 'git-gutter:unchanged)))
      (goto-char (point-min))
      (while (not (eobp))
        (git-gutter:view-at-pos sign (point))
        (forward-line 1)))))

(defun git-gutter:view-diff-infos (diffinfos)
  (let ((curwin (get-buffer-window))
        (win-width (or git-gutter:window-width
                       (git-gutter:longest-sign-width))))
    (when git-gutter:unchanged-sign
      (git-gutter:view-for-unchanged))
    (when diffinfos
      (save-excursion
        (mapc #'git-gutter:view-diff-info diffinfos)))
    (when (or git-gutter:always-show-gutter diffinfos git-gutter:unchanged-sign)
      (set-window-margins curwin win-width (cdr (window-margins curwin))))))

(defun git-gutter:delete-overlay ()
  (mapc #'delete-overlay git-gutter:overlays)
  (setq git-gutter:overlays nil)
  (let ((curwin (get-buffer-window)))
    (set-window-margins curwin 0 (cdr (window-margins curwin)))))

(defun git-gutter:process-diff (curfile)
  (let ((diffinfos (git-gutter:diff curfile)))
    (setq git-gutter:diffinfos diffinfos)
    (funcall git-gutter:view-diff-function diffinfos)))

(defun git-gutter:clear-overlays ()
  (git-gutter:delete-overlay))

(defun git-gutter:search-near-diff-index (diffinfos is-reverse)
  (loop with current-line = (line-number-at-pos)
        with cmp-fn = (if is-reverse '> '<)
        for diffinfo in (if is-reverse (reverse diffinfos) diffinfos)
        for index = 0 then (1+ index)
        for start-line = (plist-get diffinfo :start-line)
        when (funcall cmp-fn current-line start-line)
        return (if is-reverse
                   (1- (- (length diffinfos) index))
                 index)))

;;;###autoload
(defun git-gutter:next-diff (arg)
  (interactive "p")
  (when git-gutter:diffinfos
    (let* ((is-reverse (< arg 0))
           (diffinfos git-gutter:diffinfos)
           (len (length diffinfos))
           (index (git-gutter:search-near-diff-index diffinfos is-reverse))
           (real-index (if index
                           (let ((next (if is-reverse (1+ index) (1- index))))
                             (mod (+ arg next) len))
                         (if is-reverse (1- (length diffinfos)) 0)))
           (diffinfo (nth real-index diffinfos)))
      (goto-char (point-min))
      (forward-line (1- (plist-get diffinfo :start-line))))))

;;;###autoload
(defun git-gutter:previous-diff (arg)
  (interactive "p")
  (git-gutter:next-diff (- arg)))

;;;###autoload
(defun git-gutter ()
  (interactive)
  (git-gutter:delete-overlay)
  (let ((file (buffer-file-name)))
    (when (and file (file-exists-p file))
      (let* ((gitroot (git-gutter:root-directory))
             (default-directory gitroot)
             (current-file (file-relative-name file gitroot)))
        (git-gutter:process-diff current-file)
        (setq git-gutter:enabled t)))))

;;;###autoload
(defun git-gutter:clear ()
  (interactive)
  (funcall git-gutter:clear-function)
  (setq git-gutter:enabled nil))

;;;###autoload
(defun git-gutter:toggle ()
  (interactive)
  (if git-gutter:enabled
      (git-gutter:clear)
    (git-gutter)))

(defun git-gutter:check-file-and-directory ()
  (and (buffer-file-name)
       default-directory (file-directory-p default-directory)))

;;;###autoload
(define-minor-mode git-gutter-mode ()
  "Git-Gutter mode"
  :group      'git-gutter
  :init-value nil
  :global     nil
  :lighter    git-gutter:lighter
  (if git-gutter-mode
      (if (and (git-gutter:check-file-and-directory)
               (git-gutter:in-git-repository-p))
          (progn
            (make-local-variable 'git-gutter:overlays)
            (make-local-variable 'git-gutter:enabled)
            (make-local-variable 'git-gutter:diffinfos)
            (add-hook 'after-save-hook 'git-gutter nil t)
            (add-hook 'after-revert-hook 'git-gutter nil t)
            (add-hook 'change-major-mode-hook 'git-gutter nil t)
            (add-hook 'window-configuration-change-hook 'git-gutter nil t)
            (run-with-idle-timer 0 nil 'git-gutter))
        (message "Here is not Git work tree")
        (git-gutter-mode -1))
    (remove-hook 'after-save-hook 'git-gutter t)
    (remove-hook 'after-revert-hook 'git-gutter t)
    (remove-hook 'change-major-mode-hook 'git-gutter t)
    (remove-hook 'window-configuration-change-hook 'git-gutter t)
    (git-gutter:clear)))

;;;###autoload
(define-global-minor-mode global-git-gutter-mode
  git-gutter-mode
  (lambda ()
    (when (and (not (minibufferp)) (buffer-file-name))
      (git-gutter-mode 1)))
  :group 'git-gutter)

(provide 'git-gutter)

;;; git-gutter.el ends here
