Intrinsic Expressions

An intrinsic expression is a builtin that appears in expression position and produces a value.

intrinsic = "@" IDENT "(" [ intrinsic_arg { "," intrinsic_arg } ] ")" ;
intrinsic_arg = expression | type ;

Intrinsics MAY accept expressions, types, or a combination of both as arguments, depending on the specific intrinsic.

Each intrinsic has a fixed signature specifying the number and types of arguments it accepts.

It is a compile-time error to call an intrinsic with the wrong number of arguments.

It is a compile-time error to use an unknown intrinsic name.

Quick Reference

The following table provides a quick reference to all available intrinsics:

IntrinsicPurposeArgumentsReturn Type
@dbgPrint debug output0+ expressions (phase-dependent types)()
@size_ofGet type size in bytes1 typeusize
@align_ofGet type alignment in bytes1 typeusize
@castConvert between numeric types1 expression (numeric)inferred numeric type
@target_archGet target architecturenoneArch
@target_osGet target OSnoneOs
@rangeConstruct integer range1-3 expressions (integers)Range(T)
@importImport module1 expression (string literal or comptime_str)module type
@embed_fileEmbed a file's bytes at compile time1 expression (string literal)Slice(u8)

@dbg

The @dbg intrinsic prints its arguments for debugging purposes. Its output destination depends on the phase in which it executes: calls evaluated at runtime print to standard output, while calls evaluated inside a comptime context print to standard error during compilation.

@dbg accepts zero or more arguments. The accepted argument types depend on the evaluation phase:

  • At runtime: each argument MUST be of integer, boolean, or String type.
  • At compile time: each argument MUST be a compile-time evaluable value of integer, boolean, unit, or comptime_str type.

@dbg formats each argument as a human-readable string, joins the results with single ASCII space characters, and emits the resulting line followed by a newline. A call with zero arguments emits an empty line.

At runtime, the formatted line is written to standard output. Integer values are formatted as signed or unsigned decimal according to their declared type, boolean values as true or false, and String values as their UTF-8 contents.

When @dbg is evaluated during compile-time interpretation, the compiler immediately writes the formatted line to standard error, prefixed with the literal string comptime dbg: . Compile-time evaluation always formats integers as signed decimal, booleans as true or false, comptime_str values as their contents, and () as ().

Each @dbg call whose arguments are evaluated at compile time also produces a post-compilation warning ("debug statement present"). The warning is attached to the call site and is emitted once per call.

The compiler collects the formatted messages from compile-time @dbg calls in a buffer on the compilation result, whether or not the compiler also prints them. A compiler-driver flag (--capture-comptime-dbg) suppresses the on-the-fly stderr print while leaving the buffer intact; this flag is intended for tools that consume the buffer directly.

@dbg observes its arguments without consuming them. When an argument is a place expression of an affine type (e.g. String), the binding remains usable after the call.

The return type of @dbg is ().

fn main() -> i32 {
    @dbg(42);                 // prints: 42
    @dbg(true);               // prints: true
    @dbg("hello");            // prints: hello
    @dbg("n =", 42);          // prints: n = 42 (variadic)
    @dbg();                   // prints an empty line
    0
}

@dbg is useful for inspecting values during development:

fn factorial(n: i32) -> i32 {
    @dbg(n);  // trace each call
    if n <= 1 {
        1
    } else {
        n * factorial(n - 1)
    }
}

fn main() -> i32 {
    factorial(5)
}

Inside a comptime block, @dbg is a compile-time debugging tool. The output appears on the compiler's standard error and the build emits a warning for each call:

fn compute(comptime n: i32) -> i32 {
    comptime { @dbg("computing with n =", n); }
    n * 2
}

fn main() -> i32 {
    compute(21)
    // compiler output: comptime dbg: computing with n = 21
    // compiler warning: debug statement present — remove before release
}

@size_of

The @size_of intrinsic returns the size of a type in bytes.

@size_of accepts exactly one argument, which MUST be a type.

The return type of @size_of is usize.

The value returned by @size_of is determined at compile time.

fn main() -> i32 {
    let n: usize = @size_of(i32);   // 8 (one 8-byte slot)
    @cast(n)
}
struct Point { x: i32, y: i32 }

fn main() -> i32 {
    let n: usize = @size_of(Point); // 16 (two 8-byte slots)
    @cast(n)
}

@align_of

The @align_of intrinsic returns the alignment of a type in bytes.

@align_of accepts exactly one argument, which MUST be a type.

The return type of @align_of is usize.

The value returned by @align_of is determined at compile time.

All types in Gruel currently have 8-byte alignment.

fn main() -> i32 {
    let a: usize = @align_of(i32);  // 8
    @cast(a)
}

@ownership

The @ownership intrinsic classifies the ownership posture of a type (see ADR-0008, ADR-0059). The classification is computed from conformance to the compiler-recognized Copy interface (§3.8) plus the linear keyword.

@ownership accepts exactly one argument, which MUST be a type.

The return type of @ownership is the built-in enum Ownership, which has three variants:

