The Monad language. Dependent types, functional programming compiled with LLVM. Hobby project.
monad docs src modules.md
10 kB
Markdown
at main

Modules and Imports #

Monad organizes code into modules, allowing you to structure, reuse, and namespace your code.

Basic Module Structure #

Each .mo file is a module. The module name is derived from its path relative to a search-path root: std/concurrent/fiber.mo is the module std.concurrent.fiber.

Importing Modules #

Use use to bring a module into scope, listing exactly the names you want in {...}:

use io {IO}
use std::path {Path}

def p : Option Path := none
def io_unit : Option (IO Unit) := none

:: separates module path segments #

A use path uses :: between its segments, the way Rust does. :: is only for use: open paths name a namespace rather than a file, and dotted definition names (String.length), member access (x.field) and constructor paths (List.cons) all keep .. The separator is what tells a module path apart from a name path on sight.

:: is the only spelling a use path accepts — the corpus migration is complete, so a dotted use std.list is a parse error rather than a synonym. Both parsers enforce it (use_path_sep in lang/src/parser.mo, use_path_expression in core/src/parser.rs). The three-way rule in full:

Form Separator Example
use path (names a file) :: use std::list {intercalate}
open path (names a namespace) . open List {map}
term reference, decl name, field access . List.cons, x.field, String.length

Inside a mote, lib names that mote's own library root — Rust's crate:

use lib::parser::core {ParseResult}   // this mote's own src/parser/core.mo

That block is tagged ignore because lib only means something inside a mote, and the examples here are checked standing alone. lib is root-relative within its mote, so it reads the same from any file in it.

An empty {} still loads the module — for qualified access, and for its instances — without binding any bare names:

use io {}
open IO {println}

def main (args : List String) : IO Unit := println "Hello"

{*} imports everything explicitly. A bare use io with no braces at all still parses, but the compiler warns that it is deprecated.

Opening Modules #

open makes a module's definitions available without their prefix:

use io {IO}
open IO {println}

def main (args : List String) : IO Unit := println "Hello"

Without the open, write the full path — which always works:

use io {IO}

def main (args : List String) : IO Unit := IO.println "Hello"

What the Prelude Opens #

The prelude is loaded into every file and opens these, which is why their constructors are available bare:

open Unit {unit}
open Bool {and, false, not, or, true}
open Result {err, ok}
open Option {none, some}
open True {trivial}

The Standard Library #

The library is split in two, and the split is a rule, not a convention:

  • init/ — pure, portable core. Code that must work in any environment, including wasm and embedded targets. No OS-specific natives.
  • std/ — OS-specific implementations and genuine side effects.

IO itself (the type and its Monad instance) lives in init/io.mo; IO.println and file access live in std/io.mo, because they touch the operating system.

What is available without importing #

Twelve modules are loaded ambiently: the prelude, plus id, io, number, math, string, list, the init hub, std.path, std.io, std.process, and the std hub. Everything else must be imported.

Note that prelude is not a module you can use — it is loaded for you.

The re-export hubs do not cover everything #

use init {...} resolves to init/lib.mo, which re-exports only id, io, number, math, string, and list. use std {...} resolves to std/lib.mo, which re-exports only std.path, std.io, and std.process.

Everything else needs an explicit qualified import. This catches people out, so here is the full list:

Module Ambient? Contents
init yes Re-export hub; From class
init.list yes List.get
init.string yes String operations
init.math / init.number yes All fixed-width numeric ops and instances
init.id yes The Id identity monad
init.foldable no Semigroup, Monoid, Foldable, Traversable
init.optics no Lens, Prism, view, set, over
init.meta no Reflection types used by #[derive]
io yes The IO type and its Monad instance
std yes Re-export hub
std.io yes IO.println, file I/O, get_env, current_time
std.path yes The validated Path type
std.process yes exec_cmd, process_id
std.list no length, filter, any, all, sum, dedup_by, …
std.map no Map class, HashMap, BTreeMap
std.base no Ordering, Ord, Default, Enum, Bounded
std.show / std.debug no The Show and Debug classes
std.derive no The #[derive] backends
std.test no Test.assert
std.bench no Timing helpers
std.ansi no Terminal colours
std.sha256 no SHA-256, in pure Monad
std.concurrent.fiber / .combine no Fibers and combinators

