Skip to content
CryoCryo home
LanguageFoundations

1Lexical Structure

This section describes the building blocks the lexer recognises before any syntactic or semantic meaning is assigned.

1.1 Identifiers

An identifier begins with a letter (a-z, A-Z) or underscore, followed by any sequence of letters, digits, and underscores.

identifier = letter { letter | digit | "_" }

The compiler does not enforce naming, but the standard library and ecosystem use the following conventions, which the bundled TextMate grammar and LSP also assume:

  • snake_case for variables, functions, methods.
  • PascalCase for types (struct, union, enum, trait, alias).
  • SCREAMING_SNAKE_CASE for compile-time constants.

1.2 Keywords

Keywords are reserved identifiers. They cannot be used as variable, function, or type names — but they can name a member: a field or a method, declared and used.

![repr(C)]
type struct VkDescriptorPoolSize {
    type:  u32;          // the C header's own field name
    count: u32;
}

mut p: VkDescriptorPoolSize = VkDescriptorPoolSize { type: 1, count: 4 };
p.type = 2;

A member position is never a declaration or an operator, so the keyword is unambiguous there: what follows the name says which member it is — ( or < a method, : a field — and after . or :: a token can only be a member name. This exists because a bound C struct's field names are not Cryo's to choose; a binding generator cannot rename type without breaking the ABI it exists to preserve.

The exceptions are the keywords a member position reads first: public and private open a visibility label, static and async are member modifiers, and protected, virtual and override are read there to be refused (E0117). A field with one of those seven names cannot be declared, and a C struct carrying one still needs a ![repr(C)] shadow struct.

A binding is the other way round. A statement that begins with a keyword opening a declaration is parsed as that declaration, so a local named by one could never be assigned or called through: module = q; is not an assignment. The 19 keywords that open a declaration - function, const, mut, type, struct, class, enum, trait, import, module, extern, implement, public, private, protected, namespace, intrinsic, static, async - therefore cannot name a variable, a parameter, a pattern binding or a destructured local (E0100): a keyword is never an identifier. Keywords that open nothing (default, string) remain legal names, and a destructuring shorthand over a field named by one of the 19 needs a rename: const { type: t }: S = s;.

const module: i32 = 42;                  // E0100: expected a variable name, found `module`, which is a reserved word
match (r) { R::D(static) => { .. } }     // E0100: expected a binding name
Control flowDeclarationsModifiersOperator keywordsSpecial valuesReserved for future use
iffunctionfromconstnewtrueyield
elseclassasmutdeletefalseauto
matchstructimplementstaticsizeofnullunsigned
whileenumintrinsicpublicalignofthistuple
fortraitwhereprivatetypeofThisoptional
looptypeexternprotectedinwith
donamespacevirtualas
breakmoduleoverrideawait
continueimportinline
returnexportunsafe
asmstatic_assertmove
unionasync
default

class, protected, virtual (outside a trait object's type), override and default open no form of the language: they are reserved, so a declaration or member written with one is refused where it is written (E0117) rather than misread.

move marks a closure that captures its environment by move (see section 15.3).

async marks a function, method, or trait method whose body is compiled into a state machine and whose call returns a future; await suspends the enclosing async body until a future completes (see section 18).

Reserved-for-future-use keywords are recognised by the lexer; the parser may accept them in places that have no semantic implementation. See section 21.

1.3 Comments

Cryo recognises four comment styles. Documentation comments are semantically meaningful: they attach to the declaration that follows them and surface in LSP hovers and generated documentation.

// Line comment.

/* Block comment.
   Spans multiple lines. */

/// Outer documentation comment (line form). Attaches to the next declaration.
/// Multiple consecutive /// lines are joined.

/** Outer documentation comment (block form). Attaches to the next declaration. */

///! Inner documentation comment. Attaches to the enclosing module/namespace.

1.4 Literals

Numeric Literals

Integer literals support four bases. Underscores are visual separators that the compiler ignores. A type suffix pins the literal to a width; without one, the type is inferred from context (defaulting to i32 for integers and f64 for floats).

42                // decimal
1_000_000         // separators: identical to 1000000
0xFF              // hex
0b1010            // binary
0o755             // octal
42u64             // typed: unsigned 64-bit
42i8              // typed: signed 8-bit
3.14              // float (defaults to f64)
3.14f32           // explicit 32-bit float
1.0e10            // scientific notation
2.5e-3f64         // scientific with explicit type

Type suffixes: u8 u16 u32 u64 u128 i8 i16 i32 i64 i128 usize isize f32 f64

A literal stored where an integer of a written type is - a constant's or a variable's initializer, an assignment's value, a call's argument - takes that type, and must hold its value there. So must an expression made only of literals: its value is what the arithmetic gives, and a type that does not hold it is refused (E0010) rather than wrapped. A value is held up to the type's unsigned maximum (a bit pattern) and, for a signed type, down to its minimum.

const a: u8 = 100 + 100;   // 200: held
const b: u8 = 200 + 100;   // error: 300 does not fit `u8`, and would silently truncate
const c: i8 = 0 - 128;     // -128: held

Trap. Integer literals exceeding i64::MAX (e.g. 0xFFFF_FFFF_FFFF_FFFF) wrap to negative when used inline against a u64 operand. Hoist the literal into a const u64 NAME = ... binding to compare correctly.

String and Character Literals

Strings are enclosed in double quotes; characters in single quotes. Both share the same set of escape sequences.

"Hello, world!"
"line one\nline two"
'A'
'\n'
'\x41'                     // hex byte: equivalent to 'A'

Escape sequences:

escapevalue
\n \t \rnewline (10), tab (9), carriage return (13)
\a \b \f \vbell (7), backspace (8), form feed (12), vertical tab (11)
\\ \' \"the backslash or quote itself
\xHHthe byte one or two hex digits spell
\OOOthe byte one to three octal digits spell
\u{H...}the Unicode code point one or more hex digits spell, up to 10FFFF

An octal escape is one to three digits 0-7, read as C reads them: the escape ends after three digits or at the first character that is not one, so \0 is NUL, "\033[1m" is ESC and then [1m, \177 is 127, and "\0123" is \012 (newline) and then 3. \8 and \9, and an octal value above \377, are refused (E0013).

In a string, \u{...} is the code point's UTF-8 bytes, so "\u{e9}" is the two bytes C3 A9; in a character it is the code point itself, so '\u{42}' is 'B'. What a char holds for a code point above 255 depends on char's width, which is not yet decided. A malformed \u (no braces, no digit, or past 10FFFF), a \x with no hex digit, and an escape this table does not name (\q) are kept as written in a string; in a character, an escape the table does not name is the character after the backslash.

A string literal keeps every byte it spells, a NUL among them: the constant the compiler emits for "a\0b" is a, NUL, b and the terminating NUL. A string is read up to its first NUL by what measures it, so "a\0b".length() is 1 while the bytes after the NUL are still there to be read through a pointer.

Raw strings (r"...") are reserved but not yet implemented - see section 21.

f-strings (string interpolation)

An f-string, prefixed with f, builds an owned String by interpolating expressions written inside {...}:

const x: i32 = 42;
const opt: Option<i32> = Option::Some(7);
const s: String = f"x = {x}, opt = {opt:?}";   // "x = 42, opt = Some(7)"
  • {expr} formats expr through the Display trait; {expr:?} formats it through Debug. Any type implementing the relevant trait works, including Option, Result, and Array<T>.

  • The embedded expression is a full expression: f"{a + b}", f"{p.x}", f"{m.get(k)}". Because a hole holds source rather than literal text, quoting nests inside it - a string or char literal written in a hole is its own literal, and its quotes and braces are not f-string syntax:

    f"panel {if (shown) { "shown" } else { "hidden" }}"

    Only a " outside every {...} closes the f-string.

  • A hole may carry a format spec after a colon:

    {expr:[[fill]align][#][0][width][.precision][type]}
    • align is < (left), > (right), or ^ (centre); the default is left. fill is any single character placed before the alignment ({n:*>6} → ****42).
    • width is a minimum field width counted in Unicode scalars; shorter values are padded with fill (space by default).
    • A leading 0 zero-pads numbers, keeping the sign and any radix prefix leftmost ({−5:06} → -00005, {255:#06x} → 0x00ff).
    • .precision truncates a Display value to that many scalars ({"hello":.3} → hel) and sets the fractional digits of a float ({pi:.2} → 3.14).
    • type selects an integer radix: x/X (hex), o (octal), b (binary); # adds the 0x/0o/0b prefix. A radix formats the value's own-width two's-complement bits, so {(-5i32):x} → fffffffb.
    • The spec separator is the first top-level :. A hole that mixes a ternary with a spec must parenthesise the ternary (f"{(c ? a : b):>4}"); a bare a ? b : c (spaces around :) is not mistaken for a spec.
  • {{ and }} produce literal { and }. Standard escape sequences in the literal text are processed as in a normal string.

  • The result is a heap-backed String the caller owns (and drops). The parser desugars the whole f-string to calls into std::fmt::interp, which is auto-imported into any module that uses one.

For raw, untyped formatted output (C printf semantics, %d/%s specifiers, not type-checked), use printf from std::fmt. It is an ordinary variadic function, not an intrinsic, and it is not in the prelude: write import std::fmt; and call fmt::printf(...), or import std::fmt::{ printf }; to call it bare. For typed, Display-formatted output, build a String (usually with an f-string) and pass it to print / println from std::fmt; both take a single already-formatted argument (println(f"{x}")), not a printf-style format string with trailing values.

Boolean and Null Literals

true
false
null            // null pointer; valid in any pointer context

Booleans are strict. Nothing converts to boolean, implicitly or with as: no integer, char, float, pointer, enum, Option or Result has a truth value. Every place that wants a truth value requires exactly boolean: an if, while, do/while or for condition, a match guard, an if expression, the operands of &&, || and !, and any value assigned, passed or returned as a boolean. A test is written as the comparison it means:

if (n != 0) { ... }            // not `if (n)`; `n as boolean` is refused too
while (p != null) { ... }      // not `while (p)`
if (c == Color::Red) { ... }   // not `if (c)`, for any enum

The other direction stays: boolean as an integer type is 1 for true and 0 for false.