diff --git a/ob-elixir.el b/ob-elixir.el index 2e456b3..c6044d0 100644 --- a/ob-elixir.el +++ b/ob-elixir.el @@ -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 diff --git a/test/test-ob-elixir-sessions.el b/test/test-ob-elixir-sessions.el new file mode 100644 index 0000000..c8d3bc0 --- /dev/null +++ b/test/test-ob-elixir-sessions.el @@ -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