;;; elixir-ts-mode.el --- Major mode for Elixir with tree-sitter support -*- lexical-binding: t; -*-

;; Copyright (C) 2022-2024 Free Software Foundation, Inc.

;; Author: Wilhelm H Kirschbaum <wkirschbaum@gmail.com>
;; Created: November 2022
;; Keywords: elixir languages tree-sitter

;; This file is part of GNU Emacs.

;; GNU Emacs 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.

;; GNU Emacs 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 GNU Emacs.  If not, see <https://www.gnu.org/licenses/>.

;;; Commentary:
;;
;; This package provides `elixir-ts-mode' which is a major mode for editing
;; Elixir files and embedded HEEx templates that uses Tree Sitter to parse
;; the language.
;;
;; This package is compatible with and was tested against the tree-sitter grammar
;; for Elixir found at https://github.com/elixir-lang/tree-sitter-elixir.
;;
;; Features
;;
;; * Indent
;;
;; `elixir-ts-mode' tries to replicate the indentation provided by
;; mix format, but will come with some minor differences.
;;
;; * IMenu
;; * Navigation
;; * Which-fun

;;; Code:

(require 'treesit)
(eval-when-compile (require 'rx))

(declare-function treesit-parser-create "treesit.c")
(declare-function treesit-node-child "treesit.c")
(declare-function treesit-node-type "treesit.c")
(declare-function treesit-node-child-by-field-name "treesit.c")
(declare-function treesit-parser-language "treesit.c")
(declare-function treesit-parser-included-ranges "treesit.c")
(declare-function treesit-parser-list "treesit.c")
(declare-function treesit-node-p "treesit.c")
(declare-function treesit-node-parent "treesit.c")
(declare-function treesit-node-start "treesit.c")
(declare-function treesit-node-end "treesit.c")
(declare-function treesit-query-compile "treesit.c")
(declare-function treesit-query-capture "treesit.c")
(declare-function treesit-node-eq "treesit.c")
(declare-function treesit-node-prev-sibling "treesit.c")

(defgroup elixir-ts nil
  "Major mode for editing Elixir code."
  :prefix "elixir-ts-"
  :group 'languages)

(defcustom elixir-ts-indent-offset 2
  "Indentation of Elixir statements."
  :version "30.1"
  :type 'integer
  :safe 'integerp
  :group 'elixir-ts)

;; 'define-derived-mode' doesn't expose the generated mode hook
;; variable to Custom, because we are not smart enough to provide the
;; ':options' for hook variables.  Also, some packages modify hook
;; variables.  The below is done because users of this mode explicitly
;; requested the hook to be customizable via Custom.
(defcustom elixir-ts-mode-hook nil
  "Hook run after entering `elixir-ts-mode'."
  :type 'hook
  :options '(eglot-ensure)
  :group 'elixir-ts
  :version "30.1")

(defface elixir-ts-comment-doc-identifier
  '((t (:inherit font-lock-doc-face)))
  "Face used for doc identifiers in Elixir files."
  :group 'elixir-ts)

(defface elixir-ts-comment-doc-attribute
  '((t (:inherit font-lock-doc-face)))
  "Face used for doc attributes in Elixir files."
  :group 'elixir-ts)

(defface elixir-ts-sigil-name
  '((t (:inherit font-lock-string-face)))
  "Face used for sigils in Elixir files."
  :group 'elixir-ts)

(defface elixir-ts-atom
  '((t (:inherit font-lock-constant-face)))
  "Face used for atoms in Elixir files."
  :group 'elixir-ts)

(defface elixir-ts-keyword-key
  '((t (:inherit elixir-ts-atom)))
  "Face used for keyword keys in Elixir files."
  :group 'elixir-ts)

(defface elixir-ts-attribute
  '((t (:inherit font-lock-preprocessor-face)))
  "Face used for attributes in Elixir files."
  :group 'elixir-ts)

(defconst elixir-ts--sexp-regexp
  (rx bol
      (or "call" "stab_clause" "binary_operator" "list" "tuple" "map" "pair"
          "sigil" "string" "atom" "alias" "arguments" "identifier"
          "boolean" "quoted_content" "bitstring")
      eol))

(defconst elixir-ts--test-definition-keywords
  '("describe" "test"))

(defconst elixir-ts--definition-keywords
  '("def" "defdelegate" "defexception" "defguard" "defguardp"
    "defimpl" "defmacro" "defmacrop" "defmodule" "defn" "defnp"
    "defoverridable" "defp" "defprotocol" "defstruct"))

(defconst elixir-ts--definition-keywords-re
  (concat "^" (regexp-opt
               (append elixir-ts--definition-keywords
                       elixir-ts--test-definition-keywords))
          "$"))

(defconst elixir-ts--kernel-keywords
  '("alias" "case" "cond" "else" "for" "if" "import" "quote"
    "raise" "receive" "require" "reraise" "super" "throw" "try"
    "unless" "unquote" "unquote_splicing" "use" "with"))

(defconst elixir-ts--kernel-keywords-re
  (concat "^" (regexp-opt elixir-ts--kernel-keywords) "$"))

(defconst elixir-ts--builtin-keywords
  '("__MODULE__" "__DIR__" "__ENV__" "__CALLER__" "__STACKTRACE__"))

(defconst elixir-ts--builtin-keywords-re
  (concat "^" (regexp-opt elixir-ts--builtin-keywords) "$"))

(defconst elixir-ts--doc-keywords
  '("moduledoc" "typedoc" "doc"))

(defconst elixir-ts--doc-keywords-re
  (concat "^" (regexp-opt elixir-ts--doc-keywords) "$"))

(defconst elixir-ts--reserved-keywords
  '("when" "and" "or" "not" "in"
    "not in" "fn" "do" "end" "catch" "rescue" "after" "else"))

(defconst elixir-ts--reserved-keywords-re
  (concat "^" (regexp-opt elixir-ts--reserved-keywords) "$"))

(defconst elixir-ts--reserved-keywords-vector
  (apply #'vector elixir-ts--reserved-keywords))

(defvar elixir-ts--capture-anonymous-function-end
  (when (treesit-available-p)
    (treesit-query-compile 'elixir '((anonymous_function "end" @end)))))

(defvar elixir-ts--capture-operator-parent
  (when (treesit-available-p)
    (treesit-query-compile 'elixir '((binary_operator operator: _ @val)))))

(defvar elixir-ts--syntax-table
  (let ((table (make-syntax-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)
    (modify-syntax-entry ?_ "_" table)
    (modify-syntax-entry ?? "w" table)
    (modify-syntax-entry ?~ "w" table)
    (modify-syntax-entry ?! "_" table)
    (modify-syntax-entry ?' "\"" table)
    (modify-syntax-entry ?\" "\"" table)
    (modify-syntax-entry ?# "<" table)
    (modify-syntax-entry ?\n ">" 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)
    (modify-syntax-entry ?@ "'" table)
    table)
  "Syntax table for `elixir-ts-mode'.")

(defun elixir-ts--argument-indent-offset (node _parent &rest _)
  "Return the argument offset position for NODE."
  (if (or (treesit-node-prev-sibling node t)
          ;; Don't indent if this is the first node or
          ;; if the line is empty.
          (save-excursion
            (beginning-of-line)
            (looking-at-p "[[:blank:]]*$")))
      0 elixir-ts-indent-offset))

(defun elixir-ts--argument-indent-anchor (node parent &rest _)
  "Return the argument anchor position for NODE and PARENT."
  (let ((first-sibling (treesit-node-child parent 0 t)))
    (if (and first-sibling (not (treesit-node-eq first-sibling node)))
        (treesit-node-start first-sibling)
      (elixir-ts--parent-expression-start node parent))))

(defun elixir-ts--parent-expression-start (_node parent &rest _)
  "Return the indentation expression start for NODE and PARENT."
  ;; If the parent is the first expression on the line return the
  ;; parent start of node position, otherwise use the parent call
  ;; start if available.
  (if (eq (treesit-node-start parent)
          (save-excursion
            (goto-char (treesit-node-start parent))
            (back-to-indentation)
            (point)))
      (treesit-node-start parent)
    (let ((expr-parent
           (treesit-parent-until
            parent
            (lambda (n)
              (member (treesit-node-type n)
                      '("call" "binary_operator" "keywords" "list"))))))
      (save-excursion
        (goto-char (treesit-node-start expr-parent))
        (back-to-indentation)
        (if (looking-at "|>")
            (point)
          (treesit-node-start expr-parent))))))

(defvar elixir-ts--indent-rules
  (let ((offset elixir-ts-indent-offset))
    `((elixir
       ((parent-is "^source$") column-0 0)
       ((parent-is "^string$") parent-bol 0)
       ((parent-is "^quoted_content$")
        (lambda (_n parent bol &rest _)
          (save-excursion
            (back-to-indentation)
            (if (bolp)
                (progn
                  (goto-char (treesit-node-start parent))
                  (back-to-indentation)
                  (point))
              (point))))
        0)
       ((node-is "^|>$") parent-bol 0)
       ((node-is "^|$") parent-bol 0)
       ((node-is "^]$") ,'elixir-ts--parent-expression-start 0)
       ((node-is "^}$") ,'elixir-ts--parent-expression-start 0)
       ((node-is "^)$") ,'elixir-ts--parent-expression-start 0)
       ((node-is "^>>$") ,'elixir-ts--parent-expression-start 0)
       ((node-is "^else_block$") grand-parent 0)
       ((node-is "^catch_block$") grand-parent 0)
       ((node-is "^rescue_block$") grand-parent 0)
       ((node-is "^after_block$") grand-parent 0)
       ((parent-is "^else_block$") parent ,offset)
       ((parent-is "^catch_block$") parent ,offset)
       ((parent-is "^rescue_block$") parent ,offset)
       ((parent-is "^rescue_block$") parent ,offset)
       ((parent-is "^after_block$") parent ,offset)
       ((parent-is "^access_call$")
        ,'elixir-ts--argument-indent-anchor
        ,'elixir-ts--argument-indent-offset)
       ((parent-is "^tuple$")
        ,'elixir-ts--argument-indent-anchor
        ,'elixir-ts--argument-indent-offset)
       ((parent-is "^list$")
        ,'elixir-ts--argument-indent-anchor
        ,'elixir-ts--argument-indent-offset)
       ((parent-is "^pair$") parent ,offset)
       ((parent-is "^bitstring$") parent ,offset)
       ((parent-is "^map_content$") parent-bol 0)
       ((parent-is "^map$") ,'elixir-ts--parent-expression-start ,offset)
       ((node-is "^stab_clause$") parent-bol ,offset)
       ((query ,elixir-ts--capture-operator-parent) grand-parent 0)
       ((node-is "^when$") parent 0)
       ((parent-is "^body$")
        (lambda (node parent _)
          (save-excursion
            ;; The grammar adds a comment outside of the body, so we have to indent
            ;; to the grand-parent if it is available.
            (goto-char (treesit-node-start
                        (or (treesit-node-parent parent) (parent))))
            (back-to-indentation)
            (point)))
        ,offset)
       ((parent-is "^arguments$")
        ,'elixir-ts--argument-indent-anchor
        ,'elixir-ts--argument-indent-offset)
       ;; Handle incomplete maps when parent is ERROR.
       ((node-is "^keywords$") parent-bol ,offset)
       ((n-p-gp "^binary_operator$" "ERROR" nil) parent-bol 0)
       ;; When there is an ERROR, just indent to prev-line.
       ((parent-is "ERROR") prev-line ,offset)
       ((node-is "^binary_operator$")
        (lambda (node parent &rest _)
          (let ((top-level
                 (treesit-parent-while
                  node
                  (lambda (node)
                    (equal (treesit-node-type node)
                           "binary_operator")))))
            (if (treesit-node-eq top-level node)
                (elixir-ts--parent-expression-start node parent)
              (treesit-node-start top-level))))
        (lambda (node parent _)
          (cond
           ((equal (treesit-node-type parent) "do_block")
            ,offset)
           ((equal (treesit-node-type parent) "binary_operator")
            ,offset)
           (t 0))))
       ((parent-is "^binary_operator$")
        (lambda (node parent bol &rest _)
          (treesit-node-start
           (treesit-parent-while
            parent
            (lambda (node)
              (equal (treesit-node-type node) "binary_operator")))))
        ,offset)
       ((node-is "^pair$") first-sibling 0)
       ((query ,elixir-ts--capture-anonymous-function-end) parent-bol 0)
       ((node-is "^end$") standalone-parent 0)
       ((parent-is "^do_block$") grand-parent ,offset)
       ((parent-is "^anonymous_function$")
        elixir-ts--treesit-anchor-grand-parent-bol ,offset)
       ((parent-is "^else_block$") parent ,offset)
       ((parent-is "^rescue_block$") parent ,offset)
       ((parent-is "^catch_block$") parent ,offset)
       ((parent-is "^keywords$") parent-bol 0)
       ((node-is "^call$") parent-bol ,offset)
       ((node-is "^comment$") parent-bol ,offset)
       ((node-is "\"\"\"") parent-bol 0)
       ;; Handle quoted_content indentation on the last
       ;; line before the closing \"\"\", where it might
       ;; see it as no-node outside a HEEx tag.
       (no-node (lambda (_n _p _bol)
                  (treesit-node-start
                   (treesit-node-parent
                    (treesit-node-at (point) 'elixir))))
                0)))))

(defvar elixir-ts--font-lock-settings
  (treesit-font-lock-rules
   :language 'elixir
   :feature 'elixir-definition
   `((call target: (identifier) @target-identifier
           (arguments
            (call target: (identifier) @font-lock-function-name-face
                  (arguments)))
           (:match ,elixir-ts--definition-keywords-re @target-identifier))
     (call target: (identifier) @target-identifier
           (arguments (identifier) @font-lock-function-name-face)
           (:match ,elixir-ts--definition-keywords-re @target-identifier))
     (call target: (identifier) @target-identifier
           (arguments
            (call target: (identifier) @font-lock-function-name-face
                  (arguments ((identifier)) @font-lock-variable-name-face)))
           (:match ,elixir-ts--definition-keywords-re @target-identifier))
     (call target: (identifier) @target-identifier
           (arguments
            (binary_operator
             left: (call target: (identifier) @font-lock-function-name-face)))
           (:match ,elixir-ts--definition-keywords-re @target-identifier))
     (call target: (identifier) @target-identifier
           (arguments (identifier) @font-lock-function-name-face)
           (do_block)
           (:match ,elixir-ts--definition-keywords-re @target-identifier))
     (call target: (identifier) @target-identifier
           (arguments
            (call target: (identifier) @font-lock-function-name-face
                  (arguments ((identifier)) @font-lock-variable-name-face)))
           (do_block)
           (:match ,elixir-ts--definition-keywords-re @target-identifier))
     (call target: (identifier) @target-identifier
           (arguments
            (binary_operator
             left: (call target: (identifier) @font-lock-function-name-face
                         (arguments ((identifier)) @font-lock-variable-name-face))))
           (do_block)
           (:match ,elixir-ts--definition-keywords-re @target-identifier))
     (unary_operator
      operator: "@"
      (call (arguments
             (binary_operator
              left: (call target: (identifier) @font-lock-function-name-face))))))

   ;; A function definition like "def _foo" is valid, but we should
   ;; not apply the comment-face unless its a non-function identifier, so
   ;; the comment matches has to be after the function matches.
   :language 'elixir
   :feature 'elixir-comment
   '((comment) @font-lock-comment-face
     ((identifier) @font-lock-comment-face
      (:match "^_[a-z]\\|^_$" @font-lock-comment-face)))

   :language 'elixir
   :feature 'elixir-variable
   `((call target: (identifier)
           (arguments
            (binary_operator
             (call target: (identifier)
                   (arguments ((identifier) @font-lock-variable-use-face))))))
     (call target: (identifier)
           (arguments
            (call target: (identifier)
                  (arguments ((identifier)) @font-lock-variable-use-face))))
     (dot left: (identifier) @font-lock-variable-use-face operator: "." ))

   :language 'elixir
   :feature 'elixir-doc
   `((unary_operator
      operator: "@" @elixir-ts-comment-doc-attribute
      operand: (call
                target: (identifier) @elixir-ts-comment-doc-identifier
                ;; Arguments can be optional, so adding another
                ;; entry without arguments.
                ;; If we don't handle then we don't apply font
                ;; and the non doc fortification query will take specify
                ;; a more specific font which takes precedence.
                (arguments
                 [
                  (string) @font-lock-doc-face
                  (charlist) @font-lock-doc-face
                  (sigil) @font-lock-doc-face
                  (boolean) @font-lock-doc-face
                  (keywords) @font-lock-doc-face
                  ]))
      (:match ,elixir-ts--doc-keywords-re
              @elixir-ts-comment-doc-identifier))
     (unary_operator
      operator: "@" @elixir-ts-comment-doc-attribute
      operand: (call
                target: (identifier) @elixir-ts-comment-doc-identifier)
      (:match ,elixir-ts--doc-keywords-re
              @elixir-ts-comment-doc-identifier)))

   :language 'elixir
   :feature 'elixir-string
   '((interpolation
      "#{" @font-lock-escape-face
      "}" @font-lock-escape-face)
     (string (quoted_content) @font-lock-string-face)
     (quoted_keyword (quoted_content) @font-lock-string-face)
     (charlist (quoted_content) @font-lock-string-face)
     ["\"" "'" "\"\"\""] @font-lock-string-face)

   :language 'elixir
   :feature 'elixir-sigil
   `((sigil
      (sigil_name) @elixir-ts-sigil-name
      (quoted_content) @font-lock-string-face
      ;; HEEx and Surface templates will handled by
      ;; heex-ts-mode if its available.
      (:match "^[^HF]$" @elixir-ts-sigil-name))
     @font-lock-string-face
     (sigil
      (sigil_name) @font-lock-regexp-face
      (:match "^[rR]$" @font-lock-regexp-face))
     @font-lock-regexp-face
     (sigil
      "~" @font-lock-string-face
      (sigil_name) @font-lock-string-face
      quoted_start: _ @font-lock-string-face
      quoted_end: _ @font-lock-string-face))

   :language 'elixir
   :feature 'elixir-operator
   `(["!"] @font-lock-negation-char-face
     ["%"] @font-lock-bracket-face
     ["," ";"] @font-lock-operator-face
     ["(" ")" "[" "]" "{" "}" "<<" ">>"] @font-lock-bracket-face)

   :language 'elixir
   :feature 'elixir-data-type
   '([(atom) (alias)] @font-lock-type-face
     (keywords (pair key: (keyword) @elixir-ts-keyword-key))
     [(keyword) (quoted_keyword)] @elixir-ts-atom
     [(boolean) (nil)] @elixir-ts-atom
     (unary_operator operator: "@" @elixir-ts-attribute
                     operand: [
                               (identifier) @elixir-ts-attribute
                               (call target: (identifier)
                                     @elixir-ts-attribute)
                               (boolean) @elixir-ts-attribute
                               (nil) @elixir-ts-attribute
                               ])
     (operator_identifier) @font-lock-operator-face)

   :language 'elixir
   :feature 'elixir-keyword
   `(,elixir-ts--reserved-keywords-vector
     @font-lock-keyword-face
     (binary_operator
      operator: _ @font-lock-keyword-face
      (:match ,elixir-ts--reserved-keywords-re @font-lock-keyword-face))
     (binary_operator operator: _ @font-lock-operator-face)
     (call
      target: (identifier) @font-lock-keyword-face
      (:match ,elixir-ts--definition-keywords-re @font-lock-keyword-face))
     (call
      target: (identifier) @font-lock-keyword-face
      (:match ,elixir-ts--kernel-keywords-re @font-lock-keyword-face)))

   :language 'elixir
   :feature 'elixir-function-call
   '((call target: (identifier) @font-lock-function-call-face)
     (unary_operator operator: "&" @font-lock-operator-face
                     operand: (binary_operator
                               left: (identifier)
                               @font-lock-function-call-face
                               operator: "/" right: (integer)))
     (call
      target: (dot right: (identifier) @font-lock-function-call-face))
     (unary_operator operator: "&" @font-lock-variable-use-face
                     operand: (integer) @font-lock-variable-use-face)
     (unary_operator operator: "&" @font-lock-operator-face
                     operand: (list)))

   :language 'elixir
   :feature 'elixir-string-escape
   :override t
   `((escape_sequence) @font-lock-escape-face)

   :language 'elixir
   :feature 'elixir-number
   '([(integer) (float)] @font-lock-number-face)

   :language 'elixir
   :feature 'elixir-variable
   '((binary_operator left: (identifier) @font-lock-variable-use-face)
     (binary_operator right: (identifier) @font-lock-variable-use-face)
     (arguments ( (identifier) @font-lock-variable-use-face))
     (tuple (identifier) @font-lock-variable-use-face)
     (list (identifier) @font-lock-variable-use-face)
     (pair value: (identifier) @font-lock-variable-use-face)
     (body (identifier) @font-lock-variable-use-face)
     (unary_operator operand: (identifier) @font-lock-variable-use-face)
     (interpolation (identifier) @font-lock-variable-use-face)
     (do_block (identifier) @font-lock-variable-use-face)
     (access_call target: (identifier) @font-lock-variable-use-face)
     (access_call "[" key: (identifier) @font-lock-variable-use-face "]"))

   :language 'elixir
   :feature 'elixir-builtin
   :override t
   `(((identifier) @font-lock-builtin-face
      (:match ,elixir-ts--builtin-keywords-re
              @font-lock-builtin-face))))

  "Tree-sitter font-lock settings.")

(defvar elixir-ts--treesit-range-rules
  (when (treesit-available-p)
    (treesit-range-rules
     :embed 'heex
     :host 'elixir
     '((sigil (sigil_name) @name (:match "^[HF]$" @name) (quoted_content) @heex)))))

(defvar heex-ts--sexp-regexp)
(defvar heex-ts--indent-rules)
(defvar heex-ts--font-lock-settings)

(defun elixir-ts--forward-sexp (&optional arg)
  "Move forward across one balanced expression (sexp).
With ARG, do it many times.  Negative ARG means move backward."
  (or arg (setq arg 1))
  (funcall
   (if (> arg 0) #'treesit-end-of-thing #'treesit-beginning-of-thing)
   (if (eq (treesit-language-at (point)) 'heex)
       heex-ts--sexp-regexp
     elixir-ts--sexp-regexp)
   (abs arg)))

(defun elixir-ts--treesit-anchor-grand-parent-bol (_n parent &rest _)
  "Return the beginning of non-space characters for the parent node of PARENT."
  (save-excursion
    (goto-char (treesit-node-start (treesit-node-parent parent)))
    (back-to-indentation)
    (point)))

(defun elixir-ts--treesit-language-at-point (point)
  "Return the language at POINT."
  (let ((node (treesit-node-at point 'elixir)))
    (if (and (equal (treesit-node-type node) "quoted_content")
             (let ((prev-sibling (treesit-node-prev-sibling node t)))
               (and (treesit-node-p prev-sibling)
                    (string-match-p
                     (rx bos (or "H" "F") eos)
                     (treesit-node-text prev-sibling)))))
        'heex
      'elixir)))

(defun elixir-ts--defun-p (node)
  "Return non-nil when NODE is a defun."
  (member (treesit-node-text
           (treesit-node-child-by-field-name node "target"))
          (append
           elixir-ts--definition-keywords
           elixir-ts--test-definition-keywords)))

(defun elixir-ts--defun-name (node)
  "Return the name of the defun NODE.
Return nil if NODE is not a defun node or doesn't have a name."
  (pcase (treesit-node-type node)
    ("call" (let ((node-child
                   (treesit-node-child (treesit-node-child node 1) 0)))
              (pcase (treesit-node-type node-child)
                ("alias" (treesit-node-text node-child t))
                ("call" (treesit-node-text
                         (treesit-node-child-by-field-name node-child "target") t))
                ("binary_operator"
                 (treesit-node-text
                  (treesit-node-child-by-field-name
                   (treesit-node-child-by-field-name node-child "left") "target")
                  t))
                ("identifier"
                 (treesit-node-text node-child t))
                (_ nil))))
    (_ nil)))

(defvar elixir-ts--syntax-propertize-query
  (when (treesit-available-p)
    (treesit-query-compile
     'elixir
     '(((["\"\"\""] @quoted-text))))))

(defun elixir-ts--syntax-propertize (start end)
  "Apply syntax text properties between START and END for `elixir-ts-mode'."
  (let ((captures
         (treesit-query-capture 'elixir elixir-ts--syntax-propertize-query start end)))
    (pcase-dolist (`(,name . ,node) captures)
      (pcase-exhaustive name
        ('quoted-text
         (put-text-property (1- (treesit-node-end node)) (treesit-node-end node)
                            'syntax-table (string-to-syntax "$")))))))

(defun elixir-ts--electric-pair-string-delimiter ()
  "Insert corresponding multi-line string for `electric-pair-mode'."
  (when (and electric-pair-mode
             (eq last-command-event ?\")
             (let ((count 0))
               (while (eq (char-before (- (point) count)) last-command-event)
                 (cl-incf count))
               (= count 3))
             (eq (char-after) last-command-event))
    (save-excursion
      (insert (make-string 2 last-command-event)))
    (save-excursion
      (newline 1 t))))

;;;###autoload
(define-derived-mode elixir-ts-mode prog-mode "Elixir"
  "Major mode for editing Elixir, powered by tree-sitter."
  :group 'elixir-ts
  :syntax-table elixir-ts--syntax-table

  ;; Comments.
  (setq-local comment-start "# ")
  (setq-local comment-start-skip
              (rx "#" (* (syntax whitespace))))

  (setq-local comment-end "")
  (setq-local comment-end-skip
              (rx (* (syntax whitespace))
                  (group (or (syntax comment-end) "\n"))))

  ;; Compile.
  (setq-local compile-command "mix")

  ;; Electric pair.
  (add-hook 'post-self-insert-hook
            #'elixir-ts--electric-pair-string-delimiter 'append t)

  (when (treesit-ready-p 'elixir)
    ;; The HEEx parser has to be created first for elixir to ensure elixir
    ;; is the first language when looking for treesit ranges.
    (when (treesit-ready-p 'heex)
      ;; Require heex-ts-mode only when we load elixir-ts-mode
      ;; so that we don't get a tree-sitter compilation warning for
      ;; elixir-ts-mode.
      (require 'heex-ts-mode)
      (treesit-parser-create 'heex))

    (treesit-parser-create 'elixir)

    (setq-local treesit-language-at-point-function
                'elixir-ts--treesit-language-at-point)

    ;; Font-lock.
    (setq-local treesit-font-lock-settings elixir-ts--font-lock-settings)
    (setq-local treesit-font-lock-feature-list
                '(( elixir-comment elixir-doc elixir-definition)
                  ( elixir-string elixir-keyword elixir-data-type)
                  ( elixir-sigil elixir-builtin elixir-string-escape)
                  ( elixir-function-call elixir-variable elixir-operator elixir-number )))


    ;; Imenu.
    (setq-local treesit-simple-imenu-settings
                '((nil "\\`call\\'" elixir-ts--defun-p nil)))

    ;; Indent.
    (setq-local treesit-simple-indent-rules elixir-ts--indent-rules)

    ;; Navigation.
    (setq-local forward-sexp-function #'elixir-ts--forward-sexp)
    (setq-local treesit-defun-type-regexp
                '("call" . elixir-ts--defun-p))

    (setq-local treesit-defun-name-function #'elixir-ts--defun-name)

    ;; Embedded Heex.
    (when (treesit-ready-p 'heex)
      (setq-local treesit-range-settings elixir-ts--treesit-range-rules)

      (setq-local treesit-simple-indent-rules
                  (append treesit-simple-indent-rules heex-ts--indent-rules))

      (setq-local treesit-font-lock-settings
                  (append treesit-font-lock-settings
                          heex-ts--font-lock-settings))

      (setq-local treesit-simple-indent-rules
                  (append treesit-simple-indent-rules
                          heex-ts--indent-rules))

      (setq-local treesit-font-lock-feature-list
                  '(( elixir-comment elixir-doc elixir-definition
                      heex-comment heex-keyword heex-doctype )
                    ( elixir-string elixir-keyword elixir-data-type
                      heex-component heex-tag heex-attribute heex-string )
                    ( elixir-sigil elixir-builtin elixir-string-escape)
                    ( elixir-function-call elixir-variable elixir-operator elixir-number ))))

    (treesit-major-mode-setup)
    (setq-local syntax-propertize-function #'elixir-ts--syntax-propertize)))

(derived-mode-add-parents 'elixir-ts-mode '(elixir-mode))

(if (treesit-ready-p 'elixir)
    (progn
      (add-to-list 'auto-mode-alist '("\\.elixir\\'" . elixir-ts-mode))
      (add-to-list 'auto-mode-alist '("\\.ex\\'" . elixir-ts-mode))
      (add-to-list 'auto-mode-alist '("\\.exs\\'" . elixir-ts-mode))
      (add-to-list 'auto-mode-alist '("mix\\.lock" . elixir-ts-mode))))

(provide 'elixir-ts-mode)

;;; elixir-ts-mode.el ends here