tasks/07-session-support.md done

This commit is contained in:
2026-01-24 18:10:35 +01:00
parent 3b816ae656
commit ba4b695add
2 changed files with 341 additions and 2 deletions

View File

@@ -36,7 +36,9 @@
(require 'ob)
(require 'ob-eval)
(require 'ob-comint)
(require 'cl-lib)
(require 'ansi-color)
;;; Customization
@@ -68,6 +70,26 @@ 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.")
;;; Header Arguments
(defvar org-babel-default-header-args:elixir
@@ -363,6 +385,204 @@ Each statement has the form: var_name = value"
(ob-elixir--elisp-to-elixir value))))
(org-babel--get-vars params)))
;;; 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))
;;; Execution
(defconst ob-elixir--value-wrapper
@@ -411,13 +631,18 @@ 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* ((result-type (cdr (assq :result-type params)))
(let* ((session (cdr (assq :session params)))
(result-type (cdr (assq :result-type params)))
(result-params (cdr (assq :result-params params)))
;; Expand body with variable assignments
(full-body (org-babel-expand-body:generic
body params
(org-babel-variable-assignments:elixir params)))
(result (ob-elixir--execute full-body result-type)))
(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
;; For output/scalar/verbatim - return as-is

View File

@@ -0,0 +1,114 @@
;;; test-ob-elixir-sessions.el --- Session tests for ob-elixir -*- lexical-binding: t; -*-
;; Copyright (C) 2024
;;; Commentary:
;; Tests for IEx session support in ob-elixir.
;;; Code:
(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")))
(ert-deftest ob-elixir-test-session-reuse ()
"Test that same session name returns same buffer."
(skip-unless (executable-find ob-elixir-iex-command))
(unwind-protect
(let ((buffer1 (org-babel-elixir-initiate-session "test-reuse" nil))
(buffer2 (org-babel-elixir-initiate-session "test-reuse" nil)))
(should (eq buffer1 buffer2)))
(ob-elixir-kill-session "test-reuse")))
(ert-deftest ob-elixir-test-multiple-sessions ()
"Test that multiple named sessions work independently."
(skip-unless (executable-find ob-elixir-iex-command))
(unwind-protect
(progn
;; Set x in session-a
(ob-elixir--evaluate-in-session "session-a" "x = 1" 'value)
;; Set x in session-b to different value
(ob-elixir--evaluate-in-session "session-b" "x = 100" 'value)
;; Verify each session has its own value
(let ((result-a (ob-elixir--evaluate-in-session "session-a" "x" 'value))
(result-b (ob-elixir--evaluate-in-session "session-b" "x" 'value)))
(should (equal "1" result-a))
(should (equal "100" result-b))))
(ob-elixir-kill-session "session-a")
(ob-elixir-kill-session "session-b")))
(ert-deftest ob-elixir-test-session-output-mode ()
"Test session output mode (capturing stdout)."
(skip-unless (executable-find ob-elixir-iex-command))
(unwind-protect
(let ((result (ob-elixir--evaluate-in-session
"test-output"
"IO.puts(\"Hello from session\")"
'output)))
(should (string-match-p "Hello from session" result)))
(ob-elixir-kill-session "test-output")))
(ert-deftest ob-elixir-test-kill-session ()
"Test killing a session."
(skip-unless (executable-find ob-elixir-iex-command))
(let ((buffer (org-babel-elixir-initiate-session "test-kill" nil)))
(should buffer)
(ob-elixir-kill-session "test-kill")
(should-not (buffer-live-p buffer))
(should-not (gethash "test-kill" ob-elixir--sessions))))
(ert-deftest ob-elixir-test-kill-all-sessions ()
"Test killing all sessions."
(skip-unless (executable-find ob-elixir-iex-command))
(let ((buffer1 (org-babel-elixir-initiate-session "test-all-1" nil))
(buffer2 (org-babel-elixir-initiate-session "test-all-2" nil)))
(should buffer1)
(should buffer2)
(ob-elixir-kill-all-sessions)
(should-not (buffer-live-p buffer1))
(should-not (buffer-live-p buffer2))
(should (= 0 (hash-table-count ob-elixir--sessions)))))
(provide 'test-ob-elixir-sessions)
;;; test-ob-elixir-sessions.el ends here