Powered by AppSignal & Oban Pro

Tier 4: Modules & Prelude

docs/demos/tier4.livemd

Tier 4: Modules & Prelude

Section

Mix.install([
  {:haruspex, path: "/Users/quinn/dev/beam_box/haruspex"},
  {:roux, path: "/Users/quinn/dev/beam_box/roux"},
  {:pentiment, path: "/Users/quinn/dev/beam_box/pentiment"},
  {:constrain, path: "/Users/quinn/dev/beam_box/constrain"},
  {:quail, path: "/Users/quinn/dev/beam_box/quail"}
])

Tier 4 adds multi-module compilation to Haruspex — file→module mapping, cross-module name resolution, import declarations with visibility control, and an auto-imported prelude that can be opted out of with @no_prelude.

What’s new

  • FileInfo entity — stores imports and file-level metadata per source file
  • {:global, module, name, arity} — new core term for cross-module references
  • Import modes — qualified-only, open: true, and open: [names]
  • @private — visibility control for definitions
  • Haruspex.Prelude — auto-imported builtins (types + ops), opt-out via @no_prelude
  • Erased param handling — cross-module calls correctly skip zero-multiplicity params

Setup

# Helper to create a fresh database and register haruspex.
new_db = fn ->
  db = Roux.Database.new()
  Roux.Lang.register(db, Haruspex)
  db
end

# Helper to set source text for a file URI.
set_source = fn db, uri, source ->
  Roux.Input.set(db, :source_text, uri, source)
end

# Helper to purge compiled modules between examples.
purge = fn mod ->
  :code.purge(mod)
  :code.delete(mod)
end

Module name derivation

Haruspex derives Elixir module names from file URIs by camelizing path segments and stripping configurable source roots.

[
  Haruspex.module_name_from_uri("lib/math.hx", ["lib"]),
  Haruspex.module_name_from_uri("lib/data/vec.hx", ["lib"]),
  Haruspex.module_name_from_uri("src/utils/string_helpers.hx", ["src"])
]

Cross-module compilation with open imports

Define inc in module A, then import it into module B with open: true so it’s available unqualified. The import creates a {:global, MathA, :inc, 1} core term that flows through elaboration, checking, erasure, and codegen.

db = new_db.()

set_source.(db, "lib/math_a.hx", """
def add(x : Int, y : Int) : Int do x + y end
""")

set_source.(db, "lib/math_b.hx", """
import MathA, open: true
def double(x : Int) : Int do add(x, x) end
""")

{:ok, _} = Roux.Runtime.query(db, :haruspex_compile, "lib/math_a.hx")
{:ok, mod_b} = Roux.Runtime.query(db, :haruspex_compile, "lib/math_b.hx")

IO.puts("MathB.double(5) = #{mod_b.double(5)}")
IO.puts("MathB.double(21) = #{mod_b.double(21)}")

purge.(MathA)
purge.(MathB)
:ok

Qualified access

With a plain import (no open option), names are only accessible via Module.name syntax.

db = new_db.()

set_source.(db, "lib/math_a.hx", """
def add(x : Int, y : Int) : Int do x + y end
""")

set_source.(db, "lib/math_b.hx", """
import MathA
def double(x : Int) : Int do MathA.add(x, x) end
""")

{:ok, _} = Roux.Runtime.query(db, :haruspex_compile, "lib/math_a.hx")
{:ok, mod_b} = Roux.Runtime.query(db, :haruspex_compile, "lib/math_b.hx")

IO.puts("MathB.double(7) = #{mod_b.double(7)}")

purge.(MathA)
purge.(MathB)
:ok

Selective open imports

open: [names] selectively exposes only the listed names unqualified. Names not in the list are rejected as unbound.

db = new_db.()

set_source.(db, "lib/math_a.hx", """
def inc(x : Int) : Int do x + 1 end
def dec(x : Int) : Int do x - 1 end
""")

set_source.(db, "lib/math_b.hx", """
import MathA, open: [inc]
def inc3(x : Int) : Int do inc(inc(inc(x))) end
""")

{:ok, _} = Roux.Runtime.query(db, :haruspex_compile, "lib/math_a.hx")
{:ok, mod_b} = Roux.Runtime.query(db, :haruspex_compile, "lib/math_b.hx")

IO.puts("MathB.inc3(0) = #{mod_b.inc3(0)}")

# dec is NOT in the open list — it's rejected:
set_source.(db, "lib/math_c.hx", """
import MathA, open: [inc]
def try_dec(x : Int) : Int do dec(x) end
""")