VariantMeaning
Ownership::CopyValues may be implicitly duplicated by bitwise copy.
Ownership::AffineValues may be used at most once and are implicitly dropped if not consumed. This is the default for user-defined structs.
Ownership::LinearValues must be explicitly consumed; implicit drop is a compile-time error.

The variants are mutually exclusive: every type has exactly one ownership posture. The classification is:

  • Linear if T carries the linear keyword.
  • Copy if T is a primitive (integers, floats, bool, char, ()), a pointer or reference, a struct or enum declared copy, or a tuple whose every element is Copy. Anonymous enum { … } literals (used by the prelude's Option(T) / Result(T, E)) infer Copy structurally the same way tuples do.
  • Affine otherwise. Arrays and Vec are perpetually non-Copy regardless of their element type.

The value returned by @ownership is determined at compile time.

fn main() -> i32 {
    match @ownership(i32) {
        Ownership::Copy => 1,
        Ownership::Affine => 2,
        Ownership::Linear => 3,
    }  // 1
}
struct Point { x: i32, y: i32 }  // Affine by default

fn main() -> i32 {
    match @ownership(Point) {
        Ownership::Copy => 1,
        Ownership::Affine => 2,
        Ownership::Linear => 3,
    }  // 2
}

@implements

The @implements intrinsic reports whether a type structurally implements an interface (see §6 and ADR-0056).

@implements accepts exactly two arguments. The first MUST be a type; the second MUST name an interface.

The return type of @implements is bool.

@implements(T, I) evaluates to true if every method requirement of interface I is satisfied by a method of type T whose receiver mode, parameter types, and return type all match the requirement (with Self substituted by T); otherwise it evaluates to false. For the compiler-recognized interface Drop, conformance is determined by the language's ownership rules rather than user-declared methods (see §3.8 and ADR-0059): @implements(T, Drop) is true iff T is non-linear and not Copy. ADR-0080 retired Copy from the interface set — @implements(T, Copy) falls through the existing "unknown interface" diagnostic; query Copy posture via @ownership(T) == Ownership::Copy instead.

It is a compile-time error if the second argument does not name an interface, or if either argument cannot be resolved.

The value returned by @implements is determined at compile time.

interface Greeter {
    fn greet(self);
}

struct Friendly {
    name: String,
    fn greet(self) {}
}

fn main() -> i32 {
    if @implements(Friendly, Greeter) { 1 } else { 0 }  // 1
}
fn main() -> i32 {
    if @implements(i32, Copy) { 1 } else { 0 }  // 1
}

@cast

The @cast intrinsic converts a numeric value from one numeric type to another, covering both integer-to-integer conversions and conversions involving floating-point types.

@cast accepts exactly one argument, which MUST be a numeric type (any integer type or any floating-point type).

The target type of the conversion is inferred from the context where @cast is used.

It is a compile-time error if the target type cannot be inferred or is not a numeric type.

For integer-to-integer conversions, if the source value cannot be exactly represented in the target type, a runtime panic occurs.

For float-to-float conversions (e.g., f64 to f32), the value is narrowed or widened following IEEE 754 rounding rules. Precision loss during narrowing is silent (no panic). Narrowing a value outside the target range produces infinity.

For integer-to-float conversions, the integer value is converted to the closest representable floating-point value. Loss of precision for large integer values is silent (no panic).

For float-to-integer conversions, the float value is truncated toward zero. A runtime panic occurs if the value is NaN or if the truncated value is outside the representable range of the target integer type.

fn main() -> i32 {
    let x: f64 = 3.14;
    let y: f32 = @cast(x);      // f64 → f32 (narrowing)
    let z: f64 = @cast(y);      // f32 → f64 (widening)
    0
}
fn main() -> i32 {
    let n: i32 = 42;
    let f: f64 = @cast(n);      // i32 → f64
    let m: i32 = @cast(f);      // f64 → i32 (truncates toward zero)
    m
}
// This panics at runtime: NaN cannot be converted to an integer
fn main() -> i32 {
    let nan: f64 = 0.0 / 0.0;
    let n: i32 = @cast(nan);    // panic: float-to-integer cast overflow
    n
}
// This panics at runtime: value too large for target integer type
fn main() -> i32 {
    let big: f64 = 9999999999999999999999.0;
    let n: i32 = @cast(big);    // panic: float-to-integer cast overflow
    n
}
fn main() -> i32 {
    let x: i32 = 100;
    let y: u8 = @cast(x);       // Integer narrowing
    @cast(y)                     // Integer widening
}

@target_arch

The @target_arch intrinsic returns the target architecture as an Arch enum value.

@target_arch accepts no arguments.

The return type of @target_arch is Arch.

The Arch enum is a built-in enum with the following variants, in order:

  • Arch::X86_64 - x86-64 / AMD64
  • Arch::Aarch64 - ARM64 / AArch64
  • Arch::X86 - 32-bit x86
  • Arch::Arm - 32-bit ARM
  • Arch::Riscv32 - 32-bit RISC-V
  • Arch::Riscv64 - 64-bit RISC-V
  • Arch::Wasm32 - 32-bit WebAssembly
  • Arch::Wasm64 - 64-bit WebAssembly

Variant indices are stable: existing variants keep their position and new variants are appended.

The value returned by @target_arch is determined at compile time based on the compilation target.

fn main() -> i32 {
    match @target_arch() {
        Arch::X86_64 => 1,
        Arch::Aarch64 => 2,
        _ => 0,
    }
}

@target_os

The @target_os intrinsic returns the target operating system as an Os enum value.

@target_os accepts no arguments.

The return type of @target_os is Os.

The Os enum is a built-in enum with the following variants, in order:

  • Os::Linux - Linux
  • Os::Macos - macOS / Darwin
  • Os::Windows - Microsoft Windows
  • Os::Freestanding - no operating system (bare metal)
  • Os::Wasi - WebAssembly System Interface

Variant indices are stable: existing variants keep their position and new variants are appended.

The value returned by @target_os is determined at compile time based on the compilation target.

fn main() -> i32 {
    match @target_os() {
        Os::Linux => 1,
        Os::Macos => 2,
        _ => 0,
    }
}

Combining @target_arch and @target_os for platform-specific code:

fn main() -> i32 {
    match @target_arch() {
        Arch::X86_64 => {
            match @target_os() {
                Os::Linux => 99,
                Os::Macos => 88,
                _ => 0,
            }
        },
        Arch::Aarch64 => {
            match @target_os() {
                Os::Linux => 77,
                Os::Macos => 66,
                _ => 0,
            }
        },
        _ => 0,
    }
}

@range

The @range intrinsic constructs a Range(T) value representing an integer range, for use with for-in loops.

@range accepts 1, 2, or 3 integer arguments:

FormMeaning
@range(end)0 to end, exclusive, stride 1
@range(start, end)start to end, exclusive, stride 1
@range(start, end, stride)start to end, exclusive, step by stride

All arguments to @range MUST be the same integer type T. The result has type Range(T).

Range(T) is a builtin comptime type constructor parameterized by an integer type. It has fields start, end, stride of type T, and inclusive of type bool. The .inclusive() method returns a new range with inclusive set to true.

fn main() -> i32 {
    let mut sum = 0;
    for i in @range(10) {
        sum = sum + i;
    }
    sum  // 45
}
fn main() -> i32 {
    let mut sum = 0;
    for i in @range(0, 10, 2) {
        sum = sum + i;
    }
    sum  // 20 (0+2+4+6+8)
}

@import

The @import intrinsic imports a module from another source file.

@import accepts exactly one argument. The argument MUST be either a string literal or an expression of type comptime_str specifying the module path. Expressions of type comptime_str are evaluated by the compile-time interpreter; this enables conditional imports driven by @target_os(), @target_arch(), or any other comptime-known data.

The return type of @import is a module struct type containing all pub declarations from the imported file.

Module path resolution follows this order:

  1. Standard library: @import("std") resolves to the bundled standard library
  2. A file {path}.gruel relative to the importing file's directory
  3. A directory module _{path}.gruel with subdirectory {path}/

It is a compile-time error if the module path does not resolve to an existing file.

It is a compile-time error to pass an argument to @import that is neither a string literal nor a comptime_str expression. Passing a runtime value (e.g. a String parameter or a local bound to a runtime expression) is a compile-time error because the module path must be resolvable during semantic analysis.

// math.gruel
pub fn add(a: i32, b: i32) -> i32 { a + b }
pub fn sub(a: i32, b: i32) -> i32 { a - b }
fn helper() -> i32 { 42 }  // private, not exported

// main.gruel
fn main() -> i32 {
    let math = @import("math");
    math.add(1, 2)  // returns 3
}

Private declarations (those without pub) are not visible to importers:

// main.gruel
fn main() -> i32 {
    let math = @import("math");
    // math.helper()  // Error: `helper` is not visible
    0
}

The imported module can be bound to any name:

fn main() -> i32 {
    let m = @import("math");
    m.add(1, 2)
}

Nested paths are supported for importing from subdirectories:

fn main() -> i32 {
    let strings = @import("utils/strings");
    0
}

A comptime_str argument enables platform-conditional imports. The expression is evaluated by the compile-time interpreter before module resolution:

fn main() -> i32 {
    let sys = @import(comptime {
        if @target_os() == Os::Linux {
            "sys_linux"
        } else {
            "sys_macos"
        }
    });
    0
}

@embed_file

The @embed_file intrinsic embeds the contents of a file at compile time as a read-only byte slice.

@embed_file accepts exactly one argument. The argument MUST be a string literal specifying the path to the file. Path resolution is relative to the source file containing the @embed_file call; absolute paths are used as-is.

The return type of @embed_file is Slice(u8). The slice's pointer references a binary-baked global; the bytes have effectively static lifetime and MUST NOT be mutated.

It is a compile-time error if the path argument is not a string literal, or if the file cannot be read at the time semantic analysis runs.

fn main() -> i32 {
    let data: Slice(u8) = @embed_file("greeting.txt");
    @cast(data[0], i32)  // first byte of greeting.txt
}