Language reference Dodo 0.1.4

Syntax and expressions

Implemented syntax, inference, constants, control flow, and numeric behavior in Dodo 0.1.4.

On this page

This is the compact reference for syntax accepted by Dodo 0.1.4. New to the language? Begin with values and arrays, functions and data types, and control flow. The language specification states the normative rules; the examples and limits here describe the current compiler.

Source-file structure

package main

import "core/slice"

const LIMIT: usize = 3

struct Sample { value: i32 }

fn main() {
    values := [10i32, 20, 30]
    core.assert_eq(values.len, LIMIT)
    match slice.get(&values, 1) {
        some(value) => { core.assert_eq(*value, 20i32) },
        none => { core.assert(false) },
    }
}

Every source file starts with package name, apart from comments and separators. Top-level declarations are fn, struct, enum, const, and static; imports make another package’s public names available through a qualifier. There is no top-level executable statement or automatic init function. See projects and imports for file discovery and visibility.

Source text and statement boundaries

Source is UTF-8. Identifiers are case-sensitive ASCII letters/underscores followed by ASCII letters/digits/underscores. Comments use //. Newlines terminate statements; semicolons also separate statements and three-part loop clauses. Delimited expressions and expressions continued after an operator accept newlines. A newline also continues an expression when the next non-newline token is .; indentation, blank lines, and line comments do not change this rule. An explicit semicolon ends the expression. Other leading operators do not gain continuation outside delimited expressions. Braces delimit control-flow bodies; match arms also accept expressions.

Block comments, raw strings, ordinary character literals, Unicode identifiers, and a UTF-8 byte-order mark are unsupported. /// is an ordinary line comment. Reserved keywords are:

package import pub fn struct enum const let return if else for in
break continue match unsafe extern as from static void mut true false

self, Self, primitive type names, and constructor names have contextual meanings. stores and requires_plain introduce specialized function contracts described in container element safety.

Literals and strings

Integer literals support decimal, 0x, 0o, 0b, digit separators, and type suffixes. Unsuffixed integer literals use contextual types and otherwise default to isize. Floating literals use decimal fractions/exponents and optional f32/f64 suffixes; their default is f64. Boolean literals are true and false. Strings and byte strings have immutable program-lifetime storage. Strings support the escapes listed below; &str.len counts UTF-8 bytes. String indexing is rejected; use a byte slice for byte indexing. There is no implicit string allocation or mandatory NUL terminator.

Literal Example Type or interpretation
Integer 42, 42u32, 0xffu8, 0o755, 0b1010 Contextual integer; default isize.
Float 1.5, 1e3, 2f32 Contextual float; default f64.
Boolean true, false bool; no numeric truthiness.
Byte b'A', b'\xFF' Exactly one u8.
Byte string b"ABC" Shared &[u8] with program-lifetime bytes.
String "Dodo", "\u{1F426}" &str, valid UTF-8.

All quoted literals accept \n, \r, \t, \0, \\, \", \', and \xHH. Only strings accept \u{...} with one to six hexadecimal digits for a Unicode scalar value. Byte literals and byte strings require ASCII source characters or byte escapes. Physical newlines cannot appear inside a literal. Integer literal magnitude must fit u64; - is a separate prefix operation.

Type syntax

Syntax Meaning
bool Boolean value.
i8, i16, i32, i64; u8, u16, u32, u64 Fixed-width signed and unsigned integers.
isize, usize Target-pointer-sized integers.
f32, f64 IEEE binary floating-point values.
void No return value.
[N]T An owned fixed-length array.
&T, &mut T Checked shared or exclusive reference.
&[T], &mut [T] Shared or exclusive slice.
&str Shared UTF-8 string view.
*const T, *mut T Nullable raw pointer.
Name, package.Name, Name<T, U> User-defined struct or enum type.
Option<T> some(T) or none.
Result<T, E>, T!E ok(T) or err(E).
MaybeUninit<T> Opaque storage for potentially uninitialized T.

