Skip to content

Modules & Visibility

Zolo organizes code into modules. A module is either a file on disk or an inline block declared with mod foo { ... }. Items inside a module can be imported into another module with use, optionally renamed with as, and exposed with pub (with optional scope refinements).

See also: the cherry-picks and innovations roadmap in specs/mod-namespace-innovations.html.

mod — declare a module

File-based modules #

Each .zolo file in your project is a module. To bring a sibling file into the current scope as a namespace, declare it with mod:

// src/main.zolo
mod helpers          // loads src/helpers.zolo

fn main() {
    helpers.greet("world")
}

The first path segment of mod resolves to a file in the same directory (or a <name>/mod.zolo if a directory). Nested paths work the same way:

mod core::math       // loads src/core/math.zolo

After mod helpers, the binding helpers is in scope as a module table — call its pub items with helpers.foo(...).

Inline modules #

You can also declare a module inline with a body. The block is self-contained — no extra file is read:

mod arith {
    pub fn add(a: int, b: int) -> int { a + b }
    pub fn mul(a: int, b: int) -> int { a * b }

    // Visible to siblings inside `arith`, hidden outside the file.
    pub(mod) fn secret_factor() -> int { 3 }

    pub(crate) fn triple(x: int) -> int { x * secret_factor() }
}

fn main() {
    print(arith.add(2, 3))      // 5
    print(arith.triple(4))      // 12 (uses secret_factor)
    // arith.secret_factor()    // ERROR — `pub(mod)` keeps it file-local
}

Only the single-segment form (mod foo { ... }) is allowed. For deeper nesting, write nested mod declarations:

mod outer {
    mod inner {
        pub fn ping() -> str { "pong" }
    }
}

use — import names into scope

Single item #

use core::math::Vec3        // brings `Vec3` into scope

The last segment is the binding. Use the whole path when calling — the binding hides the import path.

List of items #

use core::math::{Vec3, Quat, dot}

Every name in the braces becomes a local. The path before the braces points at the module that owns them.

Glob #

use core::math::*       // every `pub` item of `core::math` is imported

Use sparingly — globs make it harder to trace where a name came from. The preferred pattern for "import everything" is a curated prelude module:

// core/prelude.zolo
pub use core::math::{Vec3, Quat, dot}
pub use core::ecs::{Entity, World}

// app.zolo
use core::prelude::*

Renaming with as

as introduces an alias — the alias becomes the local binding, the original name stays in the source path:

use std::http::Server as HttpServer
use std::fs::{read as fs_read, write as fs_write}
use std::math::abs as absolute

as resolves name collisions without churning every call site.

self inside a list

Adding self to a list also imports the module itself, alongside its members. This is handy when you want to call a few items directly and keep the module namespace around for the rest:

use helpers::{self, add, double, circle_area}

fn main() {
    print(add(2, 3))             // 5         — direct call
    print(helpers.double(7))     // 14        — through the module
    print(circle_area(2.0))      // 12.56636
}

self accepts an alias too:

use very::deep::path::{self as p, foo}
//  ...
p.bar()        // call via the alias
foo()          // direct member call

The binding produced by an un-aliased self is the last segment of the pathuse foo::bar::{self} binds bar, not the literal "self".

use plugin <name> — native plugins

