Files
ob-elixir/tasks/08-mix-project-support.md

12 KiB

Task 08: Mix Project Support

Phase: 3 - Mix Integration Priority: High Estimated Time: 2-3 hours Dependencies: Task 07 (Session Support) or Phase 1 complete

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.

Prerequisites

  • Phase 1 complete (or Phase 2 for session+mix)
  • Understanding of Mix build tool
  • A test Mix project

Background

Mix projects have:

  • Dependencies in mix.exs
  • Compiled modules in _build/
  • Configuration in config/

To execute code in project context, we need:

  1. Run code from the project directory
  2. Use mix run for one-shot execution
  3. Use iex -S mix for sessions

Steps

Step 1: Add Mix configuration

Add to ob-elixir.el:

;;; Mix Configuration

(defcustom ob-elixir-mix-command "mix"
  "Command to run Mix."
  :type 'string
  :group 'ob-elixir
  :safe #'stringp)

(defcustom ob-elixir-auto-detect-mix t
  "Whether to automatically detect Mix projects.

When non-nil and no :mix-project is specified, ob-elixir will
search upward from the org file for a mix.exs file."
  :type 'boolean
  :group 'ob-elixir)

(defconst org-babel-header-args:elixir
  '((mix-project . :any)    ; Path to Mix project root
    (mix-env . :any)        ; MIX_ENV (dev, test, prod)
    (mix-target . :any))    ; MIX_TARGET for Nerves, etc.
  "Elixir-specific header arguments.")

Step 2: Implement Mix project detection

;;; Mix Project Detection

(defun ob-elixir--find-mix-project (&optional start-dir)
  "Find Mix project root by searching for mix.exs.

Starts from START-DIR (default: current directory) and searches
upward.  Returns the directory containing mix.exs, or nil."
  (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)
  "Resolve Mix project path from PARAMS or auto-detection.

Returns project path or nil."
  (let ((explicit (cdr (assq :mix-project params))))
    (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)
  "Return t if execution should happen in Mix project context."
  (not (null (ob-elixir--resolve-mix-project params))))

Step 3: Implement Mix execution

;;; Mix Execution

(defun ob-elixir--execute-with-mix (body result-type params)
  "Execute BODY in Mix project context.

RESULT-TYPE is 'value or 'output.
PARAMS contains header arguments including :mix-project."
  (let* ((project-dir (ob-elixir--resolve-mix-project params))
         (mix-env (cdr (assq :mix-env params)))
         (mix-target (cdr (assq :mix-target params)))
         (default-directory project-dir)
         (tmp-file (org-babel-temp-file "ob-elixir-mix-" ".exs"))
         (code (if (eq result-type 'value)
                   (ob-elixir--wrap-for-value body)
                 body))
         (env-vars (ob-elixir--build-mix-env mix-env mix-target)))
    
    ;; Write code to temp file
    (with-temp-file tmp-file
      (insert code))
    
    ;; Execute with mix run
    (let ((command (format "%s%s run %s"
                           env-vars
                           ob-elixir-mix-command
                           (org-babel-process-file-name tmp-file))))
      (ob-elixir--process-result
       (shell-command-to-string command)))))

(defun ob-elixir--build-mix-env (mix-env mix-target)
  "Build environment variable prefix for Mix execution."
  (let ((vars '()))
    (when mix-env
      (push (format "MIX_ENV=%s" mix-env) vars))
    (when mix-target
      (push (format "MIX_TARGET=%s" mix-target) vars))
    (if vars
        (concat (mapconcat #'identity vars " ") " ")
      "")))

Step 4: 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)))
         (mix-project (ob-elixir--resolve-mix-project params))
         (full-body (org-babel-expand-body:generic
                     body params
                     (org-babel-variable-assignments:elixir params)))
         (result (cond
                  ;; Session mode
                  ((and session (not (string= session "none")))
                   (ob-elixir--evaluate-in-session 
                    session full-body result-type params))
                  ;; Mix project mode
                  (mix-project
                   (ob-elixir--execute-with-mix 
                    full-body result-type params))
                  ;; Plain execution
                  (t
                   (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 5: Update session for Mix projects

Modify session creation to support Mix:

(defun ob-elixir--start-session (buffer-name session-name params)
  "Start a new IEx session in BUFFER-NAME."
  (let* ((mix-project (ob-elixir--resolve-mix-project params))
         (mix-env (cdr (assq :mix-env params)))
         (buffer (get-buffer-create buffer-name))
         (default-directory (or mix-project default-directory))
         (process-environment 
          (append
           (list "TERM=dumb")
           (when mix-env (list (format "MIX_ENV=%s" mix-env)))
           process-environment)))
    
    (with-current-buffer buffer
      (if mix-project
          ;; Start with mix
          (make-comint-in-buffer
           (format "ob-elixir-%s" session-name)
           buffer
           ob-elixir-iex-command
           nil
           "-S" "mix")
        ;; Start plain IEx
        (make-comint-in-buffer
         (format "ob-elixir-%s" session-name)
         buffer
         ob-elixir-iex-command
         nil))
      
      ;; Wait for prompt
      (ob-elixir--wait-for-prompt buffer 30)
      
      ;; Configure IEx
      (ob-elixir--configure-session buffer)
      
      buffer)))

Step 6: Add compilation support

(defcustom ob-elixir-compile-before-run nil
  "Whether to run mix compile before execution.

When non-nil, ensures project is compiled before running code.
This adds overhead but catches compilation errors early."
  :type 'boolean
  :group 'ob-elixir)

(defun ob-elixir--ensure-compiled (project-dir)
  "Ensure Mix project at PROJECT-DIR is compiled."
  (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)
  "Execute BODY in Mix project context."
  (let* ((project-dir (ob-elixir--resolve-mix-project params))
         (default-directory project-dir))
    
    ;; Optionally compile first
    (when ob-elixir-compile-before-run
      (ob-elixir--ensure-compiled project-dir))
    
    ;; ... rest of execution
    ))

Step 7: Add tests

Create test/test-ob-elixir-mix.el:

;;; test-ob-elixir-mix.el --- Mix project tests -*- lexical-binding: t; -*-

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

(defvar ob-elixir-test--mix-project-dir nil
  "Temporary Mix project directory for testing.")

(defun ob-elixir-test--setup-mix-project ()
  "Create a temporary Mix project for testing."
  (let ((dir (make-temp-file "ob-elixir-test-" t)))
    (setq ob-elixir-test--mix-project-dir dir)
    (let ((default-directory dir))
      ;; Create mix.exs
      (with-temp-file (expand-file-name "mix.exs" dir)
        (insert "defmodule TestProject.MixProject do
  use Mix.Project

  def project do
    [app: :test_project, version: \"0.1.0\", elixir: \"~> 1.14\"]
  end
end"))
      ;; Create lib directory and module
      (make-directory (expand-file-name "lib" dir))
      (with-temp-file (expand-file-name "lib/test_project.ex" dir)
        (insert "defmodule TestProject do
  def hello, do: \"Hello from TestProject!\"
  def add(a, b), do: a + b
end")))
    dir))

(defun ob-elixir-test--cleanup-mix-project ()
  "Clean up temporary Mix project."
  (when ob-elixir-test--mix-project-dir
    (delete-directory ob-elixir-test--mix-project-dir t)
    (setq ob-elixir-test--mix-project-dir nil)))

;;; Tests

(ert-deftest ob-elixir-test-find-mix-project ()
  "Test Mix project detection."
  (skip-unless (executable-find ob-elixir-mix-command))
  (unwind-protect
      (let* ((project-dir (ob-elixir-test--setup-mix-project))
             (sub-dir (expand-file-name "lib" project-dir))
             (default-directory sub-dir))
        (should (equal project-dir 
                       (ob-elixir--find-mix-project))))
    (ob-elixir-test--cleanup-mix-project)))

(ert-deftest ob-elixir-test-mix-project-execution ()
  "Test code execution in Mix project context."
  (skip-unless (and (executable-find ob-elixir-mix-command)
                    (executable-find ob-elixir-command)))
  (unwind-protect
      (let* ((project-dir (ob-elixir-test--setup-mix-project))
             (params `((:mix-project . ,project-dir))))
        ;; Compile first
        (let ((default-directory project-dir))
          (shell-command-to-string "mix compile"))
        ;; Test execution
        (let ((result (ob-elixir--execute-with-mix
                       "TestProject.hello()" 'value params)))
          (should (string-match-p "Hello from TestProject" result))))
    (ob-elixir-test--cleanup-mix-project)))

(ert-deftest ob-elixir-test-mix-env ()
  "Test MIX_ENV handling."
  (skip-unless (executable-find ob-elixir-mix-command))
  (let ((env-str (ob-elixir--build-mix-env "test" nil)))
    (should (string-match-p "MIX_ENV=test" env-str))))

(ert-deftest ob-elixir-test-explicit-no-mix ()
  "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

Create Mix tests in test.org:

* Mix Project Tests

** Using project module (explicit path)

#+BEGIN_SRC elixir :mix-project ~/my_elixir_project
MyApp.hello()
#+END_SRC

** Using project module (auto-detect)

When this org file is inside a Mix project:

#+BEGIN_SRC elixir
MyApp.some_function()
#+END_SRC

** With specific MIX_ENV

#+BEGIN_SRC elixir :mix-project ~/my_elixir_project :mix-env test
Application.get_env(:my_app, :some_config)
#+END_SRC

** Session with Mix

#+BEGIN_SRC elixir :session mix-session :mix-project ~/my_elixir_project
# Has access to project modules
alias MyApp.SomeModule
#+END_SRC

** Disable auto-detect

#+BEGIN_SRC elixir :mix-project no
# Plain Elixir, no project context
1 + 1
#+END_SRC

Acceptance Criteria

  • :mix-project path executes in specified project
  • Auto-detection finds mix.exs in parent directories
  • :mix-project no disables auto-detection
  • :mix-env sets MIX_ENV correctly
  • Project modules are accessible
  • Sessions with :mix-project use iex -S mix
  • Compilation errors are reported properly
  • All tests pass

Header Arguments Reference

Argument Values Description
:mix-project path, no Project path or disable
:mix-env dev, test, prod MIX_ENV value
:mix-target host, target MIX_TARGET for Nerves

Troubleshooting

Module not found

Ensure project is compiled:

cd /path/to/project && mix compile

Dependencies not available

Check that mix deps.get has been run.

Wrong MIX_ENV

Explicitly set :mix-env header argument.

Files Modified

  • ob-elixir.el - Add Mix support
  • test/test-ob-elixir-mix.el - Add Mix tests

References