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.zoloAfter 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::helperBoth 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.zoloFrom 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: fromcomponents::ui::button, the firstsuperreachescomponents::uiand the second reachescomponents.
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 scopeThe 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 importedUse 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 absoluteas 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 callThe 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 wgpuEach 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 itselfMember 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(exportsDatabase,DbError,DbErrorKind, andSQL) plus the low-levelsqlitemodule. - Math-ish:
vec. - Specialized collections:
collectionsexportsBitSet,Deque,Queue,Stack,PriorityQueue,LRUCache,OrderedMap,SortedMap, andWeakMap. - Resources:
resourceexportsResource,ResourceBuilder,ResourceFs,ResourceFile, andResourceStat. - Reactive:
reactiveexportsSignal; the bare constructorssignal(),computed(), andeffect()stay implicit. - Async:
asyncexportsPromiseplus the implicit async types. - Time:
timeexportsDurationandTimer. - Strings:
stringalso exportsStringBuilder. - 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¶
examples/features/14-modules/— runnable examples for every form on this page.specs/mod-namespace-innovations.html— the design doc, including the cherry-picks Zolo has implemented and the ideas still on the roadmap.
DOCS / FEEDBACK
Did this page leave a question?
Tell us where the explanation lost you. Documentation is part of the language experience.