Self is valid inside a struct declaration and names its current type. Result shorthand binds outside references and slices: &T!E means Result<&T, E>. Use the full spelling to make nested type relationships clear. Tuples, type aliases, general function-value types, and trait objects are not implemented.

Operators and evaluation order

From weakest to strongest:

Precedence Operators
1 ||
2 &&
3 |
4 ^
5 &
6 ==, !=
7 <, <=, >, >=
8 <<, >>
9 +, -
10 *, /, %
11 as Type
12 Prefix -, !, ~, *, &, &mut
13 Calls, .field, [index], subslices, postfix ? and !

Parentheses override precedence. Prefix ! negates a Boolean; postfix ! unwraps a Result or panics. & is a shared borrow in prefix position and integer bitwise AND in binary position. Prefix * dereferences; binary * multiplies. Binary operators associate left. Operands, call arguments, and literal fields are evaluated left to right; && and || short-circuit. Compound assignment evaluates the destination address once, then its previous value, then the right operand. Numeric operands must have compatible types; there are no implicit mixed-width conversions between already typed values.

=, +=, -=, *=, /=, %=, &=, |=, ^=, <<=, and >>= are statements, not expressions. Comparisons do not chain mathematically: use low <= value && value < high. Equality supports primitive scalars, raw pointers, and payload-free enums; arbitrary aggregate equality is unavailable. Operators cannot be overloaded.

Declarations and returns

const LIMIT: usize = 16

struct Point {
    x: i32
    y: i32

    fn magnitude_squared(&self) -> i32 {
        self.x * self.x + self.y * self.y
    }
}

enum Message { End, Count(value: u32), Pair(u8, u8) }

fn add(left: i32, right: i32) -> i32 {
    left + right
}

Canonical bindings, fields, constants, and parameters use name: Type. The earlier type-first forms remain supported for locals, fields, constants, and enum payloads. An omitted function return type means void. Struct receivers accept self, &self, and &mut self; field literals accept same-name shorthand. Non-void functions return their final expression unless it ends with a semicolon; final conditionals, matches, and blocks follow the same rule. Explicit return provides early exits. Void functions retain statement semantics.

All non-void parameter and return types are declared; inference does not derive function signatures. Struct literals use named fields, enum payloads are positional, and function arguments are positional. Methods live inside structs; associated functions omit a receiver and use Type.name(...). See functions, structs, and enums for complete examples.

Mutable and immutable bindings

Form Mutable binding? Initialization
let value = expression No Required; inferred type.
let value: T = expression No Required; explicit type.
value := expression Yes Required; inferred type.
value: T = expression Yes Explicit type.
value: T Yes Must initialize on every path before reading.
const VALUE: T = expression No Compile-time expression required.

let name = value and let name: Type = value create immutable runtime bindings. := and ordinary typed locals remain mutable; const remains compile-time-only. Immutability prevents reassignment, mutable borrowing of owned storage, and writes to owned fields/elements. It does not weaken a stored &mut T or mutable slice: writes through those references remain permitted, including field/index writes.

A moved-from binding is uninitialized for reading until it is reinitialized. See ownership and borrowing for moves, loans, and cleanup.

Generic syntax and inference

Explicit generic calls canonically use f::<T>(); the earlier f<T>() spelling remains accepted. Types and generic declarations use angle brackets without ::.

Generics are monomorphized. Function calls and struct literals infer omitted type arguments from arguments, fields, and expected result types. Examples are identity(42i32), value: i32 = identity(42), and Box{value: 42i32}. some(value) infers its Option payload. Ambiguous calls require explicit arguments, such as make::<i32>(); explicit forms remain accepted. Inference follows local expression context, not later uses of a binding. Function signatures remain declared. Constraints/traits, specialization, and separately compiled generic interfaces are not implemented. Recursive expansion is bounded and diagnosed.

