;;; elixir-iex.el --- IEx REPL via eat terminal emulator -*- coding: utf-8; lexical-binding: t; -*-

;; Copyright (C) 2026 Allen Gooch

;; Author: Allen Gooch <allen.gooch@gmail.com>
;; URL: https://github.com/mojochao/elixir-iex
;; Package-Version: 20260412.1946
;; Package-Revision: a82808ebb91b
;; Keywords: elixir, languages, processes, tools
;; Package-Requires: ((emacs "29.1") (eat "0.9"))

;; This file is NOT part of GNU Emacs.

;; Permission is hereby granted, free of charge, to any person obtaining
;; a copy of this software and associated documentation files (the
;; "Software"), to deal in the Software without restriction, including
;; without limitation the rights to use, copy, modify, merge, publish,
;; distribute, sublicense, and/or sell copies of the Software, and to
;; permit persons to whom the Software is furnished to do so, subject to
;; the following conditions:
;;
;; The above copyright notice and this permission notice shall be
;; included in all copies or substantial portions of the Software.
;;
;; THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
;; EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
;; MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
;; NONINFRINGEMENT.  IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
;; BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
;; ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
;; CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
;; SOFTWARE.

;;; Commentary:
;;
;; elixir-iex provides an Elixir IEx REPL integration for Emacs
;; using the `eat' (Emulate A Terminal) package as its terminal
;; backend.  It is designed as a drop-in replacement for `inf-elixir'
;; that solves the fundamental TAB-completion problem inherent in
;; comint-based Elixir REPL packages.
;;
;;; Rationale:
;;
;; The existing `inf-elixir' package (https://github.com/J3RN/inf-elixir)
;; runs IEx inside a `comint-mode' buffer.  While comint is a fine
;; abstraction for line-oriented processes, it is not a terminal
;; emulator.  IEx's TAB completion relies on Erlang's `:edlin' line
;; editor, which communicates via terminal control sequences sent to a
;; real TTY.  Because comint does not emulate a terminal, these
;; sequences are lost and TAB completion silently fails.
;;
;; In practice, pressing TAB in an `inf-elixir' buffer either:
;;   - Gets intercepted by YASnippet (if `yas-global-mode' is active),
;;     which finds no matching snippet and falls through to
;;     `indent-for-tab-command', inserting whitespace; or
;;   - Reaches `comint-mode's `completion-at-point', which has no
;;     IEx-aware completion function and offers only filename completion.
;;
;; Attempts to work around this by querying IEx programmatically (e.g.
;; sending expressions to `IEx.Autocomplete.expand/1' via process
;; filter swaps and parsing the output) are fragile, timing-dependent,
;; and thus unreliable.
;;
;;; Design:
;;
;; The solution is to run IEx inside a real terminal emulator.  The
;; `eat' package (https://codeberg.org/akib/emacs-eat) provides a
;; pure-Elisp terminal emulator for Emacs that renders ANSI escape
;; sequences, supports terminal-level input, and integrates well with
;; Emacs keybindings via its semi-char mode.
;;
;; By running IEx in an eat buffer:
;;   - TAB completion works natively.  IEx sees a real terminal and
;;     its `:edlin' line editor operates normally.
;;   - ANSI syntax highlighting works natively.  IEx emits color codes
;;     and eat renders them.
;;   - No custom CAPF, no process-filter gymnastics, no output parsing.
;;
;; `eat' was chosen over `vterm' because:
;;   - It is pure Elisp with no C module compilation required, making
;;     the package trivially portable across platforms.
;;   - Its semi-char mode is a natural fit for REPL workflows, keeping
;;     standard Emacs navigation keys (C-x, M-x, etc.) while passing
;;     most other input to the terminal.
;;   - Terminal fidelity is more than sufficient for IEx.
;;
;;; Implementation:
;;
;; The package provides:
;;
;; REPL lifecycle:
;;   `elixir-iex'          Start a bare `iex' session.
;;   `elixir-iex-project'  Start `iex -S mix' in the project root.
;;   `elixir-iex-run-cmd'  Programmatic entry point (DIR + CMD).
;;
;; Send from source buffers:
;;   `elixir-iex-send-line'      Send the current line.
;;   `elixir-iex-send-region'    Send the active region.
;;   `elixir-iex-send-buffer'    Send the entire buffer.
;;   `elixir-iex-reload-module'  Send `r(ModuleName)' to reload.
;;
;; REPL management:
;;   `elixir-iex-set-repl'  Explicitly choose which REPL to use.
;;
;; `elixir-iex-minor-mode' provides keybindings under the `C-c i'
;; prefix and is intended to be activated via a hook on Elixir major
;; modes (e.g. `elixir-ts-mode').
;;
;; REPL resolution follows a priority order:
;;   1. Buffer-local override (set via `elixir-iex-set-repl').
;;   2. Project REPL (looked up by the current file's Mix project root).
;;   3. User prompt to select an existing REPL or create a new one.
;;
;; Project detection walks up the directory tree looking for mix.exs
;; files.  When `elixir-iex-prefer-umbrella' is non-nil (the
;; default), the outermost mix.exs is used, supporting umbrella
;; projects.
;;
;; The `ELIXIR_EDITOR' environment variable is set (scoped to the
;; spawned process via a let-bound `process-environment', not globally
;; via `setenv') so that IEx's `open/1' helper can open files in
;; Emacs via emacsclient.
;;
;;; Usage:
;;
;; Basic setup with `use-package':
;;
;;   (use-package elixir-iex
;;     :ensure t
;;     :after (elixir-ts-mode eat)
;;     :hook (elixir-ts-mode . elixir-iex-minor-mode)
;;     :bind (:map elixir-iex-minor-mode-map
;;                 ("C-c i i" . elixir-iex)
;;                 ("C-c i p" . elixir-iex-project)
;;                 ("C-c i l" . elixir-iex-send-line)
;;                 ("C-c i r" . elixir-iex-send-region)
;;                 ("C-c i b" . elixir-iex-send-buffer)
;;                 ("C-c i m" . elixir-iex-reload-module)
;;                 ("C-c i s" . elixir-iex-set-repl)))
;;
;; The minor mode keymap ships empty because `C-c <letter>' sequences
;; are reserved for user bindings per Emacs conventions.  The `:bind'
;; block above provides suggested defaults.
;;
;; All launch commands accept a prefix argument (C-u) to prompt for a
;; custom command string.

;;; Code:

(require 'eat)
(require 'map)
(require 'seq)

;;;; Customization

(defgroup elixir-iex nil
  "IEx REPL via the eat terminal emulator."
  :prefix "elixir-iex-"
  :group 'languages)

(defcustom elixir-iex-command "iex"
  "Command to start a bare IEx session."
  :type 'string
  :group 'elixir-iex)

(defcustom elixir-iex-project-command "iex -S mix"
  "Command to start an IEx session in the context of a Mix project."
  :type 'string
  :group 'elixir-iex)

(defcustom elixir-iex-prefer-umbrella t
  "When non-nil, find the outermost mix.exs when locating the project root."
  :type 'boolean
  :group 'elixir-iex)

(defcustom elixir-iex-switch-to-repl-on-send t
  "When non-nil, switch to the REPL window after any send command."
  :type 'boolean
  :group 'elixir-iex)

(defcustom elixir-iex-open-command
  "emacsclient --no-wait +__LINE__ __FILE__"
  "Value for the `ELIXIR_EDITOR' environment variable.
IEx uses this to open files from the REPL via the `open/1' helper.
Run `h(open)' in IEx for details.
Changing this variable does not affect already-running REPLs."
  :type 'string
  :group 'elixir-iex)

;;;; Internal state

(defvar-local elixir-iex--buffer nil
  "The IEx eat buffer associated with this source buffer.")

(defvar elixir-iex--project-buffers (make-hash-table :test 'equal)
  "Mapping of project root directories to their IEx eat buffers.")

(defvar elixir-iex--unaffiliated-buffers '()
  "List of IEx eat buffers not associated with any project.")

;;;; Project root detection

(defun elixir-iex--find-umbrella-root (dir)
  "Walk upward from DIR returning the highest directory containing mix.exs."
  (when-let* ((project-dir (locate-dominating-file dir "mix.exs")))
    (let ((parent (file-name-directory (directory-file-name project-dir))))
      (or (elixir-iex--find-umbrella-root parent)
          project-dir))))

(defun elixir-iex--find-project-root ()
  "Find the root of the current Mix project."
  (if elixir-iex-prefer-umbrella
      (elixir-iex--find-umbrella-root default-directory)
    (locate-dominating-file default-directory "mix.exs")))

;;;; Buffer management

(defun elixir-iex--buffer-live-p (buf)
  "Return non-nil if BUF is live and has a running process."
  (and buf
       (buffer-live-p buf)
       (get-buffer-process buf)
       (process-live-p (get-buffer-process buf))))

(defun elixir-iex--prune-dead-buffers ()
  "Remove dead buffers from the project and unaffiliated buffer lists."
  (setq elixir-iex--unaffiliated-buffers
        (seq-filter #'elixir-iex--buffer-live-p
                    elixir-iex--unaffiliated-buffers))
  (let ((dead-keys '()))
    (maphash (lambda (dir _buf)
               (unless (elixir-iex--buffer-live-p
                        (gethash dir elixir-iex--project-buffers))
                 (push dir dead-keys)))
             elixir-iex--project-buffers)
    (dolist (key dead-keys)
      (remhash key elixir-iex--project-buffers))))

(defun elixir-iex--all-live-buffers ()
  "Return a list of all live IEx eat buffers."
  (elixir-iex--prune-dead-buffers)
  (append elixir-iex--unaffiliated-buffers
          (map-values elixir-iex--project-buffers)))

(defun elixir-iex--project-name (dir)
  "Return a human-readable name for project at DIR."
  (if dir
      (file-name-nondirectory (directory-file-name dir))
    ""))

(defun elixir-iex--start (name cmd dir source-buf)
  "Start an IEx session named NAME running CMD in directory DIR.
DIR of nil means the REPL is not affiliated with any project.
SOURCE-BUF is the buffer that initiated the request; its
buffer-local `elixir-iex--buffer' is updated to point at the
new REPL.  Returns the eat buffer."
  (let* ((default-directory (or dir default-directory))
         (buf-name (format "*%s*" name))
         (existing (get-buffer buf-name)))
    ;; Kill existing dead buffer with that name.
    (when (and existing (not (elixir-iex--buffer-live-p existing)))
      (kill-buffer existing)
      (setq existing nil))
    ;; If already running, ask whether to kill and replace.
    (when (elixir-iex--buffer-live-p existing)
      (if (y-or-n-p (format "An IEx REPL is already running in %s.  Kill it? "
                            (or (elixir-iex--project-name dir) "this buffer")))
          (progn
            (delete-process (get-buffer-process existing))
            (kill-buffer existing)
            (setq existing nil))
        ;; User declined -- just switch to existing.
        (when source-buf
          (with-current-buffer source-buf
            (setq elixir-iex--buffer existing)))
        (pop-to-buffer existing)
        existing))
    ;; Start new REPL if we haven't returned early.
    (unless existing
      (let* ((process-environment
              (append (list (concat "ELIXIR_EDITOR="
                                    elixir-iex-open-command))
                      process-environment))
             (buf (eat-make buf-name
                            (or (executable-find "env") "/usr/bin/env")
                            nil "sh" "-c" cmd)))
        (with-current-buffer buf
          (eat-semi-char-mode))
        ;; Register in the appropriate tracking structure.
        (if dir
            (puthash dir buf elixir-iex--project-buffers)
          (unless (memq buf elixir-iex--unaffiliated-buffers)
            (push buf elixir-iex--unaffiliated-buffers)))
        ;; Update the source buffer's local binding.
        (when source-buf
          (with-current-buffer source-buf
            (setq elixir-iex--buffer buf)))
        (pop-to-buffer buf)
        buf))))

;;;; REPL resolution

(defun elixir-iex--determine-repl-buffer ()
  "Determine which REPL buffer to send to.
Priority:
1. Buffer-local override (`elixir-iex--buffer') if live.
2. Project REPL for the current file's project if live.
3. Prompt the user to select or create one."
  (cond
   ;; 1. Buffer-local override.
   ((elixir-iex--buffer-live-p elixir-iex--buffer)
    elixir-iex--buffer)
   ;; 2. Project REPL.
   ((when-let* ((proj-dir (elixir-iex--find-project-root))
                (proj-buf (gethash proj-dir elixir-iex--project-buffers))
                ((elixir-iex--buffer-live-p proj-buf)))
      (setq elixir-iex--buffer proj-buf)
      proj-buf))
   ;; 3. Prompt.
   (t (elixir-iex--prompt-repl-buffer))))

(defun elixir-iex--prompt-repl-buffer ()
  "Prompt the user to select a live IEx REPL or create a new one.
Sets the chosen buffer as `elixir-iex--buffer' for the current
source buffer and returns it."
  (let* ((live (elixir-iex--all-live-buffers))
         (choices (append '("Create new (iex)" "Create new (iex -S mix)")
                          (mapcar #'buffer-name live)))
         (selected (completing-read "IEx REPL: " choices nil t)))
    (let ((buf (cond
                ((equal selected "Create new (iex)")
                 (elixir-iex))
                ((equal selected "Create new (iex -S mix)")
                 (elixir-iex-project))
                (t
                 (get-buffer selected)))))
      (setq elixir-iex--buffer buf)
      buf)))

;;;; Public commands -- launch REPL

(defun elixir-iex-run-cmd (dir cmd)
  "Start an IEx REPL running CMD in project directory DIR.
DIR should be an absolute path to a Mix project root, or nil for
an unaffiliated REPL.  Returns the REPL buffer."
  (let ((name (if dir
                  (format "IEx - %s" (elixir-iex--project-name dir))
                "IEx")))
    (elixir-iex--start name cmd dir (current-buffer))))

;;;###autoload
(defun elixir-iex (&optional cmd)
  "Start an IEx REPL in an eat terminal buffer.
With prefix argument, prompt for CMD.  Otherwise use
`elixir-iex-command'."
  (interactive)
  (let ((cmd (if current-prefix-arg
                (read-string "IEx command: " elixir-iex-command)
              (or cmd elixir-iex-command))))
    (elixir-iex-run-cmd nil cmd)))

;;;###autoload
(defun elixir-iex-project (&optional cmd)
  "Start an IEx REPL in the context of the current Mix project.
With prefix argument, prompt for CMD.  Otherwise use
`elixir-iex-project-command'."
  (interactive)
  (if-let* ((root (elixir-iex--find-project-root)))
      (let ((cmd (if current-prefix-arg
                    (read-string "IEx project command: "
                                 elixir-iex-project-command)
                  (or cmd elixir-iex-project-command))))
        (elixir-iex-run-cmd root cmd))
    (user-error "No mix.exs found; use `elixir-iex' for a bare IEx session")))

;;;; Public commands -- send to REPL

(defun elixir-iex--send (text)
  "Send TEXT to the current IEx eat REPL.
If no live REPL is associated, prompt to select or create one."
  (when-let* ((buf (elixir-iex--determine-repl-buffer)))
    (with-current-buffer buf
      (eat-term-send-string eat-terminal (concat text "\n")))
    (when elixir-iex-switch-to-repl-on-send
      (pop-to-buffer buf))))

;;;###autoload
(defun elixir-iex-send-line ()
  "Send the current line to the IEx REPL."
  (interactive)
  (elixir-iex--send
   (buffer-substring-no-properties
    (line-beginning-position) (line-end-position))))

;;;###autoload
(defun elixir-iex-send-region ()
  "Send the active region to the IEx REPL."
  (interactive)
  (unless (use-region-p)
    (user-error "No active region"))
  (elixir-iex--send
   (buffer-substring-no-properties (region-beginning) (region-end))))

;;;###autoload
(defun elixir-iex-send-buffer ()
  "Send the entire buffer to the IEx REPL."
  (interactive)
  (elixir-iex--send
   (buffer-substring-no-properties (point-min) (point-max))))

;;;###autoload
(defun elixir-iex-reload-module ()
  "Reload the first module defined in the current buffer.
Sends `r(ModuleName)' to the IEx REPL, which recompiles all
modules defined in the file."
  (interactive)
  (let ((module (save-excursion
                  (goto-char (point-min))
                  (when (re-search-forward
                         "defmodule\\s-+\\([A-Z][A-Za-z0-9._]+\\)" nil t)
                    (match-string-no-properties 1)))))
    (unless module
      (user-error "No `defmodule' found in current buffer"))
    (elixir-iex--send (format "r(%s)" module))))

;;;; Public commands -- REPL management

;;;###autoload
(defun elixir-iex-set-repl ()
  "Select which IEx REPL this buffer should send to."
  (interactive)
  (elixir-iex--prompt-repl-buffer))

;;;; Minor mode for source buffers

(defvar elixir-iex-minor-mode-map
  (make-sparse-keymap)
  "Keymap for `elixir-iex-minor-mode'.
No default bindings are provided, as sequences like
\\`C-c <letter>' are reserved for users per Emacs conventions.
See the package Commentary or README for suggested bindings.")

;;;###autoload
(define-minor-mode elixir-iex-minor-mode
  "Minor mode for Elixir source buffers to interact with IEx REPLs."
  :lighter " IEx"
  :keymap elixir-iex-minor-mode-map)

(provide 'elixir-iex)
;;; elixir-iex.el ends here
