Skip to content

Guide 27 of 36

Modules & Visibility

Organize code with `mod` (file or inline block), bring names into scope with `use`, alias them with `as`, import the module table itself with `self`, and choose the right visibility from `pub` to `pub(crate)`, `pub(super)`, or `pub(mod)`.

On this page

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 inside `arith`, hidden from every other module.
    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 module-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" }
    }
}

Canonical identity and local aliases

The resolver identifies a module by its crate plus its complete path, not by the last filename alone. For example, these are different modules and may declare homonymous types, methods, functions, effects, and exports safely:

src/left/helper.zolo   → <current crate>::left::helper
src/right/helper.zolo  → <current crate>::right::helper

Both src/left/mod.zolo and src/right/mod.zolo may write use helper::{make}. helper is a source-local alias; the import graph, runtime module key, AOT lowering, type/method metadata, and incremental cache retain the canonical left::helper or right::helper identity. Moving a file to a different module path therefore changes its identity even when its basename and public declarations stay the same.

use — import names into scope

File, crate, and parent roots

Source imports support three explicit points of view:

src/
├── main.zolo
└── components/
    ├── utils.zolo
    └── ui/
        ├── button.zolo
        └── local.zolo

From components/ui/button.zolo, these imports mean:

use local::{local_value}                    // file-relative: ui/local.zolo
use crate::components::utils::{cn}           // package-relative: components/utils.zolo
use super::super::utils::{cn as parent_cn}   // parent-relative: components/utils.zolo
  • A bare path starts beside the importing file. It is concise for tightly coupled siblings.
  • crate:: starts at the package source root (src/ in a standard project). It remains stable when the importing file moves, so editor auto-imports prefer this form.
  • Each leading super:: walks one module toward the package root. The file module itself counts: from components::ui::button, the first super reaches components::ui and the second reaches components.

crate and super are roots only at the beginning of a source import. A path that tries to escape above the package root, or places either segment in the middle, is rejected instead of silently resolving somewhere else.

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 path — use 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. Core modules use use std::<module>; grouped types use use std::<module>::<Type> (or a list/glob form that brings the type 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 (exports Database, DbError, DbErrorKind, and SQL) plus the low-level sqlite module.
  • Math-ish: vec.
  • Specialized collections: collections exports BitSet, Deque, Queue, Stack, PriorityQueue, LRUCache, OrderedMap, SortedMap, and WeakMap.
  • Resources: resource exports Resource, ResourceBuilder, ResourceFs, ResourceFile, and ResourceStat.
  • Reactive: reactive exports Signal; the bare constructors signal(), computed(), and effect() stay implicit.
  • Async: async exports Promise plus the implicit async types.
  • Time: time exports Duration and Timer.
  • Strings: string also exports StringBuilder.
  • 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, BodyStream, 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 a visibility modifier, an item is private to its declaring module.

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
no modifier visible only inside the declaring module
pub visible from every crate/module
pub(crate) visible only inside the declaring crate
pub(super) visible in the declaring module's parent subtree
pub(mod) visible only inside the declaring module
pub(self) alias for pub(mod)
pub(in a::b::c) visible in the named ancestor module subtree

The resolver assigns every file and inline module a canonical crate/module identity and enforces these regions on single, list, glob and namespace imports. pub(in ...) must name the declaring module or one of its ancestors; an unrelated path is rejected at the declaration. Re-exports preserve their original declaration and intersect both regions, so pub use can narrow an API but can never make a restricted item more public.

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

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

    pub(mod) fn hash(secret: str) -> str { /* ... */ }  // module-local, but
                                                       // callable by items
                                                       // declared in `auth`
}

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

How visibility interacts with imports

An item is reachable through use foo::name only when the importing module is inside its declared region. Glob imports bind only the visible subset. Plain private and pub(mod)/pub(self) items remain local to the declaring module; pub(crate), pub(super) and pub(in ...) cross boundaries only within their explicit region. The same rules apply to inline and file-backed modules.

Explicit inaccessible imports report IM002 at the import path; an unknown name reports IM001. An invalid pub(in ...) region reports IM003 at the declaration. A glob simply omits names outside the importer's region.

Re-exports preserve origin and cannot widen access

pub use exposes a selected import through the current module, but it does not erase where the item was declared. The effective visibility is the intersection of the origin declaration and every re-export in the chain:

mod origin {
    pub(crate) fn rotate_key() {}
    pub fn version() -> int { 1 }
}

mod facade {
    pub use origin::{rotate_key, version}
}

facade::version is globally public. facade::rotate_key remains crate-only despite the plain pub use; a re-export may narrow an API but never widen it. Aliases retain the same origin and restriction, and the rule is identical for single, list, and glob re-exports.

See also

DOCS / FEEDBACK

Did this page leave a question?

Tell us where the explanation lost you. Documentation is part of the language experience.

Global index

Find your way through Zolo

Try an idea

Start here

9 results

9 results

en