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
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; 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 baresignal()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 #
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.