Compiling Candid sources
compile_did, compile_with_resolver and compile_did_file — the three entry points and when to use each.
Compiling turns Candid .did text into a Compilation:
a validated, canonically ordered Contract graph
plus an optional SourceInfo provenance sidecar carrying everything the
Contract deliberately drops — raw source text, import edges, doc comments, argument
names, original field-label spellings. Parsing and semantic checking are delegated
to the official candid_parser crate; what this crate adds is the
projection, the canonicalization, the identities, and the bounds.
The entry points fall into three families, differing in exactly one respect: where the source bytes come from. Nothing on any of these paths opens a network socket, and only one of them touches a filesystem. All of them are behind the compiler feature or its superset filesystem-compiler, both on by default.
The seven entry points#
Seven public functions, three families. There is no _with_limits
variant of any of them — a compile takes a full RuntimeContext, so a
caller who wants to set limits also gets the cancellation token and the deadline
that travel with them.
| Function | Source of bytes | Feature |
|---|---|---|
compile_didcompile_did_with_optionscompile_did_with_context |
One self-contained &str. Imports rejected. |
compiler |
compile_with_resolver |
A host-supplied SourceResolver. Imports included, no filesystem. |
compiler |
compile_did_filecompile_did_file_with_optionscompile_did_file_with_context |
A path on disk, through a sandboxed WorkspaceResolver. |
filesystem-compiler |
Within each file-or-string family the three names are a widening of control, and
each funnels into the next: the plain form calls the
_with_options form with CompileOptions::default(), which
calls the _with_context form with
RuntimeContext::default(). compile_with_resolver has no
variants at all, because options and context are always explicit there.
compile_did — one self-contained source#
pub fn compile_did(source: &str) -> Result<Compilation, CompileError>
pub fn compile_did_with_options(
source: &str,
options: CompileOptions,
) -> Result<Compilation, CompileError>
pub fn compile_did_with_context(
source: &str,
options: CompileOptions,
context: &RuntimeContext,
) -> Result<Compilation, CompileError>
Compiles one string with no import resolution and no filesystem access. The
single in-memory source is named memory:/inline.did, and that name
appears in any span or provenance output.
This is the entry point for source you already hold as text: a request body, an
editor buffer, a string literal in a test. Reading the result is the same on every
path — contract() for the graph, source_info() for the
sidecar.
use candid_core::{compile_did, Actor, TypeNode};
use std::error::Error;
fn main() -> Result<(), Box<dyn Error>> {
let compilation = compile_did(
r#"
/// A recursive value that remains finite in the Contract graph.
type List = opt record {
/// The current item.
head: nat;
/// The rest of the list.
tail: List;
};
/// A small service used by the walkthrough.
service : {
/// Return the supplied list.
echo: (input: List) -> (output: List) query;
};
"#,
)?;
let contract = compilation.contract();
println!("contract identity: {}", contract.contract_id());
println!("interface identity: {:?}", contract.interface_id());
println!("canonical type nodes: {}", contract.types().len());
let Some(Actor::Service { service }) = contract.actor() else {
return Err("walkthrough expected a service actor".into());
};
let TypeNode::Service { methods } = &contract.types()[*service as usize] else {
return Err("actor did not reference a service node".into());
};
for method in methods {
let TypeNode::Func { args, results, mode } = &contract.types()[method.function as usize]
else {
return Err("service method did not reference a function node".into());
};
println!(
"method {} (wire id {}): {:?}, {} argument(s), {} result(s)",
method.name, method.id, mode, args.len(), results.len()
);
}
println!("\nCanonical Contract JSON:\n{}", contract.to_json_pretty()?);
Ok(())
}cargo run --example contract_walkthrough
After parsing, the declarations are scanned for import and
import service. If any are present the call fails in the
Load phase with the code
did_import_requires_file, carrying one note per import path
(import: types.did) and a message naming the two alternatives.
This is not a limitation to route around by concatenating your files.
import service merges the target's main service methods into yours;
those merge semantics are not textual, and doing it by hand produces a different
interface.
compile_with_resolver — a bundle the host supplies#
pub fn compile_with_resolver(
entry: &str,
resolver: &dyn SourceResolver,
options: CompileOptions,
context: &RuntimeContext,
) -> Result<Compilation, CompileError>
Compiles a whole multi-file bundle — imports included — from sources the host
supplies synchronously as data. The sources are merged into one virtual program
and type-checked in memory through the official candid_parser
merged-program APIs. Nothing is materialized, no directory is opened and no
ambient authority is used, so this runs on
wasm32-unknown-unknown.
This is the platform primitive. The supported boundary is
synchronous, immutable source data supplied by the host: the resolver
decides identity and returns bytes, and the compiler owns every limit.
candid-core never fetches anything, never resolves asynchronously and
never consults a registry.
The SourceResolver trait#
pub trait SourceResolver {
fn identify(&self, from: Option<&SourceId>, import: &str) -> Result<SourceId, ResolveError>;
fn load(&self, id: &SourceId, limits: &Limits) -> Result<ResolvedSource, ResolveError>;
// Defaulted. Override load_with_context to checkpoint inside long work.
fn load_with_context(&self, id: &SourceId, context: &RuntimeContext)
-> Result<ResolvedSource, ResolveError>;
fn resolve(&self, from: Option<&SourceId>, import: &str, limits: &Limits)
-> Result<ResolvedSource, ResolveError>;
fn resolve_with_context(&self, from: Option<&SourceId>, import: &str, context: &RuntimeContext)
-> Result<ResolvedSource, ResolveError>;
}
Only identify and load are required.
identify turns an import spelling — relative to the importing source,
or None for the entry — into a canonical SourceId, a
normalized logical URI of the form <scheme>:/<path>, not a
host path. load returns a ResolvedSource: the canonical
ID, the text, and a sha256:<hex> digest of that text.
The loader re-parses whatever SourceId you return and rejects it as
did_invalid_source_id if it is not already canonical; it rejects
did_resolver_identity_mismatch if load returns a source
whose id differs from the one it was asked for; and it calls
ResolvedSource::verify(), rejecting
did_source_digest_mismatch if the declared digest does not match the
bytes. Compute the digest from the bytes you are actually returning.
MemoryResolver — the bundle you already hold#
MemoryResolver is a resolver over a map you fill in. IDs must use the
memory scheme; a bare name like "api/root.did" is
normalized to memory:/api/root.did. insert takes
&mut self; with_source is the chaining form.
//! Imported-bundle compilation with no filesystem.
use candid_core::{compile_with_resolver, CompileOptions, MemoryResolver, RuntimeContext};
use std::error::Error;
fn main() -> Result<(), Box<dyn Error>> {
let mut resolver = MemoryResolver::new();
resolver.insert(
"api/root.did",
r#"import "types.did"; service : { read: () -> (Item) query };"#,
)?;
resolver.insert(
"api/types.did",
"type Item = record { id: nat64; label: text };",
)?;
let compilation = compile_with_resolver(
"api/root.did",
&resolver,
CompileOptions::default(),
&RuntimeContext::default(),
)?;
let source_info = compilation
.source_info()
.ok_or("source provenance was not requested")?;
println!("contract: {}", compilation.contract().contract_id());
println!("source bundle: {}", source_info.source_bundle_id());
for source in source_info.sources() {
println!("- {} ({} bytes)", source.name, source.source.len());
}
Ok(())
}cargo run --example hermetic_bundle
A repeated import target is loaded exactly once: the loader keys sources by
canonical SourceId and, on a repeat, records whether that edge also
required merging the target's actor. A diamond of four sources produces exactly
four load calls. Import depth is bounded by
max_import_depth (default 64) and edges by
max_import_edges (default 1024); a cycle fails with
did_import_cycle. See
Sources, imports and provenance.
compile_did_file — a sandboxed native workspace#
pub fn compile_did_file(path: impl AsRef<Path>) -> Result<Compilation, CompileError>
pub fn compile_did_file_with_options(
path: impl AsRef<Path>,
options: CompileOptions,
) -> Result<Compilation, CompileError>
pub fn compile_did_file_with_context(
path: impl AsRef<Path>,
options: CompileOptions,
context: &RuntimeContext,
) -> Result<Compilation, CompileError>
Splits the path into a parent directory and a file name, opens a
WorkspaceResolver rooted at that parent, resolves the bundle, then
type-checks it through candid_parser::check_file over a
materialized copy written into a private temporary directory.
WorkspaceResolver holds an open cap-std directory
capability for the authorized root and opens every path relative to it.
Authorization and reading use the same handle, so replacing a path concurrently
cannot swap in a file from outside. An escape — an absolute symlink out of the
root, a parent traversal — fails with did_import_outside_workspace,
while an ordinary permission denial stays did_file_read_error. The two
are kept distinct on purpose.
use candid_core::{compile_did_file_with_options, CompileOptions};
use std::error::Error;
fn main() -> Result<(), Box<dyn Error>> {
// Only ./api is authorized: the resolver is rooted at the file's parent.
let compilation = compile_did_file_with_options(
"api/root.did",
CompileOptions { include_source_info: true },
)?;
let (contract, source_info) = compilation.into_parts();
println!("contract: {}", contract.contract_id());
if let Some(info) = source_info {
println!("bundle: {}", info.source_bundle_id());
for edge in info.imports() {
println!("{} -> {} (spelled {:?})", edge.from, edge.to, edge.import);
}
}
Ok(())
}
compile_did_file("api/root.did") authorizes api/ and
nothing else, so an import of "../shared/types.did" pops past the
root and fails with did_import_outside_workspace. If your bundle
spans directories, root a WorkspaceResolver higher yourself and call
compile_with_resolver, or restructure the imports.
The file path keeps candid_parser::check_file as its authority rather
than routing through the in-memory backend, which is why it exists as a separate
path at all. The two backends are held to each other by an in-crate differential
test: valid bundles must produce byte-identical Contracts, identities and
provenance; invalid bundles must produce identical stable codes and phases.
Materialization writes the resolved sources under numeric names
(0.did, 1.did, …), and upstream errors mention those
names. Each is mapped back to its logical source ID before it reaches you, and byte
offsets that cannot be proven correct against your original text are withheld
rather than reported. No diagnostic ever exposes a temporary directory, a numeric
materialized name, or a rewritten offset presented as an original one.
CompileOptions#
pub struct CompileOptions {
/// Preserve optional names, comments, raw source, and label spelling in a
/// sidecar. This never changes the Contract or its identities.
pub include_source_info: bool,
}
One field, and Default sets it to true. So every
compile_did and compile_did_file call collects and
validates the full sidecar unless you opt out: the raw text of every source, every
doc comment, every label spelling. Pass
CompileOptions { include_source_info: false } when you only want the
graph. The Contract and its identities are byte-identical either way — provenance
is bound to contract_id but never enters it.
What a Compilation holds#
impl Compilation {
pub fn contract(&self) -> &Contract;
pub fn source_info(&self) -> Option<&SourceInfo>;
pub fn into_parts(self) -> (Contract, Option<SourceInfo>);
}
Borrow both halves with contract() and source_info(), or
take ownership of both with into_parts().
source_info() is None exactly when
include_source_info was false.
A Compilation serializes as
{"contract": …, "source_info": …}, with the sidecar key omitted when
absent. It does not implement Deserialize, and that is
deliberate: a trait impl has no argument position for a resource policy, so it could
only ever decode under limits the library chose. Decode through the bounded entry
points instead.
// Decoding: max_input_bytes gates the input before serde_json is invoked, and
// the gate, the decode and validation share one budget.
Compilation::from_json_with_limits(input: &str, limits: &Limits) -> Result<Self, ContractJsonError>
Compilation::from_json_with_context(input: &str, context: &RuntimeContext)
Compilation::from_slice_with_limits(input: &[u8], limits: &Limits)
Compilation::from_slice_with_context(input: &[u8], context: &RuntimeContext)
// Rendering: revalidates, then charges the rendered length against
// max_canonicalization_work.
Compilation::to_json_pretty_with_limits(&self, limits: &Limits) -> Result<String, ContractValidationError>
Compilation::to_json_pretty_with_context(&self, context: &RuntimeContext)
To authenticate a sidecar somebody handed you rather than one you compiled, use
SourceInfo::try_from_raw(raw, contract, limits) or its
_with_context twin. It recompiles the embedded source bundle through
the same backend compile_with_resolver uses and rejects the sidecar
unless the rederived identity and every provenance collection match exactly.
When compilation fails#
Every entry point returns CompileError, which carries
diagnostics: Vec<Diagnostic>. Each diagnostic has a stable
machine-matchable code, a phase, a human
message, and optionally a span, related locations, notes, and a
resource triple. The code and the phase are the stable
surface; message text is not.
| Phase | Serialized | What failed |
|---|---|---|
Parse | parse | The source is not valid Candid syntax. |
TypeCheck | type_check | It parses but does not type-check. |
Load | load | A source could not be resolved, read, or accepted — including did_import_requires_file and every resolver failure. |
Lower | lower | The checked result could not be projected into the Contract graph. |
A budget failure keeps the phase it happened in and reports what ran out:
resource_limit_exceeded with a
{resource, limit, observed} triple attached,
operation_cancelled, or operation_deadline_exceeded. See
Working with limits and
the diagnostics reference.
Next#
The RuntimeContext every _with_context variant takes, and how to build one.
How bundles are resolved hermetically, and how the sidecar is re-derivable rather than trusted.
Rust crate Worked examplesThe runnable programs in examples/ and the exact command for each.