;;; ruby-electric.el --- Minor mode for electrically editing ruby code
;;
;; Authors: Dee Zsombor <dee dot zsombor at gmail dot com>
;;          Yukihiro Matsumoto
;;          Nobuyoshi Nakada
;;          Akinori MUSHA <knu@iDaemons.org>
;;          Jakub Kuźma <qoobaa@gmail.com>
;; Maintainer: Akinori MUSHA <knu@iDaemons.org>
;; Created: 6 Mar 2005
;; URL: https://github.com/knu/ruby-electric.el
;; Keywords: languages ruby
;; License: The same license terms as Ruby
;; Version: 2.2.3

;;; Commentary:
;;
;; `ruby-electric-mode' accelerates code writing in ruby by making
;; some keys "electric" and automatically supplying with closing
;; parentheses and "end" as appropriate.
;;
;; This work was originally inspired by a code snippet posted by
;; [Frederick Ros](https://github.com/sleeper).
;;
;; Add the following line to enable ruby-electric-mode under
;; ruby-mode.
;;
;;     (eval-after-load "ruby-mode"
;;       '(add-hook 'ruby-mode-hook 'ruby-electric-mode))
;;
;; Type M-x customize-group ruby-electric for configuration.

;;; Code:

(require 'ruby-mode)

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

(defgroup ruby-electric nil
  "Minor mode providing electric editing commands for ruby files"
  :group 'ruby)

(defconst ruby-electric-expandable-bar-re
  "\\s-\\(do\\|{\\)\\s-*|")

(defconst ruby-electric-delimiters-alist
  '((?\{ :name "Curly brace"  :handler ruby-electric-curlies       :closing ?\})
    (?\[ :name "Square brace" :handler ruby-electric-matching-char :closing ?\])
    (?\( :name "Round brace"  :handler ruby-electric-matching-char :closing ?\))
    (?\' :name "Quote"        :handler ruby-electric-matching-char)
    (?\" :name "Double quote" :handler ruby-electric-matching-char)
    (?\` :name "Back quote"   :handler ruby-electric-matching-char)
    (?\| :name "Vertical bar" :handler ruby-electric-bar)
    (?\# :name "Hash"         :handler ruby-electric-hash)))

(defvar ruby-electric-matching-delimeter-alist
  (apply 'nconc
         (mapcar #'(lambda (x)
                     (let ((delim (car x))
                           (plist (cdr x)))
                       (if (eq (plist-get plist :handler) 'ruby-electric-matching-char)
                           (list (cons delim (or (plist-get plist :closing)
                                                 delim))))))
                 ruby-electric-delimiters-alist)))

(defvar ruby-electric-expandable-keyword-re)

(defmacro ruby-electric--try-insert-and-do (string &rest body)
  (declare (indent 1))
  `(let ((before (point))
         (after (progn
                  (insert ,string)
                  (point))))
     (unwind-protect
         (progn ,@body)
       (delete-region before after)
       (goto-char before))))

