1130 lines
40 KiB
EmacsLisp
1130 lines
40 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 (plist-get error-info :full-output)))
|
|
;; 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.")
|
|
|
|
(defconst ob-elixir--imports-block-regexp
|
|
"^[ \t]*#\\+BEGIN_IMPORTS[ \t]+elixir[ \t]*\n\\(\\(?:.*\n\\)*?\\)[ \t]*#\\+END_IMPORTS"
|
|
"Regexp matching an imports block.
|
|
Group 1 captures the imports 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--find-imports-for-position (pos)
|
|
"Find the most recent imports block before POS.
|
|
|
|
Returns the imports content as a string, or nil if no imports block found."
|
|
(save-excursion
|
|
(goto-char pos)
|
|
(let ((found nil))
|
|
(while (and (not found)
|
|
(re-search-backward ob-elixir--imports-block-regexp nil t))
|
|
(when (< (match-end 0) pos)
|
|
(setq found (match-string-no-properties 1))))
|
|
found)))
|
|
|
|
;;; Module Definition Block Parsing
|
|
|
|
(defun ob-elixir--find-all-module-blocks (pos)
|
|
"Find all module definition blocks before POS.
|
|
|
|
Scans the buffer for Elixir source blocks with a :module header argument.
|
|
Returns an alist of (MODULE-NAME . BODY-STRING) with merged bodies
|
|
for blocks sharing the same module name.
|
|
|
|
Blocks are processed in document order, so later blocks with the same
|
|
module name have their content appended to earlier blocks."
|
|
(save-excursion
|
|
(save-restriction
|
|
(widen)
|
|
(goto-char (point-min))
|
|
(let ((modules (make-hash-table :test 'equal)))
|
|
(org-element-map (org-element-parse-buffer) 'src-block
|
|
(lambda (src-block)
|
|
(when (and (< (org-element-property :begin src-block) pos)
|
|
(string= (org-element-property :language src-block) "elixir"))
|
|
(let* ((params (org-babel-parse-header-arguments
|
|
(or (org-element-property :parameters src-block) "")))
|
|
(module-name (cdr (assq :module params))))
|
|
(when module-name
|
|
(let* ((body (org-element-property :value src-block))
|
|
(existing (gethash module-name modules "")))
|
|
(puthash module-name
|
|
(if (string-empty-p existing)
|
|
(string-trim body)
|
|
(concat existing "\n\n" (string-trim body)))
|
|
modules)))))))
|
|
;; Convert hash table to alist
|
|
(let (result)
|
|
(maphash (lambda (k v) (push (cons k v) result)) modules)
|
|
(nreverse result))))))
|
|
|
|
(defun ob-elixir--generate-module-definitions (modules-alist)
|
|
"Generate Elixir module definitions from MODULES-ALIST.
|
|
|
|
Each entry in MODULES-ALIST is (MODULE-NAME . BODY-STRING).
|
|
Returns a string with all defmodule definitions separated by blank lines,
|
|
or nil if MODULES-ALIST is empty."
|
|
(when modules-alist
|
|
(mapconcat
|
|
(lambda (entry)
|
|
(format "defmodule %s do\n%s\nend" (car entry) (cdr entry)))
|
|
modules-alist
|
|
"\n\n")))
|
|
|
|
(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 &optional imports-string modules-string)
|
|
"Execute BODY with dependencies from DEPS-STRING.
|
|
|
|
RESULT-TYPE is `value' or `output'.
|
|
IMPORTS-STRING, if provided, is prepended after module definitions.
|
|
MODULES-STRING, if provided, contains module definitions to prepend first.
|
|
|
|
When modules are defined, imports and user code are wrapped in Code.eval_string.
|
|
This is required because Elixir cannot import a module defined in the same file
|
|
at the top level - Code.eval_string defers evaluation until runtime."
|
|
(let* ((project-dir (ob-elixir--get-deps-project deps-string))
|
|
(default-directory project-dir)
|
|
(tmp-file (org-babel-temp-file "ob-elixir-" ".exs"))
|
|
(wrapped (if (eq result-type 'value)
|
|
(ob-elixir--wrap-for-value body)
|
|
body))
|
|
(code (if modules-string
|
|
;; When modules are defined, wrap imports + code in Code.eval_string
|
|
(let ((eval-body (concat
|
|
(when imports-string (concat (string-trim imports-string) "\n"))
|
|
wrapped)))
|
|
(concat
|
|
(string-trim modules-string) "\n\n"
|
|
"Code.eval_string(\"\"\"\n"
|
|
(ob-elixir--escape-for-eval-string eval-body)
|
|
"\n\"\"\")"))
|
|
;; Normal path without modules
|
|
(concat
|
|
(when imports-string (concat (string-trim imports-string) "\n\n"))
|
|
wrapped))))
|
|
|
|
;; 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 &optional imports-string modules-string)
|
|
"Evaluate BODY in SESSION, return result.
|
|
|
|
RESULT-TYPE is `value' or `output'.
|
|
IMPORTS-STRING, if provided, is prepended after module definitions.
|
|
MODULES-STRING, if provided, contains module definitions to prepend first.
|
|
|
|
When modules are defined, imports and user code are wrapped in Code.eval_string.
|
|
This is required because Elixir cannot import a module defined in the same file
|
|
at the top level - Code.eval_string defers evaluation until runtime."
|
|
(let* ((buffer (org-babel-elixir-initiate-session session nil))
|
|
(wrapped (if (eq result-type 'value)
|
|
(ob-elixir--session-wrap-for-value body)
|
|
body))
|
|
(code (if modules-string
|
|
;; When modules are defined, wrap imports + code in Code.eval_string
|
|
(let ((eval-body (concat
|
|
(when imports-string (concat (string-trim imports-string) "\n"))
|
|
wrapped)))
|
|
(concat
|
|
(string-trim modules-string) "\n\n"
|
|
"Code.eval_string(\"\"\"\n"
|
|
(ob-elixir--escape-for-eval-string eval-body)
|
|
"\n\"\"\")"))
|
|
;; Normal path without modules
|
|
(concat
|
|
(when imports-string (concat (string-trim imports-string) "\n\n"))
|
|
wrapped)))
|
|
(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 &optional imports-string modules-string)
|
|
"Evaluate BODY in SESSION with DEPS-STRING context.
|
|
|
|
RESULT-TYPE is `value' or `output'.
|
|
IMPORTS-STRING, if provided, is prepended after module definitions.
|
|
MODULES-STRING, if provided, contains module definitions to prepend first.
|
|
|
|
When modules are defined, imports and user code are wrapped in Code.eval_string.
|
|
This is required because Elixir cannot import a module defined in the same file
|
|
at the top level - Code.eval_string defers evaluation until runtime."
|
|
(let* ((buffer (ob-elixir--get-or-create-session-with-deps session deps-string))
|
|
(wrapped (if (eq result-type 'value)
|
|
(ob-elixir--session-wrap-for-value body)
|
|
body))
|
|
(code (if modules-string
|
|
;; When modules are defined, wrap imports + code in Code.eval_string
|
|
(let ((eval-body (concat
|
|
(when imports-string (concat (string-trim imports-string) "\n"))
|
|
wrapped)))
|
|
(concat
|
|
(string-trim modules-string) "\n\n"
|
|
"Code.eval_string(\"\"\"\n"
|
|
(ob-elixir--escape-for-eval-string eval-body)
|
|
"\n\"\"\")"))
|
|
;; Normal path without modules
|
|
(concat
|
|
(when imports-string (concat (string-trim imports-string) "\n\n"))
|
|
wrapped)))
|
|
(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--escape-for-eval-string (str)
|
|
"Escape STR for use inside Elixir Code.eval_string heredoc.
|
|
|
|
Escapes backslashes and double quotes."
|
|
(let ((result str))
|
|
(setq result (replace-regexp-in-string "\\\\" "\\\\\\\\" result))
|
|
(setq result (replace-regexp-in-string "\"" "\\\\\"" result))
|
|
result))
|
|
|
|
(defun ob-elixir--execute (body result-type &optional imports-string modules-string)
|
|
"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.
|
|
IMPORTS-STRING, if provided, contains import/alias/require statements.
|
|
MODULES-STRING, if provided, contains module definitions to prepend.
|
|
|
|
Code is assembled in this order:
|
|
1. Module definitions (MODULES-STRING)
|
|
2. Imports (IMPORTS-STRING) - wrapped in Code.eval_string if modules present
|
|
3. User code (BODY, wrapped for value if needed)
|
|
|
|
When modules are defined, imports and user code are wrapped in Code.eval_string.
|
|
This is required because Elixir cannot import a module defined in the same file
|
|
at the top level - Code.eval_string defers evaluation until runtime.
|
|
|
|
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"))
|
|
(wrapped (if (eq result-type 'value)
|
|
(ob-elixir--wrap-for-value body)
|
|
body))
|
|
(code (if modules-string
|
|
;; When modules are defined, wrap imports + code in Code.eval_string
|
|
(let ((eval-body (concat
|
|
(when imports-string (concat (string-trim imports-string) "\n"))
|
|
wrapped)))
|
|
(concat
|
|
(string-trim modules-string) "\n\n"
|
|
"Code.eval_string(\"\"\"\n"
|
|
(ob-elixir--escape-for-eval-string eval-body)
|
|
"\n\"\"\")"))
|
|
;; Normal path without modules
|
|
(concat
|
|
(when imports-string (concat (string-trim imports-string) "\n\n"))
|
|
wrapped))))
|
|
(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 ((module-name (cdr (assq :module params))))
|
|
(if module-name
|
|
;; This is a module definition block - don't execute
|
|
(format "Module %s: functions defined" module-name)
|
|
;; Normal execution path
|
|
(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)))
|
|
;; Find imports for this block's position
|
|
(imports-string (ob-elixir--find-imports-for-position (point)))
|
|
;; Find module definitions for this block's position
|
|
(modules-alist (ob-elixir--find-all-module-blocks (point)))
|
|
(modules-string (ob-elixir--generate-module-definitions modules-alist))
|
|
;; Expand body with variable assignments
|
|
(full-body (org-babel-expand-body:generic
|
|
body params
|
|
(org-babel-variable-assignments:elixir params)))
|
|
(result (condition-case err
|
|
(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 imports-string modules-string))
|
|
;; Session mode without deps
|
|
((and session (not (string= session "none")))
|
|
(ob-elixir--evaluate-in-session session full-body result-type imports-string modules-string))
|
|
;; Non-session with deps
|
|
(deps-string
|
|
(ob-elixir--execute-with-deps full-body result-type deps-string imports-string modules-string))
|
|
;; Plain execution
|
|
(t
|
|
(ob-elixir--execute full-body result-type imports-string modules-string)))
|
|
(ob-elixir-error
|
|
;; Return error message so it appears in buffer
|
|
(cadr err)))))
|
|
(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
|