Files
ob-elixir/tasks/07-session-support.md

13 KiB

Task 07: IEx Session Support

Phase: 2 - Sessions Priority: High Estimated Time: 3-4 hours Dependencies: Phase 1 Complete

Objective

Implement IEx session support so code blocks can share state when using :session header argument.

Prerequisites

  • Phase 1 complete
  • Understanding of Emacs comint mode
  • IEx (Elixir REPL) available

Background

Sessions allow multiple code blocks to share state:

#+BEGIN_SRC elixir :session my-session
x = 42
#+END_SRC

#+BEGIN_SRC elixir :session my-session
x * 2  # Can use x from previous block
#+END_SRC

This requires:

  1. Starting an IEx process
  2. Managing the process via comint
  3. Sending code and capturing output
  4. Proper prompt detection
  5. Session cleanup

Steps

Step 1: Add session configuration

Add to ob-elixir.el:

;;; 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.")

Step 2: Implement session initialization

;;; Session Management

(require 'ob-comint)

(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."
  (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))))

Step 3: Implement 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)))

Step 4: Implement 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)))

(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))
         (start-marker nil)
         output)
    (unless buffer
      (error "Failed to create Elixir session: %s" session))
    
    (with-current-buffer buffer
      ;; Mark position before output
      (goto-char (point-max))
      (setq start-marker (point-marker))
      
      ;; Send the code
      (ob-elixir--send-command buffer code)
      (ob-elixir--wait-for-prompt buffer 30)
      
      ;; Send EOE marker
      (ob-elixir--send-command buffer 
                               (format "\"%s\"" ob-elixir--eoe-marker))
      (ob-elixir--wait-for-prompt buffer 5)
      
      ;; Extract output
      (setq output (ob-elixir--extract-session-output 
                    buffer start-marker)))
    
    (ob-elixir--clean-session-output output)))

(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))

Step 5: Implement 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)
  "Clean OUTPUT from IEx session."
  (let ((result output))
    ;; Remove ANSI escape codes
    (setq result (ansi-color-filter-apply result))
    
    ;; Remove prompts
    (setq result (replace-regexp-in-string
                  ob-elixir--prompt-regexp "" result))
    
    ;; Remove the input echo
    (setq result (replace-regexp-in-string
                  "^.*\n" "" result nil nil nil 1))
    
    ;; Remove EOE marker
    (setq result (replace-regexp-in-string
                  (format "\"%s\"" ob-elixir--eoe-marker) "" result))
    
    ;; Extract value if using value wrapper
    (when (string-match "__ob_value_start__\n\\(.*\\)\n__ob_value_end__" result)
      (setq result (match-string 1 result)))
    
    ;; Remove :ok from wrapper
    (setq result (replace-regexp-in-string ":ok\n*$" "" result))
    
    (string-trim result)))

Step 6: Update execute function

Modify org-babel-execute:elixir:

(defun org-babel-execute:elixir (body params)
  "Execute a block of Elixir code with org-babel."
  (let* ((session (cdr (assq :session params)))
         (result-type (cdr (assq :result-type params)))
         (result-params (cdr (assq :result-params params)))
         (full-body (org-babel-expand-body:generic
                     body params
                     (org-babel-variable-assignments:elixir params)))
         (result (if (and session (not (string= session "none")))
                     ;; Session mode
                     (ob-elixir--evaluate-in-session session full-body result-type)
                   ;; External process mode
                   (ob-elixir--execute full-body result-type))))
    (org-babel-reassemble-table
     (org-babel-result-cond result-params
       result
       (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))))))

Step 7: Implement prep-session

(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))

Step 8: Implement 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))

Step 9: Add tests

Add to test/test-ob-elixir-sessions.el:

;;; test-ob-elixir-sessions.el --- Session tests -*- lexical-binding: t; -*-

(require 'ert)
(require 'ob-elixir)

(ert-deftest ob-elixir-test-session-creation ()
  "Test session creation."
  (skip-unless (executable-find ob-elixir-iex-command))
  (unwind-protect
      (let ((buffer (org-babel-elixir-initiate-session "test-create" nil)))
        (should buffer)
        (should (org-babel-comint-buffer-livep buffer)))
    (ob-elixir-kill-session "test-create")))

(ert-deftest ob-elixir-test-session-persistence ()
  "Test that sessions persist state."
  (skip-unless (executable-find ob-elixir-iex-command))
  (unwind-protect
      (progn
        ;; First evaluation - define variable
        (ob-elixir--evaluate-in-session "test-persist" "x = 42" 'value)
        ;; Second evaluation - use variable
        (let ((result (ob-elixir--evaluate-in-session 
                       "test-persist" "x * 2" 'value)))
          (should (equal "84" result))))
    (ob-elixir-kill-session "test-persist")))

(ert-deftest ob-elixir-test-session-none ()
  "Test that :session none uses external process."
  (skip-unless (executable-find ob-elixir-command))
  (should (null (org-babel-elixir-initiate-session "none" nil))))

(ert-deftest ob-elixir-test-session-module-def ()
  "Test defining module in session."
  (skip-unless (executable-find ob-elixir-iex-command))
  (unwind-protect
      (progn
        (ob-elixir--evaluate-in-session 
         "test-module"
         "defmodule TestMod do\n  def double(x), do: x * 2\nend"
         'value)
        (let ((result (ob-elixir--evaluate-in-session
                       "test-module" "TestMod.double(21)" 'value)))
          (should (equal "42" result))))
    (ob-elixir-kill-session "test-module")))

(provide 'test-ob-elixir-sessions)

Step 10: Test in org buffer

Create session tests in test.org:

* Session Tests

** Define variable in session

#+BEGIN_SRC elixir :session my-session
x = 42
#+END_SRC

** Use variable from session

#+BEGIN_SRC elixir :session my-session
x * 2
#+END_SRC

#+RESULTS:
: 84

** Define module in session

#+BEGIN_SRC elixir :session my-session
defmodule Helper do
  def greet(name), do: "Hello, #{name}!"
end
#+END_SRC

** Use module from session

#+BEGIN_SRC elixir :session my-session
Helper.greet("World")
#+END_SRC

#+RESULTS:
: "Hello, World!"

Acceptance Criteria

  • :session name creates persistent IEx session
  • Variables persist across blocks in same session
  • Module definitions persist
  • :session none uses external process (default)
  • Multiple named sessions work independently
  • Sessions can be killed with ob-elixir-kill-session
  • Proper prompt detection
  • Output is clean (no prompts, ANSI codes)
  • All tests pass

Troubleshooting

Session hangs

Check for proper prompt detection. IEx prompts can vary.

ANSI codes in output

The ansi-color-filter-apply should remove them. Check TERM environment variable.

Process dies unexpectedly

Check for Elixir errors. May need to handle compilation errors in session context.

Files Modified

  • ob-elixir.el - Add session support
  • test/test-ob-elixir-sessions.el - Add session tests

References