The generics tutorial explains generic structs, associated functions, and structural method protocols with executable examples.

Formatting and syntax migration

dodo fmt [FILE|DIRECTORY] migrates historical syntax and applies four-space indentation and consistent spacing while preserving comments and literal text. Directory formatting recurses through .dodo files, excluding hidden directories, target, build, and symlinks. The default input is the current directory. --check exits 1 for formatting differences and writes nothing; --stdout previews one file. Input - reads stdin and writes stdout (or checks with --check). Formatting parses every input before replacing any file, requires no imports or type checking, and stages replacements for atomic per-file renames. An unchanged file is not rewritten. Syntax errors leave all inputs untouched. Legacy forms remain supported in 0.1; removal or warnings belong to a future announced deprecation after this migration path is available.

See the command-line guide for formatting commands.

Arrays and constants

Array lists infer their length and element type from context or their elements. Bracket arrays are canonical. A binding annotation supplies explicit types, as in values: [2]u16 = [1, 2]. A list in any expression can retain its type and length with ([1, 2]: [2]u16) or ([]: [0]u8). The annotation applies only to an unannotated bracket list and lowers to the same typed literal as the legacy [2]u16{1, 2}. The formatter migrates legacy typed literals to that annotated bracket form, including nested and constant arrays. This preserves element types and length checks without introducing copying or runtime conversions. Repeated arrays evaluate their initializer once, even for a zero length, and require a copyable element. Constant initializers support checked scalar operators/conversions, strings, arrays, repeated arrays, and struct literals. They may reference other constants. Package constants permit forward references; local constants follow lexical scope. Cycles and mutable-static dependencies are rejected. Array lengths accept integer constant expressions and use the selected target width. Compile-time function calls are not implemented.

Compiler resource limits

Syntax nesting is bounded to 64 parser levels. Constant dependency depth is bounded to 128; expansion work is bounded to 200,000 nodes. Array lengths must fit the target’s usize and LLVM’s 32-bit element-count limit. Nonzero repeated constant arrays are limited to 1,000,000 elements; zero initialization has a compact representation. These limits produce diagnostics.

Pattern expansion has a separate limit of 4,096 alternatives per pattern and 131,072 coverage work units. See patterns for coverage semantics.

Value-producing blocks

if, match, unsafe, and plain blocks produce values in expression positions. Every continuing path must yield the same type; at least one value-producing path is required. Results are transferred before local cleanup. Explicit returns, propagation, break, and continue retain their surrounding control-flow meaning. The checker rejects local-storage borrows escaping a value block and preserves loans from earlier arguments while checking nested blocks.

Ranges and collection loops

for is the only loop keyword. The statement forms are for { ... }, for condition { ... }, for init; condition; step { ... }, and for item in collection_or_range { ... }. break and continue target the nearest enclosing loop. There are no labeled loops or loop result values. See decisions and loops for examples of every form.

for i in start..end captures both integer bounds once and excludes end. Empty and reversed ranges do not iterate. Loop bindings are fresh, and assigning them does not change the counter. Ranges have no general value or iterator protocol. Collection loops still bind shared &T elements or exclusive &mut T elements. for &value in values and for index, &value in values explicitly copy a shared element into a fresh T binding. T must satisfy the ordinary copyability rule; owned arrays, structs, enums, and mutable references/slices are rejected. Assigning the copied binding cannot mutate the source. The collection is evaluated once and stays borrowed for the loop, and copied references retain their borrow dependencies. The index remains usize. Reference patterns on integer ranges, on the index, or on mutable iteration are rejected; use a shared reborrow to copy from a mutable collection view. &mut value binding patterns are not supported.

Slices and bounds

Subslices use &data[start..end] or &mut data[start..end]; bare slicing produces a shared view. Omitted bounds default to zero and length. Source and bounds are evaluated once and checked in every optimization profile. The captured source is reserved while bounds run: they may read it, but cannot move or mutate it. Exclusive access is established after the bounds. Indexed loans remain conservative: separate ranges do not prove mutable slices disjoint.