{:ok, _} = Roux.Runtime.query(db, :haruspex_parse, "lib/math_a.hx")
result = Roux.Runtime.query(db, :haruspex_elaborate, {"lib/math_c.hx", :try_dec})
IO.puts("dec unqualified → #{elem(result, 0)}")

purge.(MathA)
purge.(MathB)
:ok

Visibility: @private

Definitions marked @private are invisible to other modules, even with open: true.

db = new_db.()

set_source.(db, "lib/math_a.hx", """
def public_fn(x : Int) : Int do x + 1 end
@private
def secret(x : Int) : Int do x * 2 end
""")

set_source.(db, "lib/math_b.hx", """
import MathA, open: true
def use_public(x : Int) : Int do public_fn(x) end
""")

{:ok, _} = Roux.Runtime.query(db, :haruspex_compile, "lib/math_a.hx")
{:ok, mod_b} = Roux.Runtime.query(db, :haruspex_compile, "lib/math_b.hx")
IO.puts("use_public(5) = #{mod_b.use_public(5)}")

# secret is not accessible even with open: true:
set_source.(db, "lib/math_c.hx", """
import MathA, open: true
def use_secret(x : Int) : Int do secret(x) end
""")

{:ok, _} = Roux.Runtime.query(db, :haruspex_parse, "lib/math_a.hx")
result = Roux.Runtime.query(db, :haruspex_elaborate, {"lib/math_c.hx", :use_secret})
IO.puts("secret access → #{elem(result, 0)}")

purge.(MathA)
purge.(MathB)
:ok

Three-module chain

Modules can form dependency chains. Here Top imports Middle which imports Arith — each building on the previous.

db = new_db.()

set_source.(db, "lib/arith.hx", """
def inc(x : Int) : Int do x + 1 end
""")

set_source.(db, "lib/middle.hx", """
import Arith, open: true
def inc2(x : Int) : Int do inc(inc(x)) end
""")

set_source.(db, "lib/top.hx", """
import Middle, open: true
def inc4(x : Int) : Int do inc2(inc2(x)) end
""")

{:ok, _} = Roux.Runtime.query(db, :haruspex_compile, "lib/arith.hx")
{:ok, _} = Roux.Runtime.query(db, :haruspex_compile, "lib/middle.hx")
{:ok, mod} = Roux.Runtime.query(db, :haruspex_compile, "lib/top.hx")

IO.puts("Top.inc4(0) = #{mod.inc4(0)}")
IO.puts("Top.inc4(10) = #{mod.inc4(10)}")

purge.(Arith)
purge.(Middle)
purge.(Top)
:ok

Prelude and @no_prelude

The prelude auto-imports builtin types and operations. Files can opt out with @no_prelude, which makes even Int unavailable.

# The prelude provides these names automatically:
Haruspex.Prelude.names() |> Enum.sort()
db = new_db.()

# @no_prelude makes Int unavailable:
set_source.(db, "lib/bare.hx", """
@no_prelude
def f(x : Int) : Int do x end
""")

result = Roux.Runtime.query(db, :haruspex_elaborate, {"lib/bare.hx", :f})
IO.puts("@no_prelude → #{elem(result, 0)}")
:ok

Cross-module erased params

Functions with zero-multiplicity (erased) parameters work correctly across module boundaries. The arity excludes erased params, and the checker strips zero-pi prefixes when synthesizing the imported type.

db = new_db.()

set_source.(db, "lib/poly.hx", """
def wrap(0 a : Type, x : Int) : Int do x end
""")

set_source.(db, "lib/user.hx", """
import Poly, open: true
def use_wrap(x : Int) : Int do wrap(x) end
""")

{:ok, _} = Roux.Runtime.query(db, :haruspex_compile, "lib/poly.hx")
{:ok, mod} = Roux.Runtime.query(db, :haruspex_compile, "lib/user.hx")

IO.puts("use_wrap(42) = #{mod.use_wrap(42)}")

purge.(Poly)
purge.(User)
:ok

The {:global, …} core term

Tracing a cross-module call through the pipeline shows the {:global, ...} core term at the elaboration and erasure stages.

db = new_db.()

set_source.(db, "lib/math_a.hx", """
def inc(x : Int) : Int do x + 1 end
""")

set_source.(db, "lib/math_b.hx", """
import MathA, open: true
def inc2(x : Int) : Int do inc(inc(x)) end
""")

{:ok, _} = Roux.Runtime.query(db, :haruspex_parse, "lib/math_a.hx")
{:ok, {type, body}} = Roux.Runtime.query(db, :haruspex_elaborate, {"lib/math_b.hx", :inc2})

IO.inspect(body, label: "elaborated")
IO.inspect(Haruspex.Erase.erase(body, type), label: "erased")

purge.(MathA)
:ok