diff --git a/tasks/08-mix-project-support.md b/tasks/08-mix-project-support.md index 15390ab..183ebf2 100644 --- a/tasks/08-mix-project-support.md +++ b/tasks/08-mix-project-support.md @@ -1,40 +1,88 @@ -# Task 08: Mix Project Support +# Task 08: Mix Dependencies Support **Phase**: 3 - Mix Integration **Priority**: High -**Estimated Time**: 2-3 hours +**Estimated Time**: 3-4 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. +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 -- Phase 1 complete (or Phase 2 for session+mix) +- Phase 1 complete (or Phase 2 for session+deps) - Understanding of Mix build tool -- A test Mix project +- Network access for fetching dependencies ## Background -Mix projects have: -- Dependencies in `mix.exs` -- Compiled modules in `_build/` -- Configuration in `config/` +### The Problem -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 +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. + +### The Solution + +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 -### Step 1: Add Mix configuration - -Add to `ob-elixir.el`: +### Step 1: Add configuration and infrastructure ```elisp -;;; Mix Configuration +;;; Mix Dependencies Configuration (defcustom ob-elixir-mix-command "mix" "Command to run Mix." @@ -42,126 +90,241 @@ Add to `ob-elixir.el`: :group 'ob-elixir :safe #'stringp) -(defcustom ob-elixir-auto-detect-mix t - "Whether to automatically detect Mix projects. +(defcustom ob-elixir-deps-cache-dir + (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 -search upward from the org file for a mix.exs file." - :type 'boolean +Each unique set of dependencies gets its own subdirectory, +named by the hash of the dependencies." + :type 'directory :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.") +(defvar ob-elixir--deps-projects (make-hash-table :test 'equal) + "Hash table mapping deps-hash to project directory path.") + +(defvar ob-elixir--cleanup-registered nil + "Whether the cleanup hook has been registered.") ``` -### Step 2: Implement Mix project detection +### Step 2: Implement deps block parsing ```elisp -;;; Mix Project Detection +;;; Deps Block Parsing -(defun ob-elixir--find-mix-project (&optional start-dir) - "Find Mix project root by searching for mix.exs. +(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.") -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--find-deps-for-position (pos) + "Find the most recent deps block before POS. -(defun ob-elixir--resolve-mix-project (params) - "Resolve Mix project path from PARAMS or auto-detection. +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))) -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--normalize-deps (deps-string) + "Normalize DEPS-STRING for consistent hashing. -(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)))) +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))) ``` -### Step 3: Implement Mix execution +### Step 3: Implement temp Mix project creation ```elisp -;;; Mix Execution +;;; Temporary Mix Project Management -(defun ob-elixir--execute-with-mix (body result-type params) - "Execute BODY in Mix project context. +(defconst ob-elixir--mix-exs-template + "defmodule ObElixirTemp.MixProject do + use Mix.Project -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))) + 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)) +``` + +### 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) - (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) (ob-elixir--wrap-for-value body) - body)) - (env-vars (ob-elixir--build-mix-env mix-env mix-target))) + body))) ;; 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 + (let ((command (format "%s run --no-compile %s 2>&1" ob-elixir-mix-command - (org-babel-process-file-name tmp-file)))) + (shell-quote-argument 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 " ") " ") - ""))) +(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 + (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 (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))) (result-type (cdr (assq :result-type 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 body params (org-babel-variable-assignments:elixir params))) (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"))) - (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)) + (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))))) @@ -175,247 +338,397 @@ Modify `org-babel-execute:elixir`: (cdr (assq :rownames params)))))) ``` -### Step 5: Update session for Mix projects - -Modify session creation to support Mix: +### Step 6: Implement session support with deps ```elisp -(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))) +(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.") + +(defun ob-elixir--get-or-create-session-with-deps (name deps-string params) + "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))) - (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))) + ;; 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 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." + (let* ((buffer (ob-elixir--get-or-create-session-with-deps session deps-string 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 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 -(defcustom ob-elixir-compile-before-run nil - "Whether to run mix compile before execution. +;;; Cleanup -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--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--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-cleanup-deps-projects () + "Delete all temporary Mix projects created by ob-elixir. -(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 - )) +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)))) ``` -### Step 7: Add tests +### Step 8: Add tests -Create `test/test-ob-elixir-mix.el`: +Create `test/test-ob-elixir-deps.el`: ```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 'ob-elixir) -(defvar ob-elixir-test--mix-project-dir nil - "Temporary Mix project directory for testing.") +;;; Parsing Tests -(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 +(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))))) - 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)) +(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)))))) -(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))) +(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)))))) -;;; 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 () - "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-normalize-deps () + "Test deps normalization." + (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-mix-project-execution () - "Test code execution in Mix project context." +;;; Integration Tests (require network and mix) + +(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) (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))) + (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) + (delete-directory ob-elixir-deps-cache-dir t)))) -(ert-deftest ob-elixir-test-mix-env () - "Test MIX_ENV handling." +(ert-deftest ob-elixir-test-deps-execution () + "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)) - (let ((env-str (ob-elixir--build-mix-env "test" nil))) - (should (string-match-p "MIX_ENV=test" env-str)))) + (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) + (delete-directory ob-elixir-deps-cache-dir t)))) -(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) +(provide 'test-ob-elixir-deps) ``` -### 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 -* Mix Project Tests +* Deps Block Tests -** Using project module (explicit path) +** Basic deps usage -#+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_DEPS elixir +[{:jason, "~> 1.4"}] +#+END_DEPS #+BEGIN_SRC elixir -MyApp.some_function() +Jason.encode!(%{hello: "world", number: 42}) #+END_SRC -** With specific MIX_ENV +** Multiple dependencies -#+BEGIN_SRC elixir :mix-project ~/my_elixir_project :mix-env test -Application.get_env(:my_app, :some_config) +#+BEGIN_DEPS elixir +[ + {: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 -** Session with Mix +** Git dependencies -#+BEGIN_SRC elixir :session mix-session :mix-project ~/my_elixir_project -# Has access to project modules -alias MyApp.SomeModule +#+BEGIN_DEPS elixir +[ + {:prova, git: "git@gitlab.com:babel-upm/makina/prova.git"} +] +#+END_DEPS + +#+BEGIN_SRC elixir +# Use the prova library +Prova.hello() #+END_SRC -** Disable auto-detect +** Session with deps -#+BEGIN_SRC elixir :mix-project no -# Plain Elixir, no project context -1 + 1 +#+BEGIN_DEPS elixir +[{:jason, "~> 1.4"}] +#+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 ``` ## 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 +- [ ] `#+BEGIN_DEPS elixir` block is recognized and parsed +- [ ] Deps content is extracted correctly (Elixir list syntax) +- [ ] Temp Mix project is created with correct `mix.exs` +- [ ] `mix deps.get` runs automatically +- [ ] `mix compile` runs automatically +- [ ] Subsequent elixir blocks can use the dependencies +- [ ] 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 ## 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 | +This feature does not add new header arguments. Instead, it uses special blocks: + +| Block | Format | Description | +|-------|--------|-------------| +| `#+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 -### Module not found +### Dependencies not found -Ensure project is compiled: -```bash -cd /path/to/project && mix compile +Ensure the deps block is before the code block: + +```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 -- `ob-elixir.el` - Add Mix support -- `test/test-ob-elixir-mix.el` - Add Mix tests +- `ob-elixir.el` - Add deps block support +- `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 -- [docs/04-elixir-integration-strategies.md](../docs/04-elixir-integration-strategies.md) - Mix Project Context - [Mix Documentation](https://hexdocs.pm/mix/Mix.html) +- [Mix.Project deps](https://hexdocs.pm/mix/Mix.Tasks.Deps.html)