Files
ob-elixir/tasks/13a-module-definition-blocks-fix.md

6.8 KiB

Task 13a: Fix Module Definition Blocks - Code.eval_string Wrapper

Problem

The initial implementation of module definition blocks (Task 13) has a fundamental issue: Elixir does not allow importing a module that is defined in the same file at the top level. When we generate code like:

defmodule Helpers do
  def double(x), do: x * 2
end

import Helpers  # ERROR: module Helpers is not loaded but was defined

double(21)

Elixir produces this error:

error: module Helpers is not loaded but was defined. This happens when you 
depend on a module in the same context in which it is defined...
If the module is defined at the top-level and you are trying to use it at 
the top-level, this is not supported by Elixir

Note: The anonymous function wrapper approach does NOT work either - Elixir still compiles the function body at the same time as the module definition.

Solution

Use Code.eval_string/1 to defer the evaluation of imports and user code until runtime, after the module has been compiled and loaded:

defmodule Helpers do
  def double(x), do: x * 2
end

Code.eval_string("""
import Helpers

result = (
double(21)
)
IO.puts(inspect(result, limit: :infinity, printable_limit: :infinity, charlists: :as_lists))
""")

This works because:

  1. The defmodule is compiled and loaded first
  2. Code.eval_string/1 defers parsing and evaluation until runtime
  3. By the time the string is evaluated, the module is fully available

Implementation Plan

Step 1: Add escape helper function

File: ob-elixir.el (before ob-elixir--execute)

Add:

(defun ob-elixir--escape-for-eval-string (str)
  "Escape STR for use inside Elixir Code.eval_string heredoc.

Escapes backslashes and double quotes."
  (let ((result str))
    (setq result (replace-regexp-in-string "\\\\" "\\\\\\\\" result))
    (setq result (replace-regexp-in-string "\"" "\\\\\"" result))
    result))

Step 2: Modify ob-elixir--execute

File: ob-elixir.el (around line 968)

New code assembly:

(code (if modules-string
          ;; When modules are defined, wrap imports + code in Code.eval_string
          (let ((eval-body (concat
                            (when imports-string (concat (string-trim imports-string) "\n"))
                            wrapped)))
            (concat
             (string-trim modules-string) "\n\n"
             "Code.eval_string(\"\"\"\n"
             (ob-elixir--escape-for-eval-string eval-body)
             "\n\"\"\")"))
        ;; Normal path without modules
        (concat
         (when imports-string (concat (string-trim imports-string) "\n\n"))
         wrapped)))

Step 3: Modify ob-elixir--execute-with-deps

File: ob-elixir.el (around line 610)

Apply the same Code.eval_string pattern as Step 2.

Step 4: Modify ob-elixir--evaluate-in-session

File: ob-elixir.el (around line 736)

Apply the same Code.eval_string pattern as Step 2.

Step 5: Modify ob-elixir--evaluate-in-session-with-deps

File: ob-elixir.el (around line 892)

Apply the same Code.eval_string pattern as Step 2.

Step 6: Fix test expectations

File: test/test-ob-elixir-modules.el

The tests had incorrect expectations. When using Code.eval_string with modules:

  • Scalar numbers come back as strings (e.g., "42" not 42)
  • String values come back with quotes (e.g., "\"hello\"" not "hello")
  • Lists are parsed correctly as before

Also, the test using Config as module name conflicted with Elixir's built-in Config module. Changed to MyConfig.

Step 7: Run tests and verify

After making the changes:

make test

Expected: All 80 tests should pass, including the 6 that were previously failing:

  • ob-elixir-test-module-integration-basic
  • ob-elixir-test-module-integration-merged
  • ob-elixir-test-module-integration-multiple-modules
  • ob-elixir-test-module-with-module-attributes
  • ob-elixir-test-module-with-private-functions
  • ob-elixir-test-module-without-import

Generated Code Examples

Example 1: Basic module with import

Input org file:

#+BEGIN_SRC elixir :module Helpers
def double(x), do: x * 2
#+END_SRC

#+BEGIN_IMPORTS elixir
import Helpers
#+END_IMPORTS

#+BEGIN_SRC elixir :results value
double(21)
#+END_SRC

Generated Elixir code:

defmodule Helpers do
def double(x), do: x * 2
end

(fn ->
import Helpers
result = (
double(21)
)
IO.puts(inspect(result, limit: :infinity, printable_limit: :infinity, charlists: :as_lists))
end).()

Example 2: Multiple modules

Input org file:

#+BEGIN_SRC elixir :module ModA
def value, do: 10
#+END_SRC

#+BEGIN_SRC elixir :module ModB
def value, do: 20
#+END_SRC

#+BEGIN_IMPORTS elixir
alias ModA
alias ModB
#+END_IMPORTS

#+BEGIN_SRC elixir :results value
ModA.value() + ModB.value()
#+END_SRC

Generated Elixir code:

defmodule ModA do
def value, do: 10
end

defmodule ModB do
def value, do: 20
end

(fn ->
alias ModA
alias ModB
result = (
ModA.value() + ModB.value()
)
IO.puts(inspect(result, limit: :infinity, printable_limit: :infinity, charlists: :as_lists))
end).()

Example 3: Without imports (using full module name)

Input org file:

#+BEGIN_SRC elixir :module Helpers
def greet, do: "hello"
#+END_SRC

#+BEGIN_SRC elixir :results value
Helpers.greet()
#+END_SRC

Generated Elixir code:

defmodule Helpers do
def greet, do: "hello"
end

(fn ->
result = (
Helpers.greet()
)
IO.puts(inspect(result, limit: :infinity, printable_limit: :infinity, charlists: :as_lists))
end).()

Example 4: No modules defined (no wrapper needed)

Input org file:

#+BEGIN_IMPORTS elixir
import Enum
#+END_IMPORTS

#+BEGIN_SRC elixir :results value
map([1,2,3], &(&1 * 2))
#+END_SRC

Generated Elixir code (no change from current behavior):

import Enum

result = (
map([1,2,3], &(&1 * 2))
)
IO.puts(inspect(result, limit: :infinity, printable_limit: :infinity, charlists: :as_lists))

Summary of Changes

File Function Change
ob-elixir.el ob-elixir--execute Wrap imports+code in anon fn when modules present
ob-elixir.el ob-elixir--execute-with-deps Wrap imports+code in anon fn when modules present
ob-elixir.el ob-elixir--evaluate-in-session Wrap imports+code in anon fn when modules present
ob-elixir.el ob-elixir--evaluate-in-session-with-deps Wrap imports+code in anon fn when modules present
test/test-ob-elixir-modules.el Various tests Adjust expectations if needed

Notes

  • The anonymous function wrapper is ONLY used when modules-string is non-nil
  • When no modules are defined, the code generation remains unchanged (backward compatible)
  • The wrapper doesn't affect the return value semantics - result = (...) still works
  • Session mode should work identically since modules persist after first evaluation