Quickstart: Rust
Compile a Candid interface into a validated Contract graph and read its identities.
This page takes a Candid .did interface and turns it into a
Contract: the validated, canonical type graph this project is
built around, carrying content-addressed identities that let you tell two
interfaces apart — or prove they are the same — without diffing text. Cargo is
the only tool you need.
Parsing and type checking are not reimplemented here. The crate delegates
both to the official candid_parser, then projects the
checked result into its own arena of type nodes, sorts and canonicalizes it,
and hashes the canonical bytes. Everything downstream — the TypeScript
generator, the schema runtime, the CLI — consumes that graph rather than the
source text.
candid-core is at 0.1.0-beta.3. Cargo only
selects a prerelease when the requirement mentions one, so a caret
requirement such as candid-core = "0.1" resolves to
nothing. Every example below pins the exact version. Until 1.0, any
release may change the public API, the serialized shapes, the canonical bytes,
and therefore every identity computed over them.
From .did source to a Contract#
-
Add the dependency#
tomlCargo.toml[dependencies] candid-core = "=0.1.0-beta.3"Or from the command line,
cargo add candid-core@=0.1.0-beta.3. The MSRV is Rust 1.78 and the licence is Apache-2.0.That line gives you the whole surface, because all three Cargo features are on by default: compiler (the
candid_parser-backed source compiler), filesystem-compiler (reading.didfiles, and thecandid-corebinary) and host-value (the tagged JSON value ABI). A consumer that only reads Contracts someone else produced can switch all three off withdefault-features = falseand keep the model, the validation and the identities; Install and features lays out the four surfaces and what each one pulls into the dependency graph. -
Compile an inline source and read its identities#
compile_didtakes Candid source as a&strand returns aCompilation: the Contract itself plus an optional provenance sidecar. It needs no filesystem, which is what makes it the entry point a browser or WASM host uses.rustuse candid_core::compile_did; use std::error::Error; fn main() -> Result<(), Box<dyn Error>> { let compilation = compile_did( r#" type Account = record { owner: principal; balance: nat }; service : { balance_of: (Account) -> (nat) query; }; "#, )?; let contract = compilation.contract(); println!("contract: {}", contract.contract_id()); println!("interface: {}", contract.interface_id().unwrap_or("(none)")); Ok(()) }textcontract: candid-core:contract:v1:sha256:<64 lowercase hex digits> interface: candid-core:interface:v1:sha256:<64 lowercase hex digits>The digests are elided above because the exact bytes depend on the release you build against — that is the point of the beta warning, not a coyness about the values. The shape is fixed and is checked on every Contract that loads: a domain prefix, then
sha256:, then exactly 64 lowercase hex characters.The two identities answer different questions.
contract_idcovers the complete canonical Contract payload: the format markers, both profile markers, every retained type node, the declarations and their names, and the actor when there is one.interface_idcovers only the type graph reachable from the actor plus the profile markers, so declaration names and actor-unreachable declarations do not move it — renameAccounttoAcctand the interface identity holds while the contract identity changes. It isOption<&str>because a declaration-only Contract with noserviceblock has no interface to identify.Neither one covers
producer, source text, comments or formatting. What identifies raw source bytes is a third value,source_bundle_id, which lives on the provenance sidecar; what commits to the exact octets of a serialized document is a fourth,artifact_id, computed on demand and never written into the document. Content-addressed identities is the page that pulls the four apart.From the same
Compilationyou can walk the graph itself —contract.types()is the arena,contract.declarations()the named entries,contract.actor()the root — andcompilation.source_info()gives the provenance sidecar with the field labels, documentation comments and source list. The runnable walkthrough does exactly that:bashcargo run --example contract_walkthrough -
Compile a file instead#
compile_did_fileis the same pipeline over a path, and it is the one entry point that resolvesimportstatements: it roots aWorkspaceResolverat the entry file's parent directory and reads the bundle through it, so nothing outside that directory is reachable.rustuse candid_core::compile_did_file; use std::error::Error; fn main() -> Result<(), Box<dyn Error>> { let compilation = compile_did_file("./service.did")?; let contract = compilation.contract(); println!("{} type node(s)", contract.types().len()); println!("{} declaration(s)", contract.declarations().len()); println!("{}", contract.to_json_pretty()?); Ok(()) }This needs the filesystem-compiler feature, which is on by default and pulls in
cap-std. Turn default features off and the item is absent at compile time — a build error namingcompile_did_file, not a runtime stub.compile_did refuses imports by designHand
compile_dida source containingimportand it fails with adid_import_requires_filediagnostic listing each import as a note, rather than resolving anything. That is the boundary being explicit: an in-memory entry point never touches a filesystem. Supply the bundle yourself through aSourceResolverand callcompile_with_resolver, or read it from disk withcompile_did_file. See Sources, imports and provenance. -
Tighten the limits and read the diagnostic#
Every entry point runs under a resource budget, so
.didsource you did not write cannot hang the process or allocate without bound.Limits::default()is the frozeninteractive_v1profile — 27 values, includingmax_source_bytesat 1 MiB andmax_input_bytesat 4 MiB. Fields are private; you start from a profile and override withwith_*builders, then wrap the result in aRuntimeContextand pass it to a_with_contextentry point.rustuse candid_core::{compile_did_with_context, CompileOptions, Limits, RuntimeContext}; fn main() { // 38 bytes of source, and a 16-byte ceiling. let source = "service : { ping: () -> (nat) query };"; let context = RuntimeContext::new(Limits::default().with_max_source_bytes(16)); let error = compile_did_with_context(source, CompileOptions::default(), &context) .expect_err("the source is larger than the limit"); for diagnostic in &error.diagnostics { println!("code: {}", diagnostic.code); println!("phase: {:?}", diagnostic.phase); println!("message: {}", diagnostic.message); if let Some(limit) = &diagnostic.resource_limit { println!( "budget: resource={} limit={} observed={}", limit.resource, limit.limit, limit.observed ); } } }textcode: resource_limit_exceeded phase: Some(Load) message: source "memory:/inline.did" uses 38 bytes; limit is 16 budget: resource=source_bytes limit=16 observed=38That is the whole failure contract. A
CompileErroris a struct with one public field,diagnostics: Vec<Diagnostic>, and aDiagnosticcarries a stablecode, amessage, and optionally aphase(parse,type_check,loadorlower), aseverity, apath, aspannaming the logical source, orderedrelatedlocations,notes, and the{resource, limit, observed}triple above. The codes, the paths and the resource metadata are the machine surface and are stable; the message text is not.limitandobservedare fixed-widthu64, so the serialized triple means the same thing on a 32-bit and a 64-bit target.Raising a limit does not raise the others: the budgets are charged separately, so a caller who raised
max_input_bytesenough to parse a document may still fail to render it againstmax_canonicalization_work.cargo run --example bounded_parsingdemonstrates exactly that, and the limits reference lists all 27 values with their defaults. -
Skip the Rust and use the binary#
The crate ships one binary, also called
candid-core, gated on the same filesystem-compiler feature. It accepts exactly this grammar and nothing else.textcandid-core compile <path> [--no-source-info | --envelope] candid-core validate <path>bashcargo install candid-core --version 0.1.0-beta.3 --locked candid-core compile ./service.did # contract + provenance sidecar candid-core compile ./service.did --envelope # one self-describing document candid-core validate ./contract.json # re-check a document you were givencompileprints{"contract": …, "ok": true, "source_info": …}. The Contract inside has nine top-level keys when the interface has an actor, in this order. Digests and arena indices are elided below — the indices a real run assigns depend on the canonical ordering of your own type graph:json{ "actor": { "kind": "service", "service": N }, "canonicalization_profile": "candid-core-canon-1", "declarations": [{ "name": "Account", "type": N }], "format": "candid-core", "format_version": 1, "identities": { "contract": "candid-core:contract:v1:sha256:…", "interface": "candid-core:interface:v1:sha256:…" }, "producer": { "candid_parser_version": "0.4.0", "candid_version": "0.10.30", "name": "candid-core", "version": "0.1.0-beta.3" }, "semantics_profile": "candid-1", "types": [ "…the arena, one node per entry…" ] }A declaration-only interface omits the
"actor"key entirely, leaving eight, and the"interface"identity is likewise absent."actor": nullis not part of the v1 wire format at all: serialization never emits it, the identity payload never hashes it, and decoding rejects it.produceris unverified provenance held outside both semantic identities: rewriting it leavescontract_idandinterface_idbyte-identical, which is why a version bump does not move them.--envelopeis the flag to reach for when the consumer is TypeScript: it prints aContractEnvelope—{"contract": …, "extensions": {…}}— whoseorg.candid-core.field-names/v1extension carries the field-name table, because a semantic Contract stores label ids and not text. That document is exactly whatschemaFromContractin@candid-core/schemaconsumes whole. Extensions live outside the canonical identities, so carrying them never moves acontract_id.Failures print to stdout, not stderrEvery non-usage outcome is one pretty-printed JSON document on stdout with an empty stderr — success and failure alike. Branch on the exit status or on the
okkey, never on which stream produced the output. Success exits 0; a read, parse, validation or resource-limit failure exits 1; a usage error writes nothing to stdout, prints the usage text on stderr, and exits 64. Flags go after the path, and a token beginning with-is always an option in the path position — spell a dash-leading file as./-service.did.The candid-core binary has the full output contract, including the two different failure keys
validateuses depending on how far the read got.
Next#
You now have a Contract and know what its identities do and do not cover. The two directions from here are downward, into the model itself, and outward, into the TypeScript that consumes it.
The arena of type nodes, what a TypeRef is, and the invariants validation enforces.
The six runnable programs in examples/, and the exact command for each.
Turning that Contract document into typed, validated schemas with @candid-core/schema.