;;; nix-mode.el --- Major mode for editing .nix files -*- lexical-binding: t -*-

;; Maintainer: Matthew Bauer <mjbauer95@gmail.com>
;; Homepage: https://github.com/NixOS/nix-mode
;; Version: 1.5.0
;; Keywords: nix, languages, tools, unix
;; Package-Requires: ((emacs "27.1") magit-section reformatter (transient "0.3"))

;; This file is NOT part of GNU Emacs.

;;; Commentary:

;; A major mode for editing Nix expressions (.nix files).  See the Nix manual
;; for more information available at https://nixos.org/nix/manual/.

;;; Code:

(require 'nix)
(require 'smie)
(require 'ffap)
(eval-when-compile (require 'subr-x))

(defgroup nix-mode nil
  "Nix mode customizations."
  :group 'nix)

(defcustom nix-indent-function 'smie-indent-line
  "The function to use to indent.

Valid functions for this are:

- ‘indent-relative’
- ‘nix-indent-line' (buggy)
- `smie-indent-line' (‘nix-mode-use-smie’ must be enabled)"
  :group 'nix-mode
  :type 'function)

(defcustom nix-mode-use-smie t
  "Whether to use SMIE when editing Nix files.
This is enabled by default, but can take a while to load with
very large Nix files (all-packages.nix)."
  :group 'nix-mode
  :type 'boolean)

(defgroup nix-faces nil
  "Nix faces."
  :group 'nix-mode
  :group 'faces)

(defface nix-keyword-face
  '((t :inherit font-lock-keyword-face))
  "Face used to highlight Nix keywords."
  :group 'nix-faces)

(defface nix-keyword-warning-face
  '((t :inherit font-lock-warning-face))
  "Face used to highlight Nix warning keywords."
  :group 'nix-faces)

(defface nix-builtin-face
  '((t :inherit font-lock-builtin-face))
  "Face used to highlight Nix builtins."
  :group 'nix-faces)

(defface nix-constant-face
  '((t :inherit font-lock-constant-face))
  "Face used to highlight Nix constants."
  :group 'nix-faces)

(defface nix-attribute-face
  '((t :inherit font-lock-variable-name-face))
  "Face used to highlight Nix attributes."
  :group 'nix-faces)

(defface nix-antiquote-face
  '((t :inherit font-lock-preprocessor-face))
  "Face used to highlight Nix antiquotes."
  :group 'nix-faces)

(defface nix-store-path-face nil
  "Face used to highlight Nix store paths."
  :group 'nix-faces)

(defface nix-store-path-realised-face
  '((t :inherit nix-store-path-face))
  "Face used to highlight realised Nix store paths."
  :group 'nix-faces)

(defface nix-store-path-unrealised-face
  '((t :inherit nix-store-path-face))
  "Face used to highlight unrealised Nix store paths."
  :group 'nix-faces)

;;; Constants

(defconst nix-system-types
  '("x86_64-linux" "i686-linux" "aarch64-linux" "x86_64-darwin")
  "List of supported systems.")

(defconst nix-keywords
  '("if" "then"
    "else" "with"
    "let" "in"
    "rec" "inherit"
    "or"))

(defconst nix-builtins
  '("builtins" "baseNameOf"
    "derivation" "dirOf"
    "true" "false" "null"
    "isNull" "toString"
    "fetchTarball" "import"
    "map" "removeAttrs"
    "toString" "derivationStrict" "placeholder" "scopedImport" "fromTOML"
    "fetchTarball" "fetchGit" "fetchTree" "fetchMercurial"))

(defconst nix-warning-keywords
  '("assert" "abort" "throw"))

;;; Regexps

(defconst nix-re-file-path
  "[a-zA-Z0-9._+-]*\\(/[a-zA-Z0-9._+-]+\\)+")

(defconst nix-re-url
  "[a-zA-Z][a-zA-Z0-9+.-]*:[a-zA-Z0-9%/?:@&=+$,_.!~*'-]+")

(defconst nix-re-bracket-path
  "<[a-zA-Z0-9._+-]+\\(/[a-zA-Z0-9._+-]+\\)*>")

(defconst nix-re-variable-assign
  "\\<\\([a-zA-Z_][a-zA-Z0-9_'.-]*\\)[ \t]*=[^=]")

(defconst nix-re-quotes "''\\|\"")

(defun nix-re-keywords (keywords)
  "Produce a regexp matching some keywords of Nix.
KEYWORDS a list of strings to match as Nix keywords."
  (concat
   "\\(?:[[:space:];:{}()]\\|^\\)"
   (regexp-opt keywords t)
   "\\(?:[[:space:];:{}()]\\|$\\)"
   ))

(defun nix--keyword-matcher (regexp)
  "Return a font-lock matcher searching for REGEXP.
Leaves point at the end of the keyword (group 1) instead of after
the trailing delimiter, so the delimiter can still serve as the
leading delimiter of an adjacent keyword, as in \"else if\"."
  (lambda (limit)
    (when (re-search-forward regexp limit t)
      (goto-char (match-end 1))
      t)))

(defconst nix-font-lock-keywords
  `((,(nix--keyword-matcher (nix-re-keywords nix-keywords)) 1 'nix-keyword-face)
    (,(nix--keyword-matcher (nix-re-keywords nix-warning-keywords)) 1 'nix-keyword-warning-face)
    (,(nix--keyword-matcher (nix-re-keywords nix-builtins)) 1 'nix-builtin-face)
    (,nix-re-url 0 'nix-constant-face)
    (,nix-re-file-path 0 'nix-constant-face)
    (,nix-re-variable-assign 1 'nix-attribute-face)
    (,nix-re-bracket-path 0 'nix-constant-face)
    (nix--syntax-match-antiquote 0 'nix-antiquote-face t))
  "Font lock keywords for nix.")

(defconst nix--variable-char "[a-zA-Z0-9_'-]")

(defvar nix-mode-abbrev-table
  (make-abbrev-table)
  "Abbrev table for Nix mode.")

(defvar nix-mode-syntax-table
  (let ((table (make-syntax-table)))
    (modify-syntax-entry ?/ ". 14" table)
    (modify-syntax-entry ?* ". 23" table)
    (modify-syntax-entry ?# "< b" table)
    (modify-syntax-entry ?\n "> b" table)
    (modify-syntax-entry ?_ "_" table)
    (modify-syntax-entry ?. "'" table)
    (modify-syntax-entry ?- "_" table)
    (modify-syntax-entry ?' "'" table)
    (modify-syntax-entry ?= "." table)
    (modify-syntax-entry ?< "." table)
    (modify-syntax-entry ?> "." table)
    ;; We handle strings
    (modify-syntax-entry ?\" "." table)
    ;; We handle escapes
    (modify-syntax-entry ?\\ "." table)
    table)
  "Syntax table for Nix mode.")

(defun nix--syntax-match-antiquote (limit)
  "Find antiquote within a Nix expression up to LIMIT."
  (unless (> (point) limit)
    (if (get-text-property (point) 'nix-syntax-antiquote)
        (progn
          (set-match-data (list (point) (1+ (point))))
          (forward-char 1)
          t)
      (let ((pos (next-single-char-property-change (point) 'nix-syntax-antiquote
                                                   nil limit)))
        (when (and pos (not (> pos limit)))
          (goto-char pos)
          (let ((char (char-after pos)))
            (pcase char
              (`?{
               (forward-char 1)
               (set-match-data (list (1- pos) (point)))
               t)
              (`?}
               (forward-char 1)
               (set-match-data (list pos (point)))
               t))))))))

(defun nix--mark-string (pos string-type)
  "Mark string as a Nix string.

POS position of start of string
STRING-TYPE type of string based off of Emacs syntax table types"
  (put-text-property pos (1+ pos)
                     'syntax-table (string-to-syntax "|"))
  (put-text-property pos (1+ pos)
                     'nix-string-type string-type))

(defun nix--get-parse-state (pos)
  "Get the result of `syntax-ppss' at POS."
  (save-excursion (save-match-data (syntax-ppss pos))))

(defun nix--get-string-type (parse-state)
  "Get the type of string based on PARSE-STATE."
  (let ((string-start (nth 8 parse-state)))
    (and string-start (get-text-property string-start 'nix-string-type))))

(defun nix--open-brace-string-type (parse-state)
  "Determine if this is an open brace string type based on PARSE-STATE."
  (let ((open-brace (nth 1 parse-state)))
    (and open-brace (get-text-property open-brace 'nix-string-type))))

(defun nix--open-brace-antiquote-p (parse-state)
  "Determine if this is an open brace antiquote based on PARSE-STATE."
  (let ((open-brace (nth 1 parse-state)))
    (and open-brace (get-text-property open-brace 'nix-syntax-antiquote))))

(defun nix--single-quotes ()
  "Handle Nix single quotes."
  (let* ((start (match-beginning 0))
         (end (match-end 0))
         (context (nix--get-parse-state start))
         (string-type (nix--get-string-type context)))
    (unless (or (equal string-type ?\")
                (and (equal string-type nil)
                     (< 1 start)
                     (string-match-p nix--variable-char
                                     (buffer-substring (1- start) start))))
      (when (equal string-type nil)
        (nix--mark-string start ?\')
        (setq start (+ 2 start)))
      (when (equal (mod (- end start) 3) 2)
        (let ((str-peek (buffer-substring end (min (point-max) (+ 2 end)))))
          (cond
           ((or (string-prefix-p "${" str-peek)
                (string-prefix-p "\\" str-peek))
            (goto-char (+ 2 end)))
           ((string-prefix-p "$" str-peek)
            (goto-char (1+ end)))
           (t
            (nix--mark-string (1- end) ?\'))))))))

(defun nix--escaped-dollar-sign-antiquote-sq-style ()
  "Hande Nix escaped dollar sign antiquote sq style."
  (let* ((start (match-beginning 0))
         (ps (nix--get-parse-state start))
	 (string-type (nix--get-string-type ps)))
    (when (equal string-type ?\")
      (nix--antiquote-open-at (+ start 2) ?\"))))

(defun nix--escaped-antiquote-dq-style ()
  "Handle Nix escaped antiquote dq style."
  (let* ((start (match-beginning 0))
         (ps (nix--get-parse-state start))
         (string-type (nix--get-string-type ps)))
    (when (equal string-type ?\')
      (nix--antiquote-open-at (1+ start) ?\'))))

(defun nix--double-quotes ()
  "Handle Nix double quotes."
  (let* ((pos (match-beginning 0))
         (ps (nix--get-parse-state pos))
         (string-type (nix--get-string-type ps)))
    (unless (equal string-type ?\')
      (nix--mark-string pos ?\"))))

(defun nix--antiquote-open-at (pos string-type)
  "Handle Nix antiquote open at based on POS and STRING-TYPE."
  (put-text-property pos (1+ pos)
                     'syntax-table (string-to-syntax "|"))
  (put-text-property pos (+ 2 pos)
                     'nix-string-type string-type)
  (put-text-property (1+ pos) (+ 2 pos)
                     'nix-syntax-antiquote t))

(defun nix--antiquote-open ()
  "Handle Nix antiquote open."
  (let* ((start (match-beginning 0))
         (ps (nix--get-parse-state start))
         (string-type (nix--get-string-type ps)))
    (when string-type
      (nix--antiquote-open-at start string-type))))

(defun nix--antiquote-close-open ()
  "Handle Nix antiquote close then open."
  (let* ((start (match-beginning 0))
         (ps (nix--get-parse-state start))
         (string-type (nix--get-string-type ps)))
    (if string-type
        (nix--antiquote-open-at (1+ start) string-type)
      (when (nix--open-brace-antiquote-p ps)
        (let ((string-type (nix--open-brace-string-type ps)))
          (put-text-property start (+ 3 start)
                             'nix-string-type string-type)
          (put-text-property start (1+ start)
                             'nix-syntax-antiquote t)
          (put-text-property (+ 2 start) (+ 3 start)
                             'nix-syntax-antiquote t))))))

(defun nix--antiquote-close ()
  "Handle Nix antiquote close."
  (let* ((start (match-beginning 0))
         (ps (nix--get-parse-state start)))
    (unless (or (nth 4 ps)
                (nix--get-string-type ps))
      (let ((string-type (nix--open-brace-string-type ps)))
        (when string-type
          (put-text-property start (1+ start)
                             'nix-string-type string-type)
          (put-text-property start (1+ start)
                             'nix-syntax-antiquote t)
          (let ((ahead (buffer-substring (1+ start)
                                         (min (point-max) (+ 5 start)))))
            (pcase string-type
              (`?\" (cond
                     ((or (string-match "^\\\\\"" ahead)
                          (string-match "^\\\\\\${" ahead))
                      (nix--mark-string (1+ start) string-type)
                      (goto-char (+ start (match-end 0) 1)))
                     ((string-match-p "^\"" ahead)
                      (goto-char (+ 2 start)))
                     ((< (1+ start) (point-max))
                      (nix--mark-string (1+ start) string-type)
                      (goto-char (+ 2 start)))))
              (`?\' (cond
                     ((or (string-match "^'''" ahead)
                          (string-match "^''\\${" ahead)
                          (string-match "^''\\\\\\(?:.\\|\n\\)" ahead))
                      (nix--mark-string (1+ start) string-type)
                      (goto-char (+ start (match-end 0) 1)))
                     ((string-match-p "^''" ahead)
                      (goto-char (+ 3 start)))
                     ((< (1+ start) (point-max))
                      (nix--mark-string (1+ start) string-type)
                      (goto-char (+ 2 start))))))))))))

(defun nix-syntax-propertize (start end)
  "Special syntax properties for Nix from START to END."
  (goto-char start)
  (remove-text-properties start end
                          '(nix-string-type nil nix-syntax-antiquote nil))
  (funcall
   (syntax-propertize-rules
    ("\\\\\\\\"
     (0 (ignore)))
    ("\\\\\""
     (0 (ignore)))
    ("\\$\\$"
     (0 (ignore)))
    ("\\\\\\$\\${"
     (0 (ignore (nix--escaped-dollar-sign-antiquote-sq-style))))
    ("\\\\\\${"
     (0 (ignore (nix--escaped-antiquote-dq-style))))
    ("'\\{2,\\}"
     (0 (ignore (nix--single-quotes))))
    ("}\\${"
     (0 (ignore (nix--antiquote-close-open))))
    ("\\${"
     (0 (ignore (nix--antiquote-open))))
    ("}"
     (0 (ignore (nix--antiquote-close))))
    ("\""
     (0 (ignore (nix--double-quotes)))))
   start end))

;; Indentation using SMIE

(defconst nix-smie-grammar
  (smie-prec2->grammar
   (smie-merge-prec2s
    (smie-bnf->prec2
     '((id)
       (expr (arg ":" expr)
             ("if" expr "then" expr "else" expr)
             ("let" decls "in" expr)
             ("with" expr "nonsep-;" expr)
             ("assert" expr "nonsep-;" expr)
             (attrset)
             (id))
       (attrset ("{" decls "}"))
       (decls (decls ";" decls)
              (id "=" expr))
       (arg (id) ("{" args "}"))
       (args (args "," args) (id "arg-?" expr)))
     '((assoc ";"))
     '((assoc ","))
     ;; resolve "(with foo; a) <op> b" vs "with foo; (a <op> b)"
     ;; in favor of the latter.
     '((nonassoc "nonsep-;") (nonassoc " -dummy- "))
     ;; resolve "(if ... then ... else a) <op> b"
     ;; vs "if ... then ... else (a <op> b)" in favor of the latter.
     '((nonassoc "in") (nonassoc " -dummy- ")))
    (smie-precs->prec2
     '((nonassoc " -dummy- ")
       (nonassoc "=")
       ;; " -bexpskip- " and " -fexpskip- " are handy tokens for skipping over
       ;; whole expressions.
       ;; For instance, suppose we have a line looking like this:
       ;; "{ foo.bar // { x = y }"
       ;; and point is at the end of the line. We can skip the whole
       ;; expression (i.e. so the point is just before "foo") using
       ;; `(smie-backward-sexp " -bexpskip- ")'. `(backward-sexp)' would
       ;; skip over "{ x = y }", not over the whole expression.
       (right " -bexpskip- ")
       (left " -fexpskip- ")
       (nonassoc "else")
       (right ":")
       (right "->")
       (assoc "||")
       (assoc "&&")
       (nonassoc "==" "!=")
       (nonassoc "<" "<=" ">" ">=")
       (left "//")
       (nonassoc "!")
       (assoc "-" "+")
       (assoc "*" "/")
       (assoc "++")
       (left "?")
       ;; Tokens for skipping sequences of sexps
       ;; (i.e. identifiers or balanced parens).
       ;; For instance, suppose we have a line looking like this:
       ;; "{ foo.bar // f x "
       ;; and point is at the end of the line. We can skip the "f x"
       ;; part by doing `(smie-backward-sexp " -bseqskip- ")'.
       (right " -bseqskip- ")
       (left " -fseqskip- "))))))

(defconst nix-smie--2char-symbols
  '("->" "||" "&&" "==" "!=" "<=" ">=" "++" "//"))

(defconst nix-smie--infix-symbols-re
  (concat "\\`"
	  (regexp-opt (append '(":" "<" ">" "-" "+" "*" "/" "?")
                              nix-smie--2char-symbols))
	  "\\'"))

(defconst nix-smie-indent-tokens-re
  (concat "\\`" (regexp-opt '("{" "(" "[" "=" "let" "if" "then" "else")) "\\'"))

;; The core indentation algorithm is very simple:
;; - If the last token on the previous line matches `nix-smie-indent-tokens-re',
;;   then the current line is indented by `tab-width' relative to the
;;   previous line's 'anchor'.
;; - Otherwise, let SMIE handle it.
;; The 'anchor' of a line is defined as follows:
;; - If the line contains an assignment, it is the beginning of the
;;   left-hand side of the first assignment on that line.
;; - Otherwise, it is the position of the first token on that line.
(defun nix-smie-rules (kind token)
  "Core smie rules."
  (pcase (cons kind token)
    (`(:after . ,(guard (string-match-p nix-smie-indent-tokens-re
                                        token)))
     (nix-smie--indent-anchor))
    (`(,_ . "in")
     (let ((bol (line-beginning-position)))
       (forward-word)
       ;; Go back to the corresponding "let".
       (smie-backward-sexp t)
       (pcase kind
         (:before
          (if (smie-rule-hanging-p)
              (nix-smie--indent-anchor 0)
            `(column . ,(current-column))))
         (:after
          (cond
           ((bolp) '(column . 0))
           ((<= bol (point))
            `(column . ,(current-column))))))))
    (`(:after . "nonsep-;")
     (forward-char)
     (backward-sexp)
     (if (smie-rule-bolp)
         `(column . ,(current-column))
       (nix-smie--indent-anchor)))
    (`(:after . ":")
     (or (nix-smie--indent-args-line)
         (nix-smie--indent-anchor)))
    (`(:after . ",")
     (smie-rule-parent tab-width))
    (`(:before . ",")
     ;; The parent is either the enclosing "{" or some previous ",".
     ;; In both cases this is what we want to align to.
     (smie-rule-parent))
    (`(:before . "if")
     (let ((bol (line-beginning-position)))
       (save-excursion
         (and
          (equal (nix-smie--backward-token) "else")
          (<= bol (point))
          `(column . ,(current-column))))))
    (`(:before . ,(guard (string-match-p nix-smie--infix-symbols-re token)))
     (forward-comment (- (point)))
     (let ((bol (line-beginning-position)))
       (smie-backward-sexp token)
       (if (< (point) bol)
           (nix-smie--indent-anchor 0))))))

(defun nix-smie--anchor ()
  "Return the anchor's offset from the beginning of the current line."
  (save-excursion
    (beginning-of-line)
    (let ((eol (line-end-position))
          anchor
          tok)
      (forward-comment (point-max))
      (unless (or (eobp) (< eol (point)))
        (setq anchor (current-column))
        (catch 'break
          (while (and (not (eobp))
                      (progn
                        (setq tok (car (smie-indent-forward-token)))
                        (<= (point) eol)))
            (when (equal "=" tok)
              (backward-char)
              (smie-backward-sexp " -bseqskip- ")
              (setq anchor (current-column))
              (throw 'break nil))))
        anchor))))

(defun nix-smie--indent-anchor (&optional indent)
  "Intended for use only in the rules function."
  (let ((indent (or indent tab-width)))
    `(column . ,(+ indent (nix-smie--anchor)))))

(defun nix-smie--indent-args-line ()
  "Indent the body of a lambda whose argument(s) are on a line of their own."
  (save-excursion
    ;; Assume that point is right before ':', skip it
    (forward-char)
    (let ((tok ":"))
      (catch 'break
        (while (equal tok ":")
          (setq tok (nth 2 (smie-backward-sexp t)))
          (when (smie-rule-bolp)
            (throw 'break `(column . ,(current-column)))))))))

(defconst nix-smie--path-chars "a-zA-Z0-9-+_.:/~")

(defun nix-smie--skip-angle-path-forward ()
  "Skip forward a path enclosed in angle brackets, e.g <nixpkgs>."
  (let ((start (point)))
    (when (eq (char-after) ?<)
      (forward-char)
      (if (and (nix-smie--skip-path 'forward t)
               (eq (char-after) ?>))
          (progn
            (forward-char)
            (buffer-substring-no-properties start (point)))
        (ignore (goto-char start))))))

(defun nix-smie--skip-angle-path-backward ()
  "Skip backward a path enclosed in angle brackets, e.g <nixpkgs>."
  (let ((start (point)))
    (when (eq (char-before) ?>)
      (backward-char)
      (if (and (nix-smie--skip-path 'backward t)
               (eq (char-before) ?<))
          (progn
            (backward-char)
            (buffer-substring-no-properties start (point)))
        (ignore (goto-char start))))))

(defun nix-smie--skip-path (how &optional no-sep-check)
  "Skip path related characters."
  (let ((start (point)))
    (pcase-exhaustive how
      ('forward (skip-chars-forward nix-smie--path-chars))
      ('backward (skip-chars-backward nix-smie--path-chars)))
    (let ((sub (buffer-substring-no-properties start (point))))
      (if (or (and no-sep-check
                   (< 0 (length sub)))
              (string-match-p "/" sub))
          sub
        (ignore (goto-char start))))))

;; Returns non-nil if it successfully skipped a symbol.
(defun nix-smie--skip-symbol (how)
  (let* ((start (point))
         (nskip (pcase-exhaustive how
                  ('backward (skip-syntax-backward "._"))
                  ('forward  (skip-syntax-forward "._"))))
         (abs-skip (abs nskip)))
    (or (= 1 abs-skip)
        (and (= 2 abs-skip)
             (member (buffer-substring-no-properties (point) start)
                     nix-smie--2char-symbols))
        (if (< 0 abs-skip)
            (goto-char (+ start (if (< 0 nskip) 1 -1)))
          (goto-char start)
          nil))))

(defun nix-smie--forward-token-1 ()
  "Move forward one token."
  (forward-comment (point-max))
  (or (nix-smie--skip-angle-path-forward)
      (nix-smie--skip-path 'forward)
      (buffer-substring-no-properties
       (point)
       (progn
         (or (/= 0 (skip-syntax-forward "'w_"))
             (nix-smie--skip-symbol 'forward))
         (point)))))

(defun nix-smie--forward-token ()
  "Move forward one token, skipping certain characters."
  (let ((sym (nix-smie--forward-token-1)))
    (if (member sym '(";" "?"))
        ;; The important lexer for indentation's performance is the backward
        ;; lexer, so for the forward lexer we delegate to the backward one.
        (save-excursion (nix-smie--backward-token))
      sym)))

(defun nix-smie--backward-token-1 ()
  "Move backward one token."
  (forward-comment (- (point)))
  (or (nix-smie--skip-angle-path-backward)
      (nix-smie--skip-path 'backward)
      (buffer-substring-no-properties
       (point)
       (progn
         (or (/= 0 (skip-syntax-backward "'w_"))
             (nix-smie--skip-symbol 'backward))
         (point)))))

(defun nix-smie--backward-token ()
  "Move backward one token, skipping certain characters."
  (let ((sym (nix-smie--backward-token-1)))
    (unless (zerop (length sym))
      (pcase sym
        (";" (if (nix-smie--nonsep-semicolon-p) "nonsep-;" ";"))
        ("?" (if (nix-smie--arg-?-p) "arg-?" "?"))
        (_ sym)))))

(defun nix-smie--nonsep-semicolon-p ()
  "Whether the semicolon at point terminates a `with' or `assert'."
  (save-excursion
    (member (nth 2 (smie-backward-sexp " -bexpskip- ")) '("with" "assert"))))

(defun nix-smie--arg-?-p ()
  "Whether the question mark at point is part of an argument declaration."
  (member
   (nth 2 (progn
            (smie-backward-sexp)
            (smie-backward-sexp)))
   '("{" ",")))

(defun nix-smie--eol-p ()
  "Whether there are no tokens after point on the current line."
  (let ((eol (line-end-position)))
    (save-excursion
      (forward-comment (point-max))
      (or (eobp)
          (< eol (point))))))

(defun nix-smie--indent-close ()
  "Align close paren with opening paren."
  (save-excursion
    (when (looking-at "\\s)")
      (forward-char 1)
      (condition-case nil
          (progn
            (backward-sexp 1)
            ;; If the opening paren is not the last token on its line,
            ;; and it's either '[' or '{', align to the opening paren's
            ;; position. Otherwise, align its line's anchor.
            (if (and (memq (char-after) '(?\[ ?{))
                     (not (save-excursion (forward-char) (nix-smie--eol-p))))
                (current-column)
              (nix-smie--anchor)))
        (scan-error nil)))))

(defun nix-smie--indent-exps ()
  "This function replaces and is based on `smie-indent-exps'.
An argument to a function is indented relative to the function,
not to any other arguments."
  (save-excursion
    (let (parent   ;; token enclosing the expression list
          skipped) ;; whether we skipped at least one expression
      (let ((start (point)))
        (setq parent (nth 2 (smie-backward-sexp " -bseqskip- ")))
        (setq skipped (not (eq start (point))))
        (cond
         ((not skipped)
          ;; We're the first expression of the list.  In that case, the
          ;; indentation should be (have been) determined by its context.
          nil)
         ((equal parent "[")
          ;; It's a list, align with the first expression.
          (current-column))
         ;; We're an argument.
         (t
          ;; We can use (current-column) or (current-indentation) here.
          ;; (current-column) will indent relative to the first expression
          ;; in the sequence, and (current-indentation) will indent relative
          ;; to the indentation of the line on which the first expression
          ;; begins. I'm not sure which one is better.
          (+ tab-width (current-indentation))))))))

;;; Indentation not using SMIE

(defun nix-find-backward-matching-token ()
  "Find the previous Nix token."
  (cond
   ((looking-at "in\\b")
    (let ((counter 1))
      (while (and (> counter 0)
                  (re-search-backward "\\b\\(let\\|in\\)\\b" nil t))
        (unless (or (nix--get-string-type (nix--get-parse-state (point)))
                    (nix-is-comment-p))
          (setq counter (cond ((looking-at "let") (- counter 1))
                              ((looking-at "in") (+ counter 1))))))
      counter ))
   ((looking-at "}")
    (backward-up-list) t)
   ((looking-at "]")
    (backward-up-list) t)
   ((looking-at ")")
    (backward-up-list) t)))

(defun nix-indent-to-backward-match ()
  "Match the previous line’s indentation."
  (let ((matching-indentation (save-excursion
                                (beginning-of-line)
                                (skip-chars-forward "[:space:]")
                                (if (nix-find-backward-matching-token)
                                    (current-indentation)))))
    (when matching-indentation (indent-line-to matching-indentation) t)))

(defun nix-indent-first-line-in-block ()
  "Indent the first line in a block."

  (let ((matching-indentation (save-excursion
                                ;; Go back to previous line that contain anything useful to check the
                                ;; contents of that line.
                                (beginning-of-line)
                                (skip-chars-backward "\n[:space:]")

                                ;; Grab the full string of the line before the one we're indenting
                                (let ((line (buffer-substring-no-properties (line-beginning-position) (line-end-position))))
                                  ;; Then regex-match strings at the end of the line to detect if we need to indent the line after.
                                  ;; We could probably add more things to look for here in the future.
                                  (if (or (string-match "\\blet$" line)
                                          (string-match "\\bimport$" line)
                                          (string-match "\\[$" line)
                                          (string-match "=$" line)
                                          (string-match "\($" line)
                                          (string-match "\{$" line))

                                      ;; If it matches any of the regexes above, grab the indent level
                                      ;; of the line and add 2 to ident the line below this one.
                                      (+ 2 (current-indentation)))))))
    (when matching-indentation (indent-line-to matching-indentation) t)))

(defun nix-indent-expression-start ()
  "Indent the line as a continuation of the enclosing expression.
Indent `tab-width' past the indentation of the line containing
the innermost unclosed bracket.  Return non-nil when such a
bracket exists and indentation was applied."
  (let ((open (save-excursion
                (beginning-of-line)
                (nth 1 (nix--get-parse-state (point))))))
    (when open
      (indent-line-to (+ tab-width
                         (save-excursion
                           (goto-char open)
                           (current-indentation))))
      t)))

(defun nix-indent-prev-level ()
  "Get the indent level of the previous line."
  (save-excursion
    (beginning-of-line)
    (skip-chars-backward "\n[:space:]")
    (current-indentation)))

;;;###autoload
(defun nix-mode-format ()
  "Format the entire `nix-mode' buffer."
  (interactive)
  (when (eq major-mode 'nix-mode)
    (save-excursion
      (goto-char (point-min))
      (while (not (equal (point) (point-max)))
        (if (equal (string-match-p "^[[:space:]]*$" (thing-at-point 'line)) 0)
            (delete-horizontal-space)
          (nix-indent-line))
        (forward-line)))))

;;;###autoload
(defun nix-indent-line ()
  "Indent current line in a Nix expression."
  (interactive)
  (let ((end-of-indentation
         (save-excursion
           (cond
            ;; Indent first line of file to 0
            ((= (line-number-at-pos) 1)
             (indent-line-to 0))

            ;; comment
            ((save-excursion
               (beginning-of-line)
               (nix-is-comment-p))
             (indent-line-to (nix-indent-prev-level)))

            ;; string
            ((save-excursion
               (beginning-of-line)
               (nth 3 (syntax-ppss)))
             (indent-line-to (+ (nix-indent-prev-level)
                                (* tab-width
                                   (+ (if (save-excursion
                                            (forward-line -1)
                                            (end-of-line)
                                            (skip-chars-backward "[:space:]")
                                            (looking-back "''" 0)) 1 0)
                                      (if (save-excursion
                                            (beginning-of-line)
                                            (skip-chars-forward
                                             "[:space:]")
                                            (looking-at "''")
                                            ) -1 0)
                                      )))))

            ;; dedent '}', ']', ')' 'in'
            ((nix-indent-to-backward-match))

            ;; indent line after 'let', 'import', '[', '=', '(', '{'
            ((nix-indent-first-line-in-block))

            ;; indent between = and ; + 2, or to 2
            ((nix-indent-expression-start))

            ;; else
            (t
             (indent-line-to (nix-indent-prev-level))))
           (point))))
    (when (> end-of-indentation (point)) (goto-char end-of-indentation))))

(defun nix-is-comment-p ()
  "Whether we are in a comment."
  (nth 4 (syntax-ppss)))

(defun nix-is-string-p ()
  "Whether we are in a string."
  (or (looking-at nix-re-quotes)
      (nix--get-string-type (nix--get-parse-state (point)))))

;;;###autoload
(defun nix-indent-region (start end)
  "Indent on a whole region. Enabled by default.
START where to start in region.
END where to end the region."
  (interactive (list (region-beginning) (region-end)))
  (save-excursion
    (goto-char start)
    (while (< (point) end)
      (or (and (bolp) (eolp))
          (when (and
                 ;; Skip if previous line is empty or a comment.
                 (save-excursion
                   (let ((line-is-comment-p (nix-is-comment-p)))
                     (forward-line -1)
                     (not
                      (or (and (nix-is-comment-p)
                               ;; Unless this line is a comment too.
                               (not line-is-comment-p))
                          (nix-is-comment-p)))))
                 ;; Don't mess with strings.
                 (not (nix-is-string-p)))
            (funcall nix-indent-function)))
      (forward-line 1))))

;;;###autoload
(defun nix-mode-ffap-nixpkgs-path (str)
  "Support `ffap' for <nixpkgs> declarations.
If STR contains brackets, call `nix-instantiate' to find the
location of STR. If `nix-instantiate' has a nonzero exit code,
don’t do anything"
  (when (and (string-match nix-re-bracket-path str)
             (executable-find nix-instantiate-executable))
    (let ((nix-executable nix-instantiate-executable))
      (ignore-errors
	(nix--process-string "--eval" "-E" str)))))

;; Key maps

(defvar nix-mode-menu (make-sparse-keymap "Nix")
  "Menu for Nix mode.")

(defvar nix-mode-map (make-sparse-keymap)
  "Local keymap used for Nix mode.")

(defun nix-create-keymap ()
  "Create the keymap associated with the Nix mode."
  )

(defun nix-create-menu ()
  "Create the Nix menu as shown in the menu bar."
  (let ((m '("Nix"
             ["Format buffer" nix-mode-format t])))
    (easy-menu-define nix-mode-menu nix-mode-map "Menu keymap for Nix mode" m)))

(nix-create-keymap)
(nix-create-menu)

;;;###autoload
(define-derived-mode nix-mode prog-mode "Nix"
  "Major mode for editing Nix expressions.

The following commands may be useful:

  '\\[newline-and-indent]'
    Insert a newline and move the cursor to align with the previous
    non-empty line.

  '\\[fill-paragraph]'
    Refill a paragraph so that all lines are at most `fill-column'
    lines long.  This should do the right thing for comments beginning
    with `#'.  However, this command doesn't work properly yet if the
    comment is adjacent to code (i.e., no intervening empty lines).
    In that case, select the text to be refilled and use
    `\\[fill-region]' instead.

The hook `nix-mode-hook' is run when Nix mode is started.

\\{nix-mode-map}"
  :group 'nix-mode
  :syntax-table nix-mode-syntax-table
  :abbrev-table nix-mode-abbrev-table

  ;; Disable hard tabs and set tab to 2 spaces
  ;; Recommended by nixpkgs manual: https://nixos.org/nixpkgs/manual/#sec-syntax
  (setq-local indent-tabs-mode nil)
  (setq-local tab-width 2)
  (setq-local electric-indent-chars '(?\n ?{ ?} ?\[ ?\] ?\( ?\)))

  ;; Font lock support.
  (setq-local font-lock-defaults '(nix-font-lock-keywords))

  ;; Special syntax properties for Nix
  (setq-local syntax-propertize-function 'nix-syntax-propertize)

  ;; Look at text properties when parsing
  (setq-local parse-sexp-lookup-properties t)

  ;; Setup SMIE integration
  (when nix-mode-use-smie
    (smie-setup nix-smie-grammar 'nix-smie-rules
                :forward-token 'nix-smie--forward-token
                :backward-token 'nix-smie--backward-token)
    (setq-local smie-indent-basic 2)

    (let ((nix-smie-indent-functions
           ;; Replace the smie-indent-* equivalents with nix-mode's.
           (mapcar (lambda (fun) (pcase fun
                                   ('smie-indent-exps  'nix-smie--indent-exps)
                                   ('smie-indent-close 'nix-smie--indent-close)
                                   (_ fun)))
                   smie-indent-functions)))
      (setq-local smie-indent-functions nix-smie-indent-functions)))

  ;; Automatic indentation [C-j]
  (setq-local indent-line-function
              (lambda ()
                (if (and (not nix-mode-use-smie)
                         (eq nix-indent-function 'smie-indent-line))
                    (indent-relative)
                  (funcall nix-indent-function))))

  ;; Indenting of comments
  (setq-local comment-start "# ")
  (setq-local comment-end "")
  (setq-local comment-start-skip "\\(^\\|\\s-\\);?#+ *")
  (setq-local comment-multi-line t)

  ;; Filling of comments
  (setq-local adaptive-fill-mode t)
  (setq-local paragraph-start "[ \t]*\\(#+[ \t]*\\)?$")
  (setq-local paragraph-separate paragraph-start)

  ;; Find file at point
  (add-to-list 'ffap-alist '(nix-mode . nix-mode-ffap-nixpkgs-path))
  (add-to-list 'ffap-string-at-point-mode-alist
               '(nix-mode "--:\\\\$<>+@-Z_[:alpha:]~*?" "@" "@;.,!:")))

;;;###autoload
(add-to-list 'auto-mode-alist '("\\.nix\\'" . nix-mode))

(provide 'nix-mode)
;;; nix-mode.el ends here
