Files
ob-elixir/ob-elixir.el

955 lines
31 KiB
EmacsLisp

;;; ob-elixir.el --- Org Babel functions for Elixir -*- lexical-binding: t; -*-
;; Copyright (C) 2024 Your Name
;; Author: Your Name <your.email@example.com>
;; URL: https://github.com/username/ob-elixir
;; Keywords: literate programming, reproducible research, elixir
;; Version: 0.1.0
;; Package-Requires: ((emacs "27.1") (org "9.4"))
;; This file is not part of GNU Emacs.
;; 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.
;;; Commentary:
;; Org Babel support for evaluating Elixir code blocks.
;;
;; Features:
;; - Execute Elixir code in org-mode source blocks
;; - Support for :results value and :results output
;; - Variable passing with :var header argument
;; - Mix project context support
;;
;; Usage:
;; Add (elixir . t) to `org-babel-load-languages':
;;
;; (org-babel-do-load-languages
;; 'org-babel-load-languages
;; '((elixir . t)))
;;; Code:
(require 'ob)
(require 'ob-eval)
(require 'ob-comint)
(require 'cl-lib)
(require 'ansi-color)
;;; Customization
(defgroup ob-elixir nil
"Org Babel support for Elixir."
:group 'org-babel
:prefix "ob-elixir-")
(defcustom ob-elixir-command "elixir"
"Command to execute Elixir code.
Can be a full path or command name if in PATH."
:type 'string
:group 'ob-elixir
:safe #'stringp)
(defcustom ob-elixir-signal-errors t
"Whether to signal Emacs errors on Elixir execution failure.
When non-nil, Elixir errors will be signaled as Emacs errors.
When nil, errors are returned as the result string."
:type 'boolean
:group 'ob-elixir)
(defcustom ob-elixir-show-warnings t
"Whether to include warnings in output.
When non-nil, Elixir warnings are included in the result.
When nil, warnings are stripped from the output."
:type 'boolean
:group 'ob-elixir)
;;; Session Configuration
(defcustom ob-elixir-iex-command "iex"
"Command to start IEx session."
:type 'string
:group 'ob-elixir
:safe #'stringp)
(defconst ob-elixir--prompt-regexp
"^\\(?:iex\\|\\.\\.\\)([0-9]+)> "
"Regexp matching IEx prompt.
Matches both regular prompt `iex(N)> ' and continuation `...(N)> '.")
(defconst ob-elixir--eoe-marker
"__ob_elixir_eoe_marker__"
"End-of-evaluation marker for session output.")
(defvar ob-elixir--sessions (make-hash-table :test 'equal)
"Hash table mapping session names to buffer names.")
;;; Mix Dependencies Configuration
(defcustom ob-elixir-mix-command "mix"
"Command to run Mix."
:type 'string
:group 'ob-elixir
:safe #'stringp)
(defcustom ob-elixir-deps-cache-dir
(expand-file-name "ob-elixir" (or (getenv "XDG_CACHE_HOME") "~/.cache"))
"Directory for caching temporary Mix projects.
Each unique set of dependencies gets its own subdirectory,
named by the hash of the dependencies."
:type 'directory
:group 'ob-elixir)
(defvar ob-elixir--deps-projects (make-hash-table :test 'equal)
"Hash table mapping deps-hash to project directory path.")
(defvar ob-elixir--session-deps (make-hash-table :test 'equal)
"Hash table mapping session names to their deps-hash.
Used to ensure session deps consistency.")
(defvar ob-elixir--cleanup-registered nil
"Whether the cleanup hook has been registered.")
;;; Header Arguments
(defvar org-babel-default-header-args:elixir
'((:results . "value")
(:session . "none"))
"Default header arguments for Elixir code blocks.")
;;; Language Registration
;; File extension for tangling
(add-to-list 'org-babel-tangle-lang-exts '("elixir" . "ex"))
;; Associate with elixir-mode for syntax highlighting (if available)
(with-eval-after-load 'org-src
(add-to-list 'org-src-lang-modes '("elixir" . elixir)))
;;; Error Handling
(defconst ob-elixir--error-regexp
"^\\*\\* (\\([A-Za-z.]+Error\\))\\(.*\\)"
"Regexp matching Elixir runtime errors.
Group 1 is the error type, group 2 is the message.")
(defconst ob-elixir--compile-error-regexp
"^\\*\\* (\\(CompileError\\|TokenMissingError\\|SyntaxError\\))\\(.*\\)"
"Regexp matching Elixir compile-time errors.")
(defconst ob-elixir--warning-regexp
"^warning: \\(.*\\)"
"Regexp matching Elixir warnings.")
(define-error 'ob-elixir-error
"Elixir evaluation error")
(define-error 'ob-elixir-compile-error
"Elixir compilation error"
'ob-elixir-error)
(define-error 'ob-elixir-runtime-error
"Elixir runtime error"
'ob-elixir-error)
(defun ob-elixir--detect-error (output)
"Check OUTPUT for Elixir errors.
Returns a plist with :type, :message, and :line if an error is found.
Returns nil if no error detected."
(cond
;; Compile-time error
((string-match ob-elixir--compile-error-regexp output)
(list :type 'compile
:error-type (match-string 1 output)
:message (string-trim (match-string 2 output))
:full-output output))
;; Runtime error
((string-match ob-elixir--error-regexp output)
(list :type 'runtime
:error-type (match-string 1 output)
:message (string-trim (match-string 2 output))
:full-output output))
;; No error
(t nil)))
(defun ob-elixir--format-error (error-info)
"Format ERROR-INFO into a user-friendly message."
(let ((type (plist-get error-info :type))
(error-type (plist-get error-info :error-type))
(message (plist-get error-info :message)))
(format "Elixir %s: (%s) %s"
(if (eq type 'compile) "Compile Error" "Runtime Error")
error-type
message)))
(defun ob-elixir--strip-warnings (output)
"Remove warning lines from OUTPUT if configured."
(if ob-elixir-show-warnings
output
(let ((lines (split-string output "\n")))
(mapconcat #'identity
(cl-remove-if (lambda (line)
(string-match-p ob-elixir--warning-regexp line))
lines)
"\n"))))
(defun ob-elixir--process-result (result)
"Process RESULT from Elixir execution.
Checks for errors and handles them according to `ob-elixir-signal-errors'.
Returns the cleaned result string."
(let ((trimmed (string-trim result))
(error-info (ob-elixir--detect-error result)))
(if error-info
(if ob-elixir-signal-errors
(signal (if (eq (plist-get error-info :type) 'compile)
'ob-elixir-compile-error
'ob-elixir-runtime-error)
(list (ob-elixir--format-error error-info)))
;; Return error as result
(plist-get error-info :full-output))
;; No error, return trimmed result
trimmed)))
;;; Type Conversion
(defun ob-elixir--escape-string (str)
"Escape special characters in STR for Elixir string literal."
(let ((result str))
;; Escape backslashes first
(setq result (replace-regexp-in-string "\\\\" "\\\\\\\\" result))
;; Escape double quotes
(setq result (replace-regexp-in-string "\"" "\\\\\"" result))
;; Escape newlines
(setq result (replace-regexp-in-string "\n" "\\\\n" result))
;; Escape tabs
(setq result (replace-regexp-in-string "\t" "\\\\t" result))
result))
(defun ob-elixir--elisp-to-elixir (value)
"Convert Elisp VALUE to Elixir literal syntax.
Handles:
- nil -> nil
- t -> true
- numbers -> numbers
- strings -> quoted strings
- symbols -> atoms
- lists -> Elixir lists
- vectors -> tuples"
(cond
;; nil
((null value) "nil")
;; Boolean true
((eq value t) "true")
;; Numbers
((numberp value)
(number-to-string value))
;; Strings
((stringp value)
(format "\"%s\"" (ob-elixir--escape-string value)))
;; Symbols become atoms (except special ones)
((symbolp value)
(let ((name (symbol-name value)))
(cond
((string= name "hline") ":hline")
((string-match-p "^[a-z_][a-zA-Z0-9_]*[?!]?$" name)
(format ":%s" name))
(t (format ":\"%s\"" name)))))
;; Vectors become tuples
((vectorp value)
(format "{%s}"
(mapconcat #'ob-elixir--elisp-to-elixir
(append value nil) ", ")))
;; Lists
((listp value)
(format "[%s]"
(mapconcat #'ob-elixir--elisp-to-elixir value ", ")))
;; Fallback
(t (format "%S" value))))
;;; Result Formatting
(defvar ob-elixir-nil-to 'hline
"Elisp value to use for Elixir nil in table cells.
When nil appears in an Elixir list that becomes a table,
it is replaced with this value. Use `hline' for org table
horizontal lines, or nil for empty cells.")
(defun ob-elixir--parse-value (str)
"Parse STR as a simple Elixir value."
(let ((trimmed (string-trim str)))
(cond
((string= trimmed "nil") nil)
((string= trimmed "true") t)
((string= trimmed "false") nil)
((string-match-p "^[0-9]+$" trimmed)
(string-to-number trimmed))
((string-match-p "^[0-9]+\\.[0-9]+$" trimmed)
(string-to-number trimmed))
((string-match-p "^\".*\"$" trimmed)
(substring trimmed 1 -1))
((string-match-p "^:.*$" trimmed)
(intern (substring trimmed 1)))
(t trimmed))))
(defun ob-elixir--parse-keyword-list (str)
"Parse STR as Elixir keyword list into alist.
Handles format like: [a: 1, b: 2]"
(when (string-match "^\\[\\(.*\\)\\]$" str)
(let ((content (match-string 1 str)))
(when (string-match-p "^[a-z_]+:" content)
(let ((pairs '()))
(dolist (part (split-string content ", "))
(when (string-match "^\\([a-z_]+\\):\\s-*\\(.+\\)$" part)
(push (cons (intern (match-string 1 part))
(ob-elixir--parse-value (match-string 2 part)))
pairs)))
(nreverse pairs))))))
(defun ob-elixir--sanitize-row (row)
"Sanitize a single ROW for table display."
(if (listp row)
(mapcar (lambda (cell)
(cond
((null cell) ob-elixir-nil-to)
((eq cell 'nil) ob-elixir-nil-to)
(t cell)))
row)
row))
(defun ob-elixir--sanitize-table (data)
"Sanitize DATA for use as an org table.
Replaces nil values according to `ob-elixir-nil-to'.
Ensures consistent structure for table rendering."
(cond
;; Not a list - return as-is
((not (listp data)) data)
;; Empty list
((null data) nil)
;; List of lists - could be table
((and (listp (car data)) (not (null (car data))))
(mapcar #'ob-elixir--sanitize-row data))
;; Simple list - single row
(t (ob-elixir--sanitize-row data))))
(defun ob-elixir--table-or-string (result)
"Convert RESULT to Emacs table or string.
If RESULT looks like a list, parse it into an Elisp list.
Otherwise return as string.
Uses `org-babel-script-escape' for parsing."
(let ((trimmed (string-trim result)))
(cond
;; Empty result
((string-empty-p trimmed) nil)
;; Looks like a list - try to parse
((string-match-p "^\\[.*\\]$" trimmed)
(condition-case nil
(let ((parsed (org-babel-script-escape trimmed)))
(ob-elixir--sanitize-table parsed))
(error trimmed)))
;; Looks like a tuple - convert to list first
((string-match-p "^{.*}$" trimmed)
(condition-case nil
(let* ((as-list (replace-regexp-in-string
"^{\\(.*\\)}$" "[\\1]" trimmed))
(parsed (org-babel-script-escape as-list)))
(ob-elixir--sanitize-table parsed))
(error trimmed)))
;; Scalar value
(t trimmed))))
;;; Variable Handling
(defun ob-elixir--var-name (name)
"Convert NAME to a valid Elixir variable name.
Elixir variables must start with lowercase or underscore."
(let ((str (if (symbolp name) (symbol-name name) name)))
;; Ensure starts with lowercase or underscore
(if (string-match-p "^[a-z_]" str)
str
(concat "_" str))))
(defun org-babel-variable-assignments:elixir (params)
"Return list of Elixir statements assigning variables from PARAMS.
Each statement has the form: var_name = value"
(mapcar
(lambda (pair)
(let ((name (car pair))
(value (cdr pair)))
(format "%s = %s"
(ob-elixir--var-name name)
(ob-elixir--elisp-to-elixir value))))
(org-babel--get-vars params)))
;;; Deps Block Parsing
(defconst ob-elixir--deps-block-regexp
"^[ \t]*#\\+BEGIN_DEPS[ \t]+elixir[ \t]*\n\\(\\(?:.*\n\\)*?\\)[ \t]*#\\+END_DEPS"
"Regexp matching a deps block.
Group 1 captures the deps content.")
(defun ob-elixir--find-deps-for-position (pos)
"Find the most recent deps block before POS.
Returns the deps content as a string, or nil if no deps block found."
(save-excursion
(goto-char pos)
(let ((found nil))
;; Search backward for deps blocks
(while (and (not found)
(re-search-backward ob-elixir--deps-block-regexp nil t))
(when (< (match-end 0) pos)
(setq found (match-string-no-properties 1))))
found)))
(defun ob-elixir--normalize-deps (deps-string)
"Normalize DEPS-STRING for consistent hashing.
Removes comments, extra whitespace, and normalizes formatting."
(let ((normalized deps-string))
;; Remove Elixir comments
(setq normalized (replace-regexp-in-string "#.*$" "" normalized))
;; Normalize whitespace
(setq normalized (replace-regexp-in-string "[ \t\n]+" " " normalized))
;; Trim
(string-trim normalized)))
(defun ob-elixir--hash-deps (deps-string)
"Compute SHA256 hash of DEPS-STRING for caching."
(secure-hash 'sha256 (ob-elixir--normalize-deps deps-string)))
;;; Temporary Mix Project Management
(defconst ob-elixir--mix-exs-template
"defmodule ObElixirTemp.MixProject do
use Mix.Project
def project do
[
app: :ob_elixir_temp,
version: \"0.1.0\",
elixir: \"~> 1.14\",
start_permanent: false,
deps: deps()
]
end
def application do
[extra_applications: [:logger]]
end
defp deps do
%s
end
end
"
"Template for temporary Mix project mix.exs file.
%s is replaced with the deps list.")
(defun ob-elixir--ensure-cache-dir ()
"Ensure the cache directory exists."
(unless (file-directory-p ob-elixir-deps-cache-dir)
(make-directory ob-elixir-deps-cache-dir t)))
(defun ob-elixir--get-deps-project (deps-string)
"Get or create a Mix project for DEPS-STRING.
Returns the project directory path, creating and initializing
the project if necessary."
(let* ((deps-hash (ob-elixir--hash-deps deps-string))
(cached (gethash deps-hash ob-elixir--deps-projects)))
(if (and cached (file-directory-p cached))
cached
;; Create new project
(ob-elixir--create-deps-project deps-hash deps-string))))
(defun ob-elixir--create-deps-project (deps-hash deps-string)
"Create a new temporary Mix project for DEPS-STRING.
DEPS-HASH is used as the directory name.
Returns the project directory path."
(ob-elixir--ensure-cache-dir)
(ob-elixir--register-cleanup)
(let* ((project-dir (expand-file-name deps-hash ob-elixir-deps-cache-dir))
(mix-exs-path (expand-file-name "mix.exs" project-dir))
(lib-dir (expand-file-name "lib" project-dir)))
;; Create project structure
(make-directory project-dir t)
(make-directory lib-dir t)
;; Write mix.exs
(with-temp-file mix-exs-path
(insert (format ob-elixir--mix-exs-template deps-string)))
;; Write a placeholder module
(with-temp-file (expand-file-name "ob_elixir_temp.ex" lib-dir)
(insert "defmodule ObElixirTemp do\nend\n"))
;; Fetch dependencies
(let ((default-directory project-dir))
(message "ob-elixir: Fetching dependencies...")
(let ((output (shell-command-to-string
(format "%s deps.get 2>&1" ob-elixir-mix-command))))
(when (string-match-p "\\*\\*.*error\\|Could not" output)
(error "ob-elixir: Failed to fetch dependencies:\n%s" output)))
;; Compile
(message "ob-elixir: Compiling dependencies...")
(let ((output (shell-command-to-string
(format "%s compile 2>&1" ob-elixir-mix-command))))
(when (string-match-p "\\*\\*.*[Ee]rror" output)
(error "ob-elixir: Failed to compile dependencies:\n%s" output))))
;; Cache and return
(puthash deps-hash project-dir ob-elixir--deps-projects)
(message "ob-elixir: Dependencies ready")
project-dir))
;;; Execution with Dependencies
(defun ob-elixir--execute-with-deps (body result-type deps-string)
"Execute BODY with dependencies from DEPS-STRING.
RESULT-TYPE is `value' or `output'."
(let* ((project-dir (ob-elixir--get-deps-project deps-string))
(default-directory project-dir)
(tmp-file (org-babel-temp-file "ob-elixir-" ".exs"))
(code (if (eq result-type 'value)
(ob-elixir--wrap-for-value body)
body)))
;; Write code to temp file
(with-temp-file tmp-file
(insert code))
;; Execute with mix run
(let ((command (format "%s run --no-compile %s 2>&1"
ob-elixir-mix-command
(shell-quote-argument tmp-file))))
(ob-elixir--process-result
(shell-command-to-string command)))))
;;; Session Management
(defun org-babel-elixir-initiate-session (&optional session params)
"Create or return an Elixir session buffer.
SESSION is the session name (string or nil).
PARAMS are the header arguments.
Returns the session buffer, or nil if SESSION is \"none\"."
(unless (or (not session) (string= session "none"))
(let* ((session-name (if (stringp session) session "default"))
(buffer (ob-elixir--get-or-create-session session-name params)))
(when buffer
(puthash session-name (buffer-name buffer) ob-elixir--sessions))
buffer)))
(defun ob-elixir--get-or-create-session (name params)
"Get or create an IEx session named NAME with PARAMS."
(let* ((buffer-name (format "*ob-elixir:%s*" name))
(existing (get-buffer buffer-name)))
(if (and existing (org-babel-comint-buffer-livep existing))
existing
(ob-elixir--start-session buffer-name name params))))
(defun ob-elixir--start-session (buffer-name session-name _params)
"Start a new IEx session in BUFFER-NAME.
SESSION-NAME is used for the process name.
_PARAMS is reserved for future use."
(let* ((buffer (get-buffer-create buffer-name))
(process-environment (cons "TERM=dumb" process-environment)))
(with-current-buffer buffer
;; Start the IEx process
(make-comint-in-buffer
(format "ob-elixir-%s" session-name)
buffer
ob-elixir-iex-command
nil)
;; Wait for initial prompt
(ob-elixir--wait-for-prompt buffer 10)
;; Configure IEx for programmatic use
(ob-elixir--configure-session buffer)
buffer)))
(defun ob-elixir--configure-session (buffer)
"Configure IEx session in BUFFER for programmatic use."
(let ((config-commands
'("IEx.configure(colors: [enabled: false])"
"IEx.configure(inspect: [limit: :infinity, printable_limit: :infinity])")))
(dolist (cmd config-commands)
(ob-elixir--send-command buffer cmd)
(ob-elixir--wait-for-prompt buffer 5))))
;;; Session Prompt Detection
(defun ob-elixir--wait-for-prompt (buffer timeout)
"Wait for IEx prompt in BUFFER with TIMEOUT seconds."
(with-current-buffer buffer
(let ((end-time (+ (float-time) timeout)))
(while (and (< (float-time) end-time)
(not (ob-elixir--at-prompt-p)))
(accept-process-output (get-buffer-process buffer) 0.1)
(goto-char (point-max)))
(ob-elixir--at-prompt-p))))
(defun ob-elixir--at-prompt-p ()
"Return t if the last line in buffer looks like an IEx prompt."
(save-excursion
(goto-char (point-max))
(forward-line 0)
(looking-at ob-elixir--prompt-regexp)))
;;; Session Command Sending
(defun ob-elixir--send-command (buffer command)
"Send COMMAND to IEx process in BUFFER."
(with-current-buffer buffer
(goto-char (point-max))
(insert command)
(comint-send-input nil t)))
(defconst ob-elixir--session-value-wrapper
"_ob_result_ = (
%s
)
IO.puts(\"__ob_value_start__\")
IO.puts(inspect(_ob_result_, limit: :infinity, printable_limit: :infinity))
IO.puts(\"__ob_value_end__\")
:ok
"
"Wrapper for capturing value in session mode.")
(defun ob-elixir--session-wrap-for-value (body)
"Wrap BODY to capture its value in session mode."
(format ob-elixir--session-value-wrapper body))
(defun ob-elixir--evaluate-in-session (session body result-type)
"Evaluate BODY in SESSION, return result.
RESULT-TYPE is `value' or `output'."
(let* ((buffer (org-babel-elixir-initiate-session session nil))
(code (if (eq result-type 'value)
(ob-elixir--session-wrap-for-value body)
body))
(eoe-indicator ob-elixir--eoe-marker)
(full-body (concat code "\nIO.puts(\"" eoe-indicator "\")"))
output)
(unless buffer
(error "Failed to create Elixir session: %s" session))
(setq output
(org-babel-comint-with-output
(buffer eoe-indicator t full-body)
(ob-elixir--send-command buffer full-body)))
(ob-elixir--clean-session-output output result-type)))
;;; Session Output Extraction
(defun ob-elixir--extract-session-output (buffer start-marker)
"Extract output from BUFFER since START-MARKER."
(with-current-buffer buffer
(let ((end-pos (point-max)))
(buffer-substring-no-properties start-marker end-pos))))
(defun ob-elixir--clean-session-output (output result-type)
"Clean OUTPUT from IEx session.
RESULT-TYPE is `value' or `output'."
(let ((result (if (listp output)
(mapconcat #'identity output "\n")
output)))
;; Remove ANSI escape codes
(setq result (ansi-color-filter-apply result))
;; Remove prompts (including continuation prompts)
(setq result (replace-regexp-in-string
"^\\(?:iex\\|\\.\\.\\)([0-9]+)> *" "" result))
;; Remove EOE marker output
(setq result (replace-regexp-in-string
(regexp-quote ob-elixir--eoe-marker) "" result))
;; For value results, extract the value between markers
(when (eq result-type 'value)
(if (string-match "__ob_value_start__\n\\(\\(?:.\\|\n\\)*?\\)\n__ob_value_end__" result)
(setq result (match-string 1 result))
;; Fallback: remove wrapper artifacts
(setq result (replace-regexp-in-string
"^_ob_result_ = (\n?" "" result))
(setq result (replace-regexp-in-string
"\n?)$" "" result))
(setq result (replace-regexp-in-string
"^IO\\.puts.*\n?" "" result))
(setq result (replace-regexp-in-string
":ok$" "" result))))
(string-trim result)))
;;; Session Prep
(defun org-babel-prep-session:elixir (session params)
"Prepare SESSION according to PARAMS.
Sends variable assignments to the session."
(let ((buffer (org-babel-elixir-initiate-session session params))
(var-lines (org-babel-variable-assignments:elixir params)))
(when (and buffer var-lines)
(dolist (var-line var-lines)
(ob-elixir--send-command buffer var-line)
(ob-elixir--wait-for-prompt buffer 5)))
buffer))
;;; Session Cleanup
(defun ob-elixir-kill-session (session)
"Kill the Elixir session named SESSION."
(interactive
(list (completing-read "Kill session: "
(hash-table-keys ob-elixir--sessions))))
(let ((buffer-name (gethash session ob-elixir--sessions)))
(when buffer-name
(let ((buffer (get-buffer buffer-name)))
(when buffer
(let ((process (get-buffer-process buffer)))
(when process
(delete-process process)))
(kill-buffer buffer)))
(remhash session ob-elixir--sessions))))
(defun ob-elixir-kill-all-sessions ()
"Kill all Elixir sessions."
(interactive)
(maphash (lambda (name _buffer)
(ob-elixir-kill-session name))
ob-elixir--sessions))
;;; Session with Dependencies
(defun ob-elixir--start-session-with-deps (buffer-name session-name deps-string)
"Start a new IEx session with dependencies in BUFFER-NAME.
SESSION-NAME is used for the process name.
DEPS-STRING contains the dependencies specification."
(let* ((project-dir (ob-elixir--get-deps-project deps-string))
(buffer (get-buffer-create buffer-name))
(default-directory project-dir)
(process-environment (cons "TERM=dumb" process-environment)))
(with-current-buffer buffer
;; Start IEx with Mix project
(make-comint-in-buffer
(format "ob-elixir-%s" session-name)
buffer
ob-elixir-iex-command
nil
"-S" "mix")
;; Wait for initial prompt (longer timeout for deps loading)
(ob-elixir--wait-for-prompt buffer 30)
;; Configure IEx for programmatic use
(ob-elixir--configure-session buffer)
buffer)))
(defun ob-elixir--get-or-create-session-with-deps (name deps-string)
"Get or create an IEx session NAME with DEPS-STRING."
(let* ((deps-hash (ob-elixir--hash-deps deps-string))
(buffer-name (format "*ob-elixir:%s*" name))
(existing (get-buffer buffer-name))
(existing-deps (gethash name ob-elixir--session-deps)))
;; Check if existing session has different deps
(when (and existing existing-deps (not (string= existing-deps deps-hash)))
(message "ob-elixir: Session %s has different deps, recreating..." name)
(ob-elixir-kill-session name)
(setq existing nil))
(if (and existing (org-babel-comint-buffer-livep existing))
existing
;; Create new session
(let ((buffer (ob-elixir--start-session-with-deps buffer-name name deps-string)))
(puthash name (buffer-name buffer) ob-elixir--sessions)
(puthash name deps-hash ob-elixir--session-deps)
buffer))))
(defun ob-elixir--evaluate-in-session-with-deps (session body result-type deps-string)
"Evaluate BODY in SESSION with DEPS-STRING context.
RESULT-TYPE is `value' or `output'."
(let* ((buffer (ob-elixir--get-or-create-session-with-deps session deps-string))
(code (if (eq result-type 'value)
(ob-elixir--session-wrap-for-value body)
body))
(eoe-indicator ob-elixir--eoe-marker)
(full-body (concat code "\nIO.puts(\"" eoe-indicator "\")"))
output)
(unless buffer
(error "Failed to create Elixir session with deps: %s" session))
(setq output
(org-babel-comint-with-output
(buffer eoe-indicator t full-body)
(ob-elixir--send-command buffer full-body)))
(ob-elixir--clean-session-output output result-type)))
;;; Deps Project Cleanup
(defun ob-elixir--register-cleanup ()
"Register cleanup hook if not already registered."
(unless ob-elixir--cleanup-registered
(add-hook 'kill-emacs-hook #'ob-elixir-cleanup-deps-projects)
(setq ob-elixir--cleanup-registered t)))
(defun ob-elixir-cleanup-deps-projects ()
"Delete all temporary Mix projects created by ob-elixir.
Called automatically on Emacs exit."
(interactive)
(maphash
(lambda (_hash project-dir)
(when (and project-dir (file-directory-p project-dir))
(condition-case err
(delete-directory project-dir t)
(error
(message "ob-elixir: Failed to delete %s: %s" project-dir err)))))
ob-elixir--deps-projects)
(clrhash ob-elixir--deps-projects)
(message "ob-elixir: Cleaned up temporary Mix projects"))
(defun ob-elixir-list-deps-projects ()
"List all cached dependency projects."
(interactive)
(if (= (hash-table-count ob-elixir--deps-projects) 0)
(message "No cached dependency projects")
(with-output-to-temp-buffer "*ob-elixir deps projects*"
(princ "Cached ob-elixir dependency projects:\n\n")
(maphash
(lambda (hash dir)
(princ (format " %s\n -> %s\n" (substring hash 0 12) dir)))
ob-elixir--deps-projects))))
;;; Execution
(defconst ob-elixir--value-wrapper
"result = (
%s
)
IO.puts(inspect(result, limit: :infinity, printable_limit: :infinity, charlists: :as_lists))
"
"Wrapper template for capturing Elixir expression value.
%s is replaced with the user's code.")
(defun ob-elixir--wrap-for-value (body)
"Wrap BODY to capture its return value.
The wrapper evaluates BODY, then prints the result using
`inspect/2` with infinite limits to avoid truncation."
(format ob-elixir--value-wrapper body))
(defun ob-elixir--execute (body result-type)
"Execute BODY as Elixir code.
RESULT-TYPE is either `value' or `output'.
For `value', wraps code to capture return value.
For `output', captures stdout directly.
Returns the result as a string.
May signal `ob-elixir-error' if execution fails and
`ob-elixir-signal-errors' is non-nil."
(let* ((tmp-file (org-babel-temp-file "ob-elixir-" ".exs"))
(code (if (eq result-type 'value)
(ob-elixir--wrap-for-value body)
body)))
(with-temp-file tmp-file
(insert code))
(let ((result (with-temp-buffer
(call-process ob-elixir-command nil t nil
(org-babel-process-file-name tmp-file))
;; Capture both stdout and stderr
(buffer-string))))
(ob-elixir--process-result result))))
(defun org-babel-execute:elixir (body params)
"Execute a block of Elixir code with org-babel.
BODY is the Elixir code to execute.
PARAMS is an alist of header arguments.
This function is called by `org-babel-execute-src-block'."
(let* ((session (cdr (assq :session params)))
(result-type (cdr (assq :result-type params)))
(result-params (cdr (assq :result-params params)))
;; Find deps for this block's position
(deps-string (ob-elixir--find-deps-for-position (point)))
;; Expand body with variable assignments
(full-body (org-babel-expand-body:generic
body params
(org-babel-variable-assignments:elixir params)))
(result (cond
;; Session mode with deps
((and session (not (string= session "none")) deps-string)
(ob-elixir--evaluate-in-session-with-deps
session full-body result-type deps-string))
;; Session mode without deps
((and session (not (string= session "none")))
(ob-elixir--evaluate-in-session session full-body result-type))
;; Non-session with deps
(deps-string
(ob-elixir--execute-with-deps full-body result-type deps-string))
;; Plain execution
(t
(ob-elixir--execute full-body result-type)))))
(org-babel-reassemble-table
(org-babel-result-cond result-params
;; For output/scalar/verbatim - return as-is
result
;; For value - parse into Elisp data
(ob-elixir--table-or-string result))
(org-babel-pick-name (cdr (assq :colname-names params))
(cdr (assq :colnames params)))
(org-babel-pick-name (cdr (assq :rowname-names params))
(cdr (assq :rownames params))))))
(provide 'ob-elixir)
;;; ob-elixir.el ends here