(defconst ruby-modifier-beg-symbol-re
  (regexp-opt ruby-modifier-beg-keywords 'symbols))

(defun ruby-electric--modifier-keyword-at-point-p ()
  "Test if there is a modifier keyword at point."
  (and (looking-at ruby-modifier-beg-symbol-re)
       (let ((end (match-end 1)))
         (not (looking-back "\\."))
         (save-excursion
           (let ((indent1 (ruby-electric--try-insert-and-do "\n"
                            (ruby-calculate-indent)))
                 (indent2 (save-excursion
                            (goto-char end)
                            (ruby-electric--try-insert-and-do " x\n"
                              (ruby-calculate-indent)))))
             (= indent1 indent2))))))

(defconst ruby-block-mid-symbol-re
  (regexp-opt ruby-block-mid-keywords 'symbols))

(defun ruby-electric--block-mid-keyword-at-point-p ()
  "Test if there is a block mid keyword at point."
  (and (looking-at ruby-block-mid-symbol-re)
       (looking-back "^\\s-*")))

(defconst ruby-block-beg-symbol-re
  (regexp-opt ruby-block-beg-keywords 'symbols))

(defun ruby-electric--block-beg-keyword-at-point-p ()
  "Test if there is a block beginning keyword at point."
  (and (looking-at ruby-block-beg-symbol-re)
       (if (string= (match-string 1) "do")
           (looking-back "\\s-")
         (not (looking-back "\\.")))
       ;; (not (ruby-electric--modifier-keyword-at-point-p)) ;; implicit assumption
       ))

(defcustom ruby-electric-keywords-alist
  '(("begin" . end)
    ("case" . end)
    ("class" . end)
    ("def" . end)
    ("do" . end)
    ("else" . reindent)
    ("elsif" . reindent)
    ("end" . reindent)
    ("ensure" . reindent)
    ("for" . end)
    ("if" . end)
    ("module" . end)
    ("rescue" . reindent)
    ("unless" . end)
    ("until" . end)
    ("when" . reindent)
    ("while" . end))
  "Alist of keywords and actions to define how to react to space
or return right after each keyword.  In each (KEYWORD . ACTION)
cons, ACTION can be set to one of the following values:

    `reindent'  Reindent the line.

    `end'       Reindent the line and auto-close the keyword with
                end if applicable.

    `nil'       Do nothing.
"
  :type '(repeat (cons (string :tag "Keyword")
                       (choice :tag "Action"
                               :menu-tag "Action"
                               (const :tag "Auto-close with end"
                                      :value end)
                               (const :tag "Auto-reindent"
                                      :value reindent)
                               (const :tag "None"
                                      :value nil))))
  :set (lambda (sym val)
         (set sym val)
         (let (keywords)
           (dolist (x val)
             (let ((keyword (car x))
                   (action (cdr x)))
               (if action
                   (setq keywords (cons keyword keywords)))))
           (setq ruby-electric-expandable-keyword-re
                 (concat (regexp-opt keywords 'symbols)
                         "$"))))
  :group 'ruby-electric)

(defvar ruby-electric-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map " " 'ruby-electric-space/return)
    (define-key map [remap delete-backward-char] 'ruby-electric-delete-backward-char)
    (define-key map [remap newline] 'ruby-electric-space/return)
    (define-key map [remap newline-and-indent] 'ruby-electric-space/return)
    (define-key map [remap electric-newline-and-maybe-indent] 'ruby-electric-space/return)
    (dolist (x ruby-electric-delimiters-alist)
      (let* ((delim   (car x))
             (plist   (cdr x))
             (name    (plist-get plist :name))
             (func    (plist-get plist :handler))
             (closing (plist-get plist :closing)))
        (define-key map (char-to-string delim) func)
        (if closing
            (define-key map (char-to-string closing) 'ruby-electric-closing-char))))
    map)
  "Keymap used in ruby-electric-mode")

(defcustom ruby-electric-expand-delimiters-list '(all)
  "*List of contexts where matching delimiter should be inserted.
The word 'all' will do all insertions."
  :type `(set :extra-offset 8
              (const :tag "Everything" all)
              ,@(apply 'list
                       (mapcar #'(lambda (x)
                                   `(const :tag ,(plist-get (cdr x) :name)
                                           ,(car x)))
                               ruby-electric-delimiters-alist)))
  :group 'ruby-electric)

(defcustom ruby-electric-newline-before-closing-bracket nil
  "*Non-nil means a newline should be inserted before an
automatically inserted closing bracket."
  :type 'boolean :group 'ruby-electric)

(defcustom ruby-electric-autoindent-on-closing-char nil
  "*Non-nil means the current line should be automatically
indented when a closing character is manually typed in."
  :type 'boolean :group 'ruby-electric)

(defvar ruby-electric-mode-hook nil
  "Called after `ruby-electric-mode' is turned on.")

;;;###autoload
(define-minor-mode ruby-electric-mode
  "Toggle Ruby Electric minor mode.
With no argument, this command toggles the mode.  Non-null prefix
argument turns on the mode.  Null prefix argument turns off the
mode.

When Ruby Electric mode is enabled, an indented 'end' is
heuristicaly inserted whenever typing a word like 'module',
'class', 'def', 'if', 'unless', 'case', 'until', 'for', 'begin',
'do' followed by a space.  Single, double and back quotes as well
as braces are paired auto-magically.  Expansion does not occur
inside comments and strings. Note that you must have Font Lock
enabled."
  ;; initial value.
  nil
  ;;indicator for the mode line.
  " REl"
  ;;keymap
  ruby-electric-mode-map
  (if ruby-electric-mode
      (run-hooks 'ruby-electric-mode-hook)))

(defun ruby-electric-space/return-fallback ()
  (if (or (eq this-original-command 'ruby-electric-space/return)
          (null (ignore-errors
                  ;; ac-complete may fail if there is nothing left to complete
                  (call-interactively this-original-command)
                  (setq this-command this-original-command))))
      ;; fall back to a globally bound command
      (let ((command (global-key-binding (char-to-string last-command-event) t)))
        (and command
             (call-interactively (setq this-command command))))))

(defun ruby-electric-space/return (arg)
  (interactive "*P")
  (and (boundp 'sp-last-operation)
       (setq sp-delayed-pair nil))
  (cond ((or arg
             (region-active-p))
         (or (= last-command-event ?\s)
             (setq last-command-event ?\n))
         (ruby-electric-replace-region-or-insert))
        ((ruby-electric-space/return-can-be-expanded-p)
         (let (action)
           (save-excursion
             (goto-char (match-beginning 0))
             (let* ((keyword (match-string 1))
                    (allowed-actions
                     (cond ((ruby-electric--modifier-keyword-at-point-p)
                            '(reindent)) ;; no end necessary
                           ((ruby-electric--block-mid-keyword-at-point-p)
                            '(reindent)) ;; ditto
                           ((ruby-electric--block-beg-keyword-at-point-p)
                            '(end reindent)))))
               (if allowed-actions
                   (setq action
                         (let ((action (cdr (assoc keyword ruby-electric-keywords-alist))))
                           (and (memq action allowed-actions)
                                action))))))
           (cond ((eq action 'end)
                  (ruby-indent-line)
                  (save-excursion
                    (newline)
                    (ruby-electric-end)))
                 ((eq action 'reindent)
                  (ruby-indent-line)))
           (ruby-electric-space/return-fallback)))
        ((and (eq this-original-command 'newline-and-indent)
              (ruby-electric-comment-at-point-p))
         (call-interactively (setq this-command 'comment-indent-new-line)))
        (t
         (ruby-electric-space/return-fallback))))

(defun ruby-electric--get-faces-at-point ()
  (let* ((point (point))
         (value (or
                 (get-text-property point 'read-face-name)
                 (get-text-property point 'face))))
    (if (listp value) value (list value))))

(defun ruby-electric--faces-at-point-include-p (&rest faces)
  (and ruby-electric-mode
       (loop for face in faces
             with pfaces = (ruby-electric--get-faces-at-point)
             thereis (memq face pfaces))))

(defun ruby-electric-code-at-point-p()
  (not (ruby-electric--faces-at-point-include-p
        'font-lock-string-face
        'font-lock-comment-face)))

(defun ruby-electric-string-at-point-p()
  (ruby-electric--faces-at-point-include-p
   'font-lock-string-face))

(defun ruby-electric-comment-at-point-p()
  (ruby-electric--faces-at-point-include-p
   'font-lock-comment-face))

(defun ruby-electric-escaped-p()
  (let ((f nil))
    (save-excursion
      (while (char-equal ?\\ (preceding-char))
        (backward-char 1)
        (setq f (not f))))
    f))

(defun ruby-electric-command-char-expandable-punct-p(char)
  (or (memq 'all ruby-electric-expand-delimiters-list)
      (memq char ruby-electric-expand-delimiters-list)))

(defun ruby-electric-space/return-can-be-expanded-p()
  (and (ruby-electric-code-at-point-p)
       (looking-back ruby-electric-expandable-keyword-re)))

(defun ruby-electric-replace-region-or-insert ()
  (and (region-active-p)
       (bound-and-true-p delete-selection-mode)
       (fboundp 'delete-selection-helper)
       (delete-selection-helper (get 'self-insert-command 'delete-selection)))
  (insert (make-string (prefix-numeric-value current-prefix-arg)
                       last-command-event))
  (setq this-command 'self-insert-command))

(defmacro ruby-electric-insert (arg &rest body)
  `(cond ((and
           (null ,arg)
           (ruby-electric-command-char-expandable-punct-p last-command-event))
          (let ((region-beginning
                 (cond ((region-active-p)
                        (prog1
                            (save-excursion
                              (goto-char (region-beginning))
                              (insert last-command-event)
                              (point))
                          (goto-char (region-end))))
                       (t
                        (insert last-command-event)
                        nil))))
            ,@body
            (and region-beginning
                 ;; If no extra character is inserted, go back to the
                 ;; region beginning.
                 (eq this-command 'self-insert-command)
                 (goto-char region-beginning))))
         ((ruby-electric-replace-region-or-insert))))

(defun ruby-electric-curlies (arg)
  (interactive "*P")
  (ruby-electric-insert
   arg
   (cond
    ((ruby-electric-code-at-point-p)
     (save-excursion
       (insert "}")
       (font-lock-fontify-region (line-beginning-position) (point)))
     (cond
      ((ruby-electric-string-at-point-p) ;; %w{}, %r{}, etc.
       (if region-beginning
           (forward-char 1)))
      (ruby-electric-newline-before-closing-bracket
       (cond (region-beginning
              (save-excursion
                (goto-char region-beginning)
                (newline))
              (newline)
              (forward-char 1)
              (indent-region region-beginning (line-end-position)))
             (t
              (insert " ")
              (save-excursion
                (newline)
                (ruby-indent-line t)))))
      (t
       (if region-beginning
           (save-excursion
             (goto-char region-beginning)
             (insert " "))
         (insert " "))
       (insert " ")
       (and region-beginning
            (forward-char 1)))))
    ((ruby-electric-string-at-point-p)
     (let ((start-position (1- (or region-beginning (point)))))
       (cond
        ((char-equal ?\# (char-before start-position))
         (unless (save-excursion
                   (goto-char (1- start-position))
                   (ruby-electric-escaped-p))
           (insert "}")
           (or region-beginning
               (backward-char 1))))
        ((or
          (ruby-electric-command-char-expandable-punct-p ?\#)
          (save-excursion
            (goto-char start-position)
            (ruby-electric-escaped-p)))
         (if region-beginning
             (goto-char region-beginning))
         (setq this-command 'self-insert-command))
        (t
         (save-excursion
           (goto-char start-position)
           (insert "#"))
         (insert "}")
         (or region-beginning
             (backward-char 1))))))
    (t
     (delete-char -1)
     (ruby-electric-replace-region-or-insert)))))

(defun ruby-electric-hash (arg)
  (interactive "*P")
  (ruby-electric-insert
   arg
   (if (ruby-electric-string-at-point-p)
       (let ((start-position (1- (or region-beginning (point)))))
         (cond
          ((char-equal (following-char) ?')) ;; likely to be in ''
          ((save-excursion
             (goto-char start-position)
             (ruby-electric-escaped-p)))
          (region-beginning
           (save-excursion
             (goto-char (1+ start-position))
             (insert "{"))
           (insert "}"))
          (t
           (insert "{")
           (save-excursion
             (insert "}")))))
     (delete-char -1)
     (ruby-electric-replace-region-or-insert))))

(defun ruby-electric-matching-char (arg)
  (interactive "*P")
  (ruby-electric-insert
   arg
   (let ((closing (cdr (assoc last-command-event
                              ruby-electric-matching-delimeter-alist))))
     (cond
      ;; quotes
      ((char-equal closing last-command-event)
       (cond ((let ((start-position (or region-beginning (point))))
                ;; check if this quote has just started a string
                (and
                 (unwind-protect
                     (save-excursion
                       (subst-char-in-region (1- start-position) start-position
                                             last-command-event ?\s)
                       (goto-char (1- start-position))
                       (save-excursion
                         (font-lock-fontify-region (line-beginning-position) (1+ (point))))
                       (not (ruby-electric-string-at-point-p)))
                     (subst-char-in-region (1- start-position) start-position
                                           ?\s last-command-event))
                 (save-excursion
                   (goto-char (1- start-position))
                   (save-excursion
                     (font-lock-fontify-region (line-beginning-position) (1+ (point))))
                   (ruby-electric-string-at-point-p))))
              (if region-beginning
                  ;; escape quotes of the same kind, backslash and hash
                  (let ((re (format "[%c\\%s]"
                                    last-command-event
                                    (if (char-equal last-command-event ?\")
                                        "#" "")))
                        (bound (point)))
                    (save-excursion
                      (goto-char region-beginning)
                      (while (re-search-forward re bound t)
                        (let ((end (point)))
                          (replace-match "\\\\\\&")
                          (setq bound (+ bound (- (point) end))))))))
              (insert closing)
              (or region-beginning
                  (backward-char 1)))
             (t
              (and (eq last-command 'ruby-electric-matching-char)
                   (char-equal (following-char) closing) ;; repeated quotes
                   (delete-char 1))
              (setq this-command 'self-insert-command))))
      ((ruby-electric-code-at-point-p)
       (insert closing)
       (or region-beginning
           (backward-char 1)))))))

(defun ruby-electric-closing-char(arg)
  (interactive "*P")
  (cond
   (arg
    (ruby-electric-replace-region-or-insert))
   ((and
     (eq last-command 'ruby-electric-curlies)
     (= last-command-event ?})
     (not (char-equal (preceding-char) last-command-event))) ;; {}
    (if (char-equal (following-char) ?\n) (delete-char 1))
    (delete-horizontal-space)
    (forward-char))
   ((and
     (= last-command-event (following-char))
     (not (char-equal (preceding-char) last-command-event))
     (memq last-command '(ruby-electric-matching-char
                          ruby-electric-closing-char))) ;; ()/[] and (())/[[]]
    (forward-char))
   (t
    (ruby-electric-replace-region-or-insert)
    (if ruby-electric-autoindent-on-closing-char
        (ruby-indent-line)))))

(defun ruby-electric-bar(arg)
  (interactive "*P")
  (ruby-electric-insert
   arg
   (cond ((and (ruby-electric-code-at-point-p)
               (looking-back ruby-electric-expandable-bar-re))
          (save-excursion (insert "|")))
         (t
          (delete-char -1)
          (ruby-electric-replace-region-or-insert)))))

(defun ruby-electric-delete-backward-char(arg)
  (interactive "*p")
  (cond ((memq last-command '(ruby-electric-matching-char
                              ruby-electric-bar))
         (delete-char 1))
        ((eq last-command 'ruby-electric-curlies)
         (cond ((eolp)
                (cond ((char-equal (preceding-char) ?\s)
                       (setq this-command last-command))
                      ((char-equal (preceding-char) ?{)
                       (and (looking-at "[ \t\n]*}")
                            (delete-char (- (match-end 0) (match-beginning 0)))))))
               ((char-equal (following-char) ?\s)
                (setq this-command last-command)
                (delete-char 1))
               ((char-equal (following-char) ?})
                (delete-char 1))))
        ((eq last-command 'ruby-electric-hash)
         (and (char-equal (preceding-char) ?{)
              (delete-char 1))))
  (delete-char (- arg)))

(put 'ruby-electric-delete-backward-char 'delete-selection 'supersede)

(defun ruby-electric-end ()
  (interactive)
  (if (eq (char-syntax (preceding-char)) ?w)
      (insert " "))
  (insert "end")
  (save-excursion
    (if (eq (char-syntax (following-char)) ?w)
        (insert " "))
    (ruby-indent-line t)))

(provide 'ruby-electric)

;;; ruby-electric.el ends here
