This commit is contained in:
2026-01-24 19:27:55 +01:00
parent ba4b695add
commit 293d97884c

View File

@@ -1,40 +1,88 @@
# Task 08: Mix Project Support # Task 08: Mix Dependencies Support
**Phase**: 3 - Mix Integration **Phase**: 3 - Mix Integration
**Priority**: High **Priority**: High
**Estimated Time**: 2-3 hours **Estimated Time**: 3-4 hours
**Dependencies**: Task 07 (Session Support) or Phase 1 complete **Dependencies**: Task 07 (Session Support) or Phase 1 complete
## Objective ## Objective
Implement Mix project support so Elixir code can be executed within the context of a Mix project, with access to project dependencies and modules. Implement Mix dependencies support so users can specify dependencies directly in the org file using a special `#+BEGIN_DEPS` block. ob-elixir will automatically create a temporary Mix project with those dependencies, cache it for reuse, and use it as the execution context for subsequent Elixir code blocks.
## Prerequisites ## Prerequisites
- Phase 1 complete (or Phase 2 for session+mix) - Phase 1 complete (or Phase 2 for session+deps)
- Understanding of Mix build tool - Understanding of Mix build tool
- A test Mix project - Network access for fetching dependencies
## Background ## Background
Mix projects have: ### The Problem
- Dependencies in `mix.exs`
- Compiled modules in `_build/`
- Configuration in `config/`
To execute code in project context, we need: When writing Elixir code in org-mode, users often need access to external libraries (e.g., JSON parsing, HTTP clients, custom libraries). Without Mix project support, users are limited to Elixir's standard library.
1. Run code from the project directory
2. Use `mix run` for one-shot execution ### The Solution
3. Use `iex -S mix` for sessions
Instead of requiring users to point to an existing Mix project, ob-elixir will:
1. Parse dependency specifications from a special `#+BEGIN_DEPS` block
2. Create a temporary Mix project with those dependencies
3. Cache the project based on a hash of the dependencies (for fast re-evaluation)
4. Execute code blocks within that project context
5. Clean up temporary projects when Emacs exits
### Example Usage
```org
* Using External Dependencies
#+BEGIN_DEPS elixir
[
{:jason, "~> 1.4"},
{:prova, git: "git@gitlab.com:babel-upm/makina/prova.git"}
]
#+END_DEPS
#+BEGIN_SRC elixir
# Both jason and prova are available
Jason.encode!(%{hello: "world"})
#+END_SRC
#+BEGIN_SRC elixir :session deps-session
# Session also has access to deps
Prova.some_function()
#+END_SRC
* Different Dependencies Section
#+BEGIN_DEPS elixir
[{:httpoison, "~> 2.0"}]
#+END_DEPS
#+BEGIN_SRC elixir
# Now using httpoison instead of the previous deps
HTTPoison.get!("https://example.com")
#+END_SRC
```
## Design Decisions
| Aspect | Decision | Rationale |
|--------|----------|-----------|
| Block format | `#+BEGIN_DEPS elixir ... #+END_DEPS` | Special block, not confused with executable code |
| Deps syntax | Elixir list: `[{:pkg, "~> 1.0"}]` | Natural for Elixir users, supports all dep formats |
| Scope | All subsequent blocks until next deps block | Simple mental model |
| Caching | Hash-based | Fast re-evaluation, no redundant compilation |
| Multiple deps blocks | Later overrides | Allows different sections with different deps |
| Cleanup | Auto on Emacs exit | No orphaned temp projects |
| Elixir version | System default | Keep it simple, no version management |
## Steps ## Steps
### Step 1: Add Mix configuration ### Step 1: Add configuration and infrastructure
Add to `ob-elixir.el`:
```elisp ```elisp
;;; Mix Configuration ;;; Mix Dependencies Configuration
(defcustom ob-elixir-mix-command "mix" (defcustom ob-elixir-mix-command "mix"
"Command to run Mix." "Command to run Mix."
@@ -42,126 +90,241 @@ Add to `ob-elixir.el`:
:group 'ob-elixir :group 'ob-elixir
:safe #'stringp) :safe #'stringp)
(defcustom ob-elixir-auto-detect-mix t (defcustom ob-elixir-deps-cache-dir
"Whether to automatically detect Mix projects. (expand-file-name "ob-elixir" (or (getenv "XDG_CACHE_HOME") "~/.cache"))
"Directory for caching temporary Mix projects.
When non-nil and no :mix-project is specified, ob-elixir will Each unique set of dependencies gets its own subdirectory,
search upward from the org file for a mix.exs file." named by the hash of the dependencies."
:type 'boolean :type 'directory
:group 'ob-elixir) :group 'ob-elixir)
(defconst org-babel-header-args:elixir (defvar ob-elixir--deps-projects (make-hash-table :test 'equal)
'((mix-project . :any) ; Path to Mix project root "Hash table mapping deps-hash to project directory path.")
(mix-env . :any) ; MIX_ENV (dev, test, prod)
(mix-target . :any)) ; MIX_TARGET for Nerves, etc. (defvar ob-elixir--cleanup-registered nil
"Elixir-specific header arguments.") "Whether the cleanup hook has been registered.")
``` ```
### Step 2: Implement Mix project detection ### Step 2: Implement deps block parsing
```elisp ```elisp
;;; Mix Project Detection ;;; Deps Block Parsing
(defun ob-elixir--find-mix-project (&optional start-dir) (defconst ob-elixir--deps-block-regexp
"Find Mix project root by searching for mix.exs. "^[ \t]*#\\+BEGIN_DEPS[ \t]+elixir[ \t]*\n\\(\\(?:.*\n\\)*?\\)[ \t]*#\\+END_DEPS"
"Regexp matching a deps block.
Group 1 captures the deps content.")
Starts from START-DIR (default: current directory) and searches (defun ob-elixir--find-deps-for-position (pos)
upward. Returns the directory containing mix.exs, or nil." "Find the most recent deps block before POS.
(let* ((dir (or start-dir default-directory))
(found (locate-dominating-file dir "mix.exs")))
(when found
(file-name-directory found))))
(defun ob-elixir--resolve-mix-project (params) Returns the deps content as a string, or nil if no deps block found."
"Resolve Mix project path from PARAMS or auto-detection. (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)))
Returns project path or nil." (defun ob-elixir--normalize-deps (deps-string)
(let ((explicit (cdr (assq :mix-project params)))) "Normalize DEPS-STRING for consistent hashing.
(cond
;; Explicit project path
((and explicit (not (eq explicit 'no)))
(expand-file-name explicit))
;; Explicitly disabled
((eq explicit 'no)
nil)
;; Auto-detect if enabled
(ob-elixir-auto-detect-mix
(ob-elixir--find-mix-project))
;; No project
(t nil))))
(defun ob-elixir--in-mix-project-p (params) Removes comments, extra whitespace, and normalizes formatting."
"Return t if execution should happen in Mix project context." (let ((normalized deps-string))
(not (null (ob-elixir--resolve-mix-project params)))) ;; 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)))
``` ```
### Step 3: Implement Mix execution ### Step 3: Implement temp Mix project creation
```elisp ```elisp
;;; Mix Execution ;;; Temporary Mix Project Management
(defun ob-elixir--execute-with-mix (body result-type params) (defconst ob-elixir--mix-exs-template
"Execute BODY in Mix project context. "defmodule ObElixirTemp.MixProject do
use Mix.Project
RESULT-TYPE is 'value or 'output. def project do
PARAMS contains header arguments including :mix-project." [
(let* ((project-dir (ob-elixir--resolve-mix-project params)) app: :ob_elixir_temp,
(mix-env (cdr (assq :mix-env params))) version: \"0.1.0\",
(mix-target (cdr (assq :mix-target params))) 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))
```
### Step 4: Implement execution with deps context
```elisp
;;; 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) (default-directory project-dir)
(tmp-file (org-babel-temp-file "ob-elixir-mix-" ".exs")) (tmp-file (org-babel-temp-file "ob-elixir-" ".exs"))
(code (if (eq result-type 'value) (code (if (eq result-type 'value)
(ob-elixir--wrap-for-value body) (ob-elixir--wrap-for-value body)
body)) body)))
(env-vars (ob-elixir--build-mix-env mix-env mix-target)))
;; Write code to temp file ;; Write code to temp file
(with-temp-file tmp-file (with-temp-file tmp-file
(insert code)) (insert code))
;; Execute with mix run ;; Execute with mix run
(let ((command (format "%s%s run %s" (let ((command (format "%s run --no-compile %s 2>&1"
env-vars
ob-elixir-mix-command ob-elixir-mix-command
(org-babel-process-file-name tmp-file)))) (shell-quote-argument tmp-file))))
(ob-elixir--process-result (ob-elixir--process-result
(shell-command-to-string command))))) (shell-command-to-string command)))))
(defun ob-elixir--build-mix-env (mix-env mix-target) (defun ob-elixir--start-session-with-deps (buffer-name session-name deps-string)
"Build environment variable prefix for Mix execution." "Start a new IEx session with dependencies in BUFFER-NAME.
(let ((vars '()))
(when mix-env SESSION-NAME is used for the process name.
(push (format "MIX_ENV=%s" mix-env) vars)) DEPS-STRING contains the dependencies specification."
(when mix-target (let* ((project-dir (ob-elixir--get-deps-project deps-string))
(push (format "MIX_TARGET=%s" mix-target) vars)) (buffer (get-buffer-create buffer-name))
(if vars (default-directory project-dir)
(concat (mapconcat #'identity vars " ") " ") (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
(ob-elixir--wait-for-prompt buffer 30) ; Longer timeout for deps loading
;; Configure IEx for programmatic use
(ob-elixir--configure-session buffer)
buffer)))
``` ```
### Step 4: Update execute function ### Step 5: Update main execute function
Modify `org-babel-execute:elixir`: Modify `org-babel-execute:elixir` to check for deps context:
```elisp ```elisp
(defun org-babel-execute:elixir (body params) (defun org-babel-execute:elixir (body params)
"Execute a block of Elixir code with org-babel." "Execute a block of Elixir code with org-babel.
BODY is the Elixir code to execute.
PARAMS is an alist of header arguments."
(let* ((session (cdr (assq :session params))) (let* ((session (cdr (assq :session params)))
(result-type (cdr (assq :result-type params))) (result-type (cdr (assq :result-type params)))
(result-params (cdr (assq :result-params params))) (result-params (cdr (assq :result-params params)))
(mix-project (ob-elixir--resolve-mix-project 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 (full-body (org-babel-expand-body:generic
body params body params
(org-babel-variable-assignments:elixir params))) (org-babel-variable-assignments:elixir params)))
(result (cond (result (cond
;; Session mode ;; 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"))) ((and session (not (string= session "none")))
(ob-elixir--evaluate-in-session (ob-elixir--evaluate-in-session session full-body result-type))
session full-body result-type params)) ;; Non-session with deps
;; Mix project mode (deps-string
(mix-project (ob-elixir--execute-with-deps full-body result-type deps-string))
(ob-elixir--execute-with-mix
full-body result-type params))
;; Plain execution ;; Plain execution
(t (t
(ob-elixir--execute full-body result-type))))) (ob-elixir--execute full-body result-type)))))
@@ -175,247 +338,397 @@ Modify `org-babel-execute:elixir`:
(cdr (assq :rownames params)))))) (cdr (assq :rownames params))))))
``` ```
### Step 5: Update session for Mix projects ### Step 6: Implement session support with deps
Modify session creation to support Mix:
```elisp ```elisp
(defun ob-elixir--start-session (buffer-name session-name params) (defvar ob-elixir--session-deps (make-hash-table :test 'equal)
"Start a new IEx session in BUFFER-NAME." "Hash table mapping session names to their deps-hash.
(let* ((mix-project (ob-elixir--resolve-mix-project params)) Used to ensure session deps consistency.")
(mix-env (cdr (assq :mix-env params)))
(buffer (get-buffer-create buffer-name)) (defun ob-elixir--get-or-create-session-with-deps (name deps-string params)
(default-directory (or mix-project default-directory)) "Get or create an IEx session NAME with DEPS-STRING."
(process-environment (let* ((deps-hash (ob-elixir--hash-deps deps-string))
(append (buffer-name (format "*ob-elixir:%s*" name))
(list "TERM=dumb") (existing (get-buffer buffer-name))
(when mix-env (list (format "MIX_ENV=%s" mix-env))) (existing-deps (gethash name ob-elixir--session-deps)))
process-environment)))
(with-current-buffer buffer ;; Check if existing session has different deps
(if mix-project (when (and existing existing-deps (not (string= existing-deps deps-hash)))
;; Start with mix (message "ob-elixir: Session %s has different deps, recreating..." name)
(make-comint-in-buffer (ob-elixir-kill-session name)
(format "ob-elixir-%s" session-name) (setq existing nil))
buffer
ob-elixir-iex-command (if (and existing (org-babel-comint-buffer-livep existing))
nil existing
"-S" "mix") ;; Create new session
;; Start plain IEx (let ((buffer (ob-elixir--start-session-with-deps buffer-name name deps-string)))
(make-comint-in-buffer (puthash name deps-hash ob-elixir--session-deps)
(format "ob-elixir-%s" session-name) buffer))))
buffer
ob-elixir-iex-command (defun ob-elixir--evaluate-in-session-with-deps (session body result-type deps-string)
nil)) "Evaluate BODY in SESSION with DEPS-STRING context."
(let* ((buffer (ob-elixir--get-or-create-session-with-deps session deps-string nil))
;; Wait for prompt (code (if (eq result-type 'value)
(ob-elixir--wait-for-prompt buffer 30) (ob-elixir--session-wrap-for-value body)
body))
;; Configure IEx (eoe-indicator ob-elixir--eoe-marker)
(ob-elixir--configure-session buffer) (full-body (concat code "\nIO.puts(\"" eoe-indicator "\")"))
output)
buffer)))
(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)))
``` ```
### Step 6: Add compilation support ### Step 7: Implement cleanup
```elisp ```elisp
(defcustom ob-elixir-compile-before-run nil ;;; Cleanup
"Whether to run mix compile before execution.
When non-nil, ensures project is compiled before running code. (defun ob-elixir--register-cleanup ()
This adds overhead but catches compilation errors early." "Register cleanup hook if not already registered."
:type 'boolean (unless ob-elixir--cleanup-registered
:group 'ob-elixir) (add-hook 'kill-emacs-hook #'ob-elixir-cleanup-deps-projects)
(setq ob-elixir--cleanup-registered t)))
(defun ob-elixir--ensure-compiled (project-dir) (defun ob-elixir-cleanup-deps-projects ()
"Ensure Mix project at PROJECT-DIR is compiled." "Delete all temporary Mix projects created by ob-elixir.
(let ((default-directory project-dir))
(shell-command-to-string
(format "%s compile --force-check" ob-elixir-mix-command))))
(defun ob-elixir--execute-with-mix (body result-type params) Called automatically on Emacs exit."
"Execute BODY in Mix project context." (interactive)
(let* ((project-dir (ob-elixir--resolve-mix-project params)) (maphash
(default-directory project-dir)) (lambda (_hash project-dir)
(when (and project-dir (file-directory-p project-dir))
;; Optionally compile first (condition-case err
(when ob-elixir-compile-before-run (delete-directory project-dir t)
(ob-elixir--ensure-compiled project-dir)) (error
(message "ob-elixir: Failed to delete %s: %s" project-dir err)))))
;; ... rest of execution 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))))
``` ```
### Step 7: Add tests ### Step 8: Add tests
Create `test/test-ob-elixir-mix.el`: Create `test/test-ob-elixir-deps.el`:
```elisp ```elisp
;;; test-ob-elixir-mix.el --- Mix project tests -*- lexical-binding: t; -*- ;;; test-ob-elixir-deps.el --- Deps block tests -*- lexical-binding: t; -*-
(require 'ert) (require 'ert)
(require 'ob-elixir) (require 'ob-elixir)
(defvar ob-elixir-test--mix-project-dir nil ;;; Parsing Tests
"Temporary Mix project directory for testing.")
(defun ob-elixir-test--setup-mix-project () (ert-deftest ob-elixir-test-deps-block-parsing ()
"Create a temporary Mix project for testing." "Test finding deps blocks in buffer."
(let ((dir (make-temp-file "ob-elixir-test-" t))) (with-temp-buffer
(setq ob-elixir-test--mix-project-dir dir) (insert "#+BEGIN_DEPS elixir\n[{:jason, \"~> 1.4\"}]\n#+END_DEPS\n\n")
(let ((default-directory dir)) (insert "#+BEGIN_SRC elixir\nJason.encode!(%{})\n#+END_SRC\n")
;; Create mix.exs (goto-char (point-max))
(with-temp-file (expand-file-name "mix.exs" dir) (let ((deps (ob-elixir--find-deps-for-position (point))))
(insert "defmodule TestProject.MixProject do (should deps)
use Mix.Project (should (string-match-p ":jason" deps)))))
def project do (ert-deftest ob-elixir-test-deps-block-override ()
[app: :test_project, version: \"0.1.0\", elixir: \"~> 1.14\"] "Test that later deps blocks override earlier ones."
end (with-temp-buffer
end")) (insert "#+BEGIN_DEPS elixir\n[{:jason, \"~> 1.4\"}]\n#+END_DEPS\n\n")
;; Create lib directory and module (insert "#+BEGIN_SRC elixir\ncode1\n#+END_SRC\n\n")
(make-directory (expand-file-name "lib" dir)) (insert "#+BEGIN_DEPS elixir\n[{:httpoison, \"~> 2.0\"}]\n#+END_DEPS\n\n")
(with-temp-file (expand-file-name "lib/test_project.ex" dir) (let ((pos (point)))
(insert "defmodule TestProject do (insert "#+BEGIN_SRC elixir\ncode2\n#+END_SRC\n")
def hello, do: \"Hello from TestProject!\" (let ((deps (ob-elixir--find-deps-for-position (+ pos 10))))
def add(a, b), do: a + b (should deps)
end"))) (should (string-match-p ":httpoison" deps))
dir)) (should-not (string-match-p ":jason" deps))))))
(defun ob-elixir-test--cleanup-mix-project () (ert-deftest ob-elixir-test-no-deps-block ()
"Clean up temporary Mix project." "Test behavior when no deps block exists."
(when ob-elixir-test--mix-project-dir (with-temp-buffer
(delete-directory ob-elixir-test--mix-project-dir t) (insert "#+BEGIN_SRC elixir\n1 + 1\n#+END_SRC\n")
(setq ob-elixir-test--mix-project-dir nil))) (should (null (ob-elixir--find-deps-for-position (point))))))
;;; Tests (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-find-mix-project () (ert-deftest ob-elixir-test-normalize-deps ()
"Test Mix project detection." "Test deps normalization."
(skip-unless (executable-find ob-elixir-mix-command)) (should (string= (ob-elixir--normalize-deps "[ {:a, \"1\"} ]")
(unwind-protect (ob-elixir--normalize-deps "[{:a,\"1\"}]")))
(let* ((project-dir (ob-elixir-test--setup-mix-project)) ;; Comments are stripped
(sub-dir (expand-file-name "lib" project-dir)) (should (string= (ob-elixir--normalize-deps "[{:a, \"1\"}] # comment")
(default-directory sub-dir)) (ob-elixir--normalize-deps "[{:a, \"1\"}]"))))
(should (equal project-dir
(ob-elixir--find-mix-project))))
(ob-elixir-test--cleanup-mix-project)))
(ert-deftest ob-elixir-test-mix-project-execution () ;;; Integration Tests (require network and mix)
"Test code execution in Mix project context."
(ert-deftest ob-elixir-test-deps-project-creation ()
"Test creating a temporary Mix project with deps."
(skip-unless (and (executable-find ob-elixir-mix-command) (skip-unless (and (executable-find ob-elixir-mix-command)
(executable-find ob-elixir-command))) (executable-find ob-elixir-command)))
(unwind-protect (let ((ob-elixir-deps-cache-dir (make-temp-file "ob-elixir-test-" t))
(let* ((project-dir (ob-elixir-test--setup-mix-project)) (deps "[{:jason, \"~> 1.4\"}]"))
(params `((:mix-project . ,project-dir)))) (unwind-protect
;; Compile first (let ((project-dir (ob-elixir--get-deps-project deps)))
(let ((default-directory project-dir)) (should (file-directory-p project-dir))
(shell-command-to-string "mix compile")) (should (file-exists-p (expand-file-name "mix.exs" project-dir)))
;; Test execution (should (file-directory-p (expand-file-name "deps/jason" project-dir))))
(let ((result (ob-elixir--execute-with-mix ;; Cleanup
"TestProject.hello()" 'value params))) (ob-elixir-cleanup-deps-projects)
(should (string-match-p "Hello from TestProject" result)))) (delete-directory ob-elixir-deps-cache-dir t))))
(ob-elixir-test--cleanup-mix-project)))
(ert-deftest ob-elixir-test-mix-env () (ert-deftest ob-elixir-test-deps-execution ()
"Test MIX_ENV handling." "Test executing code with deps."
(skip-unless (and (executable-find ob-elixir-mix-command)
(executable-find ob-elixir-command)))
(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)))
(should (string-match-p "\"a\"" result)))
;; Cleanup
(ob-elixir-cleanup-deps-projects)
(delete-directory ob-elixir-deps-cache-dir t))))
(ert-deftest ob-elixir-test-deps-caching ()
"Test that deps projects are cached and reused."
(skip-unless (executable-find ob-elixir-mix-command)) (skip-unless (executable-find ob-elixir-mix-command))
(let ((env-str (ob-elixir--build-mix-env "test" nil))) (let ((ob-elixir-deps-cache-dir (make-temp-file "ob-elixir-test-" t))
(should (string-match-p "MIX_ENV=test" env-str)))) (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)
(delete-directory ob-elixir-deps-cache-dir t))))
(ert-deftest ob-elixir-test-explicit-no-mix () (provide 'test-ob-elixir-deps)
"Test disabling Mix with :mix-project no."
(let ((params '((:mix-project . no))))
(should (null (ob-elixir--resolve-mix-project params)))))
(provide 'test-ob-elixir-mix)
``` ```
### Step 8: Test in org buffer ### Step 9: Test in org buffer
Create Mix tests in `test.org`: Create test cases in `test.org`:
```org ```org
* Mix Project Tests * Deps Block Tests
** Using project module (explicit path) ** Basic deps usage
#+BEGIN_SRC elixir :mix-project ~/my_elixir_project #+BEGIN_DEPS elixir
MyApp.hello() [{:jason, "~> 1.4"}]
#+END_SRC #+END_DEPS
** Using project module (auto-detect)
When this org file is inside a Mix project:
#+BEGIN_SRC elixir #+BEGIN_SRC elixir
MyApp.some_function() Jason.encode!(%{hello: "world", number: 42})
#+END_SRC #+END_SRC
** With specific MIX_ENV ** Multiple dependencies
#+BEGIN_SRC elixir :mix-project ~/my_elixir_project :mix-env test #+BEGIN_DEPS elixir
Application.get_env(:my_app, :some_config) [
{:jason, "~> 1.4"},
{:decimal, "~> 2.0"}
]
#+END_DEPS
#+BEGIN_SRC elixir
num = Decimal.new("3.14159")
Jason.encode!(%{pi: Decimal.to_string(num)})
#+END_SRC #+END_SRC
** Session with Mix ** Git dependencies
#+BEGIN_SRC elixir :session mix-session :mix-project ~/my_elixir_project #+BEGIN_DEPS elixir
# Has access to project modules [
alias MyApp.SomeModule {:prova, git: "git@gitlab.com:babel-upm/makina/prova.git"}
]
#+END_DEPS
#+BEGIN_SRC elixir
# Use the prova library
Prova.hello()
#+END_SRC #+END_SRC
** Disable auto-detect ** Session with deps
#+BEGIN_SRC elixir :mix-project no #+BEGIN_DEPS elixir
# Plain Elixir, no project context [{:jason, "~> 1.4"}]
1 + 1 #+END_DEPS
#+BEGIN_SRC elixir :session json-session
data = %{users: [%{name: "Alice"}, %{name: "Bob"}]}
#+END_SRC
#+BEGIN_SRC elixir :session json-session
Jason.encode!(data, pretty: true)
#+END_SRC
** Deps override
#+BEGIN_DEPS elixir
[{:httpoison, "~> 2.0"}]
#+END_DEPS
#+BEGIN_SRC elixir
# Jason no longer available here, httpoison is
{:ok, resp} = HTTPoison.get("https://httpbin.org/get")
resp.status_code
#+END_SRC
** No deps (plain execution)
#+BEGIN_SRC elixir
# This block has no deps context (before any deps block or in different file)
Enum.map(1..5, &(&1 * 2))
#+END_SRC #+END_SRC
``` ```
## Acceptance Criteria ## Acceptance Criteria
- [ ] `:mix-project path` executes in specified project - [ ] `#+BEGIN_DEPS elixir` block is recognized and parsed
- [ ] Auto-detection finds `mix.exs` in parent directories - [ ] Deps content is extracted correctly (Elixir list syntax)
- [ ] `:mix-project no` disables auto-detection - [ ] Temp Mix project is created with correct `mix.exs`
- [ ] `:mix-env` sets MIX_ENV correctly - [ ] `mix deps.get` runs automatically
- [ ] Project modules are accessible - [ ] `mix compile` runs automatically
- [ ] Sessions with `:mix-project` use `iex -S mix` - [ ] Subsequent elixir blocks can use the dependencies
- [ ] Compilation errors are reported properly - [ ] Projects are cached by deps hash (identical deps = same project)
- [ ] Cache reuse is fast (no re-compilation)
- [ ] Sessions work with deps context (`iex -S mix`)
- [ ] Session maintains deps consistency (recreates if deps change)
- [ ] Later deps blocks override earlier ones for subsequent code blocks
- [ ] Auto-cleanup on Emacs exit works
- [ ] `ob-elixir-cleanup-deps-projects` command available
- [ ] `ob-elixir-list-deps-projects` command available
- [ ] Proper error handling for:
- Invalid dependency specs
- Network failures during `deps.get`
- Compilation errors
- [ ] All tests pass - [ ] All tests pass
## Header Arguments Reference ## Header Arguments Reference
| Argument | Values | Description | This feature does not add new header arguments. Instead, it uses special blocks:
|----------|--------|-------------|
| `:mix-project` | path, `no` | Project path or disable | | Block | Format | Description |
| `:mix-env` | dev, test, prod | MIX_ENV value | |-------|--------|-------------|
| `:mix-target` | host, target | MIX_TARGET for Nerves | | `#+BEGIN_DEPS elixir` | Elixir list | Dependencies for subsequent code blocks |
Supported dependency formats (same as Mix):
```elixir
# Hex package
{:package_name, "~> 1.0"}
# Git repository
{:package_name, git: "https://github.com/user/repo.git"}
{:package_name, git: "git@github.com:user/repo.git", tag: "v1.0"}
{:package_name, git: "...", branch: "main"}
# Path (for local development)
{:package_name, path: "../local_package"}
# With options
{:package_name, "~> 1.0", only: :dev}
{:package_name, "~> 1.0", runtime: false}
```
## Troubleshooting ## Troubleshooting
### Module not found ### Dependencies not found
Ensure project is compiled: Ensure the deps block is before the code block:
```bash
cd /path/to/project && mix compile ```org
#+BEGIN_DEPS elixir <-- Must come first
[{:jason, "~> 1.4"}]
#+END_DEPS
#+BEGIN_SRC elixir <-- Code block after deps
Jason.encode!(%{})
#+END_SRC
``` ```
### Dependencies not available ### Network errors during deps.get
Check that `mix deps.get` has been run. Check network connectivity. The error message will include Mix output.
### Wrong MIX_ENV ### Compilation errors
Explicitly set `:mix-env` header argument. Check that dependency versions are compatible. Look at the error output for details.
### Slow first execution
The first execution with new deps will be slow (fetching + compiling). Subsequent executions with the same deps use the cached project.
### Cleanup
To manually clean up cached projects:
```
M-x ob-elixir-cleanup-deps-projects
```
To see what's cached:
```
M-x ob-elixir-list-deps-projects
```
## Files Modified ## Files Modified
- `ob-elixir.el` - Add Mix support - `ob-elixir.el` - Add deps block support
- `test/test-ob-elixir-mix.el` - Add Mix tests - `test/test-ob-elixir-deps.el` - Add deps tests
## Implementation Notes
### Why not use header arguments?
The deps list can be quite long and complex (multiple deps, git URLs, options). A dedicated block is more readable and maintainable than cramming it into a header argument.
### Why auto-cleanup?
Temporary projects can consume significant disk space (compiled deps). Auto-cleanup on exit prevents orphaned projects while still allowing cache reuse during a session.
### Why hash-based caching?
Computing a hash of the normalized deps string is fast and ensures that:
1. Identical deps always use the same cached project
2. Any change in deps (version, options, etc.) creates a new project
3. No false cache hits from similar-looking deps
## References ## References
- [docs/04-elixir-integration-strategies.md](../docs/04-elixir-integration-strategies.md) - Mix Project Context
- [Mix Documentation](https://hexdocs.pm/mix/Mix.html) - [Mix Documentation](https://hexdocs.pm/mix/Mix.html)
- [Mix.Project deps](https://hexdocs.pm/mix/Mix.Tasks.Deps.html)