135. Modules, crates and visibility
In JavaScript a file is a module and export opens the door. Rust builds the module tree on purpose, keeps everything private unless you write pub, and lets a struct guard its own invariants. Then a workspace stacks crates into a whole compiler.
- Declare modules inline and in files, and refer to items with use, crate:: and super::.
- Explain Rust's privacy rule, including private fields in public structs, and read E0603, E0616 and E0624.
- Re-export items with pub use to shape a crate's public face.
- Describe drip-rs's module tree and engine/rust's workspace of six crates.
Nine files, one line each
Here is the top of drip-rs’s src/lib.rs, the root of the drip
library crate:
pub mod chunk;
pub mod compiler;
pub mod debug;
pub mod gc;
pub mod object;
pub mod scanner;
pub mod table;
pub mod value;
pub mod vm;
pub use vm::{Options, Outcome, Vm};
In chapter 12 each JavaScript file was a module just by existing, and
import { scan } from './scanner.js' found it by path. Rust works the
other way round. A file is not compiled until a parent says it should
be: mod scanner; tells the compiler “there is a module called
scanner; its code is in scanner.rs next to me”. Delete that line
and scanner.rs is ignored, however much code is in it.
A JavaScript project is a pile of files that point at each other. A Rust crate is a family tree drawn from the top: the root names its children, each child names its own, and a file nobody names isn’t in the family at all.
The module tree
A module can also be written inline, with a body in braces. You have
seen one in every Rust exercise: the tests are wrapped in
mod tests { use super::*; ... }. Inline or in a file, the result is
the same tree, rooted at crate:
crate src/lib.rs
├── chunk src/chunk.rs
├── compiler src/compiler.rs
├── scanner src/scanner.rs
│ └── tests mod tests { } inside
└── ...
Items are named by paths through the tree. crate::scanner::Token
starts at the root; super:: means the parent module, which is how a
tests module reaches the code above it; self:: is the current one.
use brings a path into scope under a short name, like import.
drip-rs’s compiler opens with:
use crate::chunk::{Chunk, OpCode};
use crate::gc::{Heap, Trace};
use crate::scanner::{Scanner, Token};
A module is a named scope in a crate’s module tree, declared with
mod name { ... } or with mod name;, whose body is then read
from name.rs (or name/mod.rs). Items are reached by paths
(crate::a::b, super::x, self::y), and use path; binds a path
to a short name in the current module[1].
Private by default
In chapter 12, names were private unless exported. Rust’s default is the
same, with one more level: a struct can be public while its fields stay
private. Here is drip-rs’s hash table (table.rs, trimmed):
pub struct Table {
count: usize,
keys: Vec<Option<StrRef>>,
values: Vec<Value>,
}
impl Table {
pub fn new() -> Self { ... }
pub fn get(&self, key: StrRef)
-> Option<Value> { ... }
// no pub: private
fn grow(&mut self, capacity: usize) {
...
}
}
Other modules may name Table and call new and get. They can’t
touch count, keys or values, and they can’t call grow. Try both
on a small copy of this table, from outside its module, and Rust 1.97
says (trimmed):
error[E0624]: method `grow` is private
9 | fn grow(&mut self, cap: usize) {}
| ------------------------------
| private method defined here
...
15 | t.grow(16);
error[E0616]: field `count` of struct
`Table` is private
16 | println!("{}", t.count);
| ^^^^^ private field
A private free function or module named by a path, such as
table::helper(), gives a third code, E0603 (“function helper is
private”).
Why bother? count counts live entries and tombstones, and the two
columns must always have the same length. If any module
could write t.count = 0 or push onto keys alone, the table could be
broken from anywhere. With private fields, only table.rs can break
it, so only table.rs has to be read to trust it. Because outsiders
can’t fill in private fields, they also can’t build a Table { ... }
literal: they must go through new.
The exact rule, from the Rust Reference: a private item may be used by
the module it is in and that module’s descendants; a public one by
anyone who can reach all its ancestor modules[2].
That second half is why a tests child module can test private
functions, and why pub on an item inside a private module doesn’t
make it visible outside the crate. Two exceptions: the variants of a
pub enum are all public, and so are the items of a pub trait.
For in-between cases there is pub(crate), visible anywhere in this
crate but not outside; neither drip-rs nor engine/rust uses it.
Visibility says where a name may be used. Items, fields and methods
are private by default: usable only in their own module and its
descendants. pub makes them usable wherever their parent modules
are reachable; pub(crate) limits that to the current crate.
pub use re-exports an item under a new path.
The public face
The last line of lib.rs, pub use vm::{Options, Outcome, Vm};, is a
re-export. The VM really lives at drip::vm::Vm, but users can
write drip::Vm. drip-rs’s main.rs is a separate crate, the
drip-rs binary (chapter 121), and it sees only what the library made
pub:
use drip::compiler::compile;
use drip::gc::Heap;
use drip::{Options, Vm};
Like a package’s exports in npm, the set of pub paths is a crate’s
public API, and changing it is what breaks other people’s code.
Workspaces
drip-rs is one package holding two crates. The Rust Viscosity is bigger:
six packages, each with its own Cargo.toml, built together as one
workspace. Its root Cargo.toml has no code, only this (trimmed):
[workspace]
members = ["crates/vir", "crates/core",
"crates/frontend-js",
"crates/frontend-python",
"crates/cli", "crates/wasm"]
[workspace.dependencies]
viscosity-vir = { path = "crates/vir",
version = "0.1.0" }
oxc_parser = "0.156"
(The real file keeps each entry on one line.) A member then writes
viscosity-vir.workspace = true instead of a path or a version, so
every crate agrees on one version of oxc. The diagram leaves out
frontend-python, chapter 187’s Python plugin, which sits beside
frontend-js; the five here are the ones Part IX uses. A
package named viscosity-core is viscosity_core in Rust code, since
- can’t appear in a path. Step through what depends on what:
A workspace is a set of packages built together from one root
Cargo.toml with a [workspace] table. Its members share one
Cargo.lock and one output folder, target/[1],
and depend on each other with path dependencies. engine/rust’s README
tests them all with cargo test --workspace.
The split follows the TypeScript engine’s packages, and the README maps
each Rust module to one TypeScript file (plan.ts to core::plan,
lower.ts to frontend_js::lower). Crate boundaries turn chapter 90’s
design into something the compiler checks: core doesn’t depend on
frontend-js, so its library code can’t mention oxc or JavaScript
syntax, only VIR and its own LanguagePlugin trait. Its tests do use
the JavaScript plugin, through a [dev-dependencies] entry: those are
built only for tests, so they don’t count as a cycle (a normal
dependency from core to frontend-js would, and cargo rejects it with
“cyclic package dependency”). The README says the analyses contain no
JavaScript-specific code; the dependency graph is what keeps it that
way. And crates are cargo’s unit of compilation, so the
command-line tool and the WebAssembly build each add only a thin crate
over the same core.
Your turn
The soa module holds particles as two columns, xs and vxs, that must always be the same length. As given, the tests can’t even compile: nothing is public. Make exactly the API public (the struct, new, push, len, x and step), and keep the columns private so no one outside can push to just one. Then fix step, which should move each particle by vx * dt.
mod soa {
// Invariant: xs and vxs always
// have the same length.
struct Particles {
xs: Vec<f64>,
vxs: Vec<f64>,
}
impl Particles {
fn new() -> Self {
Particles {
xs: Vec::new(),
vxs: Vec::new(),
}
}
fn push(&mut self, x: f64,
vx: f64) {
self.xs.push(x);
self.vxs.push(vx);
}
fn len(&self) -> usize {
self.xs.len()
}
fn x(&self, i: usize)
-> f64 {
self.xs[i]
}
fn step(&mut self,
dt: f64) {
let vs = &self.vxs;
let xs = self.xs.iter_mut();
for (x, v) in xs.zip(vs) {
*x += v;
}
}
}
}What you learned
- A crate is a module tree rooted at
crate.mod name;pulls inname.rs; an unnamed file isn’t compiled. - Paths name items:
crate::,super::,self::.useshortens them. - Everything is private by default, fields included. Private means
“this module and its descendants”;
pubopens it up,pub usere-exports. - Private fields let a type guard its invariants, like drip-rs’s
Table. - A workspace builds several crates together, with one lock file
and one
target/. engine/rust’s six crates make the plugin boundary a compile-time fact.
Check your understanding
0/6- 1.
You add
src/table.rsto a crate but never writemod table;. What happens? - 2.
In
pub struct Table { count: usize, pub keys: Vec<u32> }, what can another module do? - 3.
A private function is defined in module
a. Where can it be called? - 4.
What does
pub use vm::{Options, Outcome, Vm};in drip-rs’slib.rsdo? - 5.
What do the six crates in engine/rust’s workspace share?
- 6.
viscosity-core’s tests useviscosity-frontend-js, which depends on core. How is that allowed?
References
- [1] Steve Klabnik, Carol Nichols, Chris Krycho (2026). The Rust Programming Language (3rd edition). No Starch Press; free online at doc.rust-lang.org/book. doc.rust-lang.org/book/ (checked 2026-10-31)
- [2] The Rust Project Developers. The Rust Reference: Visibility and privacy. The Rust Reference (rust-lang/reference on GitHub, rendered at doc.rust-lang.org/reference). doc.rust-lang.org/reference/visibility-and-privacy.html (checked 2026-10-31)