Modules
How code is organised, and what the standard library gives you.
Modules
A directory is a module, with no mod foo; declaration anywhere. Every
.plum file in a directory shares one namespace; subdirectories become
nested child modules, discovered from the file tree itself.
myapp/
main.plum
shapes/
circle.plum
rectangle.plum // both files are just the `shapes` module
// shapes/circle.plum
pub struct Circle { pub radius: Float }
pub let area (c: Circle): Float = 3.14159 * c.radius * c.radius
let internal_helper (c: Circle): Float = c.radius * 2.0 // private, no `pub`// main.plum
use shapes;
let main (): Unit = println(shapes.area(shapes.Circle { radius: 2.0 }).to_string())12.56636
use is qualify-by-default (Go-style): shapes.area, not a bare
area, so call sites stay self-explanatory without cross-referencing
imports.
Functions are private by default. pub let opts one into being
callable from outside its module; without it, a qualified call is
rejected:
// shapes/circle.plum
let secret_helper (): Int = 1// main.plum
use shapes;
let main (): Unit = println(shapes.secret_helper().to_string())error: shapes.secret_helper is private to module `shapes`. Add `pub` to its declaration to use it from the root module
Types are private by default too. pub struct and pub enum opt
one into being named from outside its module: in an annotation, in a
literal, and in a pattern:
// secrets/s.plum
struct Secret { n: Int }
pub let make (): Secret = Secret { n: 7 }
pub let read (s: Secret): Int = s.n// main.plum
use secrets;
// Fine: the VALUE may cross. `hold` never names the type.
let hold (): Int = secrets.read(secrets.make())
// Rejected: the NAME may not.
let named (): secrets.Secret = secrets.make()
let main (): Unit = println(hold().to_string())error: struct secrets.Secret is private to module `secrets`
A private type that escapes through a pub function is opaque
outside its module: you can hold it and pass it on, but not take it
apart, because taking it apart means naming it. That is the same shape
a handle type has.
Fields are private by default too, independently of their struct:
// counter/c.plum
pub struct Counter { pub label: String, n: Int }
pub let start (label: String): Counter = Counter { label: label, n: 0 }
pub let count (c: Counter): Int = c.n// main.plum
use counter;
let label_of (c: counter.Counter): String = c.label // fine
let count_of (c: counter.Counter): Int = c.n // rejected
let main (): Unit = println(label_of(counter.start("hits")))error: field counter.Counter.n is private to module `counter`
A struct with any private field cannot be constructed from outside
its module, because a literal has to name every field. That is the
point rather than a side effect: it makes a constructor function the
only way in. The same applies to destructuring. Counter { label, n }
and the positional Counter(label, n) both name n, so both are
refused.
Two modules may declare the same type name. A type is identified by
the module that declared it, so shapes.Circle and render.Circle are
different types, and a bare Circle means the one declared where you
wrote it: your own module first, then the root, then the prelude. A
root declaration shadows a prelude one of the same name rather than
colliding with it.
They do not silently unify:
// inner/p.plum
pub struct P { pub v: Int }
pub let make (): P = P { v: 1 }// main.plum
use inner;
pub struct P { pub v: Int }
// `P` here is the root module's own, which is not `inner.P`.
let a (): P = inner.make()
let main (): Unit = println("unreachable")error: declared return type P doesn't match body type inner.P
Anywhere a type or variant can be named, the module can be part of the name. When two modules declare the same enum, that is the only way to say which one you mean: in an annotation, an expression, and a pattern alike:
// light/shade.plum
pub enum Shade { On, Off }// dark/lamp.plum
pub struct Lamp { pub watts: Int }use light;
use dark;
let describe (s: light.Shade): String = match s {
light.Shade.On => "on",
light.Shade.Off => "off",
}
let lamp (): dark.Lamp = dark.Lamp { watts: 60 }
let main (): Unit = println(describe(light.Shade.On).concat(" at ").concat(lamp().watts.to_string()))on at 60
Naming the wrong module is an error rather than a correction: a
dark.Shade.On pattern matched against a light.Shade reports the
mismatch.
The prelude is a module of its own, so pub applies to the standard
library too: Map.get is part of the interface, Map’s buckets are
not, and reaching for the latter is an error wherever you are.
Its module cannot be named; there is no use prelude; and no
prelude.println(..). Prelude names are reached the way they always
were, unqualified; the module exists so that what the prelude does not
export is genuinely unavailable rather than merely undocumented.
use shapes.Circle; (importing one specific name unqualified) is
available as an escape hatch for names used constantly in a file.
Standard-library modules
Most of the standard library is in the prelude, with no use needed —
and for the type namespaces that is not a convenience, it is required:
T.f(x) is the method-call mechanism, so xs.map(f) only works
because Array.map is always in scope.
A namespace that names no type is a different thing. Those are modules, and a file that wants one says so:
| module | what is in it |
|---|---|
Os | files, directories, environment, subprocesses, platform, exit |
Time | the clock, and the calendar on top of it |
Net | TCP sockets |
Http | HTTP and HTTPS client; HTTP server |
Crypto | SHA-256. Cryptographic, unlike String.hash |
use Os;
use Time;
let stamp (): String = Time.rfc7231(Time.now())
let here (): String = Os.platform()
let conf (): Result[String, String] = Os.read_file("app.conf")A module can depend on another. Http uses Net for plain HTTP and a
native TLS transport for HTTPS. use Http; brings its dependencies in
with it, so you do not have to import them separately.
Without the use, the error says what to do:
unbound variant/function: Time -- `Time` is a standard library module;
add `use Time;` to this file
HTTP and HTTPS
Http.get, Http.post, and Http.request accept both http:// and
https:// URLs and return Result[Http.Response, String]. A response
contains its status code, headers, and body as Bytes:
use Http;
let fetch (): Unit = match Http.get("https://example.com/") {
Ok(response) => {
println(response.status.to_string());
match response.text() {
Ok(text) => println(text),
Err(message) => println(message),
}
},
Err(message) => println(message),
}Bodies preserve all octets, including NUL and invalid UTF-8. For text,
pass "hello".as_bytes() to Http.post or Http.request, and use
response.text() or request.text() to validate and decode UTF-8. These
helpers return Result[String, String]; they do not infer a charset from
Content-Type or decompress content. Encoding a String and decoding valid
UTF-8 share the existing reference-counted payload, without copying it.
Handlers likewise construct responses with body: "hello".as_bytes();
use Bytes.empty() for an empty body. This replaces the previous String
body API; direct binary access remains through .body.
HTTP buffers whole messages. Transport reads and temporary write slices are bounded to at most 16 KiB, and sending headers does not create a copy of the complete outgoing body. This is not a total memory limit: headers and received bodies remain uncapped, and repeated immutable concatenation can copy growing prefixes. Streaming, total framing limits (#111), and precise compiler lifetime/reuse improvements (#349) remain separate work.
HTTPS verifies the server’s certificate and hostname. Plum embeds Mbed TLS, so no separate TLS development library is needed to build a client. Root certificates come from the system: conventional CA files on Unix and the Windows ROOT certificate store. No CA bundle ships with Plum; a system without usable roots cannot establish a verified HTTPS connection.
Redirects are returned to the caller rather than followed. Connections
close after each request, and chunked transfer encoding is unsupported.
Http.serve and Http.serve_once serve plain HTTP; HTTPS support is
for the client. See the Http API reference for
the request and server functions.
Time moved in 0.0.8 and the rest in 0.0.9. Os was held back
deliberately: unlike Time, it was reachable from every program
already written, so the break got a release of its own rather than
being slipped in beside the feature that revealed it.
What stayed in the prelude, with no use needed: println/print,
the assert family, Json, and every type namespace.
Packages
A project can use code from another directory on disk. It says so in a
plum.pkg file at its root:
// plum.pkg
Package {
name: "myapp",
version: "0.1.0",
deps: [
Dep { name: "parsec", path: "../parsec" },
],
}Native sources and linked libraries can declare which embedded dependencies those inputs require:
Package { name: "native_helper", native_deps: [] }native_deps: [] certifies that this package’s selected native sources
and linked libraries do not need embedded TLS. Use native_deps: ["tls"]
when they need Plum’s TLS shim, Mbed TLS, or PSA APIs, including through
native headers or libraries. tls is currently the only supported name;
unknown names and duplicate entries are errors. The declaration applies
on every target, and does not change which platform sources or link
libraries are selected.
Omitting native_deps preserves compatibility: a package with native
sources or linked libraries receives the complete TLS bundle. Packages
without native inputs do not acquire TLS merely because the field is
absent. An explicit ["tls"] requests TLS even without native inputs.
Each package owns its declaration, including transitive dependencies. A
root package’s [] cannot override a dependency’s requirement. Direct
Plum calls to TLS/Mbed TLS/PSA functions select TLS independently of these
native declarations. The compiler does not inspect C text to guess dependencies.
An incorrect empty declaration can therefore cause a missing-header or
link error; correct it to ["tls"] rather than relying on an automatic retry.
For native sources/libraries added with --link-c or --link-lib,
--native-deps none or --native-deps tls declares the requirements of
those command-line inputs only. Without the option, their requirements
remain unknown and TLS is included. These options are accepted by build
and run; as with other existing run options, arguments after the
project are also forwarded to the program. Package declarations apply to
build, run, and test. Selection is resolved for every invocation,
including cache hits and private recovery builds; manifest edits invalidate
the cached IR identity.
A dependency is an ordinary project: a directory with .plum files in
it, which builds and tests on its own. Nothing has to be published.
plum check, run, build, test and doc all read the
dependency’s source alongside your own, and the language server sees it
too.
Depending on a git repository
A dependency can also name a repository and a commit:
// plum.pkg
Package {
name: "myapp",
version: "0.1.0",
deps: [
Dep {
name: "parsec",
git: "https://github.com/someone/parsec",
rev: "9f2a1c4e8b7d3f6a0c5e2b9d4a8f1c3e7b6d0a52",
sha256: "3b8f1d0c6a24e7593f8c1b0d4a6e29f7c53b8d1a0f462e97c8b3d5a1f0e6c294",
},
],
}rev is a full commit hash, never a branch or a tag. Both move, and
a dependency that can change underneath a build is not pinned — which
would make “the manifest is the lockfile” false, and that is the promise
the whole design rests on. A short hash is rejected too: it is a pin
that can become ambiguous as a repository grows.
sha256 is optional, and is how the manifest becomes a lockfile.
It is the hash of the package’s contents. Declare one and every later
fetch has to produce exactly those bytes, on any machine. plum fetch
prints the hash of what it got, so adding one is a copy and a paste. It
is checked on every build, not only when something is downloaded.
Fetching is a separate step. plum build never touches the network;
a dependency that is not in the cache is an error telling you to run
plum fetch, not a download starting in the middle of a build.
plum fetch # download what plum.pkg names, into the cache
plum fetch my-project # or point it at a project
plum vendor # copy Git dependencies into ./vendor
plum cache list # show fetched packages and their total size
plum cache clean # remove fetched packages; they can be fetched again
Fetched packages live in a global cache, shared between your
projects, not in a directory inside this one — $PLUM_CACHE, else
$XDG_CACHE_HOME/plum, else ~/.cache/plum. Each commit gets its own
directory, so two projects wanting two versions of one package is two
directories rather than a conflict. The path is readable on purpose:
<cache>/pkg/github.com/someone/parsec/<commit>/.
plum cache list uses that same root and reports each cached commit plus
the file count and exact byte total. plum cache clean removes only
<cache>/pkg, not the cache root itself: PLUM_CACHE is user-controlled
and future Plum cache kinds must not be erased as a side effect. Cleaning
is safe; a removed dependency is simply fetched again on the next explicit
plum fetch.
plum vendor copies every resolved Git dependency, including transitive
ones, into vendor/ below the project. It keeps git, rev and sha256
in the manifest: builds prefer the matching vendored tree and verify its
hash. The layout mirrors the cache (vendor/github.com/acme/parsec/<commit>/)
so two commits coexist, and vendor/ is never scanned as one of the
project’s own modules. A vendored project builds with no cache or network.
git is only needed if you fetch. Plum delegates repository transport
to Git; a project with only path dependencies never needs
it, and neither does building the compiler. If it is missing, the error
says so by name.
A dependency’s modules arrive under its package name, so two
packages can both ship a json without ever meeting:
let main (): Unit = println(parsec.greet(parsec.json.tag()))use brings one in under its short name, for the file that says so:
use parsec.json;
let main (): Unit = println(json.tag())and as renames it, which is how one file reaches two packages that
both ship the same module name:
use parsec.json as pj;
use core.json as cj;An alias REPLACES the short name rather than adding one: after
use parsec.json as pj;, pj works and json does not. Binding one
name twice in a file is an error naming both and telling you to use
as.
A binding wins over a module of the same name, including one of your
own. If your project has a json/ module and a file says
use parsec.json;, then in THAT FILE json is the dependency’s. The
use is at the top of the file and says so, which is the same rule
Python follows for from parsec import json.
A module named after its own package collapses to just the package, so
a library called semver whose main module is semver/ is reached as
semver.parse(..) rather than semver.semver.parse(..).
Inside a package, its own modules stay bare: parsec/json/json.plum
says use parsec; and calls parsec.greet(..), and that reads the same
whether the library is built alone or used from somewhere else.
pub means the same thing across a package boundary as it does across
a module boundary: a dependency’s unexported names are unavailable, not
merely undocumented.
Dependencies of dependencies come along, with their paths resolved relative to the manifest that names them. Two packages that depend on each other terminate rather than recursing.
A package’s name belongs to the package. If a dependency has its own
plum.pkg declaring a name, the Dep naming it has to agree, so a
path edited to point somewhere else cannot go on claiming to be the
old dependency.
A library’s code goes in module subdirectories
The root module is where main lives and its names are unqualified, so
a package may not put anything there. A dependency contributes only its
module subdirectories, and source at a dependency’s root is an
error:
dependency `parsec` has Plum source at its root: ../parsec/parsec.plum
A dependency's code lives in module subdirectories, so its module
names are the same whether it is built on its own or used from
another project.
So a library is laid out one level deeper than you might first write
it, the same shape as Rust’s src/lib.rs:
parsec/
plum.pkg declares that it is called `parsec`
parsec/ module `parsec`
json/ module `json`
The second half of that error message is the half that matters. A
module is named by its directory, chosen by the library’s author, in
every context. That is what lets json/json.plum say use parsec; and
keep working when the library is checked on its own, which is most of
what “a dependency is an ordinary project” means.
examples/packages/ is the whole thing, two
projects and about a hundred lines.
The one collision that is left
Your own modules are bare, so a package you depend on can still share a name with one of them. That is an error, and unlike a collision between two dependencies it is one you can act on, because you own one of the two:
`parsec` is both one of your modules and something dependency `parsec`
provides.
Your own modules are named bare, so rename yours: a directory called
something else.
A manifest is data
plum.pkg is read by Plum’s own lexer and parser, which is why there
is no second configuration format to learn. It is deliberately not a
.plum file, and it holds a bare value rather than a declaration.
That distinction is the whole design. setup.py, build.rs and
build.zig all began as configuration and became programs, because a
config file written in a general-purpose language invites computing in
it, and then the tool has to run the file in order to read it. Reusing
a parser to read data carries none of that risk: text goes in, a tree
comes out, and nothing executes. Making the file look like a program
carries all of it.
So the shape is enforced rather than trusted. Anything that is not a
string, a number, true/false, an array or a struct literal is
rejected, including string interpolation:
plum.pkg: a manifest is data, not a program, and a function call is
not data.
An unknown field is an error too, so dependencies: written for
deps: says so instead of quietly building a project with no
dependencies.
What is not here yet
Version numbers are recorded but do not select dependency versions.
There is no registry or lockfile. Git dependencies instead pin a full
commit and content hash in the manifest; plum fetch downloads them
explicitly, and plum vendor makes them available for offline builds.
Whatever arrives, the compiler’s own bootstrap stays what it is today: a seed, and no network.
Standard library
Two generated references, neither written by hand:
docs/stdlib/is one page per module, with the documentation written on each declaration. Start withprelude, which is what every program gets without asking for it.docs/stdlib/index.mdis every declaration in one file, each linked to the page that documents it, for when you want to search rather than read.
Both come out of plum doc, in one pass, and bootstrap/check-docs
fails if either has fallen behind the compiler. This section used to
list the library by hand; it was accurate, and nothing checked it, which
is a promise that keeps for exactly as long as somebody keeps
remembering.
The functions the compiler implements itself, Array.map and
String.concat among them, are in there too, even though they have no
Plum source to read.
Net, Http, Os, Time, Process, Encoding, Url, Path,
Json and Terminal need a use. Everything else is in the prelude
and needs nothing.
Documentation comes from /// comments in the source, so the same
mechanism works on your own code:
plum doc my-project -o docs # Markdown, one page per module
plum doc my-project -o docs --html # a browsable site, with search
plum doc my-project -o docs --with-deps # and every package it depends on
The output directory defaults to docs; missing directories are created.
Generated files are published individually through exclusive temporary files
and atomic replacement. Symlinked pages, symlinked ancestor directories, and
special files are refused with a path-specific error. Use a physical output
path rather than an alias through a symlink. Windows also rejects junctions
and output paths exceeding 128 ancestor directories. A failed run can leave
pages published earlier in the run; publication is atomic per file.
Your modules, not your dependencies’. plum doc my-project
documents the modules in my-project and stops there. What you publish
under your own name is your own API.
--with-deps adds the packages your manifest names, under their package
names — parsec.json rather than json, so it stays clear whose code
you are reading. It is worth knowing about: there is no registry and no
hosted documentation yet, so generating a dependency’s pages locally is
currently the only way to read its API. If a project has dependencies
and nothing pub of its own, plum doc says so rather than leaving you
with an empty index.
--stdlib is unaffected either way — it has no project, so it has no
dependencies.
plum highlight <file> prints Plum source as marked-up HTML, using the
compiler’s own lexer rather than a regex approximation of it. It is what
colours the code on plumlang.org. Every static
site generator ships a highlighter, and none of them ships one for a
language this young. Because it is the real lexer over a lossless token
stream, stripping the tags back out gives the source byte for byte, and
bootstrap/highlight-check asserts exactly that over every fixture and
every file of the compiler’s own source. A snippet that does not lex
renders plain rather than not at all.
Generated from MODULES.md in the repository.