Step 01
Hello world
Start here. A Crisp program is a few items in a .crp file. You define work with name(args) = …, bind locals with :=, and mark the process entry as pub main.
shape Named = {
name: str
}
type Guest = {
name: str = "world"
}
id(x: T) = x
greet(who: Named) = "hello {who.name}"
pub main() = {
world := Guest {}
print(id(greet(world)))
}
shape Named is structural: any type with name: str can be greeted.
Guest {} fills the default name = "world". Interpolation writes "hello {who.name}".
id(x: T) is an implicit generic — unbound T is a type parameter. <> is only a pin.
- No semicolons; the last expression in a block is the value (here
print returns unit).
Try: crisp run examples/hello
Under the hood: crisp analyzes the crate, emits a normal Cargo project under target/rust/, then cargo run --manifest-path … with cwd at the crate root (#106). rustc remains the soundness boundary — if emitted Rust fails to compile, that is treated as a compiler bug, not a user debt.
Step 02
Functions and bindings
Most Crisp looks like “small math and logging.” A function is a value. Naming it at the top level (double(n) = …) or writing |n| … is the same kind of callable — not two features. Locals are introduced once with :=; later assignment uses = (and needs mut:= if you will reassign — see loops).
double(n) = n + n
apply(f, x) = f(x)
run(f) = f(21)
label(g, p) = g(p)
type Person = { name: str }
pub main() = {
x := double(5)
inc := |n| n + 1
log("x={x} twice={apply(double, x)} next={inc(x)}")
log("hole={apply(_ * 2, 21)} trail={run { |x| x * 2 }}")
log("field={label(.name, Person { name: "Ada" })}")
}
pub exports the item from the module (needed for main and for other files to use it).
print / log both write to stdout; prefer log when interpolating.
- Omit types while the shape of the program is clear; add them when APIs should be stable.
- Holes (
_ * 2), trailing last-arg (run { |x| … }), field sections (.name), and method sections (.magnitude()) are sugar for the same function values. See examples/closures.
Try: crisp test examples/closures
Under the hood: HM-style type inference plus a separate ownership pass. Named items often emit as Rust fn; locals and |…| emit as move closures / impl Fn. Silence means “infer”; an annotation is a hard constraint the solver must satisfy.
Step 03
Strings and comments
Text is a first-class string type (str). Comments use -- (line) or nested {- … -} (block). Interpolation embeds expressions inside string literals.
-- single-line comment
{- block comment -}
greet(name: str) = "hello " ++ name
pub main() = {
who := "world"
log("greet={greet(who)}")
}
"{expr}" interpolates — here the call greet(who) is evaluated inside the string.
- You can annotate early (
name: str) to document an API even when inference would succeed.
Under the hood: Interpolation lowers toward Rust format!-style formatting in the emitted project. String values are owned String (or borrowed where the ownership pass proves it safe).
Step 04
Types when you need them
Inference is the default, not a limitation. Annotate when two interpretations are possible, when you design a public boundary, or when you want the compiler to reject silent widening.
port(host: str, n: int) = n
scale(x: float) = x * 2.0
-- scale(3) is ok: int widens to float in a checking position (#112)
- Common primitives:
int, uint, float, bool, char, str.
- Return types can be written
-> T on functions when you want them explicit.
int widens to float only in a checking position; write as float / as int when you want the conversion explicit. Unary - on a float stays float.
Under the hood: int/uint/float map to Rust i64/u64/f64 in the current bootstrap. Annotations feed the same constraint solver as inferred uses — they are not a second type system.
Step 05
Structs and defaults
Group related fields with type Name = { … }. Defaults on fields mean callers only pass what they care about — useful for config-shaped data.
type ServerConfig = {
host: str = "127.0.0.1"
port: uint = 9000
debug: bool = false
}
pub main() = {
cfg := ServerConfig { port: 3000 }
log("port={cfg.port}")
}
- Omitted fields fill from defaults (
host and debug above).
- Read fields with
cfg.port — same idea as other systems languages.
- If field access on a parameter is ambiguous across several structs, annotate the parameter (see limitations).
Under the hood: Struct types become Rust structs; defaults are applied at construction in the emit layer. Field projection participates in ownership inference (& vs owned) like other places.
Step 06
Modules
One .crp file under src/ is one module. Share helpers by marking them pub and importing with use — similar in spirit to Rust modules or TypeScript imports, with lighter syntax.
-- src/arith.crp
pub sum(a, b) = a + b
-- src/main.crp
use arith { sum }
pub main() = {
log("sum={sum(2, 3)}")
}
- Flat
src/*.crp modules can import each other without worrying about file order.
- Nested paths such as
src/math/vector.crp become a nested Rust module tree when emitted. Nested use of functions and types both get crate:: paths (#93 / #100; examples/nested_math, examples/nested_types).
Under the hood: Resolve builds a module graph from the crate root (crisp.toml + src/). Visibility and use are checked before typeck; emit then writes a conventional mod tree for Cargo.
Step 07
Tests
Write checks next to the code. test "…" is a positive assertion. test_compile_fail expects analysis to reject the body — useful for guarding error messages and “this must not typecheck” cases.
test "greet works" = {
assert_eq(greet("world"), "hello world")
}
test_compile_fail "unknown name" = {
definitely_not_a_builtin()
}
crisp test .
- Keep tests in the same crate as the code under exercise unless you outgrow that.
- Prefer small named cases over one giant script. Titles may repeat across modules — emit prefixes the module path (#102).
assert_eq on floats uses a small epsilon; bool / str / comparisons stay assert_eq!.
Under the hood: Positive tests become Rust #[test] functions in the emitted crate. Compile-fail tests are run by the Crisp harness (expect diagnostics), not by hoping rustc fails on bad user code.
Step 08
Fallible functions
When a function can fail, mark it with ambient !. Use throw to fail and catch to recover. Beginners can think “this returns a value or an error,” without writing Rust Result by hand.
parse_port(s) ! = {
if s == "" then throw "empty"
-- …
}
pub main() = {
p := parse_port("8080") catch { e => 0 }
log("port={p}")
}
! on the function means callers must handle failure (or themselves be fallible).
catch { e => … } turns an error into a normal value at that site.
- See
examples/fallible for chaining and richer error sets.
Under the hood: Ambient errors lower to Result<T, CrispError> (and related plumbing) in emitted Rust. The error pass tracks which error variants a function may produce; sealed crates freeze those signatures at publish time.
Step 09
Enums and match
Enums name a closed set of cases — plain tags or payloads. match is how you branch on them exhaustively. If you know Rust enums or TypeScript discriminated unions, this will feel familiar.
type Color =
| Red
| Green
| Blue
| Custom(int, int, int)
describe(color) = match color {
Color.Red -> "red"
Color.Green -> "green"
Color.Blue -> "blue"
Color.Custom(r, g, b) -> "rgb({r},{g},{b})"
}
- Write variants as
Color.Red when constructing or matching.
- Payloads bind names in the arm (
r, g, b above).
- String literal arms work too:
match name { "h2" -> 1, _ -> 0 } (#101).
Try: crisp run examples/enums
Under the hood: Enums become Rust enums; match becomes a Rust match. Exhaustiveness is enforced in analysis so emit does not rely on “hope the arms cover it.”
Step 10
Loops
Crisp has while, for … in (over vec today), and a value-producing loop with break / continue. Counters that change need mut:= so reassignment is allowed.
sum_to(n) = {
total mut:= 0
i mut:= 0
while i < n {
i = i + 1
total = total + i
}
total
}
countdown_stop(start, stop) = {
n mut:= start
loop {
if n == stop then break n
if n <= 0 then break 0
n = n - 1
}
}
mut:= introduces a mutable binding; later updates use plain =.
break expr exits a loop with a value — the whole loop expression yields that value.
for MVP iterates Crisp vec; richer iterators are still growing (see limitations).
- Chained
else if works in then-form and brace-form (#117). Growable vecs: new(), push, [1.0, 2.0], and xs[i] (#119 / #120, examples/vec_ops).
Try: crisp test examples/loops · also examples/vec_ops
Under the hood: Loops lower to Rust while / loop / for with mutable locals. Parser disables struct-literal greed in if/while/for heads (Rust-style) so while i < n { … } parses cleanly.
Step 11
Inherent methods
Attach functions to a type with impl Type = { … }. Use self for instance methods and Type.name(…) for associated constructors — same mental model as Rust inherent impls, with less ceremony.
type Vec2 = {
x: float
y: float
}
impl Vec2 = {
pub new(x: float, y: float) = Vec2 {
x: x
y: y
}
pub magnitude(self) =
(self.x ** 2.0 + self.y ** 2.0) ** 0.5
}
pub main() = {
v := Vec2.new(3.0, 4.0)
mag := .magnitude()
log("mag={v.magnitude()} via={mag(v)}")
}
** is exponentiation (lowers to a float power call).
- Call associated items as
Vec2.new(…); call methods as v.magnitude().
- A method section
.magnitude() is the function value |v| v.magnitude(). Extra args bake in: .scale(2.0) is |v| v.scale(2.0), not a two-argument function.
See also: examples/vec2_methods, examples/closures
Under the hood: Inherent methods become Rust impl Type { … }. Receiver mode (&self vs owned) is chosen by the ownership pass from how self is used.
Step 12
Traits
Traits are named contracts: “this type can show itself.” You declare the trait, then write an explicit impl Trait for Type. The prelude also offers Show / Eq / Ord-style helpers for common cases.
trait Show = { show(self) -> str }
type Point = {
x: int
y: int
}
impl Show for Point = {
show(self) = "({self.x},{self.y})"
}
label(x: T) = x.show()
pub main() = {
p := Point { x: 1, y: 2 }
log("p={p.show()} l={label(p)}")
}
- Prefer traits when several types share a behavior you want to name.
label(x: T) = x.show() infers T: Show from the body (v1.6.1 / #84). Written where / generic trait bounds / dyn Trait are still limited — see limitations and examples/trait_defaults.
Try: crisp test examples/show_trait
Under the hood: Traits lower toward Rust traits / impls. A unique method on generic T records a nullary bound and typeck E0084 rejects instantiations without an impl. Full Rust-style written bounds / dyn Trait remain partial.
Step 13
Shapes
Shapes are structural: “anything with x and y floats.” Unlike traits, matching structs do not need an explicit impl — useful when you care about fields, not a named protocol.
shape HasPosition = {
x: float
y: float
}
type Point = {
x: float
y: float
}
distance(a: HasPosition, b: HasPosition) -> float = {
dx := a.x - b.x
dy := a.y - b.y
dx * dx + dy * dy
}
pub main() = {
p := Point { x: 0.0, y: 0.0 }
q := Point { x: 3.0, y: 4.0 }
log("d={distance(p, q)}")
}
- Use a
shape for “duck” field requirements; use a trait for named behavior.
Point satisfies HasPosition automatically because the fields match.
- Parametric
HasPosition<T> (next step) infers T: Add / Sub / Mul from + - * — examples/shapes_generic.
Try: crisp run examples/shapes · also examples/shapes_generic
Under the hood: Shapes lower to a generated Rust trait plus accessors so emit stays coherent. Method/anonymous shape edges are still limited — prefer named data shapes as in the example.
Step 14
Generics
Prefer implicit binders. Unbound names in type position are parameters. Write <> only to pin a definition or to apply arguments: Pair<int, str>, Boxy<int>.
type Pair = { left: A, right: B }
id(x: T) = x
first(p: Pair<A, B>) = p.left
shape Boxy = { value: T }
unwrap_int(b: Boxy<int>) = b.value
pub main() = {
p := Pair { left: 1, right: 2 }
log("id={id(41)} first={first(p)}")
}
Keep parametric T. The body infers the constraint; a bad instantiation is a typeck error (E0084), not only a later rustc failure.
shape HasPosition = { x: T, y: T }
distance(a: HasPosition<T>, b: HasPosition<T>) = {
dx := a.x - b.x
dy := a.y - b.y
dx * dx + dy * dy
}
- Free names (
T, A, B) become parameters. <T> is a pin — same meaning, optional.
- A matching struct satisfies
Boxy<int> automatically; no impl required for the shape.
+ - * on T infer Add / Sub / Mul. Unique methods infer a trait bound (x.show() → T: Show). User methods on a user type: examples/shapes_user.
- Written
where / generic trait bounds / dyn Trait are still limited — see limitations.
Try: crisp test examples/generics_implicit · also examples/generics, examples/shapes_generic, examples/shapes_user, examples/generics_pub
Under the hood: Implicit params stay rigid in the definition and instantiate at call sites. Shapes emit trait Boxy<T> plus structural impl Boxy<i64> for IntBox. Arith on T emits std::ops; typeck checks the bound before rustc. Field access on a type parameter currently requires T: Clone in the generated Rust. pub schemes freeze in crisp.lock.
Step 15
Import a Rust crate
Crisp is meant to sit on the Rust ecosystem. Declare a Cargo dependency with rust = true in crisp.toml, then use items much like TypeScript importing a package. A local crate can use path = "…" (implies rust = true); the compiler rewrites it for target/rust/ (#105). Other crates need an extern rust block (or a src/*.crpi sidecar) so calls are typed (#116).
-- crisp.toml
-- [dependencies]
-- serde_json = { version = "1", rust = true }
-- local_core = { path = "vendor/local_core" }
use serde_json { from_str, to_string }
pub main() = {
v := from_str("[1, true, \"crisp\"]")
print(to_string(v))
}
- Compat path:
use rust.serde_json { … } if you prefer an explicit namespace.
- Known
Result-returning APIs can surface as Crisp ambient errors (? / catch) — see limitations.
- Declare scalar signatures with
extern rust local_core { answer() -> int } (or src/local_core.crpi). Undeclared imports are E0089.
Try: crisp run examples/rust_import · also examples/path_dep
Under the hood: Dependencies with rust = true are forwarded into the emitted Cargo.toml. Path deps are rewritten relative to target/rust/. crisp run keeps cwd at the crate root (#106). Crisp does not reimplement those crates — it generates ordinary Rust uses against them.
Step 16
Async
Mark entrypoints or blocks async when you need concurrency or timers. Start with a tiny sleep to prove the runtime is wired; then grow toward spawn/join patterns in the examples.
pub main() = async {
sleep_ms(1)
print("async-ok")
}
- You still write Crisp — the bootstrap selects a Tokio-oriented emit for async crates.
- Prefer the examples before inventing large async graphs by hand.
Try: crisp run examples/async_hello · also examples/async_spawn
Under the hood: Async lowers toward Tokio in the emitted Cargo project (runtime = "tokio" in manifests). This is a Rust-hosted async story, not a separate Crisp scheduler.
Step 17
Where to go
You now have the core surface: values, modules, tests, errors, data, traits/shapes, generics, Rust interop, and async. Next, read for depth or scan for fit.
Tip: When inference surprises you, run reveal on the same path — it prints inferred types, ownership, and emitted Rust so you can see what silence meant.