;;; fish-mode.el --- Major mode for fish shell scripts  -*- lexical-binding: t; -*-

;; Copyright (C) 2014  Tony Wang

;; Author: Tony Wang <wwwjfy@gmail.com>
;; Keywords: Fish, shell
;; Package-Requires: ((emacs "24"))

;; 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:

;; A very basic version of major mode for fish shell scripts.
;; Current features:
;;
;;  - keyword highlight
;;  - basic indent
;;  - comment detection
;;  - run fish_indent for indention
;;
;;  To run fish_indent before save, add the following to init script:
;;  (add-hook 'fish-mode-hook (lambda ()
;;                              (add-hook 'before-save-hook 'fish_indent-before-save)))
;;
;;  Configuration:
;;  fish-indent-offset: customize the indentation

;;; Code:

(require 'cl-lib)
(eval-when-compile (require 'subr-x))

(defgroup fish nil
  "Fish shell support."
  :group 'languages)

(defcustom fish-indent-offset 4
  "Default indentation offset for Fish."
  :group 'fish
  :type 'integer
  :safe 'integerp)

(defvar fish-enable-auto-indent nil
  "Controls auto-indent feature.
If the value of this variable is non-nil, whenever a word in
`fish-auto-indent-trigger-keywords' is typed, it is indented instantly.")

(unless (fboundp 'setq-local)
  (defmacro setq-local (var val)
    "Set variable VAR to value VAL in current buffer."
    `(set (make-local-variable ',var) ,val)))

;;; Syntax highlighting
(defconst fish-builtins
  (list
   "alias"
   "argparse"
   "bg"
   "bind"
   "block"
   "breakpoint"
   "builtin"
   "cd"
   "commandline"
   "command"
   "complete"
   "contains"
   "count"
   "dirh"
   "dirs"
   "disown"
   "echo"
   "emit"
   "exec"
   "fg"
   "fish_config"
   "fishd"
   "fish_indent"
   "fish_pager"
   "fish_prompt"
   "fish_right_prompt"
   "fish"
   "fish_update_completions"
   "funced"
   "funcsave"
   "functions"
   "help"
   "history"
   "isatty"
   "jobs"
   "math"
   "mimedb"
   "nextd"
   "open"
   "popd"
   "prevd"
   "printf"
   "psub"
   "pushd"
   "pwd"
   "random"
   "read"
   "realpath"
   "set_color"
   "source"
   "status"
   "string"
   "trap"
   "type"
   "ulimit"
   "umask"
   "vared"
   ))
(defconst fish-keywords
  (list
   "&&"
   "||"
   "and"
   "begin"
   "break"
   "case"
   "continue"
   "else"
   "end"
   "eval"
   "exit"
   "false"
   "for"
   "function"
   "if"
   "or"
   "return"
   "set"
   "switch"
   "test"
   "true"
   "while"
   ))
(defconst fish-font-lock-keywords-1
  (list

   ;; Builtins
   `( ,(rx-to-string `(and
                       symbol-start
                       (eval `(or ,@fish-builtins))
                       symbol-end)
                     t)
      .
      font-lock-builtin-face)

   ;; Keywords
   `( ,(rx-to-string `(and
                       symbol-start
                       (eval `(or ,@fish-keywords))
                       symbol-end)
                     t)
      .
      font-lock-keyword-face)

   ;; Backslashes

   ;; This doesn't highlight backslashes inside strings.  I guess this
   ;; is a limitation of using the regexp-based syntax highlighting.
   ;; Also, using `(rx (symbol escape))' doesn't match them, even
   ;; though I tried adding backslashes to the syntax table as escape
   ;; chars.
   `( ,(rx
        "\\")
      .
      font-lock-negation-char-face)

   ;; Function definitions

   ;; Using form:
   ;;
   ;; (MATCHER MATCH-HIGHLIGHT MATCH-ANCHORED)
   ;;
   ;; The help for "font-lock-keywords" seems to have an error in
   ;; which it would mean to use the form "(MATCHER MATCH-ANCHORED)",
   ;; which would leave off the MATCH-HIGHLIGHT for the first MATCHER.
   ;; However, the example in the help shows the correct form, which
   ;; is used here.

   ;; It would be nice to highlight less-important options like
   ;; "description" differently than important ones like "on-event",
   ;; but I haven't been able to get it working. If I divide the
   ;; options into two groups, each group is only matched in order
   ;; (i.e. if an option in the second group appears before an option
   ;; in the first group, it doesn't match at all). The help for
   ;; font-lock-keywords doesn't mention anything about matching
   ;; subsequent MATCH-ANCHORED expressions in order, but it appears
   ;; to do so.
   `( ,(rx symbol-start
           "function"
           (1+ space)
           ;; Function name
           (group (1+ (or alnum (syntax symbol))))
           symbol-end)
      (1 font-lock-function-name-face)
      ;; Function options
      (,(rx (group symbol-start
                   (repeat 1 2 "-")
                   (1+ (or alnum (syntax symbol)))
                   symbol-end))
       nil nil
       (1 font-lock-negation-char-face)))

   ;; Variable definition
   `( ,(rx
        symbol-start
        "set"
        (1+ space)
        (optional "-" (repeat 1 2 letter) (1+ space))
        (group (1+ (or alnum (syntax symbol)))))
      1
      font-lock-variable-name-face)

   ;; For loops
   `( ,(rx
        ;; Beginning of command or line
        (or line-start
            ";")
        (0+ space)
        ;; "for" keyword
        "for"
        (1+ space)
        ;; variable name
        (group (1+ (or alnum
                       (syntax symbol))))
        (1+ space)
        ;; "in"
        (group "in")
        (1+ space)
        ;; list
        (group (or
                ;; plain list
                (1+ (or alnum
                        (syntax symbol)
                        space))
                ;; process substitution
                (and
                 (optional "$")
                 (syntax open-parenthesis)
                 (+? anything)
                 (syntax close-parenthesis)))))
      (1 font-lock-variable-name-face)
      (2 font-lock-keyword-face)
      (3 font-lock-string-face t))

   ;; Variable substitution
   `( ,(rx
        (group "$") (group (1+ (or alnum (syntax symbol)))) symbol-end)
      (1 font-lock-string-face)
      (2 font-lock-variable-name-face))

   ;; Negation
   `( ,(rx symbol-start
           (or (and (group "not")
                    symbol-end)))
      1
      font-lock-negation-char-face)

   ;; "set" options
   `( ,(rx symbol-start (and "set"
                             (1+ space)
                             (group (and "-" (repeat 1 2 letter)))
                             (1+ space)))
      1
      font-lock-negation-char-face)

   ;; Process substitution
   `( ,(rx
        (1+ space)
        (optional "$")
        (syntax open-parenthesis)
        ;; command name
        (group (1+ (or alnum (syntax symbol))))
        (0+ not-newline)
        (syntax close-parenthesis))
      1
      ;; It would be nice to use the sh-quoted-exec face, but it's only
      ;; available in sh-mode
      font-lock-builtin-face)

   ;; Process substitution in the form of $()
   `( "\\$(\\([^)\n]+\\)" 1 font-lock-builtin-face prepend)

   ;; Important characters
   `( ,(rx symbol-start
           (or (any "|&")
               (syntax escape))
           )
      .
      font-lock-negation-char-face)

   ;; Redirection
   `( ,(rx
        (1+ space)
        (group
         (any "><^")
         (optional "&")
         (optional (1+ space))
         (1+ (not (any space)))))
      .
      font-lock-negation-char-face)

   ;; Command name
   `( ,(rx-to-string
        `(and
          (or line-start  ;; new line
              ";" ;; new command
              "&"  ;; background
              "|") ;; pipe
          (0+ space)
          (optional (eval `(or ,@fish-keywords))
                    (1+ space))
          (group (1+ (or alnum (syntax symbol))))
          symbol-end)
        t)
      1
      font-lock-builtin-face)

   ;; Numbers
   `( ,(rx symbol-start (optional "-")(1+ (or digit (char ?.))) symbol-end)
      .
      font-lock-constant-face)))

(defvar fish-mode-syntax-table
  (let ((tab (make-syntax-table text-mode-syntax-table)))
    (modify-syntax-entry ?\# "<" tab)
    (modify-syntax-entry ?\n ">" tab)
    (modify-syntax-entry ?\" "\"\"" tab)
    (modify-syntax-entry ?\' "\"'" tab)
    (modify-syntax-entry ?\\ "\\" tab)
    (modify-syntax-entry ?$ "'" tab)
    tab)
  "Syntax table for `fish-mode'.")

(defvar fish-auto-indent-trigger-keywords
  '("end"
    "else"
    "case")
  "Keywords that should trigger auto-indent.")

(defvar fish/auto-indent-trigger-events
  (let ((trigger-letters
         (mapcar (lambda (x)
                   (substring x -1 nil))
                 fish-auto-indent-trigger-keywords)))
    (listify-key-sequence
     (string-join (delete-dups trigger-letters))))
  "List of key events that should trigger auto-indent.")

(defvar fish/auto-indent-trigger-regexps
  (mapcar (lambda (x)
            (concat "[ \t]*" x "\\>"))
          fish-auto-indent-trigger-keywords)
  "List of regexps used to determine whether or not to trigger auto-indent.")

;;; Indentation helpers

(defvar fish/block-opening-terms-re
  (rx symbol-start
      (or "if"
          "function"
          "while"
          "for"
          "begin"
          "switch")
      symbol-end)
  "Regular expression matching block opening terms.")

(defun fish/current-line ()
  "Return the line at point as a string."
  (buffer-substring (line-beginning-position) (line-end-position)))

(defun fish/fold (f x list)
  "Recursively applies (F i j) to LIST starting with X.
For example, (fold F X \\='(1 2 3)) computes (F (F (F X 1) 2) 3)."
  (let ((li list) (x2 x))
    (while li
      (setq x2 (funcall f x2 (pop li))))
    x2))

(defun fish/count-tokens-on-current-line (positive-re &optional negative-re)
  "Return count of matches for POSITIVE-RE
   that do not also match NEGATIVE-RE on current line.
POSITIVE-RE and NEGATIVE-RE are regular expressions."
  (cl-flet ((count-matches (re)
                           (save-excursion
                             (cl-loop initially (goto-char (line-beginning-position))
                                      while (re-search-forward re (line-end-position) t)
                                      for syntax = (syntax-ppss)
                                      count (not (or
                                                  ;; String
                                                  (nth 3 syntax)
                                                  ;; Comment
                                                  (nth 4 syntax)))))))
    (let ((positive-count (count-matches positive-re))
          (negative-count (when negative-re
                            (count-matches negative-re))))
      (if negative-re
          (- positive-count negative-count)
        positive-count))))

(defun fish/at-comment-line? ()
  "Returns t if looking at comment line, nil otherwise."
  (looking-at "[ \t]*#"))

(defun fish/at-empty-line? ()
  "Returns t if looking at empty line, nil otherwise."
  (looking-at "[ \t]*$"))

(defun fish/count-of-opening-terms ()
  (fish/count-tokens-on-current-line fish/block-opening-terms-re
                                     (rx symbol-start "else if" symbol-end)))

(defun fish/count-of-end-terms ()
  (fish/count-tokens-on-current-line (rx symbol-start "end" symbol-end) nil))

(defun fish/at-open-block? ()
  "Returns t if line contains block opening term
   that is not closed in the same line, nil otherwise."
  (> (fish/count-of-opening-terms)
     (fish/count-of-end-terms)))

(defun fish/at-open-end? ()
  "Returns t if line contains `end' term and
   doesn't contain block opening term that matches
   this `end' term. Returns nil otherwise."
  (> (fish/count-of-end-terms)
     (fish/count-of-opening-terms)))

(defun fish/line-contains-open-switch-term? ()
  "Returns t if line contains switch term, nil otherwise."
  (> (fish/count-tokens-on-current-line (rx symbol-start "switch" symbol-end) nil)
     (fish/count-of-end-terms)))

;;; Indentation

(defun fish-indent-line ()
  "Indent current line."
  ;; start calculating indentation level
  (let ((cur-indent 0)      ; indentation level for current line
        (rpos (- (point-max)
                 (point)))) ; used to move point after indentation :: todo - check if it's possible to avoid this variable
    (save-excursion
      ;; go to beginning of line
      (beginning-of-line)
      ;; check if already at the beginning of buffer
      (unless (bobp)
        (cond
         ;; found comment line
         ;; cur-indent is based on previous non-empty and non-comment line
         ;; todo - answer why we can't move it to default case
         ((fish/at-comment-line?)
          (setq cur-indent (fish-get-normal-indent)))

         ;; found line that starts with 'end'
         ;; this is a special case
         ;; so get indentation level
         ;; from 'fish-get-end-indent function
         ((looking-at "[ \t]*end\\>")
          (setq cur-indent (fish-get-end-indent)))

         ;; found line that stats with 'case'
         ;; this is a special case
         ;; so get indentation level
         ;; from 'fish-get-case-indent
         ((looking-at "[ \t]*case\\>")
          (setq cur-indent (fish-get-case-indent)))

         ;; found line that starts with 'else'
         ;; cur-indent is previous non-empty and non-comment line
         ;; minus fish-indent-offset
         ((looking-at "[ \t]*else\\>")
          (setq cur-indent (- (fish-get-normal-indent) fish-indent-offset)))

         ;; default case
         ;; cur-indent equals to indentation level of previous
         ;; non-empty and non-comment line
         (t (setq cur-indent (fish-get-normal-indent))))))

    ;; before indenting check cur-indent for negative level
    (if (< cur-indent 0) (setq cur-indent 0))

    ;; indent current line
    (indent-line-to cur-indent)

    ;; shift point to respect previous position
    (if (> (- (point-max) rpos) (point))
        (goto-char (- (point-max) rpos)))))

(defun fish-get-normal-indent ()
  "Returns indentation level based on previous non-empty and non-comment line."
  (catch :indent
    (cl-labels ((back-to-non-continued
                 () (cl-loop do (forward-line -1)
                             while (and (not (bobp)) (line-continued-p))))
                (line-continued-p
                 () (save-excursion
                      (forward-line -1)
                      (looking-at-p (rx (1+ nonl) "\\" eol)))))
      (while (not (bobp))
        (if (line-continued-p)
            ;;  Line is continued: return indentation of previous
            ;;  non-continued line.
            (progn
              (back-to-non-continued)
              (throw :indent (+ (current-indentation) fish-indent-offset)))
          ;; Line is not continued.  Get indentation from previous lines.

          ;; NOTE: Move to previous line.  The rest of this loop
          ;; refers to the previous line, not the one whose
          ;; indentation is being returned.
          (forward-line -1)

          (cond
           ((fish/at-empty-line?)
            ;; Return indentation of previous non-continued line.
            (back-to-non-continued)
            (throw :indent (current-indentation)))

           ((fish/at-comment-line?)
            ;;  Return current indentation.
            (throw :indent (current-indentation)))

           ((fish/at-open-block?)
            ;; Return one-deeper level of indentation.
            (throw :indent (+ (current-indentation) fish-indent-offset)))

           ((looking-at-p "[ \t]*\\(else\\|case\\)\\>")
            ;; Return one-deeper level of indentation.
            (throw :indent (+ (current-indentation) fish-indent-offset)))

           ((looking-at-p "[ \t]*end\\>")
            ;; Closing block: return current indentation.
            (throw :indent (current-indentation)))

           ;; found a line that contains open 'end' term
           ;; and doesn't start with 'end' (the order matters!)
           ;; it means that this 'end' is indented to the right
           ;; so we need to decrease indentation level
           ((fish/at-open-end?)
            (throw :indent (- (current-indentation) fish-indent-offset)))

           ((line-continued-p)
            ;;  Return indentation of previous non-continued line.
            (back-to-non-continued)
            (throw :indent (current-indentation)))

           (t
            ;;  Return current indentation.
            (throw :indent (current-indentation)))))))))

(defun fish-get-end-indent ()
  "Returns indentation level based on matching block opening term."
  (let ((cur-indent 0)
        (count-of-ends 1))
    (while (not (or (eq count-of-ends 0)
                    (bobp)))

      ;; move to previous line
      (forward-line -1)

      (cond
       ;; found empty line, so just skip it
       ((fish/at-empty-line?))

       ;; found comment line, so just skip it
       ((fish/at-comment-line?))

       ;; we found the line that contains unmatched
       ;; block opening term so decrease the count of end terms
       ((fish/at-open-block?)
        (setq count-of-ends (- count-of-ends 1))
        ;; when count of end terms is zero
        ;; it means that we found matching term that
        ;; opens block
        ;; so cur-indent equals to inden equals to
        ;; indentation level of current line
        (when (eq count-of-ends 0)
          (setq cur-indent (current-indentation))))

       ;; we found new end term
       ;; so just increase the count of end terms
       ((fish/at-open-end?)
        (setq count-of-ends (+ count-of-ends 1)))

       ;; do nothing
       (t)))

    ;; it means that we didn't found a matching pair
    ;; for 'end' term
    (unless (eq count-of-ends 0)
      (error "Found unmatched 'end' term."))

    cur-indent))

(defun fish-get-case-indent ()
  "Returns indentation level based on matching `switch' term."
  (let ((cur-indent 0)
        (not-indented t)
        (count-of-ends 0))
    (while (and not-indented
                (not (bobp)))
      ;; move to previous line
      (forward-line -1)

      (cond
       ;; found empty line, so just skip it
       ((fish/at-empty-line?))

       ;; found comment line, so just skip it
       ((fish/at-comment-line?))

       ;; the following two conditions are to check embeded switch term
       ((and
         (> (fish/count-of-opening-terms) 0)
         (> count-of-ends 0))
        (setq count-of-ends (1- count-of-ends)))

       ((fish/at-open-end?)
        (setq count-of-ends (1+ count-of-ends)))


       ;; line contains switch term
       ;; so cur-indent equals to increased
       ;; indentation level of current line
       ((fish/line-contains-open-switch-term?)
        (setq cur-indent (+ (current-indentation) fish-indent-offset)
              not-indented nil))

       ;; do nothing
       (t)))

    ;; it means that we didn't find a matching pair
    (when not-indented
      (error "Found 'case' term without matching 'switch' term"))

    cur-indent))

(defun fish/auto-indent ()
  "Auto-indent when a word in FISH-AUTO-INDENT-TRIGGER-KEYWORDS is typed."
  ;; check last-command-event first because it's less expensive than
  ;; string-match
  (when (member last-command-event fish/auto-indent-trigger-events)
    ;; next check whether the line matches a regexp in
    ;; fish/auto-indent-trigger-regexps
    (when (cl-some (lambda (x)
                  (string-match x (thing-at-point 'line)))
                fish/auto-indent-trigger-regexps)
      (fish-indent-line)
      (fish/auto-indent-post-indent-check (line-number-at-pos)))))

(defun fish/auto-indent-post-indent-check (line-num)
  "Handle next key event after auto-indenting
   by re-indenting if we're still on line LINE-NUM."
  (add-hook 'post-self-insert-hook
            (defun fish/post-auto-indent ()
              (when (= (line-number-at-pos) line-num)
                (fish-indent-line))
              (remove-hook 'post-self-insert-hook
                           'fish/post-auto-indent))
            nil t))

;;; fish_indent
(defun fish_indent ()
  "Indent current buffer using fish_indent"
  (interactive)
  (let ((current-point (point)))
    (call-process-region (point-min) (point-max) "fish_indent" t t nil)
    (goto-char current-point)
    ))

;;; Mode definition

;;;###autoload
(defun fish_indent-before-save ()
  (interactive)
  (when (eq major-mode 'fish-mode) (fish_indent)))

;;;###autoload
(define-derived-mode fish-mode prog-mode "Fish"
  "Major mode for editing fish shell files."
  :syntax-table fish-mode-syntax-table
  (setq-local indent-line-function 'fish-indent-line)
  (setq-local font-lock-defaults '(fish-font-lock-keywords-1))
  (setq-local comment-start "# ")
  (setq-local comment-start-skip "#+[\t ]*")
  (when fish-enable-auto-indent
    (add-hook 'post-self-insert-hook 'fish/auto-indent nil t)))

;;;###autoload
(add-to-list 'auto-mode-alist '("\\.fish\\'" . fish-mode))
;;;###autoload
(add-to-list 'auto-mode-alist '("/fish_funced\\..*\\'" . fish-mode))
;;;###autoload
(add-to-list 'interpreter-mode-alist '("fish" . fish-mode))

(provide 'fish-mode)

;;; fish-mode.el ends here
