diff --git a/ob-elixir.el b/ob-elixir.el index c6044d0..00645a1 100644 --- a/ob-elixir.el +++ b/ob-elixir.el @@ -90,6 +90,33 @@ Matches both regular prompt `iex(N)> ' and continuation `...(N)> '.") (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 @@ -385,6 +412,156 @@ Each statement has the form: var_name = value" (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) @@ -583,6 +760,114 @@ Sends variable assignments to the session." (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 @@ -634,15 +919,26 @@ 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 (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)))) + (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 diff --git a/test/test-ob-elixir-deps.el b/test/test-ob-elixir-deps.el new file mode 100644 index 0000000..80c52fd --- /dev/null +++ b/test/test-ob-elixir-deps.el @@ -0,0 +1,213 @@ +;;; test-ob-elixir-deps.el --- Deps block tests -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Your Name + +;; Author: Your Name + +;; This file is not part of GNU Emacs. + +;;; Commentary: + +;; Tests for ob-elixir Mix dependencies support. + +;;; Code: + +(require 'ert) +(require 'ob-elixir) + +;;; Parsing Tests + +(ert-deftest ob-elixir-test-deps-block-parsing () + "Test finding deps blocks in buffer." + (with-temp-buffer + (insert "#+BEGIN_DEPS elixir\n[{:jason, \"~> 1.4\"}]\n#+END_DEPS\n\n") + (insert "#+BEGIN_SRC elixir\nJason.encode!(%{})\n#+END_SRC\n") + (goto-char (point-max)) + (let ((deps (ob-elixir--find-deps-for-position (point)))) + (should deps) + (should (string-match-p ":jason" deps))))) + +(ert-deftest ob-elixir-test-deps-block-override () + "Test that later deps blocks override earlier ones." + (with-temp-buffer + (insert "#+BEGIN_DEPS elixir\n[{:jason, \"~> 1.4\"}]\n#+END_DEPS\n\n") + (insert "#+BEGIN_SRC elixir\ncode1\n#+END_SRC\n\n") + (insert "#+BEGIN_DEPS elixir\n[{:httpoison, \"~> 2.0\"}]\n#+END_DEPS\n\n") + (let ((pos (point))) + (insert "#+BEGIN_SRC elixir\ncode2\n#+END_SRC\n") + (let ((deps (ob-elixir--find-deps-for-position (+ pos 10)))) + (should deps) + (should (string-match-p ":httpoison" deps)) + (should-not (string-match-p ":jason" deps)))))) + +(ert-deftest ob-elixir-test-no-deps-block () + "Test behavior when no deps block exists." + (with-temp-buffer + (insert "#+BEGIN_SRC elixir\n1 + 1\n#+END_SRC\n") + (should (null (ob-elixir--find-deps-for-position (point)))))) + +(ert-deftest ob-elixir-test-deps-block-before-position () + "Test that deps block must be before the position." + (with-temp-buffer + (insert "#+BEGIN_SRC elixir\n1 + 1\n#+END_SRC\n\n") + (let ((pos (point))) + (insert "#+BEGIN_DEPS elixir\n[{:jason, \"~> 1.4\"}]\n#+END_DEPS\n") + ;; Position is before deps block, should not find it + (should (null (ob-elixir--find-deps-for-position pos)))))) + +(ert-deftest ob-elixir-test-deps-hash-consistency () + "Test that same deps produce same hash." + (let ((deps1 "[{:jason, \"~> 1.4\"}]") + (deps2 "[{:jason, \"~> 1.4\"}]") ; extra space + (deps3 "[{:httpoison, \"~> 2.0\"}]")) + (should (string= (ob-elixir--hash-deps deps1) + (ob-elixir--hash-deps deps2))) + (should-not (string= (ob-elixir--hash-deps deps1) + (ob-elixir--hash-deps deps3))))) + +(ert-deftest ob-elixir-test-normalize-deps () + "Test deps normalization." + ;; Extra spaces between tokens get normalized to single space + (should (string= (ob-elixir--normalize-deps "[{:a, \"1\"}]") + (ob-elixir--normalize-deps "[{:a, \"1\"}]"))) + ;; Comments are stripped + (should (string= (ob-elixir--normalize-deps "[{:a, \"1\"}] # comment") + (ob-elixir--normalize-deps "[{:a, \"1\"}]")))) + +(ert-deftest ob-elixir-test-normalize-deps-multiline () + "Test normalization of multiline deps." + ;; Multiline deps with same content should produce the same hash + (let ((multiline1 "[\n {:jason, \"~> 1.4\"},\n {:decimal, \"~> 2.0\"}\n]") + (multiline2 "[\n{:jason, \"~> 1.4\"},\n{:decimal, \"~> 2.0\"}\n]")) + ;; Both should hash to the same value since they have equivalent whitespace normalization + (should (string= (ob-elixir--hash-deps multiline1) + (ob-elixir--hash-deps multiline2))))) + +(ert-deftest ob-elixir-test-deps-block-with-comments () + "Test deps block parsing with Elixir comments." + (with-temp-buffer + (insert "#+BEGIN_DEPS elixir\n") + (insert "[\n") + (insert " # JSON library\n") + (insert " {:jason, \"~> 1.4\"}\n") + (insert "]\n") + (insert "#+END_DEPS\n\n") + (insert "#+BEGIN_SRC elixir\ncode\n#+END_SRC\n") + (goto-char (point-max)) + (let ((deps (ob-elixir--find-deps-for-position (point)))) + (should deps) + (should (string-match-p ":jason" deps))))) + +;;; Mix Project Template Tests + +(ert-deftest ob-elixir-test-mix-exs-template () + "Test that mix.exs template is valid." + (should (stringp ob-elixir--mix-exs-template)) + (should (string-match-p "defmodule" ob-elixir--mix-exs-template)) + (should (string-match-p "use Mix.Project" ob-elixir--mix-exs-template)) + (should (string-match-p "deps()" ob-elixir--mix-exs-template)) + (should (string-match-p "%s" ob-elixir--mix-exs-template))) + +(ert-deftest ob-elixir-test-mix-exs-generation () + "Test generating mix.exs content." + (let ((deps "[{:jason, \"~> 1.4\"}]") + (mix-exs (format ob-elixir--mix-exs-template "[{:jason, \"~> 1.4\"}]"))) + (should (string-match-p ":jason" mix-exs)) + (should (string-match-p "~> 1.4" mix-exs)))) + +;;; Cache Directory Tests + +(ert-deftest ob-elixir-test-cache-dir-default () + "Test default cache directory." + (should (stringp ob-elixir-deps-cache-dir)) + (should (string-match-p "ob-elixir" ob-elixir-deps-cache-dir))) + +;;; Integration Tests (require network and mix) + +(ert-deftest ob-elixir-test-deps-project-creation () + "Test creating a temporary Mix project with deps." + :tags '(:integration :network) + (skip-unless (and (executable-find "mix") + (executable-find "elixir"))) + (let ((ob-elixir-deps-cache-dir (make-temp-file "ob-elixir-test-" t)) + (deps "[{:jason, \"~> 1.4\"}]")) + (unwind-protect + (let ((project-dir (ob-elixir--get-deps-project deps))) + (should (file-directory-p project-dir)) + (should (file-exists-p (expand-file-name "mix.exs" project-dir))) + (should (file-directory-p (expand-file-name "deps/jason" project-dir)))) + ;; Cleanup + (ob-elixir-cleanup-deps-projects) + (when (file-directory-p ob-elixir-deps-cache-dir) + (delete-directory ob-elixir-deps-cache-dir t))))) + +(ert-deftest ob-elixir-test-deps-execution () + "Test executing code with deps." + :tags '(:integration :network) + (skip-unless (and (executable-find "mix") + (executable-find "elixir"))) + (let ((ob-elixir-deps-cache-dir (make-temp-file "ob-elixir-test-" t)) + (deps "[{:jason, \"~> 1.4\"}]")) + (unwind-protect + (let ((result (ob-elixir--execute-with-deps + "Jason.encode!(%{a: 1})" + 'value + deps))) + ;; Result should contain JSON output with key "a" + ;; Jason.encode! returns "{\"a\":1}" which when inspected becomes "\"{\\\"a\\\":1}\"" + (should (string-match-p "a" result))) + ;; Cleanup + (ob-elixir-cleanup-deps-projects) + (when (file-directory-p ob-elixir-deps-cache-dir) + (delete-directory ob-elixir-deps-cache-dir t))))) + +(ert-deftest ob-elixir-test-deps-caching () + "Test that deps projects are cached and reused." + :tags '(:integration :network) + (skip-unless (executable-find "mix")) + (let ((ob-elixir-deps-cache-dir (make-temp-file "ob-elixir-test-" t)) + (deps "[{:jason, \"~> 1.4\"}]")) + (unwind-protect + (let ((project1 (ob-elixir--get-deps-project deps)) + (project2 (ob-elixir--get-deps-project deps))) + ;; Should return same directory + (should (string= project1 project2)) + ;; Should only have one entry in hash table + (should (= 1 (hash-table-count ob-elixir--deps-projects)))) + ;; Cleanup + (ob-elixir-cleanup-deps-projects) + (when (file-directory-p ob-elixir-deps-cache-dir) + (delete-directory ob-elixir-deps-cache-dir t))))) + +(ert-deftest ob-elixir-test-different-deps-different-projects () + "Test that different deps create different projects." + :tags '(:integration :network) + (skip-unless (executable-find "mix")) + (let ((ob-elixir-deps-cache-dir (make-temp-file "ob-elixir-test-" t)) + (deps1 "[{:jason, \"~> 1.4\"}]") + (deps2 "[{:decimal, \"~> 2.0\"}]")) + (unwind-protect + (let ((project1 (ob-elixir--get-deps-project deps1)) + (project2 (ob-elixir--get-deps-project deps2))) + ;; Should be different directories + (should-not (string= project1 project2)) + ;; Should have two entries in hash table + (should (= 2 (hash-table-count ob-elixir--deps-projects)))) + ;; Cleanup + (ob-elixir-cleanup-deps-projects) + (when (file-directory-p ob-elixir-deps-cache-dir) + (delete-directory ob-elixir-deps-cache-dir t))))) + +;;; Cleanup Tests + +(ert-deftest ob-elixir-test-cleanup-clears-hash-table () + "Test that cleanup clears the hash table." + (let ((ob-elixir--deps-projects (make-hash-table :test 'equal))) + (puthash "test-hash" "/tmp/test-dir" ob-elixir--deps-projects) + (should (= 1 (hash-table-count ob-elixir--deps-projects))) + ;; Cleanup should clear the table (dir doesn't exist, so no error) + (ob-elixir-cleanup-deps-projects) + (should (= 0 (hash-table-count ob-elixir--deps-projects))))) + +(provide 'test-ob-elixir-deps) +;;; test-ob-elixir-deps.el ends here