The Rock Programming Language
A practical guide to learning Rock, from your first expression to generic functional abstractions and systems programming.
Rock is a statically typed, expression-oriented language with significant indentation. It combines algebraic data types, pattern matching, traits, ownership and borrowing, first-class functions, user-defined operators, and a system-programming core.
This book assumes that you have programmed before. You do not need experience with Rust, Haskell, or another functional language. When Rock uses an unfamiliar idea, the book introduces the motivation before the syntax.
Rock is under active development. This book describes the language implemented in this repository, calls out current limitations, and avoids compiler-only mechanisms that ordinary programs should not use.
Use the left and right arrow keys to move between pages, press S to focus search, or use the chapter list in the sidebar.
Introduction
Rock is a full-fledged, expression-oriented programming language designed to make powerful programs easy to read. Like Rust, it embraces a rich syntax while keeping structure visible and understandable. It provides the foundations expected of a modern systems language, including type checking, ownership, and contracts for method receivers. Indentation forms blocks, function calls use spaces and commas, and operators are supplied by the program and its explicit dependencies.
This is a complete program. It uses only names supplied by the standard-library prelude and can be read from top to bottom.
enum Greeting
Hello &Str
Goodbye
message = greeting ->
match greeting
Greeting::Hello name => "Hello, " + name
Greeting::Goodbye => "See you soon"
main = !->
Greeting::Hello "Rock"
|> message
|> (.println!)
The program has three top-level declarations:
Greetingis an enum. A value is eitherHellowith a borrowed string payload orGoodbyewith no payload.messageis a function. Its parameter isgreeting, and its body is the indented expression after->.mainis the executable entry point. Its!->body runs for effects and returns unit (()).
The execution order is explicit. main constructs a Greeting::Hello, passes it to message, and prints the returned string. Its !-> body discards the trailing value and returns unit (()), which the runtime maps to process exit status 0. The match expression chooses the first arm whose pattern fits the value. Because the Hello arm binds name, the body can concatenate that payload with another string.
The visual grammar
Rock uses indentation instead of braces. Every line indented farther than its header belongs to that header. The else keyword aligns with the if that owns it, and match arms align with one another.
Function declarations use name = parameters -> body. Calls put arguments after the callee and separate multiple arguments with commas:
add = left, right -> left + right
main = !-> add 20, 22 .println!
The call add 20, 22 has two arguments. Each argument is a complete expression, so add 2 + 3, 4 passes 5 and 4 without extra grouping. Parentheses remain useful when they change precedence or delimit a nested call containing commas; they do not replace the space-and-comma call syntax. A trailing ! invokes a zero-argument function or method, so println! means “call println with no explicit arguments.”
Blocks are expressions. A block evaluates each earlier expression for its effects and gives the final expression as its value:
square = number ->
number * number
main = !->
square 6 .println!
The function square returns 36 because its final expression is number * number. In main, .println! runs for its effect, and !-> discards its value and returns unit.
Types without ceremony
Local types are commonly inferred. An annotation is useful when it documents an interface or removes ambiguity:
double: I64 -> I64
double = value ->
value * 2
main = !->
answer: I64 = double 21
answer.println!
double accepts an I64 and returns an I64. The annotation does not change the call syntax or create a runtime conversion. It gives the compiler a contract to check.
What to expect from the book
The getting-started chapters build a runnable program before the language reference separates the ideas. Read Hello, Rock! first, then A First Project. The language chapters revisit values, functions, control flow, and data types before introducing ownership, borrowing, traits, and reusable library interfaces.
Most examples use the standard library shipped with the selected Rock toolchain. The rock project command resolves that matching library automatically unless a manifest explicitly sets no_std = true.
A Learning Path
Read the book in order for a first pass, or choose a checkpoint that matches your experience:
| Stage | Build or explain | What you practice |
|---|---|---|
| Getting started | FizzBuzz | Calls, branches, enum values, and loops |
| Feedback | Editors and Diagnostics | A project-aware editor and a build that verifies saved code |
| Ownership | Arrays and slices, then borrowing | Choosing an owner, borrowing a view, and ending conflicting access |
| Abstraction | Traits and error handling | Reusable contracts and explicit failure paths |
| Byte-oriented programs | Files and TCP | Partial reads, complete writes, and resource cleanup |
| Capstone | An HTTP server | Package dependencies, routing, ownership, protocol limits, and concurrent workers |
Higher-kinded types and unsafe programming are deeper topics, not prerequisites for writing your first application. Return to them when a reusable abstraction or a low-level boundary makes their motivation concrete.
At each stage, run the complete program first. Then change one input, predict the result, and compare it with the output or diagnostic. For the HTTP project, the chapter includes requests that check success, method rejection, and missing routes rather than relying only on a server-started message.
The examples in this book are intentionally complete. A rock fence either contains a whole program or a complete top-level module fragment whose declarations are all present in that fence. A name supplied by the stdlib prelude may be used without an import; any other external name must be imported in the same file.
What this book does not cover
This is a user guide, not a compiler implementation guide. It does not document parser data structures, intermediate representations, artifact internals, compiler-recognized item markers, or code-generation machinery. Low-level syntax appears only when it is part of a public application or library interface.
Rock is still a prototype. The broad language model is exercised by parser and compiler tests, but some ecosystem commands and library conveniences are incomplete. Each chapter calls out current behavior where it differs from the ideal a future release may provide.
Common first mistakes
- Treating parentheses as mandatory call syntax. Write
max 5, 7, notmax(5, 7). - Forgetting that a block’s last expression is its value.
- Using an enum payload without first matching the enum variant.
- Assuming a familiar operator has a compiler-defined meaning. The active program and its dependencies must provide the operator implementation.
- Running
rockoutside a project directory containingrock.toml.
Installation
Rock’s release toolchain supports only Linux x86_64 with GNU glibc (x86_64-unknown-linux-gnu). The binary baseline is Ubuntu 24.04, requiring glibc 2.39 or newer and a C linker available as cc. LLVM 18 is statically linked, so end users do not need to install LLVM. The executables are not fully static: system-library dependencies remain.
Release format: rockup requires
v0.5.0or later. Historical releases use a different asset layout and cannot be installed through this bootstrap.
Prerequisites
For binary installation on Ubuntu 24.04, install the runtime requirements yourself:
$ sudo apt install build-essential curl ca-certificates
GNU tar, gzip, and sha256sum must also be installed (normally already present on Ubuntu). Rockup uses curl for HTTPS downloads and those tools to verify and unpack archives. The installer never invokes sudo or installs operating-system packages. Neither Rust nor LLVM is required to use a binary release.
Install a release
Install the manager and complete Rock toolchain with this one-liner. It executes a script from the official Rock-lang-org/Rock release publisher; only run it if you trust that publisher. You can inspect install.sh separately first.
curl --proto '=https' -fsSL https://github.com/Rock-lang-org/Rock/releases/latest/download/install.sh | sh
The bootstrap downloads the standalone rockup-x86_64-unknown-linux-gnu and its exact .sha256 sidecar over HTTPS, checks that the sidecar names that asset and that its checksum matches, then executes rockup self install. This copies the manager and command shims and adds shell setup. The script then invokes the installed manager’s rockup install stable automatically to download and install the compiler, project command, language server, and standard library. No shell restart or separate command is needed between these steps.
Rockup verifies the toolchain archive before unpacking it. Checksums detect corruption; they are not independent signatures, so you still trust the official release publisher and HTTPS.
The bootstrap defaults to stable, meaning GitHub’s latest published non-prerelease release, not a promise that this experimental language has a stable API. To pin both the manager and toolchain versions when bootstrapping, pass a published vVERSION tag:
$ curl --proto '=https' -fsSL https://github.com/Rock-lang-org/Rock/releases/latest/download/install.sh | sh -s -- v0.6.0
That command installs both rockup and the v0.6.0 toolchain. From a checkout, sh scripts/install.sh v0.6.0 does the same. Stable manager assets come from releases/latest/download; pinned manager assets come from releases/download/vVERSION.
Home and shell setup
The bootstrap persists rockup as ~/.rockup/bin/rockup, creates rock, rockc, and rock-lsp shims in ~/.rockup/bin, and installs the selected toolchain under ~/.rockup/toolchains. Set ROCKUP_HOME to a nonempty absolute path before installation to use another location; keep using that value in later shells.
An installed toolchain uses the standard library beside its own binaries, even when you run it inside a compiler checkout with a target/ directory. CARGO_TARGET_DIR affects development sysroot discovery, not installed toolchains. ROCK_SYSROOT remains an explicit override; leave it unset for normal rockup-managed use.
The bootstrap refuses to overwrite an existing ROCKUP_HOME/bin/rockup, including a dangling symlink. Use that installed manager’s install, update, or self update command instead of rerunning the bootstrap. If toolchain installation fails after the manager is installed, the script exits with an error and prints the exact command to retry; the manager remains installed. Installing a release does not delete an installed local dev toolchain or replace an existing default; the first toolchain in an empty home becomes the default.
Rockup writes an env file and a managed activation block to .bashrc for Bash, .zshrc for Zsh, or .profile for other shells, based on SHELL. It also updates existing Bash and Zsh configuration files. Restart your shell, or activate a POSIX-compatible shell immediately:
. "${ROCKUP_HOME:-$HOME/.rockup}/env"
After activating the shell, verify:
rock --version
rock-lsp --help
The generated activation script uses POSIX shell syntax; it is not native Fish or PowerShell setup. rock-lsp without arguments starts an LSP server over standard input/output, not an interactive prompt.
Manage releases and pins
With rockup installed, the shortest commands install or update the latest stable release:
$ rockup install
$ rockup update
Both commands default to stable. Use update when that toolchain is already installed.
Install a version without changing an existing default:
$ rockup install v0.6.0
The bare spelling rockup install 0.6.0 selects the same release and stores it as v0.6.0; do not run both installation commands for the same version. To choose it globally or run a single command explicitly:
$ rockup default v0.6.0
$ rockup run v0.6.0 rock --version
rockup default NAME selects an installed toolchain, including local dev, and automatically installs a missing stable or versioned release. Arbitrary local names must first be installed with --path.
For a project pin, create rock-toolchain.toml in the project directory with this schema:
[toolchain]
channel = "v0.6.0"
Install that version first. The shims select a toolchain using ROCKUP_TOOLCHAIN first, then the nearest rock-toolchain.toml in the current directory or its ancestors, then the global default. A project pin selects an installed toolchain; it does not automatically download one. Use the canonical v spelling for version pins, or an installed local name such as dev.
Update the moving stable toolchain and the manager separately:
$ rockup update
$ rockup update stable
$ rockup self update
$ rockup list
The first two commands are equivalent. Updating stable leaves separately installed pinned versions and local development toolchains in place. self update replaces the running rockup executable with the latest stable manager. To remove a version, select a different default first and remove any project or environment selection that still refers to it:
$ rockup default stable
$ rockup remove v0.6.0
Rockup is not a full Rustup replacement. Release downloads do not support Windows, macOS, musl, other CPU architectures, a nightly channel, or automatic cross-target installation. The local target add --path component workflow does not imply downloadable cross-target releases.
Build from source
For compiler development or a local source build, install these additional tools through your operating system:
- Git
- A Rust toolchain with Cargo
- LLVM 18 development tools (including
llvm-config), headers, and static archives - A C linker available as
cc
On Ubuntu 24.04, install the build dependencies and select LLVM 18 explicitly:
$ sudo apt install llvm-18-dev libpolly-18-dev libzstd-dev libxml2-dev zlib1g-dev libffi-dev libedit-dev libncurses-dev build-essential
$ export LLVM_SYS_180_PREFIX=/usr/lib/llvm-18
Source builds require static LLVM archives; there is no dynamic-linking fallback.
Clone the repository and build the release toolchain:
$ git clone https://github.com/Rock-lang-org/Rock.git
$ cd Rock
$ cargo build --release
Preparing a development toolchain
Package the standard library beside the release binaries, then install that directory as a local toolchain:
$ target/release/rockup dev stdlib package \
--path stdlib \
--sysroot target/release
$ target/release/rockup install dev --path target/release
$ target/release/rockup default dev
The installer creates command shims and prints the shell setup it added. Restart the shell when prompted, then verify the user-facing command:
$ rock --version
$ rock-lsp --help
$ rockup list
The selected toolchain contains the compiler implementation and matching standard library, but application authors interact with it through rock. The project command discovers manifests, builds path dependencies, selects the standard library, and manages output paths.
It also contains rock-lsp, the language server used by editors. rockup installs a shim for this command alongside rock, so an editor started from the activated shell can find the selected server. rock-lsp --help checks that the command can start; running it without arguments starts an LSP server over standard input and output, not an interactive compiler prompt.
Optional editor setup
The separate rock.nvim repository contains a plugin that uses Neovim’s built-in LSP client; it does not require nvim-lspconfig. The plugin requires Neovim 0.11 or newer. Clone that repository, then add this to init.lua, replacing the path with your checkout:
vim.opt.runtimepath:prepend("/absolute/path/to/rock.nvim")
require("rock").setup()
Installing the plugin does not build or install the Rock toolchain. Complete the toolchain steps above first, then open a project’s .rk file and run :checkhealth rock.
For local-checkout setup, editor commands, and a walkthrough of compiler errors, continue with Editors and Diagnostics. Other LSP clients can start rock-lsp for .rk files using the rock language identifier and standard input/output transport.
Compiler contributor checks
Run the compiler’s Rust tests when changing the language implementation:
$ cargo test -p rock-lib
Common setup failures
- HTTP 404 for bootstrap assets means the selected release does not provide the required assets; select
v0.5.0or later, or use the source workflow. - A checksum mismatch or malformed sidecar stops installation before the downloaded manager executes. Do not bypass verification; retry or report the release asset problem.
GLIBC_2.39errors mean the host is older than the release baseline. Use Ubuntu 24.04 or a compatible newer GNU system, or build from source on your host.- Source-build
llvm-configor missing static-archive errors mean you should check the LLVM 18 development packages above andLLVM_SYS_180_PREFIX=/usr/lib/llvm-18; do not switch to dynamic linking. Binary releases do not require an LLVM installation. - A missing standard-library component means the packaging step did not complete or the installed toolchain does not match the checkout.
- If
rockis not found after installation, restart the shell or apply the activation command printed byrockup. - If
rock-lspis missing from a toolchain built from an older checkout, rebuild and package the current workspace before installing it; a current toolchain installation requires the server binary too. - If an editor cannot find
rock-lspbut the shell can, start the editor from that shell or configure an absolute server command as shown in Editors and Diagnostics. - If a generated executable is not found, inspect the project’s
build/directory.
Hello, Rock!
Create your first project with rock new:
rock new hello
cd hello
This creates a new directory containing rock.toml, src/main.rk, and a .gitignore that excludes build output. Existing directories are never overwritten. Project names start with an ASCII letter and can contain letters, digits, hyphens, and underscores; stdlib is reserved.
The generated rock.toml manifest is:
[crate]
name = "hello"
version = "0.1.0"
[lib]
path = "src/main.rk"
The generated src/main.rk contains this complete program:
main = !->
"Hello, Rock!".println!
Run it from the project directory:
$ rock run
Hello, Rock!
The output contains one line, and the process exits with status zero.
Reading the program
main = !-> declares a function named main with no parameters. The arrow separates its parameter list from its body. The extra indentation makes the next line part of that body.
The string literal is borrowed string data. .println! calls the prelude’s printing method with no explicit arguments. The !-> body evaluates that call, discards its result, and returns unit (()). The runtime maps a unit-returning main to process exit status 0, so no final 0 expression is needed.
Use main = !-> for ordinary programs. Keep main = -> when you intentionally return an integer exit status, such as 1 to report failure; that arrow returns the body’s trailing value instead of discarding it.
Calls with arguments use spaces and commas:
main = !->
max 10, 20 .println!
max is a prelude function. The first argument is 10 and the second is 20. The space before .println! makes the method apply to the result of the complete max 10, 20 call, so this prints 20 directly.
Expressions and statements
A one-expression function can stay on one line or use an indented body:
square = number ->
number * number
main = !->
square 5 .println!
The final expression of square is its return value. Earlier expressions can perform effects before the final value:
announce_square = number ->
"squaring a number".println!
number * number
main = !->
announce_square 5 .println!
An if/else expression produces the value of the selected branch. When it is the final expression in a function, that value becomes the function’s return value:
absolute = number ->
if number >= 0
then number
else -number
main = !->
absolute -5 .println!
For -5, the condition is false, so the else branch evaluates -number and produces 5. For a nonnegative value, the then branch produces number unchanged. The selected value is returned by absolute.
Comments
Rock supports line and block comments:
main = !->
// This line explains why the next value is printed.
/* A block comment can
cover several source lines. */
"comments do not produce values".println!
Comments are ignored by the compiler. Use them for intent, constraints, or a non-obvious reason, not as a translation of every line.
Common mistakes
- Using
!->when you intend to return a custom integer exit status; use->instead. - Writing
println("text"); the current call form is"text".println!. - Indenting a statement at the same level as
main = !->; that makes it a separate top-level item instead of part ofmain. - Running
rockoutside the directory containingrock.toml.
A First Project: FizzBuzz
FizzBuzz is a useful first project because it combines a value-producing function, an enum, guarded pattern matching, a range, and an inline callback without needing a large library. The rules are:
- Visit the integers from 1 through 30.
- Print
FizzBuzzfor a multiple of both 3 and 5. - Otherwise print
Fizzfor a multiple of 3. - Otherwise print
Buzzfor a multiple of 5. - Print the number for every other value.
The complete program
Create a project manifest as rock.toml:
[crate]
name = "fizzbuzz"
version = "0.1.0"
[lib]
path = "main.rk"
Save the program as main.rk beside the manifest:
enum FizzBuzzValue
Text &Str
Number I64
fizzbuzz_value: I64 -> FizzBuzzValue
fizzbuzz_value = number ->
match number
n if n % 15 == 0 => FizzBuzzValue::Text "FizzBuzz"
n if n % 3 == 0 => FizzBuzzValue::Text "Fizz"
n if n % 5 == 0 => FizzBuzzValue::Text "Buzz"
n => FizzBuzzValue::Number n
print_value: FizzBuzzValue -> I32
print_value = value ->
match value
FizzBuzzValue::Text text => text.println!
FizzBuzzValue::Number number => number.println!
main = !->
for_each 1..=30, number !->
number
|> fizzbuzz_value
|> print_value
Run it from the project directory:
$ rock run
The complete output is:
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
16
17
Fizz
19
Buzz
Fizz
22
23
Fizz
Buzz
26
Fizz
28
29
FizzBuzz
Start with the result type
The calculation has two possible shapes: a word or the original number. The enum gives both shapes one static type:
enum FizzBuzzValue
Text &Str
Number I64
FizzBuzzValue is the type. Text carries borrowed string data, while Number carries an I64. A constructor is qualified with the enum name:
enum FizzBuzzValue
Text &Str
Number I64
main = !->
word = FizzBuzzValue::Text "Fizz"
number = FizzBuzzValue::Number 7
word_text = match word
FizzBuzzValue::Text text => text
FizzBuzzValue::Number value => "number"
word_text.println!
number_text = match number
FizzBuzzValue::Text text => text
FizzBuzzValue::Number value => "number"
number_text.println!
Both local bindings have the same enum type even though their payloads differ. The matches make the payload types visible: text is &Str in its arm and value is I64 in its arm.
Separate calculation from output
The signature states the contract before the definition:
enum FizzBuzzValue
Text &Str
Number I64
fizzbuzz_value: I64 -> FizzBuzzValue
fizzbuzz_value = number ->
match number
n if n % 15 == 0 => FizzBuzzValue::Text "FizzBuzz"
n if n % 3 == 0 => FizzBuzzValue::Text "Fizz"
n if n % 5 == 0 => FizzBuzzValue::Text "Buzz"
n => FizzBuzzValue::Number n
The signature says that one I64 becomes one FizzBuzzValue. The body does not print, so the calculation can be reused by another output function or a test. Each match arm constructs the same enclosing enum type even though the selected variant differs.
Each n pattern binds the input integer within its own arm. An if guard checks an additional condition; % computes a remainder and == produces a Bool. Arms are checked from top to bottom, and the first successful arm supplies the result. Checking divisibility by 15 first is essential because a 3-only arm placed first would hide the combined case. The final unguarded n arm handles every remaining number.
Recover the payload with match
The output function consumes one enum value and handles both variants:
enum FizzBuzzValue
Text &Str
Number I64
print_value: FizzBuzzValue -> I32
print_value = value ->
match value
FizzBuzzValue::Text text => text.println!
FizzBuzzValue::Number number => number.println!
An arm pattern checks the variant and binds its payload at the same time. The arm body is evaluated only after its pattern succeeds. Listing both variants makes the output decision visible in one place.
Traverse the range with for_each
for_each is a generic prelude function with two arguments: the values to visit and an action to run for each value. Here 1..=30 includes both endpoints, and number !-> introduces the inline action:
for_eachsupplies the next integer asnumber, in ascending order.number |> fizzbuzz_valuepasses that integer to the calculation and produces aFizzBuzzValue.|> print_valuepasses that result to the output function.- The action’s
!->discards the printing result and returns unit (()).
The |> operator passes the value on its left to the function on its right. Indented continuation lines keep the pipeline together as one expression. There is no counter to update and no intermediate collection: bounded ranges are traversed directly, and for_each returns only after every action has completed. Both endpoints are required; an open-ended range is rejected at runtime.
The same function also works with foldable containers such as Vec, Option, and Result; Higher-Kinded Types explains how their shared Foldable abstraction supplies the traversal.
A for version
An ordinary for loop can express the same traversal. An exclusive range written with .. excludes its upper bound, so this version uses 1..31:
enum FizzBuzzValue
Text &Str
Number I64
fizzbuzz_value: I64 -> FizzBuzzValue
fizzbuzz_value = number ->
match number
n if n % 15 == 0 => FizzBuzzValue::Text "FizzBuzz"
n if n % 3 == 0 => FizzBuzzValue::Text "Fizz"
n if n % 5 == 0 => FizzBuzzValue::Text "Buzz"
n => FizzBuzzValue::Number n
print_value: FizzBuzzValue -> I32
print_value = value ->
match value
FizzBuzzValue::Text text => text.println!
FizzBuzzValue::Number number => number.println!
main = !->
for number in 1..31
value = fizzbuzz_value number
print_value value
The loop pattern number receives each range element. There is no explicit increment because the range iterator advances it.
Extract a repeated idea
The divisibility test is a reusable function with two arguments:
is_divisible: I64 -> I64 -> Bool
is_divisible = number, divisor ->
number % divisor == 0
The signature lists two I64 parameters followed by a Bool result. The -> definition receives both arguments in one call; it is not a curried ~> definition. In a larger version of the project, the first match arm could read n if is_divisible n, 15 => FizzBuzzValue::Text "FizzBuzz" without changing the underlying calculation.
Common mistakes
- Checking
% 3before% 15, which changes the result for 15. - Forgetting that
1..31excludes 31 and therefore includes 30 as its final element. - Placing the unguarded
narm first, which would match every number before any divisibility guard is checked. - Returning a raw string from one match arm and an integer from another; all arms still need one enclosing type.
- Using
->for an action that leaves the printing result as its return value;for_eachexpects a unit-returning action, so use!->. - Assuming an enum payload is available outside its matching arm; the binding is introduced by the pattern.
The next chapters isolate each of these language features so that you can reason about the type and value flowing through every line.
Projects and the Toolchain
A project gives rock enough information to find an entry file, resolve path modules, and choose build output locations. This chapter starts with one file; Modules and Visibility later shows the complete source of a multi-file project, and Packages and Dependencies builds two path-dependent packages.
The manifest
Create a directory containing rock.toml:
[crate]
name = "hello"
version = "0.1.0"
[lib]
path = "main.rk"
Put a complete Rock program in main.rk:
main = !->
"project entry point".println!
Rock currently calls the source entry a library path even when it contains the executable main function. From the project directory, build and run it:
$ rock build
$ rock run
project entry point
Pass application arguments after --:
$ rock run -- first second
The project command writes products below build/, including object files, crate artifacts, and a linked executable when requested.
Everyday commands
$ rock format # Format the configured entry source
$ rock build # Build the current project
$ rock run -- first second # Build and run it with two arguments
$ rock expand # Print macro-expanded source
$ rock artifact # Materialize a reusable crate artifact
There is currently no rock test command. Compiler contributors use Cargo’s Rust test suite; application tests are ordinary Rock programs or external harnesses.
Formatting caution
The formatter is still maturing. Known rough edges affect some trait implementation headers, nested generic applications, and &mut expressions. Keep source under version control, inspect formatter changes, and compile after formatting nontrivial code.
One project workflow
Keep even small learning programs in a project with rock.toml. This gives examples, scripts, editors, and larger packages the same rock format, rock build, and rock run workflow from the beginning.
How the editor finds your program
When you open a .rk file, rock-lsp searches its parent directories for the nearest rock.toml. It uses the manifest’s entry path and crate name, resolves path dependencies through the same project code as rock build, and selects the toolchain standard library. Ordinary projects do not need a hand-written list of editor dependency artifacts.
For the project above, opening main.rk analyzes the program selected by [lib].path. In a multi-file project, the server still starts from that entry and follows its module graph; opening an unrelated .rk file below the same manifest does not automatically include it in the program. See Modules and Visibility for declaring modules.
Open source buffers are supplied to analysis with their unsaved text. Files in the entry graph that are not open are read from disk. Path dependencies are resolved as compiled artifacts, rather than merged into one live source workspace, so save dependency changes before expecting a consuming project to see them. Resolving dependencies can build or refresh artifacts and write build outputs; editor startup is not necessarily a read-only operation.
Project resolution is cached. After changing rock.toml, dependency files, or the selected toolchain, save a Rock source buffer to invalidate its cached project resolution, or restart the language server. In Neovim, use :RockLspRestart. Unsaved manifest edits are not a replacement for the manifest on disk.
Without a manifest, the server analyzes the opened file as a standalone entry and does not automatically supply the toolchain standard library. Prefer a small project like the one above for learning, especially when using operators or prelude methods.
From editor feedback to a build
Live diagnostics help you fix syntax, name resolution, and type errors before saving. They are not a successful build: the editor’s source analysis does not run borrow checking, code generation, or linking for the main project.
Save your source, then check the complete program:
$ rock build
$ rock run
If the build fails, read the first diagnostic’s message and source labels before the final command-failure summary. A label can point to a module or dependency rather than the entry file. Editors and Diagnostics walks through both source errors and setup failures.
Editors and Diagnostics
An editor can show a problem while you type; a build checks whether the saved program can become an executable. This chapter sets up that feedback loop and shows how to read an error without guessing which line to change.
Start with a project
Complete Installation, then create a directory with these two files. Every Rock example in this chapter is a complete replacement for main.rk, not an addition to the previous example.
rock.toml:
[crate]
name = "diagnostics_demo"
version = "0.1.0"
[lib]
path = "main.rk"
Correct program: main.rk:
add: I64 -> I64 -> I64
add = left, right -> left + right
main = !->
add 20, 22 .println!
From the directory containing rock.toml, run:
$ rock run
42
The manifest lets the editor and build command agree on the program entry, path dependencies, and standard library. The + operator and println method in this example come from the standard library selected by the project tooling.
Connect Neovim
The separate rock.nvim plugin uses Neovim’s native LSP APIs and requires Neovim 0.11 or newer; no nvim-lspconfig dependency is needed.
Clone the repository if you do not already have a checkout:
$ git clone https://github.com/rock-lang-org/rock.nvim.git
Add the plugin repository root to runtimepath in init.lua before setup. Replace the path with your checkout’s absolute path:
vim.opt.runtimepath:prepend("/absolute/path/to/rock.nvim")
require("rock").setup()
These snippets install or load Lua integration, not the compiler. The toolchain installation provides rock-lsp. By default, the plugin uses rock-lsp on PATH. You can also set the command explicitly:
require("rock").setup({
cmd = { "rock-lsp" },
})
If Neovim’s environment does not include the command, use an absolute path in cmd instead. Start Neovim after activating the toolchain shell environment so that both the server and its compiler dependencies can be found.
Open main.rk, then inspect the integration:
:set filetype?
:checkhealth rock
:RockLspInfo
The filetype should be rock. The health check verifies the Neovim APIs, executable, setup, and active clients; it is not a compilation check. RockLspInfo shows the command and client root, which is useful when diagnosing a server-path problem.
With the cursor on left in the body of add in the correct program, request hover information:
:lua vim.lsp.buf.hover()
The server can show the parameter’s I64 type from the function signature. With the cursor inside add 20, 22, request call information:
:lua vim.lsp.buf.signature_help()
Signature help identifies the function’s parameters and the active argument. Commas trigger requests automatically in supporting clients; an explicit request is useful because Rock calls do not need parentheses. The plugin does not add custom key mappings, so these commands work without relying on a particular user’s keybindings.
Tree-sitter highlighting is separate. tree-sitter-rock supplies the grammar and queries, but the LSP setup above does not install that parser. The VS Code extension is also maintained separately.
Reading a source diagnostic
Intentional error: an integer annotation with a Boolean value.
main = !->
count: I64 = true
count.println!
Replace main.rk with this example and save it, then run rock build. Compilation should fail: true has type Bool, but the binding requires I64. The following reading order applies even as exact diagnostic wording and terminal layout evolve:
- Read the message and any bracketed family such as
type. The family describes the kind of problem, not a unique numbered error. - Read the displayed file path and source position. A project can contain several files; do not assume the report refers to the file currently open in the editor.
- Read the primary underline and its label. It marks the source expression or construct associated with the failure, not necessarily the only place you should edit.
- Read secondary labels, if present. They can show another constraint, an earlier access, or a declaration in another file.
- Read any note or help text before changing the program. Not every diagnostic includes these sections.
For this example, compare the annotation with the initializer. If count is meant to be a number, provide a number rather than changing an unrelated print call.
Corrected program:
main = !->
count: I64 = 3
count.println!
rock run prints 3. If the intended value were a flag instead, a Bool annotation and a suitable binding name would express that different intent. A compiler error tells you which constraints disagree; it cannot choose the intended design for you.
In Neovim, put the cursor at the reported location and open the full diagnostic text:
:lua vim.diagnostic.open_float()
The LSP message includes available notes and help. Secondary labels are sent as related diagnostic information, though how they are displayed or navigated depends on the client. The terminal build report remains useful when an editor only shows a short message.
Following an earlier borrow
Some errors concern the relationship between two operations. Here is a complete program with a shared reference that is still needed after an assignment.
Intentional error: changing a value while it is borrowed.
main = !->
mut number: I64 = 1
view = &number
number = 2
*view .println!
Run rock build to check this example. The assignment conflicts with the shared borrow: view is used afterward, so its access must remain valid across the assignment. A borrow-conflict report can label both the conflicting access and where the borrow was introduced. Read those labels together rather than deleting the highlighted assignment without considering the later use.
Corrected program: finish using the reference before assigning.
main = !->
mut number: I64 = 1
view = &number
*view .println!
number = 2
number.println!
This version prints 1 and then 2. The last use of view now precedes the assignment. See References and Borrowing for the ownership rules behind this correction.
The editor may show no error for the incorrect version: its main-project analysis checks the source frontend, including types, but does not run borrow checking, code generation, or linking. A clean editor diagnostic list is not proof that rock build will succeed.
When the location is not your source
Source-aware diagnostics keep labels associated with the file and source text they describe. A label in another module, a macro-related source location, or source retained with a dependency artifact is not an instruction to edit the same line number in main.rk. Follow the displayed path and read the related labels. Artifact-backed excerpts can describe the source used to build the artifact rather than your current working copy.
Not all failures have a meaningful source range. A missing file, invalid project configuration, unreadable artifact, or missing toolchain component can be reported with a file, project, artifact, or toolchain location instead. If no source text is available, a terminal report can fall back to a path and byte offsets; those offsets are not line numbers. In the editor, a project or toolchain failure may appear at the start of a buffer simply because it has no source range.
For setup failures, use this order:
- Run
rock buildfrom the manifest directory and read the original diagnostic before the final command-failure summary. - Check the named path: does
rock.tomlselect an existing entry, and do its path dependencies exist? - Check that the selected toolchain has its matching standard library; follow Installation if packaging or selection is incomplete.
- After correcting files or configuration, save and restart editor analysis if its report has not refreshed.
Do not change valid source to work around a missing toolchain. Likewise, an internal diagnostic is a compiler/tooling failure, not a request to guess at syntax changes; retain the full message and a small reproducing project when reporting it.
Keep feedback current
The server finds the nearest ancestor rock.toml and analyzes its entry graph. Unsaved open source buffers overlay that graph, while unopened source comes from disk. Files outside the graph are not automatically analyzed just because they are under the project directory. Dependency packages are consumed as artifacts, so save their changes before refreshing a consuming project.
Dependency resolution can build artifacts and is cached. There is no automatic manifest/dependency file-watcher refresh; after saving changes to project configuration or selecting another toolchain, save a Rock buffer or restart:
:RockLspRestart
Diagnostics are currently published to open files rather than collected into a complete workspace index. Hover and signature help require a successful analysis; after an invalid edit, the server retains the previous successful snapshot, so information can be stale. Refreshing one buffer also does not guarantee that all other buffers’ diagnostics have been cleared. Save and run rock build when editor feedback is incomplete or disagrees with your expectations.
The server currently provides diagnostics, hover, and signature help, not completion, go-to-definition, rename, code actions, or LSP formatting. Use rock format for the configured entry and inspect its changes, then rock build and rock run for the saved program. Tooling Reference lists server overrides and plugin options when you need more control.
Bindings, Reassignment, and Mutability
A binding gives a value a name. Rock does not use a let keyword; an assignment-shaped statement introduces a local when the name has not been seen in the current scope.
main = !->
answer = 42
name = "Ada"
answer.println!
name.println!
The compiler infers answer as I64 and name as &Str. The names are available only after their declarations. The println! calls are evaluated for effects; !-> discards the trailing value and makes main return unit (()), which maps to process exit status 0.
Type annotations
Put an annotation after the name and before =:
main = !->
answer: I64 = 42
ratio: F64 = 0.75
enabled: Bool = true
answer.println!
ratio.println!
enabled.println!
An annotation documents the intended type and gives inference an expected type. It is not a conversion: 0.75 already has a floating-point type in this example.
Observed reassignment behavior
The current compiler permits a later assignment to an existing local without requiring mut:
main = !->
count = 0
count = count + 1
count = count + 1
count.println!
The first count = 0 establishes the local. The next two assignments target that local and update its value. This is current observed behavior, not a promise that every future mutability rule will be identical.
mut is required where the operation explicitly requests a mutable place. A mutable borrow is the clearest example:
main = !->
mut value = 1
reference = &mut value
*reference = 2
value.println!
value must be marked mut because &mut value creates a mutable reference. The assignment through *reference changes the original place, so the output is 2.
The same requirement applies to a mutable receiver method. ^@ marks a method that borrows its receiver mutably:
struct Counter
< value: I64
impl Counter
^@increment = amount ->
self.value = self.value + amount
return
main = !->
mut counter = Counter
value: 3
counter.increment 4
counter.value.println!
counter is marked mut because increment needs a mutable receiver. The method changes the field from 3 to 7. Ordinary reassignment and mutable borrowing are separate checks: the former currently works without mut, while the latter requires it.
Reusing a name during a transformation
A parameter can be assigned a new value as a transformation step:
increment_and_print = value ->
value = value + 1
value.println!
value
main = !->
increment_and_print 4 .println!
The assignment changes the current value of value; it does not introduce a second parameter. Keeping the same name is useful when the old representation is no longer needed, but a new descriptive name is clearer when both values remain meaningful.
Destructuring
A pattern on the left can bind several parts of a tuple:
main = !->
pair = (10, "ten")
(number, word) = pair
number.println!
word.println!
pair = (10, "ten") creates the tuple. In (number, word) = pair, the pattern binds that tuple’s first element to number and its second element to word. The pattern must have a shape compatible with the value.
Patterns can mark an individual binding as mutable. Here only left is marked mut; the plain reassignment shown would also work without that marker:
main = !->
(mut left, right) = (1, 2)
left = left + right
left.println!
The output is 3, while right remains 2. The matching chapter shows how an enum pattern can both check a variant and bind its payloads.
Updating a field
A field path is also an assignment target. Declare the struct in the same module as the use:
struct Point
< x: I64
< y: I64
main = !->
point = Point
x: 1
y: 2
point.x = 5
point.x.println!
point.y.println!
The first assignment constructs point; point.x = 5 updates its existing field. Indexing and mutable receiver calls use the same general idea of selecting a place, but their trait and receiver contracts can impose additional requirements.
Unit-valued functions
Some functions exist for effects and return (). A unit-arrow body uses !->:
log_value: I64 -> ()
log_value = value !->
value.println!
main = !->
log_value 9
A !-> body evaluates every statement, including its trailing expression, then discards the trailing value and returns () automatically. Use an ordinary -> body when the trailing value should become the function result.
Common mistakes
- Saying that every assignment requires
mut; plain reassignment currently does not. - Forgetting
mutbefore a value passed to&mutor a^@receiver. - Destructuring a tuple with the wrong number of elements.
- Using a field or enum name without declaring its containing type in the module.
Types and Values
Rock is statically typed: the compiler determines a type for every expression before generating executable code. Local annotations are optional when inference is clear, while signatures make function boundaries explicit.
Primitive values
The primitive families used in ordinary programs are:
| Family | Types | Typical literals |
|---|---|---|
| Signed integers | I8, I16, I32, I64 | -5, 42 |
| Unsigned integers | U8, U16, U32, U64 | 0, 255 |
| Floating point | F32, F64 | 3.5, 0.125 |
| Boolean | Bool | true, false |
| Character | Char | 'R' |
| Borrowed string | &Str | "rock" |
| No meaningful value | () | () |
This complete program gives each of the commonly encountered literal forms an explicit type:
main = !->
signed: I64 = -5
unsigned: U64 = 255
fraction: F64 = 3.5
enabled: Bool = true
letter: Char = 'R'
text: &Str = "rock"
signed.println!
unsigned as I64 .println!
fraction.println!
enabled.println!
letter.println!
text.println!
Numeric literals receive an expected type from an annotation or a function signature. When no expected type is available, the compiler applies its numeric defaulting rules. Use fixed-width names in public signatures so callers do not have to guess.
Without an expected type, whole-number literals default to I64 and decimal literals default to F64:
show_default_integer: I64 -> ()
show_default_integer = value !->
value.println!
show_default_float: F64 -> ()
show_default_float = value !->
value.println!
main = !->
whole = 42
decimal = 2.5
show_default_integer whole
show_default_float decimal
The unannotated bindings are already defaulted before the calls are checked. The functions make those inferred types explicit to the reader, and the program prints 42 and 2.5. A surrounding annotation can choose another numeric type before defaulting occurs.
Borrowed and owned strings
A string literal is borrowed string data with type &Str. String is a separate prelude type that owns a heap allocation:
main = !->
borrowed: &Str = "hello"
owned = String::from borrowed
borrowed.println!
owned.println!
The literal can be used while its source is available; String::from copies the bytes into owned storage. Current string helpers operate on bytes, so an index or offset is not automatically a Unicode scalar-value position.
Annotations and signatures
Local annotations use a colon:
main = !->
enabled: Bool = true
distance: F64 = 12.5
enabled.println!
distance.println!
Function signatures use arrows. The following declaration and definition agree on two F64 parameters and an F64 result:
hypotenuse: F64 -> F64 -> F64
hypotenuse = a, b ->
a * a + b * b
main = !->
hypotenuse 3.0, 4.0 .println!
The arrows are read left to right as the function’s parameter types followed by its return type. Calls still use spaces and commas. Despite its name, this hypotenuse body computes the sum of the squares without taking a square root, so the call prints 25, not 5.
Compound type notation
Tuples, arrays, slices, references, pointers, generic applications, and function types use the following spellings. The example declares every local value it mentions:
enum DemoError
Missing
read_shared: &I64 -> I64
read_shared = value ->
*value
first_slice: &[I64] -> I64
first_slice = values ->
values[0]
apply: (I64 -> I64) -> I64 -> I64
apply = function, value ->
function value
main = !->
pair: (I64, Bool) = (7, true)
array: [I64; 4] = [1, 2, 3, 4]
maybe: Option I64 = Option::Some 5
result: Result I64, DemoError = Result::Ok 5
mut number: I64 = 7
shared: &I64 = &number
shared_value = read_shared shared
mutable_reference: &mut I64 = &mut number
pointer: *I64 = mutable_reference as *I64
unsafe *pointer = 9
doubled = apply value -> value * 2, 4
pair.0.println!
array[0].println!
shared_value.println!
doubled.println!
The tuple has two elements, the fixed array has four I64 elements, and Option and Result are generic prelude enums. [I64] is an unsized slice type, so a borrowed slice is written &[I64]. &T and &mut T are shared and mutable references. *I64 is a raw pointer type; the dereference and assignment in the example are inside unsafe because the compiler cannot prove a raw pointer’s validity.
Generic applications use spaces and commas rather than angle brackets. For example, Result I64, DemoError means Result with success type I64 and error type DemoError.
Casts
Use as for an explicit primitive or pointer conversion:
main = !->
byte: U8 = 7
count = byte as I64
fraction = count as F64
fraction.println!
The cast binds more tightly than binary operators, so each operand is converted before division:
average: I64 -> I64 -> F64
average = total, count ->
total as F64 / count as F64
main = !->
average 9, 2 .println!
A cast is still checked against the supported conversion rules; it is not a general escape from type checking. Pointer casts deserve the same care as raw-pointer dereferences.
Common mistakes
- Writing
Strwithout a reference; unsized string data is used as&Str. - Using a result type without declaring or importing its failure type. This chapter uses the locally declared
DemoError. - Confusing
[I64; 4]with[I64]: the first is fixed-size storage, the second is an unsized slice type. - Assuming
aschanges ownership; it changes representation or numeric type, not the ownership contract.
Functions and Calls
Functions are the main unit of behavior in Rock. A declaration has a name, an equals sign, parameters, an arrow, and a body:
add = left, right ->
left + right
main = !->
add 2, 3 .println!
The parameter names are introduced before the body is evaluated. The final expression of add is its result.
Signatures
A separate signature documents and checks a function’s interface:
clamp: I64 -> I64 -> I64 -> I64
clamp = value, low, high ->
if value < low
low
else if value > high
high
else
value
main = !->
clamp 120, 0, 100 .println!
The signature lists three parameter types followed by the return type. The branches of clamp all produce I64, so the definition satisfies the contract.
Calling functions
Arguments follow the function and are separated by commas. Each argument consumes a complete expression, including a nested call with its own comma-separated arguments:
add = left, right ->
left + right
double = value ->
value * 2
main = !->
double add 2, 3 .println!
The inner call owns the arguments 2, 3 and produces 5; the outer call receives that complete result and produces 10. Neither call needs grouping.
An inline callback can follow another argument: for_each 1..=3, number !-> number.println!. At this level, the comma separates call arguments, so the lambda has just the number parameter. Parenthesize a callback with multiple parameters, as in apply (left, right -> left + right), to keep its parameter commas inside the lambda.
Use ! for a zero-argument call:
main = !->
mut values = Vec::new!
values.push 10
length = values.len!
length.println!
Vec::new! calls an associated function through the type path. values.push 10 passes one argument to a method, while values.len! calls a method with no explicit arguments. The mut binding is required because push uses a mutable receiver.
Associated functions and paths
:: selects an item through a type or module path. Associated functions do not receive an existing instance:
main = !->
some = Option::Some 5
text = String::from 42
some_text = match some
Option::Some value => text
Option::None => String::from "missing"
some_text.println!
Option::Some constructs a prelude enum variant, and String::from constructs an owned string through the From trait’s associated function. A method would instead use a receiver and dot syntax, as some_text.println! does.
Receiver contracts
Methods declare how they use self. A shared receiver uses @; a mutable receiver uses ^@; a consuming receiver uses ~@:
struct Counter
< value: I64
impl Counter
@read = -> @value
^@add = amount !->
self.value = self.value + amount
~@finish = -> self.value
main = !->
mut counter = Counter
value: 4
current = counter.read!
counter.add 3
total = counter.finish!
current.println!
total.println!
The first call borrows counter shared. The second needs a mutable receiver, so the binding is marked mut. The final call consumes counter; after it returns, that value is no longer available for another use. The markers are part of the method’s type-level contract, not decoration.
Return values and early exit
With an ordinary -> body, the final expression is returned:
sign = value ->
if value < 0
then -1
else 1
main = !->
sign -8 .println!
sign 8 .println!
Use return when a condition should leave the function immediately:
first_nonzero = left, right ->
if left != 0
then left
else right
main = !->
first_nonzero 3, 9 .println!
first_nonzero 0, 9 .println!
For (3, 9), the first branch returns 3 and skips right. For (0, 9), execution reaches the final expression and returns 9.
Unit-returning functions
A function whose purpose is an effect can use !->:
greet: &Str -> ()
greet = name !->
"Hello, " + name .println!
main: () -> ()
main = !->
greet "Ada"
The trailing expression still runs, but its value is discarded and the function returns () automatically. An ordinary -> body returns its trailing value instead. The signature of this main is () -> (): it takes no arguments and returns unit. The runtime maps unit to process exit status 0, so ordinary programs should use main = !-> without a trailing 0.
Keep -> when main intentionally chooses an integer exit status:
> stdlib::env::args
main: () -> I64
main = ->
if args!.len! > 1
0
else
"expected at least one argument".println!
1
This program exits with status 0 when given a user argument and 1 otherwise. Both integers are meaningful results here; !-> would discard the selected status.
Receiver sections
A parenthesized expression beginning with . creates a one-parameter function whose parameter is the receiver. (.println!) is shorthand for value -> value.println!: it returns the method’s result.
Use (!.println!) when the callback should return () instead. It is shorthand for value !-> value.println!, which suits for_each:
main = !->
for_each 1..=3, (!.println!)
for_each (Option::Some 7), (!.println!)
This prints 1, 2, 3, and 7, each on its own line. The leading ! discards the section body’s result; the trailing ! calls println with no explicit arguments.
The body can include method arguments and chains. The entire chain runs before its final result is discarded:
main = !->
suffix = String::from "!"
for_each (Option::Some (String::from "hello")), (!.concat suffix.clone! .println!)
This prints hello!. As with an explicit lambda, values referenced by the body are captured, and ordinary receiver and ownership rules still apply.
Recursion
A function can call itself after its declaration:
factorial: I64 -> I64
factorial = n ->
if n <= 1
then 1
else n * factorial n - 1
main = !->
factorial 5 .println!
The recursive call decreases n, so the base case n <= 1 is eventually reached. Rock does not promise tail-call optimization; use a loop when unbounded recursion could exhaust the stack.
Multiline expressions
An operator may continue on a following line at one deeper indentation level:
total: I64 -> I64 -> I64 -> I64
total = base, tax, shipping ->
base
+ tax
+ shipping
main = !->
result = total 100, 8, 2
result.println!
The continuation lines remain one expression. Binding intermediate values is often clearer for complicated calls because each type and ownership boundary gets a name.
A method chain on following lines continues the completed call, including all its inline arguments:
as_option = value -> Option::Some value
main = !->
as_option 42
.map (+ 1)
.unwrap_or 0
.println!
This prints 43. .map acts on the option returned by as_option 42, not on the integer argument. No parentheses around the call are needed. To chain on an argument instead, group that argument explicitly or put it in a multiline argument block with its own more-indented chain.
Common mistakes
- Omitting commas between call arguments.
- Adding parentheses as if Rock used Rust-style
function(arguments)calls. - Calling a
^@method through a binding that is not markedmut. - Using a consuming
~@receiver and then trying to use the consumed value again.
Control Flow
Rock’s control-flow forms are expressions. They decide which work runs and, except for ordinary loop control, can produce a value for the surrounding expression.
if expressions
The multiline form uses indentation:
category: I64 -> &Str
category = temperature ->
if temperature < 0
"freezing"
else if temperature < 20
"cool"
else
"warm"
main = !->
category 12 .println!
The condition must be Bool. The compiler evaluates conditions from top to bottom and evaluates only the selected branch. Because category returns &Str, every reachable branch must produce a compatible string type.
For short alternatives, then and else fit on one line:
absolute: I64 -> I64
absolute = value ->
if value >= 0
then value
else -value
main = !->
absolute -7 .println!
while
Use while when the number of iterations depends on a condition:
sum_to_ten: I64
sum_to_ten = ->
index = 0
total = 0
while index < 10
total = total + index
index = index + 1
total
main = !->
sum_to_ten! .println!
The condition is checked before each iteration. The body adds the current index, then increments it. When index reaches 10, the body stops and the final total becomes the function result. Plain reassignment is currently permitted, so neither counter needs mut here.
Rock has no built-in ++ or --; write the state transition explicitly.
for
for pattern in expression iterates over a range, fixed array, or slice. start..end excludes the upper bound, while start..=end includes it:
main = !->
for number in 0..10
number.println!
Only ranges with both endpoints are finite loop inputs. Open-ended forms such as start.. remain useful for slicing but are rejected as for iterators.
For an inline action instead of a loop body, the prelude also provides for_each 0..10, (!.println!). The unit-returning receiver section (!.println!) means number !-> number.println!. It visits the same integers in order; the FizzBuzz project uses for_each with a pipeline.
The upper bound is exclusive, so this prints 0 through 9. The pattern is bound for each iteration:
main = !->
values = [10, 20, 30]
for value in values
value.println!
The current iteration facilities are smaller than Rust’s iterator ecosystem. A growable Vec is not directly the same as a fixed array; use its as_slice! view, a library traversal helper, or a while loop when a Vec is the source.
This complete example borrows a vector as a slice and iterates over that fixed view:
main = !->
mut values = Vec::new!
values.push 4
values.push 5
values.push 6
view = values.as_slice!
for value in *view
value.println!
values owns its growable allocation throughout. as_slice! borrows its initialized elements, and *view supplies the slice value accepted by for. The loop prints 4, 5, and 6; the borrow ends after its final use. Slice views and consuming traversal methods are covered in Arrays, Slices, and Tuples and Strings and Collections.
loop, break, and continue
loop repeats until control leaves it. break leaves the nearest loop, and continue starts its next iteration:
main = !->
index = 0
loop
index = index + 1
if index == 2
continue
if index == 4
break
index.println!
The output is 1 and 3: the second iteration uses continue, and the fourth uses break before printing. Expressions after break and continue are accepted syntactically, but ordinary control statements have the strongest end-to-end coverage today.
match
match is the natural control flow for enums and structural patterns:
enum Status
Ready
Waiting I64
label: Status -> &Str
label = status ->
match status
Status::Ready => "ready"
Status::Waiting seconds if seconds > 10 => "late"
Status::Waiting _ => "waiting"
main = !->
label Status::Ready .println!
label Status::Waiting 20 .println!
Arms are tested from top to bottom. The first Waiting arm handles values over 10; the second handles the remaining Waiting values. The enums chapter explains payload bindings, guards, and exhaustiveness in detail.
Common mistakes
- Returning incompatible branch types from an
ifexpression. - Forgetting the update in a
while, producing a condition that never changes. - Expecting
start..endto include the upper endpoint; usestart..=endwhen it should be included. - Putting a broad
matchpattern before a more specific guarded pattern. - Using
breakorcontinueoutside the nearest loop.
Structs
Structs combine named fields into a new type. They are useful when several values form one concept and should travel through a function together.
Declaring a struct
struct Point
< x: I64
< y: I64
The declaration introduces Point. Each indented line declares one field. The leading < exports a field from its module; an unmarked field is private to that module.
Generic parameters follow the type name:
struct Pair A, B
< first: A
< second: B
Pair A, B is one generic struct declaration. Each field uses one of the declared type parameters.
Constructing a value
Write the type name followed by an indented field block:
struct Point
< x: I64
< y: I64
main = !->
origin = Point
x: 0
y: 0
origin.x.println!
origin.y.println!
The field labels are part of the constructor syntax. Rock checks that every required field is present and that each expression has the declared type. Initialize every field explicitly; default field expressions are not reliably preserved by the current semantic pipeline.
Generic fields can often be inferred from their constructor expressions:
struct Pair A, B
< first: A
< second: B
main = !->
entry = Pair
first: "answer"
second: 42
entry.first.println!
entry.second.println!
Here the compiler infers A as &Str and B as I64 from the two field values.
Reading and updating fields
Use dot notation to read a field and to select a field assignment target:
struct Point
< x: I64
< y: I64
main = !->
point = Point
x: 2
y: 3
horizontal = point.x
point.x = 8
horizontal.println!
point.x.println!
horizontal keeps the value read before the update, so the output is 2 followed by 8. Whether a field read copies, borrows, or moves depends on the field type and the surrounding ownership context.
Adding behavior
An inherent impl groups associated functions and methods with a type:
struct Point
< x: I64
< y: I64
impl Point
new: I64 -> I64 -> Point
new = x, y ->
Point
x: x
y: y
@length_squared: I64
@length_squared = -> @x * @x + @y * @y
main = !->
point = Point::new 3, 4
value = point.length_squared!
value.println!
Point::new is an associated function because it is called through the type path. length_squared! is a method because it is called through a value. @ marks a shared receiver, and @x is shorthand for reading the receiver’s field.
Mutable and consuming receiver markers are ^@ and ~@:
struct Counter
< value: I64
impl Counter
^@increment = amount ->
self.value = self.value + amount
return
~@take = -> self.value
main = !->
mut counter = Counter
value: 2
counter.increment 5
total = counter.take!
total.println!
The mut binding is required for increment because its receiver is mutable. take! consumes the value, so counter cannot be used afterward.
Visibility
Export a declaration by placing < before it. Exported fields are independently marked:
< struct PublicPoint
< x: I64
< y: I64
main = !->
point = PublicPoint
x: 1
y: 2
point.x.println!
An explicit export path can also assemble a module facade, but inline exports are easiest to read for a small public type.
Common mistakes
- Omitting a required field or using the wrong field type.
- Treating
Point::newandpoint.newas the same call; the former is associated, the latter is a method-style call. - Forgetting that
@,^@, and~@describe different receiver ownership contracts. - Calling a mutable receiver method without a
mutbinding. - Assuming field defaults are reliable in the current compiler.
Enums and Pattern Matching
An enum is a value that can have one of several variants. Each variant may carry a different payload, but every variant belongs to the same enclosing type.
Declaring and constructing variants
enum Message
Quit
Move I64, I64
Write &Str
main = !->
stop = Message::Quit
movement = Message::Move 4, -2
text = Message::Write "hello"
stop_text = match stop
Message::Quit => "quit"
Message::Move x, y => "move"
Message::Write value => value
stop_text.println!
movement_text = match movement
Message::Quit => "quit"
Message::Move x, y => "move"
Message::Write value => value
movement_text.println!
text_text = match text
Message::Quit => "quit"
Message::Move x, y => "move"
Message::Write value => value
text_text.println!
Message::Quit has no payload, Message::Move has two I64 payloads, and Message::Write has one &Str payload. Construction qualifies the variant with the enum path so the compiler knows which type owns it.
Generic enums use the same declaration form:
enum Choice Left, Right
First Left
Second Right
main = !->
choice: Choice &Str, &Str = Choice::First "left"
label = match choice
Choice::First value => value
Choice::Second value => "right"
label.println!
The prelude’s Option T and Result T, E are ordinary generic enums of this kind. Option has Some T and None; Result has Ok T and Err E.
Matching variants
A constructor pattern checks the variant and binds each payload:
enum Message
Quit
Move I64, I64
Write &Str
describe: Message -> &Str
describe = message ->
match message
Message::Quit => "quit"
Message::Move x, y => "move"
Message::Write text => text
main = !->
describe Message::Move 4, -2 .println!
describe Message::Write "hello" .println!
For a Move, x and y are introduced only in that arm and have type I64. For a Write, text has type &Str. Every arm returns &Str, which satisfies the function signature.
Use _ when a value is deliberately ignored:
enum Message
Quit
Move I64, I64
Write &Str
is_quit: Message -> Bool
is_quit = message ->
match message
Message::Quit => true
_ => false
main = !->
is_quit Message::Quit .println!
is_quit Message::Write "hello" .println!
Qualified patterns such as Message::Quit are clearer than unqualified variant names and avoid collisions when several enums have similarly named variants.
Guards
An if guard runs after the structural pattern succeeds:
classify: I64 -> &Str
classify = value ->
match value
number if number < 0 => "negative"
0 => "zero"
_ => "positive"
main = !->
classify -3 .println!
classify 0 .println!
classify 8 .println!
The first arm binds number, then checks its guard. If the guard is false, matching continues with the next arm. Guards may use names introduced by their pattern.
Current status: A guard cannot currently consume a non-copy enum payload binding, even when matching a borrowed scrutinee. Match without the guard and put the condition inside the arm instead.
Payload bindings and wildcards
An enum payload can contain several values. A constructor pattern binds those values positionally, while _ ignores a payload that the arm does not need:
enum Packet
Data I64, &Str
Empty
describe: Packet -> &Str
describe = packet ->
match packet
Packet::Data number, text if number > 0 => text
Packet::Data _, _ => "nonpositive data"
Packet::Empty => "empty"
main = !->
describe Packet::Data 3, "three" .println!
describe Packet::Data -1, "negative" .println!
describe Packet::Empty .println!
The first arm binds both payloads and checks the guard. If the guard is false, the second arm matches any Data payload without introducing names. Tuple destructuring assignments are covered in the bindings chapter; the current compiler does not yet execute refutable tuple matches.
Matching through a reference
Matching an owned enum by value can move a non-copy payload into an arm binding. Matching through a shared reference borrows the payload instead:
enum Message
Quit
Write &Str
show_message: &Message -> ()
show_message = message ->
match *message
Message::Write text => text.println!
Message::Quit => "quit".println!
return
main = !->
message = Message::Write "borrowed payload"
show_message &message
The *message scrutinee is a borrowed match. The original message remains available after show_message returns because the match did not consume the enum.
Exhaustiveness and arm order
List every variant or finish with a wildcard:
enum Color
Red
Green
Blue
name: Color -> &Str
name = color ->
match color
Color::Red => "red"
Color::Green => "green"
Color::Blue => "blue"
main = !->
name Color::Blue .println!
The current compiler does not diagnose every incomplete enum match, so exhaustive source is an important programmer responsibility. Put specific patterns before broad ones; a wildcard first would make later arms unreachable in intent even where the compiler does not report it.
Pattern reference
The forms used in this chapter are:
| Form | Meaning |
|---|---|
_ | Ignore a value |
name | Bind a value |
mut name | Bind it for a mutable operation |
42, true, "x" | Match a literal |
Type::Variant value | Match an enum variant and bind its payload |
&pattern, &mut pattern | Match through a reference pattern |
Common mistakes
- Binding a payload name in one arm and trying to use it in another arm.
- Placing
_before a specific variant pattern. - Treating a guard as a replacement for the structural pattern; the pattern still has to match first.
- Consuming an owned enum when a shared-reference match would preserve it.
- Adding a variant without updating every exhaustive match.
Arrays, Slices, and Tuples
Rock has three useful shapes for a fixed collection of values:
[T; N]is an owned, fixed-size array.Tis the element type andNis part of the type.&[T]is a shared borrowed slice. It views contiguous storage without owning it.(A, B)and longer tuples are fixed products. Each position can have a different type.
Vec T is the standard library’s growable owned collection. Choose it when the number of elements changes at run time.
Fixed-Size Arrays
An array literal infers one element type and one length. The following program creates an array, annotates another array explicitly, and prints both values.
main = !->
scores = [10, 20, 30, 40]
labeled: [I64; 4] = [10, 20, 30, 40]
scores.println!
labeled.println!
The inferred type of scores is [I64; 4]: integer literals are inferred as I64 here, and the literal has four elements. The result is:
[10, 20, 30, 40]
[10, 20, 30, 40]
All elements must have the same type. A cast or an annotation can establish a narrower element type.
main = !->
bytes: [U8; 3] = [97, 98, 99]
first = bytes[0] as I64
first.println!
The type of bytes is [U8; 3], and first is I64 because the as I64 cast is explicit. The output is 97.
Repeat Literals
[value; length] evaluates value once and copies the result into a fixed-size array. The initializer therefore must be a value the compiler knows is copyable.
main = !->
zeros: [U8; 4] = [0; 4]
zeros[0].println!
zeros[3].println!
The output is two lines containing 0. A String is owned and is not copyable, so a repeated owned string is rejected. Use a loop and a fresh String::from call when each element must own a separate allocation.
Indexing and Mutation
Reading values[index] selects the standard library’s Index implementation. Assigning values[index] = replacement selects IndexMut and requires a mutable place.
main = !->
mut values = [10, 20, 30]
before = values[1]
values[1] = 99
after = values[1]
before.println!
after.println!
before and after are both inferred as I64; the output is 20 followed by 99. The array remains owned by values before and after the indexing operations. An index below zero or at least the array length terminates the program with an index out of bounds message.
Nested arrays use the same rule at each position.
set_second: &mut [I64] -> ()
set_second = row !->
row[1] = 7
first_row: &[I64] -> I64
first_row = row -> row[1]
main = !->
mut row = [1, 0]
second_row = [0, 1]
set_second &mut row
first_row &row .println!
first_row &second_row .println!
matrix = [row, second_row]
The output is 7 and 1. Nested fixed-array literals are valid, but the current implementation does not support chained indexing directly on an inner fixed-array value; borrow a row as a slice before indexing when a program needs that operation.
Slices
[T] is a dynamically sized view, not a standalone value type for fields or parameters. In ordinary safe code, write it behind a reference: &[T] for shared access or &mut [T] for exclusive mutable access.
Borrowing a fixed array as a slice does not move the array. The slice points at the same storage, so the owner remains responsible for the array.
sum_three: &[I64] -> I64
sum_three = values ->
values[0] + values[1] + values[2]
main = !->
values = [2, 4, 6]
total = sum_three &values
total.println!
values[0].println!
Before the call, values owns [2, 4, 6]. During the call, &values is coerced to &[I64]; sum_three borrows it and does not own it. After the call, values is still available. The output is 12 and 2.
A fixed array value is not coerced to a slice value by itself. Borrow it explicitly at the call site. This keeps the lifetime of the view tied to the array and makes ownership visible.
Mutable slices let a function update the owner’s storage without taking ownership of the array.
write_middle: &mut [I64] -> ()
write_middle = values !->
values[1] = 42
main = !->
mut values = [7, 8, 9]
write_middle &mut values
values[1].println!
Before the call, values is mutable and owned by main. During the call, &mut values gives write_middle the only active mutable access. After the call, the mutable borrow ends at its last use and values[1] prints 42.
Slices support the same indexing traits as arrays. A slice does not resize, allocate, or own the backing storage; use Vec T for those operations.
Range Slicing
Use a range to borrow a prefix, suffix, or middle portion of an array or slice. Range indexing selects a view through Index or IndexMut; the surrounding & or &mut makes the borrow explicit. No elements are copied or allocated.
> stdlib::slice::slice_len
inspect: &[I64] -> ()
inspect = values !->
slice_len values .println!
values[0].println!
main = !->
values = [10, 20, 30, 40]
inspect &values[..2]
inspect &values[1..]
inspect &values[1..=2]
The example prints lengths and first values for [10, 20], [20, 30, 40], and [20, 30]. slice_len returns an I64 element count without requiring a compiler intrinsic.
For a four-element sequence, the six range forms select these positions:
| Range | Meaning | Positions |
|---|---|---|
1..3 | Start included, end excluded | 1, 2 |
1..=3 | Both endpoints included | 1, 2, 3 |
..2 | Prefix ending before 2 | 0, 1 |
..=2 | Prefix through 2 | 0, 1, 2 |
2.. | Suffix starting at 2 | 2, 3 |
.. | Whole sequence | 0, 1, 2, 3 |
Ranges are first-class values, so a program can store one before indexing:
main = !->
values = [10, 20, 30, 40]
middle = 1..3
view = &values[middle]
view[0].println!
These values have the standard library type Range, whose endpoints are I64. Creating a range does not check it against any collection; indexing does.
Bounds and Empty Views
An exclusive range must satisfy 0 <= start <= end <= length. Equal endpoints produce an empty view, including length..length at the end of a sequence. An inclusive endpoint must identify an actual element, so it must be less than the length. ..=length is not a spelling for the whole sequence; use .. instead.
> stdlib::slice::slice_len
main = !->
values = [10, 20, 30, 40]
empty = &values[4..4]
slice_len empty .println!
The output is 0. Do not index element zero of an empty slice. Negative, reversed, or out-of-bounds ranges terminate the process with range out of bounds; slicing does not clamp endpoints or return an Option. Validate a count from untrusted input before constructing its view. A byte count returned by a successful standard-library read already fits the supplied buffer, which makes &buffer[..count] useful for safe I/O.
Mutable Subslices
A mutable range view updates the original storage:
update_middle: &mut [I64] -> ()
update_middle = values !->
mut middle = &mut values[1..3]
middle[0] = 77
main = !->
mut values = [10, 20, 30, 40]
update_middle &mut values
values[1].println!
values[2].println!
The output is 77 and 30. Index zero in middle is index one in values. Shared and mutable views obey ordinary borrowing rules: a view cannot outlive its owner, and conflicting access is rejected while an earlier borrow remains live. Do not assume the borrow checker can prove that two independently written ranges are disjoint.
Range syntax also supports bounded integer loops:
main = !->
mut total = 0
for value in 1..=3
total = total + value
total.println!
This prints 6; 1..3 would visit only 1 and 2. The current range-loop form requires both endpoints directly in the loop expression; open-ended slicing syntax is not an unbounded iterator API.
Growable Vectors
Vec T owns a heap allocation and can grow with push. Its receiver is mutable for mutation, and len! borrows it to inspect the current length.
main = !->
mut values = Vec::new!
values.push 10
values.push 20
values.push 30
values.len!.println!
values[1].println!
The output is 3 and 20. values owns all three elements before and after push; indexing borrows the selected element for the expression. Vec::get is the checked, non-panicking alternative when an index may be absent.
Vec T also supports the native range forms. A shared range produces &[T], while a mutable range produces &mut [T]; neither operation copies the selected elements.
> stdlib::slice::slice_len
main = !->
mut values = Vec::new!
values.push 10
values.push 20
values.push 30
tail = &values[1..]
slice_len tail .println!
tail[0].println!
This prints 2 and 20. Do not resize or move the vector while a borrowed view is still in use. For a single optional element rather than a range, use get:
main = !->
mut values = Vec::new!
values.push 10
match values.get 4
Option::Some value => *value .println!
Option::None => -1 .println!
The output is -1. get returns Option &I64, so the Some binding is a shared reference and *value reads the integer without moving it.
Tuples
A tuple stores a fixed number of values in a fixed order. Its type records every component.
main = !->
entry = (7, true, "rock")
entry.0.println!
entry.1.println!
entry.2.println!
The annotation specifies component types I64, Bool, and &Str; the output is 7, true, and rock. .0, .1, and .2 are tuple projections, not array indexes, so their positions are checked statically.
Functions can return tuples and callers can project the result.
swap: I64 -> I64 -> (I64, I64)
swap = left, right -> (right, left)
main = !->
pair = swap 10, 20
pair.0.println!
pair.1.println!
swap returns (20, 10), so the output is 20 and 10. A parenthesized expression such as (7) is grouping; a one-element tuple is not part of the current tuple syntax.
Choosing a Type
Use [T; N] when the length is fixed and the data should be stored inline in the owning value. Use &[T] or &mut [T] when a function needs a temporary view. Use Vec T when the collection owns a variable number of elements. Use (A, B) when the positions have different meanings or types.
The current slice limitations are deliberate and important: a bare [T] is rejected in ordinary struct fields and function parameters, and a fixed array must be borrowed before it can satisfy a slice parameter. The current Vec API is the standard growable sequence; a slice cannot be resized.
Ownership and Moves
Ownership answers one question for every value: which binding is responsible for using and eventually releasing it? Rock uses that information to make moves, borrows, and cleanup explicit without a garbage collector.
Copyable Values
The compiler knows that primitive values such as I64 and Bool can be copied. Assignment creates another independent value, so the original remains usable.
main = !->
first = 42
second = first
first.println!
second.println!
Both bindings contain 42, and the output is two lines containing 42. first and second do not share an owned allocation.
Rock does not expose a user-defined Copy trait. Repeat-array literals and other operations that duplicate a value use compiler-known copy facts. Do not assume that a generic T is copyable merely because a concrete call later happens to use I64.
Moving Owned Values
Heap-backed values such as String and Vec T own resources. Passing one by value transfers responsibility to the parameter or destination. The source binding cannot be used again after the move.
string_length: String -> I64
string_length = text -> text.len!
main = !->
name = String::from "Rock"
length = string_length name
length.println!
Before the call, name owns the string allocation. The call moves that String into string_length, whose parameter owns it while the function runs. After the call, name is moved and is not referenced again; the returned length is an independent I64. The output is 4.
The common mistake is to use the source after the call. If the caller needs the value afterward, pass a reference or clone it explicitly rather than pretending the move did not happen.
Borrowing Instead of Moving
A reference lets a function inspect an owned value without becoming its owner. Put the reference in the signature so callers can see the ownership contract.
measure: &String -> I64
measure = text -> text.len!
main = !->
name = String::from "Rock"
length = measure &name
length.println!
name.println!
Before the call, name owns the allocation. &name creates a shared borrow, and measure reads through it. After the borrow’s last use, name is still the owner and can be printed. The output is 4 and Rock.
Use a borrowed parameter when the operation only observes data. Use a by-value parameter when the operation must forward or consume the resource.
Consuming Standard-Library Operations
Many transformations consume an owned receiver. Option::map can move an owned payload into its callback, and Vec::map can move every owned element into a new vector.
string_length: String -> I64
string_length = text -> text.len!
main = !->
mut words = Vec::new!
words.push String::from "a"
words.push String::from "long"
lengths = words.map string_length
lengths[0].println!
lengths[1].println!
Before map, words owns both strings. words.map string_length consumes words; each String moves into string_length, and the new lengths owns the two I64 results. The output is 1 and 4. The old words binding is not used afterward.
Receiver Ownership
Methods state the same contract with a marker:
@methodborrows the receiver shared.^@methodborrows the receiver mutably.~@methodmoves the receiver.
struct Counter
< value: I64
impl Counter
@read: I64
@read = -> @value
^@set: I64 -> ()
^@set = value !->
self.value = value
~@take: I64
~@take = -> self.value
main = !->
mut counter = Counter
value: 1
counter.read!.println!
counter.set 9
counter.read!.println!
counter.take!.println!
read! leaves counter available because it borrows. set 9 requires a mutable binding and changes the owned value in place. take! consumes counter; the final I64 is returned, and counter is not used again. The output is 1, 9, and 9.
Explicit Cloning
Clone requests a second owned value. It is different from compiler-known copying and may allocate or recursively clone fields.
main = !->
first = String::from "owned"
second = first.clone!
first.println!
second.println!
first.clone! borrows first, allocates a second string, and returns it. Both bindings remain usable, so the output contains owned twice. Clone only when two owners are actually required; a shared borrow is cheaper when the second owner is unnecessary.
Scope and Cleanup
An owned value is cleaned up when its live scope ends. String, Vec, and Box use standard-library ownership and Drop implementations for this purpose.
make_message: () -> String
make_message = -> String::from "temporary"
main = !->
make_message!.len!.println!
make_message! returns ownership of a String to main. main owns it until the function ends, after printing 9. There is no implicit exception unwinding in Rock; errors are ordinary Result values and cleanup follows explicit control flow.
The Drop trait gives an owned type a cleanup method. Cleanup is automatic at scope end; calling the method explicitly is not required.
struct Resource
< id: I64
impl Drop for Resource
~@drop = -> return
main = !->
resource = Resource
id: 7
resource.id.println!
resource owns a Resource until main ends, when its Drop implementation runs. The output is 7.
Partial Moves and Current Limits
The checker tracks moves through fields, tuple elements, enum payloads, branches, and loops. Moving one non-copy field can leave the rest of an aggregate initialized, but direct cleanup types are intentionally conservative: moving part of a value that owns cleanup may be rejected rather than risk double cleanup. Destructure deliberately or borrow a field when the whole owner must remain usable.
Here, moving first does not prevent using the independent second field:
struct Names
< first: String
< second: String
take_length: String -> I64
take_length = text -> text.len!
main = !->
names = Names
first: String::from "Ada"
second: String::from "Lovelace"
take_length names.first .println!
names.second.len! .println!
take_length names.first moves only the first String. The second field remains initialized and prints length 8; trying to use names.first or move the complete names value afterward would be rejected. If Names itself implemented Drop, moving out one field could be rejected because cleanup would otherwise receive a partially initialized owner.
The practical rules are simple: after a by-value call, consider the argument moved; after a shared call, the owner remains available; after a mutable or consuming method, follow its marker; and use clone! when independent ownership is required.
References and Borrowing
A reference is a temporary access path to an existing value. &T is shared, while &mut T is exclusive and mutable. References never own the storage they point to.
Shared References
Create a shared borrow with &. Any number of compatible shared borrows can coexist, and a shared receiver cannot mutate or consume the referent while that borrow is live.
length: &String -> I64
length = text -> text.len!
main = !->
name = String::from "Rock"
first = length &name
second = length &name
first.println!
second.println!
name.println!
Before either call, name owns the allocation. Each &name creates a shared view for one call and length only reads it. After both calls, name is still available; the output is 4, 4, and Rock.
A shared reference can be copied as a reference, but every alias keeps the borrow live until its last use.
main = !->
mut number = 1
first = &number
second = first
*first .println!
*second .println!
number = 2
number.println!
The aliases are shared references to the same number. Once both aliases are last used, the borrow ends and the owner can be assigned. The output is 1, 1, and 2.
Mutable References
Create an exclusive borrow with &mut. The owner binding must be mutable, and no shared or second mutable borrow may overlap it.
increment: &mut I64 -> ()
increment = value !->
*value = *value + 1
main = !->
mut count = 4
increment &mut count
count.println!
Before the call, count owns 4. During the call, increment has the only mutable access and writes 5 through *value. After the call, the borrow ends and count remains the owner. The output is 5.
The common mistakes are taking &mut from an immutable binding, assigning to the owner while the mutable reference is still used, or creating a second overlapping mutable reference. The compiler reports these as mutability or borrow conflicts.
Dereferencing
*reference accesses the referenced place. A dereference in a value position reads the referent; a dereference on the left side of assignment writes it.
read: &I64 -> I64
read = value -> *value
main = !->
number = 17
borrowed = &number
result = read borrowed
result.println!
borrowed has type &I64, while result has type I64. The call passes borrowed to read, whose *value expression copies the integer from number because I64 is copyable. The output is 17.
Method lookup performs receiver borrowing and dereferencing for both inherent methods and trait methods. Write the simplest receiver expression first; do not add &* repeatedly unless the signature requires an explicit reference.
A shared receiver method demonstrates automatic borrowing:
struct Counter
< value: I64
impl Counter
@read: I64
@read = -> @value
main = !->
counter = Counter
value: 12
result = counter.read!
result.println!
read requires a shared receiver, but the call is written counter.read!, not (&counter).read!; method selection creates the shared borrow. Wrapper types that implement Deref use the same selection process to expose target methods. Keep an explicit &value in ordinary function calls, because automatic receiver borrowing applies to method selection rather than every argument position.
Reborrowing
A mutable reference can be temporarily reborrowed. The shorter reborrow must finish before the original mutable reference is used again.
show_and_set: &mut I64 -> ()
show_and_set = value !->
*value = 2
*value .println!
main = !->
mut value = 1
original = &mut value
temporary = &mut *original
show_and_set temporary
original first owns the exclusive access. temporary temporarily borrows through it and is moved into show_and_set, so neither the original reference nor the owner is used while the reborrow is active. The output is 2. Current borrow checking is conservative about using the original reference or owner while a named reborrow remains live; pass the reborrow to a short operation and end it before any later owner use.
Borrow Lifetimes
A reference cannot outlive its referent. Rock ends a borrow after its final use when control flow proves that the access is finished; it does not require every borrow to last until the textual end of the block.
main = !->
mut number = 1
view = &number
*view .println!
number = 2
number.println!
The shared borrow is last used by the first print. The assignment is therefore valid, and the output is 1 followed by 2.
References to call temporaries are not valid because the temporary is destroyed before the reference could be safely used. Return a value, borrow a caller-owned value, or bind an owned result before taking a reference.
References in Patterns
Matching through a shared reference borrows enum payloads rather than moving them. This lets a caller inspect an owned Option String repeatedly.
show_option: &Option String -> I64
show_option = option ->
match *option
Option::Some text => text.len!
Option::None => 0
main = !->
value = Option::Some String::from "hello"
first = show_option &value
second = show_option &value
first.println!
second.println!
The output is 5 and 5. In each Some arm, text is a shared reference to the payload, so the String remains owned by value.
Receiver Modes
Method receiver markers are reference contracts as well as ownership contracts.
struct Counter
< value: I64
impl Counter
@get: I64
@get = -> @value
^@increment: ()
^@increment = !->
self.value = self.value + 1
~@finish: I64
~@finish = -> self.value
main = !->
mut counter = Counter
value: 1
counter.get!.println!
counter.increment!
counter.get!.println!
counter.finish!.println!
get! creates shared access, increment! creates exclusive mutable access, and finish! moves the receiver. The output is 1, 2, and 2; the binding counter is unavailable after finish!.
Current Limitations
Bare [T] and bare Str are not ordinary standalone parameter or field types; use &[T], &mut [T], or &Str. Mutable references are not copyable. References to temporaries are rejected, and a shared borrow of a non-copy owned value cannot be dereferenced to move that value out. Raw pointers are a separate unsafe interface and are not needed for ordinary borrowing.
Generics
Generics let one declaration work for many concrete types while preserving static checking. A generic declaration is checked once, then specialized for the concrete types used by the program.
Generic Data Types
Type parameters follow the declaration name. A generic struct can store a value of its parameter type, and a generic enum can carry one of several parameter types.
struct Wrapper T
< value: T
enum Choice A, B
First A
Second B
main = !->
number = Wrapper
value: 7
text: Choice I64, &Str = Choice::Second "rock"
number.value.println!
match text
Choice::First value => value.println!
Choice::Second value => value.println!
The concrete type of number is Wrapper I64, so number.value is I64. The concrete type of text is Choice I64, &Str, and its selected payload is &Str. The annotation on text is needed because Second provides no value from which to infer the First payload type. The output is 7 and rock.
Generic arguments are written with spaces and commas, not angle brackets: Wrapper I64 and Choice I64, &Str.
Generic Functions
An unconstrained type parameter can be passed through a function without the function knowing its concrete representation.
identity: T -> T
identity = value -> value
main = !->
number = identity 42
flag = identity true
number.println!
flag.println!
At the first call, inference chooses T = I64; at the second, it chooses T = Bool. The output is 42 and true. The signature says that the result has exactly the same type as the argument.
Trait Bounds
A generic body may only use operations promised by its bounds. Put bounds in a where clause.
struct Box T
< value: T
same_box: Box T -> Box T -> Bool where T: Eq
same_box = left, right -> left.value == right.value
main = !->
left = Box
value: 7
left_again = Box
value: 7
equal = Box
value: 7
different = Box
value: 9
same_box left, equal .println!
same_box left_again, different .println!
The body uses ==, so T: Eq is required. In both calls inference chooses T = I64; the output is true and false. Without the bound, the generic body would have no promise that == exists.
The same rule applies to named methods. Here T: Show supplies show!, and the concrete argument type must implement that capability.
show_value: &T -> String where T: Show
show_value = value -> value.show!
main = !->
number = 42
show_value &number .println!
Here T is inferred as I64; I64 implements Show, and show_value returns a String containing 42.
Generic Implementations
An implementation can itself contain type parameters. This array implementation is selected only for arrays of length three, then its T is specialized from the receiver.
trait EchoArray
@echo: I64 -> I64
impl EchoArray for [T; 3]
@echo = value -> value
main = !->
values = [7, 8, 9]
values.echo 7 .println!
The receiver establishes T = I64 and matches the required array length of three. The trait declares the argument and result of echo as I64 independently of T; the method returns its argument, so the output is 7. A generic implementation is not a promise that every possible instantiation works; its own bounds and receiver shape still have to match.
Type Aliases
An alias gives an existing type another spelling. It does not create a new nominal type or change ownership.
type Number = I64
identity: Number -> Number
identity = value -> value
main = !->
identity 42 .println!
Number is an alias for I64, so identity accepts and returns I64. The output is 42. Use a struct or enum when a distinct type is required.
Associated Types
An associated type is chosen by each trait implementation. Callers can use Self::Output in a trait signature without repeating an output type as another generic parameter.
trait Projector
type Output
@project: Self::Output -> Self::Output
struct Identity
impl Projector for Identity
type Output = I64
@project = value -> value
main = !->
identity = Identity
identity.project 13 .println!
The implementation fixes Identity::Output to I64, so the argument and result of project are both I64. The output is 13. An implementation must define every associated type required by its trait.
Constructor Parameters
Ordinary generics abstract over complete types such as I64 or Option I64. Higher-kinded generics abstract over constructors such as Option or Result _, I64; the exact tested syntax is F _ in a where bound. Higher-Kinded Types begins with complete Option, Result, and Vec programs using that syntax. This chapter intentionally does not introduce a second spelling before those examples.
Common Mistakes and Limits
Do not call a method or operator in a generic body without a bound that supplies it. Do not leave a Result error parameter implicit: write Result I64, I64 or another concrete error type. Generic aliases and associated types preserve the underlying ownership rules; they do not make an owned value copyable or a borrowed value owned.
Traits and Methods
A trait names a capability. A type implements the trait by providing the required methods and associated types, and generic callers can require that capability with a bound.
Declaring and Implementing a Trait
This trait says that an implementor can calculate an area through a shared receiver. Two unrelated structs provide different implementations.
struct Circle
< radius: I64
struct Rectangle
< width: I64
< height: I64
trait Shape
@area: I64
impl Shape for Circle
@area = -> @radius * @radius * 3
impl Shape for Rectangle
@area = -> @width * @height
main = !->
circle = Circle
radius: 5
rectangle = Rectangle
width: 4
height: 6
circle.area!.println!
rectangle.area!.println!
circle.area! selects the Circle implementation and returns 75; rectangle.area! selects the Rectangle implementation and returns 24. The output is 75 and 24.
The @ marker is part of the method contract. The trait signature omits an explicit self parameter because the receiver is implicit; the body can use @radius, @width, or self.
Default Methods
A trait may provide a method body. An implementation can override one method and inherit the others.
struct Dog
< name: I64
struct Cat
< name: I64
trait Animal
@speak: I64
@speak = -> 0
@legs: I64
@legs = -> 4
impl Animal for Dog
@speak = -> 1
impl Animal for Cat
@speak = -> 2
main = !->
dog = Dog
name: 10
cat = Cat
name: 20
dog.speak!.println!
cat.speak!.println!
dog.legs!.println!
cat.legs!.println!
Dog and Cat override speak! but inherit legs!. The output is 1, 2, 4, and 4.
Inherent Implementations
An inherent implementation adds methods directly to a type without defining a trait. Associated functions have no receiver and are called through the type path.
struct Point
< x: I64
< y: I64
impl Point
new: I64 -> I64 -> Point
new = x, y ->
Point
x: x
y: y
@sum: I64
@sum = -> @x + @y
main = !->
point = Point::new 3, 4
point.sum!.println!
Point::new is an associated function and returns a Point; sum! is a shared method. The output is 7.
Receiver Ownership
The receiver marker also controls ownership.
struct Counter
< value: I64
impl Counter
@read: I64
@read = -> @value
^@set: I64 -> ()
^@set = value !->
self.value = value
~@take: I64
~@take = -> self.value
main = !->
mut counter = Counter
value: 1
counter.read!.println!
counter.set 9
counter.read!.println!
counter.take!.println!
@read shared-borrows, ^@set requires a mutable binding and writes in place, and ~@take consumes the receiver. The output is 1, 9, and 9; counter cannot be used after take!.
Associated Types
An associated type lets an implementation choose one output type for a trait.
trait Projector
type Output
@project: Self::Output -> Self::Output
struct Identity
impl Projector for Identity
type Output = I64
@project = value -> value
main = !->
identity = Identity
identity.project 13 .println!
The implementation defines Identity::Output = I64, so project accepts and returns I64. Omitting the required associated type is a compile-time error.
Generic Trait Bounds
A generic function can use a trait method or operator when its signature names the bound.
struct Box T
< value: T
same_box: Box T -> Box T -> Bool where T: Eq
same_box = left, right -> left.value == right.value
main = !->
first = Box
value: 7
second = Box
value: 7
same_box first, second .println!
The call specializes T to I64, and the Eq implementation for I64 supplies ==. The output is true. A bound is checked at the call site as well as in the generic body.
Focused Standard Traits
The standard library uses the same mechanism for Show and Clone. This type implements both: show! creates display text, while clone! creates independent owned data.
struct Label
< text: String
impl Show for Label
@show: String
@show = -> @text.clone!
impl Clone for Label
@clone: Label
@clone = ->
Label
text: @text.clone!
main = !->
original = Label
text: String::from "Rock"
copy = original.clone!
original.println!
copy.println!
The output is Rock twice. Cloning the String gives each Label its own allocation, while Show borrows the receiver. Other focused standard traits follow the same selection model: Arrays, Slices, and Tuples demonstrates Index and IndexMut, and Functions as Values demonstrates Fn, FnMut, and FnOnce with complete callbacks. Keep a trait small; combine bounds only when one operation genuinely needs several capabilities.
Coherence and Current Limits
Rock requires one unambiguous applicable implementation for a trait call. The following exhaustive program is intentionally rejected because Tag has two implementations of the same trait:
trait Named
@name: &Str
struct Tag
impl Named for Tag
@name = -> "first"
impl Named for Tag
@name = -> "second"
main = !->
tag = Tag
tag.name!.println!
Rock does not choose first or second by declaration order; remove one implementation to make the call coherent. Missing associated type definitions are rejected for the same reason that missing methods are rejected: an implementation must satisfy its complete trait contract. Trait selection also preserves ownership, so it cannot turn a borrowed receiver into an owned value or make an owned value copyable.
Operators
Rock does not assign permanent meanings to punctuation. The current source program and its explicit dependencies declare operator precedence and provide the function or trait implementation selected for the operand types. The standard library supplies familiar arithmetic, comparison, Boolean, and functional operators through the prelude.
Standard Operators
With the standard library prelude, ordinary operators are selected from the operand types.
main = !->
sum = 2 + 3
same = sum == 5
low = 2
high = 8
inside = sum >= low && sum <= high
sum.println!
same.println!
inside.println!
The inferred types of sum, same, and inside are I64, Bool, and Bool; the output is 5, true, and true. Operand and operator constraints determine these types without local annotations. If no implementation matches the operand types, compilation fails instead of applying a hidden built-in conversion.
Declaring Precedence and Defining a Function
An infix declaration gives a symbol a precedence from 0 through 255. The function with the same symbol supplies its meaning.
infix 9 %%
%%: I64 -> I64 -> I64
%% = left, right -> left - right
main = !->
result = 40 %% 2
result.println!
The declaration makes %% parse as an infix operator at precedence 9; the function returns left - right, so the output is 38. A dependency can transport its declarations, which is why two dependencies should not assign conflicting precedence to the same symbol.
Trait-Selected Operators
An operator can be a trait method. This example defines a new operator and selects an implementation from the left operand’s type and the right operand’s type.
infix 9 %%
struct Boxed
< value: I64
trait Combine Rhs
@%%: Rhs -> I64
impl Combine Boxed for Boxed
@%% = other -> @value + other.value
main = !->
left = Boxed
value: 40
right = Boxed
value: 2
result = left %% right
result.println!
The receiver is Boxed, the right operand is Boxed, and the selected implementation returns 42. The compiler does not need to know a special meaning for %%; the trait declaration and implementation provide it.
Operators on Borrowed Values
For a method-backed binary operator, the left operand is its receiver. The same receiver borrowing and dereferencing used by ordinary method calls applies during operator lookup.
main = !->
number = 4
borrowed = &number
incremented = borrowed + 1
even = borrowed % 2 == 0
incremented.println!
even.println!
number.println!
The output is 5, true, and 4. Although borrowed has type &I64, method lookup can select the I64 implementations of + and %. Those implementations use shared receivers (@) and read the integer through self, so the caller does not need to write *borrowed. The original integer remains available.
This also lets operator sections such as (+ 1) work as callbacks receiving &I64, as shown in the collection transformations. Receiver adjustment does not make references and values interchangeable in every argument position; the selected operator’s signature still determines its requirements.
Unary Operators
Prefix operators use the same type-directed model. The standard library provides negation for numeric values and logical not for booleans where an implementation exists.
main = !->
value = 7
condition = true
negative = -value
opposite = !condition
negative.println!
opposite.println!
The output is -7 and false. User-defined types can also implement the Neg and Not traits with @- and @! methods and an associated Output type.
Operator Sections
Parenthesized operator sections create functions. (+ 2) waits for its left operand and then adds 2.
increment: I64 -> I64
increment = (+ 2)
main = !->
increment 5 .println!
increment has type I64 -> I64, and the output is 7. Use an explicit lambda when the missing side or ownership is not obvious.
Functional Operators
The prelude also exports standard-library operators for function application through a value, mapping, binding, and fallback. The function signatures and values determine the concrete carrier types.
increment: I64 -> I64
increment = value -> value + 1
keep_even: I64 -> Option I64
keep_even = value ->
if value % 2 == 0
then Option::Some value
else Option::None
main = !->
mapped = Option::Some 4 <&> increment
bound = Option::Some 4 >>= keep_even
fallback = Option::None <|> Option::Some 9
mapped.unwrap_or 0 .println!
bound.unwrap_or 0 .println!
fallback.unwrap_or 0 .println!
The output is 5, 4, and 9. <&> maps a function over Option, >>= calls a function that returns another Option, <|> chooses the first present value, and |> passes a value to a function. These meanings are standard-library definitions, not compiler fallbacks.
Parsing and Common Mistakes
Function application has precedence 8. Operators above that precedence become part of the final argument, while operators at or below it operate on the call result. This keeps arithmetic arguments concise and functional composition outside the call.
double: I64 -> I64
double = value -> value * 2
main = !->
doubled_sum = double 2 + 2
adjusted_result = (double 2) + 2
doubled_sum .println!
adjusted_result .println!
Option::Some 2 <&> (+ 2) .unwrap_or 0 .println!
+ has precedence 9, so double 2 + 2 means double (2 + 2) and prints 8. Parentheses make (double 2) + 2 apply addition to the call result and print 6. <&> has precedence 8, so it operates on Option::Some 2; the spaced dots then apply unwrap_or and println! to each complete result. A tight dot such as value.method! still binds directly to value.
An adjacent & starts a borrowed call argument: read &value means read (&value), whereas a spaced & can be an infix operator. A trailing ? after that argument applies to the whole call: read &value? means (read (&value))?, not read (&(value?)). See the complete borrowed-call example. Parenthesize an argument explicitly when propagation should happen inside it.
Operators are library design. Use a symbol for a compact operation with a stable, documented reading direction; use a named function for a domain action whose meaning is not obvious from punctuation.
Functions as Values
Functions and lambdas are values. They can be bound to names, passed as arguments, returned from other functions, and stored in generic containers. Their function type lists parameter types followed by the return type.
Lambdas
A lambda has parameters, an arrow, and a body. The following function has the explicit signature I64 -> I64.
double: I64 -> I64
double = value -> value * 2
main = !->
double 5 .println!
The output is 10. double is a named function value; the expression after the equals sign is still a lambda-shaped function body.
Pass a lambda directly when its purpose is local.
main = !->
mut values = Vec::new!
values.push 1
values.push 2
mapped = values.map value -> value + 10
mapped[0].println!
mapped[1].println!
Vec::map consumes values, moves each I64 into the callback, and returns a new Vec I64. The output is 11 and 12.
Higher-Order Functions
A higher-order function accepts another function as a value.
apply: (I64 -> I64) -> I64 -> I64
apply = function, value -> function value
increment: I64 -> I64
increment = value -> value + 1
main = !->
apply increment, 4 .println!
The parameter function has type I64 -> I64, value has type I64, and the result is I64. The output is 5.
Applying a capturing lambda uses the same function type at the call site, but the value also carries its captured environment.
apply: (I64 -> I64) -> I64 -> I64
apply = function, value -> function value
main = !->
offset = 10
add_offset = value -> value + offset
result = apply add_offset, 5
result.println!
add_offset captures offset by shared access because it only reads it. The output is 15. The capture cannot outlive the value it refers to.
Callable Bounds
The standard library expresses callback requirements with callable traits. Call a trait-bound parameter with ordinary function-call syntax: function value, or function! when there are no arguments. This works for functions, closures, and user-defined types implementing the callable traits.
| Bound | How a call uses the callable |
|---|---|
Fn Args, Ret | Borrows it through shared access. |
FnMut Args, Ret | Borrows it through mutable access, allowing captured state to change. |
FnOnce Args, Ret | Takes it by value, allowing captured owned values to be consumed. |
Fn implies FnMut and FnOnce; FnMut implies FnOnce. Choose the least restrictive bound your implementation needs: FnOnce for a single consuming invocation, FnMut for repeated invocations that may mutate state, and Fn when shared access is required. Custom callable implementations must also implement their supertraits.
apply_mut: M -> I64 -> I64 where M: FnMut I64, I64
apply_mut = mut function, value -> function value
main = !->
mut calls = 0
callback = value ->
calls = calls + 1
value + calls
first = apply_mut callback, 10
first.println!
calls.println!
The callback’s capture is mutable, so apply_mut calls it through FnMut. Its mut function parameter permits that mutable borrow. The outputs are 11 and 1. The invocation itself borrows the callback, but passing the callback into apply_mut transfers ownership of this non-copy value.
Pass a mutable reference to keep using a mutable callback afterward. The same generic bound accepts &mut F when F implements FnMut.
twice: F -> I64 -> I64 where F: FnMut I64, I64
twice = mut function, value ->
function value
function value
main = !->
mut count = 0
mut callback = value ->
count = count + 1
value + count
result = twice &mut callback, 10
result.println!
callback 10 .println!
The output is 12 followed by 13. Each invocation reborrows the callable rather than moving it. Shared references similarly support callbacks implementing Fn. A callback that consumes an owned capture only supports FnOnce and cannot be invoked again after it is moved.
The explicit .call, .call_mut, and .call_once methods remain available, but ordinary calls select the appropriate protocol automatically. Callable bounds use () for no arguments, the argument type for one argument, and a tuple of argument types for multiple arguments; explicit methods receive that argument pack as one value.
Call Holes
An underscore in a call argument creates a function waiting for that argument. The holes are filled from left to right.
combine: I64 -> I64 -> I64 -> I64
combine = first, middle, last -> first + middle + last
main = !->
add_ends = combine 10, _, 30
answer = add_ends 2
fill_middle = combine _, 5, _
second_answer = fill_middle 1, 9
answer.println!
second_answer.println!
combine 10, _, 30 creates middle -> 10 + middle + 30, so answer is 42. combine _, 5, _ creates a two-argument function, so second_answer is 15. Use a named lambda when the hole order would obscure ownership or evaluation order.
Curried Functions
~> declares a curried function. Supplying fewer arguments returns a function for the remaining arguments.
add: I64 -> I64 -> I64
add = left, right ~> left + right
main = !->
increment = add 1
first = increment 2
second = add 1, 2
first.println!
second.println!
add 1 owns the captured left = 1 in the returned function; increment 2 supplies the remaining argument and returns 3. The direct two-argument call also returns 3, so the output is 3 and 3.
Operator Sections
An operator section is another compact function value. (+ 2) creates a function that adds 2 to its input.
increment: I64 -> I64
increment = (+ 2)
main = !->
increment 5 .println!
The output is 7. Use an explicit lambda such as value -> 2 - value when the direction of the operation is not visually obvious.
Methods as Values
A zero-argument method name is a function value until ! calls it. The value captures the receiver, so its lifetime follows the receiver’s ownership.
struct Counter
< value: I64
impl Counter
@get: I64
@get = -> @value
main = !->
counter = Counter
value: 21
get = counter.get
first = get!
second = counter.get!
first.println!
second.println!
get is a callable value with a shared receiver. Both calls return 21, and the output is 21 and 21; the receiver remains available because get is shared. A mutable or consuming method value carries the corresponding borrow or move restriction.
Current Limits
Function values obey the same ownership rules as explicit calls. A closure that borrows a local value cannot outlive that value, and a method value with a borrowed receiver cannot be detached from the receiver’s lifetime. Use an owned capture when an API requires a callback to outlive the current borrow.
Option, Result, and ?
Rock represents expected absence and recoverable failure with ordinary enum values. There are no exceptions or implicit stack unwinding. A function that may not produce a value states its carrier in its return type.
Optional Values
Option T has two constructors: Option::Some carries a T, and Option::None carries no value.
find_even: I64 -> Option I64
find_even = value ->
if value % 2 == 0
then Option::Some value
else Option::None
main = !->
present = find_even 6
absent = find_even 7
match present
Option::Some value => value.println!
Option::None => -1 .println!
match absent
Option::Some value => value.println!
Option::None => -1 .println!
present has type Option I64 and contains Some 6; absent has the same type and contains None. The output is 6 and -1. Matching makes both paths explicit.
Results and Concrete Errors
Result T, E carries either Result::Ok T or Result::Err E. Always choose a concrete error type at a public boundary; the following function uses I64 as its error code.
divide: I64 -> I64 -> Result I64, I64
divide = numerator, denominator ->
if denominator == 0
then Result::Err 1
else Result::Ok numerator / denominator
main = !->
successful = divide 10, 2
failed = divide 10, 0
match successful
Result::Ok value => value.println!
Result::Err error => error.println!
match failed
Result::Ok value => value.println!
Result::Err error => error.println!
The success path is Ok 5, and the failure path is Err 1; the output is 5 and 1. The error is data that the caller can inspect, transform, or return.
Domain errors can use a dedicated enum rather than an unstructured integer.
enum ParseError
Empty
Negative
impl Show for ParseError
@show = ->
match *self
ParseError::Empty => String::from "empty input"
ParseError::Negative => String::from "negative input"
parse_nonnegative: I64 -> Result I64, ParseError
parse_nonnegative = value ->
if value < 0
then Result::Err ParseError::Negative
else Result::Ok value
main = !->
result = parse_nonnegative -3
match result
Result::Ok value => value.println!
Result::Err error => error.println!
parse_nonnegative returns Err ParseError::Negative; the Show implementation turns that structured error into negative input, which is the output.
Propagating with ?
Postfix ? unwraps a successful Option or Result value and returns early from the enclosing function on the failure branch.
twice_present: Option I64 -> Option I64
twice_present = value ->
number = value?
Option::Some number * 2
main = !->
present = twice_present Option::Some 4
absent = twice_present Option::None
match present
Option::Some value => value.println!
Option::None => -1 .println!
match absent
Option::Some value => value.println!
Option::None => -1 .println!
For the first call, value? produces 4 and the function returns Some 8. For the second, it returns None before the multiplication. The output is 8 and -1.
The enclosing return type must implement FromResidual for the propagated carrier’s residual. With the standard implementations used here, propagation stays within Option or within a compatible Result.
positive: I64 -> Result I64, I64
positive = value ->
if value < 0
then Result::Err 1
else Result::Ok value
double_positive: I64 -> Result I64, I64
double_positive = value ->
number = positive value?
Result::Ok number * 2
main = !->
success = double_positive 4
failure = double_positive -4
success.unwrap_or 0 .println!
failure.unwrap_or 0 .println!
The successful path returns Ok 8; the failing path returns Err 1, and both are handled with unwrap_or 0, so the output is 8 and 0. Ownership follows ordinary return control flow: a value moved into a failed operation is not restored by ?.
Borrowed Call Arguments
A trailing ? propagates a call’s result even when its last argument is borrowed:
read: &I64 -> Result I64, I64
read = value -> Result::Ok *value
compute: () -> Result I64, I64
compute = ->
value = 21
number = read &value?
Result::Ok number * 2
main = !->
compute! .unwrap_or 0 .println!
read &value? means (read (&value))?: it borrows value, calls read, then propagates the returned Result. It does not apply ? to the integer. Keep & adjacent to the borrowed operand to distinguish it from a spaced infix &. This program prints 42.
Call Results and Continuations
A ? following an unparenthesized call finishes that call and propagates its result before any following operator, method chain, index, cast, or further call. This rule is independent of the following operator’s precedence:
checked: I64 -> Result I64, &Str
checked = value ->
if value < 0 then Result::Err "negative" else Result::Ok value
sum_checked: I64 -> I64 -> Result I64, &Str
sum_checked = left, right ->
total = checked left? + checked right?
Result::Ok total
main = !->
sum_checked 20, 22 .println!
sum_checked -1, 22 .println!
This prints Ok(42) and Err(negative). Each ? acts on a result returned by checked, not on an integer argument. The first error returns from sum_checked before evaluating the rest of the addition.
To propagate inside an argument instead, keep ? inside that argument’s parentheses: checked (checked left?) evaluates and propagates the inner call before calling the outer one. This grouping is also necessary when a propagated argument precedes another comma-separated argument. Parenthesized expressions, array elements, indices, and explicit nested function bodies retain their own expression boundaries.
Consuming Combinators
map changes a successful payload while preserving the carrier. and_then calls a function that returns another carrier. These operations consume an owned receiver so an owned payload can move into the callback.
increment: I64 -> I64
increment = value -> value + 1
keep_even: I64 -> Option I64
keep_even = value ->
if value % 2 == 0
then Option::Some value
else Option::None
main = !->
mapped = Option::Some 4 .map increment
chained = Option::Some 4 .and_then keep_even
flattened = Option::Some Option::Some 9 .flatten!
mapped.unwrap_or 0 .println!
chained.unwrap_or 0 .println!
flattened.unwrap_or 0 .println!
mapped is Some 5, chained is Some 4, and flattened is Some 9; the output is 5, 4, and 9. The three source Option values are consumed independently and are not reused afterward.
ok_or converts an Option T into a Result T, E by supplying the error for the None case:
main = !->
result = Option::Some 4 .ok_or 1
result.unwrap_or 0 .println!
Result has the same shape for successful values and preserves the concrete error type. The annotations below specify the error type, which cannot be inferred from Result::Ok alone.
increment: I64 -> I64
increment = value -> value + 1
main = !->
mapped: Result I64, I64 = Result::Ok 4 .map increment
chained: Result I64, I64 = Result::Ok 4 .and_then value -> Result::Ok value * 2
failed = Result::Err 7 .map increment
mapped.unwrap_or 0 .println!
chained.unwrap_or 0 .println!
failed.unwrap_or 0 .println!
The output is 5, 8, and 0; the error 7 is preserved in failed even though unwrap_or chooses the fallback for printing.
Functional Operators
The prelude exports operator spellings for the same stdlib operations. The named methods above are usually clearer while learning the carriers.
increment: I64 -> I64
increment = value -> value + 1
keep_even_option: I64 -> Option I64
keep_even_option = value ->
if value % 2 == 0
then Option::Some value
else Option::None
main = !->
mapped = Option::Some 4 <&> increment
chained = Option::Some 4 >>= keep_even_option
fallback = Option::None <|> Option::Some 9
converted = Option::Some 4 !> 1
mapped.unwrap_or 0 .println!
chained.unwrap_or 0 .println!
fallback.unwrap_or 0 .println!
converted.unwrap_or 0 .println!
<&> maps, >>= binds, <|> chooses a fallback, !> converts an Option to a Result, and |> passes a value to a function. The output is 5, 4, 9, and 4. These meanings are standard-library definitions, not compiler-owned special cases.
Flat Sequencing with do
Use a do expression when later fallible operations need values from earlier ones. <- binds a successful payload, and the rest of the block becomes the callback passed to bind. With Result, an error skips that callback and becomes the block’s result.
positive: I64 -> Result I64, I64
positive = value ->
if value < 0 then Result::Err 1 else Result::Ok value
sum_positive: I64 -> I64 -> Result I64, I64
sum_positive = left, right -> do
first <- positive left
second <- positive right
total = first + second
pure total
main = !->
sum_positive 16, 6 .unwrap_or -1 .println!
sum_positive -1, 6 .unwrap_or -1 .println!
This prints 22 and -1. An ordinary = statement keeps Rock’s usual binding or reassignment behavior. pure total constructs the successful result; pure is an ordinary prelude function whose carrier is inferred from the function signature. The last expression is returned unchanged, so you can also end with another fallible call or an explicit Result::Ok value.
The same sequencing works with Option:
main = !->
answer = do
first <- Option::Some 20
_ <- Option::Some 0
second <- Option::Some 22
pure (first + second)
answer.unwrap_or -1 .println!
This prints 42. _ <- action discards a successful payload. A nonfinal bare expression does the same: it is sequenced through bind, rather than treated as an ordinary statement. In particular, println! returns an I32, not a carrier; print the completed result outside the block, as above, or explicitly assign an eager side effect’s result to a local inside a continuation.
do is syntax sugar for calls to the bind function visible at each sequencing step. The prelude supplies the standard monadic implementation, but ordinary imports and explicit local shadowing can select another function. There is no implicit error conversion, automatic lifting of the final value, or new runtime effect system. Existing closure capture and ownership rules apply; the standard bind requires a callback that supports FnMut.
A block must be indented and end with an expression. Bind targets currently support an identifier or _. Use <- instead of ? directly in the block, and use the final expression instead of return; break and continue cannot leave the block. Explicit nested functions and loops retain their own control-flow rules.
Defining a Custom ? Carrier
? is not limited to the two standard enums. A custom carrier participates by implementing Try and FromResidual. Try::branch separates a continuing payload from a short-circuit residual; FromResidual::from_residual rebuilds the enclosing carrier when propagation stops.
enum MyFlow T
Value T
Stop I64
enum MyResidual
Stop I64
impl Try for MyFlow T
type Output = T
type Residual = MyResidual
~@branch = ->
match self
MyFlow::Value value => ControlFlow::Continue value
MyFlow::Stop code => ControlFlow::Break MyResidual::Stop code
impl FromResidual MyResidual for MyFlow T
from_residual = residual ->
match residual
MyResidual::Stop code => MyFlow::Stop code
next: Bool -> MyFlow I64
next = should_continue ->
if should_continue
then MyFlow::Value 41
else MyFlow::Stop 7
compute: Bool -> MyFlow I64
compute = should_continue ->
value = next should_continue?
MyFlow::Value value + 1
main = !->
match compute true
MyFlow::Value value => value.println!
MyFlow::Stop code => code.println!
match compute false
MyFlow::Value value => value.println!
MyFlow::Stop code => code.println!
The successful call unwraps Value 41, adds one, and prints 42. The failing call turns Stop 7 into ControlFlow::Break, reconstructs MyFlow::Stop 7, returns early from compute, and prints 7. Both implementations are ordinary public trait contracts; application code does not need compiler-only declarations.
Current Limitations
Option and Result remain the everyday carriers because their residual conversions are already provided by the standard library. A custom source carrier and the enclosing return carrier may differ, provided the return carrier implements FromResidual for the source’s exact residual type. The compiler selects that implementation; it does not invent a conversion between unrelated errors. Keep Result error parameters concrete and implement any missing conversion explicitly. Rock has no exception syntax; all failure propagation remains visible in a return type and ordinary control flow.
Higher-Kinded Types
Ordinary generics abstract over a complete type such as I64. Higher-kinded types abstract over a type constructor such as Option, Vec, or a partially applied Result. Rock’s prelude provides standalone functions and consuming receiver methods for working with these constructors; ordinary calls do not need a qualified trait path.
Kinds and Constructor Holes
The notation below is type-level documentation, not a Rock program:
I64 : Type
Option : Type -> Type
Result : Type -> Type -> Type
Result _, I64 : Type -> Type
Option waits for one type argument. Result _, I64 fixes the error type to I64 and waits for its success type. The underscore is a constructor hole, not a runtime value.
Functor and F _
The Functor trait maps a callback over a constructor while preserving its shape. The bound F _: Functor says that F is a unary constructor with a Functor implementation.
map_any: M -> F A -> F B where F _: Functor, M: FnMut A, B
map_any = mapper, value -> fmap mapper, value
increment: I64 -> I64
increment = value -> value + 1
double: I64 -> I64
double = value -> value * 2
make_values: () -> Vec I64
make_values = ->
mut values = Vec::new!
values.push 1
values.push 2
values.push 3
values
main = !->
option_result = map_any increment, Option::Some 4
vector_result = make_values!.fmap double
option_result.println!
vector_result.println!
At the first call, F = Option, A = I64, and B = I64; at the second, the receiver supplies F = Vec. The standalone fmap mapper, value and receiver form value.fmap mapper delegate to the same constructor’s Functor implementation. The output is Some(5) and [2, 4, 6]. Both input carriers are consumed by their mapping operation.
For Result, a function signature can fix the error type while the call still infers the constructor:
map_result: M -> Result A, I64 -> Result B, I64 where M: FnMut A, B
map_result = mapper, value -> fmap mapper, value
increment: I64 -> I64
increment = value -> value + 1
main = !->
success = map_result increment, Result::Ok 4
failure = map_result increment, Result::Err 9
success.println!
failure.println!
Here the inferred constructor is Result _, I64: the error type stays fixed while fmap changes the success payload. It has the required unary kind, while bare Result would still require two arguments. The output is Ok(5) and Err(9). When explicit dispatch is useful, the same operation can be written (Result _, I64)::Functor::fmap mapper, value; that qualification is not required at ordinary call sites.
Applicative Values
Applicative adds pure for lifting a value and ap for applying a wrapped function to a wrapped argument.
repure_any: F I64 -> F I64 where F _: Applicative
repure_any = ignored -> pure 2
apply_any: F (I64 -> I64) -> F I64 -> F I64 where F _: Applicative
apply_any = wrapped_function, wrapped_value ->
ap wrapped_function, wrapped_value
increment: I64 -> I64
increment = value -> value + 1
main = !->
lifted = repure_any Option::Some 0
wrapped = Option::Some increment
applied = apply_any wrapped, lifted
applied.println!
The inferred constructor is F = Option. repure_any ignores its input carrier and calls pure 2, producing Some 2; then ap applies increment, producing Some 3. The output is Some(3). The receiver form is wrapped_function.ap wrapped_value; its receiver is the carrier holding the function.
Unlike fmap, pure has no input carrier from which to infer its constructor. Its expected return type must supply that information. In repure_any, the return signature supplies F I64, and the caller fixes F through the argument. A concrete return signature works too:
make_option: () -> Option I64
make_option = -> pure 2
make_result: () -> Result I64, I64
make_result = -> pure 3
main = !->
make_option!.println!
make_result!.println!
This prints Some(2) and Ok(3). Without such context, pure 2 cannot choose a carrier. pure remains a standalone or associated function because it creates a carrier rather than consuming an existing one.
Monad Binding
Monad sequences a carrier with a callback that returns another value of the same constructor.
bind_any: F I64 -> M -> F I64 where F _: Monad, M: FnMut I64, (F I64)
bind_any = value, callback -> value.bind callback
add_two: I64 -> Option I64
add_two = value -> Option::Some value + 2
main = !->
start = Option::Some 3
result = bind_any start, add_two
result.println!
Here F = Option and M is the callback type I64 -> Option I64. bind unwraps Some 3, calls add_two, and returns Some 5. The output is Some(5); a None input would skip the callback. The standalone spelling is bind value, callback.
Foldable and Traversable
Foldable consumes a constructor from left to right with a state and a step function. Traversable maps every element into an effect while rebuilding the original shape inside that effect.
fold_digits: (I64, I64) -> I64
fold_digits = pair -> pair.0 * 10 + pair.1
increment_effect: I64 -> Option I64
increment_effect = value -> Option::Some value + 1
make_values: () -> Vec I64
make_values = ->
mut values = Vec::new!
values.push 1
values.push 2
values.push 3
values
main = !->
option_total = foldl fold_digits, 0, Option::Some 4
vector_total = make_values!.foldl fold_digits, 0
traversed = make_values!.traverse increment_effect
option_total.println!
vector_total.println!
traversed.println!
The fold over Some 4 returns 4; the vector fold computes 123; and traversal produces Some([2, 3, 4]). The output is 4, 123, and Some([2, 3, 4]). Vec supplies Functor, Foldable, and Traversable; its traversal consumes the input vector while constructing a new vector. The standalone traversal spelling is traverse increment_effect, make_values!.
traverse_m uses monadic binding instead of applicative application and can stop as soon as an effect fails.
stop_at_two: I64 -> Option I64
stop_at_two = value ->
if value == 2
Option::None
else
Option::Some value
make_values: () -> Vec I64
make_values = ->
mut values = Vec::new!
values.push 1
values.push 2
values.push 3
values
main = !->
result = make_values!.traverse_m stop_at_two
result.println!
The second callback returns None, so traversal stops and the output is None. The standalone spelling is traverse_m stop_at_two, make_values!.
Traversing for Effects
Use the standalone for_each values, action when the callback exists for effects rather than to build another container. The action returns () and may mutate its captures through FnMut:
main = !->
mut total = 0
for_each 1..=3, number !->
total = total + number
total.println!
for_each (Option::Some 7), (!.println!)
result: Result I64, I64 = Result::Ok 9
for_each result, (!.println!)
The output is 6, 7, and 9. Option::None and Result::Err invoke no callback. A Vec T supplies each owned element in order and is consumed; &Vec T and &[T] supply shared references while preserving the owner. Borrow an array as a slice to use the same function.
The public function uses the ordinary ForEach T bridge trait. Its blanket implementation for F T where F _: Foldable derives traversal from F::Foldable::foldl: the callback is carried as fold state, called once per element, and returned for the next step. Any new constructor implementing Foldable receives this behavior automatically; no separate ForEach implementation is needed.
Native Range is a concrete integer type, not a unary type constructor. Its dedicated bridge implementation visits bounded ranges directly without allocating a vector. Exclusive start..end and inclusive start..=end ranges run in ascending order; reversed ranges are empty, and an inclusive equal-endpoint range visits one integer. Open-ended ranges terminate with for_each requires a bounded range before invoking the action.
(!.println!) is a unit-returning receiver section: it means number !-> number.println!. The leading ! discards the result of the whole section body, while (.println!) would return the method’s result.
Sequencing Effects
sequence is the standard Traversable operation for turning T (G A) into G (T A). The following exact stdlib pattern sequences a vector of options.
main = !->
mut effects = Vec::new!
effects.push Option::Some 4
effects.push Option::Some 5
successful = sequence effects
match successful
Option::Some values =>
values.len!.println!
match values.get 0
Option::Some value => *value .println!
Option::None => 0.println!
Option::None => 0.println!
mut failed_effects = Vec::new!
failed_effects.push Option::Some 1
failed_effects.push Option::None
failed_effects.push Option::Some 3
failed = failed_effects.sequence!
match failed
Option::Some values => values.len!.println!
Option::None => -1 .println!
The successful sequence prints 2 and 4. The sequence containing None prints -1, because one missing effect makes the whole Option result absent. Both sequence values and values.sequence! consume the input vector.
Choosing a Call Style
The standalone functions and receiver methods are available through the prelude:
| Standalone | Receiver |
|---|---|
fmap mapper, value | value.fmap mapper |
ap wrapped_function, wrapped_value | wrapped_function.ap wrapped_value |
bind value, callback | value.bind callback |
foldl step, initial, values | values.foldl step, initial |
traverse action, values | values.traverse action |
traverse_m action, values | values.traverse_m action |
sequence values | values.sequence! |
The receiver APIs are ordinary blanket trait implementations on fully applied values. Implementing the corresponding constructor traits gives a custom carrier these methods automatically. They consume their receivers and preserve the callback bounds of the constructor operations, including FnMut where supported. Qualified calls such as F::Functor::fmap remain useful inside implementations or when choosing a trait explicitly.
For example, this custom carrier only implements Functor; the prelude supplies its .fmap method:
enum Parcel T
Item T
impl Functor for Parcel
fmap = mut mapper, value ->
match value
Parcel::Item item => Parcel::Item (mapper item)
increment: I64 -> I64
increment = value -> value + 1
main = !->
parcel = Parcel::Item 4
match parcel.fmap increment
Parcel::Item value => value.println!
This prints 5. The standalone fmap function uses the same implementation.
Current Limits and Design Guidance
Use F _ for a unary constructor bound and Result _, E when fixing one Result parameter. Do not pass a bare binary Result where a unary constructor is required. The current stdlib intentionally gives Vec no Applicative or Monad implementation because Cartesian and zipped list semantics would make an arbitrary choice. Existing .map and .and_then methods remain useful for concrete containers; fmap and bind provide a shared vocabulary for constructor-generic code.
The examples here use constructor holes, standalone functions, and receiver methods. Explicit type-lambda syntax is not needed for these APIs.
Modules and Visibility
Modules divide a crate into namespaces and source files. A module declaration tells the source loader which file belongs to a namespace; an import makes a public path available under a short name; an export controls what another module may use. This separation keeps implementation details local while making the public surface visible in the source.
Each example below is a separate project. In each project root, add this rock.toml alongside the listed source files:
[crate]
name = "module_demo"
version = "0.1.0"
[lib]
path = "main.rk"
Run rock run from that directory. The source listings in each section belong together; an entry that declares mod math cannot compile without its accompanying math.rk.
File-backed modules
The smallest file-backed module has an entry file and a sibling file. The complete project is shown here so the import in main.rk has a visible definition.
Project tree:
square-app/
|-- main.rk
`-- math.rk
main.rk
mod math
> math::square
main = !->
square 6 .println!
math::square 4 .println!
math.rk
double: I64 -> I64
double = value -> value * 2
square: I64 -> I64
square = value -> value * value
< square
mod math loads math.rk and gives it the qualified name math. The explicit import binds the exported function as square; the qualified call remains available when the name should be unambiguous. Running the project prints 36 and 16, then returns exit code 0.
The separate < square declaration exports an existing item. You can also export an item inline by putting < before its definition. For example, this alternative math.rk exports square at its definition and also exports a function named myfn that returns unit (()):
double: I64 -> I64
double = value -> value * 2
square: I64 -> I64
< square = value -> value * value
< myfn = -> ()
Both export styles make the item public; an inline export does not need a separate < square or < myfn declaration. Items without an export remain private: double is usable inside math.rk but is not importable from main.rk; attempting > math::double is a compile-time visibility error. Export only the names consumers should depend on; private helpers can then change without changing callers.
Directory modules
When a module grows, the loader also accepts a directory containing mod.rk. The complete layout and both files are shown together.
Project tree:
answer-app/
|-- main.rk
`-- tools/
`-- mod.rk
main.rk
mod tools
> tools::answer
main = !->
answer!.println!
tools/mod.rk
answer: () -> I64
answer = -> 42
< answer
The declaration is still mod tools; only the source location changes from tools.rk to tools/mod.rk. The program prints 42. Do not create both tools.rk and tools/mod.rk for the same module: the loader treats multiple candidates as an error rather than choosing by declaration order.
Imports and globs
An explicit import starts with > and names one public path. A glob import ends in ::* and brings all public names from that module or facade into scope. This example uses a facade module and shows every file it loads.
Project tree:
facade-app/
|-- main.rk
|-- api.rk
`-- arithmetic.rk
main.rk
mod api
> api::*
main = !->
add 2, 5 .println!
double 9 .println!
api.rk
mod arithmetic
< arithmetic::*
arithmetic.rk
add: I64 -> I64 -> I64
add = left, right -> left + right
double: I64 -> I64
double = value -> value * 2
< add
< double
The api module re-exports the public names from arithmetic, and main.rk imports that facade. The output is 7 and 18. Globs are useful for deliberately small facades; prefer explicit imports in application code so a new export does not silently add a name or create a conflict.
Public structs and fields
Type visibility and field visibility are independent. Export a type and each field that consumers must construct or read. The following complete two-file project exports a struct, a constructor function, and a calculation while keeping the implementation path explicit.
Project tree:
geometry-app/
|-- main.rk
`-- geometry.rk
main.rk
mod geometry
> geometry::Point
> geometry::make_point
> geometry::distance_squared
main = !->
point = make_point 3, 4
point.x.println!
point.y.println!
distance_squared point .println!
geometry.rk
< struct Point
< x: I64
< y: I64
make_point: I64 -> I64 -> Point
make_point = x, y ->
Point
x: x
y: y
distance_squared: Point -> I64
distance_squared = point -> point.x * point.x + point.y * point.y
< make_point
< distance_squared
The output is 3, 4, and 25. If x or y lost its leading <, outside code could still name Point but could not read or initialize that field. Keeping fields private is useful when a module wants to enforce construction through functions.
Re-exporting and qualified names
Qualified paths avoid ambiguity when two modules export the same short name. This example keeps both value functions qualified and therefore needs no imports for them.
Project tree:
qualified-app/
|-- main.rk
|-- left.rk
`-- right.rk
main.rk
mod left
mod right
main = !->
left::value! + right::value! .println!
left.rk
value: () -> I64
value = -> 11
< value
right.rk
value: () -> I64
value = -> 31
< value
The output is 42. If both paths are imported under the same short name, name resolution reports a conflict; use a qualified path or choose a deliberate local facade name instead.
Current limits and common mistakes
- Use file-backed modules. Inline-module syntax is parsed in parts of the front end but is not supported through the complete compilation pipeline.
- A missing module reports the searched file and directory candidates; check both
name.rkandname/mod.rklayouts. - A circular module load is an error; move shared declarations into a third module rather than creating a cycle.
- Imports do not make private declarations public. Add
<in the defining module, then import the exported path. ::selects a namespace or associated item, while.selects a field or method on a value.- A module fence that relies on another file is only complete when that companion file is shown in the same subsection, as in the examples above.
Packages and Dependencies
A package is a buildable Rock crate described by rock.toml. The manifest names the crate, selects its source entry, and records dependencies. rock builds the package graph and materializes its artifacts.
A minimal package
The manifest requires [crate], [lib], a crate name, a version, and a source path. The complete project is:
Project tree:
hello-package/
|-- rock.toml
`-- main.rk
rock.toml
[crate]
name = "hello"
version = "0.1.0"
[lib]
path = "main.rk"
main.rk
main = !->
"project entry point".println!
From hello-package/, rock build compiles the entry and writes products below build/; rock run builds and executes the linked program. The output is project entry point and the program exits with status 0 because main returns unit. The manifest key is called lib.path even when the selected source contains an executable main function.
A path dependency
Path dependencies are the supported way to compose local packages today. The example has two complete projects and shows the whole source surface on both sides of the dependency.
Project tree:
workspace/
|-- geometry/
| |-- rock.toml
| `-- main.rk
`-- app/
|-- rock.toml
`-- main.rk
geometry/rock.toml
[crate]
name = "geometry"
version = "0.1.0"
[lib]
path = "main.rk"
geometry/main.rk
< struct Point
< x: I64
< y: I64
distance_squared: Point -> I64
distance_squared = point -> point.x * point.x + point.y * point.y
< distance_squared
app/rock.toml
[crate]
name = "geometry-app"
version = "0.1.0"
[lib]
path = "main.rk"
[dependencies]
geometry = { path = "../geometry" }
app/main.rk
> geometry::Point
> geometry::distance_squared
main = !->
point = Point
x: 3
y: 4
distance_squared point .println!
rock build builds geometry before geometry-app, then passes the fresh dependency artifact to the root compilation. The application prints 25. The dependency name in the manifest is also the crate path used by > geometry::Point and > geometry::distance_squared; the imported names must have been exported by geometry/main.rk.
Version-only registry dependencies are accepted by manifest parsing but are not resolved by the current package workflow. Use a path dependency for a local project and do not assume that version = "1.0" downloads or locates a package.
Crate artifacts
rock artifact produces a reusable .rkca product containing the typed exported interface, generic bodies needed by consumers, operator declarations, dependency records, and object-linkage metadata. rock build and rock run create or reuse these products for path dependencies automatically; application authors do not need to pass artifact paths between packages.
The package graph still remains explicit. Declare each dependency in rock.toml; the tool does not search neighboring source directories for undeclared packages. A missing or mismatched dependency is a build error.
Prelude and no_std
Passing the stdlib artifact makes its prelude available automatically. A low-level package can opt out in its manifest. The complete no-stdlib source returns unit (()), so it does not rely on prelude operators or types.
Project tree:
bare-package/
|-- rock.toml
`-- lib.rk
rock.toml
[crate]
name = "bare"
version = "0.1.0"
no_std = true
[lib]
path = "lib.rk"
lib.rk
main = !->
()
Without the prelude, String, Option, familiar methods, and standard operator implementations are not injected. This is intentional: primitive operator meanings are supplied by explicit library declarations and implementations rather than by compiler-owned fallbacks. Add an explicit dependency and import when a low-level package needs a particular capability.
Project commands and arguments
The package commands operate from the directory containing rock.toml:
$ rock format
$ rock build
$ rock run -- first second
$ rock expand
$ rock artifact
rock run -- first second passes two user arguments after the separator; the program’s argument API also includes argv[0]. There is currently no rock test subcommand. Use ordinary Rock programs or an external harness for application-level tests.
Common mistakes and current limits
- Keep the manifest’s crate name, imported crate path, and artifact name aligned.
- Keep
[lib].pathrelative to the package root and ensure the file exists. - Declare every path dependency in
rock.tomland letrockbuild it in dependency order. - Do not expect registry resolution, implicit source discovery, or compiler-owned stdlib injection.
no_std = trueis a package-level choice; code that uses prelude types must opt into the appropriate explicit dependency instead.
Macros
Rock has declarative token macros that generate top-level syntax before ordinary declaration collection and type checking. Macro expansion is useful when the repeated source shape cannot be expressed as a function, trait, or generic. It also makes ownership and generated control flow less obvious, so a small conventional expansion is easier to review than a clever one.
Current status: Declarative macro parsing, direct captures, expression captures, and flat repetition are covered by compiler tests. Macro use remains experimental: nested repetition, indentation-sensitive arguments, diagnostics, and formatter support are incomplete. The examples below use parser- and expansion-tested syntax; inspect generated source with
rock expandbefore depending on it in a package.
A zero-argument declaration macro
The simplest matcher has no captures. The invocation is a top-level %name line, and the body emits an ordinary Rock declaration.
macro make_main
=>
main = !->
42.println!
%make_main
The expansion produces a normal unit-returning main function, which prints 42 and exits with status 0. The macro itself is not a runtime function and has no runtime ownership effect; only the generated declarations participate in type checking and cleanup. Keep generated names unique because two expansions that emit the same top-level name create the same conflict as handwritten declarations.
Capturing identifiers and expressions
The tested fragment categories are ident, expr, and ty. The example below uses identifier and expression captures; the next example demonstrates type-name captures.
The invocation syntax is token-based and uses spaces rather than a function-call comma list. In the example below, $name:ident captures answer and $value:expr captures the expression 6 * 7:
macro make_constant
$name:ident $value:expr =>
$name = -> $value
%make_constant answer 6 * 7
main = !->
answer! .println!
The generated answer returns 42, so this executable example prints 42. A macro invocation always starts with %, while an ordinary declaration would not be expanded.
Capturing a type
Use ty to capture a single uppercase type name. Rock distinguishes uppercase type-name tokens from ordinary identifiers, so both the new struct name Number and the field type I64 require ty captures. An ident capture would not match Number.
macro make_wrapper
$name:ty $inner:ty =>
struct $name
< value: $inner
%make_wrapper Number I64
main = !->
number = Number
value: 42
Here $name:ty captures Number and $inner:ty captures I64. The expansion declares a struct with an exported value: I64 field, and main constructs a value of that struct. Captures match syntax before type checking, so Number does not need to be declared before the invocation. Currently, ty captures consume only one type-name token, not a compound type such as a reference or a generic type application.
Repetition
A repetition matcher uses $( and )*. The following syntax is covered by the expansion tests and is deliberately flat.
macro define_values
$( $name:ident )* =>
$( $name = -> 9 )
%define_values first second third
main = !->
first! .println!
second! .println!
third! .println!
The matcher captures three identifiers and the template emits three functions. The output is 9, 9, and 9. Repetition consumes the captured tokens into generated declarations; it does not clone a runtime value and does not create a runtime loop. Captures in one repeated group must have the same number of elements.
The compiler also tests a direct capture used inside a repeated template. This form is useful when several generated functions should return one already-declared value:
macro define_aliases
$source:ident $( $name:ident )* =>
$( $name = -> $source! )
source = -> 7
%define_aliases source left right
main = !->
left! .println!
right! .println!
The output is 7 and 7. The source declaration appears before the invocation, so the generated functions have a real target and no placeholder value. Do not nest another repetition inside either matcher or template; nested repetition is a known unsupported area even though the token grammar accepts repetition groups.
Invocation matching and errors
Macro arms are tried in declaration order. A matcher that does not consume the invocation’s tokens produces a compile-time diagnostic; it does not become a runtime branch. This example has two complete arms and therefore demonstrates a deliberate fallback without relying on an undefined error type.
macro make_value
$name:ident $value:expr =>
$name = -> $value
$name:ident =>
$name = -> 0
%make_value explicit 8
%make_value fallback
main = !->
explicit! .println!
fallback! .println!
The output is 8 and 0. Put the more specific arm first; a broad arm can consume input before a later arm gets a chance. Invalid input is reported near the invocation, but expansion-origin labels and indentation diagnostics are still being improved.
Expansion workflow and design rules
Use rock expand to inspect generated source before debugging type or ownership errors. A generated ~@ method still consumes its receiver, and a generated mutable method still requires a mutable binding; macros do not weaken the borrow checker. Prefer ordinary functions for behavior, generics for type reuse, and a macro only when the repeated declaration shape itself is the useful abstraction.
Rock does not yet expose a stable procedural-macro, derive, or attribute API for application authors. This chapter therefore documents only declarative %name macros.
The Prelude and Common Traits
For an ordinary project, rock loads the selected toolchain’s standard library and makes its prelude available automatically. The prelude is a curated set of types, traits, operators, and small utility functions. Specialized modules such as files, networking, threads, and process arguments remain explicit imports.
What is available
The prelude exports the everyday vocabulary used by the examples in this chapter:
- owned types:
String,Option,Result,Vec,HashMap,Box,Arc,Mutex, andMutexGuard; - representation and ownership traits:
Show,From,Into,Clone,Drop,Deref,DerefMut, andSized; - access and comparison traits:
Index,IndexMut,Eq,Ord, andHash; - callable and thread-safety traits:
Fn,FnMut,FnOnce,Send, andSync; - functional traits:
Bifunctor,Functor,Applicative,Monad,Foldable,Traversable, and theForEachtraversal bridge; - effect-only traversal:
for_each values, action, for bounded ranges, foldable containers, borrowed vectors, and borrowed slices; - the arithmetic, comparison, logical, bitwise, negation, and functional operator declarations provided by those modules.
The prelude does not turn a missing implementation into a compiler fallback: an operator still needs a matching library trait implementation. Files, process arguments, networking handles, and thread-spawning functions remain explicit imports because they are not universal vocabulary.
Showing and printing
Show converts a value to an owned String, and println! writes that representation. Literal values have prelude implementations, so this complete program needs no imports.
main = !->
42.println!
true.println!
"Rock".println!
The output is 42, true, and Rock. Printing inspects a value where possible; it does not consume an owned value merely to display it.
An application type can implement Show by returning a String:
< struct Point
< x: I64
< y: I64
impl Show for Point
@show: String
@show = ->
"Point(" + @x.show! + ", " + @y.show! + ")"
main = !->
point = Point
x: 3
y: 4
point.println!
The output is Point(3, 4). The < markers export Point and its fields for use by other modules. In this example, main is in the same module and could construct Point even if its fields were private; outside modules would need an exported constructor or factory function to initialize private fields.
Numeric behavior and generic bounds
Primitive arithmetic is supplied by stdlib trait implementations. A concrete function can use an operator directly, while generic code must name a bound that supplies the requested operation. Complete generic-bound examples appear in Traits and Methods and Operators; this section keeps the arithmetic call concrete.
sum: I64 -> I64 -> I64
sum = left, right -> left + right
main = !->
integer = sum 20, 22
integer.println!
The output is 42. Without a matching Add implementation and operator declaration, + has no fallback meaning. Equality, ordering, negation, bitwise operations, and indexing follow the same library-owned design.
Conversion
From expresses a conversion from a source type into a destination type. The trait declares from: T -> Self: T is the source, and Self is the type receiving the implementation. Implement From when the conversion can produce a value directly, without reporting failure.
For example, an application can convert a distance in meters into a distance in millimeters:
struct Meters
< value: I64
struct Millimeters
< value: I64
impl From Meters for Millimeters
from = meters ->
Millimeters
value: meters.value * 1000
main = !->
distance = Meters
value: 3
converted = Millimeters::from distance
converted.value.println!
The output is 3000. In impl From Meters for Millimeters, Meters is the input type and Millimeters is the output type. The < markers make their fields public. The trait supplies the signature, so the implementation only needs the body of from. Calling Millimeters::from distance selects the destination explicitly; both local variable types are inferred. The argument is passed by value, so converting an owned, non-copyable source transfers ownership to from.
This example assumes the scaled distance fits in I64. For a conversion that needs validation or can fail, use a function returning Result so callers can handle the error. Implementing From in one direction does not define the reverse conversion.
The standard library uses the same trait to convert &Str, &[U8], I64, F64, and Char values into owned strings. Each call below selects a different From implementation from its argument type:
main = !->
from_text = String::from "hello"
from_integer = String::from 42
from_float = String::from 3.5
from_character = String::from 'R'
bytes = [82 as U8, 111 as U8, 99 as U8, 107 as U8]
from_bytes = String::from (&bytes)
from_text.println!
from_integer.println!
from_float.println!
from_character.println!
from_bytes.println!
The output is hello, 42, 3.5, R, and Rock. Borrowed text and bytes are copied into the new string’s owned storage.
Converting with Into
Into performs the same conversion using method syntax on the source value. The standard library provides Into U for T whenever U implements From T, so implementing From also makes into! available automatically. This applies to the custom distance conversion above as well as the string conversions.
Unlike String::from value, value.into! does not name its destination. The surrounding code must supply that type, either through an annotation or through a function parameter:
print_text: String -> ()
print_text = text !-> text.println!
main = !->
text: String = 42.into!
text.println!
print_text 'R'.into!
The output is 42 and R. The String annotation on text selects the first conversion’s destination; the print_text parameter supplies the destination for the second. into! takes its receiver by value, transferring ownership when the source is non-copyable. Prefer implementing From and using this automatic Into implementation rather than writing both yourself.
Primitive casts
Primitive casts use as; they do not call a custom From implementation:
main = !->
integer = 42
floating = integer as F64
floating.println!
Memory and callable traits
Clone explicitly creates another owner, Drop supplies deterministic cleanup, and Deref forwards access through wrappers such as Box and Arc. Fn, FnMut, and FnOnce describe callable ownership; Send and Sync are checked when values cross a spawned thread. This example demonstrates clone and dereference without hiding the declarations involved.
main = !->
original = String::from "owned"
duplicate = original.clone!
shared = Arc::new duplicate
*shared .println!
original.println!
The output is owned twice. duplicate moves into Arc, while original remains independent because clone! created a second allocation. A shared reference is cheaper than Clone when a second owner is not required.
Explicit imports remain healthy
A prelude reduces noise for universal vocabulary; it should not hide domain dependencies. This complete program imports file, I/O, and thread APIs and uses each one.
> stdlib::fs::File
> stdlib::io::IoError
> stdlib::io::Write
> stdlib::thread::spawn
write_marker: &Str -> Result I64, IoError
write_marker = path ->
mut file = File::create path?
file.write_str "marker"
main = !->
match write_marker "rock-prelude-marker.txt"
Result::Ok count => count.println!
Result::Err _ => 1
match spawn (-> 7)
Result::Ok handle =>
match handle.join!
Result::Ok value => value.println!
Result::Err _ => 1
Result::Err _ => 1
The output is 6 and 7, and the marker file contains marker. The imports document the module boundary even though Result, String, and numeric operators come from the prelude. Keep specialized imports at the top of the fence or source file so a reader can see the dependency without searching another chapter.
Current limits and common mistakes
- The prelude comes from the standard library in the selected toolchain; keep the compiler and toolchain components on the same revision.
no_stdpackages do not receive these names automatically.- An operator spelling is not a guarantee that every type supports it; trait selection still needs one applicable implementation.
Clonemay allocate and is not the same as compiler-known copying of primitive values.- Domain modules remain explicit because a universal prelude should not conceal filesystem, network, process, or scheduling effects.
Strings and Collections
The standard library supplies owned strings and heap-backed containers. Their APIs follow Rock’s ownership model: shared methods inspect without taking ownership, mutable methods require an exclusive borrow, and consuming transformations move elements into a new result. The prelude exports String, Vec, HashMap, Box, Arc, and Option; specialized string search helpers are imported explicitly below.
String and &Str
&Str is a borrowed string slice. String owns a null-terminated heap buffer. Construct an owner when data must outlive the expression that supplied the slice, and keep a slice when a function only needs to inspect text.
describe: &Str -> String
describe = value -> String::from value
main = !->
name = "Rock"
owned = describe name
empty = String::new!
number = String::from 42
copy = owned.clone!
length = owned.len!
view = owned.as_str!
combined = owned.concat number
message = "Hello, " + "Rock" + "!"
empty.len!.println!
copy.println!
length.println!
view.println!
combined.println!
message.println!
The output is 0, Rock, 4, Rock, Rock42, and Hello, Rock!. String::from converts through the From trait, while String::new! constructs an empty string; len! reads a byte count and as_str! borrows a string view. clone! makes a second owner. owned.concat number borrows its receiver owned, consumes the argument number, and returns a new owner. Because owned remains alive, view can still be printed after concatenation. The + implementations cover String and &Str combinations and also return a new String.
Strings are byte-oriented in the current library. Lengths, searches, substrings, and byte indexes count bytes rather than Unicode scalar values. A multi-byte UTF-8 character must not be split by a byte range unless the caller intentionally handles raw bytes.
Byte and search helpers
The search helpers live in stdlib::string and are not prelude names. This fence imports every non-prelude function it uses and keeps the byte buffer separate from the borrowed &Str search input.
> stdlib::string::byte_at
> stdlib::string::byte_substr
> stdlib::string::string_contains
> stdlib::string::string_find
> stdlib::string::string_len
main = !->
text = "rock"
length = string_len text
offset = string_find text, "oc"
contains = string_contains text, "ck"
bytes = [114, 111, 99, 107]
middle = byte_substr &bytes, 1, 2
second = byte_at &bytes, 1
length.println!
offset.println!
contains.println!
middle.len!.println!
second as I64 .println!
The output is 4, 1, 1, 2, and 111. string_find returns a byte offset or -1; string_contains returns 1 or 0; byte_substr returns an owned Vec U8; and byte_at reads one byte from a byte slice. Out-of-range behavior is not a Unicode-aware character operation, so validate byte boundaries in the caller.
Vec T
Vec T is a growable owned sequence. A mutable receiver method requires a mutable binding. get returns Option &T for a checked lookup; set replaces an existing element; swap_remove removes an element by moving the last element into its position.
main = !->
mut values = Vec::new!
values.push 10
values.push 20
values.push 30
view = values.as_slice!
view.println!
values.set 1, 99
match values.get 1
Option::Some value => value.println!
Option::None => -1 .println!
match values.swap_remove 0
Option::Some value => value.println!
Option::None => -1 .println!
values.len!.println!
The output is [10, 20, 30], 99, 10, and 2. as_slice! borrows the vector, so do not keep that borrow live across push, set, or swap_remove. swap_remove does not preserve order: after removing index 0, the old final element occupies that position.
Consuming and borrowing transformations
The callback type tells you whether an operation moves elements or borrows them. map, filter, and filter_map consume the source; map_ref, for_each, and retain borrow elements while processing them. try_map consumes the source and stops at the first Result::Err.
main = !->
mut source = Vec::new!
source.push 1
source.push 2
source.push 3
mapped = source.map (+ 1)
mut borrowed_source = Vec::new!
borrowed_source.push 4
borrowed_source.push 5
references = borrowed_source.map_ref (+ 1)
borrowed_source.for_each (!.println!)
mut filtered_source = Vec::new!
filtered_source.push 1
filtered_source.push 2
filtered_source.push 3
filtered = filtered_source.filter (% 2 == 0)
mut retained_source = Vec::new!
retained_source.push 1
retained_source.push 2
retained_source.push 3
retained_source.retain (% 2 == 0)
mut optional_source = Vec::new!
optional_source.push -1
optional_source.push 2
optional_source.push 3
positives = optional_source.filter_map value ->
if value > 0
then Option::Some value
else Option::None
mut checked_source = Vec::new!
checked_source.push 4
checked_source.push 5
checked = checked_source.try_map value ->
if value >= 0
then Result::Ok value
else Result::Err 1
mapped.println!
references.println!
filtered.println!
retained_source.println!
positives.println!
checked.unwrap_or Vec::new! .println!
The first map moves source, while map_ref leaves borrowed_source available after the callback. The output is 4, 5, [2, 3, 4], [5, 6], [2], [2], [2, 3], and [4, 5]; the first two lines come from for_each. try_map returns Ok here. If a callback returns Err 1, later source elements are not mapped and the original source is consumed.
map_ref and retain pass shared references to their callbacks. Their operator sections still work: (+ 1) means value -> value + 1, and (% 2 == 0) means value -> value % 2 == 0. The integer + and % implementations use shared receivers, and method lookup adjusts the borrowed left operand to select them. No explicit *value is needed; the parameter remains &I64. See operators on borrowed values.
The concrete methods above are the supported everyday Vec surface. Higher-kinded traversal interfaces are still evolving and are intentionally not expanded here. Vec has no Applicative or Monad implementation: choosing Cartesian application would require cloning, while choosing zipped application would not have list-monad semantics.
HashMap K, V
HashMap K, V requires Hash and Eq for keys. The current map supports construction, length, insertion, checked lookup, and key containment.
main = !->
mut scores = HashMap::new!
scores.insert 10, 100
scores.insert 20, 200
ten = 10
twenty = 20
thirty = 30
scores.contains_key &ten .println!
scores.contains_key &thirty .println!
match scores.get &ten
Option::Some score => score.println!
Option::None => -1 .println!
match scores.get &twenty
Option::Some score => score.println!
Option::None => -1 .println!
scores.len!.println!
The output is true, false, 100, 200, and 2. ten, twenty, and thirty are borrowed as probe keys, so the caller retains ownership. The implementation uses open addressing and grows near a 75 percent load factor. Removal, iteration, entry APIs, and configurable hashers are not currently public.
Box T
Box::new moves one value into a single owned heap allocation. as_ref! borrows it, as_mut! permits mutation through a mutable binding, and Deref forwards * access. Boxing is useful for stable indirection or recursive ownership; it is not a default replacement for a local value.
struct Point
< x: I64
< y: I64
main = !->
point = Point
x: 3
y: 4
mut boxed = Box::new point
mutable_view = boxed.as_mut!
mutable_view.x = 5
view = boxed.as_ref!
view.x.println!
view.y.println!
(*boxed).x.println!
The output is 5, 4, and 5. point is moved into boxed; as_mut! changes the owned value through an exclusive borrow, as_ref! creates a shared view, and *boxed uses Deref. The box drops its value and allocation when boxed leaves scope.
Arc T
Arc::new creates atomic shared ownership. clone! increments the reference count, but Arc does not make the contained value mutable. Combine it with Mutex T when multiple owners must update shared state.
main = !->
state = Arc::new String::from "shared"
worker_copy = state.clone!
*state .println!
*worker_copy .println!
The output is shared twice. Both Arc values point at the same owned string; dropping the final owner releases the allocation. A mutable String cannot be obtained from an Arc String without a separate synchronization design.
Current resource limits
Regression tests cover Vec and HashMap element cleanup during destruction, replacement, and growth. Zero-sized Vec elements and HashMap keys or values are still rejected on insertion. Treat the prototype’s resource behavior as an explicit current limit, not as a production boundary for long-running memory-heavy programs.
Input, Output, and Files
Rock’s I/O abstractions use Result and ownership rather than exceptions or manual close calls. File and TcpStream implement Read and Write, Stdin implements Read, and Stdout implements Write. Generic code can therefore work with more than one kind of byte stream.
The Read and Write traits
Read takes a mutable byte slice and reports a byte count or IoError. Write borrows the output handle and the source data. Import the trait when calling its methods through a generic bound or a concrete value.
| Method | Contract |
|---|---|
reader.read buffer | Reads up to the buffer length; a short read is successful. |
writer.write bytes | Attempts one write; success may report fewer bytes than supplied. |
writer.write_all bytes | Repeats writes until all bytes are written or an error occurs. |
writer.write_str text | Writes the entire string’s bytes using the complete-write helper. |
> stdlib::fs::File
> stdlib::io::IoError
> stdlib::io::Read
> stdlib::io::Write
write_text: &W -> &Str -> Result I64, IoError where W: Write
write_text = writer, text ->
writer.write_str text
read_text_prefix: &mut R -> &mut [U8] -> Result I64, IoError where R: Read
read_text_prefix = reader, buffer ->
reader.read buffer
write_demo: &Str -> Result I64, IoError
write_demo = path ->
file = File::create path?
write_text &file, "hello"
main = !->
write_demo "rock-io-traits.txt"
.unwrap_or -1
.println!
This example writes five bytes and prints 5; read_text_prefix demonstrates the reader signature even though main only uses the write path. read needs exclusive access to both the reader and buffer. Writing needs only a shared handle, so the file does not need a mutable binding. The file closes automatically when its owner is dropped.
The free functions stdlib::io::write_all and stdlib::io::write_str supply the same complete-write behavior for any W: Write; custom implementations can delegate their helper methods to them. If a write makes no progress while bytes remain, the helper returns IoError::WriteZero rather than looping forever. Other errors propagate immediately, including interrupted operating-system calls. An error may occur after some bytes have already been written; it does not undo those bytes, and the error result does not carry the partial count.
Files
File::open reads an existing path, File::create creates or truncates a path, and File::append creates or appends. Each returns Result File, IoError, so a function that uses ? must return a compatible Result.
> stdlib::fs::File
> stdlib::io::IoError
> stdlib::io::Write
write_greeting: &Str -> Result I64, IoError
write_greeting = path ->
file = File::create path?
file.write_str "Hello from Rock\n"
append_greeting: &Str -> Result I64, IoError
append_greeting = path ->
file = File::append path?
file.write_str "Again\n"
main = ->
result = do
count <- write_greeting "rock-greeting.txt"
appended <- append_greeting "rock-greeting.txt"
pure count + appended
status = result <&> total ->
total.println!
0
status.unwrap_or 1
The successful output is 22, and rock-greeting.txt contains Hello from Rock followed by Again, each followed by a newline. The descriptor is closed by Drop; callers do not call a separate close API. create and append can create a missing file, as this example requires; open reports an operating-system IoError::Os code when the file is absent. A path containing a null byte reports IoError::InvalidPath before the operating-system call.
do keeps the successful counts in scope without nested callbacks. Each <- sequences through the prelude’s bind, which continues only for Result::Ok; pure wraps the sum in the same carrier. This has the sequencing behavior of >>=. At the boundary, <&> prints a successful total and produces exit code 0, while unwrap_or 1 turns either operation’s error into exit code 1. See error handling for do and the named combinators.
Reading bytes
Only the prefix indicated by the returned count contains newly read bytes. A zero count from a read into a nonempty buffer means end of file. A read is not a request to fill the buffer: loop until the format’s expected length or end of file. This complete program creates its input first, then copies it to standard output using a small buffer.
> stdlib::fs::File
> stdlib::io::IoError
> stdlib::io::Read
> stdlib::io::Write
> stdlib::io::stdout
prepare: &Str -> Result I64, IoError
prepare = path ->
file = File::create path?
file.write_str "world"
read_file: &Str -> Result I64, IoError
read_file = path ->
mut file = File::open path?
output = stdout!
mut bytes: [U8; 3] = [0; 3]
mut total = 0
mut count = file.read &mut bytes?
while count > 0
written = output.write_all &bytes[..count]?
total = total + written
count = file.read &mut bytes?
Result::Ok total
main = ->
copied = do
prepare "rock-io-input.txt"
read_file "rock-io-input.txt"
status = copied <&> count -> if count == 5 then 0 else 1
status.unwrap_or 1
The output is world, without a newline. The caller checks that five bytes were copied before reporting success. Each &bytes[..count] borrows just the initialized prefix from this read, not any leftover bytes from the previous iteration. The shared borrow is finished before the next mutable read. The file owns its descriptor throughout and closes when read_file returns.
Prefixes, Subslices, and String Bytes
Select the data first, then write the resulting slice. Prefix and subslice writes need neither a raw pointer nor a separate length parameter:
> stdlib::io::IoError
> stdlib::io::Write
> stdlib::io::stdout
> stdlib::string::str_as_bytes
write_parts: () -> Result I64, IoError
write_parts = ->
output = stdout!
bytes = str_as_bytes "hello"
first = output.write_all &bytes[..2]?
rest = output.write_all &bytes[2..]?
Result::Ok first + rest
main = ->
status = write_parts! <&> _ -> 0
status.unwrap_or 1
This writes hello. str_as_bytes borrows an &Str as &[U8]; an owned String offers the equivalent as_bytes! method. Neither operation copies the data. The offsets count bytes, not Unicode characters, and the resulting view is a byte slice, not a string. Range bounds are checked and invalid bounds terminate the process rather than returning IoError.
Standard streams
stdout! and stdin! create lightweight handles. The output example is deterministic and avoids requiring interactive input; Stdin implements the same Read contract for a caller that supplies a mutable buffer.
> stdlib::io::stdout
> stdlib::io::Write
main = ->
output = stdout!
bytes: [U8; 5] = [104, 101, 108, 108, 111]
status = output.write_all &bytes[..3] <&> _ -> 0
status.unwrap_or 1
The process writes hel to standard output and returns 0. The native range creates a borrowed three-byte view, and write_all handles partial operating-system writes until the whole slice is sent or an error occurs.
Reading standard input uses the same ownership contract. This program performs one read and requires input at runtime; it reports the number of bytes received, not necessarily all the bytes the producer will send.
> stdlib::io::IoError
> stdlib::io::Read
> stdlib::io::stdin
read_stdin: () -> Result I64, IoError
read_stdin = ->
mut input = stdin!
mut buffer: [U8; 16] = [0; 16]
input.read &mut buffer
main = !->
read_stdin! .unwrap_or -1 .println!
Copying and the |>> operator
The generic copy function reads from &mut R and writes to &W until end of file. |>> is its pipeline-oriented spelling and consumes the source reader while borrowing the destination writer. This program creates a source file so the example has no hidden input.
> stdlib::fs::File
> stdlib::io::IoError
> stdlib::io::Write
prepare: &Str -> Result I64, IoError
prepare = path ->
file = File::create path?
file.write_str "functional byte pipe"
copy_file: &Str -> &Str -> Result I64, IoError
copy_file = source, target ->
output = File::create target?
File::open source? |>> &output
main = ->
copied = do
prepare "rock-copy-input.txt"
copy_file "rock-copy-input.txt", "rock-copy-output.txt"
status = copied <&> count ->
count.println!
0
status.unwrap_or 1
The output is 20, and the destination contains the same 20 bytes. The source File is moved into the pipe, while output remains the destination owner. This shape is useful for generic file, socket, and standard-stream code.
Program arguments
args! is an explicitly imported function that returns an owned Vec String snapshot. It includes argv[0], so a run with no user arguments still has length 1.
> stdlib::env::args
main = !->
values: Vec String = args!
values.len!.println!
With no user arguments the output is 1; with first second after the run separator it is 3. The vector owns each argument string, and there is currently no standard environment-variable API.
Errors, ownership, and portability
Use a concrete error type such as IoError in public signatures. Use >>= to sequence fallible operations, <&> to transform successes, and ? to propagate failures from a helper that returns a compatible carrier. At the boundary, unwrap_or supplies a fallback value; use match when you need to inspect and report the particular error. Moving a File transfers descriptor ownership; borrowing it for Read or Write leaves the caller responsible for the eventual drop. A failed ? returns early and does not restore values already moved into the failed operation.
The current file and stream implementations are POSIX-oriented. Error numbers and some behavior depend on the host operating system, and the examples that create files require a writable working directory.
Networking
The current networking module provides safe ownership wrappers around blocking IPv4 TCP sockets. It exposes address values, a listener, and a stream; descriptors close through Drop, and all fallible operations return a Result carrying IoError.
Addresses
Ipv4Addr::new accepts four octets. SocketAddrV4::new combines an address and an integer port. The convenience constructors localhost and any use loopback and all-interface addresses respectively.
> stdlib::net::Ipv4Addr
> stdlib::net::SocketAddrV4
main = !->
loopback: Ipv4Addr = Ipv4Addr::new 127, 0, 0, 1
address: SocketAddrV4 = SocketAddrV4::new loopback, 8080
local: SocketAddrV4 = SocketAddrV4::localhost 9000
wildcard: SocketAddrV4 = SocketAddrV4::any 7000
address.ip.a.println!
address.port.println!
local.ip.d.println!
wildcard.ip.a.println!
The output is 127, 8080, 1, and 0. Ports are checked to fit the valid range 0..=65535 when a listener or stream is created. A port of 0 asks the operating system to select an available port for a listener.
Connecting
TcpStream::connect returns Result TcpStream, IoError. This deterministic example uses an invalid port to show the library-owned validation error without depending on another process listening on a particular port.
> stdlib::io::IoError
> stdlib::net::Ipv4Addr
> stdlib::net::SocketAddrV4
> stdlib::net::TcpStream
connect_checked: () -> Result TcpStream, IoError
connect_checked = ->
address = SocketAddrV4::localhost 70000
TcpStream::connect address
main = ->
match connect_checked!
Result::Ok _ => 1
Result::Err IoError::InvalidAddress =>
"invalid port".println!
0
Result::Err _ => 2
The output is invalid port. For a valid address, connect performs a blocking IPv4 TCP connection and can return an operating-system error when no server is listening. The returned stream owns its descriptor; move it to the component that owns the connection and do not access it after that move.
Listening and accepting
TcpListener::bind creates a blocking listener, local_addr! reports the selected address, and accept! waits for a client and returns a new owned TcpStream. Binding port zero makes the setup reproducible without reserving a fixed port.
> stdlib::io::IoError
> stdlib::net::SocketAddrV4
> stdlib::net::TcpListener
open_listener: () -> Result SocketAddrV4, IoError
open_listener = ->
listener = TcpListener::bind SocketAddrV4::localhost 0?
listener.local_addr!
main = ->
match open_listener!
Result::Ok address =>
address.port > 0 .println!
address.ip.a as I64 .println!
0
Result::Err _ => 1
The output is true and 127. A listener created with port zero must be queried with local_addr! before a client can use the selected port. accept! is intentionally omitted from this setup-only example because it blocks until a client connects; the complete round-trip below exercises it with an in-process client.
Reading, writing, and shutdown
TcpStream implements the generic Read and Write traits and also has direct recv, send, shutdown, and shutdown_write methods. This complete round-trip writes a native subslice, half-closes the client write side, then receives the sent bytes on the server side.
> stdlib::io::IoError
> stdlib::io::Write
> stdlib::net::SocketAddrV4
> stdlib::net::TcpListener
> stdlib::net::TcpStream
roundtrip: () -> Result I64, IoError
roundtrip = ->
listener = TcpListener::bind SocketAddrV4::localhost 0?
address = listener.local_addr!?
client = TcpStream::connect address?
server = listener.accept!?
bytes: [U8; 5] = [112, 105, 110, 103, 120]
written = client.write_all (&bytes[..4])?
client.shutdown_write!?
mut received: [U8; 4] = [0; 4]
mut total = 0
while total < 4
count = server.recv (&mut received[total..])?
if count == 0
failure: Result I64, IoError = Result::Err IoError::InvalidInput
return failure
total = total + count
server.shutdown!?
written.println!
total.println!
received[0] as I64 .println!
received[3] as I64 .println!
Result::Ok 0
main = ->
match roundtrip!
Result::Ok code => code
Result::Err _ => 1
On success, the output is 4, 4, 112, and 103, corresponding to ping. write_all sends the four-byte prefix, while each recv fills only the still-empty suffix of the destination. The loop handles arbitrary short reads. This example uses InvalidInput to report an early end of stream because the current IoError has no dedicated unexpected-EOF variant.
shutdown_write! prevents further client writes while permitting reads; it also lets the peer observe end of stream after draining the sent bytes. shutdown! disables both directions. Neither operation releases descriptor ownership: the stream’s Drop still closes the descriptor. Both shutdown calls return Result (), IoError, so their errors are propagated here.
Use write_all (&buffer[..count]) to forward a received prefix and write_all (&buffer[start..end]) to send a middle portion. The slice carries its own length; there is no separate prefix-count argument. send, like the trait method write, performs a single write and may return a short count. write_str writes all string bytes. recv takes a shared stream handle and a mutable buffer; the trait method read requires a mutable stream receiver as well. For a nonempty receive buffer, Ok 0 means the peer has ended its sending direction.
Ownership and errors
Listeners and streams are ordinary owned values. Moving an accepted stream into a worker transfers descriptor responsibility. Arc can share ownership, but it does not serialize complete messages: concurrent writers can interleave their writes. Use a single writer or explicit synchronization when ordering matters. IoError::InvalidAddress covers an invalid port, while IoError::Os code carries an operating-system failure such as a refused connection or failed bind. Complete-write helpers can also return IoError::WriteZero.
Do not read a TCP message by assuming one recv equals one application message. TCP is a byte stream: implement framing, handle partial reads and writes, and define a shutdown path before adding protocol logic.
Current scope and limits
Networking is intentionally small:
- IPv4 only;
- blocking calls only;
- TCP only;
- no TLS;
- no async runtime;
- no socket-timeout abstraction.
Threads can provide limited blocking concurrency, but there are no channels or nonblocking task APIs. A production service also needs protocol framing, resource limits, backpressure, and platform-aware error handling.
For a complete request/response example, continue to Building an HTTP Server. The separate rock_http package implements bounded HTTP parsing and response framing over these safe TCP APIs; it is a local dependency, not a module inside stdlib.
Threads and Synchronization
The standard library provides owned POSIX threads, atomic reference counting, a spin-yield mutex, and one 64-bit atomic type. These APIs make ownership requirements visible: a spawned closure must own Send data, a join handle provides the option to join, and a mutex guard unlocks when it is dropped.
Spawning and joining
spawn is an explicitly imported function. It accepts an owned FnOnce task whose captured environment and result implement Send, and returns Result (JoinHandle T), ThreadError. join! consumes the handle and returns the task result or a concrete ThreadError.
> stdlib::thread::spawn
main = !->
match spawn (-> 42)
Result::Ok handle =>
match handle.join!
Result::Ok value => value.println!
Result::Err _ => "join failed".println!
Result::Err _ => "spawn failed".println!
The output is 42. A successful spawn transfers the closure to the new thread; join! transfers its result back. Dropping an unjoined handle detaches the thread without blocking, and the worker cleans up its result when it finishes. A failed create or join is data in ThreadError, not an exception.
Detached tasks
Use the explicitly imported spawn_detached when no join handle is needed. It accepts an owned Send task implementing FnOnce (), () and returns Result (), ThreadError: success reports that the thread started, not that the task finished.
> stdlib::thread::spawn_detached
main = ->
match spawn_detached (->
"background task".println!
return)
Result::Ok _ => 0
Result::Err _ =>
"spawn failed".println!
1
The explicit return makes the task return unit (()), rather than the result of println!. There is deliberately no expected worker output: returning from main does not wait for detached tasks, so the process can exit before the message is printed. Sleeping is not a completion guarantee; use spawn and join! when the work must finish before exit. Handle any recoverable task errors inside the task, since spawn_detached only reports startup failure.
Owned captures
A spawned closure cannot borrow a stack value that might end before the thread. Move owned data into the closure by using a consuming method or an owned container.
> stdlib::thread::spawn
struct Owned
< value: I64
impl Owned
~@take: I64
~@take = -> self.value
main = !->
owned = Owned
value: 73
match spawn (-> owned.take!)
Result::Ok handle =>
match handle.join!
Result::Ok value => value.println!
Result::Err _ => "join failed".println!
Result::Err _ => "spawn failed".println!
The output is 73. The consuming owned.take! call makes the closure capture owned by value, and the worker consumes it when the closure runs. A closure that instead borrowed the local owned would not satisfy the required thread-safety bounds.
Shared mutable state
Use Arc (Mutex T) when several owned workers must update one value. Arc::clone shares ownership; Mutex::lock! gives one worker a MutexGuard T; get_mut! changes the protected value; and dropping the guard releases the lock.
> stdlib::arc::Arc
> stdlib::sync::Mutex
> stdlib::thread::spawn
struct Counter
< value: I64
impl Counter
^@increment: () -> ()
^@increment = ->
self.value = self.value + 1
return
struct Worker
< counter: Arc (Mutex Counter)
< iterations: I64
impl Worker
~@run: I64
~@run = ->
mut index: I64 = 0
while index < self.iterations
mut guard = self.counter.lock!
guard.get_mut!.increment!
index = index + 1
index
main = !->
counter: Arc (Mutex Counter) = Arc::new (Mutex::new (Counter
value: 0))
first = Worker
counter: counter.clone!
iterations: 1000
second = Worker
counter: counter.clone!
iterations: 1000
first_handle = spawn (-> first.run!)
second_handle = spawn (-> second.run!)
match first_handle
Result::Ok handle =>
match handle.join!
Result::Ok _ => 0
Result::Err _ => 1
Result::Err _ => 1
match second_handle
Result::Ok handle =>
match handle.join!
Result::Ok _ => 0
Result::Err _ => 1
Result::Err _ => 1
guard = counter.lock!
guard.get!.value.println!
Both workers own a cloned Arc, but only one guard can update the Counter at a time. The final output is 2000 when both threads start and join successfully. Keep lock scopes short, never call unknown code while holding a guard, and establish one order before acquiring several mutexes. Arc supplies ownership; it does not supply mutation or synchronization by itself.
Guard lifetime and try_lock!
lock! waits by yielding while the lock word is held. try_lock! returns immediately with Option (MutexGuard T). A guard’s lifetime controls the lock lifetime, so the second attempt below returns None until the first guard is dropped.
> stdlib::sync::Mutex
lock_once: &Mutex I64 -> ()
lock_once = mutex ->
guard = mutex.lock!
match mutex.try_lock!
Option::Some _ => "unexpected lock".println!
Option::None => "locked".println!
return
main = !->
mutex: Mutex I64 = Mutex::new 9
lock_once (&mutex)
match mutex.try_lock!
Option::Some guard => guard.get!.println!
Option::None => "still locked".println!
The output is locked and 9. guard leaves scope before lock_once returns, so the later try_lock! succeeds. A guard is not Send; moving it into a spawned closure is rejected, which prevents a worker from unlocking a mutex through a guard created on another thread.
Atomics
AtomicU64 is an explicitly imported heap-backed atomic value. exchange, fetch_add, fetch_sub, and store return or update exact U64 values.
> stdlib::atomic::AtomicU64
main = !->
counter: AtomicU64 = AtomicU64::new 0 as U64
exchanged: U64 = counter.exchange 3 as U64
previous: U64 = counter.fetch_add 1 as U64
counter.store 5 as U64
next: U64 = counter.fetch_sub 2 as U64
exchanged as I64 .println!
previous as I64 .println!
next as I64 .println!
The output is 0, 3, and 5: exchange observes zero and writes three, fetch_add observes three, store writes five, and fetch_sub observes five before subtracting two. Atomics are appropriate for counters and simple protocols, not for a compound invariant that requires several reads and writes to change together.
Scheduling helpers
The thread module also exports current_id, yield_now, and sleep_ms. They are explicit imports because they are not prelude vocabulary.
> stdlib::thread::current_id
> stdlib::thread::sleep_ms
> stdlib::thread::yield_now
main = !->
thread_id: U64 = current_id!
thread_id as I64 .println!
yield_now!
sleep_ms 1
The first line is a host-specific pthread identifier; the program then yields and sleeps for approximately one millisecond. Neither helper creates parallel work or guarantees a scheduling order.
Current limits and common mistakes
- The implementation is pthread-oriented and assumes a Linux-style ABI.
Mutexuses an atomic word plussched_yield, not a platform blocking primitive.- There are no channels, condition variables, thread pools, async functions, or executors.
- Dropping a
JoinHandledetaches the thread;spawn_detachedstarts a unit task without returning a handle. Neither makes process exit wait for completion. - Closure-environment cleanup still has incomplete cases; treat concurrency as a prototype runtime feature.
- Do not capture a reference, a
MutexGuard, or an owned struct that contains a reference in a spawned closure.
Building an HTTP Server
TCP delivers bytes, not requests. An HTTP server must recognize where headers end, wait for the declared body length, and write a response whose framing matches its body. The rock_http library supplies those operations using Rock’s safe I/O, collections, and threading APIs.
rock_http is a separate package, not part of the standard library. It lives in the rock-lang-org/rock_http repository. Import it as rock_http::..., not stdlib::http::....
In this chapter we will build a small loopback server with a health endpoint and a binary echo endpoint. It is a local learning project, not a hardened Internet-facing service.
Before starting, read Option, Result, and ?, Packages and Dependencies, and Input, Output, and Files. The networking and concurrency chapters explain the socket and worker ownership that the library manages for us.
The Program’s Architecture
The application and library have distinct responsibilities:
TCP connection
-> bounded request parser
-> owned Request
-> application handler: routing and response construction
-> owned Response
-> framing validation and complete writes
-> connection closes
Keep routing separate from socket operations. The handler can be exercised with an owned request without accepting a connection, while the parser can be exercised with a byte buffer without starting a server. This separation also keeps partial reads, response lengths, and descriptor cleanup out of application branches.
Create the Project
Use Rock v0.6.0 with its matching standard library, or a compatible development toolchain. The commands below use the rock application CLI, which builds local dependencies and passes their artifacts to the compiler for you.
The complete project is included at docs/examples/http-server/. From the Rock repository root, clone the library alongside the compiler checkout and select the revision used by this example:
$ git clone https://github.com/rock-lang-org/rock_http.git ../rock_http
$ git -C ../rock_http checkout --detach 19beaf0eeba22e01de16939ca1475bd540cf7b5c
The checkouts have this layout:
rock_http/
rock.toml
src/lib.rk
Rock/
docs/
examples/
http-server/
rock.toml
src/main.rk
The following listings are the two files of this project, not independent single-file programs. The application manifest, docs/examples/http-server/rock.toml, is:
[crate]
name = "book_http_server"
version = "0.1.0"
[lib]
path = "src/main.rk"
[dependencies]
rock_http = { path = "../../../../rock_http" }
The current manifest format uses [lib].path for the application’s entry file too. Dependency paths are relative to the directory containing that manifest, not the shell’s current directory. From docs/examples/http-server, four parent steps reach the directory containing both checkouts. If you place the application elsewhere, adjust the path to the actual rock_http directory.
Do not replace this path with a registry version or Git dependency: those manifest dependency sources are not implemented. The explicit checkout above pins the library revision instead. Do not add a second local stdlib dependency for this tutorial either; rock supplies the selected toolchain’s standard-library artifact to both packages.
Handle a Request
Put the following complete application in src/main.rk:
> stdlib::net::SocketAddrV4
> rock_http::config::ServerConfig
> rock_http::error::HttpError
> rock_http::request::Method
> rock_http::request::Request
> rock_http::response::Response
> rock_http::server::Server
handle: Request -> Response
handle = request ->
if request.target.as_str! == "/health"
allowed = match *(&request.method)
Method::Get => true
Method::Head => true
_ => false
if allowed
(Response::text 200, "ok\n").with_header "Content-Type", "text/plain"
else
(Response::text 405, "method not allowed\n").with_header "Allow", "GET, HEAD"
else if request.target.as_str! == "/echo"
allowed = match *(&request.method)
Method::Post => true
_ => false
if allowed
(Response::bytes 200, request.body).with_header "Content-Type", "application/octet-stream"
else
(Response::text 405, "method not allowed\n").with_header "Allow", "POST"
else
(Response::text 404, "not found\n").with_header "Content-Type", "text/plain"
run_server: () -> Result (), HttpError
run_server = ->
config = ServerConfig
max_request_line_bytes: 8192
max_header_bytes: 32768
max_headers: 100
max_body_bytes: 65536
server = Server::bind_with_config (SocketAddrV4::localhost 8080), config?
"Listening on http://127.0.0.1:8080".println!
server.serve handle
main = ->
match run_server!
Result::Ok _ => 0
Result::Err _ =>
"HTTP server stopped with an error".println!
1
handle consumes one owned Request and returns one owned Response. Borrowing request.method for the match lets us inspect the enum without consuming the request’s data. For /echo, Response::bytes takes ownership of request.body, a Vec U8; there is no need to reinterpret arbitrary bytes as text or copy the body into another vector.
Response::text copies its &Str argument into an owned string. with_header consumes the response and returns it with another header appended, which allows the construction and header call to be chained. Text responses do not automatically receive a Content-Type: the handler sets text/plain for the successful health response and the 404 response, while the 405 responses add only an Allow header.
The routing here is deliberately exact. Request.target is the raw request target, not a parsed URL: /health?verbose=1 does not match /health. There is no automatic query parsing, percent decoding, routing, JSON decoding, or content negotiation. Method recognizes GET, HEAD, POST, PUT, DELETE, OPTIONS, and PATCH; other valid method tokens are represented as Method::Other String.
Run and Call the Server
From the repository root, enter the project and build it:
$ cd docs/examples/http-server
$ rock build
$ rock run
Listening on http://127.0.0.1:8080
rock run builds before launching, so a separate rock build is optional. The server binds only to loopback. If binding fails, for example because port 8080 is in use, main reports failure and exits with status 1. Change the port in the source if needed.
Leave that terminal running and use a second terminal:
$ curl --http1.1 --max-time 5 -i http://127.0.0.1:8080/health
HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 3
Connection: close
ok
$ curl --http1.1 --max-time 5 -i --data-binary 'hello' http://127.0.0.1:8080/echo
HTTP/1.1 200 OK
Content-Type: application/octet-stream
Content-Length: 5
Connection: close
hello
The echo body has no trailing newline, so the next shell prompt may appear immediately after hello. --data-binary sends a POST request with a known content length. The example does not implement chunked request bodies.
Try the remaining behaviors:
$ curl --http1.1 --max-time 5 -I http://127.0.0.1:8080/health
$ curl --http1.1 --max-time 5 -i http://127.0.0.1:8080/echo
$ curl --http1.1 --max-time 5 -i http://127.0.0.1:8080/missing
The HEAD request returns headers with Content-Length: 3 but no body. GET /echo returns 405 with Allow: POST, and /missing returns 404. Stop the server with Ctrl-C when finished; this library does not expose a graceful-shutdown API.
Ownership and Serving
Server::bind address uses ServerConfig::default!. Server::bind_with_config address, config uses explicit limits, as above. Both return Result Server, HttpError. local_addr! can report the bound address, including an operating-system-selected port when binding port zero; its error type is IoError.
serve consumes the server and shares the handler through Arc. Each accepted connection is moved into a detached worker thread. Workers may invoke the handler concurrently, so this tutorial uses a capture-free named function. Shared mutable application state needs appropriate synchronization; shared ownership alone is not a lock.
The return type of serve is Result (), HttpError, but normal serving keeps accepting connections indefinitely. Listener-accept and thread-spawn failures can escape to main. Request parsing, handler response validation, and socket-write failures inside an existing worker affect only that connection; the worker currently discards its result. An invalid application response does not automatically become a 500 response, and main is not a per-request error logger.
Inspect Headers Without a Socket
Requests retain headers in order, including duplicates. request.header name returns the first matching value as Option &Str; request.headers_named name returns owned copies of every matching value as Vec String. Lookup compares names case-insensitively.
The parser can be used independently of Server. This complete alternative src/main.rk uses the same project manifest and illustrates duplicate lookup without opening a port:
> stdlib::string::str_as_bytes
> rock_http::config::ServerConfig
> rock_http::parser::DecodeResult
> rock_http::parser::parse_request
main = ->
config = ServerConfig::default!
bytes = str_as_bytes "GET / HTTP/1.1\r\nHost: local\r\nX-Tag: one\r\nX-Tag: two\r\n\r\n"
match parse_request bytes, &config
DecodeResult::Complete request, consumed =>
match request.header "HOST"
Option::Some value => value.println!
Option::None => "missing host".println!
tags = request.headers_named "x-tag"
tags.len!.println!
0
DecodeResult::Incomplete => 1
DecodeResult::Error _ => 2
This prints local and 2. DecodeResult::Complete also reports the consumed byte count, useful when a caller has buffered additional data. Incomplete means the parser needs more bytes, not that it remembers the previous call: supply the accumulated buffer again. Server does this buffering for you but handles only one request per connection and does not serve any trailing pipelined requests.
Limits and Protocol Scope
The default configuration and the meaning of its fields are:
| Field | Default | What is limited |
|---|---|---|
max_request_line_bytes | 8192 | Request line before its CRLF terminator |
max_header_bytes | 32768 | Entire request head, including request line and final CRLF CRLF |
max_headers | 100 | Number of header fields, counting duplicates |
max_body_bytes | 1048576 | Declared Content-Length body size |
Our server lowers the body limit to 65536 bytes. Choose nonnegative, sensible limits; the configuration constructor does not validate them. These are parser limits, not a total process-memory budget: requests and bodies are buffered, a worker reads in 4096-byte chunks, and multiple connections allocate independently.
The parser accepts HTTP/1.0 and HTTP/1.1 request lines with CRLF separators and fixed-length bodies. A missing Content-Length means an empty body, not a body delimited by closing the connection. Repeated content lengths are accepted only when their numeric values agree. Header names must be HTTP tokens; values currently permit printable ASCII and horizontal tab, not arbitrary non-ASCII bytes. The parser is not a complete HTTP conformance validator, including for Host requirements.
The server maps request failures to these responses, where it can still write to the connection:
| Status | Examples |
|---|---|
| 400 | Malformed request, conflicting or invalid content length, unsupported version, oversized request line |
| 413 | Declared body exceeds the configured limit |
| 431 | Request head exceeds the byte limit or header count limit |
| 501 | Any Transfer-Encoding header, including chunked coding |
Response serialization validates status values in 100..=999, header names and values, and any supplied content length before writing. Let it calculate Content-Length; a mismatched manual value is rejected. Transfer-encoding response headers are rejected too. The writer suppresses bodies for HEAD and for 1xx, 204, and 304 statuses; HEAD normally keeps the corresponding body’s length, while those body-forbidden statuses are framed with length zero in this implementation.
Responses use HTTP/1.1 status lines and default to Connection: close. Do not override that header with keep-alive: the server still closes after one response. There is no keep-alive, pipelining, TLS, compression, multipart parser, WebSocket upgrade, async runtime, HTTP/2, or HTTP/3 support. There is also no automatic 100 Continue handshake; clients should send fixed-length bodies without waiting for one.
Finally, safe memory access does not make an unbounded service safe to expose publicly. There are no read deadlines, connection-count limits, worker-pool bounds, or built-in backpressure. A slow client can occupy a blocking worker, and a large number of clients can exhaust resources even when every individual request is within its limits. Keep this tutorial on loopback and treat deployment hardening as a separate responsibility.
Unsafe Rock and Raw Pointers
Safe Rock checks ownership, initialization, and borrowing. unsafe marks a
small boundary where the programmer accepts an invariant the compiler cannot
prove. It does not disable ordinary type checking or name resolution.
Raw pointer types
*T is a raw pointer type. A pointer value must have a documented source; an
invented integer or null value is not a valid target. The type and dereference
grammar are distinct:
POINTER_TYPE ::= "*" TYPE
POINTER_READ ::= "unsafe" "*" EXPRESSION
POINTER_WRITE ::= "unsafe" "*" EXPRESSION "=" EXPRESSION
This complete program obtains a pointer from a live mutable borrow and writes through it while the owner remains alive:
unsafe set_i64: &mut I64 -> ()
set_i64 = reference ->
pointer: *I64 = reference as *I64
unsafe *pointer = 7
return
main = !->
mut value: I64 = 42
unsafe set_i64 &mut value
value.println!
The contract for set_i64 is that the argument is aligned and initialized for
I64, remains live for the call, and is not accessed through another
incompatible alias during the write. The pointer is borrowed, not owned, so
the function must not return it or call a deallocator.
Unsafe functions
Mark a function unsafe when every caller must uphold a contract. The unsafe declaration appears before its definition and before its call:
unsafe read_i64: &I64 -> I64
read_i64 = reference ->
pointer: *I64 = reference as *I64
unsafe *pointer
main = !->
value: I64 = 8
result: I64 = unsafe read_i64 &value
result.println!
The contract states validity, alignment, initialization, aliasing, and lifetime
in prose next to the function. Calling the function without unsafe is a
compiler error; the marker makes the review boundary visible at every call
site.
Pointer casts
as changes the pointer type view. It does not allocate, initialize, change
alignment, or extend a lifetime. This complete example casts a mutable borrow
only to immediately write the same I64 representation:
unsafe increment_i64: &mut I64 -> ()
increment_i64 = reference ->
pointer: *I64 = reference as *I64
current: I64 = unsafe *pointer
unsafe *pointer = current + 1
return
main = !->
mut value: I64 = 4
unsafe increment_i64 &mut value
value.println!
Do not cast a pointer merely to bypass a failed borrow. A cast is justified only when the target representation and all aliasing rules have already been established by the wrapper’s contract.
Owning foreign allocations
An owning pointer needs one allocator and one matching release path. The following complete program uses the POSIX stdlib declarations, checks the allocation result, never dereferences uninitialized memory, and frees the allocation once:
> stdlib::libc::malloc
> stdlib::libc::free
unsafe allocate_and_release: I64 -> I64
allocate_and_release = size ->
if size <= 0
-1
else
pointer: *U8 = malloc size
if (pointer as I64) == 0
-1
else
free pointer
size
main = !->
result: I64 = unsafe allocate_and_release 8
result.println!
Its contract is that positive size is a byte count, malloc and free are
the matching C allocator pair, and no caller retains pointer after the
wrapper returns. A production owner would store the pointer and size together,
define cleanup for every early-return path, and expose initialized operations
rather than a raw pointer. It must not pass a Rock-owned allocation to free.
Building a safe abstraction
Keep unsafe code small and put the invariant at its edge:
- State the representation, alignment, initialization, aliasing, lifetime, and ownership contract.
- Check every condition that safe code can check, including lengths and null results.
- Perform the minimum raw operation.
- Return a value or safe owner whose public methods preserve the invariant.
- Release each owned allocation exactly once, using its creating allocator.
String, Vec, Box, Arc, files, sockets, and synchronization primitives
follow this general shape, although the prototype still has known cleanup gaps.
What unsafe cannot justify
An unsafe block cannot make a dangling pointer valid, make a data race safe, initialize bytes that were never written, or give two owners permission to free one allocation. It also cannot make a foreign ABI compatible. If the invariant cannot be explained locally, redesign the boundary or return a structured error before entering unsafe code.
Current target caveat
The current FFI-backed implementations assume POSIX behavior and the
x86_64-unknown-linux-gnu target. Raw pointer widths, C integer conventions,
linker libraries, and synchronization ABIs can differ elsewhere. Keep
target-specific declarations isolated and compile each unsafe boundary on the
target where it will run.
Foreign Functions
extern declares a function whose implementation is supplied by the linker,
usually by a C-compatible library. The declaration is a type contract, not an
implementation or a request to search the host for an arbitrary symbol.
A scalar function
This complete program declares the foreign symbol before using it:
extern sqrt: F64 -> F64
main = !->
result: F64 = sqrt 144.0
result.println!
The target C library must export sqrt with the declared calling convention.
F64 is a fixed-width floating representation suitable for this simple
boundary. The program owns no memory passed to sqrt, so there is no cleanup
operation associated with this call.
Build this kind of example with rock only on the documented target,
x86_64-unknown-linux-gnu. A different target may use a different library
name, ABI, or linker setup.
Pointer contracts
Raw pointers do not prove validity, alignment, initialization, aliasing, or
lifetime. The contract belongs next to the declaration that accepts one. This
complete example converts a live mutable borrow into a raw pointer, reads the
same initialized I64, and never transfers ownership:
unsafe read_i64: &mut I64 -> I64
read_i64 = reference ->
pointer: *I64 = reference as *I64
unsafe *pointer
main = !->
mut value: I64 = 41
result: I64 = unsafe read_i64 &mut value
result.println!
The caller contract is: reference points to the live value for the whole
call, is aligned for I64, is initialized, and is not accessed through an
incompatible alias while the call runs. The function returns before the
borrowed storage can end, so it returns an integer rather than the pointer.
There is no free call because the pointer is a borrowed view of a stack local.
Never invent an address or use a null pointer as a test value. A pointer must come from a live borrow, a documented allocator, or a foreign API that has returned ownership under an explicit contract.
C strings and borrowed ownership
The standard library exports the POSIX strlen declaration from a non-prelude
module. This complete wrapper imports it explicitly, declares its pointer local,
and keeps the owning String alive until the foreign call returns:
> stdlib::libc::strlen
unsafe c_string_length: String -> I64
c_string_length = text ->
pointer: *U8 = text.as_ptr!
strlen pointer
main = !->
text: String = String::from "Rock"
length: I64 = unsafe c_string_length text
length.println!
The imported declaration is equivalent to extern strlen: *U8 -> I64 in the
stdlib’s libc module. Its contract requires a non-null pointer to a readable,
initialized, null-terminated byte sequence. String supplies that terminator;
the wrapper moves the owner into the function and drops it only after strlen
has returned. A borrowed &Str is not automatically safe for this API because
it may be length-delimited and may contain an interior null byte.
Allocation and release
When a foreign allocator returns ownership, pair it with the matching foreign release function. This complete example never dereferences the allocated bytes, rejects non-positive sizes, checks the returned pointer, and frees one successful allocation exactly once:
> stdlib::libc::malloc
> stdlib::libc::free
unsafe allocate_and_release: I64 -> I64
allocate_and_release = size ->
if size <= 0
-1
else
pointer: *U8 = malloc size
if (pointer as I64) == 0
-1
else
free pointer
size
main = !->
result: I64 = unsafe allocate_and_release 16
result.println!
The malloc and free declarations are exported by the POSIX-oriented stdlib
libc module. The wrapper’s unsafe contract is that size is a positive byte
count and that the returned pointer is freed only by the allocator that created
it. Since the example does not write or read the allocation, it does not claim
an initialized element layout. A real owner type must also define what happens
on every construction failure and must release the pointer exactly once.
ABI-compatible types
Prefer fixed-width primitives such as I32, I64, U32, U64, and F64 at a
foreign boundary. Use raw pointers only with a written representation and
lifetime contract. Do not assume that Bool, an enum, a generic struct, a
closure, an ordinary function value, or a Rock struct has the C ABI layout you
want; Rock does not yet provide stable C-layout attributes.
Every pointer crossing the boundary needs these answers:
- Which side allocated it?
- Which side releases it, and with which allocator?
- Is it borrowed, and until when?
- May the foreign function retain it after the call?
- Can a callback run after the Rock stack frame returns?
- How are errors and sentinel values represented?
Never free a Rock-owned pointer through an unrelated C allocator. Never return
a pointer into a local or an already-dropped String. Keep the unsafe wrapper
small and expose a safe Rock API that preserves the representation and cleanup
invariant.
Target scope
The shipped FFI-backed stdlib currently assumes POSIX APIs and the
x86_64-unknown-linux-gnu target. Put foreign declarations in target-specific
modules and isolate them behind portable traits where that improves the public
API. A program that needs Windows, another architecture, nonblocking sockets,
or a C-layout aggregate must provide a separate boundary implementation and
test that target explicitly.
Syntax at a Glance
This chapter is a reference map of the active Rock syntax. It separates the notation used to describe syntax from programs that can be copied into a source file.
Reading grammar notation
Grammar descriptions in this chapter use a small notation:
::=means “is defined as”.- A word written in
UPPER_CASEis a metavariable supplied by the grammar. - Text in double quotes is a literal token.
|separates alternatives.- A suffix
?means zero or one occurrence. - A suffix
*means zero or more occurrences. - A suffix
+means one or more occurrences. INDENTandDEDENTare layout tokens produced by the lexer.
These marks describe grammar productions; they are not Rock source. Every
rock fence below is a complete example with its declarations in the same
fence. No grammar production uses an omission marker.
The condensed productions reuse a few base names throughout the chapter:
IDENTIFIERis a lowercase or underscore-led source name;TYPE_NAMEis an uppercase-led type name.DIGITis one decimal digit,CHARACTERis one source character accepted by the literal lexer, andOPERATORis one or more operator characters.PATHandTYPE_PATHare::-separated identifier or type segments.EXPRESSIONmeans any expression form described in this chapter;STATEMENTis an expression, binding, assignment,return,break, orcontinuein a block.BODYis either one expression after an arrow or an indentedBLOCK;DECLARATIONis any top-level declaration production.- A name ending in
_LISTis a comma-separated sequence of the named element.PARAMETER_LISTis a comma-separated pattern list,ARGUMENT_LISTis a comma-separated expression list, andTYPE_PARAMETER_LISTis a comma-separated type-name list. MACRO_PATTERN,MACRO_TEMPLATE, andMACRO_ARGUMENTare token sequences interpreted by the declarative macro matcher; their typed captures are defined in the macro production below.
These are metavariables, not words that appear literally in Rock source.
The top-level shape is:
PROGRAM ::= TOP_LEVEL_ITEM*
TOP_LEVEL_ITEM ::= IMPORT | MODULE | EXPORT | TYPE_DECLARATION | TRAIT_DECLARATION | IMPLEMENTATION | FUNCTION_DECLARATION | EXTERN_DECLARATION | MACRO_DECLARATION | OPERATOR_DECLARATION
Layout and comments
Rock uses indentation to delimit blocks. The first indented block establishes the file’s indentation step; use that step consistently. A block starts after a declaration or control-flow header and ends when the indentation returns to the surrounding level.
BLOCK ::= INDENT STATEMENT+ DEDENT
IF_EXPRESSION ::= "if" EXPRESSION BLOCK ("else" (BLOCK | IF_EXPRESSION))?
This is a complete layout example:
main = !->
if true
"the condition is true".println!
else
"the condition is false".println!
Line comments begin with //. Block comments begin with /* and end with
*/; both forms are ignored by the parser.
main = !->
// This comment ends at the line break.
/* This comment spans one complete comment token. */
"comments do not produce values".println!
Names and keywords
Identifiers name declarations and local bindings. Type names conventionally start with an uppercase letter, while functions and bindings conventionally start with a lowercase letter. The lexer reserves the following keywords:
KEYWORD ::= "struct" | "enum" | "trait" | "impl" | "if" | "then" | "else" | "for" | "in" | "while" | "loop" | "macro" | "mod" | "extern" | "match" | "unsafe" | "infix" | "return" | "continue" | "break" | "type" | "mut" | "where" | "as" | "true" | "false"
Declaration punctuation has its own meaning:
<exports a declaration, field, module, or path.>imports a path.@,^@, and~@describe shared, mutable, and consuming receivers.%nameinvokes a macro and$namenames a macro fragment.
Literals
The literal forms are:
INTEGER_LITERAL ::= DIGIT+
FLOAT_LITERAL ::= DIGIT+ "." DIGIT+
BOOLEAN_LITERAL ::= "true" | "false"
CHAR_LITERAL ::= "'" CHARACTER "'"
STRING_LITERAL ::= "\"" CHARACTER* "\""
ARRAY_LITERAL ::= "[" EXPRESSION_LIST? "]" | "[" EXPRESSION ";" INTEGER_LITERAL "]"
TUPLE_LITERAL ::= "(" EXPRESSION "," EXPRESSION_LIST? ")"
RANGE_EXPRESSION ::= EXPRESSION? (".." | "..=") EXPRESSION?
An inclusive range written with ..= requires an end expression. Native ranges are first-class values and can be used for slicing, for example &values[1..3], &values[..count], and &values[..].
This program constructs integer, floating-point, Boolean, character, string, array, and tuple literals, with explicit annotations for their types:
main = !->
count: I64 = 42
ratio: F64 = 3.5
enabled: Bool = true
initial: Char = 'R'
greeting: &Str = "Rock"
values: [I64; 3] = [1, 2, 3]
repeated: [I64; 3] = [7; 3]
pair: (I64, &Str) = (1, "one")
count.println!
ratio.println!
enabled.println!
initial.println!
greeting.println!
values.println!
repeated.println!
pair.0.println!
pair.1.println!
Strings and characters are byte-oriented in the current implementation. Use ordinary ASCII escapes only when targeting the current parser and verify non-ASCII behavior against the target compiler.
Bindings and places
The grammar distinguishes a binding from an assignment target:
BINDING ::= "mut"? IDENTIFIER (":" TYPE)? "=" EXPRESSION
ASSIGNMENT ::= PLACE "=" EXPRESSION
PLACE ::= IDENTIFIER | PLACE "." IDENTIFIER | PLACE "[" EXPRESSION "]" | "(" PLACE "," PLACE_LIST ")"
The following complete program declares every place before assigning through it:
main = !->
mut first: I64 = 1
second: I64 = 2
first = second + 3
mut values: [I64; 2] = [first, second]
values[1] = first
first.println!
values[1].println!
mut permits mutable borrowing and mutable receiver calls. Ordinary local
reassignment is available for a binding whose inferred type does not change.
Functions and calls
Function signatures and definitions use arrows. Multiple parameter arrows are curried type notation; a definition with comma-separated parameters receives those parameters in one call expression.
FUNCTION_SIGNATURE ::= IDENTIFIER ":" TYPE
FUNCTION_DEFINITION ::= IDENTIFIER "=" PARAMETER_LIST ARROW BODY
ARROW ::= "->" | "!->" | "~>"
CALL ::= EXPRESSION ARGUMENT_LIST | EXPRESSION "!"
This program shows value-returning, unit-arrow, and zero-argument calls:
add: I64 -> I64 -> I64
add = left, right -> left + right
announce: &Str -> ()
announce = message !->
message.println!
main = !->
total: I64 = add 2, 3
announce "five"
total.println!
Arguments are separated by commas, and each argument consumes a complete
expression. A nested call owns its comma-separated arguments without extra
grouping; parentheses do not turn a call into Rust-style
function(arguments) syntax.
In an inline argument list, a bare lambda has at most one parameter; commas
separate call arguments. Group a multi-parameter lambda explicitly:
apply (left, right -> left + right).
add: I64 -> I64 -> I64
add = left, right -> left + right
double: I64 -> I64
double = value -> value * 2
main = !->
result: I64 = double add 2, 3
result.println!
The postfix ! calls a function or method with no explicit arguments. A
method uses a value receiver and a type-associated function uses ::.
Structs and enums
The declaration forms are:
STRUCT_DECLARATION ::= "struct" TYPE_NAME TYPE_PARAMETER_LIST? FIELD_DECLARATION*
FIELD_DECLARATION ::= "<"? IDENTIFIER ":" TYPE
ENUM_DECLARATION ::= "enum" TYPE_NAME TYPE_PARAMETER_LIST? VARIANT_DECLARATION+
VARIANT_DECLARATION ::= IDENTIFIER TYPE_LIST?
A complete program can combine named fields, associated construction, and variant matching:
struct Point
< x: I64
< y: I64
enum Shape
Dot Point
Empty
impl Point
new: I64 -> I64 -> Point
new = x, y ->
Point
x: x
y: y
describe: Shape -> &Str
describe = shape ->
match shape
Shape::Dot point => "dot"
Shape::Empty => "empty"
main = !->
point: Point = Point::new 3, 4
shape: Shape = Shape::Dot point
describe shape .println!
All required fields must be initialized. An enum constructor is qualified by its enclosing type so that the variant’s payload type is unambiguous.
Traits, implementations, and receivers
Trait and implementation grammar is:
TRAIT_DECLARATION ::= "trait" TYPE_NAME TYPE_PARAMETER_LIST? TRAIT_MEMBER+
TRAIT_MEMBER ::= "type" IDENTIFIER | RECEIVER? IDENTIFIER ":" TYPE
IMPLEMENTATION ::= "impl" TYPE_NAME TYPE_ARGUMENT_LIST? ("for" TYPE)? IMPLEMENTATION_MEMBER+
RECEIVER ::= "@" | "^@" | "~@"
The receiver markers express the ownership contract. This complete example uses all three receiver modes without referring to a declaration outside the fence:
struct Counter
< value: I64
impl Counter
@read: I64
@read = -> @value
^@add: I64 -> ()
^@add = amount ->
self.value = self.value + amount
return
~@finish: I64
~@finish = -> self.value
main = !->
mut counter: Counter = Counter
value: 4
counter.add 3
current: I64 = counter.read!
total: I64 = counter.finish!
current.println!
total.println!
@read observes, ^@add mutates, and ~@finish consumes. A consuming call
ends the owner’s usable lifetime, so the example does not use counter after
finish!.
Bounds follow declarations and signatures:
WHERE_CLAUSE ::= "where" BOUND ("," BOUND)*
BOUND ::= TYPE_NAME ":" TRAIT_PATH_LIST | TYPE_NAME IDENTIFIER ":" TRAIT_PATH_LIST
The following program declares a generic trait with an associated type and implements it for a concrete type:
trait Combiner T
type Output
@combine: T -> Self::Output
struct AddOne
impl Combiner I64 for AddOne
type Output = I64
@combine = value -> value + 1
main = !->
()
Combiner T declares the argument type as a trait parameter, while each
implementation chooses Output. AddOne uses I64 for both and returns its
argument plus one. This main returns unit without calling combine; see
Generics for functions with where bounds.
Type forms
Type expressions use these productions:
TYPE ::= TYPE_NAME | TYPE_APPLICATION | TYPE_LAMBDA | TYPE_PROJECTION | TYPE_REFERENCE | TYPE_POINTER | TYPE_ARRAY | TYPE_SLICE | TYPE_TUPLE | TYPE_FUNCTION | "()" | "_"
TYPE_APPLICATION ::= TYPE_NAME TYPE_ARGUMENT ("," TYPE_ARGUMENT)*
TYPE_ARGUMENT ::= TYPE | "_"
TYPE_LAMBDA ::= "\\" TYPE_NAME "->" TYPE
TYPE_PROJECTION ::= TYPE_NAME "::" TYPE_NAME
TYPE_REFERENCE ::= "&" TYPE | "&mut" TYPE
TYPE_POINTER ::= "*" TYPE
TYPE_ARRAY ::= "[" TYPE ";" INTEGER_LITERAL "]"
TYPE_SLICE ::= "[" TYPE "]"
TYPE_TUPLE ::= "(" TYPE "," TYPE_LIST? ")"
TYPE_FUNCTION ::= TYPE "->" TYPE
The following program combines a generic struct, a generic function signature, a concrete type application, and a shared reference:
struct Pair A, B
< first: A
< second: B
identity: T -> T
identity = value -> value
borrow_first: &Pair I64, &Str -> &Str
borrow_first = pair -> pair.second
main = !->
pair: Pair I64, &Str = Pair
first: 7
second: "seven"
text: &Str = borrow_first &pair
text.println!
Pair I64, &Str fixes the two field types. Despite its name, borrow_first
reads pair.second through a shared reference, so the program prints seven.
The generic identity function is declared but not called.
Other forms in the grammar include _ for an inference hole, Self::Output
for an associated type projection, *T for a raw pointer, and &mut T for a
mutable borrowed view.
Control flow and patterns
The core control-flow productions are:
CONTROL_FLOW ::= IF_EXPRESSION | WHILE_LOOP | FOR_LOOP | LOOP_EXPRESSION | MATCH_EXPRESSION
WHILE_LOOP ::= "while" EXPRESSION BLOCK
FOR_LOOP ::= "for" PATTERN "in" EXPRESSION BLOCK
LOOP_EXPRESSION ::= "loop" BLOCK
MATCH_EXPRESSION ::= "match" EXPRESSION MATCH_ARM+
MATCH_ARM ::= PATTERN ("if" EXPRESSION)? "=>" EXPRESSION
This complete program uses a while, a for, break, and continue:
main = !->
mut number: I64 = 0
while number < 3
number = number + 1
if number == 2
continue
number.println!
for item in 3..6
item.println!
loop
break
Patterns include bindings, literals, tuple shapes, constructor payloads, and wildcards:
PATTERN ::= "_" | IDENTIFIER | "mut" IDENTIFIER | LITERAL | TUPLE_PATTERN | ARRAY_PATTERN | INSTANCE_PATTERN | "&" PATTERN | "&mut" PATTERN | IDENTIFIER "@" PATTERN
TUPLE_PATTERN ::= "(" PATTERN "," PATTERN_LIST ")"
ARRAY_PATTERN ::= "[" ARRAY_PATTERN_ELEMENT_LIST? "]"
ARRAY_PATTERN_ELEMENT ::= PATTERN | ".." IDENTIFIER
INSTANCE_PATTERN ::= TYPE_PATH | TYPE_PATH PATTERN_LIST | TYPE_PATH FIELD_PATTERN_LIST
FIELD_PATTERN ::= IDENTIFIER ":" PATTERN
Here is a complete constructor-pattern example:
enum Message
Quit
Number I64
label: Message -> &Str
label = message ->
match message
Message::Quit => "quit"
Message::Number number if number > 0 => "positive"
Message::Number _ => "nonpositive"
main = !->
first: &Str = label Message::Quit
second: &Str = label Message::Number 4
first.println!
second.println!
An arm guard runs only after its structural pattern matches. The current
compiler does not diagnose every incomplete enum match, so write every variant
or finish with _ deliberately.
Modules, imports, and visibility
The module grammar is:
MODULE ::= "mod" IDENTIFIER
IMPORT ::= ">" PATH
EXPORT ::= "<" (DECLARATION | PATH)
PATH ::= IDENTIFIER ("::" IDENTIFIER)* ("::*")?
mod name loads name.rk relative to the declaring file. > brings an item
into scope and < exposes an item to consumers. The following project listing
is a complete two-file source arrangement; each file is shown in its entirety
and the imported function is declared before its use in the consumer file.
/tmp/rock-syntax-project/math.rk
< square: I64 -> I64
< square = value -> value * value
/tmp/rock-syntax-project/main.rk
mod math
> math::square
main = !->
result: I64 = square 6
result.println!
Inline module syntax is parsed by some tools but is not a supported compiled project form; use this file-backed arrangement.
Operators and postfix forms
Operators are declarations in the current program or an explicit dependency:
OPERATOR_DECLARATION ::= "infix" INTEGER_LITERAL OPERATOR
INFIX_FUNCTION ::= OPERATOR "=" PARAMETER_LIST "->" BODY
PREFIX_EXPRESSION ::= OPERATOR EXPRESSION
POSTFIX_EXPRESSION ::= EXPRESSION "?" | EXPRESSION "!"
SPACED_DOT_EXPRESSION ::= EXPRESSION " ." IDENTIFIER ARGUMENTS?
Function application has precedence 8. An operator declared above 8 is
part of the final argument; an operator at or below 8 applies to the call
result. A spaced dot has lower precedence and applies to the complete
expression on its left:
double: I64 -> I64
double = value -> value * 2
main = !->
value: I64 = 3
double value + 1 .println!
Option::Some value <&> double .unwrap_or 0 .println!
Use parentheses when an operator above application precedence must instead
operate on the call result: (double value) + 1.
An adjacent & begins a shared-borrow argument: read &value parses as
read (&value), unlike a spaced infix &. Postfix ? after the final
argument propagates the whole call, including when that argument is borrowed:
read &value? means (read (&value))?. The
error-handling chapter
contains a complete example. ? is propagation syntax, not an infix operator
whose precedence is declared by the program.
This complete program defines a pipeline operator, converts its result with an associated string constructor, and prints it with a zero-argument method call:
infix 1 |>
|> = value, function -> function value
double: I64 -> I64
double = value -> value * 2
main = !->
value: I64 = 7 |> double
text: String = String::from value
text.println!
7 |> double produces 14, String::from value creates the text 14,
and text.println! prints it. The standard library supplies common arithmetic
and comparison meanings, but the compiler does not assign a meaning to an
operator spelling by itself.
Macros
Declarative macro grammar uses fragment captures:
MACRO_DECLARATION ::= "macro" IDENTIFIER MACRO_ARM+
MACRO_ARM ::= MACRO_PATTERN "=>" MACRO_TEMPLATE
MACRO_INVOCATION ::= "%" IDENTIFIER MACRO_ARGUMENT*
MACRO_CAPTURE ::= "$" IDENTIFIER ":" FRAGMENT_KIND
FRAGMENT_KIND ::= "ident" | "expr" | "ty"
This complete macro program captures an identifier and emits a function before
main calls it:
macro make_value
$name:ident =>
$name = -> 42
%make_value answer
main = !->
value: I64 = answer!
value.println!
Macro facilities are experimental. Keep macro definitions small, and test the expanded program through the normal compiler rather than treating expansion text as a stable interchange format.
Tooling Reference
Rock applications use one project command: rock. Run it from the directory containing rock.toml so it can resolve the entry source, path dependencies, standard library, and build outputs.
Create a project
Create a project directory containing this rock.toml:
[crate]
name = "rock_book_project"
version = "0.1.0"
[lib]
path = "main.rk"
Create main.rk beside it:
main = !->
"rock-book-project".println!
The manifest’s [lib].path selects the source entry even when that source contains an executable main function.
Everyday commands
$ rock format
$ rock build
$ rock run -- 7
$ rock expand
$ rock artifact
format rewrites the configured source. Inspect formatter changes before committing them, then compile again.
build resolves the manifest and path dependencies, then writes products below the project’s build/ directory. It prints the linked executable path on success.
run builds and executes the project. Values after -- are application arguments rather than options to the build tool.
expand prints macro-expanded source. It is useful when a diagnostic points into generated declarations or when a repetition does not produce the expected items.
artifact materializes a reusable crate artifact. Normal path-dependent builds create and reuse dependency artifacts automatically, so most applications do not need to invoke it directly.
There is no rock test command. Use a complete Rock executable as an application smoke test, or use the Rust integration suite when changing the compiler itself.
Standard library selection
The active toolchain supplies a matching precompiled standard library. rock loads it automatically for ordinary projects and makes the prelude available. A package can opt out explicitly:
[crate]
name = "bare"
version = "0.1.0"
no_std = true
[lib]
path = "lib.rk"
Without the standard library, familiar operators, collections, strings, and prelude traits are unavailable unless the package supplies alternatives through explicit dependencies.
rockup
rockup installs and selects local Rock toolchains. A source checkout can be packaged and installed with:
$ cargo build --release
$ target/release/rockup dev stdlib package \
--path stdlib \
--sysroot target/release
$ target/release/rockup install dev --path target/release
$ target/release/rockup default dev
$ target/release/rockup list
The first installed toolchain becomes the default automatically; rockup default switches it explicitly. Toolchain shims keep the user workflow on rock while selecting matching compiler and standard-library components behind the project command.
Language server
rock-lsp communicates over standard input and output. Configure an LSP client to launch it for .rk files with language identifier rock; it is not a replacement for the application command rock.
| Capability | Current behavior |
|---|---|
| Diagnostics | Refresh on open, change, and save using source frontend analysis; messages include available codes, notes, help, and related locations. |
| Hover | Inferred variable and expression types, function signatures, and supported type, field, variant, and generic symbols. |
| Signature help | Call signature and active parameter information; a comma is the automatic trigger. Clients can also request it explicitly. |
| Project discovery | Nearest ancestor rock.toml; uses its entry graph, path dependency artifacts, and toolchain standard library. |
| Unsaved source | Open buffers overlay files in the entry graph; unopened files come from disk. |
The server does not advertise completion, go-to-definition, references, rename, code actions, semantic tokens, or document formatting. Use rock format for the configured entry file and rock build for full validation, including borrow checking and linking.
Diagnostics are currently published to open documents, not as a complete workspace problem index. Hover and signature help require a successful analysis snapshot; during an invalid edit they may use the last successful snapshot and can be stale. Project resolution is cached until a Rock document is saved or the server restarts; there is no manifest/dependency file-watcher refresh. Save dependency changes and restart if feedback remains out of date.
The server accepts these explicit overrides:
| Argument | Meaning |
|---|---|
--extern-artifact name=/absolute/path/to/crate.rkca | Add an artifact or override a discovered artifact with the same crate name; repeat for multiple crates. |
--no-prelude | Disable dependency-provided prelude injection. This does not disable project dependency resolution. |
These options are for standalone or compiler-development workflows, not normal application setup. Outside a project, the server uses the opened file as its entry and does not discover a standard library automatically. Within a project, [crate].no_std = true also disables automatic standard-library/prelude use.
Neovim plugin
The plugin lives in rock.nvim. It requires Neovim 0.11 or newer and uses vim.lsp.config and vim.lsp.enable, without an additional Lua dependency. Clone that repository, then add its root in init.lua (replace the path with your checkout):
vim.opt.runtimepath:prepend("/absolute/path/to/rock.nvim")
require("rock").setup()
The plugin registers .rk as filetype rock. Its client root markers are rock.toml and then .git, and standalone buffers are allowed; a Git root alone does not supply project dependencies.
| Setup option | Meaning |
|---|---|
cmd | Server command string or argument list; overrides automatic executable selection. |
extern_artifacts | Table mapping crate names to artifact paths; becomes repeated --extern-artifact arguments. |
no_prelude | Pass --no-prelude; defaults to false. |
autostart | Enable the LSP configuration during setup; defaults to true. |
server | Additional native vim.lsp.Config fields; server.cmd is used only when top-level cmd is absent. |
The default server is rock-lsp from PATH. To use a compiler-development build instead, set cmd to its absolute executable path.
Use :checkhealth rock to check prerequisites, :RockLspInfo to inspect the resolved configuration and clients, and :RockLspRestart to restart Rock clients. See Editors and Diagnostics for local-checkout setup and explicit hover/diagnostic commands.
Reading diagnostics
Source diagnostics carry a primary location and may include secondary labels in other files. Available diagnostic families include parser, resolve, type, borrow, artifact, and toolchain; these classify the failure rather than identify a unique error number. Notes explain context, while help messages suggest a next step. Neither is present on every error.
File, project, artifact, and toolchain failures need not have a source underline. When source text is unavailable, the terminal fallback can show a path and byte range instead of a source excerpt. In the editor, non-source project or toolchain messages can appear at the start of a buffer; that position is not evidence of an error on its first line. Use the message and named path to decide whether to fix source or setup.
The diagnostics walkthrough includes deliberately incorrect programs and their corrections. There is no rock JSON-diagnostic flag documented by the current project CLI.
Tree-sitter grammar
Clone the separate tree-sitter-rock repository to develop the grammar:
$ git clone https://github.com/rock-lang-org/tree-sitter-rock.git
$ cd tree-sitter-rock
$ tree-sitter generate
$ tree-sitter build
$ tree-sitter test
It contains highlight and indentation queries for editor integrations. Tree-sitter installation is separate from the native LSP plugin: require("rock").setup() does not install a parser or enable Tree-sitter highlighting. Syntax highlighting does not perform type or borrow checking. There is still no package-registry workflow.
Current Limitations
Rock is an active prototype. The constraints below describe behavior that a programmer can encounter today, not promises about a final language design.
Platform and distribution
The supported code-generation target is currently
x86_64-unknown-linux-gnu. Binary releases target Ubuntu 24.04 (glibc 2.39
or newer) and statically link LLVM 18; end users do not need to install LLVM.
They are not fully static executables: system libraries and a C linker remain
required. On Ubuntu 24.04, install build-essential, curl, and
ca-certificates; GNU tar, gzip, and sha256sum must also be available.
The FFI-backed standard library is POSIX- and Linux-oriented. Rockup supports
binary releases starting with v0.5.0; there is no public package registry
workflow.
To prepare a development toolchain from a checkout, use the source installation prerequisites. Contributors need LLVM 18 development files and static archives, with no dynamic fallback. From this repository, use:
$ export LLVM_SYS_180_PREFIX=/usr/lib/llvm-18
$ cargo build --release
$ target/release/rockup dev stdlib package \
--path stdlib \
--sysroot target/release
$ target/release/rockup install dev --path target/release
Use path dependencies rather than registry-only dependencies. A complete
two-package layout is shown as text because each source file is a separate
compilation unit:
/tmp/rock-path-project/geometry/rock.toml
[crate]
name = "geometry"
version = "0.1.0"
[lib]
path = "lib.rk"
/tmp/rock-path-project/geometry/lib.rk
< square: I64 -> I64
< square = value -> value * value
/tmp/rock-path-project/app/rock.toml
[crate]
name = "app"
version = "0.1.0"
[lib]
path = "main.rk"
[dependencies]
geometry = { path = "../geometry" }
/tmp/rock-path-project/app/main.rk
> geometry::square
main = !->
result: I64 = square 6
result.println!
The workaround is to keep the dependency local and compile from
/tmp/rock-path-project/app with the project command. Version-only registry
entries are parsed but are not resolved.
Tooling
There is no rock test subcommand. Use a complete executable as an
application smoke test, and use the Rust integration suite for compiler work:
$ cargo test -p rock-lib --test integration test_hello_world -- --exact
$ cargo test -p rock-lib --test integration test_unsafe_operator_function_requires_unsafe -- --exact
$ cargo test -p rock-lib
The formatter can still change some trait implementation headers, nested
generic applications, and &mut expressions unexpectedly. This complete
program is a small formatter regression reproducer:
increment: &mut I64 -> ()
increment = value ->
*value = *value + 1
return
main = !->
mut number: I64 = 4
increment &mut number
number.println!
Format it, inspect the resulting diff, and compile the formatted file. The workaround is to keep the pre-format source under version control and restore the affected declaration manually when the formatter changes its meaning. The tree-sitter grammar, formatter, and older root documentation can also lag the active parser; compiler diagnostics are authoritative for a build.
Inline module bodies are another parser-only surface. The following listing uses a file-backed declaration instead, but it is incomplete without its companion module file:
main.rk
mod inline
main = !->
()
mod inline asks the loader for inline.rk or inline/mod.rk; inline is
the module name, not an inline-module keyword. Supply that file and import
any exported names the entry uses. Macro diagnostics and indentation recovery
are also incomplete. The project-aware language server and Neovim integration
provide source diagnostics; see Editors and Diagnostics
for setup and the current analysis boundaries.
Language surface
Guarded non-copy payloads
The compiler currently rejects a guard that consumes a non-copy enum payload. This is a complete diagnostic reproducer:
enum Message
Text String
Empty
has_text: Message -> Bool
has_text = message ->
match message
Message::Text text if text.len! > 0 => true
Message::Text _ => false
Message::Empty => false
main = !->
message: Message = Message::Text String::from "hello"
result: Bool = has_text message
result.println!
The workaround is to match without the guard and perform the condition inside the arm, where the payload’s ownership is unambiguous:
enum Message
Text String
Empty
has_text: Message -> Bool
has_text = message ->
match message
Message::Text text => text.len! > 0
Message::Empty => false
main = !->
message: Message = Message::Text String::from "hello"
result: Bool = has_text message
result.println!
Incomplete enum matches are not diagnosed in every path, so list every
variant or end with _ even when the checker accepts less source.
Tail conditional effects
A side-effecting call in a helper’s tail conditional can be dropped when the
caller ignores the helper’s result. This complete program should not be used
to rely on the print inside choose:
effect: I64 -> I64
effect = value ->
value.println!
value
choose: Bool -> I64
choose = flag ->
if flag
effect 7
else
0
main = !->
choose true
Return data from the helper and consume it in the caller instead:
effect: I64 -> I64
effect = value ->
value.println!
value
choose: Bool -> I64
choose = flag ->
if flag
effect 7
else
0
main = !->
chosen: I64 = choose true
chosen.println!
Operators, generic carriers, and text
Custom infix operators and trait-defined unary - and ! are supported.
For example, a user-defined type can select its own unary result type:
struct Wrapper
< value: I64
impl Neg for Wrapper
type Output = I64
@- = -> @value
main = !->
value = Wrapper
value: 7
-value .println!
? works with the standard Option and Result carriers and with a custom
carrier that implements matching Try and FromResidual contracts. The
complete custom MyFlow implementation in Option, Result, and
? shows both
success and early propagation. Different source and return carriers also work
when the return carrier implements FromResidual for the source residual.
Missing conversions are not synthesized: implement that exact conversion or
handle the failure with an explicit match.
Strings, characters, and Unicode text handling are byte-oriented and escaped literal behavior is not mature. This complete program measures encoded bytes, not user-perceived characters:
> stdlib::eq::Eq
> stdlib::hash::Hash
main = !->
text: String = String::from "cafe"
length: I64 = text.len!
length.println!
Use ASCII protocol data or process a deliberately specified byte encoding until Unicode-aware APIs are available. There is no async/await syntax or runtime; use an ordinary synchronous function or the prototype thread API instead.
There are no top-level global or static values and no stable C-layout
attributes. Keep state in an owned value passed to main’s helpers, and use
raw buffers with an explicit byte layout at a C boundary rather than assuming
that a Rock struct has a C representation.
Standard-library surface
Strings and Vec
String length and indexing are byte-oriented. Vec has eager traversal
methods such as for_each and map, but no general lazy iterator API or
pop method. Use swap_remove at the last index to remove the final element
without changing the order of the remaining elements:
main = !->
mut values: Vec I64 = Vec::new!
values.push 10
values.push 20
length: I64 = values.len!
match values.swap_remove (length - 1)
Option::Some last => last.println!
Option::None => "empty".println!
Removing an interior element with swap_remove moves the last element into
its slot. If interior removal must preserve order, copy the retained values
into a new vector.
HashMap
HashMap currently lacks removal, iteration, entry APIs, and configurable
hashing. Its intended lookup surface is new, len, insert, get, and
contains_key:
main = !->
mut scores: HashMap &Str, I64 = HashMap::new!
scores.insert "Ada", 10
key: &Str = "Ada"
present: Bool = scores.contains_key (&key)
if present
score: Option &I64 = scores.get (&key)
match score
Option::Some value => value.println!
Option::None => 0.println!
else
"missing".println!
An installed toolchain must contain the standard library built from the same revision as the compiler. If these calls fail after switching revisions, repackage and reinstall the development toolchain before diagnosing the source.
Keys must implement both Hash and Eq. The example uses static string
literals: the shipped implementations support &Str, not owned String
keys. An owned string’s dereference support does not satisfy a generic trait
bound on String itself. Use supported key types or define a key wrapper
with the required implementations.
The workaround for removal or traversal is to rebuild a new map from known keys, or maintain a separate owned list of keys while the map is in use.
Regression tests cover initialized Vec element destruction, replacement and
growth, and HashMap key/value destruction, overwrite and growth. This does
not establish cleanup correctness for every program. Zero-sized Vec elements
and HashMap keys or values are still rejected on insertion; use a non-zero-sized
representation when storing such values.
Environment, networking, and concurrency
Environment-variable lookup is not available. Pass configuration as explicit arguments instead:
> stdlib::env::args
main = !->
arguments: Vec String = args!
arguments.len!.println!
Networking is blocking IPv4 TCP only. It has no TLS, UDP, IPv6, or timeout abstraction. This complete program demonstrates the supported bind boundary:
> stdlib::io::IoError
> stdlib::net::Ipv4Addr
> stdlib::net::SocketAddrV4
> stdlib::net::TcpListener
bind_local = ->
address: SocketAddrV4 = SocketAddrV4::new Ipv4Addr::localhost!, 0
TcpListener::bind address
main = !->
result: Result TcpListener, IoError = bind_local!
match result
Result::Ok _ => 1.println!
Result::Err error => error.println!
Use a thread around a blocking operation when limited concurrency is enough,
and design shutdown explicitly. There are no channels, condition variables,
thread pools, or async executors. Dropping a JoinHandle detaches the thread;
call join! before dropping it when the result or completion matters.
spawn_detached is available for owned unit-returning tasks, but returning
from main does not wait for them; see the complete
detached-task example.
Thread and atomic implementations rely on platform ABI assumptions. Treat long-running, memory-intensive, and concurrent applications as experiments until cleanup and scheduling gaps close.
Working with the prototype
Keep programs and dependencies pinned to one compiler revision, compile after formatting, and prefer tested stdlib APIs over syntax found only in old plans. When relying on a new edge case, add a parser test and an integration test. Keep unsafe and FFI boundaries small, and report the smallest complete source that demonstrates a limitation.
Where to Go Next
The language chapters cover expressions, ownership, functions, control flow, data declarations, traits, modules, and the standard-library boundaries. The next step depends on which boundary you want to practice:
- Use the FizzBuzz checkpoint below if you want a short review of core syntax with deterministic output.
- Combine owned buffers and
Resultin Input, Output, and Files, then compare direct error propagation with the combinators in Option, Result, and?. - Move on to the HTTP server guide for a library-backed application that brings together requests, responses, configuration, and concurrent connection handling. Read Threads and Synchronization before changing task lifetimes or shutdown behavior.
- Use Editors and Diagnostics to keep editor feedback and project builds aligned, then reduce unexpected behavior to a focused regression.
Checkpoint: a complete FizzBuzz executable
This optional checkpoint separates calculation from output. It exercises an enum,
function signatures, guarded match arms, and the prelude’s for_each and
pipeline operator.
Outline
- Define
FizzBuzzValuewith text and numeric variants. - Define
fizzbuzz_valuewith guarded match arms, checking divisibility by 15 before 3 or 5 and ending with an unguarded numeric fallback. - Define
print_valueto consume one enum and print both variants. - Define
mainwithfor_eachover1..=30and an inline action. - Run the project with
rock. - Run the executable and compare the final lines with the expected output.
Save this manifest as rock.toml in a new project directory:
[crate]
name = "fizzbuzz"
version = "0.1.0"
[lib]
path = "main.rk"
Save this complete source beside it as main.rk:
enum FizzBuzzValue
Text &Str
Number I64
fizzbuzz_value: I64 -> FizzBuzzValue
fizzbuzz_value = number ->
match number
n if n % 15 == 0 => FizzBuzzValue::Text "FizzBuzz"
n if n % 3 == 0 => FizzBuzzValue::Text "Fizz"
n if n % 5 == 0 => FizzBuzzValue::Text "Buzz"
n => FizzBuzzValue::Number n
print_value: FizzBuzzValue -> I32
print_value = value ->
match value
FizzBuzzValue::Text text => text.println!
FizzBuzzValue::Number number => number.println!
main = !->
for_each 1..=30, number !->
number
|> fizzbuzz_value
|> print_value
Run it from the project directory:
$ rock run
The expected output starts with 1, 2, Fizz, 4, and Buzz, contains
FizzBuzz at 15, and ends with Fizz, 28, 29, and FizzBuzz at 30.
The final pipeline step moves the enum produced by fizzbuzz_value into
print_value. Its &Str payloads refer to static string literals and the
numeric payload is copyable. The inline action returns unit after printing;
for_each visits all 30 integers without creating an intermediate collection.
Read concrete source
The repository’s current examples and tests are more reliable than old design notes. Start with these exact files:
examples/fizzbuzz.rk
examples/structs.rk
examples/enums.rk
examples/enum_match.rk
examples/test_mod.rk
examples/extern_test.rk
stdlib/io.rk
stdlib/fs.rk
Read public signatures first, then inspect the implementation body that owns allocation, borrowing, or FFI cleanup. The systems chapters explain why that order matters.
Run focused tests
The integration suite has concrete tests for the same features as the checkpoint. Run one exact test while investigating compiler behavior, then run the library suite:
$ cargo test -p rock-lib --test integration test_functions -- --exact
$ cargo test -p rock-lib --test integration test_enum_match -- --exact
$ cargo test -p rock-lib --test integration test_for_loop -- --exact
$ cargo test -p rock-lib --test integration test_stdlib_program_args_are_owned_and_available_outside_main -- --exact
$ cargo test -p rock-lib
For unsafe boundaries, use the exact diagnostic test that checks the contract:
$ cargo test -p rock-lib --test integration test_unsafe_operator_function_requires_unsafe -- --exact
$ cargo test -p rock-lib --test integration test_unsafe_ampersand_function_reference_call_requires_unsafe -- --exact
There is no application-level rock test command yet. Keep executable
regressions as complete Rock sources and use Cargo’s integration harness for
compiler changes.
Contribute a language example
A useful contribution has a parser test for new syntax, an integration test for
user-visible behavior, a small complete example, and a book update when the
public surface changes. Keep the example’s declarations and imports in the
same source fence, place it in a project, and record the exact target and
rock command used.
Prefer the simplest abstraction that communicates intent. A match can be
clearer than a combinator chain, a concrete function can be clearer than a
higher-kinded trait, and a named method can be clearer than a custom operator.
Add an abstraction when it captures a repeated law or ownership contract.
Keep feedback reproducible
When a program exposes a compiler limitation, reduce it to one complete source file, state the command that compiles it, and include the observed diagnostic or output. When it exposes a cleanup or FFI problem, state who owns every allocation and which target ABI is involved. Precise examples help both book readers and compiler contributors more than broad feature requests.