Skip to content

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:

Terminal window
$ turo -e '60 mph to kph' # an inline expression
$ printf 'a = 2\nb = 3\na + b\n' | turo # a multi-line document on stdin

Throughout, identifiers you choose (my budget, MortgagePayment, USD) are yours; identifiers like m, to, solve for, sin come from the prelude.


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.


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 / 45); 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).


"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 ==, !=, <, <=, >, >=.


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.

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

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.


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 cm1000 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.


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.


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 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

% 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%

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

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.


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

if 3 > 2 then 10 else 20

The if … then … else … form is an expression: it evaluates to one branch.


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)

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

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.)


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 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.


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.

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.

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");

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.

TypeWhat you read
DocumentResultresults: Vec<StatementResult>, errors: Vec<TuroError>, warnings
StatementResultvalue(), display_strings() -> (numeric, unit), span()
ValueNumber, Quantity { magnitude, unit }, Boolean, String, Instant, Record { name, fields }, Unit, FormatSpec, …
TuroErrorvariant_name(), message(), span()

For JSON output matching the web UI’s schema, turo_lang::wire::result_rows turns a DocumentResult into serde rows.

A few global hooks configure the evaluation environment:

turo_lang::set_now(Some("2026-06-26T12:00:00Z".into())); // pin `now` for reproducibility
turo_lang::set_default_timezone(Some("Europe/Berlin".into()));
turo_lang::set_solver_trace(true); // dump the solver loop (debug builds)

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.


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 prelude

With 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.