Native plugins live outside the Zolo source tree (they're cdylib binaries with a registered loader). The conventional import form is use winit::prelude::* (or the short use winit::{Window}), but those rely on the compiler guessing "this looks like a plugin".

For an unambiguous, single-line plugin load, use the explicit form:

use plugin winit
use plugin wgpu

Each line desugars to __zolo_load_plugin("<name>") at runtime, the same call the implicit forms emit. After the load, every symbol the plugin registers is in scope as a global.

The explicit form wins over the implicit detection: even when the plugin has no bundled .d.zolo (so the registry-based heuristic would skip it), the loader still runs.

Filter forms

use plugin accepts the same :: filter grammar as the regular use. The bare form is namespaced (does not pollute globals) — opt into the legacy "every symbol as a global" behaviour with the explicit ::*:

use plugin foo                  // load + bind `foo` as a namespace
                                // (same as `use plugin foo::{self}`).
                                // Reach members via `foo.member`.
use plugin foo::*               // opt-in global glob — every symbol
                                // the plugin registers becomes a global.
use plugin foo::Bar             // load + `local Bar = foo.Bar`
use plugin foo::Bar as B        // load + `local B = foo.Bar`
use plugin foo::{a, b}          // load + per-member locals
use plugin foo::{self, a}       // also bind the plugin namespace itself

Member bindings are tolerant of both runtime conventions: plugins that expose a single <name> table (crypto, wgpu) are reached via that table; plugins that register members directly as globals (winit's Window, EventLoop) still work — the binding falls back to the plain global when the namespace table is nil.

A self entry rebinds the plugin's namespace table under the plugin name (or an alias), so you can keep foo.member() ergonomics while also pulling a couple of frequently-used members straight into scope:

use plugin crypto::{self, hash}

fn fingerprint(text: str) -> str {
    let direct = hash("sha256", text)         // direct binding
    let viaNs  = crypto.hash("sha256", text)  // through the namespace
    direct
}

plugin is a contextual keyword — it's only special right after use. Existing code that uses plugin as a regular identifier (let plugin = ..., use plugin::foo as a module path) is unaffected.

Declaring plugin APIs with mod self

Plugin authors describe the API surface in a .d.zolo file shipped next to the binary. The conventional form was struct foo + impl foo, which is a workaround for "this file is the plugin module". A more direct spelling is mod self { ... }:

//! crypto — Hashing, HMAC, AES-256-GCM, PBKDF2, and small codec helpers.
//!
//! Built on top of `ring`. All `data` / `key` / `message` parameters
//! accept either a `String` (UTF-8) or a `Bytes` value.

mod self {
    fn hash(algorithm: String, data: Any) -> String
    fn hmac(algorithm: String, key: Any, message: Any) -> String
    fn random_bytes(length: Int) -> Any
    // ...
}

The self segment is special: at ingest time the compiler replaces the block with the equivalent struct <plugin>; + impl <plugin> { fns }, taking <plugin> from the .d.zolo file's registered name. The typechecker, LSP, and runtime keep using the shape they already understood. Items declared outside mod self (extra structs, top-level fns) stay where the author put them.

mod self is also where future module-level decorators and configuration will hang — e.g. @cfg(feature = "x") on the block, or a mod self.version = "1.2" style attribute.

File-level (inner) doc comments — //! and /*! */

When you don't need the mod self block, file-level documentation goes in inner doc comments at the top of the file. They mirror Rust's syntax:

//! Single-line inner docs for the enclosing module. Multiple `//!`
//! lines join into one block.

/*!
 * Block form. Same target (the enclosing file), same payload shape.
 */

/// and /** */ are outer doc comments — they attach to the next item (the fn / struct / mod that follows). //! and /*! */ are inner doc comments — they attach to the enclosing scope, which at the top of a file is the file's module itself.

The parser stores the joined contents on Program.doc_comment, ready for zolo doc to render and for tooling to surface in hovers.

use std::* — the standard library

The stdlib is not ambient. Every namespace accessed as Mod.foo must be brought into scope with use std::<name>. Using a gated name without an import is a TE105 compile error.

use std::math
use std::{Array, Map}

fn main() {
    let xs = Array.range(0, 10)
    let scores = Map.new()
    print(math.floor(3.9))  // 3
}

Names that require an explicit import #

These follow the Mod.foo access pattern; each needs use std::<name> (or use std::*, or a list/glob form that brings the name in):

  • Lua-style modules: math, io, os, debug, string, table, coroutine, package.
  • Collections / Option / Result / Iter: Array, Map, Set, Option, Result, Iter, BigInt, String.
  • Extended modules (Rust-backed): json, crypto, csv, base64, base32, hex, hash, regex, http, net, fs, path, process, datetime, env, url, xml, yaml, toml, bigint, stats, log.
  • Database: Database, SQL, sqlite.
  • Math-ish: vec.
  • Reactive: Signal (the bare signal() constructor stays implicit; Signal.batch(...) / Signal.untrack(...) go through the module).
  • Async: Promise (combinator namespace — Promise.all, Promise.race).
  • CLI builder: Cli.

Names that stay implicit (no import needed) #

These are bare functions or singletons — they never appear as Mod.foo, so gating them would be noise. They are pre-defined for every Zolo file:

  • Lua base library: print, tostring, tonumber, type, assert, error, pcall, xpcall, pairs, ipairs, next, select, unpack, setmetatable, getmetatable, rawget, rawset, rawequal, collectgarbage, gcinfo.
  • Option ctors: Some, None.
  • Reactive ctors: signal, computed, effect, signal_batch, signal_untrack.
  • Async / fetch: fetch, Response, Headers, AbortController, AbortSignal, Future.
  • Scheduler: setTimeout, setInterval, tick, sleep, block_on.
  • Misc: inspect, Plugin.

Plugin namespaces (winit, wgpu, crypto as a plugin, ...) go through use plugin <name>, a separate mechanism — see the 14-modules examples.

Visibility #

pub makes an item visible to importers. Without pub, items are file-private.

pub fn shout(text: str) { print(text.upper()) }
fn internal_helper() { /* not exported */ }

Granular pub(...)

Inspired by Rust, Zolo accepts visibility refinements inside parentheses:

Form Meaning
pub exported — every importer sees it
pub(crate) exported, intended to stay inside the compilation unit
pub(super) exported, intended for the parent module and siblings
pub(mod) not exported — visible only inside the current module
pub(self) alias for pub(mod)
pub(in a::b::c) exported, intended for the module at path a::b::c

Today the resolver enforces only the pub / pub(mod) split (export yes/no). pub(crate), pub(super) and pub(in …) are captured by the parser, preserved by the formatter, and treated like regular pub for exports — future resolver phases will turn the intent into a real check.

mod auth {
    pub fn login(user: str) { /* ... */ }              // exported

    pub(crate) fn rotate_token() { /* ... */ }         // crate-only intent

    pub(mod) fn hash(secret: str) -> str { /* ... */ }  // file-local, but
                                                       // callable by
                                                       // `login`/`rotate_token`
}

fn main() {
    auth.login("ana")          // OK
    // auth.hash("secret")    // ERROR — pub(mod) does not export
}

How visibility interacts with imports #

Only items marked pub (any form except pub(mod)) are reachable through use foo::name from another module. Inside an inline mod block, every sibling can see every other sibling — including the pub(mod) ones — so you can keep helpers private to a module without sacrificing call-site ergonomics.

See also #

enespt-br