Public release v1.8.0 · Spec v0.2.0-draft · Star on GitHub

Tutorial

A guided tour of Crisp: each step explains the idea for beginners, shows a short snippet, then notes what happens under the hood for people who already know Rust (or compilers).

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.