See The Standard Library for what is in each.

How Modules Are Found #

Resolution is mote-based, with the directory conventions kept underneath as the script-mode fallback. For a module path a::b, the compiler tries, in order:

Candidate Notes
init/src/prelude.mo only for the exact name prelude
init/src/lib.mo only for the exact name init
std/src/lib.mo only for the exact name std
{importing file's mote root}/a/b.mo the mote doing the use
{dir of the importing file}/a/b.mo relative to the file doing the use
a/b.mo relative to the working directory
a/src/b.mo, a/src/lib.mo the head segment names a mote: use example::greet → example/src/greet.mo
init/src/a/b.mo
std/src/a/b.mo
lang/src/a/b.mo

First hit wins. init and std need their own cases because their module names no longer match their file names — both resolve to a lib.mo re-export hub.

Only when every one of those misses does the compiler look further, and this half is what makes the word mote load-bearing rather than decorative:

  1. The motes/ convention. Every motes/*/src/{stem}.mo is probed, which is how a bare use greet finds motes/example/src/greet.mo without naming its mote; then motes/{head}/src/{rest}.mo for the qualified spelling.
  2. The importing file's own manifest. mote.toml's declared dependency paths answer the lookup, so use std::list resolves to the dependency's real src/list.mo rather than to a directory that happens to be named like it. This is the step that makes resolution key off the mote, not the working directory.

The first three candidates are spelled relative to the checkout root, so from a directory that is not the root they miss — which is why they also consult the manifest, and why monad check src/main.mo from inside cli/ now loads its own init/std and reports nothing. Run the compiler from the checkout root and nothing changes, because the first candidate always hits there.

check and test each take the same three modes: explicit paths, --workspace/-w (every mote in the enclosing workspace), or bare — the mote containing the working directory. compile is the exception: it takes an explicit path only, and a bare monad compile prints its usage rather than compiling the mote you are standing in:

monad check --workspace
monad test src/main.mo
monad compile cli/src/main.mo

A file with no mote.toml above it is a script module: it declares the mote it belongs to inline, with a file-level #![mote { … }] annotation whose deps are validated the way a manifest's are.

#![mote { name := "structs", deps := [init, std] }]

Three keys are accepted: name, deps and libs (the last mirroring a manifest's [link] libs). Anything else is an unknown_mote_key_error — an inline annotation never silently swallows a misspelled key.

There is no search-path flag and no environment variable; those belong to the bootstrap host.

Visibility #

Declarations have three visibility levels:

pub def exported : I64 := 1        // visible everywhere
priv def internal : I64 := 2       // visible only in this file
def package_private : I64 := 3     // the default

The default is package-private. pub use module {*} re-exports an import, which is how the init and std hubs work.

Unused Imports #

The compiler warns when a name listed in a use/open is never referenced:

warning: unused import `Path` from `std.path`

The bootstrap host can rewrite the declarations for you — monad-rs organize-imports --write computes the minimal name list, converts bare use/open to the explicit form, and deletes imports that contribute nothing. There is no equivalent in the self-hosted compiler.

Complete Example #

use io {IO}
open IO {println}

def say_hello (s : String) : IO Unit := println s

def main (args : List String) : IO Unit :=
    args
        |> List.last
        |> (Option.get_or_default "no arguments")
        |> say_hello

Summary #

  • Each .mo file is a module; a use path uses :: between segments
  • use Module {names} loads a module; open Module {names} drops the prefix
  • {*} imports everything; bare use/open is deprecated
  • init/ is pure and portable, std/ is OS-specific
  • Only 12 modules are ambient — most of std/ needs an explicit import
  • Resolution is mote-based: a mote's own root first, the directory cascade only as the script-mode fallback
  • check/test each take explicit paths, --workspace, or the mote you are standing in; compile takes an explicit path
  • A script module names its mote with a leading #![mote { name := …, deps := […] }]
  • pub/priv/package-private control visibility

Next, we'll explore the IO monad for effectful programming.