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_did
compile_did_with_options
compile_did_with_context
One self-contained &str. Imports rejected. compiler
compile_with_resolver A host-supplied SourceResolver. Imports included, no filesystem. compiler
compile_did_file
compile_did_file_with_options
compile_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#

compile_did compiler
rustsrc/compile/mod.rs
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.

rustexamples/contract_walkthrough.rs
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(())
}
bash
cargo run --example contract_walkthrough
compile_did refuses any source containing an import

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#

compile_with_resolver compiler
rustsrc/compile/mod.rs
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#

rustsrc/resolver.rs
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.

Three ways a custom resolver is rejected before its bytes are used

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.

rustexamples/hermetic_bundle.rs
//! 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(())
}
bash
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#

compile_did_file filesystem-compiler
rustsrc/compile/mod.rs
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.

rust
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(())
}
The workspace root is the file's own parent directory

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#

rustsrc/compile/artifact.rs
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#

rustsrc/compile/artifact.rs
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.

rustsrc/compile/artifact.rs
// 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.

PhaseSerializedWhat failed
ParseparseThe source is not valid Candid syntax.
TypeChecktype_checkIt parses but does not type-check.
LoadloadA source could not be resolved, read, or accepted — including did_import_requires_file and every resolver failure.
LowerlowerThe 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#