Numeric behavior

Signed integers use two’s complement. isize and usize follow the selected target’s pointer width. Integer arithmetic and left shifts trap if the result cannot be represented. Both division and remainder trap for a signed minimum value and divisor -1, as well as for zero divisors. Shift counts must be nonnegative and smaller than the value’s bit width. Integer conversions check range; they do not truncate or wrap. Float-to-integer conversion truncates toward zero after checking the finite source against the destination’s half-open range before truncation (-0.5 as u8 traps; 255.75 as u8 is 255). Integer-to-float conversion rounds directly to the target floating representation, with identical results for constant and runtime conversions. Narrowing floats checks finite range, rejecting NaN and infinity. Identity and widening float casts preserve NaN, infinities, and signed zero in both constant expressions and runtime code. Floating arithmetic follows LLVM’s IEEE operations without fast-math flags. Constant floating arithmetic uses binary64 intermediates and rounds to the expression’s type at each node. Integer-to-f32 casts round directly to binary32; an explicit cast through f64 still performs both conversions. Decimal literals are parsed through binary64. These implementation-defined choices are recorded as ID-FLOAT in specification Appendix C.

Explicit wrapping operations are core.wrapping_add(a, b), core.wrapping_sub(a, b), and core.wrapping_mul(a, b), available without an import. They wrap modulo the operand width, interpreting signed results as two’s complement. Both arguments and the result have the same integer type; type inference or ::<T> selects it. Argument expressions still use their ordinary checked arithmetic. Wrapping calls are not constant expressions.

package main

fn main() {
    core.assert_eq(core.wrapping_add(255u8, 1), 0u8)
    core.assert_eq(core.wrapping_sub::<u8>(0, 1), 255u8)
    core.assert_eq(core.wrapping_mul(127i8, 2), -2i8)
}

Floating-point overflow and division by zero can produce infinity or NaN; integer traps do not apply to these operations. With NaN, != is true and the other comparisons are false. Dodo does not provide numeric casts to or from bool or enum tags. See math for numerical algorithms and functions.

Static storage

Package-level static NAME: T = expression declares immutable storage, and static mut NAME: T = expression declares mutable storage. Both initializers must be constant expressions. Static storage lasts for the program and is not automatically destroyed at exit. A read or write of mutable static storage requires an explicit unsafe block; the programmer must prevent races and conflicting aliases. Prefer passing state explicitly or using synchronization for shared threaded state.

Attributes and unsafe boundaries

Attribute Placement and purpose
@repr(C) Struct: C-compatible field order, alignment, and padding.
@unsafe_send, @unsafe_sync Struct: explicit unsafe thread transfer/sharing contract; see threads.
@test Safe nongeneric top-level void function with no parameters; see testing.
@ignore("reason") Test function: skipped unless ignored tests are selected.
@derive(Json) Struct: generate supported JSON methods; see JSON.
@json_name("name") Field of a JSON-derived struct: select its external key.
@json_deny_unknown JSON-derived struct: reject unknown fields.

@compiler(print), @compiler(println), and @compiler(printf) are reserved for bundled printing declarations; user packages cannot define them. Other unknown attributes are errors. There is no general macro or attribute-extension system.

unsafe { ... } permits specific unchecked operations; it does not turn off type checking, borrow checking, or checked indexing. An unsafe fn requires callers to satisfy its documented preconditions, and its body still needs explicit unsafe blocks around unchecked operations. extern "C" fn declares or defines a C-ABI function. The complete rules and examples are in memory and foreign calls.

Type to search all documentation.

Keyboard shortcuts

Search documentation
Ctrl K or /
Move through results
↑ ↓
Open selected result
Enter
Close a dialog
Esc
Show these shortcuts
?