Reference
This is the working reference for the turo language and the turo-lang crate
that runs it. It builds up from the simplest expressions to formulas and
equation solving, then covers the Rust embedding API. Read it top to bottom the
first time; each section uses only ideas from the ones before it.
Every example below is real output from the turo CLI with the standard
prelude loaded. Run them yourself:
$ turo -e '60 mph to kph' # an inline expression$ printf 'a = 2\nb = 3\na + b\n' | turo # a multi-line document on stdinThroughout, identifiers you choose (my budget, MortgagePayment, USD) are
yours; identifiers like m, to, solve for, sin come from the prelude.
The model: a document of lines
Section titled “The model: a document of lines”A turo program is a document: a sequence of lines. A line is either a binding (it names a value) or a bare expression (it just computes one). turo evaluates the whole document and shows you a result beside each line — the same feel as a spreadsheet column.
price = 50 // a binding: names `price`
tax = price * 8% // a binding that refers to an earlier one
price + tax // a bare expression
Lines do not have to be in dependency order. turo builds a dependency graph and
evaluates price before tax no matter which comes first. A genuine cycle
(a = b and b = a) is a CircularDependency error.
At the command line, turo prints the last value of the document by
default; pass --json to get one result row per line (the notepad surface the
web UI uses). The rest of this reference shows single expressions for brevity.
Numbers
Section titled “Numbers”turo has one numeric type. Integer-versus-decimal is a matter of display, not two separate types.
42
3.14
.5
1_000_000 // underscores group digits
6.022e23 // scientific notation
1.5e-3
10 / 3
2 ^ 10 // `^` is exponentiation
Division that comes out whole stays whole (20 / 4 → 5); division that does
not produces a decimal. Numbers render at “natural” precision — full digits,
with floating-point noise below twelve significant figures erased — until you
ask for a specific format (see Format and display).
Strings and booleans
Section titled “Strings and booleans”"hello world"
'single quotes' // both quote styles work
true
false
Comparisons produce booleans:
3 < 5
5 == 5.0 // compares by magnitude
2 != 3
The comparison operators are ==, !=, <, <=, >, >=.
Operators and precedence
Section titled “Operators and precedence”The arithmetic operators are +, -, *, /, ^. They follow ordinary
precedence; parentheses override it.
2 + 3 * 4 // `*` binds tighter than `+`
(2 + 3) * 4
2 ^ 3 ^ 2 // `^` is right-associative: 2 ^ (3 ^ 2)
10 - 2 - 3 // `-` is left-associative
One deliberate divergence from maths convention: negation binds
tighter than ^ in turo, so -2^2 is (-2)^2 = 4 — Excel’s reading —
where written mathematics (and Python, Julia, R) reads -(2²) = -4. This
keeps -2^2 (a folded negative literal) and x = 2; -x^2 (prefix minus on
a binding) in agreement: substituting a value for a variable never changes
an answer. Write -(x^2) or 0 - x^2 when you mean the maths reading.
Every operator in turo is declared in the prelude, not baked into the parser —
including its precedence and associativity. That is why a host can ship a
calculator with a different operator set, and why the language core stays small.
You can read the declarations in prelude/arithmetic.turo.
Boolean logic, in symbols and words
Section titled “Boolean logic, in symbols and words”true and false // `and` is the word form of `&&`
true or false // `or` is `||`
not true // `not` is `!`
3 < 5 and 2 > 1
of and per are word forms for * and /, which read naturally with units:
6 of 7
60 per 5
Units and dimensions
Section titled “Units and dimensions”This is turo’s reason to exist. A number can carry a unit, and every unit
belongs to a dimension (Length, Time, Mass, Temperature, Angle,
and dimensions derived from them like Speed = Length / Time).
Write a unit by juxtaposing it after a number:
5 m // a length
5 m + 30 cm // same dimension: it adds, converting as needed
1 km + 500 m // the left operand's unit wins the display
Arithmetic carries the dimensions through:
3 m * 4 m // two lengths make an area
10 m / 2 s // length over time makes a speed
100 km / 2 hour
Mixing incompatible dimensions is an error, with the offending span quoted:
5 m + 3 kg// error: DimensionMismatch: cannot add 5 m (Length) and 3 kg (Mass)This is the dimensional safety guarantee: a dimension violation is a reported error, never a silent coercion to a wrong number.
Unit conversion: to and as
Section titled “Unit conversion: to and as”to converts a value into a target unit and returns the converted value:
1.8 m to ft
1 km to m
3 hours to minutes
3.6 km/hour to m/s
5 m^2 to cm^2
Converting across dimensions is the same DimensionMismatch error:
5 kg to m// error: DimensionMismatch: cannot convert 5 kg (Mass) to m (Length)as reads almost the same and converts too — 10 m as cm → 1000 cm — but it
sets a display preference rather than producing a fresh value: a value shown
as a unit keeps that preference through later arithmetic. Reach for to when
you want a converted result, as when you want a value to display a certain
way.
Prefixes
Section titled “Prefixes”Metric prefixes are generated from each base unit, not listed by hand. So km,
cm, mm, µm, Mm, ms, µs, kg, mg all exist because m, s, and
g were declared with a prefix family.
1 km to m
500 ms to s
2 kg to g
The micro sign works whether you type µ (U+00B5) or the Greek μ (U+03BC);
turo normalizes them.
Compound units and multi-unit display
Section titled “Compound units and multi-unit display”Juxtapose units of the same dimension and turo adds them:
5 ft 11 in
To display one value across several units, give to/as a comma list or a
named recipe. turo peels the value greedily, largest unit first:
5 kg as lb, oz
1.8 m to ft, in
3661 s to hour, minute, s
A unit group is a named multi-dimensional display target. The prelude ships
metric and imperial; the group picks the members that match the value’s
dimension:
2000000 mm to metric
80 kg to imperial
1.8 m to imperial
0 C to imperial
Temperature: affine units
Section titled “Temperature: affine units”Temperature scales are affine — Celsius and Fahrenheit have an offset from the base unit (Kelvin), not just a ratio. turo handles the offset arithmetic so the answers stay physical.
100 C to F
0 C to K
300 K to C
20 C + 5 C
2 * (100 C - 50 C) // a difference of temperatures is a delta
Percentages
Section titled “Percentages”% is a postfix operator that makes a percentage, and the arithmetic operators
know how to combine a number with one — calculator-style:
200 + 8% // add 8% of 200
200 - 8%
200 * 8% // 8% *of* 200
200 / 8%
8% of 200 // `of` reads naturally
(100 + 10%) + 10% // percentages stack
Percentages compose with units, too:
1 kg + 10%
And percentage-of-percentage stays a percentage:
4% + 4%
Format and display
Section titled “Format and display”A format spec controls how a value renders. Apply one with as or to.
1/3 as 2 dp // two decimal places
1/3 as 3 sf // three significant figures
1 to 2 dp // padding adds the zeros
1234567 to scientific
12345.678 to 3 sf // note the thousands separator
The prelude turns on a thousands separator by default, so large formatted
numbers read as 12,300 and 1,498.88.
nearest rounds to a multiple — of a number or of a unit:
23 mm to nearest 5 mm
1234.5 to nearest 10
A default format: … line at the top of a document sets the display for the
results below it:
default format: 2 dp
355 / 113
Dates, times, and durations
Section titled “Dates, times, and durations”A @-literal is an instant — a date, a time, or a datetime. turo does the
calendar arithmetic with the jiff library, so leap days, variable month
lengths, and daylight-saving transitions are correct.
@2026-05-27 + 1 day
@2026-06-26 + 3 weeks
@12:00 + 90 minutes // a bare time gets today's date
Subtracting two instants gives a duration:
@2026-12-25 - @2026-06-26
at combines a date with a time of day; the relative-time words read like
English:
@2026-06-22 at @14:30
3 days ago // an instant three days before `now`
2 hours after @09:00 // (bare time gets today's date)
now, today, tomorrow, and yesterday resolve against the host clock (the
CLI’s system time, or a --now you pin for reproducibility). Convert an instant
to another zone with to/as a timezone, and attach a zone with in.
Functions
Section titled “Functions”Declare a function with fn. The body can be an inline expression or a block.
fn area(r) = 3.14159 * r ^ 2
area(2)
A when guard restricts when a function applies:
fn safe_div(a, b) when b != 0 = a / b
safe_div(10, 2)
Parameters and bindings can be multi-word identifiers — a real readability win for domain code:
fn elapsed(start time, end time) = end time - start time
elapsed(2 hour, 9 hour)
Bind a value with let name = … (private) or name = … (public). At the top
level both name a value; inside a formula the distinction controls what the
result exposes.
let my speed = 60 mph
my speed to kph
Conditionals
Section titled “Conditionals”if 3 > 2 then 10 else 20
The if … then … else … form is an expression: it evaluates to one branch.
Trigonometry and angles
Section titled “Trigonometry and angles”Angle is a dimension, radian its base unit, deg a derived one. The trig
functions take an angle and return a ratio; the inverse trig functions return an
angle.
sin(30 deg)
cos(60 deg)
tan(45 deg)
180 deg to radian
asin(1)
1.508 deg to dms
sqrt is dimension-aware, because it is defined as x ^ 0.5 and the ^
operator does dimension arithmetic:
sqrt(2)
sqrt(9 m^2)
Records and formulas
Section titled “Records and formulas”A formula declares a system of equations relating named properties. Instantiating it means supplying enough properties for turo to compute the rest. The result is a record — the formula’s name tags it, its public properties are its fields.
formula Trip(speed, time):
distance = speed * time
Trip(speed = 60 km/hour, time = 2 hour)
The remarkable part: a formula runs backwards. Supply the distance and the time, ask for the speed, and turo solves for it:
formula Trip(speed, time):
distance = speed * time
Trip(distance = 120 km, time = 2 hour)::speed
:: reads a field off an instance. A parameter can have a default:
formula Suvat(u, t, a = 0 m/s^2):
v = u + a * t
Suvat(u = 10 m/s, t = 5 s)::v
A formula with no argument list promotes the free names in its equations to public properties automatically — handy for textbook formulas:
formula Pythagoras:
a ^ 2 = b ^ 2 + c ^ 2
Pythagoras(b = 3, c = 4)::a
A from … where sugar reads a single field directly:
formula Circle(r):
area = 3.14159 * r ^ 2
area from Circle where r = 2
Composing formulas
Section titled “Composing formulas”A formula’s body can hold a sub-instance — a property bound to a whole other
formula. Its inner names join the outer system under a path prefix (base::area),
so the nested shape solves as one set of equations and reads back as a nested
record:
formula Rect(w, h):
area = w * h
formula Box(height):
base = Rect(w = 2 m, h = 3 m) // a sub-instance
volume = base::area * height
Box(height = 4 m)
Because the two systems merge rather than evaluate inside-out, a back-solve reaches through the composition. Leave an inner property free, supply the outer result, and turo solves the inner one — here, the base’s width, two levels down:
formula Rect(w, h):
area = w * h
formula Box(height, volume):
base = Rect(h = 3 m)
volume = base::area * height
Box(height = 4 m, volume = 24 m^3)::base::w
turo flattens this to base::area = base::w * 3 m, volume = base::area * 4 m,
and volume = 24 m^3, then isolates base::w.
(Contrast a nested call such as Box(base = Rect(w = 2 m, h = 3 m), …) written
at the call site: that evaluates the inner Rect to a value first and passes it
in. Composition is the stronger relationship — one shared system you can solve in
any direction.)
Solving equations
Section titled “Solving equations”solve for x: … solves a single equation for one unknown. turo rearranges it
using the inverse rules in the prelude — addition undoes with subtraction,
powers with roots, sin with asin.
solve for x: 3 * x + 1 = 10
solve for x: x ^ 2 = 144
solve for x: sqrt(x) = 3
The equation is unordered: solve for x: 7 = x works the same as x = 7. An
over-specified system (solve for x: x * 0 = 5) reports an inconsistency rather
than guessing.
Some shapes are beyond the solver today — a true polynomial like x^2 + x = 6
stalls with an EngineCapabilityGap, which says “the solver could not do this,”
distinct from “you gave me too little.” See
the solver section of the spec for the full strategy list.
Errors
Section titled “Errors”Errors are values with a variant, a message, and a source span. The CLI prints the span so you can see exactly which token is at fault.
5 / 0// error: DivideByZero: cannot divide 5 by zero at 4..5: '0'
5 m + 3 kg// error: DimensionMismatch: cannot add 5 m (Length) and 3 kg (Mass) at 0..10: '5 m + 3 kg'
1 + mystery value// error: UndefinedReference at 4..11: 'mystery'A parse error on one line never blocks the rest of the document — turo recovers per line and keeps going. A line it cannot make sense of at all is treated as prose and quietly ignored, so a document can mix notes and calculations.
The prelude: where the language comes from
Section titled “The prelude: where the language comes from”The language core knows literals, scoping, the parser, and the solver machinery.
Everything you have seen with a name — +, m, to, sin, imperial — is
declared in a prelude of turo source the host attaches. The standard prelude
lives in prelude/, split into chunks (arithmetic, si,
imperial, datetime, angle, format, inverses) that a root
standard.turo imports.
Eight declaration forms are reserved to the prelude — operator, native op,
derived op, error op, error fn, native fn, inverse, identity — because
they define the machinery rather than use it. A user document that tries to
declare one gets a RestrictedDeclaration error. A user document can declare
its own dimensions, units, variables, functions, and formulas:
dimension Money
unit USD of Money, also: dollars
unit GBP = 1.27 USD, also: pounds
let my budget = 500 GBP
my budget to USD
Imports let a document pull in another module by name (import "physics"); the
host decides what names resolve. Imported names outrank the ambient prelude but
yield to the document’s own bindings.
Embedding turo in Rust
Section titled “Embedding turo in Rust”The turo-lang crate is the language as a library. The unit of work is a
TuroDocument; you build one, then evaluate source against it.
Evaluate literals with no setup
Section titled “Evaluate literals with no setup”A hermetic document has an empty parent scope — it can evaluate literals but knows no operators or units.
use turo_lang::TuroDocument;
let mut doc = TuroDocument::builder().build();let result = doc.evaluate("42");assert_eq!(result.results[0].value().to_string(), "42");evaluate returns a DocumentResult { results, errors, warnings }. Each
StatementResult carries the Value, its rendered numeric_display and
unit_display strings, and a Span. Use evaluate_lines instead of evaluate
to get one row per line (the notepad surface) with per-line error isolation.
Attach a small inline prelude
Section titled “Attach a small inline prelude”Operators arrive through a prelude. The simplest form is a flat string of
declarations passed to evaluate_with_prelude:
use turo_lang::TuroDocument;
let prelude = "operator `+`: infix, left, precedence = 12\n\ operator `*`: infix, left, precedence = 13\n\ native op x: Integer `+` y: Integer -> Integer\n\ native op x: Integer `*` y: Integer -> Integer\n";
let mut doc = TuroDocument::builder().build();let result = doc.evaluate_with_prelude("2 + 3 * 4", prelude);assert_eq!(result.results[0].value().to_string(), "14");Attach the full standard prelude
Section titled “Attach the full standard prelude”The standard prelude is a set of importing chunks, so it loads through an import
resolver. Seed every prelude/*.turo chunk into a trusted InMemoryResolver
with "standard" as the entry, then vend documents from a ScopeResolver:
use std::collections::BTreeMap;use turo_lang::{ImportResolver, InMemoryResolver, ScopeResolver};
let mut chunks = BTreeMap::new();for entry in std::fs::read_dir("prelude").unwrap().flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) == Some("turo") { let stem = path.file_stem().unwrap().to_str().unwrap().to_string(); chunks.insert(stem, std::fs::read_to_string(&path).unwrap()); }}
let prelude: Box<dyn ImportResolver> = Box::new(InMemoryResolver::from_map(chunks));let user: Box<dyn ImportResolver> = Box::new(InMemoryResolver::empty());let resolver = ScopeResolver::new(prelude, user, Some("standard"));
let mut doc = resolver.new_document();let result = doc.evaluate_document("60 mph to kph");// result.results[0].value() renders as "96.56064 kph"The two resolver slots encode trust: the prelude slot may declare the reserved
eight forms; the user slot may not. The prelude scope is built once and cached,
so new_document() is cheap to call per evaluation.
Reading results back
Section titled “Reading results back”| Type | What you read |
|---|---|
DocumentResult | results: Vec<StatementResult>, errors: Vec<TuroError>, warnings |
StatementResult | value(), display_strings() -> (numeric, unit), span() |
Value | Number, Quantity { magnitude, unit }, Boolean, String, Instant, Record { name, fields }, Unit, FormatSpec, … |
TuroError | variant_name(), message(), span() |
For JSON output matching the web UI’s schema, turo_lang::wire::result_rows
turns a DocumentResult into serde rows.
Host hooks
Section titled “Host hooks”A few global hooks configure the evaluation environment:
turo_lang::set_now(Some("2026-06-26T12:00:00Z".into())); // pin `now` for reproducibilityturo_lang::set_default_timezone(Some("Europe/Berlin".into()));turo_lang::set_solver_trace(true); // dump the solver loop (debug builds)Numbers: the turo-number crate
Section titled “Numbers: the turo-number crate”turo’s single numeric type is turo_number::Number, a newtype over f64 with
disciplined boundaries — many From<primitive> conversions in, but only named
“egress doors” out (round_to_i64). It also owns number formatting through
NumberFormat (separator, grouping, precision, notation), so rendering happens
in one place. You rarely touch it directly; turo-lang re-exports what a host
needs.
Command-line reference
Section titled “Command-line reference”turo [OPTIONS] [FILE]
-e, --expr <EXPR> Evaluate an inline expression --json Emit the {results, errors, warnings} envelope, one row per line --debug Dump the solver's fixed-point trace to stderr --stats Print solver work counters to stderr --now <ISO_TIMESTAMP> Pin `now` (overrides the system clock) --default-timezone <ZONE> IANA name (Europe/Berlin) or offset (UTC+5) --prelude <FILE> Attach a prelude file (repeatable) --no-default-prelude Skip the bundled standard preludeWith no -e and no file, turo reads the document from stdin.
For the architecture behind all of this — how dispatch picks an overload, how the solver rewrites equations, how the spec and fixtures keep each other honest — read DESIGN.md. For recipes that combine these features into real calculations, read COOKBOOK.md.