Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Pepper is a compiled programming language for writing fast, native programs that are safe by default. Like Rust and Swift, it aims to rule out whole classes of memory bugs without asking you to manage memory by hand. It puts three things first:

  • Clarity. Code should say what it does. Pepper has a small, familiar syntax, one way to write most things, and error messages that point at the exact token you need to change.
  • Simplicity. There is no inheritance, no garbage collector to tune, and no ownership annotations to learn. Structs hold data, methods and extensions add behavior, and traits describe what a type can do.
  • Concurrent safety. Shared data should be safe to use from more than one thread. Only thread-safe values can be shared: numbers, async.Atomic<T>, and structs declared [Shared], whose fields the compiler checks.

Here is a complete Pepper program:

# Every program starts at main. Its return value is the process exit code.
main(): int {
    print("Hello, world!");
    return 0;
}

How Pepper manages memory

Every struct value in Pepper is a reference to an object on the heap, and the object is freed when the last reference to it goes away. The model is exactly C++’s std::shared_ptr<T> passed by value: storing a reference in a variable, a field, an array element or a parameter adds a reference, and that reference is released when the variable goes out of scope or is overwritten. There is no separate notion of a “borrowed” reference to learn. Numbers and other primitive values are copied, as in C.

Because the rules are the same everywhere, you can predict exactly when an object is freed and when its destructor runs. The chapter on memory and reference counting covers the model in detail.

How Pepper is built

The compiler is written in Pepper, and builds itself. It parses your program, checks it, and generates native code through LLVM, then links an executable with the system’s C toolchain. The same compiler powers a language server, so editors such as VS Code show Pepper’s errors as you type.

Pepper runs on Windows (x64), Linux (x64) and macOS (arm64), compiles to WebAssembly for WASI (--target wasm32-wasip1, see WebAssembly), and builds libraries that iOS and Android apps call (see iOS and Android). Each release is prebuilt for Windows (x64), Linux (x64, glibc 2.28 or newer) and macOS (arm64 and x64), and installs with one command: see Installing Pepper.

Current status

Pepper is young. The core language works and is tested: variables, functions, structs with constructors, destructors, methods and properties, value structs and enums ([Value]), derived == and hash ([Equatable]), enums (with cases that carry values, methods and computed properties) and match (as a statement or a value, on enums, integers, characters, strings and trait values, with is tests, nested and tuple patterns, guards, ranges and constants), tuples and multiple return values, array and map literals, generics (with type arguments inferred at calls, and generic methods), type aliases, traits (with default methods, and usable as types that convert back with as?), function types and closures, modules (with export * from re-exports), Result-based error handling (Result is an enum, and ? propagates errors), Optional values, iteration (with map, filter, enumerate and zip adapters), with blocks, weak references (to structs and trait values), and calling C (including C callbacks), and being called from C ([Export]ed functions in a static or shared library, with a generated header). The standard library covers strings (with a StringBuilder, StringConstant for literals’ constant bytes, to_string and a ToString trait, $"{a + b:>8}" interpolation with format specifications, and String.format), a Comparable trait for ordered types, sortable, searchable arrays with read-only slices, hash maps and sets, sorted maps and sets (Map, Set) and an insertion-ordered OrderedMap, a BitSet of small integers, a double-ended Deque and a PriorityQueue, atomics, threads, channels and mutexes, tasks (green threads that take turns on a few worker threads, parking on sockets and timeouts), file-system access (see files), text encodings (UTF-16, ASCII; see text), TCP, UDP and Unix domain sockets, TLS, an HTTP/1.1 and HTTP/2 client and server (streamed bodies, redirects, connection reuse, ALPN), URI parsing and percent-encoding, base64, form data and UUIDs, regular expressions, JSON (parsing and writing JsonValues, JSONC documents that keep their comments, and converting structs with json.encode and json.decode), TOML (parsing, writing, editing a document in place, and converting structs), gzip and zlib compression, tar archives, cryptographic hashes (SHA-256, SHA-1 and BLAKE3 in the digest module), random numbers (secure bytes from the operating system and a seedable generator), SIMD vectors (simd.Vector<T, N>), command-line arguments (main(args: String[]), parsed into options with the cli module), starting child processes, the os module (environment variables, monotonic and wall clocks with Duration, and machine and user information), the time module (dates, times of day, time zones from the system’s database, and RFC 3339, HTTP and strftime text), the debug module (stack backtraces, which a panic also prints, and the debugger), and leveled, structured logging (the log module). Unit tests are [Test] functions that pepper test runs in parallel, each in a process of its own. That is enough to write real programs, but expect gaps. pep-fmt, the code formatter, pep-profile, which profiles a program and draws a flame graph, pep-test, which runs unit tests, and pepper, the entry point that runs every tool, are themselves written in Pepper. A pep.toml project builds with pepper build alone and imports the packages it depends on by name, which pepper package fetches from GitHub, GitLab or Codeberg. Some things you will meet quickly:

  • Functions are values: a named function, an extern C function, a method (p.norm, Point.norm), a constructor (Point), a generic function’s specialization (larger<int>) or a closure such as (x: int) => x * 2 can be stored, passed and called through a function type such as (int) => int (see function types and closures).
  • A constructor marked [Implicit] converts a value into its struct wherever the struct is expected, so io.Path parameters accept a String (see implicit conversions).
  • Free functions, methods, operators, constructors, extension methods and trait members can be overloaded.
  • A static block runs in the compiler: it can list a struct’s fields and the attributes on them ([Attribute] structs of your own) and generate code for each, so one function can encode any struct (see reflection and attributes).
  • -D NAME=VALUE on the command line defines a build option, which [BuildOption("NAME")] and [Target(BuildOption(NAME) >= 2)] select code by and static blocks read (see build options).
  • A call can name its arguments, window("Editor", resizable = false), to skip defaulted parameters or pass them in any order (see named arguments).
  • Generic parameters can require traits (<T: Shape & Named>), checked wherever the generic is used and, inside the generic, against what the traits declare (see constraints). Numbers, bool and enums whose cases carry nothing can declare traits too, for constraints, but are never trait values.
  • Threads can share only thread-safe values, which the compiler checks, closure captures included. A channel hands over what it is sent without copying it (send(move(x)), or send(copy(x)) to keep x), a Select waits to receive from or send to several channels at once, and a Mutex holds any struct, with the compiler keeping each locked value apart from the rest of the program (see threads). An RwLock lets many threads read a thread-safe value at once, and Semaphore, Barrier and Latch coordinate groups of threads. A [ThreadLocal] module-scope var gives every thread its own copy, compiled to the platform’s native thread-local storage (see thread-local variables).
  • Integer arithmetic that overflows panics instead of wrapping, at every optimization level; wrapping_add and its siblings wrap on purpose (see integer overflow). Bit counting, rotation, byte swapping and the floating-point functions are methods that compile to LLVM’s intrinsics, and any other intrinsic can be declared as an extern (see LLVM intrinsics).
  • Reference counting does not reclaim cycles on its own: two objects that refer to each other are never freed. Break the cycle with a weak reference on one side.

Each chapter of this book says plainly what is and is not supported. When a chapter shows a feature, the example was compiled and run to check it.

How this book is organized

Every complete program in this book lives in the repository’s docs/examples/ folder and is compiled and run by the test suite, so what you read is what works.

Versions of this book

This documentation is versioned with the compiler. Use the version picker in the top-right corner of each page to switch between releases; dev follows the main branch. Read the version that matches the compiler you are using.

Getting started

This chapter installs Pepper on your machine, compiles and runs a first program, and sets up your editor.

Install Pepper

Each release has the Pepper toolchain prebuilt for Linux, macOS and Windows. One command installs it:

# Linux and macOS
curl -fsSL https://raw.githubusercontent.com/justinboswell/pepper/main/scripts/install.sh | sh
# Windows (PowerShell)
irm https://raw.githubusercontent.com/justinboswell/pepper/main/scripts/install.ps1 | iex

It installs into ~/.pepper (%LOCALAPPDATA%\pepper on Windows), adds its bin folder to your PATH, and offers to install what linking a program needs when it is missing: clang on Linux, Xcode’s command line tools on macOS, LLVM and the Visual Studio Build Tools on Windows. Open a new terminal afterwards. Installing Pepper covers the options, what it installs, and uninstalling.

Building from source

To work on Pepper itself, or on a platform with no prebuilt binaries, build the toolchain from source: see Building Pepper from source.

Your first program

Pepper source files end in .pep. Create a file named hello.pep:

# Every program starts at main. Its return value is the process exit code.
main(): int {
    print("Hello, world!");
    return 0;
}

Every program starts at main, which here takes no arguments and returns an int (it can also take the command-line arguments; see Functions): the process’s exit code, where 0 means success. print writes a value’s text to standard output, followed by a new line. Comments start with # and run to the end of the line.

Compile and run it

Pass the source file to pepper build. It compiles and links a native executable, but does not run it, so run the result yourself:

pepper build hello.pep -o hello
./hello
Hello, world!

On Windows, name the output with .exe (-o hello.exe), since Windows will not run a file without an extension, and run it as .\hello.exe. Without -o the executable is written to the current directory, named after the source file with no extension (hello for hello.pep).

Running a program

To build and run in one step, as python hello.py does, use pepper run. It keeps the executable out of your folder, and runs it again at once when nothing changed:

pepper run hello.pep

Arguments after the file go to the program’s main; see Running a program: pep-run.

Where the compiler finds the standard library

Every program is compiled together with Pepper’s standard library, the runtime/ folder of the repository. The compiler looks for it in this order:

  1. The directory named by the PEPPER_RUNTIME environment variable, if it is set. Nothing else is searched.
  2. Starting from the directory you run it from: runtime/, .pep/runtime/, then ../runtime/, ../../runtime/, and so on, up to five levels up.
  3. Starting from the directory that holds the compiler executable: runtime/ beside it, then in each parent directory, up to five levels up.

So an installed compiler, in ~/.pepper/bin, finds ~/.pepper/runtime, and one built in target/bin finds the runtime of the clone it was built from, wherever you run it. To use a different runtime, set PEPPER_RUNTIME or link the runtime into a project’s .pep folder:

mkdir .pep
ln -s /path/to/pepper/runtime .pep/runtime

If the compiler cannot find the runtime, it stops with an error:

error: cannot find the Pepper runtime: no runtime directory near /home/me/project or the compiler; set PEPPER_RUNTIME to its path

The compiler also writes intermediate files to .pep/build/ under the directory you run it from, and keeps a build cache in .pep/cache/: each file of the program (and the standard library) is compiled to its own object, and on the next build a file whose sources did not change reuses its object without being compiled or checked again, as long as what its imports declare did not change either: editing a function’s body recompiles that file alone, while changing a signature also recompiles the files that import it. A file that compiled with warnings is checked again, so its warnings print on every build. When nothing changed, nothing is checked or linked again. The cache also keeps the standard library checked, so a program’s own files are the only ones checked on each build. Deleting .pep/cache/ is always safe.

When something is wrong

The compiler reports errors the way clang does: the file, line and column, a message, and the source line with a caret under the exact token to fix. Store the message in a variable first, then misspell its name:

main(): int {
    var greeting = "Hello, world!";
    print(greting);
    return 0;
}

The compiler reports:

main.pep:3:11: error: use of undeclared identifier 'greting'
    3 |     print(greting);
      |           ^~~~~~~

Errors are colored when the output is a terminal; set NO_COLOR to turn color off. The chapter on compiler errors covers them in more detail.

A failed assert or a panic at run time prints its message and stops the program abnormally. For example, value() on a failed Result prints Result.value on an Err value.

Useful compiler options

OptionWhat it does
-o <file>Name the output executable (or library, with --lib).
-O, -O<n>Optimize: -O0 (the default) through -O3; -O alone is -O2.
-gEmit debug info for gdb, lldb, Visual Studio and WinDbg. See debugging.
-I <dir>Add a directory to search for imported modules. See modules and imports.
-l <lib>, -L <dir>Link a C library, and add a folder to find it in. See linking.
-D <name>[=<value>]Define a build option.
--target <triple>Compile for another platform; see Cross-compiling.
--lib static, --lib sharedBuild a library for C code to link. See calling Pepper from C.
--helpList every option.

The compiler: pep-build describes every option and environment variable the compiler reads.

A program can span several files: pass the entry file, and the compiler finds the files it imports on its own.

Editor support

The VS Code extension adds syntax highlighting and a language server, which reports compiler errors as you type and provides hover information, completion, go to definition, and an outline of each file.

The packaged extension is published as a .vsix file on the repository’s releases page, under the release whose tag starts with vsix-v. Download it and install it in VS Code with Extensions: Install from VSIX… from the command palette. The language server is bundled with the extension, so there is nothing else to install.

A few things are useful once it is installed:

  • Pepper: Restart Language Server restarts the server on demand.
  • Where a value is converted by an [Implicit] constructor, the editor shows the call it stands for as an inlay hint (Path( before "notes.txt" and ) after), and hovering the value says so. The pepper.inlayHints.implicitConversions setting turns the hints off.
  • A literal passed by position shows the name of its parameter as an inlay hint (resize(width: 800, height: 600)); the pepper.inlayHints.parameterNames setting turns these off. Inside a call’s parentheses, completion offers the parameter names to name (width = ).
  • Hover and go to definition also work where the compiler has not checked the code yet: in a generic function or method nothing calls, on a match arm’s case and the names it binds (Circle(r) => ...), and on the values an enum case declares.
  • The pepper.lspPath setting points the extension at a language server you built yourself (pep-build -O tools/pep-lsp/main.pep -o target/pep-lsp/pep-lsp in a checkout of the repository; see The language server: pep-lsp). The server restarts whenever that file changes.

To install the extension from the branch you have checked out instead of the release, see The VS Code extension from a branch.

A tour of Pepper

This chapter walks through the main features of Pepper by writing small programs, and ends with a word counter that reads a file and reports how often each word appears. It moves quickly and shows the common case of each feature; every section links to the chapter of the language guide that covers it fully.

Every program here is complete: save it to a file, compile it as shown in Getting started, and run it. Each one checks its own results with assert(condition, "message"), which stops the program with the message if the condition is false, so a program that runs to the end and returns 0 did what the comments say.

Variables and functions

# A function lists its parameters as `name: type` and gives its return
# type after the closing parenthesis.
square(x: int): int {
    return x * x;
}

# A parameter may have a default value, used when the caller leaves it out.
greet(name: String = "world"): String {
    return "Hello, " + name + "!";
}

# Adds up 1 + 2 + ... + n with a while loop.
sum_to(n: int): int {
    var total = 0;
    var i = 1;
    while i <= n {
        total += i;
        i = i + 1;
    }
    return total;
}

describe(n: int): String {
    if n > 100 {
        return "big";
    } else {
        if n > 10 {
            return "medium";
        }
    }
    return "small";
}

main(): int {
    var answer = 42;          # the type, int, comes from the value
    double ratio = 0.5;       # or write the type yourself
    var whole = (ratio * 10.0) as int;
    assert(whole == 5, "'as' converts between number types");

    assert(square(answer) == 1764, "square(42)");
    assert(greet() == "Hello, world!", "the default name");
    assert(greet("Pepper") == "Hello, Pepper!", "an explicit name");
    assert(sum_to(4) == 10, "1 + 2 + 3 + 4");

    assert(describe(answer) == "medium", "42 is medium");
    assert(describe(7) == "small", "7 is small");

    print(greet("Pepper"));
    print($"{sum_to(100)}");
    return 0;
}

A few things to notice:

  • var declares a variable whose type comes from its initial value: 42 is an int. You can also name the type yourself, as in double ratio = 0.5;. as converts between types, such as from double to int. See Types and variables.
  • A function is declared as name(parameters): return type, and each parameter as name: type. A parameter can have a default value, as name does in greet. A function that returns nothing leaves out the return type. See Functions.
  • if and while do not need parentheses around the condition, but the body always needs braces. Chain conditions with else if. See Control flow.
  • += updates a variable in place, and + joins two strings.
  • print writes one value’s text on its own line. Interpolate to combine values: print($"total: {sum_to(100)}").

Structs

A struct groups related data with the methods that act on it:

struct Point {
    var x: int = 0;
    var y: int = 0;

    # `this.x = 0` as a parameter stores the argument straight into the field.
    construct(this.x = 0, this.y = 0) {}

    length_squared(): int {
        return this.x * this.x + this.y * this.y;
    }

    move_by(dx: int, dy: int) {
        this.x = this.x + dx;
        this.y = this.y + dy;
    }
}

# An extension adds methods to a struct that is declared elsewhere.
extend Point {
    swapped(): Point {
        return Point(this.y, this.x);
    }
}

main(): int {
    var p = Point(3, 4);
    assert(p.length_squared() == 25, "3*3 + 4*4");

    var origin = Point();
    assert(origin.x == 0 && origin.y == 0, "defaults from the constructor");

    # Struct values are references: q and p name the same Point.
    var q = p;
    q.move_by(1, 1);
    assert(p.x == 4 && p.y == 5, "p sees the move made through q");

    var s = p.swapped();
    assert(s.x == 5 && s.y == 4, "swapped is a new Point");
    assert(p.x == 4, "p itself is unchanged");
    return 0;
}
  • Fields are declared like variables, with var name: type, and may have default values.
  • The constructor is named construct. A parameter written as this.x = 0 stores its argument straight into the field x, and = 0 makes it optional, so Point() and Point(3, 4) both work.
  • Methods are functions declared inside the struct. Inside a method, this is the object the method was called on.
  • extend Point { ... } adds methods to a struct from outside its declaration, even to a struct from the standard library.

Struct values are references. var q = p; does not copy the point: p and q refer to the same object, so moving q moves p. Pepper frees an object when the last reference to it goes away, so you never free memory yourself. See Structs and Memory and reference counting.

Collections

The standard library provides growable arrays, strings, and hash maps. You do not need to import them:

main(): int {
    # Array<T> is a growable list. int[] is another way to write Array<int>.
    var scores = [90, 72];
    scores.append(85);
    assert(scores.count == 3, "three scores");
    assert(scores[0] == 90, "indexing starts at 0");
    scores[1] = 75;
    assert(scores.contains(85), "contains");

    var total = 0;
    for (var score in scores) {
        total += score;
    }
    assert(total == 250, "90 + 75 + 85");

    # sort orders the elements by a comparison function.
    scores.sort((a, b) => a - b);
    assert(scores[0] == 75 && scores[2] == 90, "sorted ascending");

    int[] none = Array<int>();
    assert(none.is_empty, "a new array is empty");

    # String holds UTF-8 text.
    var name = "Pepper";
    var greeting = "Hello, " + name;
    greeting += "!";
    assert(greeting == "Hello, Pepper!", "+ and += join strings");
    assert(greeting.length == 14, "length counts characters");
    assert(greeting.starts_with("Hello"), "starts_with");
    assert(greeting.substr(7, 6) == "Pepper", "substr(start, count)");
    assert(greeting.find("Pepper") == 7, "find returns an offset, or -1");
    assert(greeting[0] == "H", "s[i] is a one-character String");

    # HashMap<K, V> maps keys to values.
    var ages = ["Ada": 36];
    ages.insert("Alan", 41);
    assert(ages.count == 2, "two entries");
    assert(ages["Ada"] == 36, "look up by key");
    assert(ages.contains("Alan"), "contains");
    assert(ages["Grace"] == 0, "a missing key reads as the default value");
    assert(!ages.contains("Grace"), "reading does not insert");
    ages.remove("Ada");
    assert(ages.count == 1, "removed");

    # HashSet<K> keeps each key once.
    var seen = HashSet<String>();
    seen.insert("apple");
    seen.insert("apple");
    assert(seen.count == 1, "duplicates are ignored");
    return 0;
}
  • [90, 72] and ["Ada": 36] are collection literals: a new array and a new map.
  • Array<T> is a list that grows as you append. int[] is a shorter way to write the type Array<int>. Indexing is checked: reading past the end stops the program with an error rather than reading stray memory. sort takes a function that compares two elements, such as the closure (a, b) => a - b.
  • String holds UTF-8 text. The length property counts characters (code points), and s[i] is the character at index i, as a one-character string. Strings compare by content with ==.
  • HashMap<K, V> maps keys to values. Reading a key that is not there gives the default value of V (0 for numbers) without adding it; use contains to tell the two apart. HashSet<K> keeps each key once.
  • for (var x in collection) visits each element of an array, or each character of a string, in order, and for (var i in 0..n) counts from 0 to n - 1. See Iteration.

Handling errors with Result

Pepper has no exceptions. A function that can fail returns a Result: either a value, or an error that explains what went wrong.

# Converts a string of decimal digits to an int, or explains why it cannot.
parse_number(text: String): Result<int> {
    if text.is_empty {
        return Result<int>.err(Error("expected a number, found nothing"));
    }
    var value = 0;
    for (var c in text) {
        var digit = "0123456789".find(c);
        if digit < 0 {
            return Result<int>.err(Error("'" + c + "' is not a digit"));
        }
        value = value * 10 + (digit as int);
    }
    return Result<int>.ok(value);
}

main(): int {
    var good = parse_number("2026");
    assert(good.is_ok(), "digits parse");
    assert(good.value() == 2026, "value() returns the value");

    var bad = parse_number("12x");
    assert(bad.is_err(), "x is not a digit");
    assert(bad.error().message == "'x' is not a digit", "the error explains why");
    print(bad.error().message);

    assert(parse_number("").ok_or(-1) == -1, "ok_or supplies a fallback");
    return 0;
}
  • Result<int> holds either an int or an Error. Build one with Result<int>.ok(value) or Result<int>.err(Error("message")).
  • is_ok() and is_err() tell you which one you have. value() and error() return what it holds; error().message is the error’s text.
  • value() returns the value, and stops the program if there is none. Use it only when an error would be a bug. ok_or(fallback) returns the value, or fallback if there is none.

See Error handling with Result.

Putting it together: a word counter

The last program combines everything above. It writes a short text file, reads it back, and counts how often each word appears. It is split into two files: counter.pep defines a WordCounter struct, and main.pep uses it.

counter.pep:

# Counts how often each word occurs in some text.
export struct WordCounter {
    var counts: HashMap<String, int>;
    var order: String[];    # each distinct word, in the order first seen
    var total: int;

    construct() {
        this.counts = HashMap<String, int>();
        this.order = Array<String>();
        this.total = 0;
    }

    add(word: String) {
        if !word.is_empty {
            if !this.counts.contains(word) {
                this.order.append(word);
            }
            this.counts[word] = this.counts[word] + 1;
            this.total = this.total + 1;
        }
    }

    # Splits `text` into words at spaces and counts each one. `add` skips
    # the empty pieces that runs of spaces leave.
    add_text(text: String) {
        for (var word in text.split(" ")) {
            this.add(word);
        }
    }

    count(word: String): int {
        return this.counts[word];
    }
}

main.pep:

import [File, Directory, Path] from io;
import "counter";

# Reads the file at `path` and counts its words.
count_file(path: Path): Result<WordCounter> {
    var text = File.read_to_string(path);
    if text.is_err() {
        return Result<WordCounter>.err(Error(text.error().message));
    }
    var counter = WordCounter();
    counter.add_text(text.value());
    return Result<WordCounter>.ok(counter);
}

main(): int {
    # Write a small file to read back.
    var dir = Directory.temp().join("pepper-word-count");
    Directory.create_all(dir);
    var path = dir.join("poem.txt");
    File.write_text(path, "the cat sat on the mat and the dog sat too");

    var result = count_file(path);
    assert(result.is_ok(), "the file we just wrote can be read");
    var counter = result.value();
    for (var word in counter.order) {
        print($"{word}: {counter.count(word)}");
    }
    assert(counter.total == 11, "eleven words");
    assert(counter.count("the") == 3, "'the' appears three times");
    assert(counter.count("sat") == 2, "'sat' appears twice");
    assert(counter.count("bird") == 0, "'bird' does not appear");

    var missing = count_file(dir.join("missing.txt"));
    assert(missing.is_err(), "a missing file is an error, not a crash");
    print(missing.error().message);

    Directory.remove_all(dir);
    return 0;
}

Compile it by passing the entry file; the compiler finds counter.pep from the import:

pepper build main.pep -o word-count
./word-count

It prints each word with its count, one word per line, and then the error message for the missing file (its exact text comes from the operating system):

the: 3
cat: 1
sat: 2
...
No such file or directory

What is new here:

  • Imports. import "counter"; makes the declarations that counter.pep marks with export available in main.pep. Anything not exported stays private to its file (or, in a module, to the module’s files). import [File, Directory, Path] from io; brings three names in from the standard library’s io module, which, unlike String or Array, must be imported. See Modules and imports and Visibility.
  • Files. Directory.temp() is the system’s temporary directory, and join adds a name to a Path; the name can be a plain String, which converts to a Path implicitly. File has helpers that read or write a whole file at once: File.write_text and File.read_to_string.
  • I/O errors are Results. File.read_to_string returns a Result whose error is an IoError, so reading a missing file is an ordinary error value, not a crash. count_file turns it into its own Result<WordCounter>.
  • Splitting strings. add_text breaks the text into words with split(" "), which returns an Array<String> of the pieces between the spaces (see String).

Where to go next

You have now seen most of what you need to write everyday Pepper code. The language guide covers each topic in depth, including features this tour skipped: generics, function types and closures, traits and extensions, atomics, threads, and calling C.

Types and variables

This chapter covers the values every Pepper program is built from: numbers, booleans and strings, the variables that hold them, and the operators that combine them.

Built-in types

Integers

Pepper has signed and unsigned integers of 8, 16, 32 and 64 bits. Each width has a short name (i32) and a descriptive alias (int). The two names are the same type, so you can use whichever reads better and assign freely between them.

BitsSignedUnsigned
8i8, charu8, byte
16i16, shortu16, ushort
32i32, intu32, uint
64i64, longu64, ulong

int is 32 bits and long is 64 bits on every platform. sizeof<T>() gives the size of a type in bytes.

Integer literals are written in decimal (42) or hexadecimal (0xFF). A literal on its own is an int when its value fits in an int, and a long otherwise, so var big = 3000000000; is a long. It takes another type when you store it in a variable of that type (u64 huge = 9000000000000000000;). A hexadecimal literal is a 64-bit bit pattern: u64 all = 0xFFFFFFFFFFFFFFFF; sets every bit.

# Integer types: widths, signedness, wraparound and conversions.
main(): int {
    # Each width has a short name and a descriptive alias.
    i8 tiny = -100;       # same type as char
    u8 octet = 200;       # same type as byte
    i16 small = -30000;   # same type as short
    u16 port = 65000;     # same type as ushort
    i32 count = 2000000;  # same type as int
    u32 mask = 4000000000; # same type as uint
    i64 big = 9000000000; # same type as long
    u64 huge = 9000000000000000000;  # same type as ulong

    assert(sizeof<char>() == 1 && sizeof<short>() == 2, "8 and 16 bits");
    assert(sizeof<int>() == 4 && sizeof<long>() == 8, "32 and 64 bits");

    # An alias names the same type, so assigning between them is lossless.
    short s = small;
    assert(s == -30000, "short and i16 are one type");

    # Storing a value that does not fit wraps around, as in C.
    u16 wrap = 65535;
    wrap = wrap + 1;
    assert(wrap == 0, "u16 wraps from 65535 to 0");
    i16 overflow = 32767;
    overflow += 1;
    assert(overflow == -32768, "i16 wraps from 32767 to -32768");

    # Widening keeps the value: signed types sign-extend, unsigned zero-extend.
    long widened = small;
    assert(widened == -30000, "i16 widens to long");
    assert((tiny as u8) == 156, "-100 as u8 is 156");
    assert((octet as i8) == -56, "200 as i8 is -56");

    # Narrowing with 'as' keeps the low bits.
    assert((70000 as u16) == 4464, "70000 as u16 keeps the low 16 bits");

    # Hexadecimal literals.
    assert(0xFF == 255, "0xFF is 255");
    assert(count + big > 0 && mask > 0 && huge > 0 && port > 0, "all in range");
    return 0;
}

Storing a value that does not fit in the destination keeps its low bits, as in C: a u16 holding 65535 becomes 0 when you add 1 and store the result, and 70000 as u16 is 4464. Arithmetic that overflows int or a wider type panics instead (see Integer overflow).

A decimal literal larger than the largest long (9223372036854775807), or a hexadecimal literal with more than 64 bits, is an error:

main.pep:2:13: error: integer literal is too large to be represented in a 64-bit integer
    2 |     var f = 99999999999999999999;
      |             ^~~~~~~~~~~~~~~~~~~~

Floating point

float is a 32-bit and double a 64-bit IEEE 754 number (f32 and f64 are aliases). A literal with a decimal point or an exponent, such as 0.25 or 1e3, is a double.

Comparisons follow IEEE 754, as in C: against NaN (0.0 / 0.0), ==, <, <=, > and >= are false and != is true, so x != x is true only for a NaN. As a condition, a float is true unless it equals zero, NaN included.

Reading numbers from text

Every number type has a static parse that reads one from a String: int.parse("42") gives an int? (see optionals), empty when the text is not a number of that type: not digits, or too big or too small for it. The integer types take a base from 2 to 36 as a second argument ("ff" in base 16 is 255), and float and double read decimal notation ("2.5", ".5", "1e-3"). Neither accepts spaces, a 0x prefix, inf or nan. Each spelling of a type is the same type, so i32.parse is int.parse:

# Reading numbers from text with each number type's static parse.

main(): int {
    # An integer type takes an optional base from 2 to 36.
    assert(int.parse("-42").value() == -42, "decimal");
    assert(byte.parse("ff", 16).value() == 255, "hexadecimal");

    # The result is empty when the text is not a number of that type.
    assert(int.parse("4 2").is_empty(), "not an integer");
    assert(byte.parse("256").is_empty(), "too big for a byte");

    # float and double read decimal notation.
    var ratio = double.parse("2.5e-1");
    if (ratio.is_value()) {
        assert(ratio.value() == 0.25, "an exponent");
    }
    return 0;
}

Booleans

bool holds true or false. Comparisons and the logical operators produce a bool. In arithmetic a bool counts as 0 or 1, and its text is true or false ($"{flag}").

Characters

A character literal is one ASCII character between single quotes: 'a', '"', '#'. Its value is the character’s code, and its type is byte (u8), the type of one byte of a UTF-8 string. Like any byte it widens to the other integer types, so it compares with, and stores into, an int or a char as well:

# Character literals are bytes.
is_lower(c: int): bool {
    return c >= 'a' and c <= 'z';
}

main(): int {
    var letter = 'a';   # a byte holding 97
    assert(letter == 97, "a character literal is its ASCII code");
    assert('"' == 34 and '\n' == 10 and '\'' == 39, "escapes work as in strings");

    # Compare the bytes of a String with character literals.
    var text = "x = 1";
    var bytes = text.c_str();
    assert(bytes[0] == 'x' and bytes[2] == '=', "compare raw bytes");
    assert(is_lower(bytes[0]), "a byte widens to int");

    # Arithmetic works as on any integer.
    int digit = bytes[4] - '0';
    assert(digit == 1, "the digit's value");

    print($"{letter}");   # 97
    return 0;
}

The escapes are the same as a string’s (see the table under Strings), plus \xHH for any byte, '\xFF' included. A character literal holds exactly one ASCII character; for anything else use a string:

main.pep:2:13: error: character literal may only contain one character; use a string for more
    2 |     var x = 'ab';
      |             ^~~~
main.pep:2:14: error: character literal must be a single ASCII character; use a string for other characters
    2 |     var x = 'é';
      |              ^

char is an 8-bit signed integer, the same type as i8, used mostly for C strings (__ptr<char>). The ASCII characters have the same value as a char and as a byte; '\xFF' is 255 as a byte but -1 read through a char. Text is a String, and a single character of it is a one-code-point String.

byte and char classify ASCII characters as C’s ctype.h does in the “C” locale: is_digit, is_hex_digit, is_alphabetic, is_alphanumeric, is_upper, is_lower, is_whitespace, is_blank, is_punctuation, is_graphic, is_printable, is_control and is_ascii, plus to_upper, to_lower and digit_value(base). The names are spelled out (is_punctuation for C’s ispunct). A byte above 127, which is a negative char, is never a letter, digit, space or punctuation. Unicode classification is not covered.

# Classify and convert ASCII characters, as C's ctype.h does.
main(): int {
    assert('7'.is_digit() and 'f'.is_hex_digit(), "digits");
    assert('q'.is_alphabetic() and 'Q'.is_upper() and 'q'.is_lower(), "letters");
    assert('\t'.is_whitespace() and '\t'.is_blank(), "whitespace");
    assert(';'.is_punctuation() and !'_'.is_alphanumeric(), "punctuation");
    assert('\n'.is_control() and !'\n'.is_printable(), "control");
    assert('a'.to_upper() == 'A' and '?'.to_upper() == '?', "case");

    # The digit's value in a base, or none when it is not a digit there.
    assert('F'.digit_value(16).value() == 15, "hexadecimal");
    assert('9'.digit_value(8).is_empty(), "not an octal digit");

    # Count the words and digits of a String, a byte at a time.
    var text = "Call 555-0100 at 9 a.m.";
    var words = 0;
    var digits = 0;
    var in_word = false;
    for (var b in text.bytes()) {
        if (b.is_digit()) {
            digits = digits + 1;
        }
        if (b.is_alphanumeric() and !in_word) {
            words = words + 1;
        }
        in_word = b.is_alphanumeric();
    }

    # A char answers the same, and bytes above 127 (negative as a char)
    # are never letters.
    char e_acute = -61;   # the first byte of "é" in UTF-8
    assert(!e_acute.is_alphabetic() and !e_acute.is_ascii(), "not ASCII");

    print($"{words} words, {digits} digits");   # 7 words, 8 digits
    return 0;
}

Strings

String is a UTF-8 string. A string literal such as "hello" is a String value wherever a String is expected, and wherever nothing asks for another type (var s = "hello"; declares a String). Where it is stored or passed as a __ptr<char>, it is the literal’s NUL-terminated bytes (see Passing strings), and where a StringConstant is expected, it is one. + joins two strings into a new one, == and != compare contents, the length property counts code points, size counts bytes, and s[i] gives the code point at index i as a one-code-point String.

# String literals are String values.
main(): int {
    var language = "Pepper";
    assert(language.length == 6, "6 code points");

    # + makes a new String; == compares contents.
    var message = "Hello, " + language;
    assert(message == "Hello, Pepper", "concatenation");
    assert(message != language, "different contents");

    # Strings are UTF-8. `length` counts code points, `size` counts bytes.
    var word = "héllo";
    assert(word.length == 5, "5 code points");
    assert(word.size == 6, "6 bytes: 'é' takes 2");
    assert(word[1] == "é", "indexing yields a one-code-point String");

    # A String is a reference: both names see an append.
    var alias = message;
    message.append("!");
    assert(alias == "Hello, Pepper!", "one object, two references");

    print(message);
    return 0;
}

A String is a reference type: var alias = message; makes a second reference to the same object rather than a copy, so a change made with append shows through both names. Memory and reference counting explains how references work, and the String reference lists every method.

Every place a literal is stored (a variable, a field, an array element, a function’s result) gets a new String, so changing it never changes the literal: after var s = "a"; s.append("b"); the next "a" is still "a". Where a literal is only read, passed to a parameter that the function neither keeps nor changes (builder.append("x"), name.contains("x")), every evaluation shares one constant object and allocates nothing.

String constants

A literal’s own type is StringConstant: a pointer to the literal’s bytes, which never change and are never freed, with their size and length. It is a [Value] type, so copying one steps no reference count, any thread may read it, and a literal stored as one allocates nothing. Its bytes always end in a NUL byte, so c_str() passes it to C as it is (a String’s c_str() does too, but a String built with no data has none).

A StringConstant has String’s read API (length, size, find, split, trim, ==, <, the indexer, for loops and so on); the methods that make new text return a String. It converts to a String wherever a String is expected, as if String had an [Implicit] constructor taking it: the conversion (and String(text)) copies the bytes, so an API that takes a String takes a StringConstant too. Where C expects a char*, it passes its own bytes, as c_str() does. Among overloads, a literal picks a StringConstant parameter over a String one, and a StringConstant value picks a String parameter last, as an implicit conversion.

# A string literal's own type is StringConstant: its constant bytes, with no
# allocation and no reference count.
extern ulong strlen(char* text);

# A `const` initialized from a literal is a StringConstant.
const GREETING = "hello";

shout(text: String): String {
    return text.upper() + "!";
}

main(): int {
    StringConstant name = "pepper";
    assert(name.length == 6, "the read API of String");
    assert(name.starts_with("pep") && name.find("p", 1) == 2, "find counts bytes");

    # Always NUL-terminated: c_str() goes to C with no copy.
    assert(strlen(GREETING.c_str()) == 5, "C reads the literal's bytes");

    # A StringConstant converts to a String wherever one is expected.
    String copy = name;
    copy.append("!");
    assert(copy == "pepper!" && name == "pepper", "the copy has its own bytes");
    print(shout(GREETING));

    # Compared with a String either way round, with no copy.
    String typed = "pepper";
    assert(name == typed && typed == name && GREETING < typed, "mixed comparisons");

    # Copying a StringConstant copies a pointer.
    var also = name;
    assert(also == name, "same text");
    return 0;
}
  • Where a literal is a StringConstant: a variable, field, parameter or return value of type StringConstant, a HashMap<StringConstant, V> key, and a const with no written type (const GREETING = "hello";), so a module-scope text constant allocates nothing and any thread may read it. Everywhere else, var s = "x" included, a literal is a String, as before.
  • Converting chains once: a literal reaches a type that converts from String (io.Path, Error) directly, but a StringConstant variable does not, since conversions never chain. Write String(text) or pass a literal.
  • Comparing: ==, !=, <, <=, > and >= compare a StringConstant with another StringConstant (a literal included) or with a String, either way round, and copy nothing.
  • A literal method call ("a,b".split(",")) calls the String method.

to_string turns any integer into its decimal text. For parsing and generating text, byte_at and substr_bytes work in bytes (the offsets find returns), compare and <, <=, >, >= order strings byte by byte (code-point order for UTF-8), lines() splits on "\n" or "\r\n", trim(), trim_start() and trim_end() drop whitespace, lower() and upper() change the case of ASCII letters, repeat(n) joins n copies, and bytes() copies the UTF-8 bytes into an array. A StringBuilder builds a long String from many pieces without recopying the text built so far on every append:

# Numbers to text, byte-level access, ordering, and building text in pieces.
main(): int {
    # to_string turns any integer into its decimal text.
    var line = 3;
    var where = to_string(line) + ":" + to_string(-7);
    assert(where == "3:-7", "to_string");

    # byte_at and substr_bytes count bytes, so they work with find's offsets.
    var setting = "indent=4";
    var eq = setting.find("=") as ulong;
    assert(setting.byte_at(eq) == '=', "byte_at compares with a character literal");
    assert(setting.substr_bytes(0, eq) == "indent", "the bytes before '='");

    # compare, <, <=, > and >= order strings byte by byte.
    assert("apple" < "banana", "ordering");
    assert("b".compare("a") > 0, "compare is negative, 0 or positive");

    # lines() splits on "\n" or "\r\n"; trim_end() drops trailing whitespace.
    var lines = "one  \r\ntwo\n".lines();
    assert(lines.count == 2, "no empty line after the final newline");
    assert(lines[0].trim_end() == "one", "trim_end");

    # trim() drops whitespace at both ends; lower() and upper() change ASCII case.
    var field = "  Content-Type ".trim();
    assert(field.lower() == "content-type", "lower");
    assert(field.upper() == "CONTENT-TYPE", "upper");

    # repeat joins copies; StringBuilder appends without recopying the text.
    var out = StringBuilder();
    for (var text in lines) {
        out.append(" ".repeat(4)).append(text.trim_end()).append("\n");
    }
    assert(out.to_string() == "    one\n    two\n", "StringBuilder");
    return 0;
}

To search and take text apart, find(needle, from) starts at a byte offset and rfind finds the last match, split(separator) cuts at every separator and split_once at the first (giving an optional pair), separator.join(parts) puts the parts back together, and replace(from, to) swaps every match (to read a number from text, see Reading numbers from text):

# Searching, splitting, joining and replacing text.

main(): int {
    # find takes a byte offset to start from; rfind finds the last match.
    var path = "src/lib/main.pep";
    var first = path.find("/");
    assert(path.find("/", (first as ulong) + 1) == 7, "the second slash");
    assert(path.rfind("/") == 7, "the last slash");

    # split cuts at every separator; split_once at the first, if any.
    var fields = "name,,age".split(",");
    assert(fields.count == 3 && fields[1] == "", "an empty field");
    var header = "Content-Type: text/plain".split_once(": ");
    if (header.is_value()) {
        var (name, value) = header.value();
        assert(name == "Content-Type" && value == "text/plain", "both halves");
    }

    # join puts the separator between the parts; replace swaps every match.
    assert(" | ".join(fields) == "name |  | age", "joined");
    assert("a\r\nb\r\n".replace("\r\n", "\n") == "a\nb\n", "line endings");
    return 0;
}

An interpolated string puts values’ text into a string literal: $"line {line + 1}: {message}". String interpolation covers which values it accepts.

A backslash in a string literal starts an escape sequence, as in C and Rust:

EscapeCharacter
\nnewline
\ttab
\rcarriage return
\0the zero byte
\\a backslash
\"a double quote
\'a single quote
\xHHthe ASCII character with hexadecimal code HH, at most \x7F (any byte in a character literal)
\u{H...}the Unicode code point with hexadecimal code H... (1 to 6 digits)
print("name:\t\"Pepper\"");   # name:	"Pepper"
print("caf\u{e9}");            # café

Any other character after a backslash is an error:

main.pep:2:13: error: unknown escape sequence '\q'
    2 |     print("a\qb");
      |             ^~

Other built-in types

  • void means “no value”. It is the return type of a function that returns nothing.
  • Array<T> is a growable array of T; int[] is shorthand for Array<int>, and int[][] for Array<Array<int>>. See Array.
  • Optional<T> is a value that may be missing; int? is shorthand for Optional<int>. See Optional values.
  • HashMap<K, V> and HashSet<K> are hash tables. See HashMap and HashSet.
  • Result<TValue, TError> and Error describe operations that can fail. See Error handling with Result.
  • async.Atomic<T> is a value that threads can update safely. See Atomics and Threads and shared data.

Raw pointers and __null

For talking to C, Pepper has raw pointers, written __ptr<T> (for example __ptr<char> or __ptr<void>), and the null pointer literal __null:

var p: __ptr<void> = __null;
assert(p == __null, "p is null");

Most programs never need them. Calling C covers pointers in full.

Naming conventions

Primitive types and keywords are lowercase: int, float, bool, var, while. Types, including the ones the runtime provides, are PascalCase: String, Array<T>, HashMap<K, V>, Result<TValue, TError>. Functions, variables and fields are snake_case, and constants are SCREAMING_CASE. Naming conventions has the full rules.

Identifiers are made of ASCII letters, digits and underscores, and cannot start with a digit. Avoid names that start with two underscores: the compiler and runtime use them for their own names, such as __ptr and __null.

Variables

Declare a variable with var and let Pepper infer its type from the initial value, or write the type yourself. Prefer var whenever the type is obvious from the value.

import async;

# Declaring variables: inferred, explicitly typed, constant, and module-level.

# Module-level variables (globals) live for the whole program. Any thread
# can reach them, so they are const and of a thread-safe type: a number, or
# an Atomic for state that changes.
const MAX_REQUESTS = 10;
const request_count = async.Atomic<int>(0);
const total_bytes = async.Atomic<long>(0);

record(bytes: int) {
    request_count.fetch_add(1);
    total_bytes.fetch_add(bytes);
}

main(): int {
    # 'var' infers the type from the initializer.
    var answer = 42;          # int: integer literals are int
    var ratio = 0.25;         # double: floating-point literals are double
    var ready = true;         # bool
    var name = "Pepper";      # String

    # Write the type first, C style...
    int width = 80;
    float scale = 1.5;

    # ...or after the name with a colon.
    var height: int = 24;
    var offset: long = 5000000000;

    # A variable declared without a value starts at zero.
    var hits: int;
    assert(hits == 0, "numbers start at zero");

    # 'const' declares a name that should not be reassigned.
    const LIMIT = 3;

    record(100);
    record(250);
    assert(request_count.load() == 2, "two requests recorded");
    assert(total_bytes.load() == 350, "350 bytes in total");
    assert(request_count.load() < MAX_REQUESTS, "globals are visible in every function");

    assert(answer + width + height == 146, "sum of ints");
    assert(ratio * 4 == 1.0 && scale == 1.5, "floating point");
    assert(ready && name == "Pepper", "bool and String");
    assert(offset > 4000000000 && LIMIT == 3, "long and const");
    return 0;
}

The three forms are:

  • var name = value; infers the type from the value.
  • Type name = value; is the C style, with the type first.
  • var name: Type = value; puts the type after the name.

A variable declared without a value starts out as zero: 0 for numbers, false for bool, and null for a struct type such as String. Give struct variables a value before you use them.

When var infers the type of an arithmetic expression, it takes the type the arithmetic produces under C’s rules (see Arithmetic follows C): var m = 1 + 0.5; is a double holding 1.5, and with u8 small = 250;, var s = small + 10; is an int holding 260.

Constants

const declares a name that you do not intend to change:

const LIMIT = 3;
const RATIO: double = 0.5;

Assigning to a const, with =, a compound assignment, ++ or --, is an error:

main.pep:5:5: error: cannot assign to const variable 'LIMIT'
    5 |     LIMIT = 4;
      |     ^~~~~
main.pep:1:7: note: 'LIMIT' declared const here
    1 | const LIMIT = 3;
      |       ^~~~~

Module-level variables

A variable declared outside any function is a module-level (global) variable. It exists for the whole run of the program and every function in the file can read it. Its initializer can be any expression, including a function call, and runs before main. Like other top-level declarations, a global is private to its file (or module) unless you mark it export (see Visibility).

Any function can run on any thread, so every thread can reach a global. It must therefore be declared const and have a thread-safe type, such as a number, an Atomic<T>, a ThreadLocal<T>, or a [Shared] struct (see Module-scope variables). A global that changes is an Atomic<T>, or a [ThreadLocal] var, which every thread has its own copy of (see Thread-local variables):

const MAX_RETRIES = 3;
const requests = async.Atomic<long>(0);   # changed with requests.fetch_add(1)
[ThreadLocal]
var depth: int = 0;                       # one per thread

Casting with as

value as Type converts a value to another type explicitly, like static_cast in C++:

var ratio = 7.9;
var whole = ratio as int;   # 7
var wide = whole as long;
  • Floating point to integer truncates toward zero: -7.9 as int is -7.
  • Integer to a narrower integer keeps the low bits: 70000 as u16 is 4464.
  • Integer to a wider integer sign-extends a signed source and zero-extends an unsigned one: (-1 as i16) as long is -1, and (65535 as u16) as long is 65535.
  • bool and enums convert like integers: true as int is 1, and an enum converts to the integer it is stored as. An integer does not convert to an enum; name a case instead.
  • A struct converts to a trait it conforms to: dog as Named. The other way, from a trait value, may fail: shape as? Square gives a Square? (see Converting a trait value back).
  • A pointer converts to __ptr<void> and back: p as __ptr<void>, then v as __ptr<int>.
  • A value converts to its own type.

Anything else is a compile error at the as, since it would not be a value conversion but a reinterpretation of memory:

main.pep:3:15: error: cannot cast '__ptr<int>' to 'ulong' with 'as'; use 'unsafe_cast<ulong>(...)' to reinterpret
    3 |     var a = p as ulong;
      |               ^~

Casting between unrelated struct types, or between a struct and a number or pointer, is an error with no alternative (cannot cast 'P' to 'Q').

as binds more tightly than the arithmetic operators, so x * 10 as int means x * (10 as int). Put parentheses around the expression you want to convert: (x * 10) as int. C-style casts such as (int)x do not exist.

Many conversions also happen without as: assigning or passing a number to a variable or parameter of another numeric type converts it, as in C, including narrowing conversions (int n = 2.7; stores 2).

Reinterpreting with unsafe_cast

unsafe_cast<Type>(value) reinterprets a value’s bits as another type, like reinterpret_cast in C++. It is for C interop and low-level code, and nothing checks that the result makes sense:

  • A pointer to a pointer of another type: unsafe_cast<__ptr<u8>>(text.c_str()).
  • A pointer to an integer, and an integer to a pointer: unsafe_cast<ulong>(p), unsafe_cast<__ptr<int>>(address).
  • A number to another number of the same width, keeping its bits: unsafe_cast<uint>(1.0 as float) is 0x3F800000, and unsafe_cast<double>(bits) turns a ulong back into a double.
# `as` converts values; `unsafe_cast<T>(value)` reinterprets bits.

extern void* malloc(ulong size);
extern void free(void* p);

main(): int {
    # A float's bits, and back: the same 32 bits seen as a uint.
    var one = 1.0 as float;
    var bits = unsafe_cast<uint>(one);
    assert(bits == 0x3F800000, "1.0 is 0x3F800000");
    assert(unsafe_cast<float>(bits) == one, "the bits convert back");

    # A double's sign bit is its top bit.
    var negative = unsafe_cast<ulong>(-2.0);
    assert((negative >> 63) == 1, "the sign bit is set");

    # `as` converts a pointer to and from `__ptr<void>`.
    var memory = malloc(8);
    var words = memory as __ptr<uint>;
    words[0] = 0x01020304;
    words[1] = 0;

    # Reading the same memory as bytes reinterprets it.
    var bytes = unsafe_cast<__ptr<u8>>(words);
    assert(bytes[0] == 4 || bytes[0] == 1, "the first byte is the low or the high one");

    # An address is an integer, and an integer is an address.
    var address = unsafe_cast<ulong>(words);
    assert(unsafe_cast<__ptr<uint>>(address + 4)[0] == 0, "the address four bytes on is the second word");

    free(memory as __ptr<void>);
    return 0;
}

It never converts a struct, trait or function value, so it cannot get around reference counting. A conversion as can make is not a reinterpretation, so unsafe_cast<long>(n) on an int is an error that says to use as:

main.pep:3:13: error: 'unsafe_cast' cannot convert 'int' to 'long': the sizes differ; use 'as' to convert the value
    3 |     var w = unsafe_cast<long>(n);
      |             ^~~~~~~~~~~

Arithmetic follows C

Arithmetic on numbers behaves exactly as it does in C (the “usual arithmetic conversions”), so code ported from C gives the same results:

  • Integer promotion. Types narrower than int (char, u8, short, u16, and bool) are converted to int before any arithmetic. The sum of two chars is an int, so 100 + 100 gives 200 rather than wrapping at 8 bits.
  • Mixed widths use the wider type: int + long is a long.
  • Signed and unsigned. When both operands have the same width and one is unsigned, the operation is unsigned: comparing int -1 with uint 1 treats -1 as 4294967295, so -1 < 1 is false for those types.
  • Integer and floating point gives floating point: 3 * 0.5 is 1.5.
  • Division of integers truncates toward zero (-7 / 2 is -3), and % takes the sign of the left operand (-7 % 3 is -1).
  • Conditionals. c ? a : b with numeric arms of different types has their common type by the same rules (c ? 0 : s.length is a ulong), and so does a match expression over its arms. The value converts from that type wherever it goes, and a var it initializes has that type (var n = c ? 0 : s.length is a ulong, not an int).
  • Shifts take the type of the (promoted) left operand. >> keeps the sign for signed types and fills with zeros for unsigned types.
  • Overflow is where Pepper leaves C: it panics, for signed and unsigned types alike (see Integer overflow).
# Arithmetic follows C's rules for promotion and conversion.
main(): int {
    # Integer division truncates toward zero; % takes the sign of the left side.
    assert(7 / 2 == 3, "7 / 2 is 3");
    assert(-7 / 2 == -3, "-7 / 2 is -3");
    assert(-7 % 3 == -1, "-7 % 3 is -1");

    # Types narrower than int are promoted to int before arithmetic,
    # so the sum of two chars does not wrap at 8 bits...
    char a = 100;
    char b = 100;
    int sum = a + b;
    assert(sum == 200, "char + char is computed as int");

    # ...but storing the result back into a char does.
    char c = a + b;
    assert(c == -56, "200 does not fit in a char");

    # Mixing signed and unsigned of the same width is unsigned, as in C.
    int minus_one = -1;
    uint one = 1;
    assert(!(minus_one < one), "-1 converts to 4294967295 when compared with a uint");
    uint zero = 0;
    assert(zero.wrapping_sub(1) == 4294967295, "zero - 1 would panic; wrapping_sub wraps");

    # Mixing an integer with a floating-point value gives floating point.
    int whole = 3;
    double half = 0.5;
    double product = whole * half;
    assert(product == 1.5, "3 * 0.5 is 1.5");

    # 'as' converts explicitly. Floating point to integer truncates toward zero.
    assert((7.9 as int) == 7, "7.9 as int is 7");
    assert((-7.9 as int) == -7, "-7.9 as int is -7");
    assert(((whole as double) / 2) == 1.5, "divide as double");

    # Assignment converts implicitly, including from double to int.
    int truncated = 2.7;
    assert(truncated == 2, "2.7 stored in an int is 2");

    # Shifts: >> is arithmetic for signed types and logical for unsigned types.
    assert(-8 >> 1 == -4, "signed shift keeps the sign");
    uint bits = 0x80000000;
    assert(bits >> 31 == 1, "unsigned shift fills with zeros");
    return 0;
}

Integer overflow

Integer arithmetic whose result does not fit its type stops the program, as in Swift (and Rust’s debug builds), instead of wrapping around silently:

main.pep:3:16: panic: integer overflow in 'int' addition

The message names the operator’s location and the type the operation ran at. These operations check:

  • +, -, * and **, unary -, ++ and --, and the compound assignments (+=, -=, *=). Negating the most negative value of a signed type overflows, and so does negating any nonzero unsigned value or subtracting past 0 (uint 0 - 1).
  • / and % by zero (division by zero), and the most negative value of a signed type divided by -1 (MIN / -1 and MIN % -1), whose result does not fit.
  • << and >> by a count of at least the type’s width, or a negative one: shift count out of range for 'int': must be from 0 to 31.

Arithmetic on types narrower than int runs at int (see above), so it overflows only past int’s range; storing the result back in the narrow type keeps its low bits, like any narrowing conversion. x++ on a u8 holding 255 leaves 0, as x += 1 does. Casts with as never check: 70000 as u16 is 4464.

When both operands are constants, an overflow is a compile error at the operator, and so is a division by a constant 0:

main.pep:2:28: error: integer overflow in 'int' multiplication
    2 |     var seconds = 100000 * 100000;
      |                          ^

Wrapping on purpose

Hashes, checksums and random number generators rely on wrapping. Each integer type has methods that say what to do on overflow instead of panicking:

MethodOn overflow
wrapping_add(x), wrapping_sub(x), wrapping_mul(x)Wraps around: the result modulo 2^bits.
checked_add(x), checked_sub(x), checked_mul(x)Returns Empty (the result is a T?).
saturating_add(x), saturating_sub(x), saturating_mul(x)Clamps to the type’s highest or lowest value.
overflowing_add(x), overflowing_sub(x), overflowing_mul(x)Returns the wrapped result and true as a (T, bool).
# Arithmetic that overflows panics; these methods say what to do instead.
main(): int {
    int big = 2147483647;

    # big + 1 would stop the program: "integer overflow in 'int' addition".
    # wrapping_ wraps around, as C's unsigned arithmetic does.
    assert(big.wrapping_add(1) == -2147483648, "wraps to the lowest int");

    # checked_ returns none on overflow.
    assert(big.checked_add(1).is_empty(), "no int is big + 1");
    assert(big.checked_sub(1).value() == 2147483646, "big - 1 fits");

    # saturating_ clamps to the type's range.
    assert(big.saturating_mul(2) == 2147483647, "clamped to the highest int");
    byte level = 10;
    assert(level.saturating_sub(20) == 0, "a byte stops at 0");

    # overflowing_ returns the wrapped result and whether it overflowed.
    var (product, overflowed) = big.overflowing_mul(2);
    assert(product == -2 && overflowed, "wrapped, and overflowed");

    # A hash multiplies with wrapping on purpose (FNV-1a).
    ulong hash = 0xcbf29ce484222325;
    var text = "pepper";
    for (var i in 0..text.size) {
        hash = (hash ^ (text.byte_at(i) as ulong)).wrapping_mul(0x100000001b3);
    }
    assert(hash == 0xac11131d51a77079, "the FNV-1a hash of 'pepper'");
    return 0;
}

The methods work at their type’s own width: byte 200.checked_add(100) is Empty, while 200 + 100 on two bytes is 300 at int.

Removing the checks

The checks stay on at every optimization level. A build that has measured their cost and accepts the risk can remove kinds of check with --no-runtime-checks, a comma-separated list:

NameRemovesThen
overflowThe overflow checks of +, -, *, **, unary -, ++, --Arithmetic wraps around (never undefined).
divideDivision and remainder by zero, and MIN / -1The result is undefined, as in C.
shiftThe shift count checkThe count is masked to the type’s width (x << 33 on an int shifts by 1).
boundsArray and String index checks (a[i], byte_at)An index out of range reads or writes arbitrary memory.
nullCalls through null function and trait values, and match on a null referenceThe program crashes or misbehaves.
allEvery check above
pepper build -O main.pep --no-runtime-checks=overflow,shift

Removing bounds or null gives up Pepper’s memory safety: an index out of range or a null reference then reads or writes arbitrary memory instead of panicking. The flag does nothing at -O0, the default, where every check stays on and the compiler warns warning: '--no-runtime-checks' has no effect at -O0. It applies from -O1 up, -g included. The setting is part of the build cache’s key, like the optimization level.

To remove checks from one function instead of a whole build, mark it [Unsafe("...")] with the same names, comma-separated in one string: [Unsafe("overflow")], [Unsafe("overflow,bounds")] or [Unsafe("all")]. It removes them from that function’s body only, at every optimization level (-O0 included), so the function behaves the same in every build. Closures written in it lose the same checks. Functions it calls keep theirs, with one exception: bounds also removes the index checks that Array and String make for it, since those run inside their indexers. [Unsafe] goes on a free function, a method or a constructor. An unknown name is an error at the string.

# `[Unsafe("...")]` removes runtime checks from one function's body.

# A hash that wraps on purpose: without the overflow check, `*` and `+`
# wrap around instead of panicking.
[Unsafe("overflow")]
hash(values: Array<int>): uint {
    uint result = 2166136261;
    for (var value in values) {
        result = (result ^ (value as uint)) * 16777619;
    }
    return result;
}

# A loop that already checked its range: the index checks of `a[i]`, made by
# `Array`'s indexer for this caller, are removed too.
[Unsafe("bounds")]
sum(values: Array<int>): long {
    long total = 0;
    ulong i = 0;
    while (i < values.count) {
        total = total + values[i];
        i = i + 1;
    }
    return total;
}

main(): int {
    var values = Array<int>();
    for (var i in 0..100) {
        values.append(i);
    }
    assert(hash(values) != 0, "a hash");
    assert(sum(values) == 4950, "0 + 1 + ... + 99");
    # Outside those functions every check stays: 2147483647 + 1 here would
    # still panic.
    return 0;
}

Bit operations and math functions

Each integer type has methods that compile to a single instruction on most CPUs, through LLVM’s intrinsics, for code that would otherwise loop over bits:

MethodReturns
leading_zeros(), trailing_zeros()The zero bits above the highest set bit, or below the lowest; the type’s width for zero.
count_ones(), count_zeros()How many bits are set, or clear.
swap_bytes()The value with its bytes reversed, to convert between little and big endian.
reverse_bits()The value with its bits reversed.
rotate_left(n), rotate_right(n)The bits rotated by n, modulo the width.
min(x), max(x)The smaller or the larger value.
abs()The value without its sign (signed types). Panics for the lowest value, whose negation does not fit.

Like the overflow methods, they work at their type’s own width: byte 1.leading_zeros() is 7.

# Bit operations on integers: counting, reversing and rotating bits.
main(): int {
    uint mask = 0x00F0;
    assert(mask.trailing_zeros() == 4, "the lowest set bit is bit 4");
    assert(mask.leading_zeros() == 24, "the highest set bit is bit 7");
    assert(mask.count_ones() == 4, "four bits are set");

    # Zero has no set bit: its counts are the type's width.
    assert((0 as ulong).trailing_zeros() == 64, "no set bit");

    # The methods work at their type's own width.
    byte low = 1;
    assert(low.leading_zeros() == 7, "a byte has 8 bits");
    assert(low.rotate_right(1) == 0x80, "the bottom bit comes back at the top");

    # Byte order: swap_bytes converts between little and big endian.
    assert((0x12345678 as uint).swap_bytes() == 0x78563412, "bytes reversed");
    assert((1 as uint).reverse_bits() == 0x80000000, "bits reversed");

    # min, max and abs.
    assert((-7).abs() == 7, "without its sign");
    assert((3).min(5) == 3 && (3).max(5) == 5, "smaller and larger");
    return 0;
}

float and double have square_root(), floor(), ceiling(), truncate(), round() (halfway cases away from zero), abs(), copy_sign(sign), multiply_add(factor, addend) (this * factor + addend rounded once) and min(x) and max(x) (which ignore a NaN argument):

# Floating-point functions: square root, rounding, sign and fused
# multiply-add.
main(): int {
    var side = (2.0).square_root();
    assert(side * side > 1.999 && side * side < 2.001, "close to 2");

    assert((-1.5).floor() == -2.0, "toward negative infinity");
    assert((-1.5).ceiling() == -1.0, "toward positive infinity");
    assert((-1.5).truncate() == -1.0, "toward zero");
    assert((2.5).round() == 3.0, "halfway cases away from zero");

    assert((-4.0).abs() == 4.0, "without its sign");
    assert((4.0).copy_sign(-1.0) == -4.0, "the sign of the argument");
    assert((2.0).multiply_add(3.0, 1.0) == 7.0, "2 * 3 + 1, rounded once");
    assert((1.5).min(-2.0) == -2.0 && (1.5).max(-2.0) == 1.5, "smaller and larger");
    return 0;
}

They also have the functions of C’s <math.h>, with their names spelled out. Angles are in radians. LLVM’s intrinsics compute the ones it has, and the C math library the rest:

MethodsC’s names
sine(), cosine(), tangent()sin, cos, tan
arc_sine(), arc_cosine(), arc_tangent(), y.arc_tangent2(x)asin, acos, atan, atan2(y, x)
hyperbolic_sine(), hyperbolic_cosine(), hyperbolic_tangent()sinh, cosh, tanh
arc_hyperbolic_sine(), arc_hyperbolic_cosine(), arc_hyperbolic_tangent()asinh, acosh, atanh
exponential(), exponential2(), exponential_minus_one()exp, exp2, expm1
logarithm(), logarithm2(), logarithm10(), logarithm_one_plus()log, log2, log10, log1p
cube_root(), hypotenuse(other)cbrt, hypot
remainder(divisor)remainder (the quotient rounded to nearest; % is fmod, rounded toward zero)
is_nan(), is_infinite(), is_finite(), sign_bit()isnan, isinf, isfinite, signbit

Powers are the ** operator: x ** 0.5, or x ** 3 with an integer exponent. The math module has the constants (math.PI, math.TAU, math.E, math.INFINITY, math.NAN, and the epsilon and limits of float and double) and math.gcd and math.lcm for integers:

# Floating-point math: trigonometry, logarithms, powers, classification,
# and the math module's constants and integer functions.
import math;

main(): int {
    var angle = math.PI / 6;
    assert((angle.sine() - 0.5).abs() < 1e-12, "sin 30 degrees is 1/2");
    assert(((1.0).arc_tangent2(1.0) - math.PI / 4).abs() < 1e-12, "45 degrees");

    assert((1024.0).logarithm2() == 10.0, "2 to the 10th is 1024");
    assert((1.0).exponential() == math.E, "e to the 1st");
    assert(2.0 ** 10 == 1024.0 && (-8.0).cube_root() == -2.0, "powers and roots");
    assert((3.0).hypotenuse(4.0) == 5.0, "a 3-4-5 triangle");

    assert(math.NAN.is_nan() && math.NAN != math.NAN, "NaN is not equal to itself");
    assert(math.INFINITY.is_infinite() && !math.INFINITY.is_finite(), "infinite");
    assert((-0.0).sign_bit() && !(-0.0 < 0), "negative zero");

    assert(math.gcd(12, 18) == 6 && math.lcm(4, 6) == 12, "integers");
    return 0;
}

Hints for the optimizer are [Likely] and [Unlikely] on a branch and the prefetches of the hint module. Any other LLVM intrinsic can be declared as an extern; see LLVM intrinsics.

Operators

# Operators on numbers and booleans.
main(): int {
    # Arithmetic.
    assert(2 + 3 * 4 == 14, "* binds tighter than +");
    assert((2 + 3) * 4 == 20, "parentheses group");
    assert(17 % 5 == 2, "remainder");
    assert(-(3) == -3, "negation");

    # Exponentiation is right-associative: 2 ** 3 ** 2 is 2 ** 9.
    assert(2 ** 10 == 1024, "power");
    assert(2 ** 3 ** 2 == 512, "right-associative");
    # With a floating-point operand, the result is floating point.
    assert(2.0 ** -2 == 0.25, "float power");
    assert(9 ** 0.5 == 3.0, "square root");

    # Comparison yields a bool.
    var small = 3 < 5;
    assert(small, "3 < 5");
    assert(5 >= 5 && 4 <= 5 && 6 > 5 && 5 == 5 && 5 != 6, "comparisons");
    # On numbers, `===` and `!==` are the same as `==` and `!=`.
    assert(5 === 5 && 5 !== 6, "identity on numbers");

    # Logical operators; 'and' and 'or' are spellings of && and ||.
    var t = true;
    var f = false;
    assert(t && !f, "and, not");
    assert(f || t, "or");
    assert(t and (f or t), "word forms");

    # Bitwise operators.
    assert((6 & 3) == 2, "and");
    assert((6 | 3) == 7, "or");
    assert((6 ^ 3) == 5, "exclusive or");
    assert(~0 == -1, "complement");
    assert(1 << 4 == 16 && 256 >> 2 == 64, "shifts");

    # Compound assignment and increment.
    var n = 5;
    n += 3;   # 8
    n -= 2;   # 6
    n *= 4;   # 24
    n /= 5;   # 4
    n %= 3;   # 1
    n <<= 3;  # 8
    n >>= 1;  # 4
    n &= 6;   # 4
    assert(n == 4, "compound assignment");
    n++;
    ++n;
    n--;
    assert(n == 5, "increment and decrement");

    # The conditional (ternary) operator.
    var sign = n > 0 ? "positive" : "not positive";
    assert(sign == "positive", "ternary");
    return 0;
}
KindOperators
Arithmetic+ - * / % **, unary - and +
Comparison== != < <= > >=
Identity=== !== (two references to the same object; plain equality for numbers and bool)
Logical&& (or and), || (or or), !
Bitwise& | ^ ~ << >>
Assignment= += -= *= /= %= <<= >>= &= |= ^=
Increment++ --, prefix or postfix
Conditionalcondition ? a : b

&& and || short-circuit: the right operand is evaluated only when the left one does not decide the result, so items.count > 0 && items[0] == 1 never reads items[0] from an empty array.

Unary operators nest: -~x is -(~x), and !!x is !(!x), which turns a number into a bool. A struct can define its own operators, the truth test !! included (see Operators).

** raises a number to a power, and is right-associative: 2 ** 3 ** 2 is 2 ** 9. On two integers the result is an integer, and the exponent can be any integer expression. A negative exponent gives the truncated real result: 1 for a base of 1, 1 or -1 for a base of -1, and 0 otherwise (2 ** -1 is 0).

When either operand is a float or double, the result is floating point, by the usual conversions above: 2.0 ** 0.5 is the square root of 2, 2.0 ** -2 is 0.25, and 4 ** 0.5 is the double 2.0. An integer exponent of at most 32 bits computes by repeated multiplication (LLVM’s powi), which is fast but may differ from C’s pow in the last bit; any other exponent calls pow itself. ** on anything but numbers or a struct that defines it is an error:

main.pep:3:15: error: invalid operands to binary expression ('__ptr<int>' and 'int')
    3 |     var q = p ** 2;
      |               ^~

Operators bind in this order, tightest first:

  1. Postfix: calls f(x), indexing a[i], member access a.b, x++, x--
  2. **
  3. as, as?
  4. Unary +, -, !, ~
  5. Prefix ++, --
  6. *, /, %
  7. +, -
  8. <<, >>
  9. .., ..> (ranges; not associative)
  10. is
  11. <, <=, >, >=
  12. ==, !=, ===, !==
  13. &
  14. |, ^
  15. &&, and
  16. ||, or
  17. ? :
  18. = and the compound assignments (right-associative)

As in C, & binds more loosely than ==, so write (flags & mask) == mask with the parentheses.

An expression can nest at most 256 levels deep, counting each operator of a chain as one level, so a sum of more than about 250 terms is a compile error (see Compiler errors).

String interpolation

An interpolated string is a string literal with a $ before its opening quote. Inside it, an expression in braces, {name} or {a + b}, is replaced by the text of its value:

# Interpolated strings: `$"..."` with expressions in braces.
main(): int {
    var name = "Pepper";
    var count = 3;
    var ready = true;

    # Each {name} is replaced by that variable's text.
    var line = $"{name} has {count} items, ready: {ready}";
    assert(line == "Pepper has 3 items, ready: true", "integers, bool and String");

    # Any expression fits between the braces, strings included.
    var sizes = [2, 5];
    assert($"total {sizes[0] + sizes[1]}" == "total 7", "arithmetic and index");
    assert($"{name.size} letters" == "6 letters", "call");
    assert($"{count > 1 ? "many" : "one"}" == "many", "ternary with strings");

    # Doubled braces are literal braces; escapes work as in any string.
    assert($"{{count}} = {count}" == "{count} = 3", "literal braces");
    assert($"tab\t{count}\n" == "tab\t3\n", "escapes");
    return 0;
}
  • {expression} holds an expression: a name (a local, a parameter, a global, or inside a method a field), a member access ({p.x}), a call ({f(x)}), an index ({items[0]}), arithmetic ({a + b}), or a ternary ({a > b ? a : b}). Spaces around it are ignored.
  • The expression changes no variable: an assignment (=, +=, …), ++ or -- anywhere in it is an error, even inside a call’s arguments ({f(a = 3)}). Only a closure’s body, which does not run in place, may hold one. A function it calls may still have effects of its own.
  • String literals may appear inside the braces ({greet("you")}), and so may character literals and other interpolated strings. A } inside them, or inside brackets of the expression’s own, does not end it.
  • The expression fits on one line and holds no comment: a line break or a # inside the braces is an error.
  • A format specification may follow the expression after a :, as in {n:>8} or {n:08x}: see below.
  • {{ and }} are a literal { and }.
  • Escapes (\n, \t, \", …) work as in any string literal.
  • The result is a new String. An interpolated string with no braces is an ordinary string.

Which values can be interpolated

Each value is converted with to_string(value), so it can be:

  • an integer of any size, signed or unsigned, which gives its decimal text;
  • a float or double, which gives the shortest decimal text that reads back as the same number, as Swift prints one: 0.1, 3.0 (a whole number keeps its .0), -2.5, and an exponent outside 1e-4 to 1e16 (1e+16, 1.5e-05); nan, inf and -inf for the special values;
  • a bool, which gives true or false;
  • a tuple of up to eight values that can each be interpolated, which gives its elements’ text in parentheses, (1, two) (a String element without quotes);
  • a String, which is used as it is;
  • a value whose type has a to_string(): String method, such as a struct that implements the runtime’s ToString trait, or a value of type ToString itself;
  • a value whose type has a to_string<T> override. Like hash<Point> (see overriding a generic function), an override applies to the whole program, so it serves types that have no to_string() method of their own.
# A struct is interpolated through its to_string() method.
struct Point {
    var x: int = 0;
    var y: int = 0;
    construct(this.x = 0, this.y = 0) {}
}

extend Point with ToString {
    to_string(): String {
        # Inside a method, fields are names too.
        return $"({x}, {y})";
    }
}

# A type without the method gets a to_string<T> override instead.
struct Celsius {
    var degrees: int = 0;
    construct(this.degrees = 0) {}
}

to_string<Celsius>(value: Celsius): String {
    return $"{value.degrees} C";
}

main(): int {
    var start = Point(1, 2);
    var reading = Celsius(21);
    assert($"from {start}" == "from (1, 2)", "ToString");
    assert($"it is {reading}" == "it is 21 C", "override");

    # A trait value converts through the value's own method.
    var shown: ToString = start;
    assert($"{shown}" == "(1, 2)", "trait value");
    return 0;
}

Numbers and tuples, with the format specifications below:

# Floating-point numbers and tuples have text too.
main(): int {
    var price = 4.5;
    var ratio = 1.0 / 3.0;
    assert($"{price}" == "4.5", "the shortest text that reads back");
    assert($"{2.0}" == "2.0", "a whole number keeps its point");
    assert($"{1.0e20}" == "1e+20", "large numbers use an exponent");
    assert($"{ratio:.3}" == "0.333", "a precision rounds to that many digits");
    assert($"[{price:8.2}]" == "[    4.50]", "numbers align right");
    assert($"{price:+08.2}" == "+0004.50", "with a sign and zeros");

    var point = (3, "north");
    assert($"{point}" == "(3, north)", "a tuple's elements in parentheses");
    print("{0} at {1:.1}", point, price);   # (3, north) at 4.5
    return 0;
}

Runtime types such as String, StringBuilder, io.Path, Uri and regex.Match implement ToString. An enum is interpolated through a to_string() method it declares (return name(); gives the case’s name).

A plain overload such as to_string(p: Point) is not used: the runtime’s code does not import your file, so it cannot see yours (see overloading). Write a to_string<Point> override or a method instead.

Format specifications

A : after the expression starts a format specification, which pads the value’s text, writes an integer in another base, or rounds a floating-point number to a number of digits. Its parts, all optional, come in this order:

{value:[[fill]align][+][0][width][.precision][type]}
PartMeaning
align< left, ^ centered (leaning left), > right. Without one, numbers go to the right and everything else to the left.
fillThe character the padding is made of, written before align; a space by default. It can be any character but {, }, ", ' and #.
+Writes a + before a number that is not negative.
0Pads with zeros after the sign (-0042). It cannot be combined with align.
widthThe least number of characters the text takes; longer text is kept whole.
.precisionFor a float or double: the number of digits after the point, rounded to nearest, a tie to the even digit ({pi:.2} is 3.14, {0.125:.2} is 0.12; .0 leaves no point). At most 100.
typed decimal, x and X hexadecimal (lower and upper case), b binary, o octal.
# Format specifications: padding, signs and other bases after a `:`.
main(): int {
    var count = 42;
    var name = "Pepper";

    # Width and alignment pad any value; numbers go right by default.
    assert($"[{count:6}]" == "[    42]", "a number goes right");
    assert($"[{name:10}]" == "[Pepper    ]", "text goes left");
    assert($"[{name:>10}]" == "[    Pepper]", "right");
    assert($"[{name:*^10}]" == "[**Pepper**]", "centered with a fill");

    # Integers take a sign, zero padding and a base.
    assert($"{count:+}" == "+42", "a plus sign");
    assert($"{count:05}" == "00042", "zero padding");
    assert($"{255:x} {255:X} {5:b} {8:o}" == "ff FF 101 10", "bases");
    assert($"0x{255:08x}" == "0x000000ff", "padded hexadecimal");
    assert($"{-42:x}" == "-2a", "a negative number keeps its sign");

    # The `:` after the whole expression starts the specification.
    assert($"[{count > 1 ? "many" : "one":>5}]" == "[ many]", "a ternary");
    return 0;
}
  • Padding (fill, align and width) works for any value that can be interpolated: it pads the value’s to_string text, counting characters, not bytes.
  • + and 0 need a number: an integer (of any size, signed or unsigned), or a float or double given a precision ({x:+08.2}). A type needs an integer, and a precision a float or double. Using one with any other value is an error at that character, and a precision cannot be combined with a type.
  • A negative number keeps its sign in every base: {-42:x} is -2a, not the bits of its two’s complement.
  • The : that starts the specification is the one after the whole expression, so a ternary can be formatted as it is: {n > 1 ? "many" : "one":>5}.

Formatting by position: String.format

String.format takes a string literal whose braces name its other arguments by position, {0} for the first, each with an optional format specification, and returns the text:

# String.format: braces name the arguments by position.
main(): int {
    var a = 2;
    var b = 3;
    assert(String.format("{0} + {1} = {2}", a, b, a + b) == "2 + 3 = 5", "in order");

    # An index can repeat or come out of order, with its own specification.
    assert(String.format("{1} before {0}", "first", "second") == "second before first", "reordered");
    assert(String.format("{0} is {0:x} in hex", 255) == "255 is ff in hex", "repeated");
    assert(String.format("[{0:>4}] {{literal}}", a) == "[   2] {literal}", "padding and braces");
    return 0;
}
  • The format must be a string literal: the compiler reads it, so an index out of range, an argument no brace names, or a bad specification is an error at compile time. {{ and }} are literal braces, as in an interpolated string.
  • Each argument is evaluated once, from left to right, however many braces name it and in whatever order.
  • An index can repeat, to show one value twice or in two forms ("{0} is {0:x} in hex").
  • It is part of the compiler, not a function in the runtime: it cannot be stored in a variable or called through a function value.
  • print takes the same arguments: print("{0} of {1}", done, total) prints what String.format makes of them.

Errors

A value with no conversion is reported at its braces, with a note at its type:

main.pep:5:17: error: cannot interpolate a value of type 'Foo': it has no 'to_string()' method and no 'to_string' overload
    5 |     print($"f = {f}");
      |                 ^~~
main.pep:1:8: note: declare 'to_string(): String' in 'Foo', or implement 'ToString' with 'extend Foo with ToString'
    1 | struct Foo {}
      |        ^~~

A lone brace is an error, since doubling it is how to write one, and so is anything after the expression but its }:

main.pep:2:15: error: unmatched '}' in interpolated string; write '}}' for a literal '}'
    2 |     print($"a } b");
      |               ^
main.pep:3:18: error: expected '}'
    3 |     print($"a {x b");
      |                  ^
main.pep:3:15: note: to match this '{'
    3 |     print($"a {x b");
      |               ^

Empty braces are reported where the expression should start:

main.pep:2:16: error: expected an expression after '{' in interpolated string; write '{{' for a literal '{'
    2 |     print($"a {} b");
      |                ^

An error in the expression itself, such as a name that is not declared (use of undeclared identifier 'x'), is reported like any other, at the token inside the braces.

A format specification’s errors are reported at the character in it that is wrong:

main.pep:5:16: error: unexpected 'q' in format specification; expected '[[fill]align][+][0][width][.precision][type]'
    5 |     print($"{n:q}");
      |                ^
main.pep:5:19: error: cannot format a value of type 'String' as an integer
    5 |     print($"{name:x}");
      |                   ^
main.pep:6:16: error: cannot format a value of type 'i32' with a precision; only 'float' and 'double' take one
    6 |     print($"{n:.2}");
      |                ^

String.format reports an index with no argument at the index, and an argument with no index at the argument:

main.pep:5:31: error: argument index 2 is out of range for 2 arguments
    5 |     print(String.format("{0} {2}", n, n));
      |                               ^
main.pep:5:35: error: argument 1 of 'String.format' is not used by its format string
    5 |     print(String.format("{0}", n, n + 1));
      |                                   ^~~~~

Changing a variable inside the braces is reported at the operator:

main.pep:3:16: error: an assignment cannot appear in an interpolated string
    3 |     print($"{a += 1}");
      |                ^~

How it works

The compiler turns $"({x}, {y + 1})" into "(" + to_string(x) + ", " + to_string(y + 1) + ")", resolving to_string in the runtime rather than in your file. So a struct’s own to_string() method can interpolate its fields without calling itself, and the result costs what the same + expression does. A value with a format specification goes through a runtime call that pads or converts its text instead. String.format becomes the same concatenation, with its arguments bound to hidden names first when the braces name them out of order or more than once.

Text encodings

A String always holds UTF-8. The text module converts text to and from the bytes of an encoding (u8[]), for files, network protocols and C libraries that use another one:

# Text in other encodings: encode a String to bytes, decode bytes back.
import text;

main(): int {
    var greeting = "héllo 🙂";

    # UTF-8 is what a String holds; decode checks the bytes.
    var utf8 = text.Utf8.encode(greeting);
    assert(utf8.count == 11, "é takes two bytes, 🙂 four");
    assert(text.Utf8.decode(utf8).value() == greeting, "round trip");

    # UTF-16: two bytes per unit, little-endian unless asked otherwise.
    var utf16 = text.Utf16.encode(greeting);
    assert(utf16.count == 16, "🙂 takes a surrogate pair");
    assert(text.Utf16.decode(utf16).value() == greeting, "round trip");

    # ASCII has no é: strict gives none, lossy writes '?'.
    assert(text.Ascii.encode(greeting).is_empty(), "not ASCII");
    assert(text.Ascii.encode_lossy("é").count == 1, "one '?'");

    # Code page 437, as old zip archives name entries: é is 0x82, and every
    # byte decodes, so decode gives a String.
    var dos = text.CodePage437.encode("café").value();
    assert(dos.count == 4 && dos[3] == 0x82, "one byte each");
    assert(text.CodePage437.decode(dos) == "café", "round trip");
    assert(text.CodePage437.encode(greeting).is_empty(), "no 🙂 in code page 437");

    # Strict decoding rejects invalid bytes; lossy decoding replaces them.
    var bad = Array<u8>();
    bad.append(104);
    bad.append(255);
    assert(text.Utf8.decode(bad).is_empty(), "0xFF is never UTF-8");
    assert(text.Utf8.decode_lossy(bad) == "h\u{FFFD}", "replaced");
    return 0;
}
TypeEncoding
text.Utf8UTF-8. encode copies the String’s bytes; decode checks them. is_valid checks bytes without decoding, and find_invalid gives the offset of a String’s first invalid byte. code_point_at reads one code point and append_code_point writes one.
text.Utf16UTF-16, two bytes per code unit, little-endian by default (encode(s, true) and decode(bytes, true) for big-endian). A code point past U+FFFF takes a surrogate pair.
text.AsciiASCII: code points below 128, one byte each. is_ascii checks a String.
text.CodePage437Code page 437, the IBM PC character set that old zip archives use for entry names: ASCII below 128, and one accented letter, Greek letter or box-drawing symbol for each byte above. Every byte decodes, so decode gives a String, not an Optional.
  • decode is strict: it gives Empty when the bytes are not valid in the encoding (a stray UTF-8 continuation byte, an unpaired UTF-16 surrogate, an odd UTF-16 byte count, an ASCII byte of 128 or more).
  • decode_lossy always succeeds: whatever is invalid becomes U+FFFD, the replacement character.
  • Ascii.encode gives Empty for text with a code point past 127, and CodePage437.encode for a character the code page lacks; encode_lossy writes ? for each instead.
  • No byte-order mark is written, and one that is read stays in the text.
  • io.File.read_string decodes with text.Utf8.decode, and an HTTP body’s text() with decode_lossy.

The reference lists every function.

Regular expressions

The regex module finds patterns in text: test whether text matches, find each match and its capture groups, and replace or split by a pattern.

# Regular expressions: match, capture, iterate, replace and split.
import regex;

main(): int {
    # Compile once, reuse. Backslashes are doubled inside a string literal.
    var date = regex.Regex("(?<year>\\d{4})-(\\d{2})-(\\d{2})");
    assert(date.is_match("due 2026-10-01"), "a date somewhere");

    # The first match, with its groups. Positions are byte offsets.
    var found = date.find("due 2026-10-01").value();
    assert(found.text() == "2026-10-01" && found.start() == 4, "where");
    assert(found.group(2).value() == "10", "a numbered group");
    assert(found.group("year").value() == "2026", "a named group");

    # Every match, left to right.
    var total = 0;
    for (var number in regex.Regex("\\d+").find_all("1 22 333")) {
        total += int.parse(number.text()).value();
    }
    assert(total == 356, "1 + 22 + 333");

    # Replace with group references, and split.
    var swapped = regex.Regex("(\\w+)@(\\w+)").replace_all("ann@home bob@work", "$2:$1");
    assert(swapped == "home:ann work:bob", "groups swapped");
    var parts = regex.Regex("\\s*,\\s*").split("a , b,c");
    assert(parts.count == 3 && parts[1] == "b", "three pieces");

    # Flags go inside the pattern: (?i) ignores case.
    assert(regex.Regex("(?i)^hello").is_match("HeLLo there"), "any case");

    # An invalid pattern from outside the program: parse reports it.
    var bad = regex.Regex.parse("(unclosed");
    assert(bad.is_err(), "missing ')'");
    return 0;
}

Compiling a pattern

  • regex.Regex(pattern) compiles a pattern and panics when it is invalid. Use it for patterns written in the program.
  • regex.Regex.parse(pattern) returns a Result<Regex> instead, whose error names the problem and its byte offset: invalid regex '(a': missing ')' at offset 0. Use it for patterns that come from input.
  • Compile once and reuse the Regex; compiling costs more than a search.
  • Regex.escape(text) escapes every character with a meaning in a pattern, so the result matches text literally.

A pattern is a String, so each backslash is doubled in a string literal: "\\d+" is the pattern \d+.

Searching

MethodResult
is_match(text)whether there is a match anywhere; the fastest test
find(text)the leftmost Match, or Empty
find_at(text, start)the leftmost match starting at or after byte start
find_all(text)an iterator over every match, left to right, without overlaps
replace(text, replacement)text with the first match replaced
replace_all(text, replacement)text with every match replaced
split(text)the pieces of text between matches

A Match has text(), start() and end() (byte offsets, as String.find returns, for use with substr_bytes), and group(index) or group(name), which give the group’s text or Empty when the group did not take part (as (a) in (a)|b matching b). Group 0 is the whole match; groups are numbered by their opening (. group_range(index) gives a group’s offsets.

Matching is leftmost-first, as in Perl: the match that starts earliest wins, and among those, a greedy repetition prefers more and alternation prefers its earlier branch (a|ab finds a in ab).

find_all skips an empty match that comes right after the previous match, so a* finds a twice in aba, not also an empty match between them.

In a replacement, $1 or ${1} stands for group 1, ${name} for a named group, $0 for the whole match and $$ for a $. A group that did not take part gives empty text. Use braces when a digit or letter follows: ${1}0, not $10.

Syntax

The syntax is RE2’s, the one Go’s regexp and Rust’s regex crate use.

SyntaxMatches
.any code point except a newline (any at all with (?s))
[abc], [a-z], [^a-z]a code point in, or not in, the set
[[:alpha:]], [[:^digit:]]a POSIX class: alnum, alpha, ascii, blank, cntrl, digit, graph, lower, print, punct, space, upper, word, xdigit
\d, \w, \san ASCII digit, word character ([0-9A-Za-z_]), or space ([\t\n\f\r ])
\D, \W, \Sanything else
\n, \t, \r, \f, \v, \aa control character
\x41, \x{1F642}the code point with that hexadecimal value
\., \*, \\, …the punctuation character itself
x*, x+, x?zero or more, one or more, zero or one
x{n}, x{n,}, x{n,m}exactly n, at least n, from n to m (at most 1000)
x*?, x+?, x??, x{n,m}?the same, preferring fewer (lazy)
xy, x|yx then y; x or y
(x)a capture group
(?<name>x), (?P<name>x)a named capture group
(?:x)a group that does not capture
^, $the start and end of the text (of a line with (?m))
\A, \zthe start and end of the text, always
\b, \Ban ASCII word boundary, and anything else

Flags go in the pattern: (?i) makes the rest of the enclosing group case-insensitive, (?i:x) only x, and (?-i) turns a flag off.

FlagMeaning
icase-insensitive (ASCII letters)
mmulti-line: ^ and $ also match at line breaks
s. also matches a newline
Uswap greedy and lazy: x* is lazy and x*? greedy

Text

  • A pattern works on code points: . and [^a] match one whole code point, such as é or 🙂, never part of one.
  • \d, \w, \s, \b, the POSIX classes and case-insensitive matching are ASCII-only: \w does not match é.
  • Positions are byte offsets, like String.find and substr_bytes, not the code point positions of substr.

Linear time

Matching never backtracks. The engine (a Pike VM, as in RE2) steps every possible match through the text together, so a search takes time proportional to the size of the text times the size of the pattern, whatever both are. A pattern such as (a*)*b, which takes exponential time in a backtracking engine, is safe on untrusted input.

The price is two features that need backtracking. A pattern that uses one fails to compile:

  • Backreferences (\1): backreferences are not supported
  • Lookaround ((?=x), (?!x), (?<=x), (?<!x)): lookaround is not supported

Groups nest at most 250 deep, and a pattern whose compiled program would exceed 100,000 instructions (from large counted repetitions such as (a{1000}){1000}) is rejected.

Threads

A Regex never changes once it is compiled, and it is [Shared]: one Regex can serve every thread at once, so a pattern the program uses often can be compiled once at module scope:

import regex;

const identifier = regex.Regex("^[A-Za-z_][A-Za-z0-9_]*$");

is_identifier(text: String): bool {
    return identifier.is_match(text);
}

Each search keeps its working state to itself, and a Match (which holds the searched String) stays on the thread that found it.

The reference lists every method.

JSON

The json module reads JSON text into a JsonValue, lets you look values up and change them, and writes them back as compact or indented text. It also converts structs to JSON and back.

# JSON: parse text, look values up, change them, and write them back.
import json;

main(): int {
    var text = "{\"name\": \"pepper\", \"server\": {\"port\": 8080}, \"tags\": [\"fast\"]}";
    var parsed = json.parse(text, path = "config.json");
    if (parsed.is_err()) {
        print(parsed.error().to_string());   # config.json:1:17: error: ...
        return 1;
    }
    var root = parsed.value();

    # Lookups chain, and a missing key gives Null instead of panicking.
    assert(root["name"].as_string().value() == "pepper", "a string");
    assert(root["server"]["port"].as_int().value_or(80) == 8080, "a number");
    assert(root["server"]["host"].as_string().value_or("localhost") == "localhost", "a default");

    # Match on the case to handle every kind.
    var described = match (root["tags"]) {
        Array(items) => $"{items.count} tag",
        Null => "no tags",
        else => "not a list",
    };
    assert(described == "1 tag", "an array");

    # Change the value in place, then write it back. Keys keep their order.
    root["tags"].append(json.JsonValue.String("safe"));
    root["debug"] = json.JsonValue.Bool(false);
    assert(root.to_string() == "{\"name\":\"pepper\",\"server\":{\"port\":8080},\"tags\":[\"fast\",\"safe\"],\"debug\":false}", "compact");

    # Build a value from scratch.
    var reply = json.JsonValue.object();
    reply["id"] = json.JsonValue.Integer(42);
    reply["ok"] = json.JsonValue.Bool(true);
    assert(reply.to_pretty_string() == "{\n  \"id\": 42,\n  \"ok\": true\n}", "indented");

    # An error names the line and column.
    var bad = json.parse("{\n  \"a\": 1\n  \"b\": 2\n}", path = "bad.json");
    assert(bad.error().to_string() == "bad.json:3:3: error: expected ',' or '}'", "where");

    # JSONC: comments and trailing commas, on request.
    var settings = json.parse("{\n  // the theme\n  \"theme\": \"dark\",\n}", comments = true);
    assert(settings.value()["theme"].as_string().value() == "dark", "JSONC");
    return 0;
}

Values

json.JsonValue is an enum whose cases carry values, so match handles each kind:

CaseHoldsJSON
Nullnothingnull
Bool(value: bool)the booleantrue, false
Integer(value: long)an integer within ±(2^53 − 1)42, -7
Number(value: double)any other number2.5, 1e3, 9007199254740993
String(value: String)the string"hi"
Array(items: Array<JsonValue>)the elements[1, 2]
Object(members: OrderedMap<String, JsonValue>)the members, in insertion order{"a": 1}
  • Numbers keep their form. A number written without a fraction or an exponent is an Integer when it is within ±(2^53 − 1), the range every JSON implementation agrees on (RFC 7493). Any other number, including a larger integer, is a Number.
  • Make values with the cases (JsonValue.Integer(42), JsonValue.String("a"), JsonValue.Null), and containers with JsonValue.object() and JsonValue.array(items).
  • Containers are shared. An Array or Object holds its array or map by reference, so changing it through value[key] = ..., append, or the map from as_object() changes every holder of the value.

Looking values up

ExpressionResult
value["key"]the member, or Null when missing or not an object
value[index]the element, or Null when out of range or not an array
value.as_string(), as_bool(), as_double()the value, or Empty for another kind
value.as_int()an Integer, or a Number with no fraction that fits in a long; otherwise Empty
value.as_array(), as_object()the array or map itself, or Empty
value.is_null()whether it is Null
value.contains("key")whether an object has the member, even a null one

A lookup that finds nothing gives Null rather than panicking, so lookups chain and a default goes at the end: root["server"]["port"].as_int().value_or(8080).

Changing values

  • value["key"] = member sets an object’s member. A new key goes at the end; an existing key keeps its place. It panics on a value that is not an object.
  • value[index] = element replaces an array’s element, and value.append(element) adds one at the end. Both panic on a value that is not an array.
  • value.remove("key") removes an object’s member and says whether it was there.

Parsing

json.parse(text) returns a Result<JsonValue, JsonError>. It is strict RFC 8259:

  • The text holds one value, with only spaces, tabs and line breaks around it. A byte-order mark at the start is skipped.
  • The text must be valid UTF-8; an invalid byte is an error at that byte.
  • A string cannot hold a raw control character (write \n), and a \u escape of an unpaired surrogate ("\uD800") is an error.
  • No comments, trailing commas, single quotes, NaN, leading zeros (01) or + signs.
  • When an object repeats a key, the last value wins, in the place of the first.

parse takes optional arguments, usually passed by name:

ArgumentDefaultMeaning
commentsfalseAccept JSONC, as VS Code’s settings files are: // and /* */ comments, and a comma after the last element or member.
path""The name of the text, which the error’s to_string() puts first.
max_depth1000How deeply arrays and objects may nest.

The parser keeps its own stack rather than recursing, so deep nesting in the text cannot overflow the call stack. max_depth still bounds it, because releasing a value releases its nested values recursively. Raise it with care: Windows gives a program’s main thread a 1 MB stack, which a few thousand levels of nested objects can use up when the value is released.

Errors

A JsonError has the message, the line and column (from 1, counting bytes) and the byte offset. to_string() renders it like a compiler error:

config.json:3:3: error: expected ',' or '}'

Without a path, it starts at the line: 3:3: error: expected ',' or '}'.

Writing

  • value.to_string() writes compact text, with no spaces or line breaks: {"id":42,"tags":["a","b"]}. String interpolation uses it too.
  • value.to_pretty_string() puts each element and member on its own line, indented two spaces per level (to_pretty_string(indent = 4) for four), with a space after each :. An empty array or object stays [] or {}.
  • Strings are written as UTF-8, escaping ", \ and the control characters. to_string(ascii_only = true) and to_pretty_string(ascii_only = true) also escape everything outside ASCII as \u, with a surrogate pair past U+FFFF.
  • A Number is written as Pepper writes a double (0.5, 1000.0, 1e+16), so it reads back as a Number. NaN and the infinities, which JSON cannot spell, are written as null.
  • Objects keep their keys in insertion order, so parsing a document and writing it back keeps its order.

Documents with comments

parse skips a JSONC file’s comments. To change a file people edit, such as a settings file, and write it back with its comments, read it with json.parse_document(text), which returns a Result<JsonDocument, JsonError>:

# JSON: change a JSONC settings file and write it back with its comments.
import json;

main(): int {
    var text = "{\n  // Spaces per level.\n  \"tab_size\": 4,\n  \"font\": \"Mono\", // the editor's\n  \"wrap\": true\n}";
    var parsed = json.parse_document(text, path = "settings.json");
    if (parsed.is_err()) {
        print(parsed.error().to_string());
        return 1;
    }
    var document = parsed.value();
    assert(document.to_string() == text, "unchanged text comes back as it was");

    # Change the value; each comment stays with its value.
    document.root["tab_size"] = json.JsonValue.Integer(2);
    document.set_comments("$.wrap", ["// Wrap long lines."]);
    assert(document.comments("$.tab_size")[0] == "// Spaces per level.", "by path");
    print(document.to_string());
    # {
    #   // Spaces per level.
    #   "tab_size": 2,
    #   "font": "Mono", // the editor's
    #   // Wrap long lines.
    #   "wrap": true
    # }
    return 0;
}
  • document.root is the JsonValue; change it as any other.
  • Each comment belongs to a value, by its path from the root $ ($.editor.tab_size, $.files[0]): the comments on the lines before it, and those on the line it ends, after its comma. Comments before a closing bracket stay at the end of their array or object, and those around the whole value stay before or after it.
  • document.to_string() writes the text indented, as to_pretty_string does (to_string(indent = 4) for four spaces), with every comment back in its place. A file in that layout reads and writes back unchanged.
  • A comment whose value is removed is dropped. One whose path later names another value, such as an element shifted into its index, goes with that value.
  • document.comments(path) returns the comments before a value, and document.set_comments(path, ["// ..."]) replaces them.
  • parse_document takes path and max_depth as parse does, and always accepts JSONC.

Structs

json.encode(value) writes a struct as JSON text, and json.decode<T>(text) reads one back. There is no code to write per type: both walk the struct’s properties with reflection while compiling, so the conversion costs what hand-written code would.

# JSON: convert structs to JSON text and back, through reflection.
import json;

enum Mode {
    Fast,
    Safe,
}

struct Tls {
    var cert: String;
}

struct ServerConfig {
    var host: String;
    [Required]
    var port: int;
    # The key is "max-connections", not the property's name.
    [Name("max-connections")]
    var max_connections: int;
    # Never written or read.
    [Ignore]
    var started_at: long;
    # `null` or missing when none.
    var tls: Tls?;
    var mode: Mode;
    var tags: Array<String>;
}

main(): int {
    var config = ServerConfig();
    config.host = "localhost";
    config.port = 8080;
    config.max_connections = 64;
    config.mode = Mode.Safe;
    config.tags = ["web"];
    var text = json.encode(config);
    assert(
        text == "{\"host\":\"localhost\",\"port\":8080,\"max-connections\":64,\"tls\":null,\"mode\":\"Safe\",\"tags\":[\"web\"]}",
        "properties in declaration order"
    );

    var decoded = json.decode<ServerConfig>("{\"port\": 443, \"tls\": {\"cert\": \"site.pem\"}}");
    if (decoded.is_err()) {
        print(decoded.error().to_string());
        return 1;
    }
    var back = decoded.value();
    assert(back.port == 443, "a number");
    assert(back.tls.value().cert == "site.pem", "a nested struct");
    assert(back.mode == Mode.Fast, "a missing key keeps its default");

    # An error names where in the document it is, by path, line and column.
    var bad = json.decode<ServerConfig>("{\"port\": 443, \"mode\": \"Slow\"}", path = "server.json");
    assert(bad.error().to_string() == "server.json:1:23: error: $.mode: 'Slow' is not a case of 'Mode'", "a path");
    var missing = json.decode<ServerConfig>("{}");
    assert(missing.error().to_string() == "1:1: error: $: missing required key 'port'", "[Required]");
    return 0;
}

A struct’s public properties become an object’s members, in declaration order. Each property’s type converts as follows, and the same rules apply at the top level (json.decode<Array<int>>("[1, 2]")):

PepperJSON
an integer type (int, u8, long, …)a number; reading one checks that it has no fraction and fits the type
float, doublea number
booltrue, false
Stringa string
an enuma case that carries no values as its name ("Safe"); one that carries values as an object with the case’s name as its one key, holding the values by name ({"Circle": {"radius": 2}}); attributes choose other forms
T?the value, or null when Empty; a missing key reads as Empty
Array<T>an array
HashMap<String, V>, OrderedMap<String, V>, Map<String, V>an object
a structan object, recursively
json.JsonValueitself, for parts of a document with no fixed shape

A struct reference that holds nothing (a String, array, struct or reference enum field never assigned) is written as null, and null reads back as one, so a value round-trips. Reading a case’s value whose key is missing is an error: $.shapes[0].Circle: missing value 'radius'. null for a number, bool or enum is an error.

Attributes

The serialize module’s attributes, which json uses (they need no import of their own), change how a property is converted:

AttributeEffect
[Name("key")]writes and reads the property under key
[Ignore]leaves the property out of both
[Required]makes a missing key an error when reading

On an enum and its cases, they choose how a case is written:

AttributeEffect
[Name("key")] on a casewrites and reads the case under key instead of its own name
[Tag("kind")] on an enumwrites each case as an object naming it under kind, with its values beside the tag: {"kind": "Circle", "radius": 2}, {"kind": "Point"}
[Tag("kind", content = "data")] on an enumputs the values in an object under data instead: {"kind": "Circle", "data": {"radius": 2}}
[Untagged] on an enumwrites no tag: a case’s values as an object ({"radius": 2}), and a case that carries none as its name ("Point")
# JSON: choose how an enum's cases are written with attributes.
import json;

# A tag beside the values: {"kind": "circle", "radius": 2}.
[Tag("kind")]
enum Shape {
    [Name("circle")]
    Circle(radius: int),
    Point,
}

# The values under their own key: {"type": "Click", "data": {"x": 1, "y": 2}}.
[Tag("type", content = "data")]
enum Event {
    Click(x: int, y: int),
    Close,
}

# No tag; reading takes the first case that fits.
[Untagged]
enum Size {
    Fixed(px: int),
    Auto,
}

main(): int {
    assert(json.encode(Shape.Circle(2)) == "{\"kind\":\"circle\",\"radius\":2}", "internally tagged");
    assert(json.encode(Shape.Point) == "{\"kind\":\"Point\"}", "a case with no values");
    assert(json.encode(Event.Click(1, 2)) == "{\"type\":\"Click\",\"data\":{\"x\":1,\"y\":2}}", "adjacently tagged");
    assert(json.encode(Size.Fixed(8)) == "{\"px\":8}", "untagged");
    assert(json.encode(Size.Auto) == "\"Auto\"", "untagged, no values");

    var shape = json.decode<Shape>("{\"kind\": \"circle\", \"radius\": 5}");
    if (shape.value() is Circle(radius)) {
        assert(radius == 5, "read back");
    } else {
        return 1;
    }
    var size = json.decode<Size>("\"Auto\"");
    assert(size.value() is Auto, "the first case that fits");
    var bad = json.decode<Shape>("{\"radius\": 5}");
    assert(bad.error().to_string() == "1:1: error: $: missing tag 'kind'", "a missing tag");
    return 0;
}

Reading an [Untagged] enum tries each case in order and takes the first that reads without an error (with UnknownKeys.Reject, an extra key is an error too), so a case whose values include another’s goes first. When none reads, the error says so: $: found an object, which matches no case of 'Size'. A [Tag] enum’s errors name its tag: $: missing tag 'kind', $.kind: 'Hex' is not a case of 'Shape'. A tag with the name of one of a case’s values is a compile error, since both would be written under one key.

Reading

decode makes a struct with its constructor that takes no arguments, then reads in each property it finds a key for. A missing key leaves its property at its default, unless the property is [Required]. It takes optional arguments, usually passed by name:

ArgumentDefaultMeaning
unknown_keysUnknownKeys.IgnoreWhat to do with a key that names no property: skip it, or with UnknownKeys.Reject, fail naming it.
commentsfalseAccept JSONC, as parse does.
path""The name of the text, which the error’s to_string() puts first.

An error has a line and column as in Errors. One in converting a value also has its value_path, where in the document the value is as a path from the root $, and its message starts with that path. The line and column are the value’s, or the enclosing object’s for a missing key:

server.json:3:13: error: $.server.port: expected a number, found a string
server.json:7:21: error: $.tags[2]: expected a string, found null
server.json:4:24: error: $["max-connections"]: 70000 does not fit in 'u16'
server.json:1:1: error: $: missing required key 'port'

json.to_json(value) and json.from_json<T>(value) do the same conversions to and from a JsonValue rather than text (with no text, a from_json error has no line: error: $.port: ...), and json.encode(value, indent = 2) writes indented text.

Types it cannot convert

A property whose type has no JSON form, such as a pointer, a function, or a Result (a result is not a value to send over the wire), is a compile error naming the property. Notes follow the conversion back to the call that asked for it:

runtime/json/convert.pep:358:25: error: 'encode' cannot convert property 'outcome' of type 'Result<int>' to JSON
...
main.pep:10:16: note: 'encode<Job>' is used by 'main' here
   10 |     var text = json.encode(job);
      |                ^~~~~~~~~~~

Current limits

  • parse skips comments; only a JsonDocument keeps them, and decode never does. A document is always written indented.
  • A struct’s own conversion cannot be customized yet: a type converts as its properties do.
  • Map keys must be Strings.

The reference lists every function.

TOML

The toml module reads TOML 1.0 into a TomlValue, lets you look values up and change them, writes tables back as documents, edits a document in place without disturbing its comments, and converts structs to TOML and back. The package manager reads and edits pep.toml and pep.lock with it.

# TOML: parse a document, look values up, edit it in place keeping its
# comments, and read it into a struct.
import toml;

struct Package {
    [Required]
    var name: String;
    var version: String;
}

struct Manifest {
    [Required]
    var package: Package;
}

main(): int {
    var text = "[package]\nname = \"demo\"  # the name\nversion = \"0.1.0\"\n";
    var parsed = toml.parse(text, path = "pep.toml");
    if (parsed.is_err()) {
        print(parsed.error().to_string());   # pep.toml:2:6: error: ...
        return 1;
    }
    var root = parsed.value();
    assert(root["package"]["name"].as_string().value() == "demo", "a string");
    assert(root.lookup("package.version").value().as_string().value() == "0.1.0", "a dotted lookup");
    assert(root.try_get("dependencies").is_empty(), "a missing key");

    # Change a value, then write the tables back as a document.
    var package = root["package"];
    package["edition"] = toml.TomlValue.Integer(2026);
    assert(root.to_document() == "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = 2026\n", "a document");

    # Edit the text in place: comments and layout stay as written.
    var document = toml.parse_document(text, path = "pep.toml").value();
    document.set(["dependencies", "yaml"], toml.TomlValue.String("0.3")).value();
    assert(
        document.to_string() == "[package]\nname = \"demo\"  # the name\nversion = \"0.1.0\"\n\n[dependencies]\nyaml = \"0.3\"\n",
        "an edit"
    );

    # Read it into a struct, with the serialize attributes json uses.
    Manifest manifest = toml.decode<Manifest>(text).value();
    assert(manifest.package.version == "0.1.0", "a struct");

    # An error names the line and column.
    var bad = toml.parse("name \"demo\"\n", path = "bad.toml");
    assert(bad.error().to_string() == "bad.toml:1:6: error: expected '=' after the key", "where");
    return 0;
}

Values

toml.TomlValue is an enum whose cases carry values:

CaseHoldsTOML
String(value: String)the string"hi", 'C:\path', """..."""
Integer(value: long)a 64-bit integer42, 0xff, 1_000
Float(value: double)a float, inf and nan included0.5, 1e10, inf
Boolean(value: bool)the booleantrue, false
DateTime(value: TomlDateTime)a date, a time, or both1979-05-27T07:32:00Z, 1979-05-27, 07:32:00
Array(items: Array<TomlValue>)the elements[1, "two"]
Table(members: OrderedMap<String, TomlValue>)the keys, in the order written[server], { x = 1 }
  • Look values up with value["key"] and value[index], which panic on a miss, or try_get("key") and lookup("package.name"), which give an empty Optional. The as_ methods (as_string, as_int, as_float, as_bool, as_date_time, as_array, as_table) give an empty Optional for another kind; as_float also reads an integer.
  • Write a value on one line with to_string() (as after a key’s =), or a table as a whole document with to_document().
  • There is no null. What json writes as null is left out, and a missing key reads as the property’s default.

Errors

toml.parse(text, path = "pep.toml") checks every rule of TOML 1.0 and gives a TomlError that renders like a compiler error: pep.toml:4:12: error: expected '=' after the key. Arrays and inline tables may nest max_depth deep (1000 by default); parsing and writing use explicit stacks, so deep nesting never overflows the stack. The module passes all 709 TOML 1.0 cases of toml-test, which its tests run.

Editing a document in place

toml.parse_document(text) gives a TomlDocument, whose set(keys, value) and remove(keys) change only the lines they must, keeping every comment and blank line, the way cargo add edits Cargo.toml. A changed value is rewritten on one line; a new key goes after the last key of its table, indented like it; a new table goes at the end as a [header] section, or as { ... } with inline = true. Edits cannot reach inside an array of tables ([[bin]]); set or remove the whole array.

Structs

toml.encode(value) and toml.decode<T>(text) (and to_toml and from_toml, which work on a TomlValue) convert structs through reflection, with the serialize attributes JSON uses: [Name("key")], [Ignore] and [Required]. unknown_keys = toml.UnknownKeys.Reject makes an unknown key an error. See the reference for every function.

YAML

YAML 1.2 is read and written by the yaml package, which has the same shape as the json and toml modules: a YamlValue to look values up in and change, clang-style errors at the line and column, documents that keep their comments when edited, and struct conversion with the serialize attributes. It is a package rather than part of the runtime because few programs need YAML; add it with pepper package add justinboswell/pepper-yaml --name yaml.

import yaml;

main(): int {
    var parsed = yaml.parse("server:\n  port: 8080\n  debug: no\n", path = "config.yaml");
    if (parsed.is_err()) {
        print(parsed.error().to_string());
        return 1;
    }
    var root = parsed.value();
    print(root["server"]["port"].as_int().value());       # 8080
    print(root["server"]["debug"].as_string().value());   # no
    root["server"]["port"] = yaml.YamlValue.Integer(9090);
    print(root.to_string());
    return 0;
}
  • The core schema: yes, no, on and off are strings, not booleans, so NO (Norway) stays NO.
  • Safe for untrusted text: only the core schema’s tags (!!str, !!int, …) are read, and any other tag is an error; aliases may not stand for more than max_alias_nodes nodes, which stops the “billion laughs” attack.
  • Merge keys: yaml.parse(text, merge_keys = true) applies YAML 1.1’s << (<<: *defaults), which CI and compose files use to reuse a mapping; the mapping’s own keys win, and merged nodes count toward max_alias_nodes. By default << is an ordinary key, as YAML 1.2 says.
  • Several documents in one stream (---) are read by yaml.parse_all and written by yaml.write_all.
  • yaml.parse_document keeps comments, key order, flow or block style and scalar styles through edits, as json.parse_document keeps a JSONC file’s comments.
  • yaml.encode and yaml.decode<T> convert structs exactly as json.encode and json.decode do.

The package’s README lists what it reads and its limits: a mapping’s keys are strings, and there is no streaming reader yet.

Compression and archives

The compress module compresses and decompresses DEFLATE data, plain or in its zlib and gzip framings, and the archive module reads and writes tar archives. Together they handle .gz and .tar.gz files. Both are written in Pepper, so a program that uses them links no extra library, unless it is built to use zlib (see Using zlib).

# Compress and decompress bytes in one call, or as streams.
import compress;
import io;

main(): int {
    var text = "the cat sat on the mat; ".repeat(100).bytes();

    # gzip, zlib and raw DEFLATE each have compress and decompress.
    var packed = compress.Gzip.compress(text);
    assert(packed.count < text.count / 10, "repetitive text shrinks");
    assert(compress.Gzip.decompress(packed).value() == text, "round trip");

    # Level 0 stores, 9 squeezes hardest; 6 is the default.
    var fast = compress.Zlib.compress(text, level = 1);
    assert(compress.Zlib.decompress(fast).value() == text, "any level reads back");

    # Corrupt data is an error, never a crash.
    packed[packed.count - 5] = packed[packed.count - 5] ^ 1;
    var bad = compress.Gzip.decompress(packed);
    assert(bad.is_err() && bad.error().is_invalid_input(), "the CRC-32 check fails");

    # Streams: a CompressWriter wraps any io.Writer, a DecompressReader any
    # io.Reader, such as a File. Here both are in memory.
    var sink = io.ByteWriter();
    var writer = compress.CompressWriter(sink, compress.Format.Gzip);
    writer.print_line("first line");
    writer.print_line("second line");
    writer.finish();

    var reader = compress.DecompressReader(io.ByteReader(sink.bytes), compress.Format.Gzip);
    var lines = 0;
    for (var line in reader.lines()) {
        lines += 1;
    }
    assert(lines == 2, "two lines");

    # The checksums are there on their own too.
    assert(compress.Crc32.of("123456789".bytes()) == 0xCBF43926, "CRC-32");
    return 0;
}

Formats

FormatStructWhere it is used
Format.Gzipcompress.Gzip.gz files, HTTP Content-Encoding: gzip
Format.Zlibcompress.ZlibPNG images, HTTP deflate
Format.Rawcompress.Deflatezip entries, other containers with their own framing
  • compress(data, level = 6) returns the compressed bytes. The level runs from 0 (stored, no compression) to 9 (smallest, slowest). Each level searches for matches with zlib’s settings for it, so the output is about the size zlib makes at the same level, at a similar speed: on 12 MB of source text, compressing at level 6 takes about 1.2 times as long as the gzip command, and decompressing is a little faster than gzip -d.
  • decompress(data) returns an io.IoResult<u8[]>. Damaged or truncated data is an error whose is_invalid_input() holds, and gzip and zlib data is checked against its CRC-32 or Adler-32 checksum.
  • Gzip input may hold several members one after another (cat a.gz b.gz); they decompress to their contents joined. Bytes after the last member are an error. A gzip header’s file name and comment are skipped, and a written header has neither.
  • A zlib stream made with a preset dictionary needs it to decompress; see Preset dictionaries.
  • decompress puts everything in memory. Use a DecompressReader for input from outside the program, so a small file that expands hugely cannot exhaust memory.

compress.Crc32 and compress.Adler32 compute the two checksums over data given in pieces (update, then value), or in one call with of.

Streams

compress.DecompressReader(source, format) is an io.Reader that decompresses source, any io.Reader, as it is read, so lines(), chunks(size) and read_all() work on it. It holds at most about 225 KiB of input and output, however large the data.

compress.CompressWriter(sink, format, level = 6) is an io.Writer that compresses what is written and writes the result to sink. Call finish() at the end: it writes the last block and the checksum. Dropping the writer does not finish it, and finish() does not close the sink.

io.ByteReader(bytes) and io.ByteWriter() stand in for a stream when the data is in memory.

Preset dictionaries

A short message compresses poorly, since the stream has no earlier bytes to refer back to. A preset dictionary gives it some: bytes that both sides agree on beforehand, such as the keys and common values of a JSON protocol, which the compressed data can then copy from as if they came just before it.

# Compress small messages against a preset dictionary of what they share.
import compress;

main(): int {
    # Short messages with the same keys compress poorly on their own: the
    # stream has no earlier bytes to refer back to.
    var dictionary = "{\"user\": \"\", \"action\": \"login\", \"status\": \"ok\"}".bytes();
    var message = "{\"user\": \"ada\", \"action\": \"login\", \"status\": \"ok\"}".bytes();

    var plain = compress.Zlib.compress(message);
    var primed = compress.Zlib.compress(message, dictionary);
    assert(primed.count < plain.count, "the dictionary supplies the repeated text");

    # Decompressing needs the same dictionary; the stream names it by its
    # Adler-32, so a missing or different one is an error.
    assert(compress.Zlib.decompress(primed, dictionary).value() == message, "round trip");
    assert(compress.Zlib.decompress(primed).is_err(), "no dictionary given");
    print($"{plain.count} bytes alone, {primed.count} with the dictionary");
    return 0;
}
  • Zlib.compress(data, dictionary, level = 6) and Zlib.decompress(data, dictionary) take one. The zlib header names the dictionary by its Adler-32, so decompress without one, or with a different one, is an is_invalid_input() error. A stream made without a dictionary decompresses as usual when one is given.
  • CompressWriter(sink, format, dictionary, level = 6) and DecompressReader(source, format, dictionary) take one for Format.Zlib or Format.Raw. Raw DEFLATE data carries no sign of it, so both sides must know to use it. Gzip has no dictionary, and asking for one panics.
  • Only the last 32 KiB of a dictionary can be referred to, since that is how far back DEFLATE reaches; put the most common text at its end.

Using zlib

Building with -D USE_ZLIB (see Build options) makes compress use zlib, the reference DEFLATE library, for compressing, decompressing, CRC-32 and Adler-32, in place of its Pepper code:

pep-build main.pep -D USE_ZLIB -o app
  • The API is the same, and so are the errors for damaged data, worded as the Pepper code words them where zlib tells the causes apart. Compressed output can differ byte for byte (zlib’s gzip header names the operating system), but each side decompresses the other’s.
  • Linux links -lz, from the development package (zlib1g-dev, zlib-devel). macOS has zlib already. Windows has none: install it (vcpkg install zlib) and name the folder holding zlib.lib with -L or PEPPER_LIBRARY_PATH.
  • zlib compresses faster than the Pepper code at the same level, and makes output of about the same size.

Tar archives

# Write a .tar.gz, list it, and extract it.
import archive;
import compress;
import io;

main(): int {
    var folder = io.Directory.temp().join("pepper-compression-tar-example");
    io.Directory.remove_all(folder);
    var path = io.Directory.temp().join("pepper-compression-tar-example.tar.gz");

    # A TarWriter over a gzip CompressWriter over a File.
    var file = io.File.create(path).value();
    var gzip = compress.CompressWriter(file, compress.Format.Gzip);
    var tar = archive.TarWriter(gzip);
    tar.append_directory("app-1.0");
    tar.append_file("app-1.0/main.pep", "main(): int { return 0; }\n".bytes());
    tar.append_file("app-1.0/run.sh", "#!/bin/sh\n".bytes(), mode = 493);
    tar.finish();
    gzip.finish();
    file.close();

    # List the entries.
    var reader = archive.TarReader(compress.DecompressReader(io.File.open(path).value(), compress.Format.Gzip));
    var names = Array<String>();
    while (true) {
        var entry = reader.next_entry().value();
        if (entry.is_empty()) {
            break;
        }
        names.append(entry.value().path);
    }
    assert(names.count == 3 && names[0] == "app-1.0/", "a directory ends with '/'");

    # Extract without the top directory.
    reader = archive.TarReader(compress.DecompressReader(io.File.open(path).value(), compress.Format.Gzip));
    reader.extract(folder, strip_components = 1).value();
    assert(io.File.read_to_string(folder.join("main.pep")).value().starts_with("main"), "extracted");

    io.Directory.remove_all(folder);
    io.File.remove(path);
    return 0;
}

archive.TarReader(source) reads an archive from any io.Reader; wrap the file in a DecompressReader to read a .tar.gz.

  • next_entry() moves to the next entry and returns its TarEntry: path, kind (File, Directory, Symlink, HardLink or Other), size, mode, modified (seconds since 1970) and link_target. It returns Empty at the end of the archive.
  • The reader is an io.Reader of the current entry’s contents: read, read_all(), lines(). Contents not read are skipped.
  • It reads ustar archives with the pax and GNU extensions for long paths, which covers GNU tar, bsdtar, and the .tar.gz snapshots that git archive, GitHub, GitLab and Codeberg make.

extract(target, strip_components = 0) writes the rest of the archive into the directory target:

  • It refuses, with an is_invalid_input() error, an entry whose path is absolute or holds a .. component, a \ or a :, so an archive cannot write outside target. archive.safe_components(path) is that check, for readers of other formats: it gives the path’s components, or Empty for an unsafe path.
  • strip_components drops leading path components, as tar’s --strip-components does. A GitHub snapshot puts everything under name-version/, which strip_components = 1 removes.
  • Files and directories are created, replacing a file already there. A hard link becomes a copy of the file it names. Devices and FIFOs are skipped.
  • Each file and directory gets the entry’s modification time and, on Linux and macOS, its permission bits less the umask, as tar does, so a script keeps its executable bit. Setuid, setgid and sticky bits are never applied. A directory gets its mode and time after its contents are written, so a read-only directory can still be filled; one that already existed keeps its own. Windows and WASI apply no permission bits.
  • Symlinks are made after everything else, so no file is written through one. A link whose target is absolute or climbs out of target with .., or that would be made inside another symlink, is skipped. Windows, where making a symlink needs Developer Mode or an administrator, skips every symlink.

archive.TarWriter(sink) writes an archive: append_file(path, data), append_directory(path), append_symlink(path, target), append(entry, data) for any header, and append_stream(entry, source) to copy entry.size bytes from a stream. finish() writes the end marker. Paths longer than a ustar header holds get a pax header, so none is cut short.

Zip archives

.zip archives are read and written by the zip package, which builds on compress and archive. It is a package rather than part of the runtime because the package manager needs only .tar.gz; add it with pepper package add justinboswell/pepper-zip --name zip.

import zip;

main(): int {
    var writer = zip.Writer.create("bundle.zip").value();
    writer.add("version.txt", "1.0.0".bytes()).value();
    writer.finish().value();

    var archive = zip.Archive.open("bundle.zip").value();
    for (var entry in archive.entries()) {
        print($"{entry.name}: {entry.size} bytes");
    }
    archive.extract_all("out").value();
    return 0;
}
  • It reads and writes stored and DEFLATE entries, ZIP64 (archives and entries past 4 GiB, more than 65,535 entries), and UTF-8 names; names in older archives are decoded as code page 437 (text.CodePage437).
  • Entries compressed another way (bzip2, LZMA, zstd) or encrypted are listed, and reading one is an error naming the entry and the method.
  • extract_all checks every name with archive.safe_components before writing anything, and each entry’s CRC-32 as it is written.

Statements and semicolons

Every simple statement ends with a ;: a variable declaration, an expression, return, break and continue. So does every declaration that does not end in a block: a module variable, a struct field, a type alias, an import, a re-export and an extern function. A statement or declaration that ends in a block (if, while, for, with, a function, a struct) needs none.

Line breaks, spaces and comments mean nothing to the grammar. A statement can span as many lines as it needs, breaking anywhere between tokens, including before an operator or a ., and several statements can share a line.

# Every simple statement ends with a `;`; line breaks mean nothing.
is_small(n: int): bool {
    return n > 0 && n < 10;
}

main(): int {
    var width = 3;
    var height = 4; var depth = 5;

    # A statement may span several lines, breaking before or after an
    # operator.
    var volume = width * height
        * depth;
    assert(volume == 60, "3 * 4 * 5");

    var in_range = is_small(width)
        && is_small(height)
        && !is_small(volume);
    assert(in_range, "3 and 4 are small; 60 is not");

    var words = Array<String>();
    words.append("line");
    words.append("breaks");
    var long_words = 0;
    # A chain of calls may break before each `.`.
    for (var word in words
        .iterate()
        .filter((w) => w.length > 4)) {  # comments are fine anywhere
        long_words += 1;
    }
    assert(long_words == 1, "one word is longer than four characters");

    # So may a call's arguments.
    assert(volume + words[0].length == 64,
        "60 + 4");
    return 0;
}

A missing ; is an error

Leaving out a ; is an error, reported right after the statement it should end, with a fix that adds it. The language server offers the fix as a quick fix:

main.pep:2:18: error: expected ';'
    2 |     var total = 1
      |                  ^
      |                  ;

Because line breaks do not end anything, a statement missing its ; would otherwise run on into the next line whenever that line can continue it:

x = y
-z

reads as x = y - z; with its ; missing, not as two statements. The error says where the ; belongs when the parse cannot go on.

Control flow

Pepper has the familiar structured control flow of C-family languages: if and else to choose, while and for to repeat, and break, continue and return to leave early.

if and else

if runs a block when its condition is true, and the optional else block when it is false. The parentheses around the condition are optional; the braces around each block are required, even for a single statement.

# Branching with if and else.
classify(n: int): String {
    if n < 0 {
        return "negative";
    } else if n == 0 {
        return "zero";
    } else {
        return "positive";
    }
}

main(): int {
    var temperature = 25;

    # Parentheses around the condition are optional; braces are required.
    if temperature > 30 {
        print("hot");
    } else {
        print("not hot");
    }

    if (temperature > 20) {
        temperature = 20;
    }
    assert(temperature == 20, "clamped to 20");

    assert(classify(-5) == "negative", "negative");
    assert(classify(0) == "zero", "zero");
    assert(classify(7) == "positive", "positive");

    # For a choice between two values, the ternary operator is shorter.
    var parity = temperature % 2 == 0 ? "even" : "odd";
    assert(parity == "even", "20 is even");

    # && and || stop at the first operand that decides the result, so the
    # count check guards the index.
    var readings = Array<int>();
    var starts_high = false;
    if (readings.count > 0 && readings[0] > 100) {
        starts_high = true;
    }
    assert(!starts_high, "an empty array does not start high");
    return 0;
}

The condition is usually a bool, but a number also works: any value other than zero counts as true, as in C. A raw pointer (__ptr<T>) or a function value counts as true when it is not null. A struct counts only if it declares the truth-test operator !!(): bool, which the condition calls; any other struct is an error, so write node !== __null to ask whether a reference is set (see Truth tests and conditions). The same holds for the condition of while and of ?:, the operands of &&, ||, and and or, and the operand of !.

Chains of conditions

else if tests another condition when the ones before it were false. The first true condition’s block runs, and the final else block, if there is one, runs when none was true:

if x > 0 {
    return 1;
} else if x < 0 {
    return -1;
} else {
    return 0;
}

For a choice between two values, the conditional operator condition ? a : b is often shorter than an if.

Guarding a check

&& and || short-circuit, as in C: the right operand is evaluated only when the left one does not already decide the result. A guard can therefore come first in the same condition:

if items.count > 0 && items[0] > 100 {
    print("starts high");
}

When items is empty, items[0] is never read.

while

while repeats its block as long as the condition is true. The condition is checked before each iteration, so the body may run zero times. As with if, the parentheses are optional and the braces are required.

var countdown = 3;
while countdown > 0 {
    countdown -= 1;
}

for

for runs its block once for each element of an Array, each code point of a String, or each value of any type that follows the iterator protocol:

for (var score in scores) {
    total += score;
}

The loop variable can be declared with var or a type (for (int score in scores), for (var score: int in scores)), and the parentheses are optional. Iteration covers the protocol behind for and how to make your own types iterable.

Pepper has no C-style for (init; condition; step) loop. To count, loop over a range: for (var i in 0..count).

with

with enters a scope object for the length of a block and binds what it hands out. The object cleans up when it is destroyed, on every way out of the block:

with (var session = Session("grace")) {
    greet(session.user);
}

Scoped blocks with with covers the protocol and the optional else block.

match

match runs the arm for the case an enum value holds, and the compiler checks that every case is handled:

match (light) {
    Red, Amber => { stop(); }
    Green => { go(); }
}

Enums and match covers patterns and the else arm.

Branch hints: [Likely] and [Unlikely]

[Likely] or [Unlikely] on the line above an if, an else if’s if or a match arm (the else arm too) says how often that branch runs. The optimizer keeps the likely path as straight-line code and moves the unlikely one, such as error handling, out of its way. A hint never changes what the program computes; a wrong one costs only speed.

# Branch hints: `[Likely]` and `[Unlikely]` say which way a branch usually
# goes, so the optimizer keeps the common path straight.
enum Reading {
    Value(celsius: double),
    Missing,
}

parse_digit(c: char): int {
    [Unlikely]
    if (c < '0' || c > '9') {
        return -1;
    } else [Likely] if (c != '0') {
        return c - '0';
    }
    return 0;
}

describe(reading: Reading): String {
    match (reading) {
        [Likely]
        Value(celsius) => {
            return $"{celsius}";
        }
        [Unlikely]
        Missing => {
            return "missing";
        }
    }
}

main(): int {
    assert(parse_digit('7') == 7, "a digit");
    assert(parse_digit('x') == -1, "not a digit");
    assert(describe(Reading.Missing) == "missing", "the rare arm still runs");
    return 0;
}
  • [Likely] above an if marks its block, so its else is the unlikely side; [Unlikely] marks the block as the rare one. To mark an else, mark the if the other way.

  • In an else if, the attribute goes between else and if: } else [Unlikely] if (x) {.

  • A hint is an LLVM branch weight (2000 to 1, as clang gives __builtin_expect), so it matters with optimization (-O1 and up).

  • Only one of them, with no arguments, can mark a branch, and no other attribute can:

    main.pep:3:5: error: 'Target' cannot mark an 'if'; only '[Likely]' or '[Unlikely]' can
        3 |     [Target(OS(Linux))]
          |     ^~~~~~~~~~~~~~~~~~~
    

while loops and the arms of a match expression take no hint yet.

break and continue

break leaves the innermost loop immediately. continue skips the rest of the current iteration and goes on with the next one; in a while loop that means checking the condition again, so update the counter before any continue.

# Looping with while and for, and leaving early with break and continue.
main(): int {
    # while repeats as long as its condition is true.
    var countdown = 3;
    var launched = false;
    while countdown > 0 {
        countdown -= 1;
    }
    launched = countdown == 0;
    assert(launched, "counted down to zero");

    # for visits each element of an Array (or any other iterable).
    var scores = Array<int>();
    scores.append(70);
    scores.append(85);
    scores.append(92);
    var total = 0;
    for (var score in scores) {
        total += score;
    }
    assert(total == 247, "sum of the scores");

    # continue skips to the next iteration; break leaves the loop.
    var odd_sum = 0;
    var i = 0;
    while (i < 100) {
        i += 1;
        if (i % 2 == 0) { continue; }
        if (i > 9) { break; }
        odd_sum += i;
    }
    assert(odd_sum == 25, "1 + 3 + 5 + 7 + 9");

    # break and continue apply to the innermost loop.
    var pairs = 0;
    for (var a in scores) {
        for (var b in scores) {
            if (b == a) { break; }
            pairs += 1;
        }
    }
    assert(pairs == 3, "(85, 70), (92, 70), (92, 85)");
    return 0;
}

Both work in while and for loops and always apply to the innermost loop. There are no labeled breaks: to leave several loops at once, set a flag and test it, or move the loops into a function and return from it.

return

return value; leaves the current function and hands value back to the caller. It works from anywhere in the function, including from inside loops. See Functions.

In a function that returns nothing, a bare return; leaves the function early.

Functions

A function is a named block of code that takes parameters and can return a value. Functions declared at the top level of a file are free functions; functions declared inside a struct are methods, covered in Structs.

Declaring a function

Write the name, the parameters in parentheses as name: Type, and the return type after a colon:

area(width: int, height: int): int {
    return width * height;
}

Pepper also accepts a C-style form, with the return type first and each parameter’s type before its name:

int perimeter(int width, int height) {
    return 2 * (width + height);
}

Both forms declare the same kind of function; pick one style and use it consistently. The first form is the one used throughout this book.

# Declaring and calling functions.

import async;

# Parameters are written 'name: Type'; the return type follows the ')'.
area(width: int, height: int): int {
    return width * height;
}

# The C-style form puts the return type first and the type before each name.
int perimeter(int width, int height) {
    return 2 * (width + height);
}

# A function that returns nothing leaves out the return type
# (or writes ': void', or 'void' in front).
# A module-scope counter: const, and changed through an Atomic.
const log_count = async.Atomic<int>(0);
log(message: String) {
    log_count.fetch_add(1);
    print("log: " + message);
}

# Functions can call themselves, and can be declared in any order.
factorial(n: int): long {
    if (n <= 1) { return 1; }
    return n * factorial(n - 1);
}

main(): int {
    assert(area(3, 4) == 12, "3 x 4");
    assert(perimeter(3, 4) == 14, "2 x (3 + 4)");
    assert(factorial(20) == 2432902008176640000, "20!");
    assert(is_even(10), "declared after main, still callable");
    # An argument converts to its parameter's type as assignment does:
    # a double passed to an int parameter truncates toward zero.
    assert(area(2.9, 3) == 6, "2.9 becomes 2");

    log("starting");
    log("done");
    assert(log_count.load() == 2, "logged twice");
    return 0;
}

is_even(n: int): bool {
    return n % 2 == 0;
}
  • Order does not matter. A function can call functions declared later in the file, and can call itself.

  • The program starts at main. main(): int returns the process’s exit code; return 0 for success. To receive the command-line arguments, declare it main(args: String[]): int instead. See Command-line arguments and exit.

  • Calls must match the declaration. Passing the wrong number of arguments is a compile error:

    main.pep:3:12: error: too many arguments to 'add': expected 2, have 3
        3 |     return add(1, 2, 3);
          |            ^~~
    

Functions that return nothing

A function that returns no value leaves out the return type. You can also write : void, or void in front in the C-style form:

log(message: String) { print(message); }
log_line(message: String): void { print(message); }
void log_c(String message) { print(message); }

Such a function ends when it reaches the closing brace, or earlier at a bare return;:

log_positive(n: int) {
    if n <= 0 {
        return;
    }
    print($"{n}");
}

A call to such a function has no value, so it cannot be stored, passed, returned, or used as an operand or condition: var x = log("hi"); reports cannot use a value of type 'void' as variable 'x'.

In a function that returns a value, return; is an error:

main.pep:2:5: error: non-void function 'f' should return a value
    2 |     return;
      |     ^~~~~~

Inferred return types

A function without a return type gets one from its return statements. It returns void when no return has a value, and otherwise the type of the first return that has one, so half(x: double) { return x / 2; } returns a double. A generic function’s return type is inferred for each specialization, so first<T>(a: T, b: T) { return a; } returns an int for first<int>. main with no return type returns an int, and reaching its closing brace exits with 0.

Writing the return type is still clearer for a function that returns a value, and it lets the compiler check each return against it.

Parameters

Parameters are passed by value. For numbers and bool, the function receives its own copy. For a String, an Array or any other struct, the value is a reference, so the function receives another reference to the same object: it can change the object’s contents, and the caller sees the change. Assigning a new object to the parameter does not affect the caller’s variable. Memory and reference counting describes exactly how references are counted when you pass them.

Arguments convert to the parameter’s type the same way assignment does, so you can pass an int where a long or double is expected, a double where an int is expected (it truncates toward zero, like int n = 2.7;), and a String where a struct with an [Implicit] constructor from String (such as io.Path) is expected.

Default parameter values

A parameter can have a default value, written = value after its type. A call that leaves the argument out gets the default. Put parameters with defaults at the end of the list, since arguments are matched from the left.

# Default parameter values.

# Parameters with defaults come last; a call may leave them out.
greet(name: String, greeting: String = "Hello", punctuation: String = "!"): String {
    return greeting + ", " + name + punctuation;
}

# Defaults work with the C-style form too.
int clamp(int value, int low = 0, int high = 100) {
    if (value < low) { return low; }
    if (value > high) { return high; }
    return value;
}

main(): int {
    assert(greet("Ada") == "Hello, Ada!", "both defaults");
    assert(greet("Ada", "Hi") == "Hi, Ada!", "one default");
    assert(greet("Ada", "Hi", ".") == "Hi, Ada.", "no defaults");

    assert(clamp(150) == 100, "clamped to the default high");
    assert(clamp(-5) == 0, "clamped to the default low");
    assert(clamp(-5, -10) == -5, "low given");
    return 0;
}

Defaults work the same way for methods and constructors. To give a later parameter a value without passing every parameter before it, name it (see Named arguments).

Named arguments

A call can name its arguments, name = value, so it reads clearly and can set one defaulted parameter without passing the ones before it:

# Named arguments: `name = value` after the positional arguments.
window(title: String, width: int = 800, height: int = 600, resizable: bool = true): String {
    return $"{title} {width}x{height} resizable={resizable}";
}

struct Point {
    var x: int;
    var y: int;

    construct(this.x = 0, this.y = 0) {}

    moved(dx: int = 0, dy: int = 0): Point {
        return Point(x + dx, y + dy);
    }
}

# 'size = ...' works as well as 'count = ...'; 'n = ...' still works, with a
# deprecation warning.
[Alias("size", "count")]
[Rename("n", "count")]
repeat(text: String, count: int = 1): String {
    var result = "";
    for (var i in 0..count) {
        result = result + text;
    }
    return result;
}

main(): int {
    assert(window("Editor") == "Editor 800x600 resizable=true", "all defaults");
    # Skip width and height.
    assert(window("Editor", resizable = false) == "Editor 800x600 resizable=false", "skip");
    # Named arguments in any order.
    assert(window("Editor", height = 400, width = 1200) == "Editor 1200x400 resizable=true", "any order");
    # Every argument may be named.
    assert(window(title = "Editor", width = 1024) == "Editor 1024x600 resizable=true", "all named");

    # Constructors and methods take them too.
    var p = Point(y = 5);
    assert(p.moved(dy = 2).y == 7, "method");

    assert(repeat("ab", size = 3) == "ababab", "alias");
    return 0;
}
  • Positional first. Positional arguments come first and match parameters from the left; named arguments follow, in any order. A positional argument after a named one is an error: positional argument cannot follow a named argument.
  • Each parameter once. Naming a parameter that already has an argument is an error: argument for 'width' given twice.
  • Every required parameter. A parameter without a default must get an argument: missing argument for parameter 'title' in call to 'window'. A name the function does not have is an error, with a suggestion when one is close: no parameter named 'widht' in 'window'; did you mean 'width'?.
  • Order of evaluation. Arguments are evaluated in the order written, then passed to their parameters.
  • Everywhere a call is. Free functions, methods, static methods, constructors (a this.x parameter is named x), extension methods, enum methods, generic functions, and calls through a trait value, which use the trait’s parameter names. A method that implements a trait should use the trait’s names, so a call names the same arguments either way: a different name is a warning (parameter 'factor' of 'area' is named 'scale' in trait 'Shape') whose fix renames the parameter.
  • Overloads. A member of an overload set without a parameter of a given name is not a candidate, so names can pick between overloads.
  • Not through function values. A function type carries no parameter names, so a call through a function value is positional only: cannot name arguments in a call through a function value of type '(int, int) => int'.
  • Not to extern functions. Their parameter names are copied from C headers and stay free to change: cannot name arguments in a call to extern function 'CreateFileW'. Wrap the C function in a Pepper function to give it named arguments.

Other names for a parameter

Two attributes on a function give its parameters more names. In both, the declared parameter’s name comes second:

  • [Alias("size", "count")] lets a call name parameter count as size too, permanently.
  • [Rename("n", "count")] keeps the former name n working after the parameter was renamed to count. Each use warns, like a deprecated declaration, with a fix that renames it.

The first name must not already be a parameter or another name, and the second must be a parameter of the function. A call that names one parameter by two of its names gets argument for 'count' given twice.

Returning several values

A function returns several values as a tuple, and the caller destructures it:

divide(a: int, b: int): (int, int) {
    return (a / b, a % b);
}

var (quotient, remainder) = divide(17, 5);

Overloading

Functions can share a name when their parameter lists differ, in one file, across a module’s files, or across the modules a file imports, and each call picks the best fit for its arguments. See Overloading.

Generic functions

A function can take type parameters in angle brackets after its name, such as first<T>(items: T[]): T. Generics covers them.

Printing with print

print(value) writes a value’s text and a line break to standard output: what interpolating it, $"{value}", would make. That covers a String, numbers, bool (true or false), tuples, and any type with a to_string(). To print several values on one line, interpolate them (print($"total: {n}")), or pass a format string and its arguments, which prints what String.format makes of them (print("{0} of {1}", done, total)).

# print writes a value's text and a line break.
main(): int {
    print("Hello, world!");
    print(42);                            # any value with a text form
    print((1.5, true));                   # (1.5, true)
    var name = "Pepper";
    var count = 42;
    print($"{name} has {count} items");   # interpolate other values
    print("{0} + {1} = {2}", 3, 4, 3 + 4); # or format them: "3 + 4 = 7"
    print("{braces} alone print as written");
    return 0;
}
  • With a string literal and no other arguments, the text prints as written: its braces are not a format.
  • A value with no text form is an error at the argument:
main.pep:5:11: error: cannot print a value of type 'Foo': it has no 'to_string()' method and no 'to_string' overload
    5 |     print(f);
      |           ^

print writes each line out as it prints it, like Rust’s println!, so the output appears as the program runs even when standard output is a pipe or a file. Text from print and from io.File.stdout() comes out in the order the program wrote it.

A float or double prints its shortest exact text (print(0.5) prints 0.5), and a tuple its elements in parentheses ((1, two)).

Assertions and panics

For checks in your programs, the runtime provides assert(condition, message), which stops the program with message when condition is false, and panic(message), which always stops it. Each is overloaded: the message may be a String, built at run time (assert(n > 0, name + " must be positive")), or a C string, which a string literal picks, so a literal message builds nothing when the assertion holds. See Built-in functions.

A panic flushes what the program printed, writes the location of the failing call and the message to standard error, and aborts:

main.pep:12:5: panic: count must be positive

In a build without -O, a backtrace of the calls that led there follows the message. PEPPER_BACKTRACE=1 turns it on in an optimized build and PEPPER_BACKTRACE=0 turns it off (see Backtraces and the debugger).

The checks the compiler adds report the same way, at the expression that failed: an overflowing operator, a null function value, a match on a null reference enum. So do the runtime’s checking methods, which report their caller’s line, not their own: indexing an Array or String out of range, Array.pop() on an empty array, Optional.value() on an Empty optional, Result.value() on an error, and the like.

They do it with an ordinary default parameter, location: __ptr<char> = __caller_location(). A call that leaves location out gets the place of that call, as file:line:col. Write the same parameter on a function of your own that checks its arguments, and pass location on to assert or panic, so a failure names the line that passed the bad value:

# Reporting the caller's location with `__caller_location()`.

# A check that names its caller's line when it fails, the way `assert`,
# `panic`, `Array` indexing and `Optional.value()` do: the default is the
# location of the call that leaves `location` out.
check_positive(value: int, location: __ptr<char> = __caller_location()): int {
    assert(value > 0, "expected a positive value", location);
    return value;
}

# The location is a C string, `file:line:col`.
here(location: __ptr<char> = __caller_location()): String {
    return String(location);
}

main(): int {
    assert(check_positive(3) == 3, "positive");
    var at = here();
    assert(at.ends_with("functions-caller-location.pep:18:14"), "this call");
    return 0;
}

Written anywhere else, __caller_location() is its own location. A compiler-generated check with no single place to report, such as a call through a null trait value, prints the message alone.

Command-line arguments and exit

main comes in two forms. main(): int takes nothing. main(args: String[]): int receives the program’s command-line arguments: element 0 is the program as it was started, and the arguments typed after it follow in order. Declare the parameter only when the program reads it, as a Windows console program declares argc/argv only when it needs them. Any other parameter list on main is an error, and so is declaring both forms.

pepper build --entry <name> (and pepper run --entry <name>) starts the program in the top-level function name instead, which takes either form of parameter list, as main does. Every main is then left out of the program, so a file can hold several entry points, such as a benchmark beside the tool it measures.

Elsewhere, process.arguments() returns the same array, so code outside main can read the arguments without having them passed down. process.exit(code) ends the program at once with status code, from anywhere, after flushing what was printed. Both are in the process module, so the file needs import process.

import process;

# args holds the program name, then each command-line argument.
main(args: String[]): int {
    if (args.count > 2) {
        print("usage: greet [NAME]");
        process.exit(2);  # ends the program here, with status 2
    }
    var name = "world";
    if (args.count == 2) {
        name = args[1];
    }
    print("Hello, " + name + "!");
    return 0;
}

exit skips everything that would run on the way out of main: locals are not released and destructors do not run. Prefer returning from main, and keep exit for ending from deep inside a call. See Processes.

Functions as values

A function’s name used without a call is a value that you can store in a variable, pass to another function, or return, and a closure such as (x: int) => x * 2 is a function written where it is used. See Function types and closures.

Tuples and multiple return values

A tuple groups a fixed number of values, each of its own type, into one value. Its main use is returning several values from a function:

# A function returns several values as a tuple.
divide(a: int, b: int): (int, int) {
    return (a / b, a % b);
}

# Elements can be of any type, and a tuple can hold another tuple.
describe(n: int): (String, (bool, int)) {
    return ("number", (n % 2 == 0, n * n));
}

main(): int {
    # Read elements by position: t.0, t.1, ...
    var t = divide(17, 5);
    assert(t.0 == 3, "the quotient");
    assert(t.1 == 2, "the remainder");

    # Nested tuples chain: d.1.0 is element 0 of element 1.
    var d = describe(4);
    assert(d.0 == "number", "the name");
    assert(d.1.0 && d.1.1 == 16, "even, and its square");

    # A tuple type can be written out; the literal converts to it.
    var wide: (long, double) = (1, 2);
    assert(wide.0 == 1 && wide.1 == 2.0, "converted elements");
    return 0;
}

Tuple types and values

  • A tuple type lists its element types in parentheses: (int, String), (bool, (int, int)). A tuple has at least two elements.
  • A tuple value lists its elements the same way: (a / b, a % b). The compiler infers its type from the elements, so (1, "x") is an (int, String).
  • Where a tuple type is expected (a declared variable, a parameter, a return value), each element converts to its element type, as it would on its own: var wide: (long, double) = (1, 2);.
  • t.0, t.1, … read an element by position, counting from 0. t.1.0 is element 0 of element 1. An element that does not exist is a compile error.
  • Elements are read-only. t.0 = 3 is a compile error; build a new tuple instead: t = (3, t.1);.
  • Tuple<A, B> is another spelling of (A, B), for places where a type in angle brackets reads better.
  • (int, String)? is an optional tuple, Optional<(int, String)> (see Optional values).

Labels

A tuple type can name its elements: (line: int, column: int), and so can a tuple value: (line: 3, column: 7). A label is another name for a position, so p.line and p.0 read the same element:

# Labels name a tuple's elements.
position(): (line: int, column: int) {
    return (line: 3, column: 7);
}

struct Token {
    var text: String = "";
    var at: (line: int, column: int);
}

main(): int {
    var p = position();
    assert(p.line == 3 && p.column == 7, "read by label");
    assert(p.0 == 3, "positions still work");

    # Labeled and unlabeled tuples convert to each other.
    var plain: (int, int) = p;
    assert(plain.1 == 7, "same elements");

    # A labeled value carries its labels into the variable's type.
    var end = (line: 4, column: 1);
    assert(end.line == p.line + 1, "labeled literal");

    var t = Token();
    t.at = (5, 6);
    assert(t.at.column == 6, "a labeled field");
    return 0;
}
  • Labels belong to the type you write, not to the value. A labeled tuple and an unlabeled one with the same element types are the same type, so each converts to the other freely.
  • Either every element has a label or none does, and labels in one tuple must differ.
  • Labels do not pass through a type argument: Optional<(line: int, column: int)> is the same type as Optional<(int, int)>, so its value() returns an unlabeled tuple. Declare a labeled variable to name the elements again.
  • var p = (line: 3, column: 7); gives p the labeled type (line: int, column: int). A labeled value takes at least two elements.
  • A labeled value initializing or returned as a labeled type must use its labels in order: (column: 7, line: 3) where a (line: int, column: int) is expected is an error, not a swap. An unlabeled value, or a labeled one going to an unlabeled type, converts by position.
  • A destructuring assignment lists places alone: (a, b) = (b, a);, never (x: a, y: b) = ....

Destructuring

var (a, b) = value; declares one variable for each element of a tuple:

struct Point {
    var x: int = 0;
    var y: int = 0;
}

min_max(values: Array<int>): (int, int) {
    var low = values[0];
    var high = values[0];
    for (var v in values) {
        if (v < low) {
            low = v;
        }
        if (v > high) {
            high = v;
        }
    }
    return (low, high);
}

main(): int {
    var values = Array<int>();
    values.append(4);
    values.append(-2);
    values.append(9);

    # Destructure the result into new variables.
    var (low, high) = min_max(values);
    assert(low == -2 && high == 9, "both ends");

    # `_` skips an element; a binding may state its type.
    var (_, top: long) = min_max(values);
    assert(top == 9, "only the maximum");

    # `const` makes every binding constant.
    const (first, last) = (values[0], values[2]);
    assert(first == 4 && last == 9, "constants");

    # Assign to existing places. The right side is evaluated first,
    # so this swaps.
    var a = 1;
    var b = 2;
    (a, b) = (b, a);
    assert(a == 2 && b == 1, "swapped");

    var p = Point();
    (p.x, p.y) = min_max(values);
    assert(p.x == -2 && p.y == 9, "fields are places too");
    return 0;
}
  • The number of names must match the number of elements.
  • _ skips an element.
  • A name may state its type, var (_, top: long) = …; the element converts to it. Without one, the variable takes the element’s type.
  • const (a, b) = … declares constants.
  • Patterns nest: var (id, (name, age)) = … takes an element that is itself a tuple apart.
  • A for loop destructures each element: for (var (name, count) in pairs).
  • (a, b) = value; assigns to places that already exist: variables, fields (p.x) and array elements. The whole right side is evaluated before any assignment, so (a, b) = (b, a) swaps.

Tuples and references

A tuple is a value, like a number. It is not an object on the heap, and copying it copies its elements. An element that is a reference is shared, not copied, exactly as if you had assigned it to a variable:

  • Building a tuple from a variable adds a reference to the object; building it from a function result takes over that result’s reference.
  • A tuple holds its references until it goes out of scope or is replaced, then releases them.
  • Destructuring moves each element into its variable. After var (a, b) = pair();, a holds the only reference to its object, just as it would after var a = make();.

A tuple is thread-safe when all its elements are, so a module-scope const origin: (int, long) = (0, 0); is allowed.

Collections, comparing and printing

A tuple works anywhere a type does, including as a type argument: Array<(int, String)>, HashMap<(int, int), String>. Iterating a HashMap yields (key, value) tuples:

main(): int {
    # A tuple can be a key: here, a grid position.
    var grid = HashMap<(int, int), String>();
    grid[(0, 0)] = "start";
    grid[(2, 3)] = "goal";
    assert(grid[(2, 3)] == "goal", "looked up by value");

    # Tuples compare element by element.
    assert((1, "a") == (1, "a"), "equal");
    assert((1, "a") != (1, "b"), "different");

    # A HashMap yields (key, value) tuples; destructure them in the loop.
    var ages = HashMap<String, int>();
    ages["ada"] = 36;
    ages["grace"] = 85;
    var total = 0;
    for (var (name, age) in ages) {
        total += age;
    }
    assert(total == 121, "every entry");

    # An array of tuples.
    var pairs = Array<(String, int)>();
    pairs.append(("x", 1));
    pairs.append(("y", 2));
    for (var (name, count) in pairs) {
        print($"{name}: {count}");
    }
    return 0;
}
  • == and != compare element by element. Each element compares the way it would on its own: a type with an == operator (such as String) uses it, and any other struct compares by identity (the same object).
  • A tuple can be a HashMap key or a HashSet member. Its hash combines the hashes of its elements, so equal tuples hash equally.
  • to_string((1, "two")) is (1, two), so a tuple of up to eight values that each have a to_string can be interpolated and printed: print($"{t}").
  • copy(t) (see Copying a value) copies each element: a struct element as copy would copy it on its own, anything thread-safe as is.

Matching tuples

A match over a tuple tests all of its elements at once. A tuple pattern lists a pattern for each element: (Rock, Scissors), (Number(v), 1), (4, column). Write the tuple in the match itself with double parentheses, match ((a, b)), or match a tuple value, match (at).

# `match` takes a tuple apart, testing every element at once.
enum Move {
    Rock,
    Paper,
    Scissors,
}

# Who wins: 1 for the first player, 2 for the second, 0 for a draw.
winner(first: Move, second: Move): int {
    match ((first, second)) {
        (Rock, Scissors), (Paper, Rock), (Scissors, Paper) => {
            return 1;
        }
        (Rock, Rock), (Paper, Paper), (Scissors, Scissors) => {
            return 0;
        }
        else => {
            return 2;
        }
    }
}

enum Token {
    Number(value: int),
    Word(text: String),
}

# Literals, cases with values, bound names and `_` mix in one pattern.
describe(token: Token, line: int): String {
    return match ((token, line)) {
        (Number(v), 1) => "a number on the first line",
        (Word(text), _) => text,
        (_, _) => "another number",
    };
}

main(): int {
    assert(winner(Move.Rock, Move.Scissors) == 1, "rock blunts scissors");
    assert(winner(Move.Paper, Move.Scissors) == 2, "scissors cut paper");
    assert(winner(Move.Paper, Move.Paper) == 0, "a draw");

    assert(describe(Token.Word("hi"), 3) == "hi", "a word binds its text");
    assert(describe(Token.Number(7), 1) == "a number on the first line", "a literal element");

    # A name binds its element; `is` tests one pattern.
    var at = (4, 2);
    if (at is (4, column)) {
        assert(column == 2, "bound by the pattern");
    }
    return 0;
}
  • Each element takes a case of its enum (Rock, Word(text)), a literal (1, "x", true), a range or a constant (0..9, LIMIT), another tuple pattern, _ for any value, or a name.
  • A name binds its element, unless it names a module-scope constant, which the element is compared with, or the element is an enum with a case of that name, which it then matches. So in (Circle, other) over two shapes, Circle is a case and other holds the second shape.
  • An arm’s patterns bind the same names, each at the same element: (x, 0), (0, x) is an error, since x would mean a different element depending on which pattern matched.
  • The arms must handle every value unless there is an else. A missing value is named with an example: match on '(Shape, int)' does not handle '(Line, _)'. Only enum cases and true/false can be listed in full; other literals and ranges need a pattern with _ or a name at that element.
  • A pattern that the arms before it already cover is an error, and an else after arms that cover everything is a warning.
  • value is (4, column) tests one tuple pattern, and binds its names as the whole condition of an if or while, as for enums.

Collection literals

A collection literal writes an array, a map or a set in one expression:

trait Shape {
    area(): int;
}

struct Square {
    int side;

    construct(side: int) {
        this.side = side;
    }
}

extend Square with Shape {
    area(): int {
        return side * side;
    }
}

total(values: long[]): long {
    long sum = 0;
    for (var value in values) {
        sum += value;
    }
    return sum;
}

main(): int {
    # An array literal is an Array of its elements' type.
    var primes = [2, 3, 5, 7];
    assert(primes.count == 4 && primes[3] == 7, "an Array<int>");

    # A trailing comma lets a long literal put one element per line.
    var operators = [
        "===",
        "!==",
        "=>",
    ];
    assert(operators[1] == "!==", "an Array<String>");

    # Where an Array type is expected, the elements convert to its element type.
    assert(total([1, 2, 3]) == 6, "an Array<long> argument");
    Shape[] shapes = [Square(2), Square(3)];
    assert(shapes[1].area() == 9, "elements convert to the trait type");

    # A map literal is a HashMap; a repeated key keeps its last value.
    var ages = ["Ada": 36, "Alan": 41];
    assert(ages["Alan"] == 41, "a HashMap<String, int>");

    # An array literal where a HashSet is expected makes a set.
    HashSet<String> keywords = ["if", "else", "if"];
    assert(keywords.count == 2, "duplicates are kept once");

    # [] and [:] are empty, where the type is declared.
    long[] none = [];
    HashMap<String, int> nobody = [:];
    assert(none.is_empty && nobody.count == 0, "empty collections");
    return 0;
}

Arrays

  • [a, b, c] is a new Array<T> holding the elements in order. A trailing comma is allowed: [a, b, c,].
  • With no other type to go by, T is the first element’s type, and every later element converts to it the way an argument converts to a parameter: [1, 2] is an Array<int>, and [1, "a"] is an error.
  • Where an array type is expected (a declared variable, a parameter, a return value, an assignment), T is that type’s element type instead, and every element converts to it: long[] a = [1, 2];, Shape[] s = [square, circle];.
  • Nested literals are typed the same way: long[][] grid = [[1], [2, 3]];.
  • [] is an empty array. It needs a declared type (long[] none = [];), since it has no element to take a type from:
main.pep:2:13: error: empty array literal has no element type; declare the variable's type or write 'Array<T>()'
    2 |     var a = [];
      |             ^~

Maps

  • [k1: v1, k2: v2] is a new HashMap<K, V> holding the entries. K and V come from the first entry, or from the HashMap<K, V> a declared variable, parameter or return type expects.
  • A key written twice keeps its last value, as two insert calls would.
  • [:] is an empty map, and needs a declared type: HashMap<String, int> none = [:];.

Sets

There is no separate set literal. An array literal where a HashSet<K> or a Set<K> is expected converts to one through the set’s implicit constructor, which keeps each element once: HashSet<String> keywords = ["if", "else"];.

Types of specializations

A literal never changes a collection’s type after it is built: Array<int> and Array<long> are different types, and neither converts to the other.

main.pep:2:21: error: cannot initialize variable 'a' of type 'Array<long>' with a value of type 'Array<int>'
    2 |     Array<long> a = Array<int>();
      |                     ^~~~~~~~~~

Arrays and slices

Array<T> is Pepper’s growable list. Beyond append, indexing and sort, it has the everyday algorithms, and slice gives a function “some of the elements” without copying them:

total(values: ArraySlice<int>): int {
    var sum = 0;
    for (var x in values) {
        sum = sum + x;
    }
    return sum;
}

main(): int {
    # Searching a sorted array.
    var sorted = [1, 3, 3, 5, 8];
    assert(sorted.is_sorted(), "sorted");
    assert(sorted.binary_search(5).value() == 3, "found at 3");
    assert(sorted.binary_search(4).is_empty(), "no 4");
    assert(sorted.upper_bound(3) - sorted.lower_bound(3) == 2, "two 3s");

    # Changing elements in place.
    var a = [4, 4, 1, 2, 2, 7];
    a.dedup();
    assert(a == [4, 1, 2, 7], "adjacent repeats removed");
    a.retain((x) => x % 2 == 0);
    assert(a == [4, 2], "odd elements removed");
    a.resize(4, 0);
    assert(a == [4, 2, 0, 0], "padded with 0");
    var tail = a.split_off(2);
    assert(a == [4, 2] && tail == [0, 0], "split in two");
    tail.fill(9);
    assert(tail == [9, 9], "filled");

    # A slice views part of an array without copying it.
    var numbers = [1, 2, 3, 4, 5];
    var middle = numbers.slice(1, 4);
    assert(middle.count == 3 && middle[0] == 2, "views [2, 3, 4]");
    assert(total(middle) == 9, "passed to a function");
    assert(middle.to_array() == [2, 3, 4], "copied out");
    return 0;
}

Searching a sorted array

  • binary_search(item) returns the index of the first element equal to item as a ulong?, in O(log count). The element type must be Comparable (every number, char and String is), and the array sorted in its compare order.
  • binary_search_by(order) searches with a function that says whether an element sorts before (negative), at (0) or after (positive) the target.
  • lower_bound(item) and upper_bound(item) are the first and last places item could be inserted to keep the array sorted; the elements between them equal item.
  • is_sorted() reports whether the array is in that order.

Changing elements in place

  • fill(value) sets every element to value.
  • retain(keep) removes each element keep returns false for, keeping the order of the rest. keep must not change the array; if it does, retain panics with Array modified during retain.
  • dedup() removes each element equal (==) to the one before it; on a sorted array that removes every duplicate.
  • resize(count, value) shortens the array, or appends copies of value.
  • split_off(index) moves the elements from index on into a new array.

Each counts as modifying the array, so a for loop over it panics on its next step.

Slices

a.slice(start, end) is an ArraySlice<T>: a read-only view of the elements from start up to but not including end. It has count, is_empty, indexing, for loops, slice, to_array, find, contains and the searches above.

A slice is safe by construction:

  • It holds a reference to its array, so the array lives as long as the slice does.
  • It is fail-fast, like an iterator. Once the array changes (any store, append, remove_at, sort, …), every later use of the slice panics with Array modified while a slice of it is in use, so it can never read an element that moved or a buffer that was freed. Make a new slice after changing the array.

Not yet

  • Slices are read-only; there is no slice that writes through to its array, and no a[1..4] syntax: write a.slice(1, 4).
  • sort takes a comparer (a.sort((x, y) => x.compare(y))); a sort() with none, sort_unstable, rotate, drain, chunks and windows are not there yet.
  • String has no slice type; substr copies.

Function types and closures

Functions in Pepper are values. You can store a function in a variable, a field or an array, pass it to another function, and return it. This lets you choose behavior at run time: a sort order, a callback, a step in a pipeline. A function value is a named function, a method, a constructor, or a closure: an anonymous function written where it is used.

Function types

A function type is written (parameter types) => return type:

TypeMeaning
(int) => inttakes an int, returns an int
(String, int) => booltakes a String and an int, returns a bool
() => voidtakes nothing, returns nothing
(int) => (int) => inttakes an int, returns an (int) => int

=> is right-associative, so the last row is a function that returns a function. A function type carries no parameter names, so a call through a function value passes its arguments by position: named arguments are an error there. A function type can be used anywhere a type can: for variables, parameters, return types, struct fields, generic arguments such as Array<(int) => int>, and type aliases.

Written with shared in front, as in shared () => void, a function type is thread-safe: its values can be passed to other threads. See Sharing function values between threads.

Referring to a named function

The name of a free function, written without a call, is a value of that function’s type. Calling a function value calls the function:

# Function types and references to named functions.
square(x: int): int { return x * x; }
negate(x: int): int { return -x; }

# A parameter can take a function: its type is '(parameter types) => return type'.
apply(f: (int) => int, x: int): int {
    return f(x);
}

twice(f: (int) => int, x: int): int {
    return f(f(x));
}

# Generic functions can take function values too.
apply_twice<T>(f: (T) => T, x: T): T {
    return f(f(x));
}

main(): int {
    # A function's name without a call is a value of its function type.
    var f = square;                 # f: (int) => int
    assert(f(3) == 9, "call through a local");

    var g: (int) => int = negate;   # the type can be written out
    assert(g(4) == -4, "explicit function type");

    # Function variables can be reassigned to another function of the same type.
    f = negate;
    assert(f(6) == -6, "reassigned");

    assert(apply(square, 5) == 25, "pass a function as an argument");
    assert(twice(square, 3) == 81, "(3 * 3) * (3 * 3)");
    assert(twice(negate, 7) == 7, "negating twice");
    assert(apply_twice<int>(square, 2) == 16, "generic");
    return 0;
}
  • var f = square; infers f’s type, (int) => int, from the function. An overloaded name, or an overloaded method (p.move), needs a declared type instead, which picks the member.
  • An extern function is a value too, of the type its C prototype gives: var f = abs; is an (int) => int. A variadic one, such as snprintf, is not.
  • A function-typed variable can be reassigned to any function of the same type.
  • A parameter of function type, such as f: (int) => int, lets the caller pass in the behavior. Generic functions can take function parameters too.

Methods, constructors and generic functions

Methods, constructors and specializations of generic functions are values too:

# Methods, constructors and generic functions used as values.
struct Point {
    int x;
    int y;
    construct(this.x = 0, this.y = 0) {}

    norm(): int {
        return x + y;
    }

    shifted(dx: int): Point {
        return Point(x + dx, y);
    }

    # Inside a method, a bare method name is `this.norm`.
    norm_of_this(): () => int {
        return norm;
    }
}

struct Box<T> {
    T value;
    construct(v: T) {
        value = v;
    }

    map<U>(f: (T) => U): Box<U> {
        return Box<U>(f(value));
    }
}

larger<T>(a: T, b: T): T {
    return a > b ? a : b;
}

combine(f: (int, int) => int, a: int, b: int): int {
    return f(a, b);
}

main(): int {
    var p = Point(3, 4);

    # A bound method: the method of one object, which it keeps alive.
    var norm = p.norm;
    assert(norm() == 7, "bound method");
    assert(p.norm_of_this()() == 7, "bare method name");

    # An unbound method takes the object as its first argument.
    var norm_of: (Point) => int = Point.norm;
    assert(norm_of(Point(1, 1)) == 2, "unbound method");

    # A struct's name is its constructor.
    var make: (int, int) => Point = Point;
    assert(make(5, 6).y == 6, "constructor");

    # A generic function with its type arguments names one specialization.
    assert(combine(larger<int>, 2, 9) == 9, "generic function");

    # Where a function type is expected, it picks the specialization.
    assert(combine(larger, 4, 1) == 4, "inferred specialization");

    # A generic method, with its type arguments written or picked by the
    # function type.
    var b = Box<int>(3);
    var to_long = b.map<long>;
    assert(to_long((x: int): long => x * 2).value == 6, "generic method");
    var to_text: ((int) => String) => Box<String> = b.map;
    assert(to_text((x: int) => "three").value == "three", "inferred generic method");

    var steps = Array<(int) => Point>();
    steps.append(p.shifted);
    steps.append(Point(10, 0).shifted);
    assert(steps[1](5).x == 15, "methods in an array");
    return 0;
}
WrittenTypeCalls
p.norm() => intp.norm(), on the object p referred to when the value was made
Point.norm(Point) => intnorm on the object passed as the first argument
Point.origin (a static method)() => PointPoint.origin()
Point(int, int) => Pointthe constructor
Box<int>(int) => Box<int>the constructor of Box<int>
larger<int>(int, int) => intthe specialization larger<int>
b.map<long> (a generic method)((int) => long) => Box<long>b.map<long>(...), on the object b referred to
  • Bound methods. p.norm keeps a reference to the object, like a closure () => p.norm() that captured p, so the object lives as long as the value does. The expression before the . is evaluated once, when the value is made. A bound method is a function value, not the value the method returns: passing p.name where a String is expected is an error. Call it, p.name(). The methods of an enum whose cases carry nothing are not values at all (see enums).

  • Inside a method. A bare method name is a value too, as a bare call is a call: norm means this.norm, and a static method’s bare name means Point.origin. This works in closures, extensions and trait default methods as well. A local or parameter of the same name hides the method. Storing norm (or this.norm) in a field of this would be a reference cycle, and is an error, as for closures.

  • Traits and extensions. A method declared in an extension works the same way, and so does a method of a trait-typed value (shape.area), which calls through the trait.

  • Generic functions. Where a function type is expected, the generic function’s name alone is enough: the expected type picks the specialization, as a call’s arguments do. var f: (int, int) => int = larger; and combine(larger, 2, 9) both mean larger<int>, and a type parameter that appears only in the return type is bound from the expected return type. With no function type to go by, as in var f = larger;, write the type arguments:

    main.pep:3:13: error: cannot infer the type arguments of 'larger' used as a value; write them (larger<T>) or give the value a function type
    
  • Generic methods. A method with type parameters of its own is a value once they are known: written (b.map<long>, Box<int>.map<long>, Box<int>.wrap<int>) or picked by the function type it flows into, as for generic functions: var m: ((int) => String) => Box<String> = b.map; is b.map<String>. Without either, var m = b.map; is an error that asks for them.

  • A type with a constructor that takes no arguments. Box<int> on its own names the constructor only when the constructor needs at least one argument; otherwise it is a call, Box<int>(). The same goes for a generic function whose parameters all have defaults.

Storing and returning functions

Function values go wherever other values go: struct fields, arrays, module-level variables and return values. A type alias gives a long function type a short name, and can have type parameters of its own.

# Function values in fields, arrays, globals, and return values.

import async;

square(x: int): int { return x * x; }
negate(x: int): int { return -x; }
increment(x: int): int { return x + 1; }

struct Transform {
    var op: (int) => int;
    construct(this.op = square) {}
    run(x: int): int { return op(x); }
}

# Choose a function at run time and return it.
pick(squared: bool): (int) => int {
    if squared {
        return square;
    }
    return negate;
}

# A module-level function variable. Every module-level variable is const
# and thread-safe: an Atomic lets it change, and a function type every thread
# can reach is declared shared.
const handler = async.Atomic<shared (int) => int>(increment);

# A type alias can name a function type, even a generic one.
type Combine<T> = (T, T) => T;
add(a: int, b: int): int { return a + b; }
fold(values: int[], combine: Combine<int>, start: int): int {
    var total = start;
    for (var v in values) {
        total = combine(total, v);
    }
    return total;
}

# A function that returns nothing has the return type void.
const events = async.Atomic<int>(0);
record(n: int) { events.fetch_add(n); }

main(): int {
    var t = Transform();
    assert(t.run(4) == 16, "the default op squares");
    t.op = negate;
    assert(t.op(4) == -4, "call through a field");

    var pipeline = Array<(int) => int>();
    pipeline.append(increment);
    pipeline.append(square);
    pipeline.append(negate);
    var value = 2;
    for (var step in pipeline) {
        value = step(value);
    }
    assert(value == -9, "-((2 + 1) * (2 + 1))");
    assert(pipeline[1](5) == 25, "call an array element");

    assert(pick(true)(5) == 25, "call a returned function directly");
    var chosen = pick(false);
    assert(chosen(5) == -5, "call a stored returned function");

    assert(handler.load()(1) == 2, "global function value");
    handler.store(square);
    assert(handler.load()(3) == 9, "replaced global");

    var numbers = Array<int>();
    numbers.append(1);
    numbers.append(2);
    numbers.append(3);
    assert(fold(numbers, add, 10) == 16, "10 + 1 + 2 + 3");

    var callback: (int) => void = record;
    callback(2);
    callback(5);
    assert(events.load() == 7, "void function values");
    return 0;
}
  • Fields. A field of function type is called like a method: t.op(4). Inside the struct, op(x) and this.op(x) both call it.
  • Arrays. pipeline[1](5) indexes the array and calls the element.
  • Module-level variables. Every module-level variable is const and thread-safe (see Module-scope variables), so one whose function changes is an Atomic of a shared function type: handler.load()(1) calls the current function and handler.store(square) replaces it.
  • Return values. pick(true)(5) calls the function that pick returns.
  • void results. A function without a return value has a function type ending in => void, such as (int) => void.

Closures

A closure literal is a parameter list, =>, and a body. The body is an expression or a block:

# Closure literals: anonymous functions that capture values.
struct Counter {
    int count;
    construct(this.count = 0) {}
    add(n: int) { count += n; }
}

apply(f: (int) => int, x: int): int {
    return f(x);
}

# A closure can outlive the function that made it: it keeps its own copy
# of 'n'.
make_adder(n: int): (int) => int {
    return (x) => x + n;
}

struct Scaler {
    int factor;
    construct(this.factor = 1) {}

    # Inside a method, a closure can use 'this' and the struct's members.
    scaler(): (int) => int {
        return (x) => x * factor;
    }
}

main(): int {
    # Parameter types written out: the closure's type is (int) => int.
    var double = (x: int) => x * 2;
    assert(double(21) == 42, "expression body");

    # With a function type to fill, the parameter types can be left out.
    var add: (int, int) => int = (a, b) => a + b;
    assert(add(40, 2) == 42, "types from the variable");
    assert(apply((x) => x * x, 5) == 25, "types from the parameter");

    # A block body, with the return type written after the parameters.
    var clamp = (x: int, low: int, high: int): int => {
        if x < low { return low; }
        if x > high { return high; }
        return x;
    };
    assert(clamp(7, 0, 5) == 5, "block body");

    # Captured values.
    var add_five = make_adder(5);
    assert(add_five(10) == 15, "captured parameter");
    var offset = 100;
    var shift = (x: int) => x + offset;
    assert(shift(1) == 101, "captured local");

    # A captured struct is shared, so the closure can change the object.
    var counter = Counter();
    var bump = () => counter.add(1);
    bump();
    bump();
    assert(counter.count == 2, "shared struct");

    var triple = Scaler(3).scaler();
    assert(triple(4) == 12, "captured this");

    # A closure can return a closure.
    var adder: (int) => (int) => int = (a) => (b) => a + b;
    assert(adder(2)(3) == 5, "curried");
    return 0;
}
  • Parameter types. (x: int) => x * 2 writes the parameter type. When the closure goes straight into a slot of function type (a typed variable, a parameter, an assignment, a return, a field), the types come from that slot, so (a, b) => a + b is enough. A parameter of a generic function counts too: passing (x) => x + 1 to apply<T>(f: (T) => T, x: T) with x an int types the closure’s x as int (see Generics). With no slot to take them from, a parameter without a type is an error:

    main.pep:2:14: error: cannot infer the type of parameter 'x'; write it: (x: Type) => ...
    
  • Return type. It is inferred from the body, or from the slot. To write it, put it after the parameters: (x: int): long => x.

  • Body. An expression body is the value the closure returns. A block body uses return like a function. A closure whose type returns void can have an expression body such as () => counter.add(1); the value is discarded.

  • Calling. A closure is called like any function value, and can be called where it is written: ((x: int) => x + 1)(2).

Captured values

A closure can use the local variables and parameters around it. It keeps a copy of each one it uses, made when the closure is created, so it can outlive the function that created it (make_adder above).

A captured variable must never be assigned, before or after the closure is created, inside the closure or outside it. Then the copy and the variable can never differ:

var count = 0;
var f = () => count;
count = 5;   # error
main.pep:4:5: error: cannot assign to 'count' because a closure captures it
main.pep:3:19: note: captured here; a closure keeps a copy, so the variable must not change

The rule applies to ++, -- and compound assignments too. Only the variable is fixed: a captured struct is a reference to the same object, so the closure can call its methods and change its fields (counter above). To share a changing number, capture a struct that holds it, such as a Counter or an Atomic<int>.

this in methods

A closure written in a method can use this, the struct’s fields and its methods. It captures this (one reference to the object), never a copy of the fields, so it sees the fields’ current values. In a trait’s default method, a closure can use this and the trait’s methods the same way.

Storing such a closure in a field of the same object would make the object and the closure keep each other alive forever (a reference cycle), so that direct case is an error:

main.pep:4:15: error: storing a closure that captures 'this' in field 'measure' of 'this' creates a reference cycle that is never freed

Capture a weak reference to the object instead.

Type checking

A function value must match the type it flows into exactly: the same parameter types in the same order and the same return type. The aliases of a primitive type count as the same type, so (i32) => i32 and (int) => int match. A mismatch in an initializer, an assignment, an argument or a return is a compile error, as is calling a function value with the wrong number of arguments:

square(x: int): int { return x * x; }

main(): int {
    var f: (String) => int = square;   # error
    return f(1, 2);                    # error
}
main.pep:4:30: error: cannot initialize variable 'f' of type '(String) => int' with a value of type '(int) => int'
    4 |     var f: (String) => int = square;   # error
      |                              ^~~~~~
main.pep:5:12: error: too many arguments to function of type '(String) => int': expected 1, have 2
    5 |     return f(1, 2);                    # error
      |            ^

The compiler reports one error at a time, so the second appears once the first is fixed.

Sharing function values between threads

A plain function type such as () => void can hold any function value, including a closure that captures a String or an Array. Such values stay on the thread that made them: their reference counts are plain increments, like those of a struct that is not [Shared] (see What threads can share).

A function type written with shared in front, such as shared () => void or shared (long) => void, is thread-safe. Its reference counts are atomic, and its values hold only thread-safe state, so any thread may copy, call and drop them. It goes wherever a thread-safe type is required: the body of a thread (ThreadBody is shared () => void), a field of a [Shared] struct, a module-scope variable, or async.Atomic<shared (int) => int>.

# shared function types: function values that threads can share.
import async;

const total = async.Atomic<long>(0);

# A named function holds no state, so it is always thread-safe.
add_one() {
    total.fetch_add(1);
}

# A [Shared] struct's fields must be thread-safe: a function field is shared.
[Shared]
struct Task {
    const weight: long;
    const run: shared (long) => void;
    construct(this.weight = 1, this.run = (n: long) => {}) {}
}

# A plain function type takes any function of the same signature.
apply(f: (long) => long, x: long): long {
    return f(x);
}

main(): int {
    # A closure converts to a shared type when everything it captures is
    # thread-safe: here nothing but the module-scope Atomic.
    var add: shared (long) => void = (n: long) => { total.fetch_add(n); };
    var task = Task(10, add);

    # A thread body is a shared () => void. This closure captures a
    # [Shared] struct.
    var worker = async.Thread.spawn(() => { task.run(task.weight); });
    var other = async.Thread.spawn(add_one);
    worker.join();
    other.join();
    assert(total.load() == 11, "both threads ran");

    # A shared value can be used where a plain one is expected.
    var step: long = 5;
    var add_step: shared (long) => long = (x: long) => x + step;
    assert(apply(add_step, 1) == 6, "shared value passed as plain");

    # A plain closure may capture anything, but stays on this thread.
    var name = "worker";
    var greet = () => name + " done";
    assert(greet() == "worker done", "plain closure");
    return 0;
}
  • What converts to shared. A named function, which holds no state; a method, constructor or generic function value with nothing captured, such as Point.norm or Box<int>; and a closure literal (or a bound method such as p.norm) whose captured values are all thread-safe: numbers, Atomic<T> and other [Shared] structs, shared function values, and the rest of that list. The value must be written where the shared type is expected, so that it is made thread-safe from the start.

  • What does not. A closure that captures a value that is not thread-safe is reported at the capture, with a note at the captured variable:

    main.pep:5:46: error: closure passed as 'shared () => void' captures 'name' of type 'String', which is not thread-safe
        5 |     var t = async.Thread.spawn(() => { print(name); });
          |                                              ^~~~
    main.pep:4:9: note: capture a thread-safe value instead, such as an 'async.Atomic<T>' or a '[Shared]' struct
        4 |     var name = "worker";
          |         ^~~~
    

    A closure in a method that uses this or a member captures this, so it is thread-safe only in a [Shared] struct. A value that already has a plain type (a variable, a parameter, a call result) never becomes shared, even when it holds nothing:

    main.pep:3:32: error: cannot initialize variable 'f' of type 'shared () => void' with a value of type '() => void'
        3 |     var f: shared () => void = g;
          |                                ^
    
  • From shared to plain. A shared value converts to the plain type of the same signature anywhere: shared (long) => long can be passed to a parameter of type (long) => long. The conversion allocates a small plain function value that holds the shared one and calls it, so the plain copy counts with plain increments while the shared value keeps its atomic ones.

  • Inference. A variable initialized with a closure, such as var f = () => 1;, has a plain type. Write the type to make it shared: var f: shared () => int = () => 1;.

  • shared is special only directly before a function type; elsewhere it is an ordinary name.

Null function values

A function-typed variable or field that has never been assigned is null. Calling it stops the program with the message called a null function value:

var handler: (int) => int;
handler(1);   # panics: called a null function value

Names and shadowing

A local variable, parameter or field with the same name as a free function hides the function inside its scope. Inside a function with a parameter named square, square means the parameter, not the function.

Memory

A function value is a reference, like a struct value, and follows the same reference-counting rules (see Memory and reference counting). Referring to a named function allocates nothing: every reference to square shares one static function object, and so does a generic function named without its type arguments (larger as an (int, int) => int). A method, constructor or generic function value allocates one small object, like a closure; a bound method’s object holds a reference to its receiver.

Creating a closure allocates one object holding the captured values, like constructing a struct. Copying the closure adds a reference to that object, and when the last reference goes, the object releases what it captured.

A plain function value’s counts are plain increments; a shared one’s are atomic, like a [Shared] struct’s (see Sharing function values between threads).

C function pointers

A C function pointer type, extern (P...) => R, holds a bare address that C can call, not a function value: it takes a named function or a closure that captures nothing, and never a closure that captures values. That is by design; see Passing a Pepper function to C for how a callback gets the state it needs.

Overloading

Several free functions in one file can share a name when their parameter lists differ. Each call picks the one whose parameters fit its arguments best:

# Overloaded free functions: one name, several parameter lists.

import io;

describe(n: long): String { return "a number"; }
describe(x: double): String { return "a fraction"; }
describe(s: String): String { return "some text"; }
describe(s: String, times: int): String { return "text, repeated"; }

# An exact match beats a widening, which beats a conversion; an
# `[Implicit]` constructor comes last.
size(p: io.Path): int { return 1; }
size(n: long): int { return 2; }

main(): int {
    var small: int = 7;
    assert(describe(small) == "a number", "int widens to long");
    assert(describe(2.5) == "a fraction", "exact match");
    assert(describe("hi") == "some text", "a literal promotes to String");
    assert(describe("hi", 3) == "text, repeated", "picked by argument count");

    assert(size(4) == 2, "an integer literal fits a long");
    assert(size("notes.txt") == 1, "only io.Path takes a String, by its [Implicit] constructor");
    return 0;
}

The functions of one name in one file are an overload set, and each is a member of it; the files of one module may share a set, and so do the sets a file imports, the runtime’s, and its own functions of that name. Methods of one name in a struct are a set too, and so are its static methods, its operators, its constructors, and the methods its extensions add.

Declaring overloads

  • The parameter lists must differ in the number or the types of the parameters. Parameter names and default values do not count (though named arguments can pick between members that differ in types), and neither does the return type:

    main.pep:2:1: error: functions that differ only in their return type cannot be overloaded
        2 | f(x: int): long { return 2; }
          | ^
    main.pep:1:1: note: previous declaration is here
        1 | f(x: int): int { return 1; }
          | ^
    

    Two functions with the same parameter types and the same return type are a redefinition of 'f'.

  • A file’s own functions join imported ones. A file’s functions of a name and the functions its imports bind to that name are one set (see Imported sets merge).

Files of one module share a set

When an import or a re-export takes one name from two files of the same module, and both are functions declared under that name, it takes their union: one set of every member. Here shapes/module.pep re-exports area from two files:

# Each file adds members to `area`; the module exports one set.
export * from "square";
export * from "rect";
export area(side: int): int {
    return side * side;
}
export area(width: int, height: int): int {
    return width * height;
}
import "shapes";

main(): int {
    assert(area(3) == 9, "square.pep's member");
    assert(area(2, 5) == 10, "rect.pep's member");
    return 0;
}

A file of the module sees the members its own file and the module’s files it imports declare (see Visibility). That holds for unexported functions too: functions of one name that files of a module declare, exported or not, are one set wherever those files are imported. Members from different files must still differ in their parameters (redefinition of 'area'). Names that are not all functions, such as two structs Point, and a renaming re-export (export size as area beside an area) are still exported twice.

Imported sets merge

Sets of one name from different modules merge too, as they do in Swift: where two imports bind describe, and where the file declares describe itself, an unqualified describe(…) picks among every member. Here two modules each export one member and the file adds a third:

export describe(n: int): String {
    return "a number";
}
export describe(flag: bool): String {
    return "a flag";
}
import "numbers";
import "flags";

# The file's own `describe` joins the two modules' sets.
describe(text: String): String {
    return "some text";
}

main(): int {
    assert(describe(3) == "a number", "numbers' member");
    assert(describe(true) == "a flag", "flags' member");
    assert(describe("hi") == "some text", "this file's member");
    return 0;
}

Unlike files of one module, two modules may each export a member with the same parameters. That is no error until a call cannot tell them apart:

main.pep:3:22: error: call to 'parse' is ambiguous
    3 | main(): int { return parse(1); }
      |                      ^~~~~
a.pep:1:8: note: candidate function
    1 | export parse(x: int): int { return 1; }
      |        ^~~~~
b.pep:1:8: note: candidate function
    1 | export parse(x: int): int { return 2; }
      |        ^~~~~

A qualifier (a.parse(1)) picks one module’s set. A file’s own member hides an imported member with the same parameters, so a file can replace one member of an imported set. Only functions merge: a struct or variable of the name hides imported functions of it (or, named in an import list, is 'parse' is imported but also declared in this file).

The runtime’s sets

The prelude’s functions, such as the runtime’s print and to_string, are a set too, which a file’s own and imported functions of the name join. Here point.pep adds a print member and the file adds a to_string member, and the runtime’s members still take every other call:

export struct Point {
    var x: int;
    var y: int;

    construct(x: int, y: int) {
        this.x = x;
        this.y = y;
    }
}

# Joins the runtime's `print(String)` wherever it is imported.
export print(p: Point) {
    print($"({p.x}, {p.y})");
}
import "point";

# The file's own `to_string` joins the runtime's.
to_string(p: Point): String {
    return $"Point({p.x}, {p.y})";
}

main(): int {
    var p = Point(1, 2);
    print(p);       # point's member
    print("text");  # the runtime's member
    assert(to_string(p) == "Point(1, 2)", "this file's member");
    assert(to_string(42) == "42", "the runtime's member");
    return 0;
}

A member may not take a runtime member’s parameters, since a call could not tell the two apart. With the same return type (or one left to inference) it is a redefinition:

main.pep:1:1: error: redefinition of 'print'
    1 | print(text: String) {}
      | ^~~~~
runtime/string.pep:1850:8: note: previous definition is here
 1850 | export print(text: String) {
      |        ^~~~~

and with another return type it is the same error as for two members of one file:

main.pep:1:1: error: functions that differ only in their return type cannot be overloaded
    1 | to_string(value: int): int { return value; }
      | ^~~~~~~~~
runtime/string.pep:1621:8: note: previous declaration is here
 1621 | export to_string(value: int): String {
      |        ^~~~~~~~~

Give the function another name or other parameters instead. A struct or variable named like a runtime function hides all of its members.

Members may differ in visibility

Exported members are what other files see; a private member is a candidate only for calls in its own file, or in its module’s files. Here shapes.pep exports area(int) and keeps the two-argument form as a helper:

# One set: `area(int)` is exported; `area(int, int)` is private to this file.
export area(side: int): int {
    return area(side, side);
}

area(width: int, height: int): int {
    return width * height;
}
import "shapes";

main(): int {
    # Only the exported member is a candidate here: `area(2, 3)` would be
    # an error, since `area(int, int)` is private to shapes.pep.
    assert(area(3) == 9, "the exported member");
    return 0;
}

A call that only the private member fits is an error in another file, and its notes list only the exported members:

main.pep:2:22: error: no matching function for call to 'area'
    2 | main(): int { return area(2, 3); }
      |                      ^~~~
shapes.pep:2:8: note: candidate function not viable: requires 1 argument, but 2 were provided
    2 | export area(side: int): int {
      |        ^~~~

Importing the name (import [area] from "shapes", or the whole file) brings every exported member, and import [area as size] renames the whole set. A re-export (export area;, export * from "shapes") passes on the exported members too; a set whose members are all private is re-exported whole.

extern functions do not overload

An extern function does not overload, by design: an extern is a C function, which has one symbol, and keeping one extern per name keeps the binding to C one-to-one. To put a family of C functions such as abs and llabs behind one name, give each extern a name of its own ([Link(name = ...)] binds it to the C symbol) and overload Pepper functions that call them. With optimization on (-O2), LLVM inlines the small wrappers:

# C's `abs` and `llabs` behind one Pepper name. An extern binds one C
# symbol, so each gets its own name, and Pepper functions overload them.
[Link(name = "abs")]
extern int c_abs(int x);
[Link(name = "llabs")]
extern i64 c_llabs(i64 x);

magnitude(x: int): int { return c_abs(x); }
magnitude(x: long): long { return c_llabs(x); }

main(): int {
    assert(magnitude(-3) == 3, "int");
    var big: long = -5000000000;
    assert(magnitude(big) == 5000000000, "long");
    return 0;
}

Overloading the extern itself is an error that says so (extern 'abs' cannot be overloaded; rename it and overload Pepper functions named 'abs' that call it).

How a call picks a member

A member is viable when the call has between its required and total number of parameters and each argument converts to its parameter. For each argument, the conversion has a rank, best first:

RankWhat converts
exactthe same type; a string literal to __ptr<char> or StringConstant
promotionan integer widening that loses nothing (int to long, u8 to int; the narrower target ranks better, so a short picks int over long), float to double, an integer literal to any integer type it fits, a struct to a trait it declares, __null to a reference, a string literal to String
conversionany other numeric conversion (bool included), and pointer conversions
implicit constructora value to a struct whose [Implicit] constructor takes it

One viable member is better than another when none of its arguments ranks worse and at least one ranks better, as in C++. Between two members whose arguments rank the same, a non-generic one is better than a generic one, and of two generic ones the more specialized is better: the one whose parameter types the other’s could take but not the reverse. So describe<A, B>(pair: (A, B)) takes a tuple argument over describe<T>(value: T), which takes anything else. The call picks the member that is better than every other viable one. When there is none, the call is an error that lists the candidates:

main.pep:3:22: error: call to 'f' is ambiguous
    3 | main(): int { return f(1); }
      |                      ^
main.pep:1:1: note: candidate function
    1 | f(x: long): int { return 1; }
      | ^
main.pep:2:1: note: candidate function
    2 | f(x: short): int { return 2; }
      | ^

An integer literal fits every integer type that holds its value equally well, so f(1) above is ambiguous; an int member would take it exactly. Two widenings to types of the same width (a ushort to long and ulong) are ambiguous too; convert the argument yourself (f(n as long)). When no member is viable, each note says why:

main.pep:5:12: error: no matching function for call to 'f'
    5 |     return f(s);
      |            ^
main.pep:1:1: note: candidate function not viable: no known conversion from 'String' to 'int' for 1st argument
    1 | f(x: int): int { return 1; }
      | ^
main.pep:2:1: note: candidate function not viable: requires 2 arguments, but 1 was provided
    2 | f(x: int, y: int): int { return 2; }
      | ^

A call with named arguments considers only the members that have a parameter of each name it uses and get every required parameter, and binds the names before ranking the arguments as above. So two overloads whose later parameters have different names can be told apart by naming one (pick(1, b = 2) against pick(a: int, b: int = 0) and pick(a: String, c: int = 0)). The members it considers must put each named argument at the same position; when they do not, the call is an error: named arguments bind differently in the overloads of 'f'.

The member picked decides the call’s type, so var x = half(9.0); is a double when half(x: double): double is picked. In the editor, hovering the call shows the member it picked and how many others the set has, and go-to-definition goes to that member.

Generic members

A set can mix generic and non-generic members. A generic member is viable when its type arguments can be inferred from the call, and ranks as an exact match for the arguments that inferred them. When a generic and a non-generic member tie, the non-generic one wins. Writing type arguments (describe<int>(7)) considers only the generic members:

# Generic and non-generic overloads, and overloads called from generic code.

# A non-generic overload wins a tie with a generic one.
describe<T>(value: T): String { return "something"; }
describe(value: int): String { return "an int"; }

# Generic overloads can differ in their number of parameters.
last<T>(a: T): T { return a; }
last<T>(a: T, b: T): T { return b; }

kind(n: int): String { return "number"; }
kind(s: String): String { return "text"; }

# Each specialization resolves `kind` for its own `T`.
label<T>(value: T): String { return kind(value); }

main(): int {
    assert(describe(7) == "an int", "non-generic wins the tie");
    assert(describe(1.5) == "something", "only the generic fits");
    assert(describe<int>(7) == "something", "type arguments pick generic overloads");

    assert(last(1) == 1, "one argument");
    assert(last(1, 2) == 2, "two arguments");

    assert(label(3) == "number", "label<int> calls kind(int)");
    assert(label("x") == "text", "label<String> calls kind(String)");
    return 0;
}

A call inside a generic function or a generic struct’s method whose argument types depend on a type parameter is resolved again for each specialization, so label<String> above calls kind(s: String).

An override such as describe<Point>(p: Point) belongs to the generic member with the same number of type parameters and parameters; it is not a member itself.

Methods and operators

A struct’s methods of one name are an overload set, and a call picks among them the same way, whether it is written m.add(x), this.add(x), or add(x) inside a method. Static methods overload among themselves (Money.of(250)), and operators are methods, so +(Money) and +(int) can coexist:

# Overloaded methods, static methods and operators.

struct Money {
    var cents: long;

    construct(long cents = 0) {
        this.cents = cents;
    }

    # Methods of one name in a struct overload like free functions.
    add(other: Money): Money { return Money(cents + other.cents); }
    add(dollars: int, cents: int): Money { return Money(this.cents + dollars * 100 + cents); }

    # Static methods overload among themselves.
    static of(cents: long): Money { return Money(cents); }
    static of(text: String): Money { return Money(text.size as long); }

    # Operators are methods, so they overload by operand type too.
    +(other: Money): Money { return add(other); }
    +(more: int): Money { return Money(cents + more); }
}

main(): int {
    var a = Money.of(250);
    var b = a.add(1, 5);                  # add(int, int)
    assert(b.cents == 355, "two ints");
    assert(a.add(b).cents == 605, "a Money");

    var c = a + b + 10;                   # +(Money), then +(int)
    assert(c.cents == 615, "operators");
    assert(Money.of("four").cents == 4, "static of(String)");

    # A method used as a value takes the member its type names.
    var plus: (Money) => Money = a.add;
    assert(plus(a).cents == 500, "method value");
    return 0;
}
  • Unary and binary operators of one symbol are one set: a unary -() and a binary -(other: Money) are told apart by their number of parameters (see Unary operators).

  • A static method and an instance method may not share a name, since a bare of(x) inside a method could mean either:

    main.pep:3:12: error: static method 'get' cannot share its name with an instance method of 'S'
        3 |     static get(x: String): int { return 2; }
          |            ^~~
    main.pep:2:5: note: previous declaration is here
        2 |     get(x: int): int { return 1; }
          |     ^~~
    
  • In a generic struct, each specialization resolves its own calls: in Holder<int>, has(x: T) takes an int. Two members that become the same after substitution (has(x: T) and has(x: String) in Holder<String>) are only an error when a call cannot tell them apart.

  • A method as a value (m.add, Money.of) picks its member from the expected function type, like a free function below; without one the error is cannot infer the type of 'f' from overloaded method 'Money.add'.

Extensions and traits

An extension’s methods join the struct’s overload set of their name, and a trait may declare several methods of one name. Each of a trait’s overloads is implemented by the conforming type’s method with the same parameter types, and a call through a value of the trait type picks among the trait’s members the same way:

struct Square {
    var side: int = 0;

    construct(this.side = 0) {}

    area(): int {
        return side * side;
    }
}

# An extension's methods join the struct's overload set of their name.
extend Square {
    area(scale: int): int {
        return area() * scale;
    }
}

# A trait may declare overloads too; each is its own member.
trait Shape {
    area(): int;
    area(scale: int): int;

    # A default joins the conforming type's set of its name.
    describe(): int {
        return area() * 100 + area(2);
    }
}

# Each trait member is implemented by the method with its parameter types.
extend Square with Shape;

total(shape: Shape): int {
    return shape.area() + shape.area(10);
}

main(): int {
    var square = Square(3);
    assert(square.area(2) == 18, "the extension's overload");
    assert(total(square) == 99, "each call through the trait takes its own member");
    assert(square.describe() == 918, "the default body calls both");
    return 0;
}
  • An extension member with a body method’s parameters is redefinition of 'area', with a note at the method it repeats.
  • A call sees only the extensions visible in its file (see Visibility): a private extension’s overload is never a candidate in another file, so importing a file can add candidates to a call. If an added candidate makes a call ambiguous, the error’s notes point at each candidate, the extension’s included.
  • A trait’s default method is copied into a conforming type that lacks a method with its parameter types, even when the type has other methods of that name; the copy joins their set.
  • A missing overload is named with its parameter types: 'Square' does not implement 'area(int)', required by trait 'Shape'.

Constructors

A struct may declare several constructors. A call Span(...) picks among them exactly as a call picks among functions:

struct Span {
    var start: int = 0;
    var length: int = 0;

    # Constructors overload like functions: each takes its own parameters.
    construct(this.length = 0) {}

    construct(start: int, end: int) {
        this.start = start;
        length = end - start;
    }

    construct(text: String) {
        length = text.length as int;
    }
}

main(): int {
    assert(Span().length == 0, "no arguments");
    assert(Span(5).length == 5, "one int");

    var middle = Span(2, 7);
    assert(middle.start == 2 && middle.length == 5, "two ints");

    assert(Span("pepper").length == 6, "a String");

    # As a value, the expected function type picks the constructor.
    var make: (int, int) => Span = Span;
    assert(make(1, 4).length == 3, "the (int, int) constructor");
    return 0;
}
  • Two constructors with the same parameter types are constructor of 'Span' cannot be redeclared, with a note at the first.
  • No constructor fits is no matching constructor for initialization of 'Span', and a tie is call to constructor of 'Span' is ambiguous; the notes point at each candidate constructor.
  • A generic struct’s constructors resolve in each specialization: Pair<int>(1, 2) picks among Pair<int>’s constructors.
  • At most one constructor may be [Implicit]: an implicit conversion needs one constructor to call.
  • [Deprecated] on one constructor warns only at the calls that resolve to it.
  • A constructor as a value (var make: (int, int) => Span = Span;) picks its member from the expected function type, like a function below.

Overloaded functions as values

The name of an overload set, written without a call, is a value only where the expected function type picks one member exactly: a typed variable, an argument, a return from a function with a declared return type, or an assignment. A closure whose parameters have no types, passed to an overloaded function, picks the member whose function parameter has its number of parameters:

# An overloaded function used as a value, and closures passed to overloads.

twice(n: int): int { return n * 2; }
twice(s: String): String { return s + s; }

apply(f: (int) => int, n: int): int { return f(n); }

# Overloads that differ in the type of a function parameter.
run(f: (int) => int): int { return f(10); }
run(f: (int, int) => int): int { return f(10, 20); }

main(): int {
    # The declared type picks the overload.
    var doubler: (int) => int = twice;
    assert(doubler(4) == 8, "twice(int)");

    # So does the parameter it is passed to.
    assert(apply(twice, 5) == 10, "twice(int) again");

    # A closure without parameter types picks the overload of its arity.
    assert(run((a) => a + 1) == 11, "one parameter");
    assert(run((a, b) => a + b) == 30, "two parameters");
    return 0;
}

Without an expected type, the compiler asks for one:

main.pep:4:13: error: cannot infer the type of 'g' from overloaded function 'f'
    4 |     var g = f;
      |             ^
main.pep:1:1: note: candidate function
    1 | f(x: int): int { return 1; }
      | ^
main.pep:2:1: note: candidate function
    2 | f(x: long): int { return 2; }
      | ^
main.pep:4:13: note: declare the type, for example 'var g: (int) => int = f;'
    4 |     var g = f;
      |             ^

An expected type that matches no member is address of overloaded function 'f' does not match required type '(bool) => int'.

Structs

A struct groups related values into one named type, and gives that type behavior through methods. Pepper has no classes and no inheritance: a struct holds data, methods and extensions add behavior, and traits describe what a type can do.

Every struct value is a reference to an object, so assigning a struct or passing it to a function shares the object rather than copying it. The memory chapter explains exactly when objects are created and destroyed.

Declaring a struct

Declare a struct with struct, a name, and its fields in braces. Struct names are PascalCase by convention.

# A struct groups related values into one type.
struct Point {
    var x: int = 0;
    var y: int = 0;
}

# Fields can also be declared C-style, with the type first.
struct Counter {
    int count;          # no initializer: starts at zero
    String label = "clicks";
}

main(): int {
    # Without a constructor, `Point()` makes a Point with every field
    # at its initial value.
    var p = Point();
    assert(p.x == 0 && p.y == 0, "fields start at their initializers");

    p.x = 3;
    p.y = 4;
    assert(p.x * p.x + p.y * p.y == 25, "fields can be read and written");

    var c = Counter();
    assert(c.count == 0, "a numeric field without an initializer is zero");
    assert(c.label == "clicks", "a String field can have an initializer");

    # A struct value is a reference: `q` names the same Point as `p`.
    var q = p;
    q.x = 10;
    assert(p.x == 10, "p and q are the same object");
    assert(p === q, "=== tests whether two references name the same object");
    assert(!(p === Point()), "a new Point is a different object");
    return 0;
}

Things to notice:

  • A field is declared like a variable: var x: int = 0; or, C-style, int x = 0;. The initializer is optional.
  • A field without an initializer starts at its type’s default: zero for numbers, false for bool, and null for a struct type such as String or another struct. Reading a field through a null reference crashes the program, so give struct-typed fields a value before you use them.
  • A struct with no constructor can be created with its name and empty parentheses, Point(). There is no new keyword at the call site.
  • p.x reads a field and p.x = 3 writes it.
  • a === b is true when a and b refer to the same object, and a !== b when they do not. See Comparing structs for ==.

A struct can have a field whose type is a struct declared later in the file, a field of its own type, or fields that lead back to it through other structs (a Team holding a Player that holds the Team). A struct-typed field holds a reference, so a linked list or a tree is just a struct that names itself:

# A field can hold the struct's own type: each Link points at the next one.
struct Link {
    var value: int;
    var next: Link;

    construct(this.value = 0) {}
}

# Two structs can refer to each other, in either declaration order.
struct Team {
    var name: String;
    var lead: Player;

    construct(this.name = "") {}
}

struct Player {
    var name: String;
    # A weak back pointer: the team owns its lead, so the player's link
    # back to the team must not keep the team alive (see the memory chapter).
    var team: Weak<Team>;

    construct(this.name = "") {}
}

sum(first: Link): int {
    var total = 0;
    var link: Link = first;
    while (link !== __null) {
        total = total + link.value;
        link = link.next;
    }
    return total;
}

main(): int {
    var list = Link(1);
    list.next = Link(2);
    list.next.next = Link(3);
    assert(sum(list) == 6, "the list holds 1, 2 and 3");
    assert(list.next.next.next === __null, "the last link has no next");

    var team = Team("Owls");
    team.lead = Player("Ada");
    team.lead.team = Weak<Team>(team);
    with (var t = team.lead.team) {
        assert(t.name == "Owls", "the player reaches its team");
    }
    return 0;
}

Objects that refer to each other in a loop keep each other alive, so make back pointers such as a child’s parent a Weak<T> (see Cycles leak).

Constructors

A constructor sets up a new object. Declare it with construct, followed by its parameters and a body. Callers create the object by calling the struct’s name like a function.

struct Rect {
    var width: int;
    var height: int;
    var area: int;

    # `this.width = 1` declares a parameter that is stored straight into the
    # field `width`; `1` is its default when the caller leaves it out.
    construct(this.width = 1, this.height = 1) {
        area = width * height;
    }
}

struct Temperature {
    var celsius: float;

    # Ordinary parameters work too, written like a function's: `name: Type`.
    construct(fahrenheit: float = 32.0) {
        celsius = (fahrenheit - 32.0) * 5.0 / 9.0;
    }
}

main(): int {
    var r = Rect(3, 4);
    assert(r.width == 3 && r.height == 4, "arguments fill the fields");
    assert(r.area == 12, "the constructor body runs after the fields are set");

    var tall = Rect(2);
    assert(tall.width == 2 && tall.height == 1, "omitted arguments use their defaults");

    var unit = Rect();
    assert(unit.area == 1, "every parameter has a default, so Rect() works");

    var boiling = Temperature(212.0);
    assert(boiling.celsius == 100.0, "212 F is 100 C");
    assert(Temperature().celsius == 0.0, "the default is 32 F");
    return 0;
}
  • A parameter written this.width = 1 stores its argument directly into the field width, before the body runs. The = 1 is required: it is the value used when the caller omits that argument.
  • Ordinary parameters are written as in a function, name: Type, with an optional default: fahrenheit: float = 32.0. The C-style form, float fahrenheit = 32.0, is accepted too.
  • Inside the body, fields are in scope by name. Use this.name when a parameter has the same name as a field.
  • A struct may declare several constructors with different parameter lists; a call picks one by its arguments, as for any overloaded function. At most one of them may be [Implicit]. A struct has at most one destructor.

A this.field parameter must have a default. Leaving it out is an error:

struct Point {
    var x: int;
    construct(this.x) {}
}
main.pep:3:21: error: expected '='
    3 |     construct(this.x) {}
      |                     ^

A struct without a constructor accepts no arguments:

main.pep:7:13: error: too many arguments to 'Point': expected 0, have 2
    7 |     var p = Point(1, 2);
      |             ^~~~~

Note: new is the older, deprecated spelling of construct. It still compiles, with a warning that offers the fix (see Deprecations).

Implicit conversions

A constructor marked [Implicit] also converts. Wherever a value flows into a place whose type is the struct, and the value has the constructor’s parameter type, the compiler calls the constructor for you:

struct Celsius {
    var degrees: double;

    # `[Implicit]` lets a `double` become a `Celsius` wherever one is expected.
    [Implicit]
    construct(double degrees) {
        this.degrees = degrees;
    }
}

struct Label {
    var text: String;

    # Without `[Implicit]`, callers write `Label("...")` themselves.
    construct(String text) {
        this.text = text;
    }
}

describe(t: Celsius): String {
    return (t.degrees > 30.0) ? "hot" : "mild";
}

main(): int {
    assert(describe(35.0) == "hot", "an argument converts");
    var today: Celsius = 21.5;            # a typed variable converts
    assert(describe(today) == "mild", "already a Celsius");

    var label = Label("kitchen");         # no conversion: call the constructor
    assert(label.text == "kitchen", "explicit constructor call");

    var n = 12.0;                         # no slot type: stays a double
    assert(n == 12.0, "untyped local");
    return 0;
}

The standard library uses this for io.Path, which converts from a String, so dir.join("logs") and io.File.open("notes.txt") work without writing io.Path(...), and for Error, so Result<int>.err("bad input") works.

  • Where it applies: a variable’s initializer when the variable’s type is written, an assignment, a function, method or constructor argument (operators and a[i] = value included), a call through a function value, and a return from a function with a declared return type.
  • Where it does not: the receiver of a method call ("docs".join("x") does not turn "docs" into a Path), a var whose type is inferred (var n = 12.0; stays a double), and __null, which stays a null reference.
  • One conversion per value. The value must already have the constructor’s parameter type, or reach it by a widening that loses nothing: a smaller integer to a larger one, an integer literal that fits, float to double, or a struct to a trait it declares. Conversions never chain: if Tag converts from Name and Name from String, a String does not become a Tag.
  • Overloads prefer anything else. Among overloads, a member that takes the value as it is, or by any other conversion, beats one that needs an [Implicit] constructor.
  • Each conversion is a constructor call, so it allocates a new object. The VS Code extension shows the call as an inlay hint around the converted value, and hovering the value names the constructor. The converted value is a temporary like any constructor call’s result: it is released after the call it was passed to (see Memory and reference counting).
  • Only a constructor that can be called with one argument may be [Implicit]: one parameter, or a first parameter followed by parameters that all have defaults. It may not convert from the struct itself or, in a generic struct, from a bare type parameter (construct(T value)):
main.pep:2:5: error: 'Implicit' constructor of 'Span' must be callable with one argument
    2 |     [Implicit]
      |     ^~~~~~~~~~

Conversions are opt-in: a constructor without [Implicit] never runs on its own. When a value would fit such a constructor, the type error says so:

main.pep:6:27: error: cannot pass a value of type 'String' as argument 1 of 'show', which has type 'Label'
    6 | main(): int { return show("x"); }
      |                           ^~~
main.pep:3:5: note: 'Label' has a constructor from 'String': call 'Label(...)', or mark the constructor [Implicit]
    3 |     construct(String text) { this.text = text; }
      |     ^~~~~~~~~

Destructors

A destructor runs when an object is destroyed, which happens when the last reference to it goes away. Declare it with destruct and a body, with no parentheses. Use it to release something the object holds that Pepper does not manage for you, such as a file handle from C.

import async;

# A module-scope counter: const, and changed through an Atomic.
const closed = async.Atomic<int>(0);

struct Connection {
    var name: String;

    construct(this.name = "") {}

    # The destructor runs once, when the last reference to the object goes
    # away. Use it to release whatever the object holds.
    destruct {
        closed.fetch_add(1);
    }
}

main(): int {
    {
        var c = Connection("db");
        assert(closed.load() == 0, "still in use");
    }
    # `c` went out of scope at the closing brace.
    assert(closed.load() == 1, "the destructor ran at the end of the block");
    return 0;
}

You never call a destructor yourself. After it runs, Pepper releases the object’s fields, so a destructor can still read them. The memory chapter lists every point where an object can be destroyed.

Methods

A method is a function declared inside a struct. You call it on an object with a dot, v.length_squared(), and inside it this refers to that object.

struct Vector {
    var x: int = 0;
    var y: int = 0;

    construct(this.x = 0, this.y = 0) {}

    # A method: name, parameters, then the return type after `:`.
    length_squared(): int {
        # Fields can be named directly inside a method...
        return x * x + y * y;
    }

    # ...or through `this`, the object the method was called on.
    dot(other: Vector): int {
        return this.x * other.x + this.y * other.y;
    }

    # Methods can change fields, and can call other methods.
    scale(factor: int) {
        x = x * factor;
        y = y * factor;
    }

    # The return type can also come first, C-style.
    bool is_zero() {
        return length_squared() == 0;
    }

    # A static method belongs to the type, not to one object: there is
    # no `this`. Call it on the type name.
    static unit_x(): Vector {
        return Vector(1, 0);
    }
}

main(): int {
    var v = Vector(3, 4);
    assert(v.length_squared() == 25, "method call");
    assert(v.dot(Vector(1, 1)) == 7, "method with an argument");

    v.scale(2);
    assert(v.x == 6 && v.y == 8, "a method can modify its object");

    assert(Vector().is_zero(), "methods can be called on a temporary");
    assert(Vector.unit_x().x == 1, "static method called on the type");
    return 0;
}
  • Methods are written like functions: name(parameters): ReturnType { ... }. Leave out the return type for a method that returns nothing. The C-style form, bool is_zero() { ... }, works too.
  • Inside a method, the object’s fields and other methods can be named directly (x, length_squared()), or through this (this.x). A parameter or local with the same name as a field hides the field; use this. to reach the field.
  • A bare call names the struct’s own method before a top-level function of the same name, unless no method by that name takes those arguments: then it calls the function. So a copy(): Buffer method can call the free function copy(label).
  • A method can change the fields of its object. Because struct values are references, the caller sees the change.
  • Calling a method does not copy the object or add a reference to it. The result of a method call is a new reference, like any function result.
  • Several methods can share a name when their parameters differ; each call picks the best fit (see Overloading).

Static methods

A method marked static belongs to the type rather than to one object. It has no this, and you call it on the type name: Vector.unit_x(). Static methods are the usual way to offer named constructors, such as the standard library’s Result<int>.ok(42).

Inside the struct, any method, static or not, can call a static method by its bare name: unit_x() in a method of Vector means Vector.unit_x().

# Static methods belong to the type; inside it, they are called by name.
struct Vector {
    var x: double = 0.0;
    var y: double = 0.0;
    construct(this.x = 0.0, this.y = 0.0) {}

    static unit_x(): Vector {
        return Vector(1.0, 0.0);
    }

    # A static method calls another by its bare name.
    static scaled_unit_x(factor: double): Vector {
        var unit = unit_x();
        return Vector(unit.x * factor, unit.y * factor);
    }
}

main(): int {
    var v = Vector.scaled_unit_x(3.0);
    assert(v.x == 3.0 && v.y == 0.0, "called on the type name outside it");
    return 0;
}

Operators

A struct can define what the operators do for it. Declare a method whose name is the operator, written like any other method: ==(other: Money): bool. The C-style form with the return type first, bool ==(Money other), is accepted too.

struct Money {
    var cents: int = 0;

    construct(this.cents = 0) {}

    # `a == b` calls `a.==(b)`. Without this method, `==` on two structs
    # compares identity, like `===`.
    ==(other: Money): bool {
        return cents == other.cents;
    }

    # `!=` is derived from `==` when the struct does not define it.

    # `a < b` calls `a.<(b)`.
    <(other: Money): bool {
        return cents < other.cents;
    }

    # `a + b` calls `a.+(b)` and returns a new Money.
    +(other: Money): Money {
        return Money(cents + other.cents);
    }

    # `-a` calls `a.-()`: a unary operator takes no parameters.
    -(): Money {
        return Money(0 - cents);
    }

    # `a - b` calls `a.-(b)`. The unary and binary `-` overload each other,
    # told apart by their number of parameters.
    -(other: Money): Money {
        return Money(cents - other.cents);
    }

    # `a += b` needs its own method; it is not rewritten to `a = a + b`.
    +=(other: Money): Money {
        cents += other.cents;
        return this;
    }
}

struct Shelf {
    var slots: Array<String>;

    construct() {
        slots = Array<String>();
        slots.append("empty");
        slots.append("empty");
    }

    # An indexer with one parameter handles reads: `shelf[i]`.
    [](index: int): String {
        return slots[index];
    }

    # An indexer with two parameters handles writes: `shelf[i] = value`.
    [](index: int, value: String): String {
        slots[index] = value;
        return value;
    }
}

main(): int {
    var price = Money(250);
    assert(price == Money(250), "== compares cents");
    assert(price != Money(100), "!= compares cents");
    # `===` and `!==` always compare identity: equal cents, different objects.
    assert(price !== Money(250), "!== compares identity");
    var same = price;
    assert(same === price, "=== is true for the same object");

    var total = price + Money(50);
    assert(total.cents == 300, "+ makes a new value");
    assert(price.cents == 250, "+ left its operands alone");

    assert(price < total, "< compares cents");

    var refund = -price;
    assert(refund.cents == -250, "unary - calls -()");
    assert((total - price).cents == 50, "binary - calls -(other)");

    total += Money(25);
    assert(total.cents == 325, "+= updated total in place");

    var shelf = Shelf();
    shelf[1] = "books";
    assert(shelf[1] == "books", "indexer write, then read");
    assert(shelf[0] == "empty", "the other slot is unchanged");
    return 0;
}

When the left operand is a struct that declares the operator, a + b becomes the call a.+(b). The right operand is the method’s single parameter, and it can be any type. These binary operators can be defined:

KindOperators
Arithmetic+ - * / % **
Bitwise& | ^
Bitwise shifts<< >>
Comparison and logical== != < <= > >= && ||
Compound assignment+= -= *= /= %= <<= >>= &= |= ^=

Keep these rules in mind:

  • An operator can be declared once per right-operand type (+(Money) and +(int)), and a + b picks by b’s type, as for any overloaded method.
  • Each operator is its own method, with one exception: when a struct defines == but not !=, a != b means !(a == b). A struct that defines == and is used as a HashMap or HashSet key also needs a matching hash<T> (see Hashing and equality). += is not rewritten to a = a + b; define each one you want to use.
  • Comparison operators must return bool; any other return type is an error (comparison operator '==' must return 'bool', not 'String'). Logical operators should return bool too. Arithmetic, bitwise and compound assignment operators usually return the struct’s own type; a compound assignment usually changes this and returns it.
  • The left operand decides: a + b uses the method on a’s type. For a number on the left, such as 2 * v, see A number on the left.
  • Operators that start with < (<, <=, <<, <<=) can only be declared in the name: Type form, <(other: V): bool: in the C-style form the parser reads bool < as the start of generic arguments.

The result of an operator has the type its method returns, so with -(other: V): int, var r = a - b; is an int.

A number on the left

2.0 * v uses the operator of the left operand’s type, double, which a struct cannot change. Declare it in an extension of the number type instead, where this is the number:

# A number on the left of an operator: `2 * v` with a struct `v`.
struct Vector {
    var x: double;
    var y: double;

    construct(this.x = 0.0, this.y = 0.0) {}

    # `v * 2.0` calls `v.*(2.0)`.
    *(scale: double): Vector {
        return Vector(x * scale, y * scale);
    }
}

# `2.0 * v` uses the operator of the left operand's type, so `double`
# declares it in an extension; `this` is the number.
extend double {
    *(v: Vector): Vector {
        return v * this;
    }
}

# `2` is an `int`, which needs its own.
extend int {
    *(v: Vector): Vector {
        return v * (this as double);
    }
}

main(): int {
    var v = Vector(1.0, 2.0);
    var a = v * 2.0;
    var b = 2.0 * v;
    var c = 2 * v;
    assert(a.x == 2.0 && b.x == 2.0 && c.y == 4.0, "both orders");
    # Numbers on both sides still use the built-in operator.
    assert(2 * 3 == 6, "built-in");
    return 0;
}
  • The number type is the left operand’s own: 2 is an int and 2.0 a double, so each needs its own extension (extend int, extend double). Without one, 2 * v is invalid operands to binary expression ('int' and 'Vector').

  • The operator takes one operand, which may be any type except a number, bool, enum or pointer: numbers on both sides always use the built-in operator.

    main.pep:2:14: error: operator '+' of 'int' cannot take an operand of type 'int': the built-in operator applies
        2 |     +(other: int): int { return 0; }
          |              ^~~
    
  • Comparisons work the same way, and 2.0 != v means !(2.0 == v) when the extension declares only ==. Compound assignments (n *= v) do not apply, since the number cannot hold the result.

  • The extension is visible like any other: where its file is imported, if it is exported.

Unary operators

A unary operator is a method with no parameters; its operand is this.

OperatorDeclarationop x calls
Negation-(): Vx.-()
Bitwise complement~(): Vx.~()
Logical not!(): boolx.!()
Truth test!!(): boolx.!!() (see below)

The C-style form works here too: V -(), bool !!(). ! and !! must return bool (truth-test operator '!!' must return 'bool', not 'int'), and !, ~ and !! take no parameters. -a on a number is unchanged. A struct may declare both a unary -() and a binary -(other: V): they overload each other, told apart by their number of parameters.

Truth tests and conditions

A condition asks a value “true or false?”. For bool the answer is the value, for a number it is “not zero”, and for a raw pointer (__ptr<T>) or a function value it is “not null”. A struct answers through its truth-test operator, !!(): bool, like C++’s operator bool:

# A struct that declares `!!(): bool` can be used as a condition.
struct Budget {
    var cents: int = 0;

    construct(this.cents = 0) {}

    # The truth test: `if (budget)` calls `budget.!!()`.
    !!(): bool {
        return cents > 0;
    }
}

struct Label {
    var text: String;

    construct(this.text = "") {}
}

spend(budget: Budget, amount: int): int {
    var spent = 0;
    # A condition, an operand of `&&`, and the operand of `!` all call `!!`.
    while (budget && amount > 0) {
        budget.cents -= 1;
        amount -= 1;
        spent += 1;
    }
    if (!budget) {
        print("budget used up");
    }
    return spent;
}

main(): int {
    var budget = Budget(3);
    assert(spend(budget, 2) == 2, "spent 2 of 3");
    assert(budget ? true : false, "a ternary condition calls !! too");
    assert(spend(budget, 5) == 1, "only 1 left");
    assert(!!budget == false, "!!x calls the truth test");

    # A struct without `!!` is not a condition: `if (label)` is an error.
    # Compare with `__null` to ask whether a reference is set.
    var label: Label;
    assert(label === __null, "no Label yet");
    label = Label("sale");
    assert(label !== __null, "now there is one");
    return 0;
}

These are the boolean contexts. In each, a struct value x becomes x.!!():

  • the condition of if, else if and while;
  • the condition of condition ? a : b;
  • each operand of &&, ||, and and or (unless the struct declares && or || itself, which then takes the whole operation);
  • the operand of !: without its own ! method, !x is !(x.!!()).

!!x is the truth test itself: on a struct it calls x.!!(); on a bool or a number it is still two negations. Optional declares !!, so if (found) asks whether an optional holds a value.

A struct that does not declare !! cannot be used as a condition at all:

main.pep:6:9: error: 'Node' cannot be used as a condition: declare '!!(): bool' on it, or compare with '__null'
    6 |     if (node) {
      |         ^~~~

A struct value is a reference, but a condition never tests it for null. To ask whether a reference is set, compare it with __null (node !== __null), or better, use an Optional for a value that may be missing. A trait value is treated the same way: it is a condition only when its trait declares !!.

Comparing structs

Without an == method, a == b on two structs compares identity: it is true only when both refer to the same object, exactly like a === b. The same goes for !=. Define == when two distinct objects with the same contents should count as equal, as String and Array do; != then follows from it. To compare field by field, mark the struct [Equatable], which derives == and hash together. === and !== always compare identity, whatever methods the struct declares, so "a" + "b" == "ab" is true while "a" + "b" === "ab" is false.

A struct never equals a number: unless the struct declares an == (or !=) taking one, comparing a struct value such as an Optional with an integer is an error (cannot compare 'Optional<ulong>' with an integer), since it could only ever compare the object itself. Test an optional with is_empty() (or if (found)) and read its value with value().

Indexers

An indexer lets you write object[index]. Declare it as a method named []:

  • With one parameter, [](index: int): String, it handles reads: shelf[i].
  • With two parameters, [](index: int, value: String): String, it handles writes: shelf[i] = value. The second parameter is the value being stored.

The C-style form, String [](int index), is accepted too. Only parameters without a default count, so either kind can end with a defaulted parameter, such as the location: __ptr<char> = __caller_location() that Array uses to report an out-of-range index at the caller’s line (see Assertions and panics).

A struct can have both. With both, compound assignment through an index (counts[i] += 1) reads with the first and writes with the second. Array and String are indexed this way.

Assignment through a chain of indexes, library["fiction"][0] = "Dune", reads with every index but the last and writes with the last, as if the value read were held in a variable first (var fiction = library["fiction"]; fiction[0] = "Dune";). Since a struct is held by reference, the write changes the object the outer struct holds. Assigning through an index whose type has only the reading indexer is an error (cannot assign through '[]': 'Shelf' declares a '[]' getter but no setter).

# A struct declaring `[]` can be indexed: one parameter reads, two write.
struct Shelf {
    var titles: Map<String, String>;
    var sections: Map<String, Shelf>;

    construct() {
        titles = Map<String, String>();
        sections = Map<String, Shelf>();
    }

    # `shelf["name"]` reads a section.
    [](name: String): Shelf {
        return sections[name];
    }

    # `shelf["name"] = section` writes one.
    [](name: String, section: Shelf): Shelf {
        sections[name] = section;
        return section;
    }

    # `shelf[slot]` and `shelf[slot] = title` overload `[]` by type.
    [](slot: ulong): String {
        return titles[$"{slot}"];
    }

    [](slot: ulong, title: String): String {
        titles[$"{slot}"] = title;
        return title;
    }
}

main(): int {
    var library = Shelf();
    library["fiction"] = Shelf();
    # A chained assignment writes into the shelf the first index reads,
    # like `var fiction = library["fiction"]; fiction[0] = ...;`.
    library["fiction"][0] = "Dune";
    library["fiction"]["classics"] = Shelf();
    library["fiction"]["classics"][0] = "Emma";
    assert(library["fiction"][0] == "Dune", "set through two indexers");
    assert(library["fiction"]["classics"][0] == "Emma", "and through three");
    return 0;
}

Properties

A property is a struct member that is read and written like a field (account.name, account.name = "Savings") but whose get and set accessors can run code. A property that needs somewhere to keep its value gets it from the compiler: its implied storage, reachable as field inside its accessors. You declare a separate private field only when you want one.

Declaring properties

A property is a field declaration followed by an accessor block instead of ;:

# A property is read and written like a field, but its `get` and `set`
# accessors can run code.
struct Account {
    # An auto property: implied storage, read and written by anyone.
    var owner: String { get; set; }

    # Read-only outside the struct: only Account's own code assigns it.
    var balance: long { get; }

    # A custom `set` over implied storage: `field` is the storage and
    # `value` is the new value.
    var name: String {
        get;
        set {
            assert(!value.is_empty, "an account name cannot be empty");
            field = value;
        }
    } = "unnamed";

    # Computed: no accessor is omitted and none uses `field`, so it takes
    # no space.
    var is_empty: bool {
        get {
            return balance == 0;
        }
    }

    # Explicit storage, when you want it.
    var _deposits: ulong;
    var deposits: ulong {
        get {
            return _deposits;
        }
    }

    # An initializer initializes the implied storage.
    var limit: long { get; set; } = 100;

    construct(this.owner = "") {}

    deposit(amount: long) {
        balance += amount;   # Account's own code may set a get-only property
        _deposits += 1;
    }
}

main(): int {
    var account = Account("Ann");
    assert(account.owner == "Ann", "a constructor parameter can set a property");
    assert(account.is_empty, "a computed property runs its get");

    account.owner = "Bob";
    account.name = "Savings";
    assert(account.name == "Savings", "the set stored the name");

    account.deposit(30);
    account.deposit(12);
    assert(account.balance == 42 && account.deposits == 2, "methods update the storage");
    assert(!account.is_empty, "the computed property follows the balance");

    # `account.balance = 0;` would not compile: 'balance' has no 'set'.

    account.limit += 50;   # a get, then a set
    assert(account.limit == 150, "compound assignment reads and writes");
    return 0;
}
  • get; and set; with no body are the defaults: get returns the storage, and set stores the new value in it. Either may have a body instead.
  • A property has implied storage when an accessor is omitted (get; or set;) or an accessor body uses field. Otherwise it is computed: it takes no space, and its get runs every time it is read.
  • An initializer after the accessors (var limit: long { get; set; } = 100;) initializes the storage, so it needs a property that has storage. It does not run set.
  • Inside an accessor, field is the storage, and inside set, value is the new value (a by-value parameter). Both are contextual: outside accessors they are ordinary names, so a local or a member called field or value elsewhere keeps working. Inside an accessor, a bare field always means the storage; reach a member of that name as this.field.
  • A property must have a get: { set; } alone is an error.
  • A property is declared with var and an explicit type; it cannot be const. There are no static properties.

Reading and writing

  • p.count calls get, and p.count = x calls set. A default accessor is not a call at all: an auto property ({ get; set; }) compiles to the same loads and stores as a field.
  • p.count += 1, p.count++ and the other compound assignments are a get and then a set. p is evaluated once.
  • A constructor parameter this.name assigns the property, through its set when it has one. A field initializer and the struct’s own field initialization do not.
  • Inside the struct’s methods, a property is reached by its bare name, like a field: balance += amount; is this.balance += amount;.
  • A property’s accessors are ordinary method calls for reference counting: get returns a temporary that owns one reference, and value is a by-value parameter, acquired on entry and released on exit.

Read-only properties

{ get; } alone makes a property read-only outside the struct: assigning it from other code is an error at the property’s name.

main.pep:6:7: error: cannot assign to 'balance': it has no 'set' accessor
    6 |     a.balance = 3;
      |       ^~~~~~~
main.pep:2:9: note: 'balance' declared here
    2 |     var balance: long { get; }
      |         ^~~~~~~

The struct’s own constructors and methods may assign a get-only property with implied storage; that writes the storage directly. An extension’s methods are not the struct’s own code: they cannot assign it either.

Properties in traits and extensions

A trait can require a property, var area: double { get; } or { get; set; }, with no accessor bodies. A struct meets the requirement with a property of the same name and type (auto or computed), or with a plain field. Reading or writing the property through a trait value calls the struct’s accessors.

An extension can add computed properties only, since it cannot add storage to the struct:

# A trait can require a property; a struct meets it with a property of the
# same name and type.
trait Shape {
    var area: double { get; }
    var label: String { get; set; }
}

struct Square {
    var side: double { get; set; } = 1.0;
    var area: double {
        get {
            return side * side;
        }
    }
    var label: String { get; set; } = "square";
}

extend Square with Shape;

# An extension can add computed properties, which need no storage.
extend Square {
    var perimeter: double {
        get {
            return side * 4.0;
        }
    }
}

describe(shape: Shape): String {
    shape.label = "seen";
    return $"{shape.label}: {shape.area}";
}

main(): int {
    var square = Square();
    square.side = 3.0;
    assert(square.area == 9.0, "the computed area follows the side");
    assert(square.perimeter == 12.0, "an extension's property reads like any other");
    assert(describe(square) == "seen: 9.0", "a trait value reaches the struct's accessors");
    assert(square.label == "seen", "the set through the trait reached the struct");
    return 0;
}

An extension property that would need storage is an error:

main.pep:5:9: error: an extension cannot add storage: property 'y' must be computed, with accessor bodies that do not use 'field'

An enum can declare computed properties too, under the same rule, and no set, since its values never change.

Properties of [Shared] structs

The fields of a [Shared] struct are const, so the storage of its properties is too:

  • A property with implied storage must be { get; }, and only the struct’s constructors assign it. A set, with or without a body, is an error at set, with a note at [Shared].
  • A computed get over the struct’s const fields is allowed, as any method is.

A [Shared] struct’s properties are get only, by design: an assignment x.p = v would read as a write to a struct whose fields cannot change. To change what a thread-safe member holds (an Atomic<int>, or a value behind a Mutex), declare a method that says what it does, such as store_hits(value) or add_name(name).

How properties compile

The compiler lowers a property to plain members: a field for its storage (named after the property when its get is the default, __field_name otherwise), a getter method __get_name() and, with a set, a setter __set_name(value). Every read and write of the property becomes a storage access or an accessor call once the receiver’s type is known. The accessor methods appear in debuggers and in profiles under those names.

Value structs

A struct is normally a reference to an object on the heap. A struct marked [Value] is a value instead, like a number or a tuple: it is stored inline in whatever holds it (a local, a field, an array element), and assigning it, passing it, or returning it copies it. Creating one allocates nothing.

# A value struct is stored inline and copied like a number.
[Value]
struct Span {
    var start: int;
    var end: int;

    construct(this.start = 0, this.end = 0) {}

    length(): int {
        return end - start;
    }

    # Assigns a field of `this`, so it changes the value it is called on.
    grow(by: int) {
        end = end + by;
    }
}

# A reference struct holding a value: the span is part of the line's object.
struct Line {
    var text: String;
    var span: Span;

    construct(this.text = "") {}
}

# The parameter is a copy: growing it does not change the caller's span.
grown(span: Span, by: int): Span {
    span.grow(by);
    return span;
}

main(): int {
    var a = Span(2, 5);
    var b = a;          # a copy
    b.grow(10);
    if (a.length() != 3 || b.length() != 13) {
        return 1;
    }

    var c = grown(a, 1);
    if (a.length() != 3 || c.length() != 4) {
        return 2;
    }

    # Through a reference, the change lands in the object's own span.
    var line = Line("hello");
    var same = line;
    line.span.grow(5);
    if (same.span.length() != 5) {
        return 3;
    }

    # An element read from an array is a copy: change it, then store it back.
    var spans = Array<Span>();
    spans.append(a);
    var first = spans[0];
    first.grow(1);
    spans[0] = first;
    if (spans[0].length() != 4) {
        return 4;
    }

    # Values compare field by field.
    if (a != Span(2, 5)) {
        return 5;
    }
    return 0;
}

Use a value struct for small types that describe a value rather than an identity: a span, a point, a color. An enum can be a value too (see Value enums); Optional and Result are. Keep a reference struct for anything that is shared and changed through several names, or that is large.

Declaring one

Put [Value] on its own line above the struct. Everything else is written as for any struct: fields, constructors, methods, operators, generic parameters, and extensions.

[Value]
struct Point {
    var x: int;
    var y: int;

    construct(this.x = 0, this.y = 0) {}
}

A variable or field of a value struct type that is not given a value holds the zero value: every field zero, false, or null.

Copies

  • var b = a; makes b an independent copy. Changing b leaves a as it was.
  • A parameter is a copy of the argument. A function can change its parameter without the caller seeing it; return the changed value instead.
  • A field of a value struct is part of whatever holds it. In a reference struct, line.span.grow(5) changes the span inside line’s object, and every reference to that object sees it.
  • Reading an element of an array, or the result of any call, gives a copy. To change an element, read it into a variable, change it, and store it back (spans[0] = first;).

A field that is itself a reference (a String, an Array) is copied as a reference, so both copies share that object, as two variables holding the same String do. Its count is kept like any other reference’s: copying the value acquires it, and the copy going out of scope releases it. Use copy for a deep copy.

Methods that change this

In a method, this is the value the method was called on, not a copy, so a method that assigns a field (end = end + by;), or calls another method that does, changes that value. The compiler works out which methods change this from their bodies. Calling one on something that would only change a copy is an error, because the change would be lost:

main.pep:9:12: error: cannot call mutating method 'grow' on a temporary 'Span'
    9 |     make().grow(1);
      |            ^~~~
main.pep:9:5: note: 'make' returns a copy; store it in a variable, change it, and assign it back
    9 |     make().grow(1);
      |     ^~~~

The same holds for assigning a field of a call’s result (spans[0].end = 3;) and for changing a const variable’s value. A closure made in a method holds a copy of this, so it cannot change this either.

Comparing and hashing

== and != compare two values field by field, and hash combines the fields’ hashes, unless the struct declares its own == or hash (declare both together; see Hashing and equality). A value has no identity, so === and !== are errors on it, and it is never null: comparing one with __null is an error too.

What a value struct cannot do

Each of these is an error at the code to change:

  • ===, !==, or __null on a value.
  • __weak<T> or __refcount of a value: there is no object to count.
  • A destruct block: nothing destroys a value. Its reference fields are still released.
  • [Shared]: a value struct is thread-safe when its fields are, and needs no atomic counts.
  • A field whose type is the struct itself, directly or through other value structs: the value would be infinitely large. Hold it through a reference struct or an Array instead.
  • A parameter or result of an extern function: pass a pointer to C instead.

Value structs as trait values

A value struct can declare traits like any struct. Where it flows into a slot of a trait type, such as an argument of type Iterator<int>, it is boxed: copied into a new object that the trait value refers to. The box is a copy, so a mutating method called through the trait value changes the box and not the original, and two conversions of one value make two boxes. Copying the trait value shares the box, as for any trait value.

The runtime’s iterators (ArrayIterator, StringIterator, the map and set iterators, ChannelIterator, and the map, filter, enumerate and zip adapters) are value structs, so a for loop over an Array, a String or any map or set allocates no iterator.

Hashing and equality

HashMap<K, V> and HashSet<K> find a key by hashing it with hash<K> and comparing it with ==. What those two mean for a type decides which keys count as the same. OrderedMap<K, V> finds keys the same way. For maps that keep their keys in order, see Maps and sets.

Key type==hash<K>
Numbers, bool, char, pointersthe valuethe value’s bits
Stringthe contentthe content
A tupleelement by elementthe elements’ hashes, combined
A value structfield by fieldthe fields’ hashes, combined
An [Equatable] struct or enumfield by fieldthe fields’ hashes, combined
Any other structidentity (===)the object’s address

Struct keys use identity by default

A struct is a reference. Unless it says otherwise, two references are the same key only when they refer to the same object. Another object with equal fields is a different key:

struct Point {
    int x;
    int y;

    construct(this.x = 0, this.y = 0) {}
}

main(): int {
    var origin = Point(0, 0);
    var visits = HashMap<Point, int>();
    visits[origin] = 1;

    # A struct key is found by identity: the same object finds its entry...
    assert(visits.contains(origin), "the stored object");

    # ...but another object with equal fields is a different key.
    assert(!visits.contains(Point(0, 0)), "an equal but distinct object");
    return 0;
}

Identity is right for objects that stand for one thing: a connection, a node in a graph, a task. It is wrong for a struct that describes a value, such as a point or a record looked up by its fields.

Deriving both with [Equatable]

Mark the struct [Equatable], and the compiler writes both halves from its fields, so they cannot drift apart. It compares and hashes like a tuple of its fields: a field of a struct type uses that struct’s == and hash, a String its content, and a null field equals only null. Mark a field [Equatable(exclude = true)], or [Equatable(include = false)], to leave it out of both; a field with neither takes part:

# `[Equatable]` derives `==` and the hash from the fields, so they always
# agree.
[Equatable]
struct Person {
    String name;
    int id;
    # Not part of the identity: two records for the same person may differ.
    [Equatable(exclude = true)]
    String nickname;

    construct(this.name = "", this.id = 0, this.nickname = "") {}
}

# An enum whose cases carry values compares the case and then its values.
[Equatable]
enum Token {
    Word(text: String),
    Number(value: int),
    End,
}

main(): int {
    HashSet<Person> people = [Person("Ada", 1, "A"), Person("Ada", 1, "Countess")];
    assert(people.count == 1, "equal people are one member");
    assert(people.contains(Person("Ada", 1)), "the nickname is ignored");
    assert(!people.contains(Person("Ada", 2)), "a different id");
    assert(Person("Ada", 1) == Person("Ada", 1, "B"), "== is derived");

    var counts = HashMap<Token, int>();
    counts[Token.Word("pepper")] = 2;
    counts[Token.End] = 1;
    assert(counts[Token.Word("pepper")] == 2, "found by case and value");
    assert(Token.Word("salt") != Token.Word("pepper"), "different values");
    assert(Token.Number(0) != Token.End, "different cases");
    return 0;
}
  • An enum whose cases carry values can be [Equatable] too: two values are equal when they hold the same case and its values are equal.
  • A generic struct derives both for each specialization, from the fields its type arguments give it.
  • include and exclude are bools, and saying both in a way that contradicts (include = true, exclude = true) is an error. On the struct itself, [Equatable] takes no arguments.
  • An [Equatable] struct cannot also declare == or override hash<T>; either is an error. To compare some other way, drop the attribute and declare both by hand (below).
  • An Array field compares by its elements but hashes by its address, so it adds nothing to the hash. Equal keys still hash equally; keys that differ only in such a field share a hash, which costs speed. The same goes for an Array in a tuple.
  • [Equatable] does not apply to a value struct or an enum whose cases carry nothing: they already compare and hash by content.

Keying a struct by its content

To decide yourself what counts as equal, give the struct both halves of the pair:

struct Point {
    int x;
    int y;

    construct(this.x = 0, this.y = 0) {}

    # Two points are equal when their coordinates are...
    ==(other: Point): bool {
        return x == other.x and y == other.y;
    }
}

# ...so equal points must hash equally: hash the same fields `==` compares.
# A tuple hashes each element in turn, which combines them.
hash<Point>(value: Point): u64 {
    return hash<(int, int)>((value.x, value.y));
}

struct Person {
    String name;
    int id;
    # Not part of the identity: two records for the same person may differ.
    String nickname;

    construct(this.name = "", this.id = 0, this.nickname = "") {}

    ==(other: Person): bool {
        return id == other.id and name == other.name;
    }
}

# A String field hashes by its content, through hash<String>.
hash<Person>(value: Person): u64 {
    return hash<(String, int)>((value.name, value.id));
}

main(): int {
    var visits = HashMap<Point, int>();
    visits[Point(1, 2)] = 1;
    visits[Point(1, 2)] = visits[Point(1, 2)] + 1;   # the same key
    assert(visits.count == 1, "one entry");
    assert(visits[Point(1, 2)] == 2, "found by content");
    assert(Point(1, 2) != Point(2, 1), "!= is the negation of ==");

    HashSet<Person> people = [Person("Ada", 1, "A"), Person("Ada", 1, "Countess")];
    assert(people.count == 1, "equal people are one member");
    assert(people.contains(Person("Ada", 1)), "the nickname is ignored");
    assert(!people.contains(Person("Ada", 2)), "a different id");
    return 0;
}

The two must agree: keys that are == must have the same hash. The simplest way to keep that promise is to hash exactly the fields that == compares, by hashing a tuple of them: hash<(int, int)>((value.x, value.y)). A tuple’s hash combines its elements’ hashes, and each element uses its own type’s hash, so a String field contributes its content. Unequal keys may share a hash. That costs speed, not correctness.

With == declared, != is its negation, as for any struct. The struct may also overload == for other types (==(rhs: int)); the map and set compare keys with the == that takes the struct itself.

Keep these rules in mind:

  • Declare both or neither. With == but no hash<T>, two equal keys usually hash differently, so a lookup would miss an entry that is there. With hash<T> but no ==, keys would still compare by identity, so an equal object would never find another’s entry. Either is a compile error when the struct is used as a HashMap or HashSet key:

    main.pep:5:5: error: 'Point' declares '==' but no 'hash<Point>', so as a HashMap or HashSet key, equal values will not find each other
        5 |     ==(other: Point): bool {
          |     ^~
    note: declare 'hash<Point>(value: Point): u64' hashing the fields '==' compares, or remove '==' and mark 'Point' '[Equatable]' to derive both
    

    The runtime’s own structs are not checked. An Array key’s == compares elements but its hash is its address, so find it with the same Array object, not an equal one.

  • Don’t change a key’s fields while it is in a map or set. The entry stays where its old hash put it, so lookups by its new or its old fields both miss it. Remove it, change it, then insert it again.

  • One override per program. hash<Point> replaces hash<T> for Point everywhere, including inside HashMap, so it may be declared in only one file, and that file must declare Point or be in its module (see overriding a generic function).

  • The hash is not keyed, so it does not resist keys chosen by an attacker to collide.

Value structs compare and hash by their fields

A value struct has no identity, so == and hash already compare and hash it field by field. Declare your own pair only to change what counts as equal, here to ignore case:

# A value struct compares and hashes by its fields with no code at all.
[Value]
struct Cell {
    int row;
    int column;

    construct(this.row = 0, this.column = 0) {}
}

# A value struct may still declare its own pair, here to ignore case.
[Value]
struct Tag {
    String text;

    construct(this.text = "") {}

    ==(other: Tag): bool {
        return text.lower() == other.text.lower();
    }
}

hash<Tag>(value: Tag): u64 {
    return hash<String>(value.text.lower());
}

main(): int {
    var board = HashMap<Cell, String>();
    board[Cell(0, 0)] = "rook";
    assert(board[Cell(0, 0)] == "rook", "fields decide");

    var tags = HashSet<Tag>();
    tags.insert(Tag("Pepper"));
    assert(tags.contains(Tag("PEPPER")), "case is ignored");
    return 0;
}

Ordering with Comparable

A type whose values sort in one order declares the Comparable trait. It asks for one method, compare(other), which returns a negative number, zero or a positive number as the value sorts before, with or after other, and gives the type the rest:

  • the operators <, <=, > and >=;
  • min(other), max(other) and clamp(low, high) (which panics when low sorts after high);
  • a place as a Map key or Set element, in that order;
  • the constraint T: Comparable in a generic function, whose body can then use all of the above on a T.
struct Version {
    var major: int;
    var minor: int;

    construct(this.major = 0, this.minor = 0) {}

    # The one method Comparable asks for.
    compare(other: Version): int {
        var order = major.compare(other.major);
        if (order != 0) {
            return order;
        }
        return minor.compare(other.minor);
    }
}

extend Version with Comparable;

# A generic function asks for an order with `T: Comparable`.
largest<T: Comparable>(items: Array<T>): T {
    var best = items[0];
    for (var item in items) {
        best = best.max(item);
    }
    return best;
}

main(): int {
    # `<`, `<=`, `>` and `>=` come from `compare`.
    assert(Version(1, 2) < Version(1, 10), "1.2 sorts before 1.10");
    assert(Version(2, 0).min(Version(1, 9)).major == 1, "min");
    assert(Version(3, 0).clamp(Version(1, 0), Version(2, 0)).major == 2, "clamp");

    # Numbers and strings are Comparable too.
    assert(largest([3, 9, 2]) == 9, "numbers");
    assert(largest(["pear", "apple"]) == "pear", "strings");
    assert((15).clamp(0, 10) == 10, "clamp a number");

    # A Comparable struct is a Map key, in its order.
    var releases = Map<Version, String>();
    releases[Version(2, 0)] = "second";
    releases[Version(1, 5)] = "first";
    assert(releases.first().value().1 == "first", "sorted by compare");

    # Sorting takes the order as a comparer.
    var versions = [Version(2, 1), Version(1, 5)];
    versions.sort((a, b) => a.compare(b));
    assert(versions[0].major == 1, "sorted");
    return 0;
}

A type that declares its own < (or min, or any of the others) keeps it; the trait’s version fills in only what the type leaves out.

compare must give a total, consistent order: the same two values always compare the same way, a.compare(b) has the opposite sign of b.compare(a), and if a sorts before b and b before c, a sorts before c. Map, Set and sorting depend on it.

Numbers, char and String

Every number type and char is Comparable by value, and String by content, the order of String.compare. Their compare returns -1, 0 or 1 (String.compare any negative or positive number).

float and double compare in a total order: NaN sorts after every number and with every other NaN, and -0.0 sorts with 0.0. Their <, <=, > and >= keep IEEE behavior, false whenever a NaN takes part, while compare, clamp and Map keys follow the total order, so a NaN key cannot break a map.

Not yet

  • Array.sort() takes a comparer: items.sort((a, b) => a.compare(b)) sorts in the elements’ own order. A sort() with no comparer comes once the published toolchain that builds Pepper has the compiler fix this needs.
  • Tuples and Optional are not Comparable, and Comparable is not derived from a struct’s fields: a struct writes its compare, as Version does above.

Maps and sets

The standard library has three kinds of map, which differ in the order they keep their keys in, and a compact set of small integers, BitSet:

TypeOrderLookup, insert, removeKeys need
HashMap<K, V>, HashSet<K>noneexpected O(1)hash<K> and == (Hashing and equality)
Map<K, V>, Set<K>sorted by keyO(log n)compare<K> (below)
OrderedMap<K, V>the order keys were first insertedexpected O(1)hash<K> and ==

All of them share one API: m[key] reads (a missing key reads as the default of V, and is not inserted) and m[key] = value writes; insert, get, try_get (an optional, Empty when the key is absent), contains, remove, count, is_empty and clear. A for loop visits a map’s entries as (key, value) tuples and a set’s members one by one. Adding or removing an entry while a loop iterates the map panics (“Map modified during iteration”), as it does for Array and HashMap; changing the value of a key that is present is allowed.

Sorted: Map and Set

Map<K, V> keeps its entries sorted by key, on a red-black tree. Besides iterating in order, it answers questions about the order:

  • first() and last(): the entries with the smallest and largest key.
  • lower_bound(key): the first entry whose key is at least key; upper_bound(key): the first whose key is greater.
  • reversed(): iterates from the largest key down.
  • range(from, to): iterates the keys from from up to, not including, to.

Each of the four queries returns an optional (key, value) tuple, Empty when there is no such entry. Set<K> is the same tree without values, and its queries return an optional member. An array converts to a Set, so an array literal can initialize one.

main(): int {
    var scores = Map<String, int>();
    scores["carol"] = 91;
    scores["alice"] = 84;
    scores["bob"] = 77;

    # Entries come out sorted by key.
    var names = "";
    for (var (name, score) in scores) {
        names = names + name + " ";
    }
    assert(names == "alice bob carol ", "key order");

    # Order queries return an optional (key, value) tuple.
    assert(scores.first().value().0 == "alice", "smallest key");
    assert(scores.lower_bound("b").value().0 == "bob", "first key >= b");
    assert(scores.upper_bound("carol").is_empty(), "nothing after carol");

    # A key range: from "a" up to, not including, "c".
    var in_range = 0;
    for (var (name, score) in scores.range("a", "c")) {
        in_range += 1;
    }
    assert(in_range == 2, "alice and bob");

    # A Set keeps sorted members; an array converts to one.
    Set<int> primes = [7, 2, 5, 3];
    var digits = 0;
    for (var p in primes.reversed()) {
        digits = digits * 10 + p;
    }
    assert(digits == 7532, "descending");
    return 0;
}

How keys are ordered

Map and Set order keys with the generic function compare<K>(a, b), which returns a negative number, zero or a positive number as a sorts before, equal to or after b. Two keys that compare equal are the same key.

  • Numbers and char compare by value, and String by content (the same order as String.compare). A NaN float or double key sorts after every number (see Ordering with Comparable).
  • A Comparable struct, or one that declares a < operator, sorts by it: the generic compare asks a < b and b < a.
  • Any other struct declares Comparable, or overrides compare<T> for itself, as a hashed key overrides hash<T> (see Overriding a generic function for one type).
# A struct with a `<` operator sorts by it.
struct Version {
    int major;
    int minor;

    construct(this.major = 0, this.minor = 0) {}

    <(other: Version): bool {
        return major < other.major or (major == other.major and minor < other.minor);
    }
}

# A struct without one overrides `compare<T>` for itself.
struct Point {
    int x;
    int y;

    construct(this.x = 0, this.y = 0) {}
}

compare<Point>(a: Point, b: Point): int {
    return a.x != b.x ? a.x - b.x : a.y - b.y;
}

main(): int {
    var releases = Map<Version, String>();
    releases[Version(1, 10)] = "third";
    releases[Version(0, 9)] = "first";
    releases[Version(1, 2)] = "second";
    assert(releases.first().value().1 == "first", "0.9 sorts first");
    assert(releases.last().value().1 == "third", "1.10 sorts last");

    var points = Set<Point>();
    points.insert(Point(2, 1));
    points.insert(Point(1, 5));
    # An equal point (compare returns 0) is the same member.
    assert(!points.insert(Point(1, 5)), "already a member");
    assert(points.first().value().x == 1, "sorted by x");
    return 0;
}

The order must be consistent: the same two keys must always compare the same way, and if a sorts before b and b before c, a sorts before c. A struct key whose fields change while it is in a map breaks that promise.

Insertion order: OrderedMap

OrderedMap<K, V> hashes its keys like HashMap, and iterates its entries in the order their keys were first inserted, like a Python dict. Setting the value of a key that is present keeps its place; removing a key and inserting it again moves it to the end. The other entries keep their order when one is removed.

main(): int {
    var config = OrderedMap<String, String>();
    config["name"] = "pepper";
    config["version"] = "0.2";
    config["license"] = "MIT";

    # Setting a key that is present keeps its place.
    config["name"] = "Pepper";

    # Removing a key and adding it again moves it to the end.
    config.remove("version");
    config["version"] = "0.3";

    var keys = "";
    for (var (key, value) in config) {
        keys = keys + key + " ";
    }
    assert(keys == "name license version ", "insertion order");

    if (config.try_get("license") is Value(license)) {
        assert(license == "MIT", "found");
    }
    assert(config.try_get("author").is_empty(), "absent");
    return 0;
}

Use it where the order of the input matters, such as the keys of a configuration file that should be written back as they were read.

Small integers: BitSet

BitSet is a set of non-negative ints that stores one bit per possible member, so a set whose largest member is m takes about m / 8 bytes however many members it holds. It suits visited flags, masks, and sieve or liveness tables over small integers; for sparse or large integers, use a HashSet<int>.

It has the set API above (insert, contains, remove, count, is_empty, clear) and iterates its members in ascending order. insert grows the set as needed and reserve(n) makes room for 0 to n - 1 ahead of time; it never shrinks. Inserting a negative number panics, while contains and remove of one return false. Changing the set while a loop iterates it is allowed: the loop sees the members above its position as they are when it reaches them.

The set operations come in two forms: union, intersection, difference and symmetric_difference return a new set, and union_with, intersect_with, difference_with and symmetric_difference_with change the set in place. is_subset and is_disjoint compare two sets. Two sets are ==, and hash equally, when they hold the same members whatever their capacity, so a BitSet works as a HashMap or HashSet key. An array converts to a BitSet, so an array literal can initialize one.

main(): int {
    # A sieve: every number below 50 starts out as a candidate.
    var composite = BitSet(50);
    var primes = BitSet();
    for (var n in 2..50) {
        if (composite.contains(n)) {
            continue;
        }
        primes.insert(n);
        var multiple = n * n;
        while (multiple < 50) {
            composite.insert(multiple);
            multiple = multiple + n;
        }
    }
    assert(primes.count == 15, "15 primes below 50");

    # Members come out in ascending order.
    var first = "";
    for (var p in primes) {
        if (p > 11) {
            break;
        }
        first = $"{first}{p} ";
    }
    assert(first == "2 3 5 7 11 ", "ascending");

    # Set operations, by value or in place.
    BitSet odd = [1, 3, 5, 7, 9, 11];
    var odd_primes = primes.intersection(odd);
    assert(odd_primes.count == 4, "3, 5, 7 and 11");
    assert(odd_primes.is_subset(primes), "a subset");
    odd.difference_with(primes);
    assert(odd == BitSet([1, 9]), "1 and 9 are odd but not prime");
    return 0;
}

Queues: Deque and PriorityQueue

Two collections take elements out in an order other than an Array’s:

TypeTakes outPush, popElements need
Deque<T>either endamortized O(1) at both endsnothing
PriorityQueue<T>the greatest elementO(log n)compare<T>, or a comparer

An Array can stand in for either, but removing its first element takes O(n), and keeping it sorted takes O(n) per insert.

Deque

A Deque (a double-ended queue) is a ring buffer that grows when full: push_back, push_front, pop_back and pop_front add and remove at the two ends, front() and back() read them, and d[i] reads or writes the element i places from the front. It is the queue of a breadth-first search, a sliding window, or an undo history that drops its oldest entry.

# Breadth-first search: a Deque is the queue of nodes to visit.
main(): int {
    # Each node's neighbors: 0 - 1 - 2 - 4 and 0 - 3 - 4.
    int[][] edges = [[1, 3], [0, 2], [1, 4], [0, 4], [2, 3]];
    int[] hops = [0, -1, -1, -1, -1];

    Deque<int> queue = [0];
    while (!queue.is_empty) {
        var node = queue.pop_front();
        for (var next in edges[node as ulong]) {
            if (hops[next as ulong] >= 0) {
                continue;
            }
            hops[next as ulong] = hops[node as ulong] + 1;
            queue.push_back(next);
        }
    }
    assert(hops[4] == 2, "two hops from node 0 to node 4");

    # Both ends work: a window of the last three readings.
    var window = Deque<int>();
    for (var reading in [4, 8, 15, 16, 23]) {
        window.push_back(reading);
        if (window.count > 3) {
            window.pop_front();
        }
    }
    assert(window.front() == 15 and window.back() == 23, "the last three");
    assert(window[1] == 16, "indexed from the front");
    return 0;
}

An array converts to a Deque, so an array literal can initialize one. count, is_empty, capacity, reserve and clear work as they do on an Array. A for loop visits the elements from front to back; changing the deque while a loop iterates it panics (“Deque modified during iteration”). Popping, or reading front() or back(), of an empty deque panics, as does an index past the back.

PriorityQueue

A PriorityQueue is a binary heap: push adds an element in O(log n), pop removes the greatest in O(log n), and peek() reads the greatest without removing it. It is the frontier of Dijkstra’s and A* searches, a scheduler’s run queue, or the k largest values of a stream.

# A scheduler: the job with the earliest deadline runs first.
struct Job {
    var name: String;
    var deadline: int;

    construct(name: String, deadline: int) {
        this.name = name;
        this.deadline = deadline;
    }
}

main(): int {
    # The comparer is reversed, so the smallest deadline is the greatest.
    var jobs = PriorityQueue<Job>((a, b) => b.deadline.compare(a.deadline));
    jobs.push(Job("backup", 30));
    jobs.push(Job("email", 5));
    jobs.push(Job("report", 12));
    assert(jobs.peek().name == "email", "the earliest deadline");

    var order = "";
    while (!jobs.is_empty) {
        order = $"{order}{jobs.pop().name} ";
    }
    assert(order == "email report backup ", "by deadline");

    # Comparable elements need no comparer: the greatest comes out first.
    PriorityQueue<int> scores = [72, 95, 88, 61];
    assert(scores.pop() == 95, "the top score");
    var rest = scores.into_sorted_array();
    assert(rest == [61, 72, 88], "the rest, ascending");
    return 0;
}

Elements are ordered by compare<T>, so numbers, String, and any Comparable type need nothing more. To order by something else, pass a comparer to the constructor: a function that returns a negative number, zero or a positive number as its first argument sorts before, with or after its second. For the smallest first, reverse the comparer, (a, b) => b.compare(a). Elements that sort together come out in no particular order.

An array converts to a PriorityQueue, and PriorityQueue<T>(items, compare) builds one from an array with a comparer; both take O(n), and copy the array. into_sorted_array() empties the queue and returns its elements from smallest to greatest. pop and peek of an empty queue panic.

Neither type is [Shared]: like an Array, each stays on one thread.

Enums and match

An enum is a type with a fixed set of named cases. A value of the enum holds exactly one of them. Use an enum where you would otherwise pass an integer code or a string, so the compiler checks that only the listed cases flow through, and that a match handles each one.

There are two kinds:

  • In an enum whose cases carry nothing (Red, Green), each case is an integer. Such an enum is a value type, like int.
  • When a case carries values (Circle(radius: int)), the enum is a reference type, like a struct. See Cases that carry values.
# An enum names a fixed set of cases; a slot of the enum type holds one.
enum Suit {
    Clubs,
    Diamonds,
    Hearts,
    Spades,
}

# A representation sets the stored integer type; a case may give its value.
enum Status: u8 {
    Ok,
    NotFound = 44,
    Failed,
}

struct Card {
    Suit suit;
    int rank = 1;
}

main(): int {
    var card = Card();
    assert(card.suit == Suit.Clubs, "a slot starts at the case whose value is 0");
    card.suit = Suit.Hearts;
    assert(card.suit != Suit.Spades, "cases compare with == and !=");

    # 'as' turns a case into its integer; the reverse is not allowed.
    assert((Suit.Hearts as int) == 2, "cases number from 0");
    assert((Status.Failed as int) == 45, "numbering continues after an explicit value");
    assert(sizeof<Status>() == 1, "Status is stored as a u8");
    return 0;
}

Declaring an enum

enum Name { ... } lists the cases, separated by commas; a comma after the last case is optional. Case names are PascalCase, like types (see naming conventions). A case may share a type’s name (String(value: String)); a string literal in the enum’s methods is still a String, and String(...) there calls the case.

Each case is an integer. The first case is 0 and each case after it is one more than the one before. A case can give its own value with = value, and numbering continues from there:

enum Status: u8 {
    Ok,             # 0
    NotFound = 44,  # 44
    Failed,         # 45
}

A value must be an integer constant: a literal such as -1 or 0x20, or a character such as 'y'. Two cases cannot have the same value.

The type after the colon is the representation, the integer type the enum is stored as. It can be any integer type (u8, short, int, long, and so on). Without one, the enum is stored as an int. Every case value must fit in the representation.

A case can carry a [Target] attribute, so a platform can have cases that others lack. A dropped case does not renumber the others.

Using enum values

This section is about enums whose cases carry nothing.

Name.Case is a value of the enum type. Such an enum is a value type, like int: it is copied, not reference counted, and it can be stored in locals, fields, arrays and module-scope constants, and passed to and returned from functions.

  • == and != compare two values of the same enum. Other operators apply only when the enum declares them (see Operators and indexers).
  • An enum does not mix with integers or with other enums: int x = Color.Red and Color c = 0 are errors, and so is comparing a Color with a Size.
  • value as int (or any integer type) gives a case’s integer value. The reverse, 1 as Color, is an error, since the integer may not name a case; name the case, or use from_value.
  • A slot of an enum type that is not given a value (a field, or a local declared without one) holds the case whose value is 0, or the first case when no case is 0.
  • sizeof<Name>() is the size of the representation.
  • An enum works wherever an integer does: as a HashMap key or HashSet element, in an Atomic<Name>, and in an extern function’s signature, where C sees the representation (an int unless the enum names another).

Cases that carry values

A case can carry values, declared like parameters: Circle(radius: int). An enum with such a case is a reference type: each value is an object, reference counted like a struct.

# Cases that carry values: each case is a constructor, and a `match`
# pattern binds the values of the case it matches.
enum Shape {
    Circle(radius: int),
    Rect(width: int, height: int),
    Square(side: int),
    Point,

    area(): int {
        match (this) {
            Circle(r) => { return 3 * r * r; }
            Rect(w, h) => { return w * h; }
            Square(s) => { return s * s; }
            Point => { return 0; }
        }
        return 0;
    }
}

# A generic enum can name itself: a list is empty, or a value and the rest.
enum List<T> {
    Empty,
    Cons(head: T, tail: List<T>),
}

length(list: List<String>): int {
    match (list) {
        Empty => { return 0; }
        # `_` skips a value.
        Cons(_, tail) => { return 1 + length(tail); }
    }
    return 0;
}

main(): int {
    var shapes = Array<Shape>();
    shapes.append(Shape.Circle(2));
    shapes.append(Shape.Rect(3, 4));
    shapes.append(Shape.Point);
    var total = 0;
    for (var s in shapes) {
        total += s.area();
    }
    assert(total == 24, "12 + 12 + 0");

    # Several patterns in one arm bind the same names.
    var side = 0;
    match (Shape.Square(5)) {
        Rect(w, _), Square(w) => { side = w; }
        else => {}
    }
    assert(side == 5, "a square's side");

    var names = List<String>.Cons("a", List<String>.Cons("b", List<String>.Empty));
    assert(length(names) == 2, "two names");
    assert(names.name() == "Cons", "name() spells the case");
    return 0;
}
  • Name.Case(values...) makes a new value, like a constructor call, and Name.Case alone is a case that carries nothing. An enum’s values are made only by its cases: Shape(...) is an error.
  • A case that carries values must be given them: Shape.Circle without (radius) is an error.
  • The values are fixed once made. There is no way to change them, so a value can be shared freely. To change one, make a new value.
  • Each case that carries nothing is a single object that every use shares. It is never freed and costs no allocation, however often it is used.
  • A slot of the enum type that is not given a value holds null, as a struct slot does. === and !== compare identity, and so does ==, unless the enum is [Equatable].
  • Mark the enum [Equatable] to derive == and hash from its cases: two values are equal when they hold the same case with equal values, so they work as HashMap and HashSet keys.
  • An enum can have type parameters (enum List<T>), and its cases can hold the enum itself, directly or through other types, so lists and trees are enums.
  • The enum can be [Shared] when every value its cases carry is thread-safe (see What threads can share). An enum whose cases carry nothing is stored as an integer and is always thread-safe. It can conform to a trait with extend Name with Trait, like a struct, and its methods can come from an extend block too.
  • Cases cannot have explicit values (= 3), and the enum has no representation type (: u8), since its values are not integers.

Value enums

An enum whose cases carry values can be marked [Value], like a value struct. Each value is then stored inline in whatever holds it, and copied when assigned, passed or returned, instead of being an object. Making one allocates nothing, which suits small enums that are made often, such as Optional and Result, which are both value enums.

[Value]
enum Shape {
    Circle(radius: int),
    Rect(width: int, height: int),
    Point,
}
  • Its cases share one storage: a value is as large as its largest case, plus the tag that says which case it holds (sizeof<Shape>() is 12: the tag, then room for Rect’s two ints, which Circle’s radius reuses).
  • A value enum is never null. A slot that is not given a value holds the first case, with every value it carries zero, false, or null: an unset Shape is Circle(0).
  • == compares the cases and their values; ===, !== and __null are errors.
  • Its cases cannot hold the enum itself, since the value would be infinitely large. Use a reference enum for lists and trees.
  • Everything else a value struct cannot do applies too, and so does boxing when it flows into a trait value.

Methods

An enum can declare methods after its cases. Inside a method, this is the value the method was called on, and a bare call of another method of the enum (next()) calls it on this. A static method is called on the enum itself (Light.first()). A method can be generic (choose<T>), with its type arguments inferred or written out (light.choose<String>(...)).

# An enum can declare methods after its cases; `this` is the value.
enum Light: u8 {
    Red,
    Amber,
    Green,

    may_go(): bool {
        return this == Light.Green;
    }

    next(): Light {
        match (this) {
            Red => { return Light.Green; }
            Amber => { return Light.Red; }
            Green => { return Light.Amber; }
        }
        return this;
    }

    # A bare call of another method acts on `this`.
    after_two(): Light {
        return next().next();
    }

    # A method can be generic, like any function.
    choose<T>(stop: T, go: T): T {
        return may_go() ? go : stop;
    }

    static first(): Light {
        return Light.Red;
    }
}

main(): int {
    var light = Light.first();
    assert(!light.may_go(), "red means stop");
    assert(light.next().may_go(), "green follows red");
    assert(light.after_two() == Light.Amber, "then amber");
    assert(light.choose("wait", "walk") == "wait" && Light.Green.choose(0, 1) == 1, "a generic method");

    # Every enum has name() and from_value().
    assert(light.name() == "Red", "name() spells the case");
    var found = Light.from_value(2);
    assert(found.is_ok() and found.value() == Light.Green, "2 is Green");
    assert(!Light.from_value(7).is_ok(), "no case has the value 7");

    # Enums key hash maps and fill atomics, like integers.
    var waits = HashMap<Light, int>();
    waits[Light.Red] = 30;
    assert(waits[Light.Red] == 30, "an enum is a hash key");
    return 0;
}

For an enum whose cases carry nothing, this is passed by value, like an int: a method cannot change the value it was called on. Return a new case instead, as next() does. Such an enum can also declare a trait with extend Light with Describe { ... }, which makes it meet the constraint T: Describe (see numbers, bool and enums); it is still never a trait value. For an enum whose cases carry values, this is the object, as in a struct’s method; its values still cannot change.

Operators and indexers

An enum can declare operators and an indexer the way a struct does: a method named after the operator (<(other: Level): bool) or []. For an enum whose cases carry nothing, a < b calls Level.< with a as this.

# An enum can declare operators and an indexer, as a struct does.
enum Level: u8 {
    Low,
    Mid,
    High,

    # `a < b` calls `Level.<`: the cases rank in order.
    <(other: Level): bool {
        return (this as int) < (other as int);
    }

    # `level + 1` moves up a level, stopping at the top.
    +(steps: int): Level {
        var next = Level.from_value((this as int + steps) as u8);
        return next.is_ok() ? next.value() : Level.High;
    }

    # `-level` mirrors it.
    -(): Level {
        return match (this) {
            Low => Level.High,
            Mid => Level.Mid,
            High => Level.Low,
        };
    }

    # `level[i]` reads, like any indexer.
    [](bit: int): bool {
        return ((this as int) >> bit) & 1 == 1;
    }
}

main(): int {
    var level = Level.Low;
    assert(level < Level.Mid && !(Level.High < level), "cases rank in order");
    assert(level + 1 == Level.Mid && level + 5 == Level.High, "a step up");
    assert(-level == Level.High, "mirrored");
    assert(Level.Mid[0] && !Level.Mid[1] && Level.High[1], "bits");
    return 0;
}

Its values never change and it already compares with == and !=, so such an enum cannot declare ==, !=, a compound assignment (+=), an indexer that assigns ([](index, value)) or the truth test !!, and its operators are not static.

Properties

An enum can also declare computed properties after its cases, var name: T { get { ... } }, which are read like fields (size.ounces). Inside the enum, a bare property name reads it on this.

# An enum can declare computed properties after its cases, read like
# fields: `size.ounces`, not `size.ounces()`.
enum Size {
    Small,
    Medium,
    Large,

    var ounces: int {
        get {
            return match (this) {
                Small => 8,
                Medium => 12,
                Large => 16,
            };
        }
    }

    # Inside the enum, a bare property name reads it on `this`.
    var cups: int {
        get { return ounces / 8; }
    }
}

# The same works for an enum whose cases carry values.
enum Shape {
    Circle(radius: int),
    Square(side: int),

    var area: int {
        get {
            return match (this) {
                Circle(r) => 3 * r * r,
                Square(s) => s * s,
            };
        }
    }
}

main(): int {
    var size = Size.Medium;
    assert(size.ounces == 12 && Size.Large.cups == 2, "value enum properties");
    assert(Shape.Square(4).area == 16 && Shape.Circle(2).area == 12, "properties that read the payload");
    return 0;
}

An enum value has nowhere to keep anything and never changes, so an enum’s property has a get with a body that does not use field, and no set:

main.pep:6:9: error: property 'n' of enum 'E' must be computed: give 'get' a body that does not use 'field'
main.pep:6:38: error: property 'n' of enum 'E' cannot have a 'set' accessor: enum values are immutable

Built-in methods

Every enum has these methods without declaring them, and cannot declare methods of the same names:

MethodWhat it does
value.name()Returns the case’s name as written ("Red"), a String. Every enum has it.
Name.from_value(n)Returns a Result<Name>: Ok with the case whose value is n, or an Err when no case has it. n has the enum’s representation type. Only enums whose cases carry nothing have it.

from_value is the checked way back from an integer (from a file, a C library or the network) to a case, since as only goes the other way.

match

match (value) { ... } runs the arm for the case that value holds:

# 'match' runs the arm of the case a value holds, and must handle every case.
enum Light {
    Red,
    Amber,
    Green,
}

next(light: Light): Light {
    match (light) {
        Red => { return Light.Green; }
        Amber => { return Light.Red; }
        Green => { return Light.Amber; }
    }
    return light;
}

may_go(light: Light): bool {
    match (light) {
        Green => { return true; }
        else => { return false; }
    }
    return false;
}

wait_seconds(light: Light): int {
    match (light) {
        Red, Amber => { return 30; }
        Light.Green => { return 0; }
    }
    return 0;
}

main(): int {
    var light = Light.Red;
    var changes = 0;
    while (!may_go(light)) {
        light = next(light);
        changes += 1;
    }
    assert(changes == 1, "red turns green");
    assert(wait_seconds(Light.Amber) == 30, "one arm handles two cases");
    assert(wait_seconds(light) == 0, "a pattern may name the enum");
    return 0;
}
  • Each arm is one or more patterns, separated by commas, then => and a block. A pattern is a case name (Red) or the case qualified by its enum (Light.Red).
  • A pattern for a case that carries values can bind them: Rect(w, h) declares w and h in its arm, holding the case’s values in order. The bindings are const. _ skips a value. A pattern lists all of its case’s values or, written without parentheses (Rect), none. Each value can also be a pattern of its own (see Nested patterns).
  • Patterns that share an arm must bind the same names, with the same types (Rect(w, _), Square(w)).
  • An else => { ... } arm, last, handles every case the other arms do not.
  • The arms must handle every case. A missing case is an error that names it, located at match; the language server offers a quick fix that adds an empty arm for each missing case. An else that can never run, because the other arms handle every case, is a warning.
  • Handling a case twice is an error.
  • In a match statement, break, continue and return inside an arm act on the enclosing loop or function, as they would inside an if.

The value is evaluated once, before any arm runs. A match on a null value of an enum whose cases carry values panics with match on a null 'Name'; a value enum is never null.

match expressions, is, and literal patterns

# `match` produces a value, `is` tests one case, and a `match` on an
# integer, character, `bool` or `String` compares with literals.
enum Shape {
    Circle(radius: int),
    Rect(width: int, height: int),
    Point,
}

enum List {
    Empty,
    Cons(head: int, tail: List),
}

area(s: Shape): int {
    return match (s) {
        Circle(r) => 3 * r * r,
        Rect(w, h) => w * h,
        Point => 0,
    };
}

keyword(word: String): int {
    return match (word) {
        "import" => 1,
        "export", "extern" => 2,
        else => 0,
    };
}

# The arms' values are numbers of different types (`0` is an `int`,
# `length` a `ulong`), so the `match` has their common type, `ulong`, and
# converts to the `int` this function returns.
length_of(word: String): int {
    return match (word) {
        "" => 0,
        else => word.length,
    };
}

main(): int {
    var total = area(Shape.Rect(2, 3)) + keyword("extern");

    # `is` binds only in the block of the `if` or `while` it is the
    # condition of.
    var s = Shape.Circle(1);
    if (s is Circle(r)) {
        total += r;
    }

    var list = List.Cons(1, List.Cons(2, List.Empty));
    while (list is Cons(head, tail)) {
        total += head;
        list = tail;
    }

    var point = Shape.Point is Point;
    if (point && !(s is Rect)) {
        total += 100;
    }

    # A `match` on an integer needs an `else`: literals cannot list every
    # value.
    var size = match (total) {
        0 => "none",
        1, 2, 3 => "few",
        else => "many",
    };
    assert(size == "many", "total is past three");
    assert(length_of("four") == 4 && length_of("") == 0, "lengths convert");
    assert(total == 6 + 2 + 1 + 3 + 100, "every step adds up");
    return 0;
}
  • A match that produces a value can go wherever a value can: after var x =, return, in an argument. Each arm is patterns => value, and arms are separated by commas (one after the last is allowed). It follows the rules above: a pattern binds names for its arm’s value, and the arms must handle every case unless there is an else. The value has the first arm’s type, and every other arm’s value must convert to it. When the arms are numbers of different types, the value has their common type, as C’s ? : does (int and long give long), and converts from it wherever it goes (0 or a ulong length, returned as an int). A closure in an arm’s value cannot use a name the arm binds; use a match statement there.
  • value is Pattern is a bool: whether value holds that case (d is Line, !(d is Text)). It takes any one pattern. When it is the whole condition of an if or while, its bindings are declared in that block only: if (d is Text(t)) { ... } reads t inside the braces, and while (list is Cons(head, tail)) { ... } examines list again on every iteration. Anywhere else, is cannot bind names. is is a keyword only between a value and a pattern, so it stays usable as a name (token.is(text)).
  • A trait value is matched by type rather than by case: Circle(c) tests for the struct Circle and binds the value as one. See Converting a trait value back.
  • Literal patterns: a match on an integer, a character, a bool or a String compares with literals (1, -1, 'a', true, "import"), using ==. Literals cannot list every value, so such a match needs an else arm, except on a bool that handles both true and false. Matching the same literal twice is an error. A match on an enum takes its case names, not literals, and a match on int takes literals, not names (except constants).

Nested patterns

The values in a case’s parentheses are patterns, as a tuple pattern’s elements are, so patterns nest to any depth:

# Patterns nest: the values a case carries can be matched by patterns of
# their own, as tuple elements can.
enum Doc {
    Text(text: String),
    Line,
    Pair(left: Doc, right: Doc),
}

describe(found: Optional<Doc>): String {
    return match (found) {
        Empty => "nothing",
        # A literal compares the value: only an empty `Text`.
        Value(Text("")) => "empty text",
        # A case of the value's enum, binding its own values.
        Value(Text(t)) => t,
        Value(Line) => "a line",
        # Deeper still, with `_` for a value that does not matter.
        Value(Pair(Line, _)) => "a pair that starts with a line",
        Value(Pair) => "a pair",
    };
}

main(): int {
    assert(describe(Optional<Doc>.Empty) == "nothing", "no document");
    assert(describe(Optional<Doc>.Value(Doc.Text(""))) == "empty text", "a literal value");
    assert(describe(Optional<Doc>.Value(Doc.Text("hi"))) == "hi", "a bound value");
    assert(
        describe(Optional<Doc>.Value(Doc.Pair(Doc.Line, Doc.Text("x")))) == "a pair that starts with a line",
        "two levels deep"
    );

    # A bare name binds the value, unless the value's enum has a case of
    # that name: here `Empty` is `Optional<int>`'s case.
    var nested = Optional<Optional<int>>.Value(Optional<int>.Empty);
    var depth = match (nested) {
        Empty => 0,
        Value(Empty) => 1,
        Value(Value(n)) => 2 + n,
    };
    assert(depth == 1, "the inner Optional is empty");

    # Ranges and guards work inside too, and so does `is`.
    var count = Optional<int>.Value(7);
    var size = match (count) {
        Value(0) => "none",
        Value(1..10) => "a few",
        Value(n) if n < 0 => "negative",
        Value(_) => "many",
        Empty => "unknown",
    };
    assert(size == "a few", "a range inside a case");
    var pair = Doc.Pair(Doc.Text("left"), Doc.Line);
    if (pair is Pair(Text(word), Line)) {
        assert(word == "left", "is binds nested names");
    }
    return 0;
}
  • Each value takes _, a name, a case of its enum (with patterns of its own values: Value(Pair(Line, _))), a literal, a range or constant, or a tuple pattern.
  • A bare name binds the value, unless the value’s enum has a case of that name, which it then matches (Value(Empty)), or it names a module-scope constant. A name repeated in one pattern (Pair(x, x)) must be a case.
  • A nested pattern handles part of its case: the arms must still handle every value. A case that only nested patterns handle is reported with the value they miss (match on 'Optional<Doc>' does not handle 'Value(Line)'), and a pattern whose values the earlier arms already match is an error.
  • Patterns that share an arm bind the same names, each at the same place: Pair(Text(t), Line), Pair(Text(t), Pair) binds t from either, but Pair(Text(t), _), Pair(_, Text(t)) is an error.
  • A nested test reads a value only once the case around it matched, so the tests stop at the first part that does not match.

Ranges and constants

A match on an integer or a character can compare with a range of values, and a match on an integer, a character or a bool with a module-scope constant:

# Range and constant patterns: a `match` on an integer or a character
# compares with ranges (`1..9`, `'a'..>'z'`) and module-scope constants.
const LIMIT = 100;

size(n: int): String {
    return match (n) {
        0 => "none",
        # `1..10` stops before 10; `10..>99` includes 99.
        1..10 => "a few",
        10..>99 => "many",
        LIMIT => "the limit",
        else => "out of range",
    };
}

kind(c: char): String {
    return match (c) {
        'a'..>'z', 'A'..>'Z' => "letter",
        '0'..>'9' => "digit",
        else => "other",
    };
}

# Ranges that cover every value of the type need no `else`.
half(b: u8): int {
    return match (b) {
        0..128 => 0,
        128..>255 => 1,
    };
}

main(): int {
    assert(size(0) == "none" && size(9) == "a few" && size(10) == "many", "ranges");
    assert(size(100) == "the limit" && size(-1) == "out of range", "a constant, then else");
    assert(kind('q') == "letter" && kind('7') == "digit" && kind('!') == "other", "character ranges");
    u8 high = 200;
    assert(half(3) == 0 && half(high) == 1, "every u8 is handled");

    # In a tuple pattern, a range or a constant compares its element.
    var pair = (LIMIT, true);
    var which = match (pair) {
        (0..LIMIT, _) => 1,
        (LIMIT, true) => 2,
        (_, _) => 3,
    };
    assert(which == 2, "the constant matches the first element");
    return 0;
}
  • start..end matches from start up to but not including end, and start..>end includes end, as ranges do elsewhere. Each bound is a literal (-1, 'z') or a constant (MIN..MAX). Ranges match integers and characters only, not bool or String.
  • A name that is a module-scope constant (const LIMIT = 100;, declared in the file or imported, or limits.MAX through a module) compares the value with it, like a literal. A local variable is not a pattern; compare with it in a guard instead (n if n == limit).
  • Inside a tuple pattern, a range or a constant compares its element ((0..LIMIT, _)), and any other bare name binds it, as before.
  • When every bound is a literal, the compiler knows which values a pattern matches: a pattern whose values the earlier arms already match is an error (2..>5 after 1..9), and a match whose ranges and literals cover every value of its type (0..128 and 128..>255 for a u8) needs no else. A pattern with a constant bound covers nothing for this check.
  • A match on an enum takes its cases, not ranges or constants, even a constant holding one of its cases.

Guards

An arm can add a guard after its patterns, if condition: the arm runs only when one of its patterns matches and the condition holds. Otherwise the next arm is tried, as if this one had not matched.

# A guard (`if condition`) after an arm's patterns: the arm runs only when a
# pattern matches and the guard holds; otherwise the next arm is tried.
enum Shape {
    Circle(radius: int),
    Rect(width: int, height: int),
    Point,
}

describe(s: Shape): String {
    return match (s) {
        # The guard reads the names its patterns bind.
        Circle(r) if r > 10 => "a large circle",
        Circle => "a circle",
        Rect(w, h) if w == h => "a square",
        Rect => "a rectangle",
        Point => "a point",
    };
}

# A guarded arm handles nothing for the check that every value is handled,
# since its guard may fail: the `bool` match below still needs `false`.
toll(heavy: bool, axles: int): int {
    match (heavy) {
        true if axles > 4 => {
            return 30;
        }
        true => {
            return 20;
        }
        false => {
            return 10;
        }
    }
}

main(): int {
    assert(describe(Shape.Circle(20)) == "a large circle", "the guard holds");
    assert(describe(Shape.Circle(2)) == "a circle", "the guard fails, the next arm runs");
    assert(describe(Shape.Rect(3, 3)) == "a square", "equal sides");
    assert(describe(Shape.Rect(3, 4)) == "a rectangle", "unequal sides");
    assert(toll(true, 6) + toll(true, 2) + toll(false, 2) == 60, "each arm runs once");

    # Guards work on tuples too.
    var pair = (5, true);
    var kind = match (pair) {
        (n, true) if n > 3 => 1,
        (_, true) => 2,
        (_, false) => 3,
    };
    assert(kind == 1, "the first arm's guard holds");
    return 0;
}
  • The guard reads the names the arm’s patterns bind (Circle(r) if r > 10). With several patterns (Word(s), Quoted(s) if s.length == 0), it applies to the arm, whichever pattern matched.
  • Guards work in match statements and match expressions, over enums, literals and tuples. is takes no guard: write x is P && condition, or test the condition inside the if.
  • A guarded arm handles nothing when the compiler checks that every case is handled, since its guard may fail: a match whose only arm for Circle has a guard does not handle Circle, and a later unguarded arm (or else) must. An else after guarded arms is not unreachable.
  • A guarded arm for a case an earlier unguarded arm already handles is an error, like any case handled twice. Two guarded arms may name the same case.
  • A closure in a guard cannot use a name the arm binds.

Current limits

  • An enum whose cases carry nothing cannot be generic, by design: its value is a plain integer, so a type parameter would have nothing to describe. An enum whose cases carry values can be (enum List<T>).
  • An enum whose cases carry values compares by identity with ==, as a struct does, unless it is a [Value] enum, is [Equatable], or declares == (with a matching hash<T> to be a key).
  • A constant is a pattern only when it is declared at module scope.
  • A match cannot examine floating-point values.
  • The methods of an enum whose cases carry nothing have no value form: c.name without the call is an error (“‘name’ is a method of ‘Color’: call it as ‘name()’”), where a struct’s p.norm is a bound method. Wrap the call in a closure instead, () => c.name().
  • An enum whose cases carry nothing is extended only with a trait (extend Color with Describe { ... }); declare its other methods inside it. It is never a trait value, since a trait value is a reference to an object, which such an enum’s value is not.

Memory and reference counting

Pepper frees memory for you, without a garbage collector and without ownership annotations. Every struct object keeps a count of the references to it, and the object is destroyed the moment that count drops to zero. Because the rules are simple and the same everywhere, you can always tell when an object will be destroyed and its destructor will run.

If you know C++, the whole model fits in one sentence: every struct value behaves exactly like a std::shared_ptr<T> passed by value.

Values and references

Numbers, bool, and the other primitive types are plain values: assigning one copies it, as in C. So is a tuple, whose elements are copied the way each element would be on its own, and so is a struct or enum marked [Value], such as Optional<T> and Result<T> (see value enums).

Everything else, including your own structs, String, Array, HashMap, and function values, is a reference to an object on the heap. Assigning a reference, passing it to a function, or storing it in a field or an array shares the object. Nothing is copied.

struct Account {
    var balance: int = 0;
}

# The parameter is another reference to the caller's object, so changes to
# its fields are visible to the caller.
deposit(account: Account, amount: int) {
    account.balance += amount;
}

# Rebinding the parameter itself only changes this function's reference.
replace(account: Account) {
    account = Account();
    account.balance = 999;
}

main(): int {
    var mine = Account();
    var yours = mine;               # shares the object; nothing is copied
    yours.balance = 50;
    assert(mine.balance == 50, "assignment shares the object");

    deposit(mine, 25);
    assert(mine.balance == 75, "a function can change the shared object");

    replace(mine);
    assert(mine.balance == 75, "rebinding a parameter does not affect the caller");

    # To get an independent object, make a new one and copy the fields.
    var copy = Account();
    copy.balance = mine.balance;
    copy.balance = 0;
    assert(mine.balance == 75, "the copy is a separate object");
    return 0;
}
  • var yours = mine; makes a second reference to the same Account. Changes through either name are visible through both.
  • A parameter is one more reference to the caller’s object. The function can change the object’s fields, but assigning a new object to the parameter only changes the function’s own reference.
  • To get an independent object, create a new one. Pepper does not copy structs for you; some library types offer a method for it, such as Array’s clone().

A struct-typed variable or field that has not been given a value, such as var next: Account;, is null: it refers to no object. Reading a field or calling a method through a null reference crashes the program. Use === or !== to check whether two references name the same object, or whether one is null (next === __null). A struct is not a condition by itself, so if (next) is an error unless the struct declares the truth test !! (see Truth tests and conditions). For a value that may be absent, prefer Optional<T>, whose type says so.

When objects are destroyed

A reference is held by whatever stores it: a local variable, a parameter, a field, an element of a container, or a temporary value in the middle of an expression. Each of those releases its reference exactly once, when it goes away. When the last reference is released, the object is destroyed: its destructor runs, and then its fields release their own references, which may destroy further objects in turn.

import async;

# A module-scope counter: const, and changed through an Atomic.
const destroyed = async.Atomic<int>(0);

struct Tracked {
    var name: String;
    construct(this.name = "") {}
    destruct {
        destroyed.fetch_add(1);
    }
}

struct Holder {
    var item: Tracked;
}

make(name: String): Tracked {
    var t = Tracked(name);
    return t;                   # the caller receives the object
}

main(): int {
    # 1. A local is released when its scope ends.
    {
        var a = Tracked("a");
    }
    assert(destroyed.load() == 1, "a was destroyed at the end of its block");

    # 2. While another reference exists, the object stays alive.
    var b = Tracked("b");
    var also_b = b;
    b = Tracked("b2");          # b lets go of the first object...
    assert(destroyed.load() == 1, "...but also_b still holds it");
    also_b = b;                 # the last reference moves away
    assert(destroyed.load() == 2, "the first object is destroyed now");

    # 3. Fields and containers hold references too.
    var holder = Holder();
    {
        var c = Tracked("c");
        holder.item = c;
    }
    assert(destroyed.load() == 2, "holder.item keeps c alive after its scope");
    holder = Holder();          # the old Holder dies and releases c
    assert(destroyed.load() == 3, "releasing the holder released its field");

    var list = Array<Tracked>();
    list.append(Tracked("d"));
    assert(destroyed.load() == 3, "the array holds d");
    list.clear();
    assert(destroyed.load() == 4, "clearing the array released d");

    # 4. Returned objects belong to the caller; unused results are
    #    destroyed right away.
    var e = make("e");
    assert(destroyed.load() == 4, "e is alive in main");
    make("temporary");
    assert(destroyed.load() == 5, "a result nobody keeps is destroyed immediately");
    return 0;
}

Concretely, a reference is released when:

  • A local variable goes out of scope, at the closing brace of the block that declared it, or when the function returns.
  • A variable or field is overwritten. b = Tracked("b2"); releases the object b held before, after taking the new one. Assigning a variable to itself (a = a) changes nothing.
  • A parameter’s function returns. The function held its parameters for as long as it ran.
  • The object that holds a field is destroyed, or a container removes an element (clear(), overwriting list[i], and so on). remove_at() and pop() are the exception: they hand the container’s reference to the caller, so var x = list.remove_at(0); leaves the count unchanged.
  • A temporary is no longer needed. A result that is not stored anywhere, like the one from make("temporary");, is released at the end of the statement. The same goes for a call result passed straight to another function, and for the object in make().name.

Returning a local hands its reference to the caller, so the object survives the function. Calling a method on an object does not add a reference to it: inside the method, this refers to the caller’s object.

Cycles leak

Reference counting cannot free a cycle: objects that refer to each other, directly or through a chain, keep each other’s counts above zero even after nothing else refers to them. Their destructors never run and their memory is never freed.

import async;

# A module-scope counter: const, and changed through an Atomic.
const destroyed = async.Atomic<int>(0);

struct Person {
    var name: String;
    var friends: Array<Person>;

    construct(this.name = "") {
        friends = Array<Person>();
    }

    destruct {
        destroyed.fetch_add(1);
    }
}

main(): int {
    # Two people who list each other as friends form a cycle: each keeps
    # the other alive, so neither is ever destroyed.
    {
        var ann = Person("Ann");
        var bob = Person("Bob");
        ann.friends.append(bob);
        bob.friends.append(ann);
    }
    assert(destroyed.load() == 0, "the cycle leaked both objects");

    # Break the cycle before letting go, and both are destroyed.
    {
        var cat = Person("Cat");
        var dan = Person("Dan");
        cat.friends.append(dan);
        dan.friends.append(cat);
        dan.friends.clear();    # Dan no longer holds Cat
    }
    assert(destroyed.load() == 2, "without the cycle, both were destroyed");
    return 0;
}

To avoid a cycle, make one direction of it a weak reference (next section), or break the cycle yourself before the last outside reference goes away: clear the container, or overwrite the field, that closes the loop.

A closure holds a reference to every struct it captures, including this, so a closure stored inside an object it captures closes a cycle too. The compiler reports the direct case, a method storing a closure that captures this in a field of this.

Weak references

A Weak<T> points at an object without keeping it alive. Use it for back pointers: a child’s parent, a document’s folder, an observer’s subject. The owner holds strong references down the tree, and everything that points back up holds a Weak<T>, so there is no cycle.

import async;

# A module-scope counter: const, and changed through an Atomic.
const destroyed = async.Atomic<int>(0);

struct Folder {
    var name: String;
    var files: Array<Document>;

    construct(this.name = "") {
        files = Array<Document>();
    }

    destruct {
        destroyed.fetch_add(1);
    }
}

struct Document {
    var title: String;
    # A weak back pointer: the folder owns its documents, not the other way
    # round, so there is no cycle.
    var folder: Weak<Folder>;

    construct(String title, Folder folder) {
        this.title = title;
        this.folder = Weak<Folder>(folder);
    }

    destruct {
        destroyed.fetch_add(1);
    }

    String path() {
        with (var f = folder) {
            return f.name + "/" + title;
        } else {
            return "(deleted)/" + title;
        }
        return "";
    }
}

main(): int {
    var kept = Document("draft", Folder("none"));
    {
        var home = Folder("home");
        var notes = Document("notes", home);
        home.files.append(notes);
        assert(notes.path() == "home/notes", "the folder is alive");
        kept = notes;
    }
    # The folder went away with `home`; the weak pointer did not keep it.
    assert(kept.path() == "(deleted)/notes", "the folder is gone");
    assert(kept.folder.expired(), "and the weak reference knows it");
    return 0;
}
  • Weak<T>(object) makes the weak reference. It does not change the object’s reference count.
  • A with block reaches the object: with (var f = folder) { ... } binds f to an ordinary strong reference for the length of the block. When the object has already been destroyed, the body is skipped and the else block runs. See Scoped blocks with with.
  • expired() reports whether the object has been destroyed.
  • T may be a trait: Weak<Shape> upgrades to a Shape (see Weak references and atomics).
  • The object is destroyed on time. When its last strong reference goes, its destructor runs and its fields are released, even if weak references remain. Only the object’s small header stays allocated until the last weak reference is gone.

In C++ terms, a struct value is a std::shared_ptr<T> and Weak<T> wraps the built-in __weak<T>, a std::weak_ptr<T>: Weak<T>(object) is weak_ptr(object), and entering it with with is lock(). Inside a constructor, this already holds the object’s first reference, like the shared_ptr that make_shared fills in: a constructor may pass this to a function or store it, and a weak reference made from it upgrades, without the object being destroyed when those copies are dropped.

References and threads

Reference counts are plain, fast increments, so a struct’s objects must stay on one thread. A struct declared [Shared] has atomic counts instead, and its references can be copied and dropped on any thread. The compiler requires such a struct’s fields to be const and thread-safe. See What threads can share.

Function values follow the same split: a plain function type such as () => void has plain counts and stays on one thread, and a shared function type such as shared () => void has atomic counts.

Copying a value with copy

copy(value) makes a copy of a value that shares no object that is not thread-safe with the original, so the copy can go to another thread: pass copy(x) to a channel to send a value you keep using.

var names = Array<String>();
names.append("ada");
var copied = copy(names);
copied[0].append(" lovelace");   # names[0] is still "ada"
  • Every struct object the value reaches is copied once. An object referenced twice is copied once and stays shared inside the copy, and a cycle becomes the same cycle among the copies.
  • Thread-safe objects are not copied: a [Shared] struct or a shared function value in the original is the same object in the copy. For a thread-safe value, copy returns the value itself.
  • A weak reference to an object that was copied points at its copy. A weak reference to an object outside the copied value becomes null.
  • A struct is copied field by field, without running its constructor.

A struct with a destructor or a raw pointer (__ptr<T>) field is not trivially copyable: copying its fields would share what it owns, such as a buffer it frees. Such a struct says how to copy itself by implementing Copyable<T>, with a copy(): T method that builds the copy and copies each value it holds with copy(value). String, Array, HashMap and HashSet implement it. String.copy() also copies a single String; it replaces the deprecated clone(). Array.clone() is unchanged: it copies the Array but shares its elements.

# A struct with a destructor is not trivially copyable, so it implements
# `Copyable<T>` to say how `copy` copies it.
struct Buffer {
    __ptr<u8> data;
    ulong size;
    String label;

    construct(size: ulong) {
        data = _pep_malloc(size);
        this.size = size;
        label = "buffer";
    }

    destruct {
        _pep_free(data);
    }

    copy(): Buffer {
        var made = Buffer(size);
        var i: ulong = 0;
        while (i < size) {
            made.data[i] = data[i];
            i = i + 1;
        }
        # The free function `copy`: it continues the running copy.
        made.label = copy(label);
        return made;
    }
}

extend Buffer with Copyable<Buffer>;

struct Message {
    Buffer body;
    Buffer again;
}

main(): int {
    var message = Message();
    message.body = Buffer(4);
    message.body.data[0] = 9;
    message.again = message.body;

    var copied = copy(message);
    copied.body.data[0] = 1;
    copied.body.label.append(" (copy)");
    assert(message.body.data[0] == 9, "the original buffer is unchanged");
    assert(message.body.label == "buffer", "the original label is unchanged");
    # The buffer appears twice, so its copy is one object.
    assert(copied.again === copied.body, "one copy of the shared buffer");
    return 0;
}

Inside copy(), the call copy(label) is the free function, not the method: a bare call in a method names the struct’s own method only when one by that name takes those arguments. While a copy runs, copy(value) continues it rather than starting another, so an object the outer copy already made is reused and sharing is kept. That is why a Copyable value is copied with copy(x): calling x.copy() directly still works, but each copy(value) inside it is then a copy of its own. A value whose contents can lead back to it calls register_copy(this, made) before copying them, so the cycle finds the copy being built.

The copy(copies: Copies): T form of Copyable<T>, which took the copy’s context as an argument and copied with copies.copy(value), still works in this release, with a warning at Copies.

Copying a value that reaches a struct that is not trivially copyable and does not implement Copyable<T> is an error at the copy call. It names the struct, shows each field on the way to it, says why it cannot be copied, and shows a Copyable<T> to fill in (the editor offers to insert it):

main.pep:14:13: error: cannot copy 'Log' because it contains 'Handle', which is not trivially copyable
   14 |     var c = copy(log);
      |             ^~~~
main.pep:9:5: note: 'Log' reaches it through field 'out'
    9 |     Handle out;
      |     ^~~~~~
main.pep:1:8: note: 'Handle' has a destructor, so copying its fields would release what it owns twice
    1 | struct Handle {
      |        ^~~~~~
note: implement 'Copyable<Handle>' to say how to copy it:
    extend Handle with Copyable<Handle> {
        copy(): Handle {
            # Build the copy; copy each struct field with copy(field).
            panic("copying 'Handle' is not implemented");
            return this;
        }
    }

Some types cannot be copied at all: an io.File owns an operating-system handle, so the error says to send its path instead. A plain function value cannot be copied, because it may capture anything; declare it shared.

Copying trait values

copy of a trait value copies the struct behind it, as copy of that struct would, and returns the copy as the same trait type. Sharing works across the boundary: a struct reached twice, through a trait value or directly, is copied once.

# `copy` of a trait value copies the struct behind it, whichever it is.
trait Shape {
    area(): int;
    grow();
}

struct Square {
    var side: int = 1;
    construct(this.side = 1) {}
}

extend Square with Shape {
    area(): int { return side * side; }
    grow() { side = side + 1; }
}

struct Rect {
    var width: int = 1;
    var height: int = 1;
    construct(this.width = 1, this.height = 1) {}
}

extend Rect with Shape {
    area(): int { return width * height; }
    grow() { width = width + 1; }
}

main(): int {
    var shapes = Array<Shape>();
    var square = Square(2);
    shapes.append(square);
    shapes.append(Rect(2, 3));
    shapes.append(square);

    var copied = copy(shapes);
    for (var shape in copied) {
        shape.grow();
    }
    assert(shapes[0].area() == 4, "the original Square is unchanged");
    assert(shapes[1].area() == 6, "the original Rect is unchanged");

    # The Square appears twice, so its copy is one object, grown twice.
    assert(copied[0] == copied[2], "one copy of the shared Square");
    assert(copied[0].area() == 16, "the copied Square grew twice");
    assert(copied[1].area() == 9, "the copied Rect grew once");
    return 0;
}

Which struct a trait value holds is known only when the program runs, so the compiler cannot check that it can be copied. Copying a trait value that holds a struct that cannot be copied (one with a destructor and no Copyable<T>, an io.File, a MutexGuard) panics:

cannot copy the 'Owner' behind a trait value, which is not trivially copyable

Handing a reference over with move

move(x) gives up x’s reference: the call returns the same object, and x is left null. Nothing is copied and no count changes. It is how a value built in a local variable goes to a channel, a Mutex or another [Move] parameter, which takes a new value, without the cost of copy(x):

var lines = Array<String>();
lines.append("first");
var log = async.Mutex<Array<String>>(move(lines));   # lines is now null

move is an ordinary function from the prelude (runtime/copy.pep), but the compiler checks every call:

  • Only a local variable can be moved (move(h.s) is an error), and only when it holds the one reference to a new value: nothing else refers to it or to anything it reaches. A parameter can be moved only when it is [Move], since the caller handed its value over.

    main.pep:8:24: error: 'a' cannot be moved: 'b' also references its value
        8 |     names.replace(move(a));
          |                        ^
    note: only a variable that holds the one reference to a new value can hand it over; pass 'copy(...)' instead
    
  • A moved variable cannot be read again until it is assigned a new value, including on the next pass through a loop:

    main.pep:8:12: error: 'a' is used after its value was moved
        8 |     return a.count;
          |            ^
    main.pep:7:19: note: 'a' was moved here; assign it a new value before using it again
        7 |     names.replace(move(a));
          |                   ^~~~
    

The runtime also moves values out of raw memory (move(buffer[head])), which is a promise the compiler cannot check: only runtime files may do it. The parameter of move is marked [MoveSource], which is also only allowed in the runtime, so a hand-off always shows at the call as move(x); a function of your own that takes over its argument uses [Move].

Where objects live

Every object, String buffer and Array buffer comes from the runtime’s allocator. A block of up to 128 bytes (most objects and short strings) comes from a small block allocator: blocks are grouped by size into classes (multiples of 16 bytes up to 128, then four classes per power of two). Each class’s blocks are cut from 64 KiB spans, and a thread hands out a class’s blocks from one span at a time, so objects made one after another sit close together in memory. Allocating, and freeing on the thread that allocated, take no lock; a block freed on another thread goes back to its span with one atomic step. Larger blocks come from the C library’s calloc and go back with free. A small block’s size is rounded up to its class, so a 40-byte object takes 48 bytes.

An empty Array allocates nothing. Array<T>() makes no object until the Array is written (append, insert, reserve, …) or shared (assigned to a second variable, stored in a field or element, passed or returned): reading it (count, is_empty, contains, a for loop over it) allocates nothing. The first write or share allocates the object, so every name for the Array still sees the same appends:

struct Node {
    String name;
    # Most nodes never get children, and an empty Array costs nothing.
    Array<Node> children = Array<Node>();
    construct(name: String) {
        this.name = name;
    }
}

main(): int {
    var a = Array<int>();           # no allocation
    assert(a.count == 0 && a.is_empty, "reading allocates nothing");
    for (var x in a) {
        assert(false, "an empty Array has no elements");
    }
    var b = a;                      # allocates the Array: a and b share it
    b.append(1);                    # allocates its buffer
    assert(a.count == 1, "both names see the append");

    var leaf = Node("leaf");
    var root = Node("root");
    root.children.append(leaf);
    assert(leaf.children.is_empty && root.children.count == 1, "only root's children were allocated");
    return 0;
}

Structs that hold an Array which usually stays empty (a tree node’s children, a list of comments) cost nothing extra for it.

The environment variable PEPPER_SMALL_BLOCK_LIMIT, read when the program starts, sets the largest size the small block allocator serves, in bytes, up to 4096 (a larger value counts as 4096). 0 turns it off, and then every block comes from the C library:

PEPPER_SMALL_BLOCK_LIMIT=0 ./main      # every block from calloc
PEPPER_SMALL_BLOCK_LIMIT=512 ./main    # blocks up to 512 bytes from the classes

With it off, a program can use another C allocator, such as mimalloc, by loading it in front of the C library (LD_PRELOAD=libmimalloc.so on Linux).

Small blocks live in address space the program reserves at start (4 GiB, committed 64 KiB at a time as it is used, and never returned to the operating system); a program that fills it falls back to calloc. On WebAssembly, which cannot reserve address space, every block comes from calloc.

Summary

CodeWhat happens to references
var b = a;b takes a new reference to a’s object.
b = c;b takes a reference to c’s object and holds it, then releases its old one. A destructor that runs then and reads b finds c’s object.
f(a)The parameter takes a reference for the duration of the call.
return a;The caller receives a reference to a’s object.
a.method()No change: this refers to a’s object.
f(move(a))No change: a’s reference goes to the call, and a is left null.
make(); (result unused)The result is released at the end of the statement.
var w = Weak<T>(a);No change to a’s count; w does not keep the object alive.
var c = copy(a);c refers to new objects; a’s count does not change.
end of a blockEvery local declared in the block releases its reference.

Generics

Generics let you write a struct or a function once and use it with many types. Add type parameters in angle brackets after the name, then use them like any other type name. The standard library’s Array<T>, HashMap<K, V>, and Result<TValue, TError> are all ordinary generic types written this way (Result is a generic enum).

Pepper generics work like C++ templates: each distinct set of type arguments, such as Stack<int> or Stack<String>, produces its own copy of the code, compiled for those exact types. There is no runtime cost for using them.

Generic structs

# `T` is a type parameter: each use such as `Stack<int>` or
# `Stack<String>` makes a separate, fully typed version of the struct.
struct Stack<T> {
    var items: Array<T>;

    construct() {
        items = Array<T>();
    }

    push(item: T) {
        items.append(item);
    }

    pop(): T {
        return items.pop();
    }

    peek(): T {
        # `top` is inferred as `T`: an `int` in `Stack<int>`, a `String`
        # in `Stack<String>`.
        var top = items.last();
        return top;
    }

    is_empty(): bool {
        return items.is_empty;
    }
}

# Several parameters are separated by commas.
struct Entry<K, V> {
    var key: K;
    var value: V;
    construct(K key, V value) {
        this.key = key;
        this.value = value;
    }
}

main(): int {
    var numbers = Stack<int>();
    numbers.push(1);
    numbers.push(2);
    assert(numbers.peek() == 2, "peek looks at the top");
    assert(numbers.pop() == 2, "last in, first out");
    assert(numbers.pop() == 1, "then the first");
    assert(numbers.is_empty(), "empty again");

    var words = Stack<String>();
    words.push("hello");
    assert(words.pop() == "hello", "the same code works for Strings");

    var e = Entry<String, int>("answer", 42);
    assert(e.key == "answer" && e.value == 42, "two type parameters");
    return 0;
}
  • Name the type parameters after the struct’s name: struct Stack<T>, struct Entry<K, V>. Inside the struct, fields, parameters, return types, and locals can all use them.
  • Name the type arguments whenever you use the struct: Stack<int>(), var s: Stack<String>;.
  • Stack<int> and Stack<String> are different types.
  • A type argument is a type. The one exception is a number, which only the lane count of a SIMD vector accepts (Vector<int, 4>); a struct of your own cannot compute with a number parameter.
  • Each type argument makes a separate copy of the struct, so a struct whose copy needs a copy with a larger argument never ends: struct Foo<T> with a field var inner: Foo<Array<T>> makes Foo<int> need Foo<Array<int>>, which needs Foo<Array<Array<int>>>, and so on. The compiler stops 64 levels deep with an error at the type that asks for the next level ('Foo' is specialized 65 levels deep, past the limit of 64), with a note where the chain started.

Generic functions

A free function takes type parameters the same way:

larger<T>(a: T, b: T): T {
    if (a > b) {
        return a;
    }
    return b;
}

struct Circle {
    var radius: int = 1;
    area(): int { return 3 * radius * radius; }
}

struct Square {
    var side: int = 2;
    area(): int { return side * side; }
}

# The body can use anything the type argument provides: this works for
# any T that has an `area()` method returning a number.
total_area<T>(shapes: Array<T>): int {
    var sum = 0;
    for (var shape in shapes) {
        sum += shape.area();
    }
    return sum;
}

# `T()` calls the type argument's constructor.
fresh<T>(): Array<T> {
    var items = Array<T>();
    items.append(T());
    return items;
}

# A closure in generic code is compiled with each use too: for `T = int`
# it calls `larger<int>`.
larger_later<T>(a: T, b: T): () => T {
    return () => larger(a, b);
}

main(): int {
    # Type arguments come from the arguments, or can be written out.
    assert(larger(3, 9) == 9, "ints");
    assert(larger<float>(2.5, 1.5) == 2.5, "floats");

    var circles = Array<Circle>();
    circles.append(Circle());
    circles.append(Circle());
    assert(total_area(circles) == 6, "circles");

    var squares = Array<Square>();
    squares.append(Square());
    assert(total_area<Square>(squares) == 4, "squares");
    assert(total_area(fresh<Circle>()) == 3, "a constructed Circle");
    var pick = larger_later(4, 7);
    assert(pick() == 7, "a closure calling a generic function");
    return 0;
}

Pepper infers the type arguments from the arguments, so larger(3, 9) calls larger<int>. You can always write them out, larger<float>(2.5, 1.5), or write only the leading ones and let the arguments give the rest: pick<int>(1, "x") for pick<T, U>(a: T, b: U). You must write them when the arguments do not determine every type parameter, for example one that appears only in the return type:

main.pep:4:13: error: cannot infer the type arguments of 'make' from its arguments; write them: make<T>(...)
    4 |     var b = make();
      |             ^~~~
main.pep:1:1: note: declared here
    1 | make<T>(): Box<T> { return Box<T>(); }
      | ^~~~

A generic function used as a value, not called, takes its type arguments from the function type it is given: var f: (int, int) => int = larger; is larger<int> (see Function types).

A parameter whose type is a trait takes its type arguments from the trait the argument’s type declares: given total<T>(items: Iterator<T>), the call total(Countdown(4)) infers T = int from extend Countdown with Iterator<int>.

Inference works through closures in both directions. In transform(words, (w) => w.size), where transform<T, U>(items: Array<T>, f: (T) => U), words gives T, which types the closure’s parameter w, and the closure’s result gives U.

A closure inside a generic function or method is compiled with each use of it, so the generic calls in its body take their type arguments from that use: in larger_later<T>(a: T, b: T) above, () => larger(a, b) calls larger<int> when T is int.

Because each use is compiled for its concrete types, the body may use any operator, field, or method that the type arguments provide. total_area<T> calls shape.area(), so it works with any T that has an area() method returning a number, and using it with a type that lacks one is a compile error inside total_area. To state the requirement up front, and have the compiler report it where the function is used, give T a constraint.

That includes constructing one: T() (or T(args)) calls the type argument’s constructor, so fresh<Circle>() above makes a Circle. In a constrained generic, which offers only what its constraints declare, it is an error.

Generic methods

A method can have type parameters of its own, in a struct, a generic struct, an extension, or a trait’s default method. They are inferred at each call like a function’s, or written after the method name:

struct Box<T> {
    T value;
    construct(T v) { value = v; }

    # A method with a type parameter of its own: `U` is chosen per call.
    map<U>(f: (T) => U): Box<U> {
        return Box<U>(f(value));
    }
}

main(): int {
    var b = Box<int>(4);

    # `U` comes from what the closure returns...
    var tens = b.map((x) => x * 10);
    assert(tens.value == 40, "tens");

    var text = tens.map((x) => "forty");
    assert(text.value == "forty", "text");

    # ...or is written after the method name.
    var wide = b.map<long>((x: int): long => x + 1);
    assert(wide.value == 5, "wide");
    return 0;
}
  • Write the type arguments with no space before < or after >: b.map<long>(f). With a space, a.b < c is a comparison.
  • A generic method cannot be called through a trait value, since each of its uses is compiled separately. Call it on the concrete type.
  • The iterator adapters map, filter, enumerate and zip are generic default methods of Iterator<T>; see Iteration.

Default type arguments

A type parameter can have a default, written Name=Type. When a use leaves that argument out, the default fills it in:

struct Measurement<TValue, TUnit=String> {
    var value: TValue;
    var unit: TUnit;
}

main(): int {
    # `Measurement<float>` fills TUnit with its default, String.
    var length = Measurement<float>();
    length.value = 2.5;
    length.unit = "m";
    assert(length.unit == "m", "TUnit defaulted to String");

    # Any argument can still be given explicitly.
    var count = Measurement<int, int>();
    count.unit = 7;
    assert(count.unit == 7, "TUnit given as int");

    # The standard library's Result uses a default the same way:
    # Result<int> means Result<int, Error>.
    var ok = Result<int>.ok(42);
    assert(ok.value() == 42, "Result with the default error type");
    return 0;
}

Arguments are matched to parameters in order, so put the parameters that have defaults last. The standard library declares enum Result<TValue, TError=Error>, so Result<int> means Result<int, Error>. See Error handling with Result.

Constraints

A type parameter can require a trait: write the trait after a colon, T: Shape. Every type argument for T must then conform to Shape. Several constraints are joined with &, and a constrained parameter can still have a default, written after them:

trait Shape {
    area(): int;
}

trait Named {
    name(): String;
}

struct Circle {
    var radius: int = 1;
    area(): int { return 3 * radius * radius; }
}

extend Circle with Shape;

struct Square {
    var side: int = 2;
    area(): int { return side * side; }
    name(): String { return "square"; }
}

extend Square with Shape;
extend Square with Named;

# Every T must conform to Shape.
total_area<T: Shape>(shapes: Array<T>): int {
    var sum = 0;
    for (var shape in shapes) {
        sum += shape.area();
    }
    return sum;
}

# Several constraints are joined with `&`.
label<T: Shape & Named>(item: T): String { return item.name(); }

# A constrained parameter can have a default, which must conform too.
struct Gallery<TTag, TShape: Shape = Square> {
    var items = Array<TShape>();
    add(item: TShape) { items.append(item); }
    total(): int { return total_area(items); }
}

main(): int {
    var circles = Array<Circle>();
    circles.append(Circle());
    circles.append(Circle());
    assert(total_area(circles) == 6, "circles");
    assert(label(Square()) == "square", "label");

    # Gallery<String> is Gallery<String, Square>.
    var gallery = Gallery<String>();
    gallery.add(Square());
    assert(gallery.total() == 4, "default");

    # A trait type conforms to its own trait.
    var shapes = Array<Shape>();
    shapes.append(Circle());
    shapes.append(Square());
    assert(total_area(shapes) == 7, "trait values");
    return 0;
}

Constraints work on generic structs, enums whose cases carry values, functions, methods, traits and type aliases. A constraint may name the parameters before it in the same list: sum<T, I: Iterator<T>>(values: I).

A type argument conforms to a trait when:

  • it is a struct declared to have the trait with extend Circle with Shape. For a generic struct, extend Box<T> with Shape makes every Box<X> conform, and extend Box<int> with Shape only Box<int>. A generic trait must match exactly: extend Counter with Iterator<int> conforms to Iterator<int>, not to Iterator<long>. Having the right methods is not enough; the extension is what declares it.
  • it is the trait type itself (Array<Shape> passed as Array<T> with T: Shape), as long as every member of the trait can be called through a value of the trait type (see traits as types).
  • it is a type parameter constrained by that trait: inside wrap<T: Shape>, Gallery<String, T> is allowed.

A number type, char, bool or an enum whose cases carry no values conforms when it declares the trait the same way (extend int with Shape; see numbers, bool and enums), for constraints only: it never becomes a trait value. Pointers, tuples and function types conform to no trait.

The Shared constraint

T: Shared is built in: it requires a type that is safe to share between threads, by the same rule as a field of a [Shared] struct. The types that conform are listed in What threads can share; no extend can make another type conform. It gives T no methods, and it joins other constraints with & (T: Shared & Named):

import async;

[Shared]
struct Settings {
    const workers: int;
    construct(this.workers = 1) {}
}

# A cell that any thread may read: its value must be thread-safe.
[Shared]
struct Cell<T: Shared> {
    const value: T;
    construct(value: T) { this.value = value; }
}

share<T: Shared>(value: T): Cell<T> { return Cell<T>(value); }

main(): int {
    var count = share(3);
    var settings = share(Settings(4));
    assert(count.value == 3 and settings.value.workers == 4, "shared cells");

    # The runtime states the same rule: spawn<T: Shared>.
    async.run(() => {
        var task = async.spawn<Settings>(() => { return Settings(2); });
        assert(task.join().value().workers == 2, "spawned");
    });
    return 0;
}

The standard library states the rule this way where it applies: spawn<T: Shared>, run_blocking<T: Shared> and Lazy<T: Shared>, so async.spawn<String>(...) is reported at String, with a note saying why it is not thread-safe ('String' is not declared [Shared]). In a constraint, Shared always means this built-in, so a trait cannot be named Shared. A generic that passes its own T to one of these (say run_blocking<T>) needs T: Shared too.

A constraint can name another type parameter of the same declaration: count_all<T, I: Iterator<T>>(source: I). Inference reads it too: the argument gives I, and the Iterator<...> that I declares gives T. A trait’s default method may name the trait’s own parameters (put_all<I: Iterator<T>> in trait Sink<T>), which take the arguments of the conformance (extend Sum with Sink<int>).

# `I`'s constraint names `T`: the argument's type gives `I`, and the
# `Iterator<T>` it declares gives `T`.
count_all<T, I: Iterator<T>>(source: I): int {
    var count = 0;
    while (source.next()) {
        count += 1;
    }
    return count;
}

first<T, I: Iterator<T>>(source: I): T {
    source.next();
    return source.current();
}

main(): int {
    var words = Array<String>();
    words.append("pepper");
    words.append("salt");

    # Both inferred: I = ArrayIterator<String>, T = String.
    assert(count_all(words.iterate()) == 2, "count");
    assert(first(words.iterate()) == "pepper", "first");

    # Written type arguments may stop early; the rest are inferred.
    assert(count_all<String>(words.iterate()) == 2, "partial");
    return 0;
}

The compiler checks every use: a type argument you write, one it infers from a call, a default, and a concrete override. The error points at the type argument, or at the call argument the type came from:

main.pep:11:21: error: 'Circle' does not conform to trait 'Shape', required by 'T' in 'Gallery'
   11 |     var g = Gallery<Circle>();
      |                     ^~~~~~
main.pep:7:19: note: constraint declared here
    7 | struct Gallery<T: Shape> {
      |                   ^~~~~
main.pep:4:8: note: declare it with 'extend Circle with Shape'
    4 | struct Circle {
      |        ^~~~~~

A type parameter passed on to a constrained one must carry the constraint itself: wrap<T>(item: T): Gallery<String, T> is an error that suggests T: Shape. The language server offers both the extension and the constraint as quick fixes.

  • A constraint must name a trait: T: Point, with Point a struct, is an error.
  • An extension of a constrained struct keeps its constraints: extend Gallery<TTag, TShape> needs no TShape: Shape of its own. You may restate them as documentation, extend Gallery<TTag, TShape: Shape>. A constraint the struct does not have makes a constrained extension.
  • In an overload set, a generic member whose inferred type arguments do not meet its constraints is not viable, and the error lists why.
  • A constrained name is always a type parameter, never an override’s type argument: describe<Point: Shape> declares a generic function whose parameter happens to be named Point.

Inside a constrained generic

The compiler also checks the generic itself, once, whether or not anything uses it: inside largest<T: Shape & Ordered>, a value of type T offers exactly what Shape and Ordered declare. It may:

  • call their instance methods and operators (item.area(), best < item),
  • call their static members through the parameter (T.unit()),
  • convert to one of their trait types (var shape: Shape = item;),
  • be stored, passed, returned, and compared with ==, !=, === and !==.
trait Shape {
    area(): int;
    scaled(by: int): this;
    static unit(): this;
}

trait Ordered {
    <(other: this): bool;
}

struct Square {
    var side: int = 1;
    construct(this.side = 1) {}
    area(): int { return side * side; }
    scaled(by: int): Square { return Square(side * by); }
    static unit(): Square { return Square(1); }
    <(other: Square): bool { return side < other.side; }
}

extend Square with Shape;
extend Square with Ordered;

# Inside the body, a T offers what Shape and Ordered declare.
largest<T: Shape & Ordered>(items: Array<T>): T {
    var best = items[0];
    for (var item in items) {
        if (best < item) {
            best = item;
        }
    }
    return best;
}

# Static members are called through the parameter.
unit_area<T: Shape>(): int { return T.unit().area(); }

# A T converts to the trait types of its constraints.
as_shape<T: Shape>(item: T): Shape {
    var shape: Shape = item.scaled(2);
    return shape;
}

main(): int {
    var squares = Array<Square>();
    squares.append(Square(2));
    squares.append(Square(3));
    assert(largest(squares).side == 3, "largest");
    assert(unit_area<Square>() == 1, "static member");
    assert(as_shape(Square(3)).area() == 36, "conversion");
    return 0;
}

Anything else is an error at the member or operator, even before any use: a field, a method no constraint declares, a constructor T(), a call of a constraint’s method with the wrong number or types of arguments, or initializing, assigning or returning a T as another type. (Passing a T to a function that takes another type is still reported in each specialization.)

main.pep:5:45: error: no member named 'perimeter' in 'T'
    5 | total<T: Shape>(item: T): int { return item.perimeter(); }
      |                                             ^~~~~~~~~
main.pep:5:10: note: 'T' is constrained by 'Shape', which has no member 'perimeter'
    5 | total<T: Shape>(item: T): int { return item.perimeter(); }
      |          ^~~~~

A method that two constraints both declare is an error too ('name' is declared by both 'Named' and 'Titled'; call it through one of them).

Each use is still compiled for its type argument, so a call goes straight to that type’s own method, with no trait value in between. A parameter without a constraint keeps working as before: its uses are checked in each specialization. In a list that mixes the two, such as f<T: Shape, U>, only the uses of T are checked in the generic.

Constrained extensions

An extension of a generic struct can add a constraint the struct does not have: extend Box<T: Named> { ... }. Its members exist only on the specializations whose type argument meets it, so Box<Square> has label() and Box<String> does not. With with, the constraint makes a conditional conformance: after extend Box<T: Shape> with Shape, a Box<Square> is a Shape (it can be passed where T: Shape is required and converted to a Shape value), and a Box<String> is not.

trait Shape {
    area(): int;
}

trait Named {
    name(): String;
}

struct Square {
    area(): int { return 4; }
    name(): String { return "square"; }
}

extend Square with Shape;
extend Square with Named;

struct Box<T> {
    var item: T;
    construct(item: T) { this.item = item; }
}

# Only a Box of something Named has `label()`.
extend Box<T: Named> {
    label(): String { return "box of " + item.name(); }
}

# A Box of shapes is itself a shape.
extend Box<T: Shape> with Shape {
    area(): int { return item.area() * 2; }
}

main(): int {
    var box = Box<Square>(Square());
    assert(box.label() == "box of square", "constrained extension");

    Shape shape = box;
    assert(shape.area() == 8, "conditional conformance");

    # Box<String> is still a Box, without `label()` or `area()`.
    var text = Box<String>("plain");
    assert(text.item == "plain", "any Box");
    return 0;
}
  • Every Box<X> keeps the struct’s own members and constraints; an extension adds to them, it never removes any.
  • Inside the extension, T offers the struct’s constraints and the extension’s, as in any constrained generic.
  • A call of a member the specialization does not have names the constraint it misses:
main.pep:20:9: error: no member named 'label' in 'Box<Circle>'
   20 |     box.label();
      |         ^~~~~
main.pep:15:15: note: 'label' requires 'Circle' to conform to 'Named'
   15 | extend Box<T: Named> {
      |               ^~~~~

and a type argument that misses a conditional conformance says so too ('Box<String>' conforms to 'Shape' only when 'String' conforms to 'Shape').

  • Only the struct’s own parameters can carry an extension’s constraints: extend Box<int: Shape> is an error.
  • A conformance that holds for every Box needs its members on every Box: extend Box<T> with Named cannot rest on a name() that only extend Box<T: Named> provides. Make the conformance conditional instead (extend Box<T: Named> with Named).
  • Several extensions may declare the same trait with different constraints; a specialization conforms when it meets any of them.
  • Inside another generic, a call such as b.label() on a Box<T> is checked in each specialization, like any member of a generic struct.

Overriding a generic function for one type

You can give a generic function a separate definition for a specific type argument. Write the concrete type where the type parameter would go:

# The generic version handles every type...
describe<T>(value: T): String {
    return "something";
}

# ...and these definitions replace it for two specific types.
describe<int>(value: int): String {
    return "an int";
}

describe<String>(value: String): String {
    return "a String";
}

main(): int {
    assert(describe<float>(1.5) == "something", "the generic version");
    assert(describe<int>(7) == "an int", "the int override");
    assert(describe<String>("hi") == "a String", "the String override");

    # The standard library's hash<String> is such an override: it hashes
    # a String by its content instead of by identity.
    assert(hash<String>("pepper") == hash<String>("pepper"), "content hash");
    return 0;
}

A call whose type argument matches an override, such as describe<int>(7), uses the override; every other call uses the generic version. The standard library uses this to give hash<String> a content-based hash, while hash<T> hashes other structs by identity; a struct keyed by its content overrides hash<T> the same way (see Hashing and equality).

An override can name a built-in type (int, bool, String, and so on) or a struct, such as describe<Point>. It must be declared next to the generic function or next to one of its type arguments: in the file that declares either, or in another file of the same module. Here main.pep overrides shapes.pep’s describe<T> for its own Point, and the generic code in shapes.pep uses the override too:

# A generic function, and code that calls it for any type.
export describe<T>(v: T): int {
    return 0;
}

export describe_twice<T>(v: T): int {
    return describe<T>(v) * 2;
}
import "shapes";

struct Point {
    var x: int;
    var y: int;
}

# Overrides shapes.pep's describe<T> for Point, from another file.
describe<Point>(p: Point): int {
    return p.x + p.y;
}

main(): int {
    var p = Point();
    p.x = 3;
    p.y = 4;
    assert(describe<Point>(p) == 7, "the override");
    assert(describe_twice<Point>(p) == 14, "generic code in shapes.pep uses it too");
    assert(describe<int>(5) == 0, "other types keep the generic version");

    # The standard library's generics, such as hash<T>, can be overridden
    # for a struct the same way.
    return 0;
}

A function whose type parameters all name types the file can see (its own, the ones it imports, under any name such as P after import [Point as P] from geo, and the standard library’s) is an override when the file can also see a generic function of that name and number of type parameters (its own, an imported one, or the standard library’s); otherwise a struct’s name is just the name of a type parameter. The generic may be spelled by any name the file has for it (show<Point> after import [describe as show]). A type argument must name a struct, not a type alias or a trait. A generic and another function of the same name in one file are overloads, unless they have the same parameters, or the generic’s type parameters all name structs the file can see without a generic of that name to override: either is an error, since it usually means an override whose type argument the file cannot see, or of a generic the file’s own function of that name hides.

This placement rule (Rust calls it the orphan rule) means a file that can see both the generic and the type can also see every override that applies to them, so the compiler can check each file on its own. An override that neither of them is declared beside is an error:

main.pep:2:1: error: cannot override 'hash' for 'int' here: an override must be in the file or module that declares 'hash' or one of its type arguments
    2 | hash<int>(value: int): ulong {
      | ^~~~

The standard library’s generics, such as hash<T> and to_string<T>, can be overridden for your own structs, but not for built-in types: the standard library declares both. Wrap the built-in type in a struct of your own instead.

To add methods to a generic struct from outside its declaration, use an extension.

Reflection and attributes

A program can look at its own types while it compiles: list a struct’s fields, read the attributes written on them, and generate code for each one. This is how one function can turn any struct into JSON, declare a command-line option per field, or compare two values field by field.

The work happens in the compiler, in a static block, so it costs nothing at run time and needs no type information in the program. This is the model of Zig’s comptime and C++’s static reflection, not of Java or C# runtime reflection.

struct Point {
    var x: int;
    var y: int;
    var label: String;
}

# One function describes any struct: the `static` block runs in the
# compiler, once per type `describe` is called with.
describe<T>(value: T): String {
    var out = StringBuilder();
    static (out, value) {
        out.append(reflect.name<T>());
        out.append("(");
        for (var field in reflect.properties<T>()) {
            # `field.name` is a compile-time string; `field.get(value)` reads
            # the field. Both statements use `out`, a captured runtime
            # value, so they are emitted once per field.
            out.append($"{field.name} = {field.get(value)}");
            if (!field.is_last) {
                out.append(", ");
            }
        }
    }
    out.append(")");
    return out.to_string();
}

main(): int {
    var p = Point();
    p.x = 3;
    p.y = 4;
    p.label = "corner";
    assert(describe(p) == "Point(x = 3, y = 4, label = corner)", "one line per field, in declaration order");
    return 0;
}

static blocks

A static block is written inside a function. It names, in parentheses, the runtime values it may emit code against: its capture list.

static (out, value) {
    ...
}

static { ... } with no list is pure compile-time work, such as a check with reflect.assert.

Each statement in the block either runs in the compiler or is emitted into the function:

  • A statement that uses a captured name, or a runtime local the block declares, is emitted. Its compile-time parts are filled in first: out.append(field.name) emits out.append("x").
  • Everything else runs in the compiler: var declarations whose value uses no captured name, if on a compile-time condition (only the branch taken is kept), for over a compile-time list, which is unrolled, one copy of its body per element, and while on a compile-time condition, which emits its body once per iteration.
  • An if whose condition uses a captured name is emitted as a runtime if; its branches can still hold compile-time steps.

A var with a captured name in its value is a runtime local, and using it counts as using a capture. Using a runtime value that is not captured is an error at that name:

main.pep:9:24: error: 'total' is a runtime value; add it to the static block's capture list

Each pass of an unrolled for or while, and the branch an if keeps, is its own block, like the body of a real loop.

A closure in an emitted statement is an ordinary closure: it captures runtime locals as any closure does. Its body cannot use a compile-time value such as field.name, since the closure outlives the block’s expansion; copy the value into a runtime local first, and the closure captures that local:

static (options, calls) {
    for (var field in reflect.fields<Options>()) {
        var text = $"{field.get(options)}";
        calls.append(() => text);
    }
}

Compile-time loops

break and continue in a compile-time for or while run in the compiler: continue drops the rest of the pass, and break ends the unrolling. They cannot sit in a runtime if, whose condition the compiler does not know:

main.pep:10:17: error: 'break' in a runtime 'if' cannot leave a compile-time loop; the condition is not known while compiling

A while that runs 100,000 times is taken to be endless and stops the compile at its while.

struct Row {
    var id: int;
    var _cache: int;
    var name: String;
    var score: int;
    var notes: String;
}

# The sum of the public `int` fields of `Row` before 'notes': `continue`
# and `break` steer the unrolled `for`.
sum_leading_ints(row: Row): int {
    var total = 0;
    static (total, row) {
        for (var field in reflect.fields<Row>()) {
            if (field.name.starts_with("_")) {
                continue;
            }
            if (field.name == "notes") {
                break;
            }
            if (reflect.is_same<field.Type, int>()) {
                total += field.get(row);
            }
        }
    }
    return total;
}

# A compile-time `while` emits its body once per iteration: here, the
# first four powers of three, as four additions.
powers_of_three(): int {
    var sum = 0;
    static (sum) {
        var power = 1;
        while (power < 30) {
            sum += power;
            power = power * 3;
        }
    }
    return sum;
}

main(): int {
    var row = Row();
    row.id = 2;
    row.score = 40;
    assert(sum_leading_ints(row) == 42, "id and score; _cache is skipped");
    assert(powers_of_three() == 1 + 3 + 9 + 27, "one addition per iteration");
    return 0;
}

In generic code

In a generic function, a static block runs once for each specialization, when its type arguments are known: describe<Point> and describe<Line> each get their own expansion. Code the block emits can call other generic functions, such as describe(field.get(value)) for a field that is itself a struct, and those are specialized in turn. A mistake in a block is reported when some specialization uses it, with the pass it happened in and, when a generic asked for that specialization, a note at each use that led to it ('dump<Point>' is used by 'main' here):

main.pep:14:13: error: 'print' cannot be called at compile time
main.pep:12:27: note: in pass 1 of 2 (member 'x')
note: a 'static' block can call the 'reflect' functions and the methods of what they return; calling other functions at compile time is not supported yet
main.pep:10:1: note: in the static block of 'dump<Point>'

At module scope

A static block outside any function runs once, while compiling. It has nothing to capture and emits no code; instead, each const declared at the top of the block becomes a module-scope constant holding what the block computed. Its other vars, and anything declared in its loops and branches, stay in the compiler. export static exports its constants, and attributes written above the block ([Target(...)]) apply to them too.

export struct Pixel {
    var red: u8;
    var green: u8;
    var blue: u8;
    var alpha: u8;
}

export enum Channel {
    Red,
    Green,
    Blue,
    Alpha
}

# A module-scope `static` block runs once, while compiling. Each `const` at
# its top becomes a module-scope constant holding what the block computed;
# `export static` exports them.
export static {
    var bytes = 0;
    for (var field in reflect.properties<Pixel>()) {
        reflect.assert(reflect.is_same<field.Type, u8>(), $"'{field.name}' must be one byte");
        bytes += 1;
    }
    const PIXEL_BYTES = bytes;
    const LAST_CHANNEL = reflect.cases<Channel>()[reflect.cases<Channel>().count - 1].get();
}
import "sizes";

main(): int {
    assert(PIXEL_BYTES == 4, "one byte per field of Pixel");
    assert(LAST_CHANNEL == Channel.Alpha, "the last case of Channel");
    return 0;
}

A constant takes a number, bool, character or enum case; a module-scope variable cannot be a String, since it must be thread-safe. At module scope, a case’s get() is the case itself, and a statement that would emit code is an error:

main.pep:7:9: error: a module-scope 'static' block cannot emit code; declare a 'const' with what it computes

What the compiler can evaluate

Compile-time values are integers, floating-point numbers, bool, strings, enum cases, and what the reflect functions return: members, cases, functions, lists and attributes. Expressions can use literals, the arithmetic, comparison and logical operators, + on strings, interpolated strings ($"{field.name}: "), ?:, assignment to a compile-time var (n += 1), and indexing a list. A string has length, is_empty, starts_with, ends_with and contains; a list has count and is_empty.

Emitted code can use a compile-time number, bool, string or enum case; the compiler writes it as a literal.

Seeing what a block does

A static block runs inside the compiler, so a print in it would be emitted into the program instead of showing anything while compiling. These show what the block computes:

  • reflect.print(value) writes value as a note each time the compiler runs it, on every compile, including one that succeeds. In an unrolled loop each note says which pass printed it:

    main.pep:9:17: note: static print: "describing Point"
        9 |         reflect.print($"describing {reflect.name<T>()}");
          |                 ^~~~~
    main.pep:11:21: note: static print: "x"
       11 |             reflect.print(field.name);
          |                     ^~~~~
    main.pep:10:27: note: in pass 1 of 2 (member 'x')
       10 |         for (var field in reflect.properties<T>()) {
          |                           ^~~~~~~~~~~~~~~~~~~~~~~
    
  • reflect.assert(condition, message) and reflect.error(message) stop the compile at that point with message, which can be an interpolated string holding the values that matter (reflect.assert(n < 8, $"{reflect.name<T>()} has {n} fields")). Like every error in a block, it carries the pass and, in generic code, the specialization it happened in (see In generic code).

  • The emitted code runs like code written by hand. Each statement a block emits keeps the source line of the statement it came from, so a runtime print it emits shows values as the program runs, and a debugger breakpoint on that line (with -g) stops there, in every pass. Stepping goes through an unrolled loop pass by pass, stopping on the for line and then on the body, and the debugger shows the compile-time variables, such as the loop’s field (see Stepping through a static block).

  • --emit-static writes the program’s source with each block replaced by what it emits (see the next section).

  • --emit-llvm writes the program’s LLVM IR into the build directory (.pep/build/<name>.ll), where the function a block expanded in shows exactly what it emitted, one copy per specialization (describe<Point>). --emit-ast prints the program as parsed, before any block runs, so it does not show the expansion.

Seeing what a static block emits

pepper build main.pep --emit-static writes a copy of the program with each static block replaced by the statements it emits, the way a C compiler’s -E shows the preprocessed source. Nothing is compiled; the copy goes into main.static/ (or the folder -o names) and builds into the same program. For the describe example at the top of this page, the copy keeps describe<T> as written and adds what it is for Point:

# 'describe' specialized for T = Point, with its static blocks expanded
describe<Point>(value: Point): String {
    var out = StringBuilder();
    # static (out, value) expanded from reflection-basics.pep:11:5 for T = Point
    {
        out.append("Point");
        out.append("(");
        {
            out.append($"x = {value.x}");
            {
                out.append(", ");
            }
        }
        {
            out.append($"y = {value.y}");
            {
                out.append(", ");
            }
        }
        {
            out.append($"label = {value.label}");
        }
    }
    # end static
    out.append(")");
    return out.to_string();
}
  • Each pass of an unrolled for or while is a block of its own, one after another; a compile-time if leaves only the branch it took; the compile-time vars and steps are gone.
  • Compile-time values are written as literals: field.name becomes "x" (inside an interpolated string, its text), field.Type the type, field.get(value) value.x, case.matches(v) v is Shape.Circle, and function.call() a call by name. A case’s value, item.get(v), becomes a match that reads it.
  • Everything else in the file is copied byte for byte, comments included. --emit-static=keep also keeps each block as # comments above its expansion.
  • A block in a generic function expands differently for each type, so the copy adds a concrete override per specialization the program uses, as above. A block in a method of a generic struct, or in a generic method, stays as written, with each specialization’s expansion above it as comments.
  • An error in the emitted code shows when the copy is built, at the line of the copy that has it.
  • To debug the program in the expanded source, build it with -g --debug-static=expanded: the copy is written beside the program as main.static.pep, and the debugger steps through it (see Stepping through a static block). In a method of a generic struct or a generic method, where the copy shows the expansion only as comments, the debugger shows the block as written.

pep-build lists the details.

The reflect functions

reflect is built in: a static block uses it without an import. The reference has an entry for each function and for the members, cases and functions they return.

FunctionReturns
reflect.name<T>()the type’s name, as written ("Box<int>")
reflect.properties<T>()the public fields and properties of a struct, in declaration order
reflect.fields<T>()every field of a struct, private ones and a property’s storage included; only in the file that declares T
reflect.cases<E>()an enum’s cases, in order
reflect.functions_with<A>()every function in the program that carries attribute A
reflect.is_struct<T>(), is_enum<T>(), is_trait<T>()what kind of type T is
reflect.is_reference<T>()whether T is a reference type, whose slots can be null: a struct or an enum whose cases carry values, unless it is [Value]
reflect.is_same<A, B>()whether A and B are the same type (int and i32 are)
reflect.has_attribute<T, A>(), reflect.attribute<T, A>()attribute A on struct or enum T
reflect.has_build_option("NAME")whether the build defines build option NAME (-D NAME)
reflect.build_option("NAME"), reflect.build_option("NAME", default)the build option’s value as a String; without a default, an error when the build does not define it
reflect.print(value)writes value as a note while compiling (see Seeing what a block does)
reflect.error(message)fails the compile with message
reflect.assert(condition, message)fails the compile with message when condition is false

fields exists for code a struct’s author writes, such as a field-by-field ==; anywhere else it is an error, which points at properties:

main.pep:4:24: error: 'fields' of 'Account' is only available in the file that declares 'Account'; use 'properties'

A member (from properties or fields) has:

  • name, index, is_last, is_const, is_property
  • type_name: its type as a string; field.Type is the type itself, usable wherever a type is written: var copy: field.Type = ..., reflect.is_same<field.Type, String>(), parse<field.Type>(text). This works for a compile-time var holding a member too (var first = case.values[0]; gives first.Type).
  • has_attribute<A>() and attribute<A>()
  • get(value) and set(value, x), which emit value.name and value.name = x

A case (from cases) has:

  • name, value (its integer value, or the tag of a case that can carry values), index, is_last, has_attribute<A>(), attribute<A>()
  • values: the values the case carries, as members, in order; empty for a case that carries none. A value’s get(v) reads it from v, which must hold this case; set is an error, since a case’s values are fixed.
  • matches(v), which emits a test of whether v holds this case
  • get(values...), which emits the case made with those values: case.get() for a case that carries none, case.get(r) for Circle(radius: int)

A function (from functions_with) has name, has_attribute<A>(), attribute<A>(), and call(arguments...), which emits a direct call.

enum Level {
    Low,
    High = 10,
}

struct Settings {
    var name: String;
    var retries: int;
    var verbose: bool;
    var level: Level;
}

# A compile-time `if` keeps only the branch it takes, so each field is
# written by the code that fits its type.
render<T>(value: T): String {
    var out = StringBuilder();
    static (out, value) {
        for (var field in reflect.properties<T>()) {
            out.append($"{field.name}=");
            if (reflect.is_same<field.Type, String>()) {
                out.append($"\"{field.get(value)}\"");
            } else if (reflect.is_enum<field.Type>()) {
                out.append(level_name(field.get(value)));
            } else {
                out.append($"{field.get(value)}");
            }
            out.append(";");
        }
    }
    return out.to_string();
}

# `reflect.cases<E>()` lists an enum's cases; `case.get()` is the case
# itself in emitted code.
level_name(level: Level): String {
    var name = "";
    static (level, name) {
        for (var case in reflect.cases<Level>()) {
            if (level == case.get()) {
                name = case.name;
            }
        }
    }
    return name;
}

# `field.set(value, x)` assigns a field.
reset_counts<T>(value: T) {
    static (value) {
        for (var field in reflect.properties<T>()) {
            if (reflect.is_same<field.Type, int>()) {
                field.set(value, 0);
            }
        }
    }
}

main(): int {
    var settings = Settings();
    settings.name = "fast";
    settings.retries = 3;
    settings.level = Level.High;
    assert(
        render(settings) == "name=\"fast\";retries=3;verbose=false;level=High;",
        "each field is written by the code for its type"
    );
    reset_counts(settings);
    assert(settings.retries == 0, "set assigns the int fields");
    return 0;
}
struct Account {
    var owner: String;
    var _balance: int;

    construct(this.owner = "", this._balance = 0) {}

    # `reflect.fields<T>()` lists private fields too, so it is available
    # only in the file that declares `T`: code its author writes, like this
    # field-by-field `==`.
    ==(other: Account): bool {
        var same = true;
        static (same, other) {
            for (var field in reflect.fields<Account>()) {
                same = same && field.get(this) == field.get(other);
            }
        }
        return same;
    }
}

main(): int {
    assert(Account("ann", 5) == Account("ann", 5), "equal fields compare equal");
    assert(!(Account("ann", 5) == Account("ann", 6)), "a private field takes part");
    return 0;
}
enum Shape {
    Circle(radius: int),
    Rect(width: int, height: int),
    Point,
}

# `case.matches(value)` tests which case a value holds, and each of the
# case's `values` reads its value with `get`.
describe<E>(value: E): String {
    var out = "";
    static (out, value) {
        for (var case in reflect.cases<E>()) {
            if (case.matches(value)) {
                out = case.name;
                for (var item in case.values) {
                    out = out + $" {item.name}={item.get(value)}";
                }
            }
        }
    }
    return out;
}

# `case.get(values...)` makes a case; here, each value is `size`.
square<E>(name: String, size: int): E {
    var out: E;
    static (out, name, size) {
        for (var case in reflect.cases<E>()) {
            if (name == case.name) {
                if (case.values.count == 0) {
                    out = case.get();
                } else if (case.values.count == 1) {
                    out = case.get(size);
                } else if (case.values.count == 2) {
                    out = case.get(size, size);
                }
            }
        }
    }
    return out;
}

main(): int {
    assert(describe(Shape.Rect(3, 4)) == "Rect width=3 height=4", "a case's values");
    assert(describe(Shape.Point) == "Point", "a case without values");
    assert(describe(square<Shape>("Rect", 5)) == "Rect width=5 height=5", "a made case");
    assert(describe(square<Shape>("Circle", 2)) == "Circle radius=2", "one value");
    return 0;
}

Attributes

An attribute is a struct marked [Attribute]. Writing it above a declaration records a compile-time value of that struct, which has_attribute<A>() tests for and attribute<A>() returns.

# An attribute is a struct marked `[Attribute]`. Its fields hold what a
# use of it says; a field with an initializer may be left out.
[Attribute]
struct Name {
    const value: String;
}

[Attribute]
struct Ignore {}

struct User {
    [Name("user_id")]
    var id: int;
    var email: String;
    [Ignore]
    var session: int;
}

# The keys a struct's fields are written under: each field's `[Name]` if
# it has one, else the field's own name, skipping `[Ignore]`d fields.
keys<T>(): String[] {
    var keys: String[] = [];
    static (keys) {
        for (var field in reflect.properties<T>()) {
            if (!field.has_attribute<Ignore>()) {
                var key = field.name;
                if (field.has_attribute<Name>()) {
                    key = field.attribute<Name>().value;
                }
                keys.append(key);
            }
        }
    }
    return keys;
}

main(): int {
    var found = keys<User>();
    assert(found.count == 2, "the [Ignore]d field is skipped");
    assert(found[0] == "user_id", "[Name] renames the key");
    assert(found[1] == "email", "a field without [Name] keeps its name");
    return 0;
}
  • Fields of an attribute struct are numbers, bool, char, String, or enums whose cases carry no values.
  • Arguments are constants: literals, a negated number, an enum case (Level.High), or strings joined with +. They can be named: [Check(weight = 3)].
  • Without a constructor, the arguments set the fields in order, and a field with an initializer may be left out. A constructor may take this.field parameters, or parameters its body assigns to fields (this.long_name = long_name;), which is all its body may do.
  • An attribute from another module can be named through it, with or without arguments: [serialize.Ignore], [serialize.Name("key")].
  • An attribute that names neither a built-in attribute nor an [Attribute] struct is an error, at its name: unknown attribute 'Optoin'.
  • Built-in attributes (Target, Shared, Deprecated, …) are [Attribute] structs too, declared in the runtime (attributes), so a block reads them like its own: function.attribute<Deprecated>().message. Target holds its condition as written ("!OS(Windows)"). The compiler still checks and acts on each one itself.
struct Settings {
    var width: int;
    [Deprecated("use 'width' instead", "width")]
    var size: int;
}

# Lists the deprecated fields of `T` with what replaces each, read from the
# built-in `Deprecated` attribute like any `[Attribute]` struct.
deprecated_fields<T>(): String {
    var out = "";
    static (out) {
        for (var field in reflect.properties<T>()) {
            if (field.has_attribute<Deprecated>()) {
                out = out + $"{field.name} -> {field.attribute<Deprecated>().replacement};";
            }
        }
    }
    return out;
}

main(): int {
    assert(deprecated_fields<Settings>() == "size -> width;", "one deprecated field");
    return 0;
}

Attributes can go on structs, enums and their cases, fields, functions, methods, constructors, parameters and module-scope variables.

[Attribute]
struct Check {
    const weight: int = 1;
}

[Check]
check_addition(): bool {
    return 1 + 1 == 2;
}

[Check(weight = 3)]
check_strings(): bool {
    return "ab" + "c" == "abc";
}

# `reflect.functions_with<A>()` lists every function in the program that
# carries `A`; `check.call()` calls it directly.
run_checks(): int {
    var score = 0;
    static (score) {
        for (var check in reflect.functions_with<Check>()) {
            if (check.call()) {
                score += check.attribute<Check>().weight;
            }
        }
    }
    return score;
}

main(): int {
    assert(run_checks() == 4, "each [Check] function runs once, weighted");
    return 0;
}

Not supported yet

  • Calling functions other than the reflect functions at compile time.
  • Reflecting methods and constructors (reflect.methods<T>()).
  • Limiting where an attribute may be used ([AttributeTargets(...)]).
  • Tracing the blocks the compiler runs.
  • Type information at run time.

Type aliases

A type alias gives an existing type another name. Use one to shorten a long type that you write often, such as HashMap<String, Array<int>>, or to say what a value means, such as Score instead of int.

Declaring an alias

Write type, the new name, =, and the type it stands for:

# An alias is another name for an existing type.
type Score = int;
type Names = Array<String>;
type Handler = (int) => int;

struct Player {
    var name: String;
    var best: Score = 0;        # aliases work in fields...
}

type Team = Player;

double_it(x: int): int {
    return x * 2;
}

# ...and in parameters and return types.
apply(h: Handler, x: Score): Score {
    return h(x);
}

main(): int {
    var s: Score = 21;
    var plain: int = s;         # Score and int are the same type
    assert(apply(double_it, plain) == 42, "function type alias");

    var names = Names();        # construct through the alias
    names.append("Ada");
    assert(names.count == 1, "Names is Array<String>");

    var p = Team();
    p.best = s;
    var q: Player = p;          # Team and Player are the same type
    assert(q.best == 21, "struct alias");
    return 0;
}

An alias is only a spelling, not a new type. The compiler replaces every use of the alias with its target before it checks anything else, so Score and int are interchangeable: you can assign one to the other, pass one where the other is expected, and error messages name the target type.

You can use an alias anywhere a type can appear: local and module-level variables, parameters, return types, struct fields, generic arguments, and casts. An alias of a struct also works where the struct’s name would: to construct it (Team()) and to call its static methods.

Generic aliases

An alias can have type parameters of its own, including defaults, just like a generic struct:

# Generic aliases take their own type parameters.
type Predicate<T> = (T) => bool;
type Lookup<V> = HashMap<String, V>;
type Outcome<T, E=Error> = Result<T, E>;

is_even(x: int): bool {
    return x % 2 == 0;
}

count_matching(values: Array<int>, test: Predicate<int>): int {
    var n = 0;
    for (var v in values) {
        if (test(v)) {
            n += 1;
        }
    }
    return n;
}

parse_digit(text: String): Outcome<int> {
    if (text == "7") {
        return Outcome<int>.ok(7);
    }
    return Outcome<int>.err(Error("not a digit I know"));
}

main(): int {
    var values = Array<int>();
    values.append(1);
    values.append(2);
    values.append(4);
    assert(count_matching(values, is_even) == 2, "Predicate<int> is (int) => bool");

    var ages = Lookup<int>();   # HashMap<String, int>
    ages.insert("Ada", 36);
    assert(ages.contains("Ada"), "Lookup<int> is HashMap<String, int>");

    # Outcome<int> fills E with its default: Result<int, Error>.
    assert(parse_digit("7").value() == 7, "ok");
    assert(parse_digit("x").is_err(), "err");
    return 0;
}

The arguments at the use site fill the alias’s parameters in order, and omitted ones take their defaults. Outcome<int> becomes Result<int, Error>, and Lookup<int> becomes HashMap<String, int>. Aliases are especially handy for function types, which are long to write out: Predicate<int> reads better than (int) => bool.

Aliases across files

Like other top-level declarations, an alias is private to the file (or module) that declares it. Mark it export to let files that import it use it; see Visibility and Modules and imports.

Recursive aliases

An alias must not refer to itself, directly or through other aliases. The compiler reports the cycle at the alias, with a note at each alias it passes through:

type Celsius = Degrees;
type Degrees = Celsius;
main.pep:1:6: error: type alias 'Celsius' refers to itself
    1 | type Celsius = Degrees;
      |      ^~~~~~~
main.pep:2:6: note: through type alias 'Degrees', declared here
    2 | type Degrees = Celsius;
      |      ^~~~~~~

Traits and extensions

Pepper has no inheritance. Instead, you add behavior to a type with an extension, and describe a set of behavior that several types share with a trait. Extensions can be written anywhere, so you can add methods to a library type, such as String, from your own code.

A trait is also a type: a value of type Shape holds any struct, or enum whose cases carry values, that has the Shape trait, and calls its methods (see Traits as types).

Extensions

An extend block adds methods to a struct declared elsewhere, whether in the same file, another file, or the standard library:

struct Temperature {
    var celsius: float = 0.0;
    construct(this.celsius = 0.0) {}
}

# `extend` adds methods to a struct declared elsewhere.
extend Temperature {
    fahrenheit(): float {
        return celsius * 9.0 / 5.0 + 32.0;
    }

    static freezing(): Temperature {
        return Temperature(0.0);
    }

    bool ==(Temperature other) {
        return celsius == other.celsius;
    }
}

# Library types can be extended too.
extend String {
    exclaim(): String {
        var s = String();
        s.append(this);
        s.append("!");
        return s;
    }
}

# One specialization of a generic library type can be extended, and its
# methods can loop over `this`.
extend Array<int> {
    total(): int {
        var sum = 0;
        for (var x in this) {
            sum += x;
        }
        return sum;
    }
}

struct Box<T> {
    var value: T;
}

# Extending the generic struct adds the method to every Box<...>.
extend Box<T> {
    get(): T {
        return value;
    }
}

main(): int {
    var t = Temperature(100.0);
    assert(t.fahrenheit() == 212.0, "extension method");
    assert(Temperature.freezing() == Temperature(0.0), "static method and operator from an extension");

    assert("hello".exclaim() == "hello!", "String extension");

    var numbers = Array<int>();
    numbers.append(2);
    numbers.append(3);
    assert(numbers.total() == 5, "Array<int> extension loops over this");

    var b = Box<int>();
    b.value = 5;
    assert(b.get() == 5, "Box<int>");
    var s = Box<String>();
    s.value = "five";
    assert(s.get() == "five", "Box<String>");
    return 0;
}
  • An extension can hold anything a struct body can hold except fields, a constructor, and a destructor: methods, static methods, operators, and indexers.
  • Methods in an extension behave exactly like methods declared in the struct. They can use this and name the struct’s fields directly, and they are called the same way.
  • A type can have any number of extensions.
  • An extension method may share its name with the struct’s methods if its parameter types differ: it joins their overload set.
  • An extension in another file is usable only where that file (or its module) is imported, and only if it is exported; the standard library’s extensions are usable everywhere. See Visibility.

Extending generic structs

extend Box<T>, using the struct’s own type parameter name, adds the methods to every specialization: Box<int>, Box<String>, and so on. The example above does this.

When Box constrains its parameter (struct Box<T: Shape>), the extension keeps the constraint without restating it; extend Box<T: Shape> may restate it as documentation (see constraints). A constraint the struct does not have limits the extension to the specializations that meet it: extend Box<T: Named> { ... } adds its methods to Box<Square> but not to Box<String>, and extend Box<T: Shape> with Shape makes a Box of shapes a shape (see constrained extensions).

extend Box<int> targets one specialization: its methods exist only on Box<int>, and calling one on a Box<String> is an error (no member named 'doubled' in 'Box<String>').

A library type’s specialization can be extended the same way. The example’s extend Array<int> adds total(), which loops over this with for.

An extension cannot add fields; declare every field in the struct itself.

Traits

A trait names a set of methods. Declare one with trait and method signatures ending in ;. Then declare that a type has the trait with extend Type with Trait, and provide the methods in that extension:

# A trait names a set of methods that a type provides.
trait Shape {
    area(): int;
    name(): String;

    # A method with a body is a default: every type with the trait gets it.
    describe(): String {
        return "a " + name();
    }
}

struct Square {
    var side: int = 1;
    construct(this.side = 1) {}
}

# `extend ... with` declares that Square has the Shape trait, and adds the
# trait's methods to it.
extend Square with Shape {
    area(): int {
        return side * side;
    }

    name(): String {
        return "square";
    }
}

# A trait can be generic, and can declare static methods; `this` as a type
# stands for the type that has the trait.
trait From<T> {
    static this from(T value);
}

extend Square with From<int> {
    static Square from(int side) {
        return Square(side);
    }
}

# A trait whose methods all have defaults needs no body: end the extension
# with `;`, the same as writing an empty `{}`.
trait Labeled {
    label(): String {
        return "shape";
    }
}

extend Square with Labeled;

main(): int {
    var sq = Square(3);
    assert(sq.area() == 9, "trait method called on the struct");
    assert(sq.name() == "square", "second trait method");
    assert(sq.describe() == "a square", "default method from the trait");
    assert(Square.from(4).area() == 16, "static method from a generic trait");
    assert(sq.label() == "shape", "default method from a bodiless extension");
    return 0;
}
  • A trait can have type parameters, like From<T>.
  • A trait can declare static methods. In a trait, this used as a type stands for the type that has the trait.
  • extend Square with Shape, Printable { ... } attaches several traits at once.
  • When the type already has every method the traits need, from its own methods or the traits’ defaults, the extension can end with ; instead of a body: extend Square with Labeled; is the same as extend Square with Labeled {}. Only an extension with a with clause can leave out its body; pep-fmt writes an empty one as ;.
  • A trait can declare several methods of one name with different parameter types; the type implements each with the method whose parameter types match (see Overloading).
  • The methods in the extension are ordinary methods of the struct, so you call them on a Square as usual.
  • A generic parameter can require a trait, total_area<T: Shape>, so that only types declared to have it are accepted (see constraints).
  • The runtime declares traits too. ToString (to_string(): String) makes a type usable in an interpolated string.

The compiler checks that the type has every method the trait declares, either in the extension or in the struct itself. A missing method is an error at the extension, with a note at the trait’s method:

main.pep:9:8: error: 'Square' does not implement 'area', required by trait 'Shape'
    9 | extend Square with Shape {
      |        ^~~~~~
main.pep:2:5: note: 'area' declared here
    2 |     area(): int;
      |     ^~~~

A method that implements a trait should name its parameters as the trait does, since a call through a value of the trait type names its arguments with the trait’s names. A different name is a warning, whose fix renames the parameter and its uses:

main.pep:8:10: warning: parameter 'factor' of 'area' is named 'scale' in trait 'Shape'
    8 |     area(factor: int): int { return side * side * factor; }
      |          ^~~~~~
main.pep:2:5: note: 'area' declared here
    2 |     area(scale: int): int;
      |     ^~~~

Default method bodies

A trait method can have a body. A type that has the trait and does not define that method gets the trait’s body as its own method; a type that defines the method keeps its own. Inside a default body, the trait’s type parameters and this stand for the types named in the with clause:

trait Shape {
    area(): int;
    sides(): int { return 4; }
}

extend Square with Shape {
    area(): int { return side * side; }
}

Here Square(3).sides() returns 4.

A default method can have type parameters of its own (see Generic methods). The runtime’s Iterator<T> uses this for its map<U> and filter adapters, which every type that declares Iterator<T> gets.

A default body sees only what its trait declares: a bare call to one of the trait’s methods calls it on this, and any other bare name means what it means where the trait is declared (a top-level function or variable), even when a conforming type happens to have a method or field of that name. Call through this explicitly only for the trait’s own methods.

A closure in a default body can use this and the trait’s methods too. Each type that gets the default gets its own copy of the closure, which captures this as that type, as a closure in one of its own methods would (see this in methods):

# A closure in a trait's default method can use `this` and the trait's
# methods.
trait Shape {
    area(): int;

    # Every type with Shape gets this method, and its own copy of the
    # closure, which captures `this`.
    scaled_area(): (int) => int {
        return (factor: int) => area() * factor;
    }
}

struct Square {
    var side: int = 1;
    construct(this.side = 1) {}
}

extend Square with Shape {
    area(): int {
        return side * side;
    }
}

main(): int {
    var square = Square(3);
    var scale = square.scaled_area();
    assert(scale(2) == 18, "closure in a default method");

    # The closure keeps the square alive and sees its current side.
    square.side = 4;
    assert(scale(2) == 32, "sees the current value");
    return 0;
}

Numbers, bool and enums

A number type (int, long, byte, char, float, double and the rest), bool, or an enum whose cases carry no values can declare a trait too, with an extension written the same way. Inside its methods, this is the value:

trait Describe {
    describe(): String;
    # A default method works for value types too.
    shout(): String { return describe() + "!"; }
}

trait Zero {
    static zero(): this;
}

# A number type conforms like a struct; `this` is the value.
extend int with Describe {
    describe(): String { return this < 0 ? "negative" : "non-negative"; }
}

extend int with Zero {
    static zero(): int { return 0; }
}

enum Light {
    Red,
    Green,
}

# An enum whose cases carry no values can conform too.
extend Light with Describe {
    describe(): String { return this == Light.Red ? "stop" : "go"; }
}

# Each call is resolved for the type argument: no trait value is made.
describe_all<T: Describe>(items: Array<T>): String {
    var text = "";
    for (var item in items) {
        text = text + item.describe() + " ";
    }
    return text;
}

zero_of<T: Zero>(): T { return T.zero(); }

main(): int {
    var numbers = Array<int>();
    numbers.append(-1);
    numbers.append(2);
    assert(describe_all(numbers) == "negative non-negative ", "numbers");
    assert(Light.Green.shout() == "go!", "enum");
    assert((5).describe() == "non-negative", "a call on a value");
    assert(zero_of<int>() == 0, "a static member through T");
    return 0;
}
  • The type then meets the constraint T: Describe (see constraints), and its methods can be called on a value (x.describe()) or, for a static one, on the type (int.zero(), or T.zero() in a generic). Each call goes straight to the method: there is no object and no vtable.

  • It is never a trait value. A number or such an enum cannot be stored in a Describe variable, passed as one, or added to an Array<Describe>:

    main.pep:7:15: error: 'int' cannot be converted to trait 'Doubled': only structs and enums whose cases carry values can be trait values
        7 | main(): int { Doubled d = 3; return 0; }
          |               ^~~~~~~
    main.pep:4:8: note: 'int' conforms to 'Doubled' here, which makes it meet the constraint, not a trait value
        4 | extend int with Doubled {
          |        ^~~
    
  • An extension of a number type may also add methods without a trait (extend int { ... }), and operators for a number on the left of another type (extend double { *(v: Vector): Vector { ... } } makes 2.0 * v work; see A number on the left). An enum declares its methods inside the enum, so an extension of one must name a trait.

  • The spellings of one type are one type: extend int is also extend i32.

Traits as types

A trait’s name is also a type. A variable, parameter, field, return value, or collection element of type Shape holds any struct, or enum whose cases carry values, that declares Shape, and calling a method on it runs that type’s own method:

# A trait used as a type holds any struct that declares the trait.
trait Shape {
    area(): int;
    name(): String;
}

struct Square {
    var side: int = 1;
    construct(this.side = 1) {}
}

extend Square with Shape {
    area(): int { return side * side; }
    name(): String { return "square"; }
}

struct Rect {
    var width: int = 1;
    var height: int = 1;
    construct(this.width = 1, this.height = 1) {}
}

extend Rect with Shape {
    area(): int { return width * height; }
    name(): String { return "rect"; }
}

# One function accepts every Shape; each call runs the value's own method.
measure(shape: Shape): int {
    return shape.area();
}

main(): int {
    # A Square converts to a Shape wherever a Shape is expected.
    var s: Shape = Square(3);
    assert(measure(s) == 9, "square through a trait parameter");
    assert(s.name() == "square", "method of the concrete type");

    # Collections can hold different types that share a trait.
    var shapes = Array<Shape>();
    shapes.append(Square(2));
    shapes.append(Rect(2, 5));
    var total = 0;
    for (var shape in shapes) {
        total = total + shape.area();
    }
    assert(total == 14, "sum of areas");

    # Two trait values are equal when they refer to the same object.
    var same: Shape = s;
    assert(same == s, "same object");
    return 0;
}
  • Conversion is implicit. A Square becomes a Shape wherever a Shape is expected: an initializer, an assignment, an argument, a return value.

  • Conformance is declared. Only a type that declares the trait with extend Type with Trait converts. Having the right methods is not enough:

    main.pep:11:20: error: 'Circle' does not conform to trait 'Shape'
       11 |     var s: Shape = Circle();
          |                    ^~~~~~
    main.pep:6:8: note: declare it with 'extend Circle with Shape'
        6 | struct Circle {
          |        ^~~~~~
    
  • A trait value is a shared reference to the struct, like any other struct value (see Memory). When the last reference goes away, whether it was a Shape or a Square, the struct’s own destructor runs.

  • Only instance methods are callable through the trait. Static methods, generic methods, and methods whose signature uses this as a type, need the concrete type; calling one through a trait value is an error.

  • Methods must match the trait’s signature. An implementation with different parameter or return types is an error. The one exception is a trait method that returns a trait type: the implementation may return a struct that declares that trait, as iterate() does in Iteration.

  • == and != on trait values compare identity: they are equal when they refer to the same object.

  • copy copies the struct behind a trait value and returns it as the same trait type (see Copying trait values). It panics when that struct cannot be copied.

Converting a trait value back

A trait value remembers the struct it holds, so it converts back to that struct, or to another trait the struct declares. A type pattern names the type, like an enum case, and binds the value as that type:

# A trait value converts back to the struct it holds, or to another trait
# that struct declares, with `is`, `match` and `as?`.
trait Shape {
    area(): int;
}

trait Named {
    name(): String;
}

struct Square {
    var side: int = 1;
    construct(this.side = 1) {}
}

extend Square with Shape {
    area(): int { return side * side; }
}

extend Square with Named {
    name(): String { return "square"; }
}

struct Rect {
    var width: int = 1;
    var height: int = 1;
    construct(this.width = 1, this.height = 1) {}
}

extend Rect with Shape {
    area(): int { return width * height; }
}

# A type pattern tests for a struct and binds the value as that struct.
describe(shape: Shape): String {
    return match (shape) {
        Square(square) => $"a square of side {square.side}",
        Rect(rect) => $"a {rect.width} by {rect.height} rectangle",
        else => "another shape",
    };
}

main(): int {
    var shape: Shape = Square(3);
    assert(describe(shape) == "a square of side 3", "match on the struct");
    assert(describe(Rect(2, 5)) == "a 2 by 5 rectangle", "another struct");

    # `is` tests without converting; as an `if` condition it can bind.
    assert(shape is Square, "a square");
    assert(!(shape is Rect), "not a rectangle");
    if (shape is Square(square)) {
        assert(square.side == 3, "bound as a Square");
    }

    # `as?` gives an optional: Empty when the value is something else.
    var square = shape as? Square;
    assert(square.value().side == 3, "converted to Square");
    var rect = shape as? Rect;
    assert(rect.is_empty(), "not a Rect");

    # Converting to another trait works when the struct declares both.
    var named = shape as? Named;
    assert(named.value().name() == "square", "a Square is Named");
    var other: Shape = Rect(1, 1);
    assert((other as? Named).is_empty(), "a Rect is not Named");
    return 0;
}
  • value is Square tests whether the value holds a Square. As the whole condition of an if or while, value is Square(square) also binds it, as for enums.

  • match (value) { Square(square) => ..., Rect(rect) => ..., else => ... } picks the arm of the value’s struct. Any struct may declare a trait, so a match expression on a trait value needs an else arm.

  • value as? Square converts the value, giving Square?: Empty when the value holds something else, or is null. as without the ? is an error for a trait value, since the conversion can fail.

  • Another trait works the same way: value as? Named and value is Named succeed when the struct behind the value declares Named too.

  • A generic struct is named with its type arguments: Cell<int>(cell).

  • A value struct’s trait value holds a copy of it (see boxing), and converting back copies it out again.

  • The bound value is another reference to the same object, like any copy (see Memory); a test holds no reference after it.

  • Naming a struct that does not declare the trait is an error, since the value can never hold one:

    main.pep:9:18: error: 'Point' does not implement 'Shape', so a 'Shape' value never holds one
        9 |     if (shape is Point) {
          |                  ^~~~~
    

Weak references and atomics

Weak<Shape> is a weak reference to a trait value: it holds the struct’s weak reference and its vtable, and a with block upgrades it to a Shape while a strong reference is left:

# A weak reference can point at a trait value: it keeps the struct behind
# it without keeping it alive.
trait Shape {
    area(): int;
}

struct Square {
    var side: int = 1;
    construct(this.side = 1) {}
}

extend Square with Shape {
    area(): int { return side * side; }
}

main(): int {
    var shape: Shape = Square(3);
    var weak = Weak<Shape>(shape);

    # While a strong reference is left, `with` reaches the value as a Shape.
    with (var live = weak) {
        assert(live.area() == 9, "upgraded to a Shape");
    } else {
        assert(false, "still alive");
    }

    # Once the last strong reference goes, the Square is destroyed.
    shape = __null;
    assert(weak.expired(), "destroyed");
    with (var live = weak) {
        assert(false, "expired: the body is skipped");
    }
    return 0;
}

Atomic<Shape> is ruled out. Atomic<T> needs a thread-safe T, and a trait value may hold any struct, thread-safe or not; it is also two pointers wide, more than one atomic instruction swaps on every platform. The error is the one for any type that is not thread-safe:

main.pep:8:32: error: 'Shape' does not conform to 'Shared', required by 'T' in 'Atomic'
    8 |     var current = async.Atomic<Shape>();
      |                                ^~~~~

To share a trait value between threads, keep it in a Mutex<Shape> and swap it with replace, or use Atomic<Square> for one [Shared] struct type.

Iteration

A for loop visits each element of a collection in turn. You can loop over an Array, over the code points of a String, over a range of numbers (0..count), and over any type of your own that follows the iterator protocol.

Looping over an array

for (var x in source) { ... } runs the body once per element, with x bound to the element:

struct Item {
    int value;
    construct(this.value = 0) {}
}

make_items(): Array<Item> {
    var items = Array<Item>();
    items.append(Item(20));
    items.append(Item(22));
    return items;
}

main(): int {
    var nums = Array<int>();
    nums.append(1);
    nums.append(2);
    nums.append(39);

    # The basic loop: x takes each element in order.
    var total = 0;
    for (var x in nums) {
        total = total + x;
    }
    assert(total == 42, "sum of the elements");

    # The loop variable may be typed, and the parentheses are optional.
    long wide = 0;
    for (long x in nums) {
        wide = wide + x;
    }
    for var x: int in nums {
        wide = wide - x;
    }
    assert(wide == 0, "typed loop variables");

    # break and continue work as in a while loop.
    var odd = 0;
    for (var x in nums) {
        if (x % 2 == 0) {
            continue;
        }
        if (x > 10) {
            break;
        }
        odd = odd + x;
    }
    assert(odd == 1, "only 1 is odd and below 10");

    # A struct element is shared: changing a field changes the element,
    # but rebinding the loop variable does not touch the array.
    var items = make_items();
    for (var item in items) {
        item.value = item.value * 10;
        item = Item(99);
    }
    assert(items[0].value == 200, "first element was updated");
    assert(items[1].value == 220, "second element was updated");

    # Iterating a temporary keeps it alive for the whole loop.
    var sum = 0;
    for (var item in make_items()) {
        sum = sum + item.value;
    }
    assert(sum == 42, "temporary array stays alive");

    # The loop is shorthand for driving the iterator yourself.
    var it = nums.iterate();
    var manual = 0;
    while (it.next()) {
        manual = manual + it.current();
    }
    assert(manual == 42, "the same elements, by hand");

    # Modifying the array after the loop is fine.
    nums.append(100);
    assert(nums.count == 4, "appended after the loop");
    return 0;
}

The example shows each part of the loop:

  • The loop variable can be declared with var or with a type: for (var x in nums), for (long x in nums), and for (var x: int in nums) all work.
  • The parentheses are optional, as with if and while: for var x in nums { ... }. The braces are required.
  • break and continue work as they do in a while loop.
  • The loop variable is a copy, like any var. Assigning a new value to it does not change the collection. For a struct element, though, the copy is another reference to the same object (see Memory and reference counting), so item.value = ... does change the element.
  • A temporary source stays alive. The loop holds a reference to what it iterates, so for (var item in make_items()) is safe.

Looping over a string

A String yields its code points one at a time. Each one is a String holding a single code point, the same value that indexing gives you (word[i]):

main(): int {
    var word = "héllo ✓";

    # A String yields one code point at a time, as a one-code-point String.
    var count = 0;
    var copy = "";
    for (var c in word) {
        assert(c.length == 1, "each piece is one code point");
        copy.append(c);
        count = count + 1;
    }
    assert(count == 7, "7 code points");
    assert(copy == word, "the pieces rebuild the string");

    # Each piece is the same value as indexing the string.
    var i = 0;
    for (var c in word) {
        assert(c == word[i], "same as word[i]");
        i = i + 1;
    }

    # An empty string runs the body zero times.
    for (var c in "") {
        assert(false, "never runs");
    }
    return 0;
}

Looping over a range

a..b is the numbers from a up to, but not including, b, and a..>b includes b. Looping over one is how you count:

main(): int {
    # `a..b` counts from a up to, but not including, b.
    var total = 0;
    for (var i in 0..5) {
        total += i;
    }
    assert(total == 10, "0 + 1 + 2 + 3 + 4");

    # `a..>b` includes b.
    var product = 1;
    for (var i in 1..>5) {
        product *= i;
    }
    assert(product == 120, "5 factorial");

    # The bounds give the type: `count` is a ulong, so i is too.
    var names = ["Ada", "Grace", "Edsger"];
    ulong letters = 0;
    for (var i in 0..names.count) {
        letters += names[i].size;
    }
    assert(letters == 14, "letters in every name");

    # A range that starts past its end is empty.
    for (var i in 3..1) {
        assert(false, "never runs");
    }

    # A range is a value: test membership, or use the iterator adapters.
    var digits = 0..10;
    assert(digits.contains(9) && !digits.contains(10), "half-open");
    var evens = 0;
    for (var even in (1..>10).filter((n) => n % 2 == 0)) {
        evens += even;
    }
    assert(evens == 30, "2 + 4 + 6 + 8 + 10");
    return 0;
}
  • The bounds give the type. A range’s element type is inferred from its bounds like a generic call’s type argument, so 0..names.count counts in ulong. The bounds must be integers; anything else is an error at the operator:

    main.pep:2:22: error: range bounds must be integers, not 'String'
        2 |     for (var c in "a".."z") {
          |                      ^~
    
  • Precedence. A range binds more loosely than arithmetic and shifts, and more tightly than comparisons, so 0..n + 1 is 0..(n + 1). Two range operators in a row (1..2..3) are an error; add parentheses. ... is not a range operator.

  • An empty range runs no iterations. A range whose start is past its end (3..1, 5..>4) is empty, and a..a is too. A closed range ending at its type’s largest value (250..>top with a byte top of 255) stops there without overflowing.

  • A range is a value, a Range<T>. It has contains(x) and the is_empty property, and it is an Iterator<T>, so it has the adapters (map, filter, enumerate, zip) and to_array(). Looping over one allocates nothing; the optimizer turns it into a plain counting loop.

The iterator protocol

The for loop is not tied to Array and String. It works with any type that has the right methods, described by two traits in the runtime (runtime/iterator.pep, documented in the Iterators reference):

export trait Iterator<T> {
    next(): bool;    # advance; true if there is a current element
    current(): T;    # the element next() just moved to
}

export trait Iterable<T> {
    iterate(): Iterator<T>;
}
  • An iterator steps through elements. next() moves to the next element and returns true, or returns false when there are no more (and keeps returning false after that). current() returns the element next() moved to.
  • An iterable is a collection that hands out a fresh iterator from iterate(). Array<T> returns an ArrayIterator<T>, String returns a StringIterator, and HashMap<K, V> and HashSet<K> return a HashMapIterator<K, V> and a HashSetIterator<K>.

for (var x in source) { body } behaves exactly like this:

{
    var it = source.iterate();   # or `var it = source;` when source is itself an iterator
    while (it.next()) {
        var x = it.current();
        { body }
    }
}

The loop chooses by the methods the source’s type has, like a C++ range-based for: it uses iterate() if the type has one, and otherwise uses the source itself as the iterator if it has both next() and current(). A type with neither is a compile error:

main.pep:4:19: error: type 'Foo' is not iterable: implement Iterable<T> (iterate()) or Iterator<T> (next() and current())
    4 |     for (var x in f) {
      |                   ^

HashMap<K, V> yields each entry as a tuple (K, V), so for (var (name, age) in ages) names both parts; HashSet<K> yields its members. Both visit entries in no particular order. Changing a value (ages[name] = … for a key already present) is fine while looping, but adding or removing an entry stops the program at the iterator’s next step.

Adapters

Iterator<T> has default methods that wrap an iterator in another one. Each works lazily, one element at a time as the loop asks for it:

  • map(transform) yields transform(x) for each element x; its element type is whatever transform returns.
  • filter(keep) yields only the elements for which keep(x) is true.
  • enumerate() yields (index, x) tuples, counting from 0 in ulong; enumerate<int>() counts in int instead.
  • zip(other) yields (x, y) pairs, one element from each iterator in step, and stops when either runs out.

They return iterators, so they chain, and a for loop can use the result directly; destructure a tuple to name its parts. to_array() runs an iterator to the end and collects what is left into a new Array:

struct Person {
    String name;
    int age;
    construct(this.name = "", this.age = 0) {}
}

main(): int {
    var people = Array<Person>();
    people.append(Person("Ada", 36));
    people.append(Person("Bo", 12));
    people.append(Person("Cyd", 40));

    # filter keeps the adults, map turns each one into a name.
    var names = Array<String>();
    for (var name in people.iterate().filter((p) => p.age >= 18).map((p) => p.name)) {
        names.append(name);
    }
    assert(names.count == 2, "two adults");
    assert(names[0] == "Ada", "Ada first");
    assert(names[1] == "Cyd", "then Cyd");

    # Adapters work on any Iterator, including a String's.
    var kept = 0;
    for (var c in "hello".iterate().filter((c) => c != "l")) {
        kept += 1;
    }
    assert(kept == 3, "h, e, o");

    # enumerate pairs each element with its position, and zip pairs two
    # iterators in step; destructure the tuple to name both parts.
    var ranked = "";
    for (var (i, person) in people.iterate().enumerate()) {
        ranked += $"{i}:{person.name} ";
    }
    assert(ranked == "0:Ada 1:Bo 2:Cyd ", "numbered from 0");
    var seats = ["window", "aisle"];
    var seated = 0;
    for (var (person, seat) in people.iterate().zip(seats.iterate())) {
        seated += 1;   # Cyd gets no seat: zip stops at the shorter
    }
    assert(seated == 2, "two pairs");

    # to_array collects what an iterator yields.
    var ages = people.iterate().map((p) => p.age).to_array();
    assert(ages.count == 3 && ages[2] == 40, "every age");
    return 0;
}

Every type that declares Iterator<T> gets them: ArrayIterator<T> (from Array.iterate()), StringIterator, ranges, and your own iterators once you write extend YourType with Iterator<T>;. The adapter holds its own copy of the iterator it wraps (the runtime’s iterators are value structs, boxed when they become an Iterator<T>), so a chain built from a temporary stays alive while the loop runs, and it.map(f) and it.to_array() leave it where it was. They are generic methods (see Generics), so they are not callable through an Iterator<T> trait value.

Reading files and directories

Input that can fail partway, such as a directory listing or a file read line by line, yields a Result per element: an IoResult<T> from the io module. Handle each error where it happens; there is nothing to check after the loop.

import [File, Directory, Path] from io;

main(): int {
    var dir = Directory.temp().join("pepper-iteration-streams-example");
    Directory.remove_all(dir);
    assert(Directory.create_all(dir).is_ok(), "created the folder");
    assert(File.write_text(dir.join("notes.txt"), "first\nsecond\r\nthird").is_ok(), "wrote notes");
    assert(File.write_text(dir.join("todo.txt"), "").is_ok(), "wrote todo");

    # A Directory yields its entries one at a time, each an IoResult<DirEntry>.
    var names = 0;
    for (var entry in Directory.open(dir).value()) {
        if (entry.is_err()) {
            print(entry.error().message);
            break;
        }
        assert(entry.value().is_file, "both entries are files");
        names += 1;
    }
    assert(names == 2, "two entries");

    # A File's lines() yields each line, without its line ending.
    var file = File.open(dir.join("notes.txt")).value();
    var lines = Array<String>();
    for (var line in file.lines()) {
        lines.append(line.value());
    }
    assert(lines.count == 3 && lines[1] == "second", "three lines, \\r\\n removed");

    # Adapters work on streams too: number the lines.
    var again = File.open(dir.join("notes.txt")).value();
    var last = "";
    for (var (i, line) in again.lines().enumerate()) {
        last = $"{i}: {line.value()}";
    }
    assert(last == "2: third", "the last line, numbered");

    assert(Directory.remove_all(dir).is_ok(), "cleaned up");
    return 0;
}
  • A Directory is iterable. for (var entry in dir) reads the listing one entry at a time (Directory.iterate() returns a DirectoryIterator), without building an array the way dir.entries() does. Each element is an IoResult<DirEntry>. A directory that cannot be read yields one error and then ends.
  • lines() yields each line of an open File, or of any other io.Reader such as a socket, from what it reads next, as an IoResult<String> without the \n or \r\n. The last line counts even without a line ending. A read error, or a line that is not valid UTF-8, is yielded as an error and ends the lines.
  • chunks(size) yields the rest of a Reader in arrays of at most size bytes, one read each, as IoResult<u8[]>s.
  • An error ends the sequence: after an Err element, next() returns false.
  • listener.incoming() accepts connections on a TcpListener, yielding each one as an IoResult<TcpStream>. It is the exception to the rule above: a failed accept is yielded and the loop goes on, since the next connection may succeed. It ends only once the listener is closed, so a server loop breaks or returns when it is done.
  • Leaving early cleans up. The listing is closed when the loop ends, including by break or return, because the iterator is released then.

Writing your own iterator

To make your own type work in a for loop, give it next() and current() (an iterator), or give it an iterate() method that returns such a type (an iterable):

# An iterator: next() advances, current() returns the element.
struct Countdown {
    int remaining;
    int now;

    construct(this.remaining = 0) {}

    bool next() {
        if (remaining == 0) {
            return false;
        }
        now = remaining;
        remaining = remaining - 1;
        return true;
    }

    int current() {
        return now;
    }
}

# Declaring the trait documents the intent; the loop goes by the methods.
extend Countdown with Iterator<int>;

# An iterable: iterate() hands out a fresh iterator for each loop.
struct Evens {
    int limit;

    construct(this.limit = 0) {}

    EvensIterator iterate() {
        var it = EvensIterator();
        it.limit = limit;
        it.now = -2;
        return it;
    }
}

struct EvensIterator {
    int limit;
    int now;

    bool next() {
        now = now + 2;
        return now < limit;
    }

    int current() {
        return now;
    }
}

extend EvensIterator with Iterator<int>;
extend Evens with Iterable<int>;

# A generic function can loop over any type that supports the protocol.
sum<C>(C values): int {
    var total = 0;
    for (var v in values) {
        total = total + v;
    }
    return total;
}

main(): int {
    var total = 0;
    for (var n in Countdown(3)) {
        total = total + n;
    }
    assert(total == 6, "3 + 2 + 1");

    # A loop over an iterator in a variable uses that object up.
    var c = Countdown(2);
    for (var n in c) {
        total = total + n;
    }
    assert(!c.next(), "the countdown is exhausted");

    # An iterable starts a new pass every time.
    var evens = Evens(10);
    assert(sum<Evens>(evens) == 20, "0 + 2 + 4 + 6 + 8");
    assert(sum<Evens>(evens) == 20, "a second pass sees the same elements");

    var nums = Array<int>();
    nums.append(30);
    nums.append(12);
    assert(sum<Array<int>>(nums) == 42, "arrays work too");
    return 0;
}

Some things to keep in mind:

  • Declaring the trait is optional. The loop looks only at which methods a type has. Writing extend Countdown with Iterator<int>; documents your intent, and the compiler then checks that Countdown has both next() and current() (see Traits and extensions). Declaring it also lets the type be used as an Iterator<int> value, and a for loop over such a value calls the methods through the trait. It also gives the type the adapters (map, filter, enumerate, zip) and to_array().
  • Looping over an iterator uses it up. When the source is itself an iterator stored in a variable, the loop drives that variable in place, so it is exhausted afterwards, and a loop left with break can be resumed by looping over it again. A const variable holding a value iterator cannot be looped over, since next() changes it. An iterable starts a new, independent pass every time you loop over it.
  • Generic code can loop over a type parameter; each specialization checks the protocol for its own type (see Generics).
  • Calling current() out of turn panics in the runtime’s iterators: before the first next() you get ArrayIterator.current() before next(), and after next() returned false you get ArrayIterator.current() past the end. Your own iterators should follow the same rule.

Modifying a collection while you loop over it

Changing an Array or a String while a loop is iterating over it stops the program with a panic, because the iterator could otherwise skip or repeat elements, or read past the end:

var nums = Array<int>();
nums.append(1);
nums.append(2);
for (var x in nums) {
    nums.append(x);   # panics: Array modified during iteration
}

Every method that changes an Array counts, including storing an element with nums[i] = v. For a String, append and grow count, and the message is String modified during iteration. The check happens on the iterator’s next step, so modifying the collection after the loop is finished is fine.

To change elements as you go, loop over an index with while and store through it, or collect the changes in a second array and apply them after the loop.

Loop scope

The loop variable and anything declared in the loop body exist only inside the loop. Each pass gets a new loop variable, and struct values declared in the body are released at the end of every pass, whether the pass ends normally or by continue, break, or return. Leaving the loop early also releases the hidden iterator, so a destructor on a custom iterator runs at that point.

Using the loop variable or a body local after the loop is an error, the same as for any name that is out of scope:

main.pep:6:12: error: use of undeclared identifier 'i'
    6 |     return i;
      |            ^

The loop variable, or a local in the loop body, may have the same name as a variable declared before the loop. It hides the outer variable inside the loop, and the outer variable keeps its value afterwards.

Scoped blocks with with

A with block enters a scope object, binds what it hands out, and cleans up when the object is destroyed. It plays the role of Python’s with and C#’s using, but the cleanup is ordinary destruction: it runs through the same reference counting as every other object (see Memory and reference counting).

import async;

# A module-scope counter: const, and changed through an Atomic.
const open_sessions = async.Atomic<int>(0);

# A scope object: enter() hands out what the block works with, and the
# destructor calls exit() when the last reference goes away.
struct Session {
    var user: String;

    construct(this.user = "") {
        open_sessions.fetch_add(1);
    }

    destruct {
        this.exit();
    }

    Session enter() {
        return this;
    }

    exit() {
        open_sessions.fetch_sub(1);
    }
}

extend Session with Scoped<Session>;

# enter() may return a null reference: then the body is skipped and the
# `else` block runs.
struct Lookup {
    var found: Session;

    construct(Session session) {
        found = session;
    }

    Session enter() {
        return found;
    }

    exit() {}
}

extend Lookup with Scoped<Session>;

first_user(): String {
    with (var session = Session("ada")) {
        # `return` leaves the block too: the session still closes.
        return session.user;
    }
    return "";
}

main(): int {
    with (var session = Session("grace")) {
        assert(open_sessions.load() == 1, "open inside the block");
        assert(session.user == "grace", "bound to what enter() returned");
    }
    assert(open_sessions.load() == 0, "closed at the end of the block");

    assert(first_user() == "ada", "the value was returned");
    assert(open_sessions.load() == 0, "closed on return as well");

    var hits = 0;
    with (var s = Lookup(Session("linus"))) {
        hits += 1;
    } else {
        hits -= 100;
    }
    with (var s = Lookup(__null)) {
        hits -= 100;
    } else {
        hits += 1;
    }
    assert(hits == 2, "the body ran for a session, the else block for null");
    return 0;
}

The header

with (var name = scope) { ... } else { ... }
with (Type name = scope) { ... }
with (scope) { ... }
  • name is bound to scope.enter(), not to scope itself. A Session can hand out itself; a Weak<T> hands out a strong reference to the object it points at, or null once that object is gone.
  • Without a binding, the block still holds the result of enter() until it ends. An enter() that returns nothing (declared without a return type) has nothing to bind, and binding it is an error.
  • The parentheses are optional, as with if, while and for. The braces are required.
  • name cannot be reassigned inside the block.

Skipping the body

When enter() returns a struct reference that is null, the body is skipped and the optional else block runs instead. else is only allowed when enter() returns a struct type, since nothing else can be null.

The Scoped<T> protocol

A type works in a with block when it has the methods of the Scoped<T> trait (runtime/scoped.pep, documented in the Scoped reference):

export trait Scoped<T> {
    enter(): T;  # what the header binds; null skips the body
    exit();      # the cleanup; call it from the destructor
}

A with block goes by the methods a type has, not by the trait: a type without enter() is a compile error in a with header. Declaring the trait (extend Session with Scoped<Session>;) documents the intent, and the compiler then checks that the type has both enter() and exit().

When exit() runs

The block keeps two locals: the scope object and the bound value. Both are released on every path out of the block (the end of the body, return, break and continue), the bound value first. Implementations call exit() from their destructor:

destruct {
    this.exit();
}

so exit() runs when the scope object’s last reference is released:

  • A scope object created in the header (with (var s = Session("grace"))) is released by the block, so exit() runs at the end of the block.
  • A scope object that something else also holds (a field, another local) lives on after the block, and exit() waits for its last owner. This is the same rule as any other object.
  • A copy of the bound value that outlives the block (stored in a field, returned) keeps that value alive, but not the scope object.

A panic stops the program without running destructors, so exit() does not run then.

Error handling with Result

An operation that can fail returns a Result: either Ok, holding the value you asked for, or Err, holding an error that says what went wrong. The caller checks which one it got before using the value. Pepper has no exceptions, so Result is how a function reports a failure the caller is expected to handle. For a failure that should stop the program, such as a broken invariant, use assert or panic instead.

Result and Error are part of the runtime, so you can use them without an import. The Result reference lists every member.

Creating and checking results

Result<TValue, TError> works like Rust’s Result<T, E>. It is an enum with two cases, Ok(value: TValue) and Err(error: TError). You build one with a case or with the static functions ok and err, and read it with match, is, or its methods:

# A function that can fail returns a Result instead of a plain value.
parse_digit(String s): Result<int> {
    if (s == "0") { return Result<int>.ok(0); }
    if (s == "1") { return Result<int>.ok(1); }
    if (s == "2") { return Result<int>.ok(2); }
    return Result<int>.err(Error("not a digit: " + s));
}

main(): int {
    var good = parse_digit("2");
    assert(good.is_ok(), "2 parses");
    assert(!good.is_err(), "is_err is the opposite of is_ok");
    assert(good.value() == 2, "value() returns the value");

    var bad = parse_digit("x");
    assert(bad.is_err(), "x does not parse");
    assert(bad.error().message == "not a digit: x", "the error explains why");

    # ok_or returns the value, or the fallback for an Err. It never panics.
    assert(good.ok_or(-1) == 2, "Ok gives its value");
    assert(bad.ok_or(-1) == -1, "Err gives the fallback");

    # Result<int> is short for Result<int, Error>.
    Result<int, Error> spelled_out = good;
    assert(spelled_out.value() == 2, "the same type");

    # Result is an enum: its cases build values too, and match reads them.
    var parsed = Result<int>.Ok(7);
    var doubled = match (parsed) { Ok(n) => n * 2, Err(e) => 0 };
    assert(doubled == 14, "match binds the value");
    if (bad is Err(e)) {
        assert(e.message.length > 0, "is binds the error");
    }

    # An operation with no value to return uses void.
    var done = Result<void>.ok();
    var failed = Result<void>.err(Error("disk full"));
    assert(done.is_ok() && failed.is_err(), "Result<void> works like any other");
    return 0;
}

The pieces:

MemberWhat it does
Result<T>.Ok(value), Result<T>.ok(value)Creates an Ok result holding value.
Result<T>.Err(error), Result<T>.err(error)Creates an Err result holding error.
is_ok(), is_err()Report which kind the result is.
value()Returns the value; panics with Result.value on an Err value if the result is an Err.
error()Returns the error; panics with Result.error on an Ok value if the result is Ok.
ok_or(fallback)Returns the value, or fallback if the result is an Err. Never panics.

TError defaults to Error, so Result<int> means Result<int, Error>, and values of the two spellings can be assigned to each other freely (the compiler does generate a separate specialization for each spelling). Error is a struct with one field, String message: Error("boom").message is "boom".

Use value() when an Err would be a bug in your program, since it stops the program. When an Err is an expected outcome, match on the result, test it with is (if (r is Err(e)) { ... }) or is_ok(), or supply a fallback with ok_or.

Check before you read

A Result holds either its value or its error, never both. A match or an is test reads the one it holds, so it cannot pick the wrong one. value() and error() panic on the wrong one, so call value() only after checking is_ok(), and error() only after checking is_err().

Result is a value enum: it is stored inline in whatever holds it, so making one allocates nothing, and it is never null. A slot of type Result that is never given a value holds its first case, Ok, carrying the zero value of TValue (0, or null for a struct), so give every result slot a value. Result<int>() is an error, since only the cases make values.

Results with no value

An operation that has nothing to return on success, such as deleting a file, uses void as its value type, like Result<(), E> in Rust. A void type argument erases what it types, so in Result<void> the case Ok carries nothing, ok() takes no argument, and value() returns nothing: calling it only checks that the result is Ok. Using that call as a value, as in var x = done.value();, is a compile error, as it is for any function that returns void.

var done = Result<void>.Ok;   # or Result<void>.ok()
var failed = Result<void>.err(Error("disk full"));
done.value();   # panics if `done` is an Err
match (failed) {
    Ok => {}
    Err(e) => { print(e.message); }
}

Custom error types and propagation

Any type can be the error type. A struct lets you carry details the caller can act on, not just a message. To pass an error up to your own caller, check the result and return early with its error; to change the error’s type on the way, build a new error from the old one:

# A custom error type: any type can be the second type argument.
struct ParseError {
    String input;
    int position;

    construct(this.input = "", this.position = 0) {}
}

digit_value(String c): int {
    var digits = "0123456789";
    var i = 0;
    while (i < 10) {
        if (c == digits[i]) {
            return i;
        }
        i = i + 1;
    }
    return -1;
}

parse_number(String s): Result<int, ParseError> {
    if (s.length == 0) {
        return Result<int, ParseError>.err(ParseError(s, 0));
    }
    var value = 0;
    var position = 0;
    for (var c in s) {
        var d = digit_value(c);
        if (d < 0) {
            return Result<int, ParseError>.err(ParseError(s, position));
        }
        value = value * 10 + d;
        position = position + 1;
    }
    return Result<int, ParseError>.ok(value);
}

# Propagate an error: check it, and return early with the same error.
add_numbers(String a, String b): Result<int, ParseError> {
    var x = parse_number(a);
    if (x.is_err()) {
        return Result<int, ParseError>.err(x.error());
    }
    var y = parse_number(b);
    if (y.is_err()) {
        return Result<int, ParseError>.err(y.error());
    }
    return Result<int, ParseError>.ok(x.value() + y.value());
}

# Convert an error into another type on the way out.
check_total(String a, String b, int limit): Result<void> {
    var sum = add_numbers(a, b);
    if (sum.is_err()) {
        var e = sum.error();
        return Result<void>.err(Error("bad number '" + e.input + "'"));
    }
    if (sum.value() > limit) {
        return Result<void>.err(Error("total is over the limit"));
    }
    return Result<void>.ok();
}

main(): int {
    var sum = add_numbers("40", "2");
    assert(sum.value() == 42, "40 + 2");

    var bad = add_numbers("40", "4x2");
    assert(bad.is_err(), "the second number is bad");
    assert(bad.error().input == "4x2", "the error names the input");
    assert(bad.error().position == 1, "and where it went wrong");

    assert(check_total("1", "2", 10).is_ok(), "3 is within the limit");
    var over = check_total("10", "20", 10);
    assert(over.error().message == "total is over the limit", "30 is not");
    var wrong = check_total("1", "", 10);
    assert(wrong.error().message == "bad number ''", "an empty number is an error");
    return 0;
}

The ? operator, below, writes each of these checks in one character.

Propagating errors with ?

? after a Result passes its error up, like Rust’s ?: on an Ok result it is the value, and on an Err result it returns that error from the enclosing function at once. var x = parse(s)?; does what the if (x.is_err()) { return ...err(x.error()); } checks above do:

# `?` propagates a failure: on an Err it returns that error from the
# enclosing function, and on an Ok it is the value.
struct ParseError {
    String input;

    construct(this.input = "") {}
}

parse_number(text: String): Result<int, ParseError> {
    if (text.is_empty) {
        return Result<int, ParseError>.err(ParseError(text));
    }
    var value = 0;
    for (var c in text) {
        var digit = "0123456789".find(c);
        if (digit < 0) {
            return Result<int, ParseError>.err(ParseError(text));
        }
        value = value * 10 + (digit as int);
    }
    return Result<int, ParseError>.ok(value);
}

# Each `?` returns early with the error, so the sum is computed only when
# both numbers parse.
add_numbers(a: String, b: String): Result<int, ParseError> {
    return Result<int, ParseError>.ok(parse_number(a)? + parse_number(b)?);
}

# A function with a different error type: the `[Implicit]` constructor
# converts a ParseError on its way out.
struct ConfigError {
    String message;

    [Implicit]
    construct(e: ParseError) {
        this.message = "bad number '" + e.input + "'";
    }
}

read_limit(text: String): Result<int, ConfigError> {
    var limit = parse_number(text)?;
    if (limit == 0) {
        return Result<int, ConfigError>.err(ConfigError(ParseError(text)));
    }
    return Result<int, ConfigError>.ok(limit);
}

first_name(names: Array<String>): String? {
    if (names.is_empty) {
        return Optional<String>.Empty;
    }
    return Optional<String>.Value(names[0]);
}

# On an Optional, `?` returns an Empty optional from a function that returns
# one.
first_length(names: Array<String>): ulong? {
    return Optional<ulong>.Value(first_name(names)?.length);
}

main(): int {
    assert(add_numbers("40", "2").value() == 42, "40 + 2");
    assert(add_numbers("40", "4x2").error().input == "4x2", "the error is the second number's");

    assert(read_limit("10").value() == 10, "a limit");
    assert(read_limit("ten").error().message == "bad number 'ten'", "converted to a ConfigError");

    assert(first_length(["Ada", "Grace"]).value() == 3, "Ada");
    assert(first_length(Array<String>()).is_empty(), "no names");
    return 0;
}

The rules:

  • The enclosing function must declare that it returns a Result, or the compiler reports '?' requires the enclosing function to return a Result. A closure that uses ? writes its return type out: (s: String): Result<int> => Result<int>.ok(parse(s)? + 1).
  • The error type must be the function’s error type, or convert to it through an [Implicit] constructor (see Implicit conversions), as ConfigError does above, like Rust’s From. Otherwise the error is cannot convert error type 'ParseError' to 'ConfigError'.
  • On a Result<void>, ? yields nothing, so it stands as a statement: remove(path)?;.
  • On an Optional, ? works the same way in a function that returns an optional: an Empty optional returns Empty (see Optional values).
  • The early return is an ordinary return: locals are released and with blocks are exited as for any other.

Each ? in a statement runs before the rest of the statement, so two placements are errors:

  • After a call in the same statement, as in f() + g()?, where g() would run before f(): '?' cannot follow a call in the same statement. Call f() in a statement of its own first. Reading variables and fields before a ? is fine, and several ?s run in the order written: add(parse(a)?, parse(b)?).
  • Where it might not run: the right operand of && or ||, a branch of ?:, or a match arm. Use an if or match statement there.

A ? in a while condition runs on every iteration, before the condition is tested.

Results from the io module

The io module is a good model of a library built on Result. Every operation that can fail returns an IoResult<T>, a type alias for Result<T, IoError>, and operations with nothing to return use IoResult<void>. IoError holds the platform’s error code and a message. To decide what to do about the failure, kind() returns the code’s portable ErrorKind (NotFound, PermissionDenied, AlreadyExists, …, Other), an enum to match on, and a predicate per kind, such as is_not_found() and is_permission_denied(), tests for one of them:

import [File, Directory, Path, IoResult, IoError, ErrorKind] from io;

# IoResult<T> is Result<T, IoError>: the same methods, a richer error.
read_setting(Path path): String {
    var text = File.read_to_string(path);
    if (text.is_err()) {
        if (text.error().is_not_found()) {
            return "default";
        }
        return "error: " + text.error().message;
    }
    return text.value();
}

# kind() sorts the platform's code into an ErrorKind, for a match.
describe(error: IoError): String {
    return match (error.kind()) {
        NotFound => "missing",
        PermissionDenied => "not allowed",
        else => error.message,
    };
}

main(): int {
    var dir = Directory.temp().join("pepper-results-io-example");
    var created = Directory.create_all(dir);
    assert(created.is_ok(), "created the folder");

    var path = dir.join("setting.txt");
    assert(read_setting(path) == "default", "a missing file gives the default");
    var missing = File.open(path);
    assert(missing.is_err() && missing.error().kind() == ErrorKind.NotFound, "the kind of the error");
    assert(describe(missing.error()) == "missing", "a match on the kind");

    IoResult<void> written = File.write_text(path, "blue");
    assert(written.is_ok(), "wrote the file");
    assert(read_setting(path) == "blue", "the file's contents");

    assert(Directory.remove_all(dir).is_ok(), "cleaned up");
    return 0;
}

See io: errors and platform types for the full list of kinds and the platform codes each one covers.

A library that reads a file format reports damaged data the same way: IoError.invalid_input(message) makes an error whose is_invalid_input() holds, as compress and archive return for corrupt input.

Reading a directory or a file line by line in a for loop yields one IoResult per element, so each error is handled where it happens; see Iteration.

Optional values

A value that may be missing has the type Optional<T>, written T? for short: either Value, holding a T, or Empty, holding nothing. It plays the role of Rust’s Option<T> and Swift’s Optional. Use it instead of a null reference or a sentinel such as -1 or "": the type tells the caller that the value can be absent, and reading it goes through a method that checks.

Optional is part of the runtime, so you can use it without an import. The Optional reference lists every member.

# A lookup that may find nothing returns an Optional instead of a sentinel.
struct Person {
    String name;
    int age;

    construct(this.name = "", this.age = 0) {}
}

find_adult(people: Array<Person>): Optional<Person> {
    for (var p in people) {
        if (p.age >= 18) { return Optional<Person>.Value(p); }
    }
    return Optional<Person>.Empty;
}

# `ulong?` is shorthand for `Optional<ulong>`.
index_of(text: String, wanted: String): ulong? {
    var i: ulong = 0;
    while (i < text.length) {
        if (text[i] == wanted) { return Optional<ulong>.Value(i); }
        i = i + 1;
    }
    return Optional<ulong>.Empty;
}

main(): int {
    var people = Array<Person>();
    people.append(Person("ada", 12));
    assert(find_adult(people).is_empty(), "no adult yet");

    people.append(Person("grace", 45));
    var adult = find_adult(people);
    assert(adult.is_value(), "grace is an adult");
    # An optional is a condition by itself: `if (adult)` tests is_some().
    if (!adult) { return 1; }
    assert(adult.value().name == "grace", "value() returns the Person");

    # Works for types that have no null, too.
    assert(index_of("pepper", "e").value() == 1, "first 'e' is at 1");
    assert(index_of("pepper", "z").is_empty(), "no 'z'");

    # value_or supplies a fallback instead of panicking.
    assert(index_of("pepper", "z").value_or(99) == 99, "fallback for Empty");

    # Optional is an enum with the cases Empty and Value(value).
    var nothing = Optional<int>.Empty;
    assert(nothing.is_empty(), "the Empty case is empty");
    var first_p = match (index_of("pepper", "p")) {
        Empty => -1,
        Value(at) => at as int,
    };
    assert(first_p == 0, "match on an optional");

    # An optional slot that is not given a value starts as Empty.
    var later: String?;
    assert(later.is_empty(), "unassigned optionals are Empty");
    return 0;
}

The T? shorthand

T? means Optional<T> wherever a type is written: x: int?, find(key: String): Node?, Array<Node?>, a field Token? leaf;. The ? applies to everything before it, so int[]? is an optional array, and Array<int?> is an array of optional values.

A C-style declaration inside a function body, such as Node? n = m;, is read as a declaration only when a name follows the ? and then =, ; or the end of the line; a ? b : c stays a conditional expression.

Returning early with ?

In a function that returns an optional, ? after an optional is its value, or, when it is Empty, returns Empty from the function at once, as ? does with an error for Result:

middle_name(person: Person): String? {
    var names = person.full_name?.split(" ");
    if (names.count < 3) {
        return Optional<String>.Empty;
    }
    return Optional<String>.Value(names[1]);
}

Using ? on an optional in a function that does not return an optional is an error ('?' requires the enclosing function to return an Optional).

Optional slots start as Empty

A variable or field of an optional type that is not given a value starts as Empty, not null:

struct Node {
    Token? leaf;   # Empty until assigned
}
var count: int?;   # Empty

Creating and checking optionals

MemberWhat it does
Optional<T>.Value(value)Creates a Value optional holding value.
Optional<T>.EmptyAn Empty optional.
is_value(), is_empty()Report which case the optional is.
!! (truth test)An optional is a condition by itself: if (found) is if (found.is_value()), and !found is found.is_empty().
value()Returns the value; panics with Optional.value on an Empty optional if the optional is Empty.
value_or(fallback)Returns the value, or fallback if the optional is Empty. Never panics.

Use value() when an Empty optional would be a bug in your program, since it stops the program. When a missing value is an expected outcome, test it first (if (found), is_value() or is_empty()), or supply a fallback with value_or. Testing the optional itself works because Optional declares the truth-test operator !! (see Truth tests).

Optional<T> works for every T, including types that have no null at all, such as int or ulong. For a struct type, a Value optional holds a reference to the object, like any other struct slot (see Memory and reference counting).

Optional<T> is an enum with the cases Empty and Value(value), so match and is work on it too:

var shown = match (name) {
    Empty => "nobody",
    Value(text) => text,
};
if (name is Value(text)) { print(text); }

An optional is a value enum: it lives inline in the variable, field or array element that holds it, so creating one allocates nothing, and copying one gives an independent optional. It is never null, and has no identity: compare two optionals with ==, which compares whether each holds a value and, if both do, their values; === on an optional is an error.

Optionals in the standard library

Standard library lookups that can come up empty return an optional rather than a sentinel value:

APIReturnsEmpty when
Array<T>.find(item)ulong?no element equals item
io.Path.parent()Path?the path is a root (/, C:\), a bare name with no root (a, ., ..), or empty
io.Path.file_name()String?the path is a root or empty, or its last component is . or ..
io.Path.file_stem()String?there is no file name
io.Path.extension()String?there is no file name, or the name has no dot after its first character (Makefile, .bashrc); foo. has the empty extension ""
io.Path.dot_index(name)ulong?name has no dot after its first character
http.Headers.get(name)String?no field has that name
HashMap<K, V>.try_get(key) (and Map, OrderedMap)V?key is absent
# Standard library lookups that may come up empty return an optional.
import io;

main(): int {
    # Array.find returns `ulong?`: the index, or none.
    var names = ["ada", "grace", "linus"];
    var at = names.find("grace");
    assert(at.value() == 1, "grace is at index 1");
    assert(names.find("ken").is_empty(), "ken is not in the array");

    # Path.parent, file_name, file_stem and extension return `Path?` or
    # `String?`: none when the path has no such part.
    var path = io.Path("docs").join("guide.md");
    assert(path.file_name().value() == "guide.md", "file name");
    assert(path.file_stem().value() == "guide", "stem");
    assert(path.extension().value() == "md", "extension");
    assert(path.parent().value() == io.Path("docs"), "parent");

    assert(io.Path("/").parent().is_empty(), "a root has no parent");
    assert(io.Path("notes").parent().is_empty(), "nor does a bare name");
    assert(io.Path("a/..").file_name().is_empty(), "'..' is not a file name");
    assert(io.Path("Makefile").extension().is_empty(), "no dot, no extension");

    # value_or supplies a default when none is an expected outcome.
    var ext = io.Path("Makefile").extension().value_or("txt");
    assert(ext == "txt", "fallback extension");
    return 0;
}

Breaking change. Array.find used to return a long that was -1 for “not found”, and the Path methods above returned an empty Path or String (or name.size for dot_index). Code written against the old API must test the optional instead: a.find(x) >= 0 becomes a.find(x).is_value(), p.parent().is_empty becomes p.parent().is_empty(), and a value is read with value() or value_or. A comparison such as a.find(x) == -1 still compiles but compares the optional object rather than an index, so search for these and rewrite them. String.find is unchanged and still returns -1.

Optional or Result

An Optional<T> says only that a value is missing. When the caller needs to know why, return a Result instead, whose Err carries an error.

Current limits

  • Some runtime APIs still use null references, default values or sentinels for “missing”: a HashMap’s get (and indexer) returns the default value for a missing key (use try_get for an optional), and String.find returns -1, for example.

Visibility

Visibility decides which declarations other files may use. A top-level declaration has one of three levels:

WrittenVisible to
private f()its own file only
f()the files of its module
export f()any file that imports it

With no modifier, a declaration is internal to its module: the files of one module see each other’s declarations, and nothing else does. A file outside any module is a module of its own, so its declarations are private to the file. This lets a module, or a file, keep its helpers to itself and offer a small, clear surface to the code that imports it.

private is optional. It is recommended for a helper that only its own file uses, so it does not leak into the rest of the module, and it is needed only where two files of one module would otherwise declare the same name (see Keeping a declaration to its file).

Exporting declarations

Put export in front of a declaration to make it usable from other files that import this one (see Modules and imports for how to import). You can export every kind of top-level declaration:

  • functions, including extern declarations of C functions
  • structs
  • enums
  • type aliases
  • traits
  • extensions
  • module-scope variables, which are always const and thread-safe (see Module-scope variables)

Here is a library file with a mix of exported and private declarations, and a program that uses it:

import async;

# Everything here is private to lib.pep unless it says `export`.

export struct Point {
    var x: int;
    var y: int;
}

export type Points = Point[];

export trait Describe {
    describe(): String;
}

# A module-scope variable is `const` and thread-safe: change it through
# an Atomic.
export const points_made = async.Atomic<int>(0);

export origin(): Point {
    return make(0, 0);
}

export at(x: int, y: int): Point {
    return make(x, y);
}

# Private: only lib.pep can call make().
make(x: int, y: int): Point {
    var p = Point();
    p.x = x;
    p.y = y;
    points_made.fetch_add(1);
    return p;
}

# An exported extension: its methods are usable wherever Point is.
export extend Point with Describe {
    describe(): String {
        return "point";
    }
}

# A private extension: only lib.pep can call p.norm().
extend Point {
    norm(): int {
        return x * x + y * y;
    }
}

export norm_of(p: Point): int {
    return p.norm();
}

# `export` goes after attributes, directly before the declaration.
[Target(OS(Windows))]
export platform_name(): String { return "Windows"; }

[Target(!OS(Windows))]
export platform_name(): String { return "not Windows"; }
import "lib";

main(): int {
    var p = at(3, 4);             # exported function
    var o: Point = origin();      # exported struct, used as a type
    Points list = Points();       # exported type alias
    list.append(p);
    list.append(o);

    assert(p.x == 3, "fields are as visible as their struct");
    assert(p.describe() == "point", "method from an exported extension");
    assert(norm_of(p) == 25, "an exported function may use private helpers");
    assert(points_made.load() == 2, "exported module variable");
    assert(platform_name().length > 0, "exported, with an attribute");
    return 0;
}

main.pep can call at, origin, and norm_of, use Point and Points as types, and read the counter in points_made, an exported const Atomic<int>. It cannot use make or call p.norm(). Trying to is an error that tells you how to fix it:

import "lib";

main(): int {
    var q = make(1, 2);
    return 0;
}
main.pep:4:13: error: 'make' is private to lib.pep; declare it with 'export' to use it from another file
    4 |     var q = make(1, 2);
      |             ^~~~
lib.pep:29:1: note: declared here
   29 | make(x: int, y: int): Point {
      | ^~~~

The same applies to private structs, enums, type aliases, traits, and variables: naming one from another file reports 'name' is private to file.pep.

Files of a module see each other through imports

Inside a module, export is not needed for one file to use another’s declarations, but an import is: a file sees the top-level declarations of another file of its module, exported or not, once it imports that file (import "rules";), as a C file sees another’s declarations through its header. export means visible outside the module, through its module.pep.

Here the inventory module keeps its rules in rules.pep, which exports nothing, and stock.pep imports it to use them:

# Module-internal: no `export`, yet every file of `inventory` can use these.
const RESTOCK_LEVEL = 5;

struct Shelf {
    var count: int;
}

needs_restock(shelf: Shelf): bool {
    return shelf.count < RESTOCK_LEVEL;
}
# Uses `Shelf`, `needs_restock` and `RESTOCK_LEVEL` from rules.pep, which
# this file imports: a file of a module sees another through an import.
import "rules";

export struct Stock {
    var _shelf: Shelf;

    construct(count: int) {
        _shelf = Shelf();
        _shelf.count = count;
    }

    low(): bool {
        return needs_restock(_shelf);
    }

    missing(): int {
        if low() {
            return RESTOCK_LEVEL - _shelf.count;
        }
        return 0;
    }
}
# The module's interface: only what this file exports reaches importers.
export * from "stock";

# `rules.pep` exports nothing, so importing it only makes it part of the
# module.
import "rules";
import "inventory";

main(): int {
    var s = Stock(2);
    assert(s.low(), "two is below the restock level");
    assert(s.missing() == 3, "three more reach it");
    # `needs_restock(...)` here would be an error: it is internal to the
    # module 'inventory'.
    return 0;
}

A file’s own declarations come first, then what its imports bring in (the files of its module it imports included), then the runtime’s. A file of the module that names a declaration of another one it does not import gets an error that names the file to import. Without its import "rules";, stock.pep would report:

inventory/stock.pep:5:17: error: 'Shelf' is declared in inventory/rules.pep, another file of this module, which this file does not import; add an import for it
    5 |     var _shelf: Shelf;
      |                 ^~~~~

So a file’s dependencies are exactly its imports, and the compiler checks and caches each file against what it imports alone. A module’s files are the files of the program in its folder (and in subfolders that are not modules themselves): a file joins the program when the module imports it, so module.pep imports a file with nothing to export, like rules.pep, just to include it. A [Target(...)] on that import (see Platform-specific imports) includes the file only on some platforms.

Outside the module, its internal declarations stay hidden. Using one is an error that names the module:

main.pep:3:13: error: 'Shelf' is internal to module 'inventory'; export it from the module to use it here
    3 |     var s = Shelf();
      |             ^~~~~
inventory/rules.pep:4:8: note: declared here
    4 | struct Shelf {
      |        ^~~~~

The files of a module still share one set of names, so two of them may not both declare a struct, enum, type alias, trait, or variable of one name: the second is a redefinition of 'name', with a note at the first, unless one of them is private. Functions are different: functions of one name in several files of a module are one overload set, as they are within a file. Extensions are shared the same way: a file sees the methods the files of its module it imports add, exported or not, unless the extension is private.

Keeping a declaration to its file

Put private in front of a top-level declaration to keep it to its own file: the other files of its module do not see it. You can mark every kind of declaration that export applies to. Two files of one module may then each declare a private helper of the same name, and each file’s code reaches its own. Here summary.pep and detail.pep each have a private WIDTH and label, and share the module-internal pad:

# `private` keeps these to this file: detail.pep may declare its own
# `WIDTH` and `label` without a clash.
private const WIDTH: ulong = 10;

private label(count: int): String {
    return $"{count} item(s)";
}

# Module-internal: no modifier, so detail.pep can use it too.
pad(text: String, width: ulong): String {
    if (text.size >= width) {
        return text;
    }
    return text + " ".repeat(width - text.size);
}

export summary_line(count: int): String {
    return pad(label(count), WIDTH);
}
# The same names as summary.pep's private ones, and private here too.
import "summary";

private const WIDTH: ulong = 12;

private label(name: String): String {
    return $"- {name}";
}

export detail_line(name: String): String {
    return pad(label(name), WIDTH) + "|";
}
# The module's interface: one function from each file.
export * from "summary";
export * from "detail";
import "report";

main(): int {
    assert(summary_line(3) == "3 item(s) ", "summary.pep's own label and width");
    assert(detail_line("pen") == "- pen       |", "detail.pep's own label and width");
    # `label(3)` here would be an error: it is private to summary.pep.
    return 0;
}

A private declaration stays out of the rest of the module entirely:

  • Another file of the module that names it, without a declaration of its own by that name, gets an error saying to remove private. Were detail.pep to have no label of its own, calling label(3) there would report:

    report/detail.pep:6:16: error: 'label' is private to report/summary.pep; remove 'private' to use it from the other files of its module
        6 |     return pad(label(3), WIDTH) + "|";
          |                ^~~~~
    report/summary.pep:5:9: note: declared here
        5 | private label(count: int): String {
          |         ^~~~~
    
  • A private function joins no overload set of the module: functions of one name in the other files form their set without it, and its own file sees only its own.

  • A private extension’s methods are usable only in its file.

Code outside the module never sees a private declaration, as it never sees a module-internal one ('label' is internal to module 'report').

In a file outside any module, private changes nothing: its declarations are already private to it. A declaration cannot be both export and private:

main.pep:1:8: error: a declaration cannot be both 'export' and 'private'
    1 | export private f(): int { return 0; }
      |        ^~~~~~~

Members of structs and extensions

Fields and methods declared inside a struct’s body are reached through the struct, so they share the struct’s visibility: if you can use the struct, you can use all of its members.

To let other code read a value but not write it, make it a property with only a get: var balance: long { get; } can be assigned only by the struct’s own constructors and methods.

Methods added by an extension share the extension’s visibility instead. In the example, norm() is added by a private extension, so only lib.pep (or, in a module, the module’s files) can call it, even though Point itself is exported:

main.pep:4:14: error: 'norm' is declared in a private extension in lib.pep; declare the extension with 'export' to use it from another file
    4 |     return p.norm();
      |              ^~~~
lib.pep:46:5: note: declared here
   46 |     norm(): int {
      |     ^~~~

An exported extension’s methods are usable where its file is imported, not everywhere: a file sees the extensions it declares itself, the exported extensions of the files it imports, the exported extensions of every file of a module it imports (import geo reaches geo/ext.pep’s), and those of the runtime, which every file sees. Here measure.pep adds manhattan() to Point, and main.pep imports it to call it:

import "point";

# An exported extension: files that import measure.pep can call these.
export extend Point {
    manhattan(): int {
        return abs(x) + abs(y);
    }
}

# Private to this file.
abs(v: int): int {
    if (v < 0) { return -v; }
    return v;
}
import "point";
import "measure";                      # brings Point's manhattan() into scope

main(): int {
    var p = Point();
    p.x = 3;
    p.y = -4;
    assert(p.manhattan() == 7, "an extension from an imported file");
    return 0;
}

Without import "measure";, the call is an error. If no file of the program imports measure.pep, the method does not exist at all (no member named 'manhattan' in 'Point'); if another file does, the error says which import is missing:

main.pep:8:14: error: 'manhattan' is declared in an extension in measure.pep, which this file does not import; add an import for it
    8 |     assert(p.manhattan() == 7, "an extension from an imported file");
      |              ^~~~~~~~~
measure.pep:5:5: note: declared here
    5 |     manhattan(): int {
      |     ^~~~~~~~~

Two kinds of call are checked where they take effect rather than here:

  • A method of a trait you can see (s.area() where area is a method of a trait Shape you declare, import, or get from the standard library): the call may go through a value of that trait, which works whatever file declares the conformance. A file can return a Shape whose struct and conformance stay private, and callers still call area on it.
  • A call on a receiver whose type is not known in this file: this.m() in a trait’s default method, or x.m() (or x.f().m()) where x is a parameter typed by a type parameter, the function’s or its struct’s (measure<X>(x: X)). Such a call resolves where the generic is used, with that file’s imports.

When the extension is in a file of another module, the error names the module to import instead (import its module 'geo'), since a file of another module is reached only through its module.

Inside a struct’s methods (and a trait’s default methods), a bare call to a name that an extension you cannot see adds as a method is an error too, rather than a call to the top-level function of that name: the compiler would otherwise bind it to the method. Rename one of them, or import the extension’s file if the method is what you meant. That import may point back at a file that imports this one (a struct’s file importing the file that extends it); such cycles are allowed.

Extending a struct does not make a private struct visible to other files.

Visibility is checked before types are known, so members are matched by name: a call x.m() is accepted if any struct body visible to the file (its own, or exported) or any extension visible to it, as above, declares a member named m.

Where export and private go

export and private go directly before the declaration’s keyword or name, after any attributes:

[Target(OS(Windows))]
export platform_name(): String { return "Windows"; }

[Inline]
private twice(x: int): int { return x * 2; }

Writing it before the attributes is a parse error:

main.pep:2:8: error: unexpected '['
    2 | export [Target(OS(Windows))] f(): int { return 1; }
      |        ^

export controls visibility between Pepper files only. Making a function callable from C with C linkage is a separate matter, handled by the [Export] attribute described in Calling Pepper from C.

The runtime’s exports

The runtime’s public surface (String, Array, HashMap, HashSet, Result, Error, hash, assert, panic, and the rest of the standard library reference) is exported the same way, and is visible in every file without an import. The runtime’s own helpers are private, so calling one, such as _pep_check_sync_enabled, reports '_pep_check_sync_enabled' is private to followed by the path of the runtime’s internals.pep.

Each module has its own namespace

A private declaration belongs to its file (or its module) alone, so two files outside any module, or two modules, may each declare a private name without clashing. Within a module, private does the same for two of its files (see Keeping a declaration to its file). Here main.pep and lib.pep each have their own Counter and make, and each file’s code reaches its own:

# lib.pep's private helpers: no other file can see them, and no other
# file's names can clash with them.
struct Counter { var n: int; }

make(): Counter {
    var c = Counter();
    c.n = 10;
    return c;
}

export lib_total(): int {
    return make().n;
}
import "lib";

# main.pep has its own private Counter and make(), unrelated to lib.pep's.
struct Counter { var n: int; var step: int; }

make(): Counter {
    var c = Counter();
    c.n = 1;
    c.step = 2;
    return c;
}

main(): int {
    var c = make();                    # main.pep's make()
    assert(c.n + c.step == 3, "main's own Counter");
    assert(lib_total() == 10, "lib's make() is its own");
    return 0;
}

This holds across modules too: two modules may each have a util.pep with a private helper. Files are told apart by their path inside their module (io/file.pep), not by their file name alone. Within one module, the files share their names instead, except private ones (see Files of a module see each other through imports).

Exported names work the same way between modules: each module’s exports are its own, so two modules may export the same name, and importers tell them apart by qualifier (a.parse and b.parse) or by an import list (import [parse as parse_b] from b). Using a name that two whole-file imports both provide is an error at the use (see Modules and imports). A private name may repeat an exported one from another file; in the file that declares it, the file’s own declaration is the one that name refers to.

Two kinds of file keep their exported names program-wide, because every file reaches them without an import or by its bare name: the runtime (and any --import module), and the program’s main file. So an export may not repeat a runtime name: exported 'String' is also declared in followed by the runtime’s file.

A few kinds of declaration are not private in this sense:

  • extern declarations name a C function, which is the same function in every file. Several files may declare the same extern.
  • Concrete overrides of a generic function, such as hash<int> alongside hash<T>, take the generic function’s name: they replace its version for those types wherever it is called. So each override may be declared in only one file (override 'hash<int>' is also declared in b.pep), and overriding one the runtime already provides, such as hash<String>, is also an error. An override must be for a generic function you can see: overriding another file’s private generic function reports that it is private. An override may be for a struct, such as hash<Point>, and must be declared in the file or module that declares the generic or one of its type arguments (see Generics).
  • Declarations that a [Target(...)] attribute drops for the platform being compiled (see Platform selection) are never compiled. This is why the first lib.pep above can declare platform_name twice.

Modules and imports

As a program grows, you split it into files, and group related files into modules. An import makes another file’s or module’s exported declarations available in the importing file. What a file exports is covered in Visibility.

Importing a file

The simplest import names another file, as a string, relative to the importing file. The .pep extension is optional:

# Every import comes before the file's declarations.
import "util";                       # util.pep's exports, unqualified
import "util" as u;                  # the same exports, as u.name
import [thrice as triple] from "util";
import "helpers/text";               # a file in a subfolder

main(): int {
    assert(twice(BASE) == 20, "unqualified names from util.pep");
    assert(u.thrice(2) == 6, "qualified by the alias");
    assert(triple(3) == 9, "renamed by the import list");
    assert(shout("hi") == "hi!", "from helpers/text.pep");

    # An imported function is a value like any other.
    var f = u.twice;
    assert(f(4) == 8, "function reference through a qualifier");
    return 0;
}

util.pep sits next to main.pep, and text.pep is in a helpers folder:

export const BASE = 10;

export twice(n: int): int {
    return n * 2;
}

export thrice(n: int): int {
    return n * 3;
}
export shout(s: String): String {
    return s + "!";
}

An import comes in one of these forms:

FormWhat it makes available
import "util";Every export of util.pep, unqualified: twice(BASE).
import "util" as u;Every export, qualified by the name you choose: u.twice(1).
import [thrice as triple, twice] from "util";Only the listed exports, unqualified, each under its own name or the one after as.
import geometry;A module’s exports, qualified by the module’s name: geometry.Point.
import geometry as g;A module’s exports, qualified by g.
import [area, Point as Pt] from geometry;The listed exports of a module, unqualified.

An import ends with a semicolon.

An import binds names only in the file that contains it. The exports of a file that this file did not import are not visible to it, even when another file of the program imports them. Here geometry/module.pep imports shapes.pep, but main.pep imports only the module:

import geometry;
main(): int { return area(1, 2); }
main.pep:2:22: error: 'area' is exported by deps/geometry/shapes.pep, which this file does not import; add an import for it
    2 | main(): int { return area(1, 2); }
      |                      ^~~~
deps/geometry/shapes.pep:3:8: note: declared here
    3 | export area(w: int, h: int): int {
      |        ^~~~

Imports come first

Every import, and every export ... from, comes before all of a file’s declarations and export name re-exports:

main(): int { return 0; }
import geometry;
main.pep:2:1: error: imports must come before all declarations
    2 | import geometry;
      | ^~~~~~

Modules

A module is a folder that contains a module.pep file. The module is named after its folder, and its exports are exactly what its module.pep exports. The other files in the folder are the module’s own files: module.pep imports them and chooses what to pass on to the module’s users. A file of a module sees the declarations of the module’s files it imports, exported or not, except those marked private (see Files of a module see each other through imports). Loading any file of a module loads its module.pep, and with it every file the module imports.

This example has a geometry module next to main.pep:

modules-folders/
    main.pep
    geometry/
        module.pep      # the module's interface
        shapes.pep      # a file of the module
        solids/
            module.pep  # a module nested inside geometry
# A file of the geometry module. Its exports are visible to the files of
# the module that import it, not to the module's importers.
export area(w: int, h: int): int {
    return w * h;
}

export struct Box<T> {
    var value: T;
}
# A module nested in the geometry folder.
export volume(w: int, h: int, d: int): int {
    return w * h * d;
}
# The geometry module: what this file exports is the module's interface.
import "shapes";
import "solids" as solids;

export struct Point {
    var x: int;
    var y: int;
}

export make(x: int, y: int): Point {
    var p = Point();
    p.x = x;
    p.y = y;
    return p;
}

reset_count(): int {
    return 0;
}

export area as rect_area;           # shapes.pep's area, renamed
export Box;                         # shapes.pep's Box, under its own name
export reset_count as reset;        # a private function, exported as reset
export solids.volume as box_volume; # from the nested module
import "geometry" as g;
import [rect_area, Point as Pt] from "geometry";

# Qualified names work in types, too.
corner(): g.Point {
    return g.make(1, 1);
}

main(): int {
    var p: g.Point = g.make(3, 4);
    assert(p.x == 3, "qualified function and type");

    Pt q = corner();
    assert(q.y == 1, "the struct under its import-list name");

    assert(g.rect_area(2, 3) == 6, "re-exported under a new name");
    assert(rect_area(2, 5) == 10, "imported unqualified");
    assert(g.reset() == 0, "a private function, re-exported");
    assert(g.box_volume(2, 3, 4) == 24, "re-exported from a nested module");

    var b = g.Box<int>();
    b.value = 7;
    var boxes: g.Box<int>[] = g.Box<int>[]();
    boxes.append(b);
    assert(boxes[0].value == 7, "a generic struct through a qualifier");
    return 0;
}

main.pep imports the folder with a string, import "geometry", because the module lives next to it. A string import names either a file (geometry.pep) or a module folder (geometry/) relative to the importing file. A module imports a module nested in its own folder the same way: geometry/module.pep imports "solids".

Re-exports

export name as new_name; exports name to importers under new_name, and export name; exports it under its own name. The name may be declared in the file (even privately) or imported from another file or module, qualified or not. This is how a module.pep publishes what its other files define.

The semicolon is required. Without it, export Box followed by a declaration would read as an exported C-style declaration whose type is Box.

The exports of the module’s other files are not the module’s exports. In the example, users of geometry can call g.rect_area, but not g.area:

main.pep:2:22: error: 'area' is not exported by 'geometry' (deps/geometry/module.pep)
    2 | main(): int { return geometry.area(1, 2); }
      |                      ^~~~~~~~~~~~~

Re-exporting a whole file or module

A module.pep that only passes on its files’ exports would repeat every name. export ... from passes them on in one line, in the same shapes as the import ... from forms:

FormWhat importers get
export * from "rect";Every export of rect.pep, each under its own name.
export * from geometry;Every export of the module geometry.
export [perimeter as outline, Side] from "measure";Only the listed exports, each under its own name or the one after as.
# The shapes module: its interface is its files' exports, passed on whole or
# by name.
export * from "rect";                               # every export of rect.pep
export [perimeter as outline, Side] from "measure"; # two, one renamed
import "shapes";

main(): int {
    var r = make_rect(3, 4);
    assert(area(r) == 12, "from export * of rect.pep");
    assert(outline(r) == 14, "from the export list, renamed");

    var s = Side();
    s.length = 5;
    assert(s.length == 5, "a struct from the export list");
    return 0;
}
  • It re-exports; it doesn’t import. export * from "rect"; makes no name visible in the file that contains it. Inside a module that does not matter, since the module’s files see each other’s declarations anyway; a file outside it imports the names as usual (import "rect";).

  • Only exports travel. Private declarations stay behind, and extensions stay visible only as they always are.

  • Collisions are errors. If two re-exports, or a re-export and one of the file’s own exports, publish one name for different declarations, the compiler reports it at the export ... from that adds the second one. List the names you want instead, or rename one with as. The exception is functions declared under one name: they join one overload set. Anything else collides:

    shapes/module.pep:2:1: error: 'area' is exported twice by module 'shapes'
        2 | export * from "measure";
          | ^~~~~~~~~~~~~~~~~~~~~~~
    shapes/module.pep:1:1: note: also exported here
        1 | export * from "rect";
          | ^~~~~~~~~~~~~~~~~~~~
    
  • Like imports, export ... from lines come before every declaration and take attributes such as [Target(...)]. A deprecated declaration stays deprecated through every re-export.

A module’s files stay inside it

A string import from a file of a module must name a file of the same module, or a module folder. Reaching into another module’s files, or out of your own module, is an error:

main.pep:1:8: error: "geometry/shapes.pep" belongs to another module; import that module's folder instead
    1 | import "geometry/shapes";
      |        ^~~~~~~~~~~~~~~~~
geometry/bad.pep:1:8: error: "../main.pep" is outside this file's module; a module imports only its own files
    1 | import "../main";
      |        ^~~~~~~~~

A file outside any module, such as your program’s main.pep, may import files in its own folder or below, except files inside a module folder there.

Files of one module may import each other, even in a cycle.

Dependencies and search paths

A module that is not part of your program’s own folders, such as a library you depend on, is imported by name rather than by string:

import geometry;

main(): int {
    var p: geometry.Point = geometry.make(3, 4);
    return p.x - 3;
}

An identifier import looks for a folder geometry/module.pep on the search paths: first each directory you pass with -I, in order, then the runtime directory. If geometry lives in a deps folder, compile with:

pepper build main.pep -I deps -o main

An identifier import never looks next to the importing file. A module that is not found is reported with the places the compiler looked:

main.pep:1:8: error: cannot find module 'nowhere': no nowhere/module.pep in deps, /home/you/pepper/runtime
    1 | import nowhere;
      |        ^~~~~~~

The standard library’s io module is found this way, in the runtime directory: import io; then io.File, or import [File, Path] from io;.

A module nested in another module’s folder is imported with a dotted name: import geometry.solids; finds geometry/solids/module.pep and binds its last name, so its declarations are solids.volume (or, with import geometry.solids as s;, s.volume).

Projects and packages

A project’s pep.toml lists the packages it depends on, and each key is the name source code imports:

[dependencies]
geometry = { github = "someone/geometry", version = "1.2" }

import geometry; then finds the package’s root module.pep in the project’s .pep/deps, with no -I, and import geometry.solids; the module in its solids folder. pepper build with no file builds the project. See Projects: pep.toml.

The runtime prelude and --import

The runtime itself is not a module: its files are loaded into every program, and their exports, such as String, Array, and Result, are visible everywhere without an import.

--import NAME adds a module, or a file NAME.pep, found on the search paths to that prelude, so every file sees its exports without importing it. A name that resolves to neither is an error:

error: --import no_such_module: cannot find module 'no_such_module' (no no_such_module/module.pep) or file no_such_module.pep in /home/you/pepper/runtime

A prelude module may redefine an export of the runtime, such as panic: it is loaded ahead of the runtime and its definition wins. It must declare the name itself (export panic(...)); re-exporting another function under a runtime export’s name is an error, since the runtime’s own files would keep calling their version. When the runtime’s function is overloaded, the prelude module replaces only the overloads it declares, matched by parameter types, defaulted ones included: an export assert(condition: bool, message: String, location: __ptr<char> = __caller_location()) replaces the String form, and a string literal message still calls the runtime’s __ptr<char> form.

Only functions may be redefined this way. A struct, trait, type alias or variable that a prelude module exports under a name the runtime (or another prelude module) already exports is an error in the module loaded first, with a note at the other:

alt/module.pep:1:15: error: redefinition of 'MapIterator'
    1 | export struct MapIterator<K, V> { var key: K; var value: V; }
      |               ^~~~~~~~~~~
runtime/iterator.pep:182:15: note: also exported here

How imported names behave

  • Qualified names work wherever a name does: in expressions (g.rect_area(1, 2), or a module variable such as u.BASE), in types (var p: g.Point, g.Box<int>[], a return type g.Point), and in generic constructions (g.Box<int>()). A generic construction is recognized only for a qualifier imported earlier in the file; elsewhere a.b < c, d > (e) is two comparisons.
  • Local names shadow imports. A local variable, parameter, generic parameter, or struct member hides a qualifier or an imported name of the same name.
  • A file may not import a name it also declares at top level: 'make' is imported but also declared in this file. Functions are the exception: a file’s own functions of a name join the imported ones in one overload set.
  • An import list wins. A name listed in import [...] takes priority over the same name from a whole-file import. When two whole-file imports provide the same name differently, using that name is an error because it is ambiguous, unless both are functions declared under that name, which join one overload set.
  • A local may share an imported declaration’s name. If g.rect_area is shapes.pep’s area, calling g.rect_area(1, 2) inside a function with a parameter named area calls shapes.pep’s function. The one exception is a re-exported runtime name: if a module exports the runtime’s print as say, a local named print where you call say is an error that asks you to rename one of them.

Each module (and each file outside a module) has its own namespace, for its exported declarations as well as its private ones, so modules may reuse names freely: two modules may both export parse, and a.parse and b.parse stay apart. See Each module has its own namespace. Here metric and imperial each export a Length, a parse and a unit:

# Both modules export `Length`, `parse` and `unit`; each keeps its own.
import "metric" as metric;
import "imperial" as imp;
import [unit as imperial_unit] from "imperial";

main(): int {
    var a: metric.Length = metric.parse("abc");
    var b: imp.Length = imp.parse("abc");
    assert(a.metres == 3.0, "metric.parse");
    assert(b.feet == 9.0, "imperial.parse");
    assert(metric.unit() == "m", "qualified by the alias");
    assert(imperial_unit() == "ft", "renamed by the import list");
    return 0;
}

Platform-specific imports

An import or a re-export may carry attributes like any declaration. With a [Target(...)] attribute it applies only on matching platforms and is ignored elsewhere (see Platform selection):

[Target(OS(Linux))]
import linux;

[Target(OS(Linux))]
export linux_open as open;

[Target(OS(Windows))]
export * from "windows";

Atomics

An atomic value can be read and updated by several threads at once without a data race: every operation on it happens as one indivisible step. Pepper provides this through async.Atomic<T>, the equivalent of C++ std::atomic<T>.

Threads themselves, and the rules for what they can share, are in Threads and shared data.

Atomic<T>

Atomic<T> belongs to the async module, with the other types threads share: import async and write async.Atomic<T>, or bring the name in with import [Atomic] from async. The Atomic reference documents every method.

import async;

main(): int {
    var a = async.Atomic<int>(41);
    assert(a.load() == 41, "the initial value");

    a.store(1);
    assert(a.load() == 1, "store replaces the value");

    # The fetch_ operations return the value from before the change.
    assert(a.fetch_add(5) == 1, "fetch_add returns the old value");
    assert(a.load() == 6, "1 + 5");
    assert(a.fetch_sub(2) == 6, "fetch_sub returns the old value");
    assert(a.load() == 4, "6 - 2");

    var flags = async.Atomic<uint>(12);          # 0b1100
    flags.fetch_and(10);                   # 0b1000
    assert(flags.load() == 8, "fetch_and");
    flags.fetch_or(1);                     # 0b1001
    assert(flags.load() == 9, "fetch_or");
    flags.fetch_xor(9);                    # 0b0000
    assert(flags.load() == 0, "fetch_xor");

    # compare_exchange stores only if the current value is the expected one.
    assert(a.compare_exchange(4, 10), "4 matched, so 10 is stored");
    assert(!a.compare_exchange(4, 20), "10 does not match 4");
    assert(a.load() == 10, "the failed exchange changed nothing");

    # exchange always stores, and returns what was there.
    assert(a.exchange(3) == 10, "exchange returns the old value");
    assert(a.load() == 3, "and stores the new one");

    assert(a.is_lock_free(), "an int fits a native atomic instruction");
    return 0;
}
MethodWhat it does
Atomic<T>(value)Creates an atomic holding value (zero, or null for a struct type, if you leave it out).
load()Reads the value.
store(v)Replaces the value with v.
compare_exchange(expected, desired)Stores desired only if the value is currently expected; returns whether it did.
exchange(v)Stores v and returns the value it replaced.
fetch_add(v), fetch_sub(v)Adds or subtracts v; returns the value from before. Overflow wraps around.
fetch_and(v), fetch_or(v), fetch_xor(v)Combines the value bitwise with v; returns the value from before.
is_lock_free()Reports whether the operations use native atomic instructions.

Each operation compiles to a native atomic instruction. load has acquire ordering, store has release ordering, and compare_exchange and the fetch_ operations are acquire-release. exchange is a compare_exchange retry loop.

Some details differ from C++:

  • compare_exchange does not update expected when it fails. Call load() to get the current value before you retry.
  • The comparison is bitwise. For a floating-point T, 0.0 and -0.0 are different values. For a struct T, it compares references (whether both are the same object), not the objects’ contents.
  • The fetch_ operations are for integer types. Calling one on a floating-point or struct T compiles, but stops the program when it runs.
  • is_lock_free() is true for any number type of 8 bytes or less, and for any struct type.
  • T must be thread-safe. Atomic is a [Shared] struct, so it is declared Atomic<T: Shared>: T is a number, bool, an enum, a [Shared] struct, or another type in What threads can share. Atomic<String> is a compile error at String (see the Shared constraint). A tuple is thread-safe, but its value cannot be stored atomically, so Atomic of a tuple is an error too, and so is Atomic of a trait type (see Weak references and atomics).

Common patterns

import async;

# An Atomic can only hold a thread-safe struct: one declared [Shared], whose
# fields are const and thread-safe.
[Shared]
struct Config {
    const version: int;
    construct(this.version = 0) {}
}

# A module-scope Atomic that every function can share.
const next_id = async.Atomic<long>(0);

new_id(): long {
    return next_id.fetch_add(1) + 1;
}

# The compare-and-swap retry loop: build any read-modify-write update.
double_it(async.Atomic<int> value) {
    var current = value.load();
    while (!value.compare_exchange(current, current * 2)) {
        current = value.load();
    }
}

main(): int {
    assert(new_id() == 1, "first id");
    assert(new_id() == 2, "second id");

    var n = async.Atomic<int>(21);
    double_it(n);
    assert(n.load() == 42, "doubled");

    # Copies of an Atomic share one slot, because Atomic is a struct.
    var same = n;
    same.store(7);
    assert(n.load() == 7, "both names see the store");

    # An Atomic can hold a struct reference.
    var first = Config(1);
    var current = async.Atomic<Config>(first);
    assert(current.load().version == 1, "load returns the reference");

    # compare_exchange compares references: the same object, not equal contents.
    var lookalike = Config(1);
    var second = Config(2);
    assert(!current.compare_exchange(lookalike, second), "a different object");
    assert(current.compare_exchange(first, second), "the same object");
    assert(current.load().version == 2, "swapped");

    var old = current.exchange(first);
    assert(old.version == 2, "exchange hands back the old reference");
    return 0;
}
  • A shared counter. fetch_add(1) hands out unique, increasing numbers, even when many threads call it at once.
  • A compare-and-swap loop. To apply any update that the fetch_ operations do not cover, load the current value, compute the new one, and compare_exchange; if another thread changed the value in between, the exchange fails and you try again with a fresh load().
  • Sharing an Atomic. Atomic is a [Shared] struct, so copying one copies a reference: every copy shares the same slot, on any thread. To share a value, pass the Atomic around or keep it in a module-scope variable.
  • Atomic struct references. An Atomic<T> whose T is a [Shared] struct type holds a counted reference, and follows the same rules as any other reference (see Memory and reference counting): store and a successful compare_exchange take a reference to the new value and release the one they replace, and load and exchange give you your own reference. The swap in the slot is itself atomic, so concurrent stores never leak or release a value twice. When the Atomic itself is destroyed, it releases the reference it holds.

Atomics and threads

Atomic<T> is how threads share a value that changes. Keep it in a module-scope variable, or in a const field of a [Shared] struct, and every thread sees the same slot. Threads and shared data shows it together with Thread.spawn.

Threads and shared data

The async module starts and manages operating-system threads. Pepper keeps threads safe by controlling what they can share: only thread-safe values can be reached from more than one thread, and the compiler checks the types that claim to be.

On WebAssembly (--target wasm32-wasip1) a program has one thread: Thread.spawn panics. Mutexes, channels and atomics still work, as long as nothing waits for another thread (see WebAssembly).

Starting and joining threads

import async;

const finished = async.Atomic<int>(0);

# A thread body is a function that takes nothing and returns nothing.
count_to_a_million() {
    var me = async.Thread.current();
    assert(me.name() == "counter", "a spawned thread sees its own Thread");
    var total: long = 0;
    var i = 0;
    while (i < 1000000) {
        total = total + 1;
        i = i + 1;
    }
    finished.fetch_add(1);
}

main(): int {
    var main_thread = async.Thread.current();
    assert(main_thread.name() == "main", "the main thread is named main");

    var first = async.Thread.spawn(count_to_a_million, "counter");
    var second = async.Thread.spawn(count_to_a_million, "counter");
    assert(first.id() != second.id(), "every thread has its own id");

    first.join();
    second.join();
    assert(first.is_finished(), "join waits for the body to return");
    assert(finished.load() == 2, "both bodies ran");

    # How many threads can run at once, and on how many cores.
    assert(async.logical_cpus() >= async.physical_cpus(), "hyperthreads count once in physical_cpus");
    return 0;
}
  • Thread.spawn(body, name) starts a new thread that runs body, a function of type ThreadBody (an alias for shared () => void), and returns its Thread. The name is optional.
  • join() waits for the body to return. After join(), the joining thread sees everything the other thread did. A thread can be joined once.
  • Thread.current() returns the calling thread’s Thread. For a spawned thread it is the same object that spawn returned. The main thread’s is named main.
  • id() returns the thread’s ThreadId, a number that no other thread in the program has. Ids come from the runtime, starting at 1, and are not the operating system’s thread ids. A ThreadId can be compared and used as a HashMap key.
  • is_finished() reports whether the body has returned. is_joinable() reports whether join() may be called.
  • Thread.sleep(milliseconds) pauses the calling thread (in a task, only the task: see Tasks).
  • Dropping every reference to a Thread without joining it detaches the thread. It keeps running, and its resources are freed when it finishes.
  • A panic or failed assert on any thread ends the whole program.

The body can be a named function or a closure. A closure passes data to the thread through its captures:

var total = async.Atomic<long>(0);
var step: long = 5;
var worker = async.Thread.spawn(() => { total.fetch_add(step); });
worker.join();

The body is a shared function value, so the closure may capture only thread-safe values (numbers, Atomic<T>, [Shared] structs, shared function values). The compiler checks each capture and reports one that is not thread-safe where the closure uses it:

main.pep:5:46: error: closure passed as 'shared () => void' captures 'name' of type 'String', which is not thread-safe
    5 |     var t = async.Thread.spawn(() => { print(name); });
      |                                              ^~~~
main.pep:4:9: note: capture a thread-safe value instead, such as an 'async.Atomic<T>' or a '[Shared]' struct
    4 |     var name = "worker";
      |         ^~~~

Every module-scope variable is const and thread-safe, so any thread may use one (see below).

The Thread reference documents every method.

Processor counts

  • async.logical_cpus() returns how many threads the machine can run at the same moment. A core with hyperthreading (SMT) counts two or more times. This is a good default size for a pool of CPU-bound worker threads. In a Linux container with a CPU quota (docker run --cpus), it is the quota rounded up, when that is fewer (see Containers).
  • async.physical_cpus() counts each physical core once. On Linux it reads the kernel’s CPU topology, on macOS hw.physicalcpu, and on Windows GetLogicalProcessorInformationEx. Inside a virtual machine it counts the cores the hypervisor presents, because the guest cannot see the host’s hardware.

Thread-local variables

A module-scope var marked [ThreadLocal] has a separate copy on every thread, like C++ thread_local or a Rust #[thread_local] static:

import async;

import [Thread] from async;

# A separate `depth` on every thread, each starting at 0.
[ThreadLocal]
var depth: int = 0;

const deepest = async.Atomic<int>(0);

descend(levels: int) {
    depth += 1;
    if (depth > deepest.load()) {
        deepest.store(depth);
    }
    if (levels > 1) {
        descend(levels - 1);
    }
    depth -= 1;
}

work() {
    descend(5);
}

main(): int {
    depth = 100;   # changes only the main thread's copy
    var a = Thread.spawn(work);
    var b = Thread.spawn(work);
    a.join();
    b.join();
    assert(deepest.load() == 5, "each thread counted from its own 0");
    assert(depth == 100, "the main thread's copy is its own");
    return 0;
}
  • Every thread’s copy starts from the initializer, which must be a constant: a number, bool or char literal (negated, or cast), an enum case, or nothing, for the type’s default (zero, or null for a struct). To start from anything else, leave the initializer out and set the value on the thread’s first use.
  • Reading or assigning one touches only the calling thread’s copy, so it is a plain var: it need not be const like other module-scope variables, and needs no atomics or locks. A read is a single load from the thread’s storage (a %fs-relative load on x86-64 Linux), not a call.
  • Its type must be thread-safe (see What threads can share): a number, a raw pointer, a [Shared] struct, a shared function type, or a tuple or [Value] struct of them. A value of any other type, such as a String, goes in a ThreadLocal<T>.
  • When a thread started with Thread.spawn exits (the async runtime’s workers and blocking threads included), each of its thread-locals is set to its default and the struct it held is released, last declared first. The main thread’s are never released, like every other module-scope variable, and a thread started by C code does not release its own.
  • A task sees the thread-locals of the worker thread it runs on, as with a ThreadLocal<T>.

[ThreadLocal] applies only to a module-scope variable. A variable whose type is not thread-safe, or whose initializer is not a constant, is reported at the type or the initializer:

main.pep:3:14: error: thread-local variable 'scratch' has type 'Plain', which is not thread-safe
    3 | var scratch: Plain;
      |              ^~~~~
note: a per-thread value of any type is an 'async.ThreadLocal<T>' (after 'import async')
main.pep:1:8: note: 'Plain' is not declared [Shared]
    1 | struct Plain { var x: int; }
      |        ^~~~~
main.pep:3:18: error: thread-local variable 'depth' must start from a constant
    3 | var depth: int = start();
      |                  ^~~~~
note: every thread starts from this value; leave it out and assign the value on the thread's first use instead

Per-thread values with ThreadLocal

ThreadLocal<T> is a variable with a separate value on every thread, of any type:

import async;

import [Thread, ThreadLocal] from async;

# One variable, a separate value on every thread.
const calls = ThreadLocal<int>();
const totals = async.Atomic<long>(0);

record_call() {
    calls.set(calls.get() + 1);
}

work() {
    record_call();
    record_call();
    record_call();
    totals.fetch_add(calls.get());   # 3 on every thread
}

main(): int {
    record_call();
    var a = Thread.spawn(work);
    var b = Thread.spawn(work);
    a.join();
    b.join();
    assert(totals.load() == 6, "each thread counted its own calls");
    assert(calls.get() == 1, "the main thread's value is its own");
    return 0;
}
  • get() returns the calling thread’s value, or the default of T (zero, or null for a struct) until that thread calls set.
  • set(value) replaces only the calling thread’s value.
  • is_set() and clear() test for and remove the calling thread’s value.
  • When a thread started with Thread.spawn exits, its value is released, even if the ThreadLocal itself was destroyed first. As with [ThreadLocal] variables, the main thread’s value is released only by clear(), and a thread started by C code leaks the values it did not clear.
  • Each thread keeps its values in a table of its own, held in [ThreadLocal] variables, so get() costs a few loads and no call, and a program can have any number of ThreadLocals.

A ThreadLocal is usually a module-scope variable. Its T does not need to be thread-safe, because each value stays on the thread that stored it. For a T that is not thread-safe, such as a String, the value is reached like a Mutex’s (see What a lock body may do):

  • with (var value = local.enter()) { ... } binds this thread’s value; the else block runs when the thread has none;
  • get() is a compile error;
  • set(value) takes a new value: a constructor call, a string literal, copy(x), or move(x).

Otherwise a function called while a Mutex is locked could keep part of the locked value in a thread-local, and a later get() would hand it out after the lock was released. The ThreadLocal reference has the details.

Tasks that take turns on one worker thread share its ThreadLocal values; a TaskLocal<T> gives each task its own.

What threads can share

Every struct value is a counted reference (see Memory and reference counting). Updating a count from two threads at once can free an object too early or never, so by default a struct’s counts are plain, fast increments, and its objects must stay on one thread.

A struct declared [Shared] has atomic reference counts, so references to one object can be copied and dropped on any thread:

import async;

# A struct that threads can share: declared [Shared], every field const and
# thread-safe.
[Shared]
struct Settings {
    const port: int;
    const retries: int;
    construct(this.port = 0, this.retries = 0) {}
}

# async.Atomic<T> of a [Shared] struct: threads read the current settings, and one
# of them can replace them.
const current = async.Atomic<Settings>(Settings(8080, 3));
const seen = async.Atomic<long>(0);

read_settings() {
    var settings = current.load();
    seen.fetch_add(settings.port);
}

main(): int {
    var readers = Array<async.Thread>();
    var i = 0;
    while (i < 4) {
        readers.append(async.Thread.spawn(read_settings));
        i = i + 1;
    }
    for (var reader in readers) {
        reader.join();
    }
    assert(seen.load() == 4 * 8080, "every reader saw the settings");

    current.store(Settings(9090, 5));
    assert(current.load().port == 9090, "replaced");
    return 0;
}

The compiler checks every [Shared] struct:

  • Every field must be const. It is set once, by the constructor, and never assigned again, so threads cannot race on it. To change shared state, give the struct a thread-safe member such as an Atomic<T>.

  • Every field must have a thread-safe type. These are all the thread-safe types, the same ones the constraint T: Shared accepts:

    • every number type (int, long, byte, char, short, float, double and the rest), bool, and void;
    • enums whose cases carry no values, which are stored as integers;
    • [Shared] structs, and enums whose cases carry values that are declared [Shared];
    • weak references to thread-safe types;
    • raw pointers (__ptr<T>, and void*) to thread-safe types;
    • shared function types, such as shared () => void (see Function types), and C function pointer types (extern (int) => int);
    • tuples whose elements are all thread-safe;
    • inside a generic, a type parameter constrained T: Shared.

    Nothing else is. A struct is never thread-safe on its own, even when all its fields are numbers: a struct value is a reference to a reference-counted object, and only a [Shared] struct counts those references atomically. So String, Array and other structs that are not [Shared] are not thread-safe, and neither is a plain function type such as () => void:

    main.pep:3:14: error: field 'F' of shared struct 'H' has type '() => void', which is not thread-safe
        3 |     const F: () => void;
          |              ^~~~~~~~~~
    note: declare the function type 'shared () => void' to share it between threads
    

A generic [Shared] struct whose parameter has no constraint is checked for each set of type arguments, and the error points at the code that asked for the specialization:

[Shared]
struct Cell<T> {
    const value: T;
}

make<T>(): Cell<T> { return Cell<T>(); }

main(): int {
    var c = make<String>();
    return 0;
}
main.pep:9:13: error: 'make<String>' requires thread-safe type arguments; 'String' is not thread-safe
    9 |     var c = make<String>();
      |             ^~~~~~~~~~~~
main.pep:6:29: note: 'Cell<String>' is required here, in 'make<String>'
    6 | make<T>(): Cell<T> { return Cell<T>(); }
      |                             ^~~~~~~
main.pep:3:18: note: required by field 'value' declared here
    3 |     const value: T;
      |                  ^
runtime/string.pep:54:15: note: 'String' is not declared [Shared]

A generic can state the rule up front with the built-in constraint T: Shared: [Shared] struct Cell<T: Shared> accepts exactly the type arguments above, and a wrong one is reported where it is written ('String' does not conform to 'Shared', required by 'T' in 'Cell'). The standard library’s Atomic<T>, spawn, run_blocking and Lazy<T> do this, so Atomic<String> is reported at String.

A field that is not const, or that has a type that is not thread-safe, is reported at the field:

main.pep:3:9: error: field 'Count' of shared struct 'Counter' must be 'const'
    3 |     int Count;
      |         ^~~~~
note: threads change a shared struct's state through a thread-safe member such as 'async.Atomic<T>' (after 'import async')

Thread, ThreadLocal<T> and Atomic<T> are [Shared] themselves, and so are the synchronization types below: Channel<T>, Mutex<T>, Condition<T>, Once and Lazy<T>.

Module-scope variables

Every module-scope variable is implicitly shared, exported or not: any function can use it, and any function can run on any thread. So it follows the same two rules as a field of a [Shared] struct:

  • It must be const. To change the value, give it a thread-safe type such as Atomic<T>, or a ThreadLocal<T> for a value per thread. The one exception is a [ThreadLocal] variable, which every thread has its own copy of.
  • It must have a thread-safe type from the list above. A String, an Array, a struct that is not [Shared], a trait value, or a plain function value cannot be a module-scope variable; keep it in a local, or in a ThreadLocal<T>.
const MAX_WORKERS = 8;                  # a number
const jobs_done = async.Atomic<long>(0);  # changed with fetch_add
const settings = Settings(8080, 3);     # a [Shared] struct
const scratch = ThreadLocal<String>();  # a String per thread
const double: shared (int) => int = (x: int) => x * 2;  # a shared function
[ThreadLocal]
var depth: int = 0;                     # a copy per thread

A variable that breaks a rule is reported at its name or type:

main.pep:1:5: error: module-scope variable 'hits' must be 'const'
    1 | var hits = 0;
      |     ^~~~
note: every thread can reach a module-scope variable; change its state through a thread-safe type such as 'async.Atomic<T>' (after 'import async'), or declare it '[ThreadLocal]' to give each thread its own copy
main.pep:2:15: error: module-scope variable 'origin' has type 'Plain', which is not thread-safe
    2 | const origin: Plain = Plain();
      |               ^~~~~
main.pep:1:8: note: 'Plain' is not declared [Shared]
    1 | struct Plain { var x: int; }
      |        ^~~~~

Module-scope variables are initialized before main runs, in the order they are declared, and each file’s after those of the files it imports and of the runtime. So an initializer may use the variables of the files it imports.

Channels

A Channel<T> carries values from threads that send to threads that receive, with any number of each, like a Go channel. (In a task, a wait parks only the task, and so do the waits of Mutex and Condition below.)

import async;

# The main thread sends numbers to a pool of workers, and the workers send
# their squares back on a second channel.
const jobs = async.Channel<long>(16);    # bounded: send waits while 16 are queued
const results = async.Channel<long>();   # unbounded: send never waits

square_numbers() {
    # The loop ends once `jobs` is closed and empty.
    for (var n in jobs) {
        results.send(n * n);
    }
}

# A `shared` function value is thread-safe, so a channel can carry work.
const tasks = async.Channel<async.ThreadBody>();
const ran = async.Atomic<long>(0);

run_tasks() {
    for (var task in tasks) {
        task();
    }
}

main(): int {
    var workers = Array<async.Thread>();
    var i = 0;
    while (i < 4) {
        workers.append(async.Thread.spawn(square_numbers));
        i = i + 1;
    }
    var n: long = 1;
    while (n <= 100) {
        jobs.send(n);
        n = n + 1;
    }
    jobs.close();   # no more work: each worker's loop ends
    for (var worker in workers) {
        worker.join();
    }
    results.close();

    var total: long = 0;
    for (var square in results) {
        total = total + square;
    }
    assert(total == 338350, "the sum of the first 100 squares");

    # receive() returns a Result: an error once the channel is closed and
    # empty. A closed channel refuses new values.
    assert(results.receive().is_err(), "nothing left");
    assert(!results.send(1), "closed");

    var runner = async.Thread.spawn(run_tasks);
    tasks.send(() => { ran.fetch_add(1); });
    tasks.send(() => { ran.fetch_add(10); });
    tasks.close();
    runner.join();
    assert(ran.load() == 11, "the runner ran both tasks");
    return 0;
}
  • Channel<T>(capacity) makes a channel that holds at most capacity values; send waits while it is full. Channel<T>() (capacity 0) is unbounded, and send never waits.
  • send(value) queues a value and returns true, or returns false if the channel is closed. try_send(value) does not wait: it also returns false when a bounded channel is full.
  • receive() waits for the next value and returns a Result<T>: the value, or an error once the channel is closed and empty. try_receive() returns an error right away when nothing is waiting, and receive_for(milliseconds) when the time runs out.
  • close() ends the stream and wakes every waiting sender and receiver. Values already queued are still received. Only close() closes a channel: dropping it, or ending the threads that use it, does not.
  • for (var v in channel) receives until the channel is closed and empty. Several threads can loop over one channel; each value goes to one of them.
  • Values from one sender arrive in the order they were sent.
  • The count property and is_closed() report the channel’s state, which other threads may change right after.

send and try_send take a new value, which nothing on the sending thread references, and hand it to the receiver without copying it: a constructor call, a literal, a call that returns a fresh value, move(x) (a variable that holds the only reference) or copy(x) (when the sender keeps using x). A String, an Array or a struct of them can be sent, and changing the original after sending a copy does not change what was sent:

import async;

# A plain struct: no destructor and no raw pointer, so `copy` copies it field
# by field. Its Strings and Array are not thread-safe, but a Channel takes
# only new values, and a copy is one.
struct Order {
    String customer;
    Array<String> items;
    Order next;

    construct(this.customer = "") {
        items = Array<String>();
    }
}

const orders = async.Channel<Order>();
const letters = async.Atomic<long>(0);

count_letters() {
    for (var order in orders) {
        for (var item in order.items) {
            letters.fetch_add(item.length as long);
        }
    }
}

main(): int {
    # copy() makes a new object for everything that is not thread-safe.
    var tea = Order("ada");
    tea.items.append("tea");
    var copied = copy(tea);
    copied.items[0].append(" and cake");
    assert(tea.items[0] == "tea", "the original is unchanged");

    # Objects referenced twice stay shared in the copy, and cycles are kept.
    var a = Order("a");
    var b = Order("b");
    a.next = b;
    b.next = a;
    var pair = copy(a);
    assert(pair.next.next === pair, "the copy has the same cycle");
    a.next = __null;
    pair.next = __null;

    # send() takes a new value: send a copy to keep changing the order.
    var worker = async.Thread.spawn(count_letters);
    var order = Order("grace");
    order.items.append("coffee");
    orders.send(copy(order));
    order.items.append("biscuits");
    # Or hand the value itself over with `move`; `last` is null afterwards.
    var last = Order("hopper");
    last.items.append("tea");
    orders.send(move(last));
    orders.close();
    worker.join();
    assert(letters.load() == 9, "the worker saw only what was sent");
    return 0;
}

Sending a variable that something else may still reference is an error, and the editor offers both fixes:

main.pep:7:16: error: 'line' is not a new value, but parameter 'value' takes a value that nothing else references
    7 |     lines.send(line);
      |                ^~~~
      |                move(line)
runtime/async/channel.pep:173:17: note: 'value' is declared [Move] here
  173 |     send([Move] value: T): bool {
      |                 ^~~~~
note: hand it over with 'move(line)', or pass a new value, such as 'copy(line)'

A thread-safe value (a number, a [Shared] struct, a shared function value such as a ThreadBody) passes freely, which makes a channel of ThreadBody values a work queue. Since nothing is copied, a channel can carry any type, trait values and values that cannot be copied (an io.File) included.

The Channel reference documents every method.

Waiting on several channels with Select

A Select<E> waits on several channels at once and takes the first value any of them receives (or, with a send arm, sends to the first with room), like Go’s select. Each channel is added with a function that turns its values into an E, usually a case of an enum of your own that carries the value, so the channels may carry different types and a match handles the result:

import async;

# Every value the select can return: one case per channel.
enum Event {
    Line(text: String),
    Reading(value: long),
    Stop,
}

const lines = async.Channel<String>();
const readings = async.Channel<long>();
const stop = async.Channel<bool>();

read_lines() {
    lines.send("first");
    lines.send("second");
    lines.close();
}

read_sensor() {
    var i: long = 1;
    while (i <= 3) {
        readings.send(i * 10);
        i = i + 1;
    }
    readings.close();
}

main(): int {
    var select = async.Select<Event>();
    select.receive(lines, (text: String): Event => Event.Line(text));
    select.receive(readings, (value: long): Event => Event.Reading(value));
    select.receive(stop, (unused: bool): Event => Event.Stop);

    var threads = [async.Thread.spawn(read_lines), async.Thread.spawn(read_sensor)];
    var text = 0;
    var total: long = 0;
    var running = true;
    while (running) {
        match (select.wait()) {
            Ok(event) => {
                match (event) {
                    Line(line) => {
                        text = text + 1;
                    }
                    Reading(value) => {
                        total = total + value;
                    }
                    Stop => {
                        running = false;
                    }
                }
            }
            Err(error) => {
                # Every channel is closed and empty (`stop` included, below).
                running = false;
            }
        }
        if (text == 2 && total == 60) {
            stop.close();
        }
    }
    for (var thread in threads) {
        thread.join();
    }
    assert(text == 2 && total == 60, "every value arrived once");
    return 0;
}
  • receive(channel, wrap) adds a channel. wrap runs on the waiting thread, with the value received.
  • wait() waits until a channel has a value and returns it as an E, in a Result<E>. wait_for(milliseconds) gives up after that long, and try_wait() does not wait at all.
  • The value is taken under that channel’s own lock, so no other receiver can take it between “ready” and “take”.
  • When several channels have values, each wait starts just after the channel that fired last, so one busy channel cannot starve the others.
  • A channel that is closed and empty is skipped. Once every channel is, wait() returns an error (every channel is closed). A timeout’s error is the time ran out.
  • A Select belongs to the thread (or task) that made it; it is not [Shared]. In a task, waiting parks the task, and a cancel or the task’s timeout ends the wait.

A Select can also wait to send, like a case ch <- v in Go’s select. send(channel, value, sent) adds a send arm: wait() fires it once channel has room (a bounded channel that is full waits), and returns what sent returns. Here a loop keeps receiving jobs while each result waits for room in a bounded channel:

import async;

# What one wait did: a job arrived, or a result went out.
enum Step {
    Job(value: long),
    Sent,
}

const jobs = async.Channel<long>();
# Holds one result at a time, so sending can wait.
const results = async.Channel<long>(1);

feed() {
    var i: long = 1;
    while (i <= 5) {
        jobs.send(i);
        i = i + 1;
    }
    jobs.close();
}

main(): int {
    var select = async.Select<Step>();
    select.receive(jobs, (value: long): Step => Step.Job(value));
    var feeder = async.Thread.spawn(feed);
    var received = 0;
    var waiting = 0;
    var total: long = 0;
    while (received < 5 || waiting > 0) {
        match (select.wait()) {
            Ok(step) => {
                match (step) {
                    Job(value) => {
                        received = received + 1;
                        # Goes into `results` once it has room; until then
                        # the select keeps receiving jobs.
                        select.send(results, value * value, (): Step => Step.Sent);
                        waiting = waiting + 1;
                    }
                    Sent => {
                        waiting = waiting - 1;
                        # Take the result back out, so the next send has room.
                        total = total + results.receive().value();
                    }
                }
            }
            Err(error) => {
                panic(error.message);
            }
        }
    }
    feeder.join();
    assert(total == 55, "every square was sent once");
    return 0;
}
  • value must be a new value, as for Channel.send (move(x), copy(x), a constructor call, …). The select holds it and moves it into the channel only when that arm fires, under the channel’s lock, so it is never sent when another arm fires first.
  • A send arm fires once: after its value is sent it leaves the select, so the next wait() does not send it again. Add another with send for the next value.
  • A send arm whose channel is closed is skipped like a closed receive channel, and its value is dropped with the select.

The Select reference documents every method.

Mutexes and conditions

A Mutex<T> owns a value that one thread at a time can use. The way to reach the value is a with block:

import async;

# Each balance is an Atomic, but moving money changes both, and no thread
# may see it in neither account. A Mutex makes the pair of updates one step.
[Shared]
struct Accounts {
    const checking: async.Atomic<long>;
    const savings: async.Atomic<long>;
    construct(long checking = 0, long savings = 0) {
        this.checking = async.Atomic<long>(checking);
        this.savings = async.Atomic<long>(savings);
    }
}

const accounts = async.Mutex<Accounts>(Accounts(1000, 0));
const bad_totals = async.Atomic<long>(0);

move_to_savings() {
    var i = 0;
    while (i < 1000) {
        with (var a = accounts.lock()) {
            a.checking.fetch_sub(1);
            a.savings.fetch_add(1);
        }   # unlocked here, on every path out of the block
        i = i + 1;
    }
}

audit() {
    var i = 0;
    while (i < 1000) {
        with (var a = accounts.lock()) {
            if (a.checking.load() + a.savings.load() != 1000) {
                bad_totals.fetch_add(1);
            }
        }
        i = i + 1;
    }
}

main(): int {
    var mover = async.Thread.spawn(move_to_savings);
    var auditor = async.Thread.spawn(audit);
    mover.join();
    auditor.join();
    assert(bad_totals.load() == 0, "the auditor never saw money missing");

    # try_lock does not wait: the body runs if the lock was free, and the
    # else block runs if another thread held it.
    with (var a = accounts.try_lock()) {
        assert(a.savings.load() == 1000, "everything was moved");
    } else {
        assert(false, "no other thread holds the lock now");
    }
    return 0;
}
  • with (var v = mutex.lock()) { ... } waits until no other thread holds the lock, binds the value, and unlocks when the block ends by any path: the end of the body, return, break or continue. Everything a thread did while it held the lock is visible to the next thread that takes it.
  • try_lock() does not wait. With with (var v = mutex.try_lock()) { ... } else { ... }, the body runs if the lock was free and the else block runs if it was not.
  • A Mutex is not re-entrant: calling lock() again on the thread that holds it panics instead of deadlocking. is_held() reports whether the calling thread holds it.
  • A panic while a Mutex is held ends the program, so a Mutex is never left “poisoned”.
  • Taking a free lock is one atomic instruction, and a thread that finds it held spins briefly before it sleeps. Locks sleep on the operating system’s wait-on-address call (futex on Linux, __ulock_wait on macOS, WaitOnAddress on Windows), as Rust’s do.

T is a struct type: a struct of your own, an Array<String>, or a [Shared] struct whose Atomic fields must change as a unit. Mutex<int> is an error (use async.Atomic<int>). A Mutex of values that are not thread-safe is kept apart from the rest of the program by the compiler; see What a lock body may do.

  • Mutex<T>(value) and replace(value) take a new value: a constructor call, a string literal, a call that returns a fresh value, copy(x), or move(x), never a variable something else still references. The caller hands its reference over, so nothing is copied.
  • replace(value) swaps the whole value under the lock and returns the old one, which the caller then owns outright.

A value built in a local variable goes in with move, which hands over the variable’s reference instead of copying the value:

import async;

main(): int {
    # Build the list here, then hand it to the Mutex: `move` gives up
    # `lines`' reference, so nothing is copied and `lines` is empty after.
    var lines = Array<String>();
    lines.append("first");
    var log = async.Mutex<Array<String>>(move(lines));

    var line = "second";
    with (var list = log.lock()) {
        # `line` holds the only reference to its String, so it can move in.
        list.append(move(line));
    }

    # Swap in a new list; the old one comes back without a copy either.
    var next = Array<String>();
    next.append("third");
    var old = log.replace(move(next));
    print($"{old.count}");     # 2
    print(old[1]);               # second
    return 0;
}

A Condition<T> lets a thread that holds a Mutex sleep until another thread changes the value. Get one with mutex.condition():

import async;

# A baker makes pies and eaters take them. The eaters sleep on a Condition
# until there is a pie, or until the bakery closes.
[Shared]
struct Bakery {
    const pies: async.Atomic<long>;
    const closed: async.Atomic<int>;
    construct() {
        pies = async.Atomic<long>(0);
        closed = async.Atomic<int>(0);
    }
}

const bakery = async.Mutex<Bakery>(Bakery());
const changed = bakery.condition();
const eaten = async.Atomic<long>(0);

eat() {
    while (true) {
        with (var b = bakery.lock()) {
            # Wait in a loop: a wakeup can be spurious, and another eater
            # may take the pie first.
            while (b.pies.load() == 0 && b.closed.load() == 0) {
                changed.wait();   # unlocks, sleeps, locks again
            }
            if (b.pies.load() == 0) {
                return;   # closed and nothing left; the block unlocks
            }
            b.pies.fetch_sub(1);
        }
        eaten.fetch_add(1);
    }
}

main(): int {
    var eaters = Array<async.Thread>();
    var i = 0;
    while (i < 3) {
        eaters.append(async.Thread.spawn(eat));
        i = i + 1;
    }
    i = 0;
    while (i < 50) {
        with (var b = bakery.lock()) {
            b.pies.fetch_add(1);
            changed.notify_one();
        }
        i = i + 1;
    }
    with (var b = bakery.lock()) {
        b.closed.store(1);
        changed.notify_all();
    }
    for (var eater in eaters) {
        eater.join();
    }
    assert(eaten.load() == 50, "every pie was eaten once");
    return 0;
}
  • wait() must run while the caller holds the Mutex, normally inside its with block. It unlocks, sleeps until notified, and locks again before returning. It panics if the caller does not hold the Mutex.
  • A thread can wake without a notification (a spurious wakeup), and another thread may get to the value first, so always wait in a loop that re-checks what it waits for.
  • wait_for(milliseconds) gives up after that long and returns false.
  • notify_one() wakes one waiting thread, and notify_all() wakes them all.

wait_while(predicate) writes that loop for you: it waits while predicate returns true for the locked value, asking again after every wakeup, and returns with the Mutex still locked. wait_while_for(milliseconds, predicate) gives up after that long and returns false:

import async;

# A worker takes jobs from a queue, sleeping while it is empty.
const jobs = async.Mutex<Array<long>>(Array<long>());
const changed = jobs.condition();
const done = async.Atomic<long>(0);

work() {
    var taken = 0;
    while (taken < 5) {
        with (var queue = jobs.lock()) {
            # Waits while the predicate holds; spurious wakeups are handled.
            # The predicate is given the locked value: it cannot capture it.
            changed.wait_while((waiting: Array<long>): bool => waiting.count == 0);
            done.fetch_add(queue.remove_at(0));
            taken = taken + 1;
        }
    }
}

main(): int {
    var worker = async.Thread.spawn(work);
    var i: long = 1;
    while (i <= 5) {
        with (var queue = jobs.lock()) {
            queue.append(i);
            changed.notify_all();
        }
        i = i + 1;
    }
    worker.join();
    assert(done.load() == 15, "every job was taken once");

    with (var queue = jobs.lock()) {
        # Gives up after 10 milliseconds: nothing is queued.
        var ready = changed.wait_while_for(10, (waiting: Array<long>): bool => waiting.count == 0);
        assert(!ready, "the time ran out");
    }
    return 0;
}

The predicate is given the locked value as its parameter, since a closure cannot capture part of a locked value. It must be a closure written at the call, and the compiler checks its body as it checks a lock body (see What a lock body may do): it cannot keep any part of the value, or bring in a value from outside.

main.pep:10:26: error: 'queue[0]' is part of the value locked by 'queue' and cannot outlive the lock
   10 |             saved.append(queue[0]);
      |                          ^~~~~~~~
main.pep:9:27: note: locked here
    9 |         ready.wait_while((queue: Array<String>): bool => {
      |                           ^~~~~
note: 'String' is not thread-safe, so no part of the locked value may be kept outside the block; take 'copy(queue[0])'

The Mutex reference documents Mutex, MutexGuard and Condition.

What a lock body may do

A value that is not thread-safe counts its references plainly, so no thread may hold a reference into a Mutex’s value while the Mutex is unlocked. The compiler checks each with block over a lock so that none can:

import async;

# A Mutex of values that are not thread-safe: a list of Strings that
# several threads add to.
const names = async.Mutex<Array<String>>(Array<String>());

add_names(prefix: String) {
    var i = 0;
    while (i < 3) {
        with (var list = names.lock()) {
            list.append("worker");            # a new String: fine
            list.append(prefix + "!");        # `+` returns a new String: fine
            list.append(copy(prefix));        # a copy of an outside value: fine
            var n = list.count;             # a number leaves freely
            if (list.contains(prefix)) {      # comparing keeps nothing
                n = n + 1;
            }
        }
        i = i + 1;
    }
}

# Part of the locked value leaves the block only as a copy.
first_name(): String {
    with (var list = names.lock()) {
        if (list.count > 0) {
            return list[0].copy();
        }
    }
    return "";
}

main(): int {
    var a = async.Thread.spawn(() => { add_names("a"); });
    var b = async.Thread.spawn(() => { add_names("b"); });
    a.join();
    b.join();

    var first = first_name();
    first.append(" (mine)");   # changing the copy leaves the list alone
    with (var list = names.lock()) {
        assert(list.count == 18, "every name was added");
        assert(list[0] == "worker", "the locked String is unchanged");
    }

    # replace swaps in a new value and hands back the old one, which the
    # caller then owns outright.
    var old = names.replace(Array<String>());
    assert(old.count == 18, "the old list");

    # A thread's own value that is not thread-safe is reached through `with`.
    var scratch = async.ThreadLocal<String>();
    scratch.set("draft");
    with (var text = scratch.enter()) {
        text.append(" 2");
    }
    with (var text = scratch.enter()) {
        assert(text == "draft 2", "this thread's value");
    }
    return 0;
}

Inside the block, every value that is not thread-safe is either part of the locked value (the binding, its fields and elements, and what calls on them return), from outside (a parameter, this, or a variable declared before the block), or new. A new value may join either side; the other two must never meet:

  • Moving in. A call, constructor or assignment that would link an outside value with the locked one is an error. Pass a new value instead: copy(line), a literal, or the result of a call that returns a fresh value. A variable that holds the only reference to its value can hand it over with move(line).

    main.pep:8:21: error: 'line' cannot move into the value locked by 'names'
        8 |         list.append(line);
          |                     ^~~~
    main.pep:7:28: note: locked here
        7 |     with (var list = names.lock()) {
          |                            ^~~~
    note: 'String' is not thread-safe, so the locked value cannot share it with the rest of the program; pass 'copy(line)'
    
  • Moving out. Storing part of the locked value outside the block, or returning it from the block, is an error; take a copy (copy(list[0]), or list[0].copy(), which returns a fresh value).

    main.pep:8:17: error: 'list[0]' is part of the value locked by 'names' and cannot outlive the lock
        8 |         first = list[0];
          |                 ^~~~~~~
    
  • Closures. A closure written in the block cannot capture part of the locked value, since a function value could carry it out.

  • Two locks. Values locked by two nested blocks stay apart as well.

  • Numbers pass freely, as do every thread-safe value and every new one.

Which calls link which values comes from what the called function does: the compiler works out, for every function, whether it keeps an argument (as append keeps its element) and whether its result shares anything with its arguments. A result that shares nothing, such as that of +, substr, copy() or a constructor, is fresh, and may cross in either direction. The language server shows “Returns a fresh value” when hovering over such a function. A call through a function value, a value an extern function hands back, and a value read through raw memory that a [Shared] struct holds are never fresh: something else may reference them. The runtime takes the few values it hands out that nothing else references out of raw memory with move(p[i]), a promise the compiler cannot check and allows only in the runtime: a channel’s receive() (the value send handed over) and a Mutex’s replace().

A function that returns a tuple has one such fact per element: after var (fresh, same) = split(line);, fresh may move into a locked value while same, which split returned from its argument, may not.

The guard a lock() returns can only be the scope of a with header: storing it in a variable or field is an error, so no lock outlives its block.

Two attributes carry these rules, and code outside the runtime may use them too:

  • [Isolated] on a struct makes it a guard: it can only be the scope of a with header, and the block is checked as above. MutexGuard<T> and ThreadLocalGuard<T> are [Isolated].
  • [Move] on a parameter (construct([Move] T value)) requires a new value for it at every call: a constructor call, a literal, a fresh result, copy(x), move(x), or any thread-safe value. The caller hands its reference over, so the function receives it without a count step.

Checking confinement at run time

Raw memory (__ptr<T>) and extern code are promises the compiler cannot check, so a program that uses them can still keep a reference into a locked value. Set the environment variable PEP_CHECK_SYNC when running a program to check at every unlock that nothing outside the locked value refers into it:

$ PEP_CHECK_SYNC=1 ./main
PEP_CHECK_SYNC: an object in the value locked by a 'Mutex<Box>' is still referenced from outside it

The check copies the locked value to count the references inside it, so it costs time and memory in proportion to the value’s size at each unlock; it is off unless the variable is set. It checks a Mutex<T> whose T is not thread-safe and can be copied.

Reader/writer locks

An RwLock<T> lets many threads read a value at once, or one thread write it:

import async;

# A price list that many threads read and one thread now and then updates.
# A writer changes both prices together, so a reader never sees one updated
# and the other not.
[Shared]
struct Prices {
    const coffee: async.Atomic<long>;
    const tea: async.Atomic<long>;
    construct() {
        coffee = async.Atomic<long>(300);
        tea = async.Atomic<long>(250);
    }
}

const prices = async.RwLock<Prices>(Prices());
const mismatches = async.Atomic<long>(0);

check_prices() {
    var i = 0;
    while (i < 1000) {
        with (var p = prices.read()) {   # many readers at once
            if (p.coffee.load() - p.tea.load() != 50) {
                mismatches.fetch_add(1);
            }
        }
        i = i + 1;
    }
}

raise_prices() {
    var i = 0;
    while (i < 100) {
        with (var p = prices.write()) {   # no reader sees the middle
            p.coffee.fetch_add(10);
            p.tea.fetch_add(10);
        }
        i = i + 1;
    }
}

main(): int {
    var threads = Array<async.Thread>();
    var i = 0;
    while (i < 3) {
        threads.append(async.Thread.spawn(check_prices));
        i = i + 1;
    }
    threads.append(async.Thread.spawn(raise_prices));
    for (var thread in threads) {
        thread.join();
    }
    assert(mismatches.load() == 0, "no reader saw a half-done update");
    with (var p = prices.read()) {
        assert(p.coffee.load() == 1300, "every raise landed");
    }
    return 0;
}
  • with (var v = lock.read()) { ... } waits until no writer holds or waits for the lock; any number of threads may read at the same time.
  • with (var v = lock.write()) { ... } waits until no other thread holds the lock at all.
  • try_read() and try_write() do not wait: the else block runs when the lock is not free.
  • replace(value) swaps the whole value under the write lock and returns the old one.
  • Writers come first: while a writer waits, new readers wait behind it, so readers cannot keep it out forever. A thread that reads must therefore not ask to read again before its block ends, and asking to write while holding the lock panics instead of deadlocking.

T must be thread-safe, as for Atomic<T>: typically a [Shared] struct whose Atomic fields a writer changes together. Readers that hold the value at once each copy a reference to it, so a value that is not thread-safe, such as an Array<String>, would have its reference counts changed by several threads at a time; it is a compile error at the type argument. Put such a value in a Mutex<T>. When readers only need the latest value and writers replace it whole, an Atomic<T> holding a [Shared] struct does the same job without a lock.

Semaphores, barriers and latches

Three types coordinate threads by counting. Each is [Shared], so keep it in a module-scope variable or pass it to threads:

  • A Semaphore(permits) lets at most permits threads hold a permit at once. acquire() waits for a free permit and takes it, try_acquire() takes one only if one is free, and release() gives one back. with (semaphore.permit()) { ... } holds a permit for the block and gives it back on every path out.
  • A Barrier(count) makes groups of count threads meet: wait() sleeps until count threads have called it, then all go on together. It returns true on exactly one of them, the leader, and resets for the next round.
  • A Latch(count) opens once count_down() has been called count times. wait() sleeps until it is open, and is_ready() asks without waiting. It is one-shot: counting down an open latch panics.
import async;

const WORKERS = 4;

# At most two workers use the printer at once.
const printer = async.Semaphore(2);
const printing = async.Atomic<long>(0);
const most_printing = async.Atomic<long>(0);

# Every worker finishes a step before any starts the next.
const step_done = async.Barrier(WORKERS);
const steps_led = async.Atomic<long>(0);

# The main thread waits until every worker has set up.
const ready = async.Latch(WORKERS);

work() {
    ready.count_down();
    var step = 0;
    while (step < 3) {
        with (printer.permit()) {
            var now = printing.fetch_add(1) + 1;
            if (now > most_printing.load()) {
                most_printing.store(now);
            }
            printing.fetch_sub(1);
        }   # the permit is given back here
        if (step_done.wait()) {
            steps_led.fetch_add(1);   # one leader per step
        }
        step = step + 1;
    }
}

main(): int {
    var workers = Array<async.Thread>();
    var i = 0;
    while (i < WORKERS) {
        workers.append(async.Thread.spawn(work));
        i = i + 1;
    }
    ready.wait();
    assert(ready.is_ready(), "every worker counted down");
    for (var worker in workers) {
        worker.join();
    }
    assert(most_printing.load() <= 2, "the semaphore let two in at most");
    assert(steps_led.load() == 3, "each step had exactly one leader");
    return 0;
}

Each is a Mutex over its counts with a Condition to sleep on, so in a task their waits park the task, not its worker.

Once and Lazy

Once runs a function exactly once, however many threads ask, and Lazy<T> builds a value the first time a thread asks for it:

import async;

[Shared]
struct Settings {
    const workers: long;
    construct(this.workers = 1) {}
}

const builds = async.Atomic<long>(0);

load_settings(): Settings {
    builds.fetch_add(1);
    return Settings(async.logical_cpus() as long);
}

# Built by the first thread that calls get(), not before main runs.
const settings = async.Lazy<Settings>(load_settings);

# Runs initialize() once, however many threads call it.
const setup = async.Once();
const setups = async.Atomic<long>(0);

initialize() {
    setups.fetch_add(1);
}

work() {
    setup.call(initialize);
    assert(settings.get().workers >= 1, "every thread sees the settings");
}

main(): int {
    assert(!settings.is_built(), "nothing is built before first use");
    var workers = Array<async.Thread>();
    var i = 0;
    while (i < 4) {
        workers.append(async.Thread.spawn(work));
        i = i + 1;
    }
    for (var worker in workers) {
        worker.join();
    }
    assert(builds.load() == 1, "the settings were built once");
    assert(setups.load() == 1, "initialize ran once");
    assert(setup.is_done(), "done");
    return 0;
}
  • once.call(body) runs body if no call has run one yet. Threads that call it while the body runs wait for it, and every caller sees what the body did. is_done() reports whether it has run.
  • Lazy<T>(initializer) takes a shared () => T function. The first get() runs it; later calls, from any thread, return the same value. is_built() reports whether it has run.
  • A Lazy<T> is the usual way to give a module-scope variable a value that is expensive to build, or that cannot be built before main runs. Its T must be thread-safe, since every thread gets the same value.

The Once reference has the details.

Limitations

  • Lazy<T> holds only thread-safe values.
  • A value received from a channel moves into a locked value directly (list.append(jobs.receive().value())), but not through a variable declared outside the block: such a variable is an outside value, whatever it holds, so it needs copy, or move when it holds the only reference (list.append(move(job))).
  • There is no reader-writer lock, and no barrier or semaphore. A Mutex is not re-entrant.
  • The value a Select’s wait() returns is not a new value for a lock body, since wrap is a function value the compiler cannot follow; copy it to move it into a locked value.
  • Deadlocks are not detected, except locking a Mutex twice on one thread, which panics.

Tasks

A task is a function that runs alongside others. Each task has its own small stack, and the tasks on a thread take turns: one runs until it waits (for a timer, for another task, or by yielding), and then another runs. A runtime has a few such threads, its workers, so tasks also run in parallel. This is the model of Go’s goroutines and of libuv or tokio event loops, without async/await: a function that waits looks like any other function, and any function can wait, however deep in the call stack. A socket call that has to wait parks only its task, so one worker can serve thousands of connections.

WASI cannot switch stacks, so on WebAssembly (--target wasm32-wasip1) starting tasks (async.run, async.run_with) panics (see WebAssembly).

Tasks live in the async module:

import async;

main(): int {
    var log = Array<String>();
    async.run(() => {
        # A task starts when the running task next waits.
        var worker = async.Task<int>.spawn_local(() => {
            log.append("worker starts");
            async.yield_now();
            log.append("worker ends");
            return 42;
        });
        log.append("first task");
        var answer = worker.join().value();   # waits; the worker runs
        assert(answer == 42, "join returns the body's value");
        log.append("joined");
    });
    assert(log[0] == "first task" && log[1] == "worker starts", "tasks take turns");
    assert(log[2] == "worker ends" && log[3] == "joined", "join waited");
    return 0;
}
  • async.run(body) runs body as the first task and returns once it has returned and every task it started has finished. run_with(options, body) does the same with settings; RuntimeOptions().stack_size(bytes) sets how much stack each task reserves (256 KiB by default).
  • Task<T>.spawn_local(body) starts a task on the running task’s worker and returns its Task<T>. The new task runs the next time the running task waits. T is the body’s result type; use Task<void> for a body that returns nothing.
  • task.join() waits for the task and returns a Result<T, TaskError>: the body’s value, or an error if the task was cancelled.
  • async.yield_now() lets every other ready task run first.
  • task.is_finished() says whether the body has returned.

A task from spawn_local stays on the thread of the task that started it, so it may use any value that task uses, as the closures above share log. Nothing needs to be thread-safe.

Several workers

run turns the calling thread into the first worker and starts one more thread per logical CPU after it (RuntimeOptions().workers(count) sets how many workers in all). async.spawn<T>(body) starts a task that whichever worker is free first runs, in parallel with the others:

import async;

sum(from: long, to: long): long {
    var total: long = 0;
    var i = from;
    while (i < to) {
        total = total + i;
        i = i + 1;
    }
    return total;
}

main(): int {
    async.run_with(async.RuntimeOptions().workers(4), () => {
        # Two halves of the work, on two workers at the same time.
        var low = async.spawn<long>(() => { return sum(0, 500000); });
        var high = async.spawn<long>(() => { return sum(500000, 1000000); });
        var total = low.join().value() + high.join().value();
        assert(total == 499999500000, "both halves were added");
    });
    return 0;
}

Because such a task may run on another thread, its body must be a shared function: it may capture only thread-safe values, as a thread’s body. Its result crosses threads too, so spawn constrains it T: Shared: a thread-safe type such as a number, a [Shared] struct, or a shared function type. async.spawn<String> is a compile error at String. join, cancel and is_finished work as for spawn_local, from the task that started it.

A task, once started, stays on its worker until it finishes, so the tasks it starts with spawn_local share values with it safely. An idle worker takes the next task that no worker has started yet. A task that computes without waiting still holds up the other tasks on its own worker (not the other workers), so a long computation should call yield_now() now and then.

async.spawn_on_each_worker(body) starts one task per worker, each on its own worker, and returns them in worker order. It is how a server runs an accept loop on every worker (see Sockets).

Blocking calls

Some work would hold a worker up: a call that blocks in the operating system (reading a file, a C library that waits) or a long computation. async.run_blocking<T>(body) runs body on a separate thread and parks the calling task until it returns, so the worker keeps running its other tasks meanwhile:

import async;

const ticks = async.Atomic<int>(0);

main(): int {
    async.run_with(async.RuntimeOptions().workers(1), () => {
        # Keeps running on the one worker while the blocking call waits.
        var ticker = async.Task<void>.spawn_local(() => {
            while (ticks.load() < 3) {
                async.sleep(5);
                ticks.fetch_add(1);
            }
        });
        var answer = async.run_blocking<int>(() => {
            async.Thread.sleep(50);   # blocks a pool thread, not the worker
            return 42;
        });
        assert(answer == 42, "the blocking call's result");
        assert(ticks.load() > 0, "the worker ran the ticker meanwhile");
        ticker.join();
    });
    return 0;
}

The runtime keeps a pool of threads for these calls. It starts one when a call finds none idle, up to RuntimeOptions().blocking_threads(count) (64 by default); past that, calls wait for a free one. As for spawn, body must be a shared function and T must be thread-safe. body always runs to the end: cancelling the task, or a timeout around the call, takes effect once the call has returned. Outside a task, run_blocking just calls body.

Waiting

async.sleep puts a task to sleep and runs the others meanwhile, so many tasks can wait at the same time on one thread. It takes an os.Duration (see The operating system), or a number of milliseconds; a duration that is not a whole number of milliseconds rounds up:

import async;
import os;

main(): int {
    var order = Array<int>();
    async.run(() => {
        var tasks = Array<async.Task<void>>();
        var i = 0;
        while (i < 3) {
            var n = i;
            # The later a task starts, the shorter it sleeps.
            tasks.append(async.Task<void>.spawn_local(() => {
                async.sleep(os.Duration.from_milliseconds(((3 - n) * 20) as ulong));
                order.append(n);
            }));
            i = i + 1;
        }
        for (var task in tasks) {
            task.join();
        }
    });
    # All three slept at the same time, on one thread.
    assert(order[0] == 2 && order[1] == 1 && order[2] == 0, "shortest sleep first");
    return 0;
}

Outside a task, sleep blocks the thread, and yield_now does nothing.

Tasks are never interrupted. A task that computes for a long time without waiting keeps every other task waiting, so a long loop should call yield_now() now and then.

Thread.sleep in a task is async.sleep.

Mutexes, channels and conditions

The synchronization types work in tasks too. When a task has to wait for a Mutex, for a value from a Channel or for room in a bounded one, in Condition.wait, for an RwLock, or in Semaphore.acquire, Barrier.wait or Latch.wait, it parks and its worker runs other tasks, just as it does for a socket. The task that holds the lock or will send the value can be on the same worker, on another worker, or a plain thread:

import async;

struct Log {
    var lines: Array<String>;
    construct() {
        this.lines = Array<String>();
    }
}

const log = async.Mutex<Log>(Log());
const jobs = async.Channel<int>(2);

main(): int {
    # One worker: every task below shares one thread.
    async.run_with(async.RuntimeOptions().workers(1), () => {
        # The consumer parks in the channel until a value arrives.
        var consumer = async.Task<int>.spawn_local(() => {
            var total = 0;
            for (var job in jobs) {
                total = total + job;
            }
            return total;
        });
        # A task may hold the lock while it waits; the other task parks
        # until it is free.
        var writer = async.Task<void>.spawn_local(() => {
            with (var l = log.lock()) {
                l.lines.append("first");
                async.sleep(10);
                l.lines.append("second");
            }
        });
        async.yield_now();
        with (var l = log.lock()) {
            l.lines.append("third");
        }
        writer.join();

        # The producer parks while the bounded channel is full.
        var i = 1;
        while (i <= 5) {
            jobs.send(i);
            i = i + 1;
        }
        jobs.close();
        assert(consumer.join().value() == 15, "every job arrived");
    });
    with (var l = log.lock()) {
        assert(l.lines[2] == "third", "the lock waited for the writer");
    }
    return 0;
}
  • Each task counts as its own holder of a Mutex, so two tasks on one worker exclude each other, and a task may hold a lock across its own waits (a sleep, a socket call, another lock).
  • A cancel, or a timeout around the call, ends a channel wait: send returns false and receive returns an error (and a for loop over the channel ends). A task waiting for a Mutex still gets the lock, and Condition.wait still waits for a notification, since the caller’s loop would only wait again; wait_for returns when its own time runs out.
  • A Select parks its task on every channel at once, and a cancel or timeout ends its wait with an error. Condition.wait_while parks like wait.
  • Outside a task they block the thread, as before.

Task-local values

A TaskLocal<T> is a variable with a separate value in every task, as a ThreadLocal<T> has one per thread. Tasks that take turns on one worker share its thread, so only a TaskLocal can tell them apart:

import async;

# The id of the request the running task is handling.
const request_id = async.TaskLocal<long>();

# Deep in the call chain, without the id passed down as a parameter.
current_request(): long {
    return request_id.get();
}

main(): int {
    # One worker: the three tasks take turns on one thread, so a
    # ThreadLocal could not tell them apart.
    async.run_with(
        async.RuntimeOptions().workers(1),
        () => {
            var tasks = Array<async.Task<bool>>();
            for (var id in [1, 2, 3]) {
                tasks.append(
                    async.Task<bool>.spawn_local(
                        () => {
                            request_id.set(id);
                            async.yield_now();  # the other tasks set their ids meanwhile
                            return current_request() == id;
                        }
                    )
                );
            }
            for (var task in tasks) {
                assert(task.join().value(), "each task sees its own id");
            }
            # This task never set one.
            assert(!request_id.is_set(), "a task starts with no value");
        }
    );
    return 0;
}
  • set(value) stores the calling task’s value and get() returns it (the default of T, zero or null, until the task sets one). is_set() and clear() work as on a ThreadLocal.
  • A task starts with no value, even one started by a task that has one, and its values are released when it finishes.
  • Code outside any task (before async.run, or on a plain Thread) has one value per thread, like a ThreadLocal.
  • For a T that is not thread-safe, such as a String, reach the value with with (var value = local.enter()) { ... }, as for a ThreadLocal; get() is then a compile error.

Sockets

A socket call in a task that would have to wait (for a connection, for data, or for room to send) parks the task and lets the others run until the socket is ready. The socket API is the same one a plain program uses; only the waiting differs:

import async;
import io;

# Echoes everything a client sends until it closes the connection.
serve(stream: io.TcpStream) {
    var buffer = Array<u8>();
    while (true) {
        var r = stream.read(buffer, 4096);   # parks this task until data arrives
        if (r.is_err() || r.value() == 0) {
            return;
        }
        stream.write_all(buffer);
        buffer.clear();
    }
}

main(): int {
    async.run(() => {
        var listener = io.TcpListener.bind(io.SocketAddress.parse("127.0.0.1:0").value()).value();
        var address = listener.local_address().value();
        var clients = 10;

        # The server: one task per connection, all on this thread.
        async.Task<void>.spawn_local(() => {
            while (true) {
                var accepted = listener.accept();
                if (accepted.is_err()) {
                    return;   # cancelled when the first task returns
                }
                var (stream, peer) = accepted.value();
                async.Task<void>.spawn_local(() => { serve(stream); });
            }
        });

        # The clients, as tasks on the same thread.
        var replies = Array<async.Task<ulong>>();
        var i = 0;
        while (i < clients) {
            var k = i;
            replies.append(async.Task<ulong>.spawn_local(() => {
                var stream = io.TcpStream.connect(address).value();
                var message = $"hello from {k}";
                stream.write_all(message);
                var buffer = Array<u8>();
                stream.read_exact(buffer, message.size);
                return buffer.count;
            }));
            i = i + 1;
        }
        for (var reply in replies) {
            assert(reply.join().value() > 0, "the server echoed the message");
        }
    });
    return 0;
}

Outside a task, the same calls block the thread as before. A socket belongs to the worker whose task made it: tasks started with spawn_local can share it, and each spawned task makes its own.

Waiting on several sockets

To wait on whichever of several sockets is ready first, give each socket a task that reads it and sends what it read to a channel, and wait on the channels with a Select. Closing a channel when its socket closes lets the select stop waiting on it. Tasks are cheap, so this costs little, and the reading code stays the ordinary blocking-style loop:

import async;
import io;

# What the select returns: bytes that arrived on one of the connections.
enum Event {
    Data(from: int, count: ulong),
}

# Reads `stream` until the peer closes it, sending the size of each read to
# `into`, then closes `into` so the select stops waiting on it.
forward(stream: io.TcpStream, into: async.Channel<ulong>) {
    var buffer = Array<u8>();
    while (true) {
        var r = stream.read(buffer, 4096);   # parks this task, not the others
        if (r.is_err() || r.value() == 0) {
            into.close();
            return;
        }
        into.send(r.value());
        buffer.clear();
    }
}

main(): int {
    async.run(() => {
        var listener = io.TcpListener.bind(io.SocketAddress.parse("127.0.0.1:0").value()).value();
        var address = listener.local_address().value();
        var first = io.TcpStream.connect(address).value();
        var (from_first, first_peer) = listener.accept().value();
        var second = io.TcpStream.connect(address).value();
        var (from_second, second_peer) = listener.accept().value();

        # One task per socket turns what it reads into channel messages.
        var first_bytes = async.Channel<ulong>();
        var second_bytes = async.Channel<ulong>();
        async.Task<void>.spawn_local(() => { forward(from_first, first_bytes); });
        async.Task<void>.spawn_local(() => { forward(from_second, second_bytes); });

        first.write_all("hello");
        first.shutdown_write();
        second.write_all("hi there");
        second.shutdown_write();

        # The select then waits on both sockets at once, through their channels.
        var select = async.Select<Event>();
        select.receive(first_bytes, (count: ulong): Event => Event.Data(0, count));
        select.receive(second_bytes, (count: ulong): Event => Event.Data(1, count));
        ulong first_total = 0;
        ulong second_total = 0;
        var running = true;
        while (running) {
            match (select.wait()) {
                Ok(event) => {
                    match (event) {
                        Data(from, count) => {
                            if (from == 0) {
                                first_total = first_total + count;
                            } else {
                                second_total = second_total + count;
                            }
                        }
                    }
                }
                Err(error) => {
                    running = false;   # both connections closed
                }
            }
        }
        assert(first_total == 5 && second_total == 8, "every byte arrived once");
    });
    return 0;
}

Files

File.read, write and write_all in a task park the task until the system call is done, and the worker runs other tasks meanwhile. With the IoUring backend the kernel does the read or write itself; with the others a thread from run_blocking’s pool makes the blocking call. Opening, seeking, metadata and the other File calls still block the thread, as they are quick on local disks. A cancel or a timeout does not cut a read or write short: it takes effect once the call returns.

The array a read fills or a write sends from is in use until the call returns, so growing it from another task meanwhile panics (“Array: the buffer is in use by an I/O operation”). A String passed to write_all is copied first.

Accepting on every worker

An io.SharedTcpListener is a listening socket that any thread can accept on. Give each worker an accept loop with spawn_on_each_worker; each connection goes to one of the waiting workers, and the stream it returns belongs to that worker:

import async;

import io;

const served = async.Atomic<int>(0);

main(): int {
    async.run_with(async.RuntimeOptions().workers(4), () => {
        var listener = io.SharedTcpListener.bind(io.SocketAddress.parse("127.0.0.1:0").value()).value();
        var port = listener.local_address().value().port();

        # One accept loop per worker; each connection goes to one of them.
        async.spawn_on_each_worker(() => {
            while (true) {
                var accepted = listener.accept();
                if (accepted.is_err()) {
                    return;   # cancelled when the first task returns
                }
                var (stream, peer) = accepted.value();
                served.fetch_add(1);
                async.Task<void>.spawn_local(() => {
                    stream.write_all("hello").value();
                });
            }
        });

        var i = 0;
        while (i < 8) {
            var client = io.TcpStream.connect_host("127.0.0.1", port).value();
            var reply = Array<u8>();
            while (client.read(reply, 64).value() > 0) {
            }
            assert(reply.count == 5, "greeted");
            i = i + 1;
        }
    });
    assert(served.load() == 8, "every connection was accepted once");
    return 0;
}

The listener has no close(), since other workers may be waiting on it. It closes when the last reference goes: here, once run has cancelled the loops.

Moving a stream to another worker

stream.into_transfer() moves a connection out of a TcpStream into an io.SocketTransfer, a thread-safe token that a shared function can capture or a Channel can carry, and io.TcpStream.adopt(transfer) takes it out again on the receiving worker. The old stream is closed afterwards, only the first adopt gets the connection, and a token no one adopts closes the connection when it goes:

import async;
import io;

main(): int {
    async.run_with(async.RuntimeOptions().workers(2), () => {
        var listener = io.TcpListener.bind(io.SocketAddress.parse("127.0.0.1:0").value()).value();
        var port = listener.local_address().value().port();
        var client = async.Task<ulong>.spawn_local(() => {
            var stream = io.TcpStream.connect_host("127.0.0.1", port).value();
            var reply = Array<u8>();
            while (stream.read(reply, 64).value() > 0) {
            }
            return reply.count;
        });

        var (stream, peer) = listener.accept().value();
        # A stream is not thread-safe; its transfer token is.
        var transfer = stream.into_transfer();
        var handler = async.spawn<void>(() => {
            var mine = io.TcpStream.adopt(transfer).value();
            mine.write_all("from any worker").value();
        });
        handler.join();
        assert(client.join().value() == 15, "the moved stream answered");
    });
    return 0;
}

When no task is ready to run, a worker blocks until a socket is ready, the next timer is due, or another worker wakes it; while tasks are running, it also checks the sockets every few dozen tasks, so a busy task cannot starve the network.

Backends

How the runtime waits for sockets is its I/O backend, an async.Backend:

BackendWhereHow
EpollLinuxepoll, edge-triggered
IoUringLinux 5.11 and laterio_uring: the kernel does stream sockets’ receives and sends and files’ reads and writes itself, submitted in batches, with deferred completion work; never picked automatically, since container seccomp profiles and hardened kernels often refuse it
KqueuemacOSkqueue, edge-triggered
IocpWindowsan I/O completion port, fed by AFD poll requests (as tokio and libuv do)
Polleverywherepoll() (WSAPoll on Windows), which looks at every waiting socket on each wait: fine for a few connections, slow for thousands

Backend.Automatic, the default, picks epoll, kqueue or IOCP, whichever the system has. RuntimeOptions().backend(kind) asks for one (and run_with panics if the system does not have it), and the PEPPER_IO_BACKEND environment variable (epoll, io_uring, kqueue, iocp or poll) overrides the automatic choice without changing the program, for testing and benchmarks. async.current_backend() says which one is running.

io_uring pays off as the amount of multiplexed I/O grows: many operations in flight, submitted and completed in batches through the ring rather than one system call each. With the IoUring backend, a stream socket’s read and write in a task go to the kernel as a receive or send request, which it runs once the socket is ready; the worker submits every request queued since its last look, and collects every completion, in one system call. In our echo benchmark that makes about an eighth of epoll’s system calls, runs as fast as epoll from 64 to 1,024 connections (half again as fast as io_uring used only for readiness), and stays about 10% behind at 4,096.

A cancel or a timeout asks the kernel to stop a request and waits for it to finish, since the kernel is writing into or reading from the array until then. A receive that got data before the cancel took effect returns that data instead of failing, so no bytes are lost. As with files, growing the array from another task meanwhile panics. Accepting, connecting, UDP sockets, and a second task reading (or writing) a socket that one already reads (or writes) still wait for readiness.

Timeouts

async.timeout(milliseconds) limits how long the waits inside a with block may take:

import async;
import io;

main(): int {
    async.run(() => {
        var listener = io.TcpListener.bind(io.SocketAddress.parse("127.0.0.1:0").value()).value();

        # Nobody connects, so the accept gives up after 50 milliseconds.
        with (var deadline = async.timeout(50)) {
            var accepted = listener.accept();
            assert(accepted.is_err() && accepted.error().is_timed_out(), "accept timed out");
            assert(deadline.is_expired(), "the limit has passed");
        }

        # A join gives up too; the task itself carries on until cancelled.
        var slow = async.Task<int>.spawn_local(() => {
            async.sleep(1000000);
            return 42;
        });
        with (async.timeout(20)) {
            var result = slow.join();
            assert(result.error().is_timed_out(), "join timed out");
        }
        slow.cancel();
    });
    return 0;
}

Once the limit passes, a socket call that has to wait fails with an IoError for which is_timed_out() holds, join fails with a TaskError for which is_timed_out() holds (the task it waited for carries on), and sleep returns early. Code that does not wait is never interrupted. The limit ends with the block. Timeouts nest, and an inner one cannot extend an outer one. deadline.is_expired() says whether the limit has passed. timeout panics outside a task.

Cancelling

task.cancel() asks a task to stop. Nothing stops it by force: from then on async.is_cancelled() is true inside it, sleep returns at once, and join on it fails with a TaskError whose is_cancelled() is true. A task waiting in join returns the same error when it is cancelled itself, and a socket call waiting in a cancelled task fails with an IoError whose is_cancelled() is true. When the first task of a run returns, every task still running is cancelled, on every worker, and run waits for each to finish:

import async;

main(): int {
    var polls = Array<int>();
    async.run(() => {
        var poller = async.Task<void>.spawn_local(() => {
            while (!async.is_cancelled()) {
                polls.append(1);
                async.sleep(5);
            }
        });
        async.sleep(30);
        poller.cancel();
        var result = poller.join();
        assert(result.is_err() && result.error().is_cancelled(), "a cancelled task's join fails");

        # A task still running when the first task returns is cancelled too,
        # and run waits for it to finish.
        async.Task<void>.spawn_local(() => {
            async.sleep(1000000);   # returns at once when cancelled
        });
    });
    assert(polls.count > 0, "the poller ran until cancelled");
    return 0;
}

If every task is waiting for another task and none can continue, run panics instead of waiting forever.

How it works

Each task runs on a fiber: a stack of its own and a saved set of registers. Switching tasks saves the running task’s registers on its stack and restores the other’s, a few instructions of assembly on Linux and macOS (the __switch_context intrinsic) and the Fibers API on Windows. A task’s stack is reserved address space with a guard page below it; only the pages the task touches use memory, and a task that overflows its stack crashes the program as a thread would. Stacks are reused for later tasks.

Each worker has its own ready queue, timers and backend. Workers talk through a queue of messages each (a spawned task finished, was cancelled, or was woken by a Mutex, Condition or Channel) and a doorbell that ends a worker’s wait in its backend from another thread: an eventfd with epoll and io_uring, a user event with kqueue, a posted completion with IOCP, and a pipe (on Windows, a loopback UDP socket that a second socket sends to) with poll(). run_blocking’s pool threads are plain threads that report back through the same messages.

Current status

  • File calls other than read, write and write_all (opening, seeking, sync, directories) block the thread in a task.
  • On Windows, a ThreadLocal has a separate value in each task (it is built on fiber local storage); elsewhere the tasks of a thread share it.

The reference page documents every function.

Files and directories

The io module reads and writes files, lists and creates directories, and inspects and changes what the file system records about them: permission bits, timestamps and symbolic links. Every call that touches the file system returns an IoResult, whose error says what went wrong portably (is_not_found(), is_permission_denied(), is_already_exists(), …; see Error handling with Result).

  • Path is a file-system path. Joining, parent(), file_name(), extension() and normalize() work on its text alone; exists(), metadata(), canonicalize() and the set_* methods below ask the file system.
  • File is an open file. File.open reads, File.create writes a new or emptied file, and OpenOptions describes anything else (append, create only if missing, …). read_to_string, write_text, read_bytes, write_bytes, copy, rename and remove work on a whole file by path.
  • Directory creates and removes directories (create, create_all, remove, remove_all) and lists one, all at once with entries() or one entry at a time with for.
  • Metadata is a snapshot of a path’s kind, size, permission bits and timestamps, from Path.metadata() (which follows symlinks), Path.symlink_metadata() (which does not) or File.metadata().
# Write, read, list and remove files with io.
import io;

main(): int {
    var folder = io.Directory.temp().join("pepper-files-basics-example");
    io.Directory.remove_all(folder);
    io.Directory.create_all(folder.join("notes")).value();

    # Whole files at once.
    var path = folder.join("notes").join("today.txt");
    io.File.write_text(path, "buy milk\n").value();
    assert(io.File.read_to_string(path).value() == "buy milk\n", "read back");

    # An open File, with OpenOptions for anything but read or replace.
    var log = io.OpenOptions().append(true).create(true).open(folder.join("log.txt")).value();
    log.write_all("started\n").value();
    log.close().value();

    # Metadata, and a directory's entries.
    var m = path.metadata().value();
    assert(m.is_file && m.size == 9, "a 9-byte file");
    var names = Array<String>();
    for (var entry in io.Directory.open(folder).value()) {
        names.append(entry.value().name);
    }
    assert(names.count == 2, "log.txt and notes");

    io.Directory.remove_all(folder).value();
    return 0;
}

Permissions

A file’s permission bits say who may read, write and run it. They are the platform’s own bits, as Metadata.permissions reports them:

  • On Linux and macOS, the POSIX mode_t bits: read, write and execute for the owner, the group and everyone else, plus setuid, setgid and sticky. Pepper has no octal literals, so rwxr-xr-x (octal 755) is written 493, and a comment gives the octal. On a directory, read lets one list it, write lets one add and remove entries, and execute lets one reach anything inside it.
  • On Windows, the FILE_ATTRIBUTE_* word. Only FILE_ATTRIBUTE_READONLY is a permission; whether a file runs depends on its extension.
  • WASI has no permission bits.

Three ways to change them:

  • set_permissions(mode) on a Path or an open File sets the raw bits (chmod and fchmod). On Windows it applies only the read-only bit of mode, and on WASI it returns an error.
  • set_read_only(bool) is the portable read-only switch. On POSIX, read-only clears every write bit and writable sets only the owner’s, so this never opens a file to others. On Windows it sets or clears FILE_ATTRIBUTE_READONLY.
  • set_executable(bool) adds an execute bit wherever there is a read bit, as chmod +x does, or clears them all. It does nothing on Windows and WASI.

A file or directory can also be created with its bits, so that it never exists with the default ones even for a moment: OpenOptions.mode(m) for a new file, and Directory.create(path, mode = m) or create_all(path, mode = m) for a directory. The process umask still applies, as it does to open and mkdir. These are ignored on Windows and WASI.

Timestamps

Metadata.modified and Metadata.accessed are nanoseconds since 1970-01-01 UTC. set_modified(time) and set_accessed(time) on a Path or an open File set one and leave the other (utimensat and futimens; SetFileTime on Windows, which keeps 100 ns). Writing to a file sets its modification time, and adding or removing an entry sets a directory’s, so set a time after the contents are final.

# Permission bits and timestamps: make a script executable, protect a file,
# and copy a modification time.
import io;

main(): int {
    var folder = io.Directory.temp().join("pepper-files-permissions-example");
    io.Directory.remove_all(folder);
    # 448 is octal 700: only the owner may list or enter it (POSIX only).
    io.Directory.create(folder, mode = 448).value();

    # Created with mode 493 (octal 755, rwxr-xr-x) less the umask, so the
    # script is never readable by others without being executable.
    var script = folder.join("run.sh");
    var file = io.OpenOptions().write(true).create_new(true).mode(493).open(script).value();
    file.write_all("#!/bin/sh\necho hello\n").value();
    file.close().value();

    # The portable way: add an execute bit where there is a read bit.
    var notes = folder.join("notes.sh");
    io.File.write_text(notes, "#!/bin/sh\n").value();
    notes.set_executable(true).value();
    [Target(OS(Linux) || OS(Mac))] {
        assert((notes.metadata().value().permissions & 64) != 0, "the owner may run it");
    }

    # Read-only: no write bits on POSIX, FILE_ATTRIBUTE_READONLY on Windows.
    [Target(!OS(Wasi))] {
        notes.set_read_only(true).value();
        assert(notes.metadata().value().read_only, "read-only");
        notes.set_read_only(false).value();
    }

    # Give the script the same modification time as the notes.
    var modified = notes.metadata().value().modified;
    script.set_modified(modified).value();
    assert(script.metadata().value().modified == modified, "same time");

    io.Directory.remove_all(folder).value();
    return 0;
}

On Windows, an open File must have been opened for writing to change its read-only bit or its times.

File.symlink(target, link) and Directory.symlink(target, link) create a symbolic link at link pointing to target. POSIX makes one kind of link either way; Windows makes a file link and a directory link differently, and a file link to a directory does not open, so use the one that matches the target. The target is stored as given and need not exist; a relative target is resolved from the directory holding the link.

Path.read_link() returns the target a link stores, without following it. Path.symlink_metadata() describes the link itself, and metadata() what it points to. Directory.remove_all removes links and never follows them.

# Symbolic links: make one to a file and one to a directory, and read them.
import io;

main(): int {
    var folder = io.Directory.temp().join("pepper-files-symlinks-example");
    io.Directory.remove_all(folder);
    io.Directory.create_all(folder.join("releases").join("1.2")).value();
    io.File.write_text(folder.join("app-1.2.conf"), "port = 80\n").value();

    # Windows needs Developer Mode or an administrator to make symlinks.
    [Target(!OS(Windows))] {
        # A relative target is resolved from the folder holding the link.
        var config = folder.join("app.conf");
        io.File.symlink(io.Path("app-1.2.conf"), config).value();
        assert(io.File.read_to_string(config).value() == "port = 80\n", "read through the link");
        assert(config.read_link().value() == io.Path("app-1.2.conf"), "the stored target");
        assert(config.symlink_metadata().value().is_symlink, "the link itself");

        var current = folder.join("current");
        io.Directory.symlink(io.Path("releases").join("1.2"), current).value();
        assert(current.is_directory(), "a directory through the link");
    }

    # remove_all removes links, never what they point to.
    io.Directory.remove_all(folder).value();
    return 0;
}

Making a symlink on Windows needs Developer Mode or an administrator; without either it fails with is_permission_denied().

Moving a file to another thread

A File is not thread-safe, so a thread cannot capture one. To hand an open file to another thread, such as the pipe from a child process’s output to a thread that reads it while this one writes to the child, file.into_transfer() moves its handle into an io.FileTransfer, a thread-safe token that a shared function can capture or a Channel can carry, and io.File.adopt(transfer) takes it out again on the other thread. The File it came from is closed afterwards, only the first adopt gets the handle, and a token no one adopts closes the file when it goes. A stream moves the same way, as a SocketTransfer (see Moving a stream to another worker).

# Hand an open File to another thread with into_transfer and adopt.
import async;
import io;

main(): int {
    var path = io.Directory.temp().join("pepper-files-transfer-example.txt");
    io.File.write_text(path, "read on another thread").value();
    var file = io.File.open(path).value();
    # A File is not thread-safe; its transfer token is.
    var transfer = file.into_transfer();
    var size = async.Atomic<ulong>(0);
    var reader = async.Thread.spawn(() => {
        var mine = io.File.adopt(transfer).value();
        size.store(mine.read_all().value().count);
    });
    reader.join();
    assert(size.load() == 22, "the other thread read the file");
    assert(!file.is_open(), "the File it came from is closed");
    io.File.remove(path);
    return 0;
}

Archives

archive.TarReader.extract uses all of the above: it restores each file’s mode and modification time and makes the archive’s symlinks, as long as they stay inside the directory it extracts into. See Compression and archives.

Networking

The io module has TCP, UDP and Unix domain sockets, IP and socket addresses, and host name lookup; the tls module encrypts TCP connections, and the http module speaks HTTP over them. In a plain program every call blocks the calling thread until it completes, like File. To serve several connections at once, give each one its own thread, or its own task: in a task, a call that has to wait parks only that task.

On WebAssembly (--target wasm32-wasip1) there are no sockets, host name lookups or TLS: each of those calls returns an IoError saying it is not supported on WASI (see WebAssembly).

Addresses

An IpAddress is an IPv4 or IPv6 address. A SocketAddress is an IP address and a port:

import io;

main(): int {
    var home = io.IpAddress.v4(127, 0, 0, 1);
    assert(home.is_loopback(), "127.0.0.1 is loopback");

    # Text is read strictly and printed in one canonical form.
    var six = io.IpAddress.parse("2001:DB8:0:0:0:0:0:1").value();
    assert(six.to_string() == "2001:db8::1", "IPv6 prints compressed and lowercase");
    assert(io.IpAddress.parse("256.1.1.1").error().is_invalid_input(), "256 is out of range");

    var service = io.SocketAddress.parse("[::1]:8080").value();
    assert(service.is_v6() && service.port() == 8080, "IPv6 with a port");
    assert(service.to_string() == "[::1]:8080", "brackets around IPv6");

    # A link-local IPv6 address names its interface after `%`: a number, or
    # a name such as `eth0` that is looked up on this machine.
    var link = io.SocketAddress.parse("[fe80::1%4000000000]:8080").value();
    assert(link.address().scope_id() == 4000000000, "the scope id");

    # A host name goes to the system resolver; an IP address is used as is.
    var local = io.resolve("localhost", 80).value();
    assert(local.count >= 1 && local[0].address().is_loopback(), "localhost is loopback");
    return 0;
}
  • IpAddress.v4(a, b, c, d) and IpAddress.v6(a, ..., h) build an address from its bytes or its eight 16-bit groups. loopback_v4(), loopback_v6(), unspecified_v4() (0.0.0.0) and unspecified_v6() (::) are the well-known ones. IpAddress() is 0.0.0.0.
  • IpAddress.parse reads dotted decimal IPv4 and the RFC 4291 text form of IPv6, and to_string() prints the RFC 5952 form: lowercase, zeros compressed, ::ffff:1.2.3.4 for an IPv4-mapped address. Parsing and printing are the same on every platform.
  • An IPv6 address may carry a scope id, the network interface a link-local address belongs to: fe80::1%eth0 or fe80::1%2 (RFC 4007). scope_id() reads it (0 for none) and with_scope_id(id) sets it. A name is looked up on this machine, and to_string() prints the interface’s name when it has one, else the number. Sockets pass it to the system as sin6_scope_id. In a URI the % is escaped, http://[fe80::1%25eth0]/ (RFC 6874); the HTTP client decodes it.
  • SocketAddress(address, port) or SocketAddress.parse("1.2.3.4:80") make a socket address. IPv6 takes brackets: [::1]:80.
  • resolve(host, port) looks a host name up with the system resolver (the hosts file and DNS) and returns every address it has, with port attached. An IP address is returned as is. A name with no address fails with is_not_found().

TCP

A TcpListener waits for connections. Each TcpStream is one connection:

import io;

# A server and a client on this machine, taking turns on one thread: the
# system completes a connection before the server accepts it.
main(): int {
    # Port 0 asks the system for any free port.
    var listener = io.TcpListener.bind(io.SocketAddress.parse("127.0.0.1:0").value()).value();
    var address = listener.local_address().value();

    var client = io.TcpStream.connect(address).value();
    var (server, peer) = listener.accept().value();
    assert(peer == client.local_address().value(), "accept reports who connected");

    # The client sends a request and closes its sending side.
    assert(client.write_all("ping").is_ok(), "send the request");
    client.shutdown_write();

    # The server reads until the client is done: read returns 0 at the end.
    var request = Array<u8>();
    while (server.read(request, 4096).value() > 0) {
    }
    assert(request.count == 4, "the whole request arrived");

    # It answers, and the client reads exactly the bytes it expects.
    server.write_all("pong");
    var reply = Array<u8>();
    assert(client.read_exact(reply, 4).is_ok(), "read the reply");
    assert(reply[0] == 'p' && reply[1] == 'o', "the reply is pong");
    return 0;
}
  • TcpListener.bind(address) listens on a local address. Port 0 lets the system pick a free port, and local_address() says which one it picked. accept() waits for the next connection and returns the stream and the peer’s address as a tuple. for (var connection in listener.incoming()) accepts one connection per step, yielding an IoResult<TcpStream> (see Iteration).
  • TcpStream.connect(address) connects to an address. TcpStream.connect_host(host, port) resolves a name and tries each of its addresses in turn.
  • read(buffer, max) waits until some data arrives, then appends up to max bytes of it to buffer, the same way File.read does. It returns 0 once the peer has closed its side. read_exact(buffer, count) loops until count bytes have arrived.
  • write(data) sends what the system will take in one call. write_all(data) loops until everything is sent; data is a u8[] or a String.
  • shutdown_write() tells the peer that nothing more is coming, while this side can still read.
  • set_no_delay(true) sends small writes immediately instead of combining them.
  • local_address() and peer_address() return the two ends of the connection.

Reading and writing any stream

A File, a TcpStream, a UnixStream and a tls.TlsStream have the io.Reader and io.Writer traits, so a function that takes an io.Reader (or <R: io.Reader>) reads from any of them:

import io;

# Any stream that is an io.Reader: a File, a socket, or a TLS stream.
count_lines(source: io.Reader): int {
    var count = 0;
    for (var line in source.lines()) {
        if (line.is_err()) {
            break;
        }
        count += 1;
    }
    return count;
}

# `print` and `print_line` write a value's text to any io.Writer, the way the
# global `print` writes to standard output.
main(): int {
    var path = io.Directory.temp().join("networking-print.txt");
    var file = io.File.create(path).value();
    file.print("total: ");
    file.print_line(42);
    file.print_line((1, "two"));
    file.close();
    assert(io.File.read_to_string(path).value() == "total: 42\n(1, two)\n", "the file holds both lines");
    assert(count_lines(io.File.open(path).value()) == 2, "a File is a Reader");
    io.File.remove(path);

    # The same calls work on a TcpStream (and a UnixStream).
    var listener = io.TcpListener.bind(io.SocketAddress.parse("127.0.0.1:0").value()).value();
    var client = io.TcpStream.connect(listener.local_address().value()).value();
    var (server, peer) = listener.accept().value();
    client.print_line($"hello from {client.local_address().value()}");
    client.print_line("bye");
    client.shutdown_write();
    assert(count_lines(server) == 2, "a TcpStream is a Reader");
    return 0;
}
  • io.Reader needs read(buffer, max) and gives lines(), the stream’s lines as IoResult<String>s, and chunks(size), its bytes in arrays of at most size, both read as a for loop asks for them (see Iteration).
  • io.Writer needs write(data) and write_all(data: u8[]) and gives print(value) and print_line(value). They write the text that to_string makes of any value, as the global print does on standard output; print_line adds a \n. Both return an IoResult<void>, like write_all.
  • io.ByteReader(bytes) reads an array of bytes as a stream, and io.ByteWriter() collects what is written in its bytes array, for code that takes a stream when the data is in memory.
  • compress.DecompressReader and compress.CompressWriter wrap another stream (see Compression and archives).
  • Another type gets them by providing those methods and declaring extend MyStream with io.Reader; or extend MyStream with io.Writer;.
  • io.Print, which gave only print and print_line, is deprecated: use io.Writer.

UDP

A UdpSocket sends and receives datagrams. Each one arrives whole or not at all, and in no guaranteed order:

import io;

main(): int {
    var loopback = io.SocketAddress(io.IpAddress.loopback_v4(), 0);
    var sender = io.UdpSocket.bind(loopback).value();
    var receiver = io.UdpSocket.bind(loopback).value();

    var datagram = Array<u8>();
    datagram.append(1);
    datagram.append(2);
    datagram.append(3);
    sender.send_to(datagram, receiver.local_address().value());

    # One receive takes one whole datagram and says who sent it.
    var buffer = Array<u8>();
    var (count, from) = receiver.receive_from(buffer, 1500).value();
    assert(count == 3 && buffer[2] == 3, "the datagram arrived whole");
    assert(from == sender.local_address().value(), "from the sender");
    return 0;
}
  • UdpSocket.bind(address) opens a socket on a local address.
  • send_to(data, address) sends one datagram. receive_from(buffer, max) waits for one datagram, appends it to buffer, and returns the byte count and the sender. The part of a datagram beyond max bytes is lost.
  • After connect(address), send and receive use that peer, and datagrams from anyone else are dropped.
  • set_broadcast(true) allows sending to a broadcast address.

Unix domain sockets

A Unix domain socket connects processes on the same machine through a path in the file system instead of an IP address and port. UnixListener and UnixStream work like TcpListener and TcpStream:

import io;

# A server and a client over a Unix domain socket, taking turns on one
# thread, as in the TCP example.
main(): int {
    var path = io.Directory.temp().join("pepper-networking-unix.sock");
    # Binding fails if the socket file is still there from an earlier run.
    io.File.remove(path);
    var listener = io.UnixListener.bind(path).value();

    var client = io.UnixStream.connect(path).value();
    var server = listener.accept().value();

    assert(client.write_all("ping").is_ok(), "send the request");
    var request = Array<u8>();
    assert(server.read_exact(request, 4).is_ok(), "read the request");
    server.write_all("pong");
    var reply = Array<u8>();
    assert(client.read_exact(reply, 4).is_ok(), "read the reply");
    assert(reply[0] == 'p' && reply[1] == 'o', "the reply is pong");

    # Closing the listener leaves the socket file; remove it when done.
    listener.close();
    assert(io.File.remove(path).is_ok(), "remove the socket file");
    return 0;
}
  • UnixListener.bind(path) creates a socket file at path and listens on it. Binding fails with is_address_in_use() if a file is already there, and closing the listener does not remove the file, so a server removes it with File.remove before binding and when it is done.
  • UnixStream.connect(path) connects to it. It fails with is_not_found() when there is no socket file, and with is_connection_refused() when the file is there but nothing listens on it.
  • accept() returns just the stream: the client’s end has no path.
  • Reading, writing, shutdown_write(), into_transfer() and adopt are the same as on a TcpStream, and SharedUnixListener is the counterpart of SharedTcpListener.
  • A path holds at most 107 bytes (103 on macOS); a longer or empty one fails with is_invalid_input().

Unix datagram sockets

UnixDatagram is the Unix domain counterpart of UdpSocket, on Linux and macOS only (Windows has no Unix datagram sockets, so it is declared with [Target(OS(Linux) || OS(Mac))]):

# Unix datagram sockets: a server bound to a path answers each datagram.
import io;

main(): int {
    # Linux and macOS only: Windows has no Unix datagram sockets.
    [Target(OS(Linux) || OS(Mac))]
    {
        var folder = io.Directory.temp().join("pepper-udgram-example");
        io.Directory.remove_all(folder);
        io.Directory.create_all(folder).value();
        var server_path = folder.join("server.sock");
        var client_path = folder.join("client.sock");

        var server = io.UnixDatagram.bind(server_path).value();
        var client = io.UnixDatagram.bind(client_path).value();
        client.send_to("ping".bytes(), server_path).value();

        # Each receive takes one whole datagram and names its sender.
        var request = Array<u8>();
        var (n, sender) = server.receive_from(request, 1024).value();
        assert(n == 4 && sender == client_path, "one datagram from the client");
        server.send_to("pong".bytes(), sender).value();

        var reply = Array<u8>();
        client.receive(reply, 1024).value();
        assert(reply.count == 4, "the reply");

        # A pair is two sockets already connected to each other.
        var (a, b) = io.UnixDatagram.pair().value();
        a.send("hi".bytes()).value();
        var got = Array<u8>();
        assert(b.receive(got, 16).value() == 2, "through the pair");

        # Closing leaves the socket files; remove them.
        io.Directory.remove_all(folder).value();
    }
    return 0;
}
  • UnixDatagram.bind(path) creates a socket file to receive on, with the same path rules and errors as UnixListener.bind; unbound() makes one that can only send, and pair() two that are connected to each other.
  • send_to(data, path) sends one datagram; it fails with is_not_found() when there is no socket file and is_connection_refused() when nothing is bound to it any more. A datagram is never dropped silently. While the receiver’s queue is full, a send waits on Linux; on macOS it fails at once with “No buffer space available” (ENOBUFS) and sends nothing, so try again later.
  • receive_from(buffer, max) returns the byte count and the sender’s path, which is empty for an unbound sender. As with UDP, a datagram longer than max is cut.
  • connect(path), send and receive work as on a UdpSocket.

TLS

The tls module encrypts a TCP connection with TLS 1.2 or 1.3. A TlsConnector makes client connections and checks the server’s certificate. A TlsAcceptor serves them with a certificate and key. Both produce a TlsStream, which reads and writes like a TcpStream:

import async;
import io;
import tls;
import "test_certificate";

# A TLS server and client on this machine, in two tasks.
main(): int {
    var connector = tls.TlsConnector.create();
    if (connector.is_err()) {
        # On Windows, or where OpenSSL 3 is not installed.
        print(connector.error().message);
        return 0;
    }
    # A real server reads its certificate and key from files it was given.
    var certificate = io.Directory.temp().join("pepper-example-cert.pem");
    var key = io.Directory.temp().join("pepper-example-key.pem");
    io.File.write_text(certificate, test_certificate());
    io.File.write_text(key, test_private_key());

    async.run(
        () => {
            var acceptor = tls.TlsAcceptor.create(certificate, key).value();
            # Mutual TLS: only clients with a certificate the test
            # certificate issued (here, the test certificate itself) get in.
            assert(acceptor.require_client_certificate(certificate).is_ok(), "ask clients for certificates");
            var listener = io.TcpListener.bind(io.SocketAddress.parse("127.0.0.1:0").value()).value();
            var port = listener.local_address().value().port();
            var server = async.Task<void>.spawn_local(() => {
                var (tcp, peer) = listener.accept().value();
                var stream = acceptor.accept(tcp).value();
                var request = Array<u8>();
                stream.read_exact(request, 4);
                stream.write_all("pong");
                stream.shutdown();
            });

            # The client trusts the test certificate as well as the system's.
            var client = connector.value();
            assert(client.add_root_certificate(certificate).is_ok(), "trust the test certificate");
            assert(client.set_identity(certificate, key).is_ok(), "present a client certificate");
            var stream = client.connect_host("localhost", port).value();
            stream.write_all("ping");
            var reply = Array<u8>();
            while (stream.read(reply, 4096).value() > 0) {}
            assert(reply.count == 4 && reply[0] == 'p' && reply[1] == 'o', "the reply is pong");
            server.join();
        }
    );
    io.File.remove(certificate);
    io.File.remove(key);
    return 0;
}
  • TlsConnector.create() trusts the system’s root certificates, and add_root_certificate(path) adds more from a PEM file, such as a private certificate authority. Make one connector and share it: it is [Shared].
  • connector.connect(tcp, host) runs the handshake over a connected TcpStream. It checks that the certificate is valid and was issued for host, which is also sent to the server (SNI). connect_host(host, port) connects the TCP stream first. An IP address in host is checked against the certificate’s addresses.
  • TlsAcceptor.create(certificate_chain, private_key) reads PEM files, and acceptor.accept(tcp) runs the handshake over a connection a TcpListener accepted.
  • Mutual TLS: acceptor.require_client_certificate(roots) asks every client for a certificate and accepts only one issued by a certificate in the PEM file roots; connector.set_identity(certificate_chain, private_key) gives the client a certificate to present. A client with no certificate, or an untrusted one, fails the server’s accept. With TLS 1.3 the client’s connect can succeed first, and its next read then fails or returns 0.
  • ALPN: connector.set_protocols(["h2", "http/1.1"]) offers protocols to servers, most preferred first, and acceptor.set_protocols(...) lists the ones a server accepts, also most preferred first. The handshake picks the first of the server’s that the client offers, and stream.protocol() names it on both sides. A client that offers no protocols connects with none chosen; one whose protocols the server accepts none of fails the handshake (the no_application_protocol alert of RFC 7301).
  • read, read_exact, write and write_all work on the decrypted bytes. shutdown() sends the TLS close_notify, and read returns 0 once the peer has sent one or closed the connection.
  • A failed handshake returns an error whose message says why, such as certificate verify failed: self-signed certificate.

On Linux and macOS the module uses OpenSSL 3, loaded when the first connector or acceptor is made: install it with the system’s package manager, or brew install openssl@3 on macOS. If it is missing, create() returns an error. On Windows it uses the system’s SChannel, which needs nothing installed; a private key (a server’s or a client’s) must be PKCS #8 (BEGIN PRIVATE KEY), and TLS 1.3 needs Windows 11 or Server 2022.

Timeouts

A socket call waits as long as it takes unless a timeout limits it:

import io;

# A read that gives up when the peer stays silent, and a connect with a
# limit of its own.
main(): int {
    var listener = io.TcpListener.bind(io.SocketAddress.parse("127.0.0.1:0").value()).value();
    var address = listener.local_address().value();

    # connect_timeout gives up after 2 seconds instead of the system's limit.
    var client = io.TcpStream.connect_timeout(address, 2000).value();
    var (server, peer) = listener.accept().value();

    # The server says nothing, so the read gives up after 100 milliseconds.
    client.set_read_timeout(100).value();
    var reply = Array<u8>();
    var silent = client.read(reply, 4096);
    assert(silent.is_err() && silent.error().is_timed_out(), "the read timed out");

    # The stream is still open: once the server answers, the read succeeds.
    server.write_all("pong");
    assert(client.read_exact(reply, 4).is_ok(), "read the late reply");
    return 0;
}
  • set_read_timeout(milliseconds) limits how long a read (or a UDP datagram receive) waits for data, and set_write_timeout(milliseconds) how long a write waits for room to send. A call that waits longer fails with is_timed_out(), and the socket stays open for another try. Each wait counts on its own, so read_exact or write_all can take longer in all. 0, the default, means no limit; read_timeout() and write_timeout() return the current values. TcpStream, UdpSocket, UnixStream and UnixDatagram have them.
  • TcpStream.connect_timeout(address, milliseconds) gives up on a connection that is not established in time, instead of after the system’s own limit, which is often minutes.
  • They work the same outside a task, where the thread blocks, and inside one, where only the task parks. In a task, async.timeout also limits every wait in its block, and whichever ends first wins.
  • A TlsStream waits through its TcpStream, so the TCP stream’s timeouts limit its reads, writes and handshake too. Set them before connect or accept.

Errors

Every operation returns an IoResult. Besides the file-system predicates, IoError has these for networking:

PredicateWhen
is_connection_refused()nothing listens at the address
is_connection_reset()the peer reset or closed the connection (writing to a closed connection included)
is_address_in_use()another socket already has the address and port
is_address_not_available()the IP address is not one of this machine’s
is_timed_out()the peer never answered, or a read, write or connect timeout expired
is_not_found()resolve found no address for the name, or UnixStream.connect found no socket file

Writing to a closed connection never raises SIGPIPE, so it cannot kill the process. The write returns an error instead.

Closing

A socket closes when the last reference to it goes, like a File, and close() closes it earlier and reports any error. A socket cannot be copied, so it cannot go through a channel to another thread. Use it on the thread that made it, or move it: into_transfer() turns a TcpStream (or a UnixStream) into a thread-safe SocketTransfer that TcpStream.adopt (or UnixStream.adopt) takes on another thread, and a SharedTcpListener is a listener any thread can accept on (see Tasks).

On POSIX a listener sets SO_REUSEADDR, so a server can restart right away. On Windows it does not, because there that option would let another program take over the port.

The reference pages for addresses, sockets and Unix domain sockets and TLS document every method.

HTTP

The http module has an HTTP/1.1 and HTTP/2 client and server, over plain TCP or TLS. Both run in tasks: a request that waits parks only its task. Over TLS the two sides pick HTTP/2 through ALPN when both have it (see HTTP/2); the Request and Response types, and the code that uses them, are the same for either version.

HTTP needs tasks and sockets, so it is not available on WebAssembly (--target wasm32-wasip1, see WebAssembly).

import async;
import http;
import io;

# Answers every request: `/hello` greets, anything else is not found.
handle(request: http.Request): http.Response {
    if (request.path() == "/hello") {
        var response = http.Response(200).set_text("hello, " + request.query());
        response.headers["Content-Type"] = "text/plain";
        return response;
    }
    return http.Response(404).set_text("nothing at " + request.path());
}

main(): int {
    async.run(
        () => {
            # Port 0 asks the system for any free port.
            var server = http.Server.bind(io.SocketAddress.parse("127.0.0.1:0").value()).value();
            var port = server.local_address().value().port();
            # serve() runs until async.run ends, so it gets a task of its own.
            async.spawn<void>(() => {
                server.serve(handle);
            });

            var base = "http://127.0.0.1:" + to_string(port);
            var client = http.Client();
            var response = client.get(base + "/hello?pepper").value();
            assert(response.status == 200, "found");
            assert(response.text() == "hello, pepper", "the handler's text");
            assert(response.headers["content-type"] == "text/plain", "names ignore case");

            var missing = client.get(base + "/elsewhere").value();
            assert(missing.status == 404 && missing.reason == "Not Found", "not found");

            var posted = client.post(base + "/hello", "text/plain", "a body").value();
            assert(posted.is_success(), "any method reaches the handler");
        }
    );
    return 0;
}

Messages

  • A Request has a method, a url, headers and a body. For a client the url is absolute (https://example.com/items?page=2); a server handler gets the request target (/items?page=2). uri() parses either as a Uri, and path() and query() return its parts.
  • A Response has a status, a reason, headers and a body. is_success() tests for a 2xx status.
  • Headers maps each field name to its values. Names ignore case (they are stored lowercased). headers["Accept"] reads the first value ("" when there is none) and headers["Accept"] = "text/html" replaces the field; get(name) returns an optional, add keeps earlier values, values(name) returns them all, and names() lists the fields in no defined order.
  • set_text and set_body fill a request or response and return it, so they chain. body() returns the bytes, for any body including binary ones; text() decodes them as UTF-8, replacing invalid bytes with U+FFFD.
  • set_reader(source) gives a request or response a body streamed from any io.Reader (a file, a compress.CompressReader) instead of one held in memory; see Streaming bodies.

URLs

Uri (in the prelude, so no import is needed) parses any URI reference following RFC 3986: an absolute URI with any scheme (https:, mailto:, file:) or a relative reference such as a request target. It splits the text into scheme, user_info, host, port, path, query and fragment; the parts that can be missing are optionals. Components keep their percent-escapes, and Uri.decode turns one into text; Uri.encode escapes text for a path segment or query value, and Uri.encode_form and Uri.decode_form write and read form data (see Encodings and UUIDs). base.resolve(reference) resolves a relative reference against a base URI, as a browser resolves a link (RFC 3986 section 5.2).

main(): int {
    # Any scheme parses; the scheme is lowercased and nothing else changes.
    var uri = Uri.parse("HTTPS://user@example.com:8443/caf%C3%A9?page=2#top").value();
    assert(uri.scheme == "https" && uri.host.value() == "example.com", "scheme and host");
    assert(uri.port.value() == 8443 && uri.user_info.value() == "user", "port and user information");
    assert(uri.path == "/caf%C3%A9" && uri.query.value() == "page=2", "path and query keep their escapes");
    assert(Uri.decode(uri.path).value() == "/café", "decoded path");
    assert(uri.path_and_query() == "/caf%C3%A9?page=2", "what an HTTP request line names");

    # Missing parts are Empty: a mailto URI has no authority.
    var mail = Uri.parse("mailto:someone@example.com").value();
    assert(mail.host.is_empty() && mail.path == "someone@example.com", "no authority");

    # A relative reference has no scheme, like a request target on a server.
    var target = Uri.parse("/items?page=2").value();
    assert(!target.is_absolute() && target.path == "/items", "relative");

    # resolve() turns a relative reference, such as a redirect's Location,
    # into the URI it names.
    var page = Uri.parse("https://example.com/docs/guide/intro").value();
    assert(page.resolve(Uri.parse("../api?v=2").value()).to_string() == "https://example.com/docs/api?v=2", "resolved");

    assert(Uri.parse("http://example.com/a b").is_err(), "a space must be percent-encoded");
    return 0;
}

Client

  • Client() trusts the system’s root certificates for https:// URLs. Client(connector) uses a tls.TlsConnector of your own, for instance one that trusts a private certificate authority.
  • get(url), post(url, content_type, body) and send(request) open a connection, send the request and read the whole response, up to MAX_RESPONSE_BODY (64 MiB). open(request) returns at the header fields and leaves the body to read as a stream (see Streaming bodies). The client adds Host, Content-Length (or Transfer-Encoding: chunked) and Connection: close.
  • Any status is a successful result: check status. An error means there was no response: the URL is not http:// or https:// (is_invalid_input()), the connection or TLS handshake failed, or the response was not valid HTTP.
  • Redirects (301, 302, 303, 307, 308) are followed, up to max_redirects (10) per request, and response.url is where the response came from. A 303, and a 301 or 302 after a POST, becomes a GET without the body; a 307 or 308 repeats the method and body, except that a body from set_reader cannot be sent twice, so that redirect is returned. Leaving the origin drops Authorization, Proxy-Authorization and Cookie. Client(0) returns every redirect as it is.
  • Every request from a Client uses a new connection; a Session keeps them open (see Reusing connections).
  • For https:// the client offers HTTP/2 and HTTP/1.1 through ALPN, on its own connector or the one it was given, and speaks whichever the server picks.

Server

  • Server.bind(address) listens for HTTP; Server.bind_tls(address, acceptor) serves HTTPS with a tls.TlsAcceptor’s certificate.
  • serve(handler) accepts connections on every worker of async.run and serves each in a task of its own, calling handler for each request with the body read in full. It returns when async.run ends. The handler is a shared (Request) => Response, since every worker calls it.
  • Connections stay open between requests unless the client asks to close or speaks HTTP/1.0. Chunked request bodies and Expect: 100-continue are understood.
  • The server adds Content-Length to each response (or, for a body from set_reader of unknown length, Transfer-Encoding: chunked), and the standard reason phrase when reason is empty (reason_phrase(status) returns it).
  • A request it cannot parse gets 400 Bad Request, header fields over 64 KiB get 431, and a body over MAX_REQUEST_BODY (16 MiB) gets 413.
  • Server.bind_tls offers HTTP/2 and HTTP/1.1 through ALPN, and a plain-TCP client that opens with the HTTP/2 preface (prior knowledge, or h2c) is served HTTP/2 too. See HTTP/2.

Streaming bodies

A body need not fit in memory. Both directions stream through io.Reader, the trait of files, sockets and the compress readers:

  • Responses to a client: client.open(request) returns the Response as soon as its header fields arrive. response.reader() is then a BodyReader that reads the body from the connection as it arrives, with no size limit, undoing chunked framing, and ends where the body ends. Read it with read, chunks or lines, or wrap it in a compress.DecompressReader. The connection closes at the end of the body, at close(), or when the last reference to the reader goes.
  • Requests from a client: request.set_reader(source) sends the body chunked; set_reader(source, length) sends it with Content-Length.
  • Responses from a server: a handler returns Response(200).set_reader(source), sent chunked to an HTTP/1.1 client (or with Content-Length when a length is given). An HTTP/1.0 client gets the body unframed, and the connection closes after it.
import async;
import http;
import io;

# `/download` streams a body of unknown length, which goes out chunked;
# `/upload` reports how many bytes it received.
handle(request: http.Request): http.Response {
    if (request.path() == "/download") {
        var data = "pepper\n".repeat(100000).bytes();
        return http.Response(200).set_reader(io.ByteReader(data));
    }
    return http.Response(200).set_text(to_string(request.body().count));
}

main(): int {
    async.run(
        () => {
            var server = http.Server.bind(io.SocketAddress.parse("127.0.0.1:0").value()).value();
            var port = server.local_address().value().port();
            async.spawn<void>(() => {
                server.serve(handle);
            });
            var base = "http://127.0.0.1:" + to_string(port);
            var client = http.Client();

            # open() returns at the header fields; the body is read as it
            # arrives and written to a file a chunk at a time.
            var response = client.open(http.Request("GET", base + "/download")).value();
            assert(response.headers["transfer-encoding"] == "chunked", "no length up front");
            var path = io.Directory.temp().join("pepper-download.txt");
            var file = io.File.create(path).value();
            for (var chunk in response.reader().chunks(65536)) {
                file.write_all(chunk.value());
            }
            file.close();

            # Send the file back as a request body read from the file.
            var upload = http.Request("PUT", base + "/upload").set_reader(io.File.open(path).value());
            assert(client.send(upload).value().text() == "700000", "every byte arrived");
            io.File.remove(path);
        }
    );
    return 0;
}

Reusing connections

client.session() returns a Session, which sends requests with the client’s settings and keeps each connection open after its response (HTTP/1.1 keep-alive). The next request to the same scheme, host and port uses it again, saving the TCP and TLS handshakes:

import async;
import http;
import io;

main(): int {
    async.run(
        () => {
            var server = http.Server.bind(io.SocketAddress.parse("127.0.0.1:0").value()).value();
            var port = server.local_address().value().port();
            async.spawn<void>(() => {
                server.serve((request) => {
                    return http.Response(200).set_text("page " + request.query());
                });
            });
            var base = "http://127.0.0.1:" + to_string(port);

            var session = http.Client().session();
            for (var page in 1..>3) {
                var response = session.get(base + "/items?" + to_string(page)).value();
                assert(response.text() == "page " + to_string(page), "each page");
            }
            # One connection carried all three requests, and waits for more.
            assert(session.idle_count == 1, "kept open");
            session.close();
        }
    );
    return 0;
}
  • A connection goes back to the session when its response body has been read to its end (send reads it; for open, read the BodyReader to its end), unless the server said Connection: close.
  • max_idle_per_host (4) connections wait for each scheme, host and port; one idle longer than idle_timeout (90 seconds) is closed instead of used. idle_count says how many wait, and close() closes them.
  • A request that finds its reused connection closed by the server goes again on a new one, unless its body came from set_reader.
  • An HTTP/2 connection is not idle and taken in turn: every request to its origin shares it at once, each on a stream of its own. idle_count counts it while no request is using it.
  • A Session is not [Shared]: use one per thread or task. A Client is shared, and connections, which belong to one thread, cannot be. Tasks started with spawn_local may share a session, and over HTTP/2 their requests go out on one connection at the same time.

HTTP/2

HTTP/2 (RFC 9113) carries many requests on one connection at once, as streams, with header fields compressed (HPACK, RFC 7541) and each body flow-controlled so that one large transfer does not hold up the rest.

  • Choosing it: over TLS, the client offers h2 and http/1.1 through ALPN and Server.bind_tls accepts both, preferring h2, so two Pepper programs speak HTTP/2 with no setting to change. A server or client that offers only HTTP/1.1 gets HTTP/1.1. On plain TCP the client speaks HTTP/1.1; the server also takes HTTP/2 from a client that starts with its preface (prior knowledge).
  • Client: a Session keeps one HTTP/2 connection per origin, and requests from several tasks share it at once. A request outside a session opens a connection that closes after its body. A connection the server ends with GOAWAY is replaced by a new one for the next request.
  • Server: each stream is a request, answered by the same handler in a task of its own, so the requests of one connection run at the same time. The handler sees the Host field from :authority. A server lets a client open 100 streams at once.
  • Messages: field names go lowercase on the wire, as HTTP/2 requires, and fields that only mean something to HTTP/1.1 (Connection, Transfer-Encoding, Upgrade, Keep-Alive) are left out. HTTP/2 has no reason phrase, so a response’s reason is the standard one for its status. Bodies stream through io.Reader exactly as for HTTP/1.1; closing a response’s reader early resets only its stream.

Current status

  • A server handler gets the request body read into memory in full, up to MAX_REQUEST_BODY (16 MiB).
  • HTTP/2 server push, stream priorities and the Upgrade: h2c handshake are not supported; a client speaks HTTP/2 only over TLS.

The reference pages for messages, the client, the server and Uri document every method.

Processes

The process module starts other programs and holds what belongs to the running one: its arguments, its exit, its id, its executable, and its working directory. Bring it in with import process.

On WebAssembly (--target wasm32-wasip1) a program cannot start another: run, spawn, exec and executable_path return an IoError saying it is not supported on WASI. Its arguments, exit and environment work (see WebAssembly).

Running a program

Command describes a program to start. Its setters return the same Command, so they chain. run() starts the program, waits for it to end, and returns an Output: the status it ended with, and the text it wrote to stdout and stderr.

import io;
import process;

# Runs a copy of this program as a child, so the example needs no other
# program on any system: the child's first argument says what to do.
main(args: String[]): int {
    if (args.count > 1) {
        io.File.stdout().write_all("hello from " + args[1]);
        io.File.stderr().write_all("a warning");
        return 3;
    }

    var self = process.executable_path().value().to_string();
    var output = process.Command(self).argument("the child").run().value();
    assert(output.stdout == "hello from the child", "standard output is collected");
    assert(output.stderr == "a warning", "and so is standard error");
    assert(!output.status.success(), "the child failed");
    assert(output.status.code().value() == 3, "with exit code 3");

    # A program that is not found fails to start.
    var missing = process.Command("no-such-program").run();
    assert(missing.is_err() && missing.error().is_not_found(), "not found");
    return 0;
}
  • The program is found the way a shell finds it. A bare name is searched for on the PATH (on Windows, also next to the running program and in the system directories, with .exe added when the name has no extension). Anything with a path separator is a path.
  • Each argument reaches the program as one argument, whatever it holds. No shell splits it or expands * or $NAME. On Windows, where a program receives a single command line, each argument is quoted so that the C runtime splits it back out as it was.
  • run() collects standard output and error, both at once, so a child that fills one pipe while the parent reads the other does not stall. Standard input is the null device unless it was set. The output is decoded as UTF-8, with each invalid byte sequence replaced by U+FFFD; read a child’s binary output from a pipe with spawn() instead.
  • A program that cannot be started is an error (is_not_found() when it does not exist). A program that runs and fails is a successful result whose status says so.

exec() runs the program in place of the running one, as a shell’s exec does: the program’s exit status becomes this process’s own, and exec() returns only when the program cannot be started, with the error. On POSIX the program takes over the process; Windows cannot replace a process, so there the program is started, waited for, and its exit status passed on. Output that print buffered is flushed first, and the standard streams must stay inherited (otherwise the error has is_invalid_input()).

Exit status

ExitStatus.success() is true for exit code 0. code() returns the exit code, and signal() the POSIX signal that ended the process (such as 9 for SIGKILL). Each is an optional: a process killed by a signal has no exit code, and a process that exited has no signal. Windows has no signals, so there every process has a code; a POSIX parent sees only the low 8 bits of it.

Talking to a child

spawn() starts the program and returns a Child at once. Each of the three standard streams is Stream.Inherit (shared with the parent, the default), Stream.Pipe (connected to the parent), or Stream.Null. A piped stream is an io.File from child.stdin(), child.stdout() or child.stderr(), so the file read and write methods apply.

import io;
import process;

main(args: String[]): int {
    if (args.count > 1) {
        # The child: read standard input to the end, and write it back in
        # capitals.
        var text = io.File.stdin().read_string().value();
        io.File.stdout().write_all(text.upper());
        return 0;
    }

    var self = process.executable_path().value().to_string();
    var child = process.Command(self)
        .argument("upper")
        .stdin(process.Stream.Pipe)
        .stdout(process.Stream.Pipe)
        .spawn()
        .value();
    child.stdin().write_all("pear and apple");
    child.stdin().close();  # the child reads to the end, so end it
    var shouted = child.stdout().read_string().value();
    var status = child.wait().value();
    assert(shouted == "PEAR AND APPLE", "the child's output");
    assert(status.success(), "the child succeeded");
    return 0;
}
  • Close a piped standard input when you are done writing, or a child that reads to the end never finishes. wait() closes it too, before waiting.
  • Read piped output before waiting. A child that fills a pipe stops until the parent reads it, so waiting first can wait forever.
  • wait() returns the exit status, and again on later calls. try_wait() returns none while the child runs. kill() ends the child at once (SIGKILL, or TerminateProcess on Windows); wait for it afterwards.
  • Wait for every child. On POSIX a child that ended stays in the process table until it is waited for. Releasing a Child neither waits for it nor kills it.

In a task, run(), wait() and reads from a pipe park the task rather than block its worker, so other tasks keep running.

Environment and working directory

A child inherits the parent’s environment. environment(name, value) sets a variable for the child, remove_environment(name) leaves one out, and clear_environment() starts from an empty environment. On Windows, names ignore case. working_directory(path) starts the child in another directory. The running process reads and changes its own environment with os.environment(name) and os.set_environment(name, value) (see The operating system).

import io;
import os;
import process;

main(args: String[]): int {
    if (args.count > 1) {
        io.File.stdout().write_all(os.environment("GREETING").value_or("unset"));
        return 0;
    }

    var self = process.executable_path().value().to_string();
    var greeted = process.Command(self).argument("child").environment("GREETING", "hello").run().value();
    assert(greeted.stdout == "hello", "the child sees the variable");

    var cleared = process.Command(self).argument("child").clear_environment().run().value();
    assert(cleared.stdout == "unset", "an empty environment");

    # The child starts in another directory; the parent's stays as it was.
    var before = process.working_directory().value();
    var elsewhere = process.Command(self).argument("child").working_directory(io.Directory.temp()).run();
    assert(elsewhere.is_ok(), "started in the temporary directory");
    assert(process.working_directory().value() == before, "unchanged");
    return 0;
}

The running process

  • process.arguments() returns the program’s command-line arguments, the same array main(args: String[]) receives (see Functions).
  • process.exit(code) ends the program at once with status code.
  • process.id() is the process’s id.
  • process.executable_path() is the absolute path of the running program’s executable, so a tool can find files installed next to it.
  • process.working_directory() and process.change_working_directory(path) read and change the directory relative paths are resolved against, as io.Directory.current() and io.Directory.set_current(path) do.

arguments() and exit() without import process still work, but are deprecated and warn.

Signals

process.signals([...]) returns an async.Channel<Signal> that receives each signal it names instead of the signal’s default action, which ends the program. A server or tool waits on it to shut down cleanly: stop accepting work, flush, remove temporary files.

SignalPOSIXWindows
InterruptSIGINT (Ctrl-C)CTRL_C_EVENT, CTRL_BREAK_EVENT
TerminateSIGTERMCTRL_CLOSE_EVENT, CTRL_LOGOFF_EVENT, CTRL_SHUTDOWN_EVENT
HangupSIGHUPnever sent
import process;
import [Signal] from process;

main(): int {
    var stop = process.signals([Signal.Interrupt, Signal.Terminate]);
    # Serve until Ctrl-C or a request to terminate arrives: here, for at most
    # a tenth of a second.
    var signal = stop.receive_for(100);
    if (signal.is_ok()) {
        print("shutting down cleanly");
    }
    # Closing the channel gives the signals back their default action.
    stop.close();
    return 0;
}

No program code runs inside the operating system’s signal handler: on POSIX the handler only writes the signal’s number to a pipe, and a runtime thread sends it on the channel. So the channel is the whole interface, and works as any channel does: receive() waits, a task can select on it alongside its other channels, and try_receive() polls between steps of a long job.

  • Every open channel that names a signal receives it.
  • Closing a channel stops it receiving; once no open channel names a signal, its default action returns.
  • On Windows the system ends the program a few seconds after Terminate (the console closing, a log-off or a shutdown) whatever it does, so keep that cleanup short.
  • WebAssembly has no signals: the channel never receives.
  • Writing to a socket whose peer has gone returns an IoError rather than raising SIGPIPE; the runtime asks for that on every socket.

Finding a program

process.find_executable(name) looks a program up on the PATH as a shell does, returning the path of the first match that may be run, or none, so a tool can say what to install before trying to run it. process.is_executable(path) tells whether a file may be run.

var perf = process.find_executable("perf");
if (perf.is_empty()) {
    print("perf is not installed");
}

See the process module reference.

The operating system

The os module reports what the operating system provides beyond the running process: environment variables, clocks, and facts about the machine and the user. Bring it in with import os. The full API is in the reference.

Environment variables

os.environment(name) returns a variable’s value as a String?: none when it is not set, and an empty String when it is set to nothing. os.environment_variables() returns them all as a HashMap<String, String> from name to value.

import os;

main(): int {
    # An optional: none when the variable is not set.
    var runtime = os.environment("PEPPER_RUNTIME").value_or("runtime");
    assert(!runtime.is_empty, "a fallback when unset");
    assert(os.environment("PEPPER_SURELY_NOT_SET").is_empty(), "unset");

    var count = 0;
    for (var (name, value) in os.environment_variables()) {
        if (name.starts_with("PEPPER_")) {
            print($"{name}={value}");
        }
        count = count + 1;
    }
    assert(count > 0, "a process has an environment");
    return 0;
}
  • On Windows, names ignore case (Path finds PATH), and values are read as UTF-16, so text outside the ANSI code page survives.
  • On POSIX a value is whatever bytes the parent passed; one that is not valid UTF-8 is returned as it is.

Changing the environment

os.set_environment(name, value) sets a variable for the running process, and os.remove_environment(name) removes one. Later reads see the change, and so do the children the process starts afterwards.

import os;

main(): int {
    var name = "PEPPER_EXAMPLE_GREETING";
    os.set_environment(name, "hello");
    assert(os.environment(name).value() == "hello", "set");

    os.set_environment(name, "");
    assert(os.environment(name).value() == "", "set to nothing");

    os.remove_environment(name);
    assert(os.environment(name).is_empty(), "removed");
    return 0;
}
  • Every read and change the runtime makes goes through one lock, so Pepper code never races with itself over the environment, from any thread.
  • C code that calls getenv itself is outside that lock. On POSIX, setenv races with such a call on another thread (Rust 2024 marks its set_var unsafe for this reason), so set variables before starting threads that run C code reading them.
  • A name that is empty or contains = or a NUL byte, or a value that contains a NUL byte, panics.
  • On Windows names ignore case, and the C runtime’s own getenv copy does not see the change. On WASI the change stays inside the program.
  • To give only a child a different environment, use process.Command’s environment, remove_environment and clear_environment (see Processes).

Time

Three value types cover time. Each is a value struct: copied, compared with ==, < and the other comparisons, and never allocated.

  • Duration is a span of time in nanoseconds, never negative. Make one with from_seconds, from_milliseconds, from_microseconds or from_nanoseconds, read it back with seconds(), milliseconds() and the others (rounded down), and add, subtract, multiply or divide it. Printing one shows it in the largest unit it reaches: 2.5s, 15ms, 40ns.
  • Instant reads a monotonic clock, for measuring how long something takes. It only moves forward, whatever the system’s date is set to. later - earlier is the Duration between two instants, and start.elapsed() the time since one.
  • SystemTime reads the wall clock: nanoseconds since the Unix epoch (1970-01-01 00:00:00 UTC). unix_seconds() is what file systems and most protocols record. Since the wall clock can be set back, duration_since(earlier) is an optional: none when earlier is later. The time module turns it into a date and time, in UTC or a time zone, and back.
import os;

main(): int {
    # Measure with the monotonic clock: subtracting two instants gives a
    # Duration.
    var start = os.Instant.now();
    long total = 0;
    for (var i in 0..100000) {
        total = total + i;
    }
    var elapsed = os.Instant.now() - start;
    print($"summed in {elapsed}");   # such as "1.234ms"

    var timeout = os.Duration.from_seconds(2) + os.Duration.from_milliseconds(500);
    assert(timeout.milliseconds() == 2500, "whole milliseconds");
    assert(timeout.to_string() == "2.5s", "the largest unit it reaches");
    assert(elapsed < timeout, "durations compare");

    # The wall clock counts from the Unix epoch.
    var now = os.SystemTime.now();
    assert(now.unix_seconds() > 1700000000, "after November 2023");
    var tomorrow = now + os.Duration.from_seconds(86400);
    assert(tomorrow.duration_since(now).value() == os.Duration.from_seconds(86400), "a day later");
    assert(now.duration_since(tomorrow).is_empty(), "no negative durations");
    return 0;
}

Subtracting a longer Duration, or a later Instant, panics: neither can be negative.

The machine and the user

import os;

main(): int {
    print($"{os.system_name()} {os.system_version()} on {os.architecture()}");
    print($"host {os.hostname()}: {os.logical_cpus()} logical processors, {os.physical_cpus()} cores");
    print($"{os.total_memory() / 1048576} MiB of memory in {os.page_size()}-byte pages");

    var user = os.user_name().value_or("unknown");
    var home = os.home_directory();
    print($"user {user}, home {home.is_value() ? home.value().to_string() : "unknown"}");
    print($"temporary files go in {os.temp_directory()}");

    var executable = os.system_os() == os.OperatingSystem.Windows ? "tool.exe" : "tool";
    assert(executable.starts_with("tool"), "picked a name");
    return 0;
}
FunctionReturns
system_os()the OperatingSystem the program was built for: Linux, Mac, Windows or Wasi
system_name()the same as text: "linux", "macos" or "windows"
system_version()the kernel release on Linux, the product version on macOS (14.5), major.minor.build on Windows
architecture()"x64", "aarch64" or "wasm32", as [Target(Arch(...))] names them
hostname()the machine’s host name
logical_cpus(), physical_cpus()processor counts, the same as async’s, capped by a Linux container’s CPU quota
total_memory(), page_size()physical memory, or a Linux container’s lower memory limit, and the virtual memory page size, in bytes
user_name()the user the program runs as, or none
home_directory()HOME (on Windows USERPROFILE), or the user database’s entry, or none
temp_directory()the directory for temporary files, the same as io.Directory.temp()
is_elevated()whether the program runs as root, or elevated (“Run as administrator”) on Windows; never on WASI

Containers

On Linux, total_memory() and logical_cpus() report what a container lets the program use, as the JVM and Go do, so a pool or a cache sized from them fits inside it. Both read the process’s cgroup and each cgroup above it, under /sys/fs/cgroup, with cgroup v2 or v1:

  • total_memory() returns the smallest of the physical memory and every memory limit found (memory.max, or v1’s memory.limit_in_bytes), as docker run --memory sets it.
  • logical_cpus() returns the smaller of the online processors and the smallest CPU quota (cpu.max, or v1’s cpu.cfs_quota_us over cpu.cfs_period_us), rounded up to whole processors: docker run --cpus=1.5 counts as 2. physical_cpus() is never more than that.

Where no limit is set, or the files cannot be read, they report the machine.

Current limitations

  • async.timeout still takes milliseconds, not a Duration.

Dates, times and time zones

The time module turns instants into calendar dates and times of day, and back: dates and times with no zone, fixed offsets from UTC, time zones with daylight saving time, and the text forms of RFC 3339, ISO 8601, HTTP and C’s strftime. Bring it in with import time. The full API is in the reference.

An instant itself is an os.SystemTime: nanoseconds since the Unix epoch. time says what date and time that is somewhere, and which instant a date and time names.

The calendar is the proleptic Gregorian calendar, which extends today’s rules to years before it was adopted, and leap seconds are ignored, as Unix time, Go and Java ignore them: every day has 86400 seconds.

Dates and times of day

Four value types carry no time zone. Each is copied, compared with ==, < and the other comparisons, and never allocated.

  • Date is a year, a month (1 to 12) and a day. It knows its weekday(), day_of_year() and whether its year is_leap_year(), and moves with add_days, add_months and add_years. A month or year later keeps the day when it can and otherwise takes the month’s last day, so a month after January 31 is February 28 or 29.
  • Time is an hour, minute, second and nanosecond within a day.
  • DateTime is a Date and a Time together: what TOML calls a local date-time. Adding or subtracting an os.Duration moves it.
  • Weekday is an enum, Monday to Sunday, whose values are ISO 8601’s numbers, 1 to 7.
import os;
import time;

main(): int {
    # A Date is a day on the calendar, with no time of day or zone.
    var leap_day = time.Date(2024, 2, 29);
    assert(leap_day.weekday() == time.Weekday.Thursday, "2024-02-29 was a Thursday");
    assert(leap_day.day_of_year() == 60, "the 60th day of the year");
    assert(leap_day.add_years(1) == time.Date(2025, 2, 28), "no February 29 in 2025");
    assert(time.Date(2024, 1, 31).add_months(1) == leap_day, "the last day of February");
    assert(time.Date(2024, 12, 25).days_since(leap_day) == 300, "days between dates");

    # Parts from outside the program are checked, not trusted.
    assert(time.Date.checked(2023, 2, 29).is_empty(), "2023 is not a leap year");
    assert(time.Date.parse("2024-02-29").value() == leap_day, "ISO 8601 text");

    # A DateTime adds a time of day; durations move it.
    var start = time.DateTime(2024, 3, 1, 23, 30);
    var end = start + os.Duration.from_seconds(90 * 60);
    print(end);  # 2024-03-02T01:00:00
    assert(end.date == time.Date(2024, 3, 2) && end.time == time.Time(1, 0), "past midnight");
    assert(start < end, "date times compare");
    return 0;
}

The constructors (Date(2024, 2, 29)) panic on parts that name no date or time, which suits values written in the program. For parts that come from outside it, checked returns an optional instead, and parse a Result. Years run from -999999 to 999999, and year 0 is 1 BC.

Offsets and timestamps

Offset is a fixed offset from UTC, east positive: Offset.from_hours(5, 30) is +05:30. OffsetDateTime is a DateTime at an Offset, which names one instant: RFC 3339’s timestamp and TOML’s offset date-time.

import time;

main(): int {
    # An OffsetDateTime is an RFC 3339 timestamp: a date and time at an
    # offset from UTC, which together name one instant.
    var meeting = time.OffsetDateTime.parse("2024-03-01T09:00:00+01:00").value();
    print(meeting.to_utc());  # 2024-03-01T08:00:00Z
    assert(meeting.offset == time.Offset.from_hours(1), "the offset it was written at");

    # The same instant at two offsets is not ==, but compares as equal.
    var utc = meeting.to_utc();
    assert(utc != meeting && !(utc < meeting) && !(utc > meeting), "one instant");
    assert(utc.to_system_time() == meeting.to_system_time(), "one SystemTime");

    # strftime codes lay a date out any way.
    assert(meeting.format("%A %e %B %Y, %H:%M %:z") == "Friday  1 March 2024, 09:00 +01:00", "strftime");
    assert(meeting.format("%G-W%V-%u") == "2024-W09-5", "the ISO week date");

    # HTTP's Date and Last-Modified headers, both ways.
    var header = meeting.to_http_date();
    assert(header == "Fri, 01 Mar 2024 08:00:00 GMT", "an IMF-fixdate, in GMT");
    assert(time.OffsetDateTime.parse_http_date(header).value() == utc, "and back");
    return 0;
}
  • OffsetDateTime.from_system_time(t, offset) is the instant t at offset (UTC by default), and to_system_time() goes back. to_offset and to_utc show the same instant at another offset.
  • == compares the date, time and offset, so the same instant written at two offsets is not ==. <, <=, > and >= compare instants.
  • DateTime.from_system_time(t) and to_system_time() read a DateTime as UTC.

Text

Each type’s to_string() writes ISO 8601, and its static parse reads it:

TypeText
Date2024-02-29; outside the years 0 to 9999, signed: -0001-12-31, +10000-01-01
Time08:30:00, with the fraction of a second when there is one: 08:30:00.25
DateTime2024-03-01T09:00:00
Offset+01:00; parse also takes Z, +0100 and +01
OffsetDateTime2024-03-01T09:00:00+01:00, Z for UTC

parse also reads what TOML allows: a space or t for the T, z for the Z, and a time without its seconds (09:00). Digits of a fraction past the nanosecond are dropped.

format(pattern) lays a value out with strftime’s % codes, such as %Y-%m-%d, %A, %B and %H:%M; the module reference lists them all. Names are English whatever the locale. %z, %:z and %s need an offset, so only an OffsetDateTime takes them; an unknown code panics.

HTTP dates (RFC 9110) go both ways: to_http_date() writes the IMF-fixdate of Date and Last-Modified headers, Fri, 01 Mar 2024 08:00:00 GMT, and OffsetDateTime.parse_http_date reads it and the two obsolete forms servers must still accept, RFC 850’s and C’s asctime.

Time zones

A TimeZone gives the offset from UTC at each instant in one place:

  • TimeZone.utc(), and TimeZone.fixed(offset) for an offset that never changes.
  • TimeZone.local(), the system’s zone: the TZ environment variable or /etc/localtime on Linux and macOS, as the C library’s localtime reads them, and the zone in Windows’ settings. WebAssembly has no local zone, so it is UTC there.
  • TimeZone.named("Europe/Paris"), a zone of the IANA time zone database, with its daylight saving time and history, or an error when the system does not know the name.

zone.offset_at(t) is the offset at the instant t, and zone.at(t) its date and time there. zone.at_local(date_time) goes the other way, from a date and time on the zone’s clocks to the instant. Where the clocks go back, a time happens twice, and at_local picks the earlier; where they go forward, a time never happens, and it moves on by the length of the gap. time.now() is the current date and time in the local zone, and time.now_utc() in UTC.

import os;
import time;

main(): int {
    # The local zone and UTC are always there.
    var now = os.SystemTime.now();
    print($"here: {time.TimeZone.local().at(now)}");
    print($"UTC:  {time.now_utc()}");

    # Named zones come from the system's time zone database.
    var found = time.TimeZone.named("Europe/Paris");
    if (found is Ok(paris)) {
        var bastille = time.OffsetDateTime.parse("2024-07-14T10:00:00Z").value();
        assert(paris.at(bastille.to_system_time()).to_string() == "2024-07-14T12:00:00+02:00", "summer time");

        # A local time in the zone gets the offset in effect then.
        var christmas = paris.at_local(time.DateTime(2024, 12, 25, 18, 0));
        assert(christmas.offset == time.Offset.from_hours(1), "winter time");

        # 02:30 never happens on the night the clocks go forward: it moves
        # on by the hour that was skipped.
        var skipped = paris.at_local(time.DateTime(2024, 3, 31, 2, 30));
        assert(skipped.to_string() == "2024-03-31T03:30:00+02:00", "the gap");
    }
    assert(time.TimeZone.named("Nowhere/Atlantis").is_err(), "an unknown zone");
    return 0;
}

Nothing is bundled: named zones come from the system.

  • On Linux and macOS, the TZif files under /usr/share/zoneinfo, or the folder the TZDIR environment variable names. A minimal Linux container may have none until its tzdata package is installed. The rule at the end of each file covers instants after its last listed change.
  • On Windows, ICU (icu.dll, part of Windows 10 version 1903 and later), which knows the same names, or the TZif files in the folder TZDIR names when it is set.
  • On WebAssembly, TZif files under /usr/share/zoneinfo (or TZDIR) when the host lends the program that folder.

Current limitations

  • format writes text but nothing reads a strftime pattern back; parse ISO 8601, RFC 3339 and HTTP dates with the parse functions.
  • Names in format are English only, and there are no locale formats (%c, %x, %X) or zone abbreviations (%Z).
  • On Android, named zones are not found: its database is one packed file, not a folder of TZif files.

Command-line options

The cli module parses a program’s command line. Declare each option and positional argument on a cli.Parser, call parse, then read the values through the handles the declarations returned, or declare them all at once as the fields of a struct. The help, --version and the error messages all come from the declarations. Bring it in with import cli; the full API is in the reference.

# Command-line parsing: declare options and arguments, then parse.
import cli;

main(args: String[]): int {
    var parser = cli.Parser("greet", "Greets someone", version = "1.0");
    var greeting = parser.option("greeting", "g", "what to say", default_value = "hello");
    var loud = parser.flag("loud", "l", "shout it");
    var times = parser.option<int>("times", "n", "how many times to say it", value_name = "COUNT", default_value = "1");
    var names = parser.positionals("names", "who to greet");

    # `parse` prints --help and --version and exits; on a mistake it writes
    # the error and the usage to standard error and returns false.
    if (!parser.parse(args)) {
        return 2;
    }

    var people = names.is_set ? names.values() : ["world"];
    for (var person in people) {
        var line = $"{greeting.value()}, {person}";
        for (var _ in 0..times.value()) {
            print(loud.is_set ? line.upper() : line);
        }
    }
    return 0;
}

greet --help prints:

Greets someone

usage: greet [OPTIONS] [names]...

Arguments:
  <names>...  who to greet

Options:
  -g, --greeting <GREETING>  what to say [default: hello]
  -l, --loud                 shout it
  -n, --times <COUNT>        how many times to say it [default: 1]
  -h, --help                 show this help
  -V, --version              show the version

The raw arguments stay where they were: main(args: String[]) receives them, and process.arguments() returns them. parse takes them as main receives them, program name first.

Declaring

DeclarationHandleReads
flag(name, short_name, help, global)Flagis_set and occurrences (properties)
option<T>(name, short_name, help, value_name, default_value, required, global, implicit_value)Argument<T>value(), value_or(fallback), is_set
multiple<T>(name, short_name, help, value_name, required, global)ArgumentList<T>values(), is_set
positional<T>(name, help, required, default_value)Argument<T>value(), value_or(fallback), is_set
positionals<T>(name, help, required)ArgumentList<T>values(), is_set

T is the type of the value (see Typed values); leave it out for a String: option("output") is option<String>("output").

  • An option’s name is its long name without --; short_name is one character without -, or empty for none. One of the two may be empty: option("", "o") is an option spelled only -o, as a C compiler’s -I, -D and -o are. Pass the rest by name: option("output", "o", "the executable to write", default_value = "a.out").
  • value() returns the value given, else the default; it panics when the argument was left out and has no default, so use value_or or check is_set for an optional one. is_set (a property, like occurrences and is_selected) is false for a value that came from the default.
  • Positional arguments fill in declaration order. A required one cannot follow an optional one, and positionals takes every value that is left, so it comes last.
  • -h/--help is always there, and -V/--version when the parser has a version. Declaring -h or -V yourself takes the short name; the long forms keep working.
  • A mistake in the declarations, such as a name declared twice or a short name longer than one character, panics: it is a bug in the program, not in its input.

On the command line

  • A value follows its option as --output a.out, --output=a.out, -o a.out, -oa.out or -o=a.out. It is taken as it is, even when it starts with -.
  • Short flags combine: -vq is -v -q, and -vo a.out gives -v and then -o a.out. -vvv counts three occurrences.
  • Options and positional arguments mix in any order. -- ends the options, so rm -- -file removes a file named -file, and - alone is a positional argument (by custom, standard input).
  • An option that takes one value keeps the last one given.
  • An option declared with an implicit_value takes a value only when it is attached (--emit-ast=json, -O3); given alone, it has the implicit value, and the argument after it is not its value. The help shows it as --emit-ast[=<FORMAT>] or -O[<LEVEL>].

Typed values

A declaration with a type argument reads its values as that type, and a value that is not one fails the parse:

greet: error: invalid value 'x' for '--times': expected an integer
TA value isAn error expects
Stringany text
int, longan integer, as int.parse reads itan integer
uint, ulongan integer that is not negativea non-negative integer
byte, short, ushortan integer in the type’s rangean integer from 0 to 255
float, doublea number, as double.parse reads ita number
io.Pathany text, as a path
an enum whose cases carry no valuesa case’s name in kebab case: DebugInfo is debug-info'fast' or 'debug-info'
  • An enum’s help lists its cases: [possible values: fast, debug-info].
  • default_value is written as on the command line, so the help shows it as it is: option<int>("jobs", default_value = "4"). A default that is not a value of the type panics.
  • Any other type is a compile error at the call.

Errors

parse writes a clang-style error, the usage and a hint to standard error and returns false, so the program can exit with 2:

greet: error: unknown option '--lound'; did you mean '--loud'?
usage: greet [OPTIONS] [names]...
Run 'greet --help' for more information.

The messages:

  • unknown option '--x' (with did you mean '--y'? for a near miss) and unknown option '-x'
  • missing value for '-o'
  • option '--loud' takes no value (for --loud=yes)
  • invalid value 'x' for '--times': expected an integer
  • missing required option '--target', missing required argument '<source>'
  • unexpected argument 'x' when there is no positional argument left for it
  • unknown command 'x' and missing command: expected 'add' or 'update'

A mistake parse cannot see, such as two options that cannot be used together, is reported the same way with usage_error, which returns 2:

if (test.is_set and library.is_set) {
    return parser.usage_error("'--test' cannot be used with '--lib'");
}

try_parse does the same parse and writes nothing. It returns a Result<String?>: an error holding the message, some text when --help or --version asked for it (print it and exit with 0), or none when the arguments were parsed. Tests use it, and so can a program that reports errors its own way.

Declaring options as a struct

add_options declares an option or positional argument for each public field of a struct, and every successful parse stores the values in that struct’s fields. The attributes [cli.Option] and [cli.Positional] on the fields carry what the builder calls take as arguments:

# Command-line parsing: declare the options as a struct's fields.
import cli;

struct Options {
    [cli.Option("g", "what to say")]
    var greeting: String = "hello";
    [cli.Option("l", "shout it")]
    var loud: bool;
    [cli.Option("n", "how many times to say it", value_name = "COUNT")]
    var times: int = 1;
    [cli.Positional("who to greet")]
    var names: String[];
}

main(args: String[]): int {
    var options = Options();
    var parser = cli.Parser("greet", "Greets someone", version = "1.0");
    parser.add_options(options);
    if (!parser.parse(args)) {
        return 2;
    }

    var people = options.names.is_empty ? ["world"] : options.names;
    for (var person in people) {
        var line = $"{options.greeting}, {person}";
        for (var _ in 0..options.times) {
            print(options.loud ? line.upper() : line);
        }
    }
    return 0;
}

Its help and errors are the same as the builder example’s above: the declarations are the same ones.

Field typeDeclaresAfter a parse
boolflagwhether it was given
Toption<T>the value given, else the field’s starting value
T?option<T>the value given, or Empty
T[]multiple<T>the values given, in order
  • T is any type of Typed values: String, a number type, io.Path or an enum. A field’s starting value is the default the help shows, except an empty String or a null io.Path, which means none.
  • An option is named after its field, with each _ written -: import_path is --import-path. Any other field type is a compile error at the call: 'add_options' cannot declare field 'table' of type 'Map<String, int>'; use bool, String, a number type, io.Path or an enum, or one of those as T? or T[].
  • [cli.Option(short_name, help, name, value_name, required, global)] sets the rest, every argument optional: [cli.Option("o", "the executable to write")], [cli.Option(required = true)].
  • [cli.Positional(help, name, required)] makes the field the next positional argument. A T one is required when it has no default or required is set; a T? one may be left out; a T[] one takes every value left (positionals), and required asks for at least one.
  • Each parse sets every field again, so a flag left out is false even if the field started true.
  • A parse that fails, or that asks for --help or --version, leaves the fields as they were.
  • The two styles mix: call flag and option on the same parser, and add_options on a command’s parser for that command’s options. A command’s struct is filled only when that command is given.

Subcommands

command(name, about) declares a subcommand, as in git add or pep-package update, and returns its own Parser to declare its options and arguments on. The first argument that is not an option names the command; after parse, is_selected tells which command was given.

# Subcommands, and handling the outcome yourself with try_parse.
import cli;

main(): int {
    var parser = cli.Parser("pkg", "Manages packages");
    var verbose = parser.flag("verbose", "v", "say more");
    var add = parser.command("add", "Adds a dependency");
    var name = add.positional("name", "the package to add");
    var dev = add.flag("dev", "d", "only for development");
    var update = parser.command("update", "Updates dependencies");
    var packages = update.positionals("packages", "what to update; everything when none");

    # The arguments as `main(args: String[])` would receive them.
    var parsed = parser.try_parse(["pkg", "-v", "add", "--dev", "yaml"]);
    assert(parsed.is_ok() && parsed.value().is_empty(), "parsed, no help asked for");
    assert(add.is_selected && !update.is_selected, "the add command");
    assert(verbose.is_set && dev.is_set && name.value() == "yaml", "its values");

    # A mistake is an error with a clang-style message.
    parsed = parser.try_parse(["pkg", "updat"]);
    assert(parsed.error().message == "unknown command 'updat'; did you mean 'update'?", "a suggestion");

    # --help gives the text to print, for the command it follows.
    parsed = parser.try_parse(["pkg", "update", "--help"]);
    print(parsed.value().value());
    return 0;
}
  • A parser with commands needs one, and cannot have positional arguments of its own.
  • Options before the command’s name belong to the parent, and everything after it to the command, so pkg -v add x works and pkg add -v x does not, unless -v is global.
  • A global option (flag("verbose", "v", global = true), and the same argument on option and multiple) may also be given after the name of any command below its parser, at any depth, and each command’s help lists it. A command cannot declare an option with a global option’s name, and a required global option may be given anywhere on the line.
  • --help after a command shows that command’s help, and errors show its usage.
  • Commands nest: a command’s parser can declare commands too.

Shell completions

completions(shell) returns a script that completes the program’s command line in Bash, Zsh, fish or PowerShell (cli.Shell), from the declarations: options (with the global ones in each command), commands, an enum’s cases, and file names for a value that is a String or an io.Path. A program prints it on request, often from a command of its own, as pkg completions fish does here:

# Shell completion scripts, printed by a command of the program's own.
import cli;

main(): int {
    var parser = cli.Parser("pkg", "Manages packages");
    parser.flag("verbose", "v", "say more", global = true);
    var add = parser.command("add", "Adds a dependency");
    add.positional("name", "the package");
    var completions = parser.command("completions", "Prints a shell completion script");
    var shell = completions.positional<cli.Shell>("shell", "bash, zsh, fish or powershell");

    # The arguments as `main(args: String[])` would receive them.
    var parsed = parser.try_parse(["pkg", "completions", "fish"]);
    assert(parsed.is_ok() && completions.is_selected, "the completions command");
    print(parser.completions(shell.value()));
    return 0;
}

The user installs what it prints where the shell looks for it:

ShellInstall the script as
bash~/.local/share/bash-completion/completions/pkg, or source it from ~/.bashrc
zsh_pkg in a directory on $fpath
fish~/.config/fish/completions/pkg.fish
powershelldot-sourced from $PROFILE

Not yet

  • Windows-style /option forms: every platform uses - and --.

Cryptographic hashes

The digest module computes SHA-256, SHA-1 and BLAKE3 digests: fixed-size fingerprints of data, used to check that a download arrived intact, to name content by its hash, or to key a cache. It also computes message authentication codes with HMAC or BLAKE3’s keyed mode, and derives keys. Bring it in with import digest; the full API is in the reference.

# Cryptographic hashes: one-shot, incremental, and as hexadecimal text.
import digest;

main(): int {
    # One call hashes a whole input, bytes or a String's UTF-8 bytes.
    var id = digest.to_hex(digest.sha256("hello"));
    assert(id == "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", "SHA-256");

    # A hasher takes the input in pieces, such as the blocks of a file as
    # they are read; the digest does not depend on how it was split.
    var hasher = digest.Blake3();
    hasher.update("hel");
    hasher.update("lo");
    var whole = digest.blake3("hello");
    assert(hasher.finish() == whole, "same digest");
    assert(whole.count == 32, "32 bytes");

    # git names a file's content by the SHA-1 of a header and the bytes.
    u8[] nul = [0];
    var git = digest.Sha1();
    git.update("blob 6");
    git.update(nul);
    git.update("hello\n");
    assert(git.finish_hex() == "ce013625030ba8dba906f756967f9e9ca394464a", "git blob id");
    return 0;
}

One-shot and incremental

Each hash has a one-shot function and a hasher type:

HashFunctionHasherDigest size
SHA-256digest.sha256(input)digest.Sha25632 bytes
SHA-1digest.sha1(input)digest.Sha120 bytes
BLAKE3digest.blake3(input)digest.Blake332 bytes
  • The input is a u8[], or a String, which hashes its UTF-8 bytes (without a terminating NUL).
  • A hasher takes the input in pieces: call update any number of times, then finish for the digest. Splitting the input differently gives the same digest. finish also resets the hasher, so it can hash another input; reset discards the input so far.
  • A digest is a u8[]. digest.to_hex(bytes) writes it as lowercase hexadecimal, the way checksums are usually shown, and each hasher’s finish_hex() does both steps; digest.from_hex(text) reads hexadecimal back into bytes.
  • An array literal passed straight to update is ambiguous between its u8[] and String overloads; give it a type first (u8[] nul = [0]; hasher.update(nul);).

Which hash

  • SHA-256 is the common choice for published checksums and anything that must interoperate.
  • BLAKE3 is faster and as strong; the compiler’s build cache keys its objects with it.
  • SHA-1 is broken for collision resistance: someone can make two inputs with the same digest. Use it only where a format requires it, such as git object ids.

None of these is the hash for a hash table: HashMap and HashSet use hash<T>, which is much faster but neither cryptographic nor stable between versions.

Message authentication

A message authentication code (MAC) is a digest that only someone with a secret key can compute, so a receiver that shares the key can check that a message is authentic and unchanged. Webhook signatures, JWTs (HS256) and S3-style request signing all use HMAC.

# Message authentication: HMAC, BLAKE3's keyed mode and key derivation.
import digest;

main(): int {
    # A webhook signs its body with HMAC-SHA-256 under a shared secret;
    # the receiver computes the MAC again and compares in constant time.
    var body = "{\"event\":\"push\"}";
    var received = digest.hmac_sha256("webhook secret", body);
    var signature = "493db758cb288b3801a0084bcdd27bd565ca0fb9b0a149404d2b4442c9d79bcd";
    assert(digest.to_hex(received) == signature, "HMAC-SHA-256");
    var expected = digest.Hmac<digest.Sha256>("webhook secret");
    expected.update(body);
    assert(digest.equal_constant_time(expected.finish(), received), "authentic");

    # Derive a 32-byte key for one purpose from a shared secret, then use
    # it for BLAKE3's keyed mode.
    var derive = digest.Blake3.derive_key("example.com 2026-10-16 webhook MACs v1");
    derive.update("a shared secret");
    var mac = digest.Blake3.keyed(derive.finish());
    mac.update(body);
    assert(mac.finish().count == 32, "a 32-byte MAC");

    # BLAKE3 gives output of any length; the first 32 bytes are the digest.
    var hasher = digest.Blake3();
    hasher.update(body);
    var long_output = hasher.finish(64);
    assert(long_output.count == 64, "64 bytes");
    assert(digest.to_hex(long_output).starts_with(digest.to_hex(digest.blake3(body))), "a prefix");
    return 0;
}
  • HMAC (RFC 2104) works over SHA-256 or SHA-1: digest.hmac_sha256(key, message) and digest.hmac_sha1(key, message) are one-shot, both arguments u8[] or both String, and digest.Hmac<digest.Sha256>(key) is the incremental hasher, with the same update, finish, finish_hex and reset as the hashes. finish and reset keep the key. A key longer than 64 bytes is hashed first.
  • Check a MAC with digest.equal_constant_time(a, b), not ==: == stops at the first byte that differs, and timing it lets an attacker find a valid MAC a byte at a time.
  • BLAKE3’s keyed mode, digest.Blake3.keyed(key), is a MAC with a 32-byte key; it panics on a key of any other length.
  • Key derivation: digest.Blake3.derive_key(context) returns a hasher whose digest is a key, derived from the input (a shared secret, say) for the purpose context names. Make context a hard-coded string unique to the application and purpose; different contexts give unrelated keys.
  • Extended output: every Blake3 hasher’s finish(length) returns length bytes, in any of the three modes. The first 32 are the digest finish() returns.

Using the system’s crypto library

By default the hashes are written in Pepper, so a program that uses them links no extra library. Building with -D USE_LIBCRYPTO (see Build options) makes SHA-256 and SHA-1 call the platform’s crypto library instead, which uses the CPU’s SHA instructions where it has them: several times faster (about 1.7 GB/s instead of 250 MB/s for SHA-256 on an x86-64 with SHA-NI).

pep-build main.pep -D USE_LIBCRYPTO -o app
PlatformLibraryWhat it needs
LinuxOpenSSL’s libcrypto (EVP_Digest*), linked as -lcryptothe development package (libssl-dev, openssl-devel)
macOSOpenSSL’s libcrypto, linked as -lcryptoHomebrew’s openssl@3, and its lib folder named with -L or PEPPER_LIBRARY_PATH (-L $(brew --prefix openssl@3)/lib)
WindowsCNG (BCryptHash*), linked as bcrypt.libnothing: CNG is part of Windows 10 and later

The API and the digests are the same either way. Blake3 stays portable Pepper, since libcrypto has no BLAKE3.

Limitations

  • Without USE_LIBCRYPTO, SHA-256 and SHA-1 run at a few hundred megabytes per second. BLAKE3 is always the portable code, below the official BLAKE3, which uses SIMD and threads.
  • The hashes have no secret-dependent branches or table lookups, but they have not been audited for timing side channels, which matter only when hashing secrets. Copies of an HMAC key stay in memory until the hasher is freed; nothing wipes them.
  • HMAC works over SHA-256 and SHA-1; there is no SHA-512 to put under it.

Random numbers

The random module gives random numbers two ways: secure bytes from the operating system, for keys, tokens and identifiers, and Generator, a fast pseudo-random generator for everything else: games, simulations, sampling, shuffling and jitter. Bring it in with import random; the full API is in the reference.

# Random numbers: this thread's generator, a seeded one, and secure bytes.
import random;

main(): int {
    # This thread's generator, seeded from the operating system.
    var die = random.next_int(1..>6);
    assert(die >= 1 && die <= 6, "a die roll");
    var names = ["ada", "grace", "edsger"];
    random.shuffle(names);
    var pick = random.choose(names);
    assert(names.contains(pick), "one of the names");

    # A seeded generator gives the same numbers on every run and platform.
    var a = random.Generator(2024);
    var b = random.Generator(2024);
    assert(a.next_double() == b.next_double(), "same seed, same numbers");

    # Keys and tokens come from the operating system, not a generator.
    var key = random.bytes(32);
    assert(key.count == 32, "256 bits");
    return 0;
}

Secure bytes

FunctionGives
random.bytes(count)a new u8[] of count random bytes
random.fill(bytes)overwrites every element of an existing u8[]
random.secure_ulong()a random ulong

They come from getentropy on Linux, Android, macOS, iOS and WASI, and from BCryptGenRandom on Windows, so no one can predict them. Each call asks the kernel, which is slower than a generator. They panic only if the operating system cannot give random bytes, which a working system never does.

Generators

random.Generator is xoshiro256**: 256 bits of state, statistically strong output, and a few instructions a number. It is not secure: anyone who sees enough of its output can predict the rest.

  • Generator() seeds itself from the operating system, so each run gives different numbers.
  • Generator(seed) gives the same numbers for the same seed on every platform, for reproducible tests and simulations.
MethodGives
next_ulong()64 random bits
next_int(range)an int in range: 0..10 is 0 to 9, 1..>6 is 1 to 6
next_double()a double from 0 up to but not including 1
next_bool()true or false
choose(items)one element of an array
shuffle(items)puts an array in a random order, in place

Every number in a range is equally likely: next_int draws again rather than let the low numbers come up a little more often. An empty range, or choose on an empty array, panics.

A generator is a value struct: copying one copies its state, so the copy gives the same numbers as the original, and a function that takes a generator as a parameter draws from its own copy. A generator belongs to one thread.

This thread’s generator

random.next_ulong, next_int, next_double, next_bool, choose and shuffle draw from a generator each thread has of its own, seeded from the operating system the first time the thread uses it. Code that needs a random number calls them without passing a generator around, and threads never share or lock one.

Encodings and UUIDs

Networked programs keep writing bytes as text and back: credentials in an HTTP header, a token in a URL, a value in a query string, an identifier in a database. These live in a few places:

WhatWhereFunctions
Base64import base64base64.encode, base64.decode
Hexadecimalimport digestdigest.to_hex, digest.from_hex
Percent-escapesUri, in the preludeUri.encode, Uri.decode
Form dataUri, in the preludeUri.encode_form, Uri.decode_form
UUIDsimport uuidUuid.v4, Uuid.v7, Uuid.parse, to_string
# Encodings: base64, hexadecimal, percent-escapes and form data, and UUIDs.
import base64;
import digest;
import uuid;

main(): int {
    # base64: an HTTP basic authentication header, and a URL-safe,
    # unpadded token as JSON Web Tokens write them.
    var credentials = base64.encode("ada:secret".bytes());
    assert(credentials == "YWRhOnNlY3JldA==", "standard, padded");
    var token = base64.encode([251, 255, 0], true, false);
    assert(token == "-_8A", "URL-safe, unpadded");
    assert(base64.decode(token, true).value()[0] == 251, "decoded");
    assert(base64.decode("not base64!").is_err(), "rejected");

    # Hexadecimal, both ways.
    assert(digest.from_hex(digest.to_hex([1, 171])).value()[1] == 171, "round trip");

    # Percent-escapes for one path segment or query value, and form data.
    assert(Uri.encode("a b&c") == "a%20b%26c", "escaped");
    var query = Uri.encode_form([("q", "pepper lang"), ("page", "2")]);
    assert(query == "q=pepper+lang&page=2", "form-encoded");
    var pairs = Uri.decode_form(query).value();
    assert(pairs[0].1 == "pepper lang", "decoded");

    # UUIDs: random (v4) and time-ordered (v7).
    var id = uuid.Uuid.v4();
    assert(uuid.Uuid.parse(id.to_string()).value() == id, "round trip");
    assert(uuid.Uuid.v7().version() == 7, "time-ordered");
    return 0;
}

Base64

base64.encode(bytes) writes RFC 4648 base64 with the standard alphabet (A-Z a-z 0-9 + /) and = padding. Two optional arguments change that: url_safe = true uses - and _ in place of + and /, so the text can go in a URL or a file name, and padded = false leaves off the padding, as JSON Web Tokens do.

base64.decode(text) returns a Result<u8[]>. It accepts text with or without padding (pass url_safe = true for the other alphabet), and is strict otherwise: whitespace, a character outside the alphabet, a length no encoding gives, or unused bits that are not zero is an error. Remove line breaks from PEM or MIME text before decoding it. The functions work on whole arrays; there is no streaming encoder yet.

Hexadecimal

digest.to_hex(bytes) writes two lowercase digits per byte, and digest.from_hex(text) reads them back, in either case; an odd length or a character that is not a digit is an error.

Percent-escapes and form data

Uri.encode(text) escapes every byte outside RFC 3986’s unreserved set (A-Z a-z 0-9 - . _ ~), so the result is safe as one path segment or one query value: /, ?, &, = and % are escaped too. Uri.decode reverses it.

Uri.encode_form(pairs) writes names and values as application/x-www-form-urlencoded, the format of HTML forms and most query strings (q=pepper+lang&page=2), and Uri.decode_form(text) reads it back into an Array<(String, String)>, in order, keeping repeated names. In this format a space is +.

UUIDs

uuid.Uuid is a 128-bit identifier (RFC 9562), a value struct of two ulong halves, high and low, so == and hash work and it can key a map.

  • Uuid.v4() makes a random one: 122 bits from the operating system’s secure source.
  • Uuid.v7() puts the wall clock’s Unix time in milliseconds first, then 74 random bits, so ids made later sort later, both as values and as text. That keeps database indexes compact. Ids made in the same millisecond are in no particular order.
  • Uuid.parse(text) reads the usual form, 0190163d-8694-739b-aea5-966c26f8ad91, in either case, and to_string() (or interpolation) writes it in lowercase.
  • version() returns the version digit and bytes() the 16 bytes, for a database or a binary format.

The full API is in the reference: base64, uuid, hexadecimal and Uri.

Optimizer hints

Hints tell the optimizer what it cannot see in the code. A hint never changes what a program computes, only how fast it runs, so a wrong hint costs speed and nothing else.

  • Which way a branch goes: write [Likely] or [Unlikely] above the if or match arm (see branch hints).
  • A function rarely called: [Cold] above it (see cold functions).
  • Memory a loop will soon touch: the hint module’s prefetches.
FunctionTells the optimizer
hint.prefetch_read(address)address will be read soon: start loading its cache line now.
hint.prefetch_write(address)address will be written soon.

A prefetch never faults, even for an address that is not mapped, and does nothing where the CPU has no prefetch instruction.

# Prefetching: start loading memory a loop will soon read.
import hint;

main(): int {
    var text = "a long text that a loop will read";
    hint.prefetch_read(text.c_str());
    var spaces = 0;
    for (var index in 0..text.length) {
        if (text.byte_at(index) == ' ') {
            spaces += 1;
        }
    }
    assert(spaces == 7, "a hint changes nothing the program computes");
    return 0;
}

The prefetches are LLVM’s llvm.prefetch intrinsic (see LLVM intrinsics). They matter only with optimization (-O2 and up, where the runtime’s functions inline into the caller); at -O0 they cost a call and do nothing.

Cold functions

[Cold] above a function, method or constructor marks it rarely called: the optimizer never inlines it and treats a call to it as the unlikely side of a branch. Split the rare work out of a hot function into a [Cold] one, so the hot function stays small enough to inline into its callers:

# A hot method with its rare work split out into a [Cold] one.
struct Counter {
    var count: int;
    var wraps: int;

    construct() {
        count = 0;
        wraps = 0;
    }

    # Called on every event: small enough to inline.
    add() {
        if (count == 1000) {
            _wrap();
        }
        count += 1;
    }

    # Called once per thousand events: kept out of line.
    [Cold]
    _wrap() {
        count = 0;
        wraps += 1;
    }
}

main(): int {
    var counter = Counter();
    for (var index in 0..2500) {
        counter.add();
    }
    assert(counter.wraps == 2 && counter.count == 500, "[Cold] changes nothing the program computes");
    return 0;
}

[Cold] is LLVM’s cold and noinline function attributes. The runtime’s allocator marks its slow paths this way.

Deprecated hints

hint.likely(condition) and hint.unlikely(condition) are deprecated: each use warns and suggests the attribute.

SIMD vectors

A SIMD vector holds several numbers of one type, its lanes, and computes on all of them with one instruction. The simd module has two types:

  • Vector<T, N>: N lanes of the number type T.
  • Mask<N>: N booleans, what a lane-wise comparison gives.
import simd;

# The index of the first byte of `text` that is not ASCII, or its size:
# sixteen bytes per step, then one at a time for the rest.
first_non_ascii(text: String): ulong {
    var bytes = unsafe_cast<__ptr<byte>>(text.c_str());
    ulong i = 0;
    while (i + 16 <= text.size) {
        var high = simd.Vector<byte, 16>.load(bytes + i).greater_equal(0x80 as byte).to_bits();
        if (high != 0) {
            return i + high.trailing_zeros();
        }
        i = i + 16;
    }
    while (i < text.size && bytes[i] < 0x80) {
        i = i + 1;
    }
    return i;
}

main(): int {
    # A vector holds `N` lanes and computes on all of them at once.
    var a = simd.Vector<int, 4>(10);
    a[1] = 20;
    var b = a * 2 + 1;
    assert(b[0] == 21 && b[1] == 41 && b.sum() == 104, "lane-wise arithmetic");

    # A comparison gives a mask, one bool per lane.
    var big = b.greater(30);
    assert(big.to_bits() == 2 && big.any() && !big.all(), "a mask");
    var capped = big.select(simd.Vector<int, 4>(30), b);
    assert(capped.maximum() == 30, "select");

    # Floats, and conversions between lane types.
    var halves = a.convert<float>() * 0.5;
    assert(halves[1] == 10.0 && halves.square_root()[0] < 2.3, "floats");

    assert(first_non_ascii("plain ascii, then caf\u{e9}") == 21, "a scan");
    assert(first_non_ascii("short") == 5, "no match");
    return 0;
}

Bring them in with import simd; and write simd.Vector<byte, 16>, or import [Vector, Mask] from simd; and write Vector<byte, 16>.

Lane types and counts

  • T is an integer or floating-point type: byte, char, short, ushort, int, uint, long, ulong, float or double. These conform to the module’s Lane trait; any other type is an error at the type argument:

    main.pep:3:25: error: 'String' does not conform to trait 'Lane', required by 'T' in 'Vector'
    
  • N is written as a number, a power of two from 1 to 64:

    main.pep:3:30: error: lane count of 'Vector<int, 3>' must be a power of two from 1 to 64, not '3'
    

    A number is a type argument only here, for a lane count; it is not a general constant parameter, so code cannot compute with N (use the count property for the lane count as a value).

  • Sixteen bytes (Vector<byte, 16>, Vector<short, 8>, Vector<int, 4>, Vector<float, 4>, Vector<double, 2>) fits a vector register on every target Pepper supports and is the width to reach for first. A wider vector works everywhere: where the target’s registers are narrower, each operation takes several instructions.

A vector is a value struct: it is stored inline, copied like a number, and safe to share between threads (a [Shared] struct’s field, a module constant).

Operations

MakeVector<T, N>() (every lane 0), Vector<T, N>(x) (every lane x)
Lanesv[i], v[i] = x (panics when i >= N), v.count
Arithmetic+ - *, unary -, with a vector or a scalar: v * 2
Division/ %: integer lanes panic on a zero divisor lane, and on the lowest value divided by -1; float lanes follow IEEE
Bitwise (integers)& | ^ ~, << >> by an int count (panics outside 0.. the lane width; >> is arithmetic for signed T), rotate_left, rotate_right
Floatssquare_root, floor, ceiling
Bothmin, max, abs
Horizontalsum, minimum, maximum
Comparisonsequal, not_equal, less, less_equal, greater, greater_equal, with a vector or a scalar, giving a Mask<N>
Whole vector== (every lane equal), != (some lane differs)
Rearrangingshuffle(indices), reverse, interleave_low, interleave_high
Typesconvert<U>() (lane by lane, like as), reinterpret<U, M>() (the same bits)
MemoryVector<T, N>.load(array, index), v.store(array, index) (panic unless index + N <= array.count); load, store, load_aligned, store_aligned on a __ptr<T>

A mask has any(), all(), & | ^ ~, m[i], select(a, b) (each lane from a where the mask is true, else from b) and to_bits(), which packs lane i into bit i of a ulong. With trailing_zeros() (see integer bit methods) that finds the first true lane, which is how a scan for a byte, or for a byte that is not ASCII, finds its match sixteen bytes at a time.

Some differences from scalar arithmetic are deliberate:

  • Integer lanes wrap around on overflow in +, -, *, unary - and abs, as vector instructions do; scalar arithmetic panics instead.

  • Comparisons that give a lane per lane are methods, because a comparison operator must return bool (see operators). == and != compare whole vectors.

  • shuffle takes each index modulo N, so every index picks a lane. With constant indices an optimized build makes it one shuffle instruction.

  • convert from float to integer saturates: a lane outside the target type’s range becomes its nearest value, and NaN becomes 0.

  • The bitwise operators, shifts and rotates exist only for integer lanes, and square_root, floor and ceiling only for float lanes:

    main.pep:4:15: error: no member named 'square_root' in 'Vector<int, 4>'
    

Targets and optimization

The operations compile to LLVM vector instructions, which the target turns into its own: SSE2 on x86-64 (wider with -march=x86-64-v3 for AVX2, see pep-build), NEON on arm64, and WebAssembly’s simd128 with -msimd128. A target without vector instructions, such as wasm32-wasip1 without -msimd128, computes the same results one lane at a time. Nothing picks instructions at run time: the -m options decide.

Each operation is a small method that the compiler always inlines, so an optimized build (-O2) of a loop over vectors has no calls in it, only the vector instructions. At -O0 it still runs, more slowly, like the rest of an unoptimized program.

Logging

The log module writes lines with a level, a message, key/value fields and the place of the call, from any thread or task. Bring it in with import log.

# Leveled, structured logging: levels, fields, a target, and a JSON file.
import io;
import log;

main(): int {
    log.info("started");
    log.warning("disk almost full", [log.field("free_mb", 120)], target = "storage");
    log.debug("hidden: the default level is info");

    log.set_level(log.Level.Debug, "storage");
    if (log.enabled(log.Level.Debug, "storage")) {
        log.debug("scanning", [log.field("path", "/var/data")], target = "storage");
    }

    # From here on, lines go to a file as JSON Lines.
    var path = io.Directory.temp().join("logging-basics.log");
    var file = io.File.create(copy(path)).value();
    log.set_output(move(file), log.Format.Json);
    log.error("cannot connect", [log.field("attempt", 3)], target = "db");
    log.set_output(io.File.stderr());

    var text = io.File.read_to_string(path).value();
    assert(text.starts_with("{\"time\":"), "one JSON object per line");
    assert(text.contains("\"level\":\"error\",\"target\":\"db\",\"message\":\"cannot connect\""), "the line's keys");
    assert(text.contains("\"attempt\":3}"), "the field, as JSON");
    io.File.remove(path);
    return 0;
}

On standard error this prints:

2026-10-17T23:48:53.555Z INFO  started (logging-basics.pep:6:5)
2026-10-17T23:48:53.555Z WARN  [storage] disk almost full free_mb=120 (logging-basics.pep:7:5)
2026-10-17T23:48:53.555Z DEBUG [storage] scanning path="/var/data" (logging-basics.pep:12:9)
FunctionDoes
log.error(message), warning, info, debug, traceWrites a line at that Level.
log.field(key, value)Makes a Field for a line’s fields; the value is written as JSON.
log.set_level(level, target)Sets the least important level shown, for one target or (with no target) for all.
log.enabled(level, target)Whether a line at level for target would be written.
log.set_output(writer, format)Sends lines to a Writer in Format.Text or Format.Json.

Lines

Each logging function takes the message, then two optional arguments, usually named:

  • fields: an array of log.field(key, value). A text line shows each as key=value, with the value’s JSON text (200, "/var/data", true).
  • target: the part of the program the line is about, such as "http". A text line shows it in brackets, and levels can be set for it alone.

Each line names the place of the call, as assert does. The time is UTC, to the millisecond. On a terminal the level is in color, unless the NO_COLOR environment variable is set (on Windows, only in a console already in virtual terminal mode, such as Windows Terminal).

Levels

The levels, most important first, are Error, Warning, Info, Debug and Trace. A target shows its level and every level above it; Off shows nothing. By default every target shows Info.

The PEPPER_LOG environment variable sets the levels when the program first logs: comma-separated items, each a level for every target or target=level for one. The names are off, error, warning (or warn), info, debug and trace, in any case. An item with an unknown level is reported on standard error and skipped.

$ PEPPER_LOG=warning,http=debug ./server

log.set_level changes them from code. A target named in PEPPER_LOG or set_level uses its own level; every other target uses the global one.

Cost

A line no target shows costs two atomic loads: no lock and no formatting. The arguments are still evaluated, so a message or field that is costly to build goes inside an if (log.enabled(...)), as in the example.

Output and threads

Lines go to standard error as text until log.set_output names a Writer, such as a file or a socket, and a format:

  • Format.Text: the readable lines above.
  • Format.Json: one JSON object per line (JSON Lines) with time, level, target (when there is one), message and location, then each field.

The log keeps the writer, so it must be a new value or one handed over with move(x). A File keeps its path, so make it from copy(path), as the example does.

Each line is built first and then written whole, with one write_all, under a Mutex, so lines from many threads and tasks never interleave. A task waiting for that lock parks instead of blocking its worker thread. A failed write is ignored.

Not yet

  • Levels cannot be removed at compile time.
  • The runtime’s own modules (such as http) and the pep-* tools do not log through log yet.

Backtraces and the debugger

The debug module walks the call stack and talks to a debugger. Bring it in with import debug.

FunctionDoes
debug.capture()Records the call stack as a Backtrace: the return address of each call, innermost first.
trace.frames()Resolves the addresses to StackFrames: function, file, line, column, address and inlined.
debug.print_backtrace()Writes the current stack to standard error, as a panic does.
debug.output(text)Writes to the platform’s debugger channel.
debug.is_debugger_attached()Whether a debugger is attached.
debug.breakpoint()Stops in the debugger, as a breakpoint on that line would.
# Capturing the call stack and resolving it to functions and lines.
import debug;

struct Parser {
    var depth: int;

    construct() {
        depth = 0;
    }

    # Records where it was called from, as an error report might.
    where(): debug.Backtrace {
        return debug.capture();
    }
}

main(): int {
    var parser = Parser();
    var trace = parser.where();
    var frames = trace.frames();
    for (var frame in frames) {
        print(frame.to_string());
    }
    # The outermost frame is main. (With -O and no -g, `where` is inlined
    # into main and has no frame of its own; WebAssembly cannot walk its
    # stack, so there a backtrace is empty.)
    if (frames.count > 0) {
        assert(frames[frames.count - 1].function == "main", "the outermost call");
    }
    debug.output("done");
    return 0;
}

Built with -g, this prints:

Parser.where at debug-backtrace.pep:13:16
main at debug-backtrace.pep:19:24
done

Built with -g -O2, the optimizer inlines where into main, and the backtrace still shows it, marked as inlined:

Parser.where at debug-backtrace.pep:13:16 (inlined)
main at debug-backtrace.pep:19:24
done

Capturing is cheap; resolving is not

capture() only records addresses, so a program can capture a stack whenever it might want to report one later, such as when it creates an error value. The work happens in frames() and to_string(), which read the program’s symbol table and line table each time they are called.

A function name needs the program’s symbol table, which every build keeps unless the executable is stripped. A file, line and column need the debug info a -g build carries:

PlatformFunction namesSource lines
LinuxThe executable’s ELF symbol tableIts DWARF line table
macOSThe executable’s symbol tableThe .dSYM bundle a -g build writes beside the executable (with dsymutil, from Xcode’s command line tools)
WindowsDbgHelp, from the .pdb every build writes beside the executableDbgHelp, from the .pdb of a -g build (no columns)
WASInonenone

A WebAssembly program cannot walk its own stack, so on WASI a backtrace is empty.

Inlined calls

A function the optimizer inlined into its caller has no frame of its own on the stack. In a -g build, frames() puts it back: it expands each address into the chain of inlined calls it is inside, as addr2line -i and debuggers do, with one StackFrame per call, innermost first. Each of these frames has inlined set and is at the line of its own call, and the frame after them, which is not inlined, is the real one. All of them share its address. Linux and macOS read the inlined calls from the DWARF debug info, and Windows asks DbgHelp. On Windows an inlined call to a generic function is named without its type arguments (helper, not helper<int>), since the .pdb records inlined calls that way.

Without -g there is no record of what was inlined, so an optimized build’s backtrace shows only the real frames, each named after the function the code was inlined into.

Which frames are left out

frames() leaves out, at the top of the stack, the frames of capture itself and of the runtime’s panic code, and the runtime’s operator methods a panic came through: a panic in a[i] starts at the line with a[i], not in Array<int>.__index_get. It also leaves out the C runtime’s frames below main. frames(full = true) keeps them all.

Backtraces on panic

A panic prints the stack after its message in a build without -O:

main.pep:3:9: panic: index out of range
backtrace:
  0: inner at main.pep:3:9
  1: main at main.pep:8:12

An optimized build prints the message alone. The PEPPER_BACKTRACE environment variable overrides the build: PEPPER_BACKTRACE=1 prints a backtrace in any build, PEPPER_BACKTRACE=full also prints the frames frames() leaves out, and PEPPER_BACKTRACE=0 never prints one. Without -g, each frame shows its function but no file or line.

Only the first panic in a process prints a backtrace. If a second thread panics at the same time, it prints just its message.

The debugger channel

debug.output(text) writes a line where a debugger shows it:

  • Windows: OutputDebugStringW. Visual Studio’s Output window and WinDbg show it, and so does DebugView when no debugger is attached. It goes nowhere else.
  • Linux, macOS and WASI: standard error. These systems have no separate channel, and debuggers show the program’s own output.

debug.is_debugger_attached() reads TracerPid in /proc/self/status on Linux, P_TRACED on macOS, and IsDebuggerPresent on Windows. It is always false on WASI.

debug.breakpoint() executes the processor’s debug trap. A debugger stops there and can continue the program. With no debugger attached, the trap ends the program, so guard it:

if (debug.is_debugger_attached()) {
    debug.breakpoint();
}

break is a keyword, so the function is called breakpoint.

See Debugging for running a program under gdb, lldb or Visual Studio.

Unit tests

A unit test is a function marked [Test]: it takes no parameters, returns nothing, and passes when it returns. It checks what the code under test does with expect_equal and the other expect_* functions, which are in the prelude, so a test file needs no import:

# Counts the words in `text`: the runs of characters between spaces.
word_count(text: String): ulong {
    ulong count = 0;
    for (var word in text.split(" ")) {
        if (!word.is_empty) {
            count += 1;
        }
    }
    return count;
}

[Test]
counts_the_words() {
    expect_equal(word_count("salt and pepper"), 3);
    expect_equal(word_count("  spaced   out "), 2);
}

[Test]
an_empty_string_has_no_words() {
    expect_equal(word_count(""), 0);
    expect_true(word_count(" ") == 0);
}

[Test]
[ShouldPanic("out of bounds")]
reading_past_the_last_word_panics() {
    var words = "salt pepper".split(" ");
    var third = words[2];
}

# A normal build leaves the tests out and runs this.
main(): int {
    print(word_count("salt and pepper"));
    return 0;
}

pepper test (the pep-test tool) builds the tests and runs them:

$ pepper test words_test.pep
running 3 tests
test counts_the_words ... ok (4 ms)
test an_empty_string_has_no_words ... ok (3 ms)
test reading_past_the_last_word_panics ... ok (5 ms)
3 passed, 0 failed

Given a folder, or nothing (the current folder), it runs every test in the .pep files under it. Its exit status is 0 only when every test passes.

Checks

Each check panics when it fails, as assert does, and reports what it compared at the line of the call, so a failing test says where and why:

words_test.pep:14:5: panic: expect_equal failed
  actual:   2
  expected: 3
CheckFails when
expect_equal(actual, expected)actual == expected is false; prints both
expect_not_equal(actual, unexpected)the two are equal; prints the value
expect_true(condition)condition is false
expect_false(condition)condition is true

expect_equal and expect_not_equal take two values of one type that has == and a text form (see to_string in String interpolation). assert and panic fail a test too: any panic does.

Tests that should panic

[ShouldPanic] turns a test around: it passes only when it panics. With a message, [ShouldPanic("out of bounds")], the panic’s message must also contain that text, so a test does not pass on some other panic. A [ShouldPanic] test that returns fails.

How tests run

Each test runs in a child process of its own, so a panic, a crash or a test that never ends fails only that test, and the others carry on. Tests run in parallel, one per logical CPU (--jobs 1 runs them one at a time, in order), and a test still running after 60 seconds fails (--timeout changes the limit). Tests share no state: each process starts fresh.

A test also fails when it leaks: the test program counts the live allocations before and after the test, and a test that returns with more (or fewer) than it started with fails, saying how many it left behind:

error: test 'counts_the_words' left 1 allocation behind

What a program makes once and keeps until it ends, such as a global made on first use, is not a leak. A test that is first to use it wraps that use in keep_for_the_program, which runs it and leaves what it allocates out of the count:

keep_for_the_program(() => {
    tls.TlsConnector.create();   # loads the TLS library, which stays loaded
});

There is no setup or teardown: a fixture is a plain function that a test calls, and what it makes is released when the test’s scope ends, as everywhere else. A struct whose destructor cleans up makes a fixture that tears itself down. TempFolder, which every test file can use, is one: a fresh folder under the system’s temporary folder, removed with what is in it when the test returns:

import io;

[Test]
writes_a_file() {
    var folder = TempFolder("writes_a_file");
    var path = folder.path.join("notes.txt");
    io.File.write_text(path, "hello");
    expect_equal(io.File.read_to_string(path).value(), "hello");
}

Where tests live

A test can sit beside the code it tests, where it can call the file’s private functions, as above. Or tests can live in their own files, such as a module’s tests/ folder, where they import the module and see its public API; pepper test -I <dir> adds the folder that holds the module to the import path:

geometry/
  module.pep
  shapes.pep
  tests/
    area_test.pep      # import geometry; [Test] functions
pepper test -I . geometry/tests

pep-test looks at each .pep file under the folders it is given and builds those with a line that starts with [Test]. The test files of one folder build into one test program, whose tests are their [Test] functions. Folders whose names start with . are skipped.

Each file keeps the scope it has in any program. A folder inside a module (a test/ folder below its module.pep, say) holds files of that module, which see each other’s names: two of them that declare the same helper, or the same test name, collide (redefinition of 'helper'). Mark such a declaration private in each file, or rename it. Test files outside a module see only each other’s exports, so their names never collide.

Test builds

A normal build leaves [Test] functions out, so they cost nothing, and a file of tests can keep a main of its own. pep-build --test, which pep-test uses, builds the test program instead:

  • it keeps the [Test] functions and defines the build option TEST, so code only the tests use (a fixture that would be unused otherwise) can be marked [BuildOption("TEST")];
  • it replaces the entry file’s main with one that answers --list (each test, one per line) and --run NAME (run one test), which is how pep-test drives it. Given several files of one folder, it builds the tests of them all into one program, and --run FILE::NAME tells apart two tests of one name.

A [Test] function that takes parameters, returns a value or is generic is an error, as are [Test] on a method and [ShouldPanic] without [Test]:

main.pep:2:8: error: test 'checks' must take no parameters
    2 | checks(x: int) {}
      |        ^

The language server checks a file that declares a [Test] function as a test build does: with the build option TEST and testing loaded, so errors, hover and go-to-definition work inside test bodies with no setting (see pep-lsp).

Tests that compare a program’s printed output with an expected file are not supported yet.

Calling C

Pepper programs can call functions written in C: the C standard library, your operating system’s API, or any other library with a C interface. You declare the C function’s prototype with extern, make sure the library that defines it is linked, and then call it like any Pepper function.

Declaring a C function

An extern declaration is a C prototype, written the way C writes it: the return type, the name, and the parameter types, each followed by a parameter name. It has no body.

extern ulong strlen(char* s);
extern int strcmp(char* a, char* b);
extern void* malloc(ulong size);
extern void free(void* p);

After that, strlen("hello") calls the C library’s strlen. The compiler does not check your prototype against any C header, so it must match the real C function exactly. A wrong parameter or return type compiles, and then passes or reads the wrong bits when the program runs.

Match C types with Pepper types of the same size:

CPepper
char, signed char, unsigned charchar or i8, u8 or byte
short, unsigned shortshort or i16, ushort or u16
int, unsigned intint or i32, uint or u32
long long, unsigned long long, int64_t, size_t (64-bit)long or i64, ulong or u64
float, doublefloat, double
T*T* in the prototype

Pepper’s long is always 64 bits. C’s long is 64 bits on Linux and macOS but 32 bits on Windows, so a C function that takes long needs a different prototype per platform (see [Link(name = ...)] below for an example).

32-bit targets

On a 32-bit target (wasm32-wasip1, see WebAssembly), pointers, C’s long and size_t are 32 bits. There, an extern’s long and ulong stand for C’s pointer-sized long/ssize_t and unsigned long/size_t: the call passes the low 32 bits and widens the result back to 64 (sign-extending a long, zero-extending a ulong), so extern ulong strlen(char* s); works unchanged. A Pepper function passed to C as a callback is converted the same way in the other direction. i64 and u64 stay 64 bits on every target; use them for C’s long long, int64_t and off_t. WebAssembly checks every call’s signature, so a prototype with the wrong width traps instead of returning garbage.

Some things to know about extern declarations:

  • The prototype uses C syntax only. The name(parameter: type): type form that Pepper functions use is a parse error on an extern, and __ptr<T> as a parameter type is an error too: write char*, not __ptr<char>.
  • An extern is private to its file (or module), like every declaration. Write export extern ... to let files that import this one call it (see Visibility).
  • Several files may declare the same extern, since every declaration names the same C function. The runtime already declares malloc, free and a few others privately, and your program can declare them again.
  • An extern is linked by name. If no linked library defines it, the program compiles and then fails to link, with the linker reporting an undefined symbol. See Linking for how to add libraries.
  • An extern is a function value too, of the function type its prototype gives (abs is an (int) => int), so it can be stored and passed like a Pepper function. Where a C function pointer type is expected, it is the C function’s own address (see Passing a Pepper function to C). A variadic extern (one ending in ..., below) is not a value: wrap a call to it in a closure.
# An extern function is a function value, like a Pepper function.
extern int abs(int x);
extern ulong strlen(char* s);

apply(f: (int) => int, x: int): int {
    return f(x);
}

main(): int {
    # Its type comes from the prototype: `abs` is an `(int) => int`.
    var absolute = abs;
    assert(absolute(-3) == 3, "through a local");
    assert(apply(abs, -4) == 4, "as an argument");

    # A `char*` parameter is a `__ptr<char>` in the function type.
    var length: (__ptr<char>) => ulong = strlen;
    var word = "four";
    assert(length(word.c_str()) == 4, "strlen");

    # Where a C function pointer is expected, it is the C function itself.
    var pointer: extern (int) => int = abs;
    assert(pointer(-5) == 5, "C function pointer");
    return 0;
}

Passing strings

Pepper’s String is a reference-counted object, not a C string, so a String value cannot be passed where C expects char*.

  • A string literal passed to an extern is passed as a NUL-terminated C string, with no allocation: strlen("hello") is 5. The same is true wherever a literal flows into a __ptr<char>, __ptr<u8>, or __ptr<void> in Pepper code (a variable, field, argument, or return value), so String("hello") copies the literal’s five bytes.
  • A StringConstant passes its constant NUL-terminated bytes with no copy, by itself where a __ptr<char> is expected or through c_str(). The pointer is valid for the whole run of the program and is never __null.
  • A String value is passed with c_str(), which returns the String’s buffer, NUL-terminated, as a __ptr<char>. A String is not guaranteed to be NUL-terminated otherwise, so always go through c_str() when C needs the terminator. The pointer belongs to the String: it is valid until the String is destroyed or grows (for example with append or +=). An empty String() has no buffer, so its c_str() is __null; check before you pass it to a C function that does not accept NULL.
  • To turn a C string back into a String, pass the pointer to the constructor: String(p) copies a NUL-terminated C string, and String(p, size) copies size bytes. If you already know the number of code points, pass it as a third argument: String(p, size, String.length_of(p, size)). The constructor copies the bytes, so you still own (and may free) the C buffer. A C string is never passed or stored where a String is expected without this copy: that is an error.
# Passing strings to C, and turning C strings back into Strings.

extern ulong strlen(char* s);
extern int strcmp(char* a, char* b);
extern char* strchr(char* s, int c);

main(): int {
    # A string literal passed to an extern is a NUL-terminated C string.
    assert(strlen("hello") == 5, "a literal is passed as a C string");
    assert(strcmp("abc", "abc") == 0, "equal C strings compare as 0");

    # So is one stored in a __ptr<char>, or passed to the String
    # constructor, which copies it.
    __ptr<char> greeting = "hello";
    assert(strlen(greeting) == 5, "a literal in a __ptr<char> is a C string");
    assert(String("hello").size == 5, "String(literal) copies its bytes");

    # A String value goes through c_str().
    var name = "Pepper";
    name += " and C";
    assert(strlen(name.c_str()) == name.size, "c_str() is the String's bytes");

    # A char* result is a __ptr<char>. Copy it into a String with the
    # constructor: the byte size and the number of code points.
    var rest = strchr(name.c_str(), 32);   # 32 is ' '
    assert(rest != __null, "strchr found a space");
    var size = strlen(rest);
    var copy = String(rest, size, String.length_of(rest, size));
    assert(copy == " and C", "the copy holds the tail of the C string");
    return 0;
}

Passing a String value directly to an extern’s char* parameter is an error:

main.pep:4:19: error: cannot pass 'String' to parameter 1 of extern 'strlen' of type '__ptr<char>'; pass its 'c_str()'
    4 |     return strlen(name) as int;
      |                   ^~~~

The reverse is an error too: a char* (or any raw pointer, void* included) is never a String, so passing one where a String is expected, assigning it to a String variable, or returning it as one is rejected. Copy it into a String with String(p) first:

main.pep:3:19: error: cannot pass a value of type '__ptr<char>' as argument 2 of 'assert', which has type 'String'; use 'String(...)' to copy a C string

Variadic functions

A C function with a variable argument list, such as printf or snprintf, ends its prototype with ... after at least one named parameter:

extern int snprintf(char* buffer, ulong size, char* format, ...);

The call uses the C calling convention for variadic functions, which differs from a plain call on some targets (such as macOS on Apple silicon). Each argument past the named ones gets C’s default argument promotions: integers narrower than int widen to int, and float widens to double. Match your format string to the promoted type: %d for a u8, %f for a float, and %lld (not %ld) for a long, because C’s long is 32 bits on Windows.

... must come last and must follow a named parameter: extern int f(...); is a parse error.

# Calling a C function that takes a variable argument list.

extern void* malloc(ulong size);
extern void free(void* p);

# On Windows, snprintf lives in legacy_stdio_definitions.
[Target(OS(Windows))] [Link("legacy_stdio_definitions")]
extern int snprintf(char* buffer, ulong size, char* format, ...);
[Target(!OS(Windows))]
extern int snprintf(char* buffer, ulong size, char* format, ...);

main(): int {
    var buffer = malloc(64) as __ptr<char>;
    u8 small = 200;     # widened to int
    float half = 0.5;   # widened to double
    long big = 5000000000;
    var written = snprintf(buffer, 64, "%d %.1f %lld %s", small, half, big, "ok");
    var text = String(buffer, written as ulong, String.length_of(buffer, written as ulong));
    free(buffer);
    assert(text == "200 0.5 5000000000 ok", "snprintf formatted every argument");
    return 0;
}

On Windows, the C runtime defines snprintf and its relatives inline in its headers, so the linkable symbols live in a separate library, legacy_stdio_definitions. The example links it with [Link] on Windows only.

Pointers

C code deals in raw pointers, and Pepper has a raw pointer type for talking to it: __ptr<T>, such as __ptr<void>, __ptr<char> or __ptr<__ptr<int>>. A void* returned by an extern is a __ptr<void> in Pepper, and a __ptr<T> can be passed to an extern that takes a C pointer.

A pointer supports these operations:

OperationMeaning
p[i]reads or writes the element i places from p; its type is T, so var v = p[i] declares a T
p + nthe address n elements after p (n bytes for __ptr<void>)
p == __null, p != __nullcompares with the null pointer
p == q, p != qcompares two addresses (a pointer also compares with an integer address)
p as __ptr<void>, v as __ptr<T>the same address, to and from __ptr<void>
unsafe_cast<__ptr<U>>(p)the same address, seen as pointing at U
unsafe_cast<ulong>(p), unsafe_cast<__ptr<T>>(n)the address as an integer, and back

as converts a pointer only to and from __ptr<void>, like static_cast in C++. Any other pointer conversion reinterprets memory, so it is spelled unsafe_cast (see Reinterpreting with unsafe_cast).

__null is the null pointer. sizeof<T>() gives the size of a type in bytes, which you need to allocate memory for C. The compiler built-ins reference lists these and the other __ intrinsics.

T* spelling is for extern prototypes only, since they mirror C. Everywhere else (parameters, return types, struct fields, locals, globals, type aliases, generic arguments and casts) you write __ptr<T>, and T* is a compile error:

clear(p: void*, n: ulong) { }
main.pep:1:10: error: C pointer syntax `void*` is only allowed in extern declarations; use `__ptr<void>`
    1 | clear(p: void*, n: ulong) { }
      |          ^~~~
# Raw pointers: __ptr<T> in Pepper code, T* in extern prototypes.

extern void* malloc(ulong size);
extern void free(void* p);
extern void* memset(void* p, int value, ulong size);

struct Buffer {
    var data: __ptr<u8> = __null;
    var size: ulong = 0;
}

make_buffer(size: ulong): Buffer {
    var b = Buffer();
    b.data = malloc(size) as __ptr<u8>;
    b.size = size;
    memset(b.data, 0, size);
    return b;
}

sum(values: __ptr<int>, count: int): int {
    var total = 0;
    var i = 0;
    while (i < count) {
        total = total + values[i];
        i = i + 1;
    }
    return total;
}

# `var` takes the element type from the pointer: `big` is a ulong, so a value
# above 2^32 reads back whole.
first(values: __ptr<ulong>): ulong {
    var big = values[0];
    return big;
}

main(): int {
    # Indexing reads and writes elements of T.
    var numbers = malloc(4 * sizeof<int>()) as __ptr<int>;
    numbers[0] = 10;
    numbers[1] = 20;
    numbers[2] = 30;
    numbers[3] = 40;
    assert(sum(numbers, 4) == 100, "indexing reads what was written");

    var wide = malloc(sizeof<ulong>()) as __ptr<ulong>;
    wide[0] = 5000000000;
    assert(first(wide) == 5000000000, "var v = p[i] has the pointee's type");
    free(wide);

    # p + n moves by n elements, not n bytes.
    var third = numbers + 2;
    assert(third[0] == 30, "numbers + 2 points at the third element");
    assert(unsafe_cast<ulong>(third) - unsafe_cast<ulong>(numbers) == 8, "two ints are eight bytes");

    # __ptr<void> strides by bytes; cast to see the memory another way.
    var raw = numbers as __ptr<void>;
    var bytes = raw as __ptr<u8>;
    assert(bytes[4] == 20, "the second int starts at byte 4 (little-endian)");
    free(raw);

    # Pointers can live in struct fields, and compare with __null.
    var b = make_buffer(16);
    assert(b.data != __null, "malloc returned memory");
    assert(b.data[15] == 0, "memset cleared it");
    free(b.data);
    b.data = __null;

    # A pointer to pointers: indexing yields the inner pointer, and indexing
    # again reads or writes through it.
    var rows = malloc(2 * sizeof<__ptr<int>>()) as __ptr<__ptr<int>>;
    var row = malloc(sizeof<int>()) as __ptr<int>;
    rows[1] = row;
    rows[1][0] = 7;
    assert(rows[1][0] == 7, "rows[1][0] reads through the row pointer");
    assert(row[0] == 7, "rows[1] is the row pointer");
    free(row);
    free(rows);
    return 0;
}

Pointers are not reference-counted and nothing checks them: indexing past the end of an allocation, using memory after free, or forgetting to free it behaves exactly as it would in C. Keep raw pointers inside small functions or structs that own the memory, and give the rest of your program ordinary Pepper types.

Owning a C resource: Handle<T>

A C library hands out objects that it frees with a function of its own (fclose, free, LLVMDisposeModule). Pepper’s reference counting does not know about them, so the runtime’s Handle<T> ties one to Pepper’s lifetimes, like C#’s SafeHandle: it holds the raw value and a release function, and calls the function once, when the last reference to the handle goes away.

# A C resource owned by a Handle: `free` runs when the last reference to
# the handle goes away.

extern void* malloc(ulong size);
extern void free(void* p);
extern void* memset(void* p, int value, ulong size);

# A buffer from C, freed with the struct that holds it.
struct Buffer {
    var _memory: Handle<__ptr<void>>;
    var size: ulong { get; }

    construct(size: ulong) {
        _memory = Handle<__ptr<void>>(malloc(size), (p) => free(p));
        this.size = size;
    }

    fill(value: u8) {
        memset(_memory.value, value as int, size);
    }

    at(index: ulong): u8 {
        return (_memory.value as __ptr<u8>)[index];
    }
}

main(): int {
    var buffer = Buffer(16);
    buffer.fill(7);
    if (buffer.at(15) != 7) {
        return 1;
    }

    # release() frees early; the handle then no longer owns the memory.
    var scratch = Handle<__ptr<void>>(malloc(8), (p) => free(p));
    scratch.release();
    return scratch.is_owned() ? 2 : 0;
}
  • Handle<T>(value, release) takes ownership of value. T is usually __ptr<void> or an integer descriptor.
  • value reads the raw value, to pass to C.
  • A handle is a reference, so copies share the resource; it is released when the last copy goes away. Keep it in a field of the struct that wraps the resource, as Buffer does.
  • release() frees the resource early, and detach() gives it back without freeing it. Either way the release function never runs again, and is_owned() becomes false.
  • release is a function value, so a closure may capture what the release needs. A captured reference stays alive until the release runs, which is how a C object that must not outlive its parent (an LLVM module and its context) keeps the parent alive.

Passing a Pepper function to C

A Pepper function value is a reference-counted object, not a C function pointer. For C, Pepper has a separate type, extern (P...) => R: the address of a function that C calls with the C calling convention. Declare a callback parameter of a C function with it, and pass a named function, or a closure that captures nothing:

# `extern (P...) => R` is a C function pointer type: qsort calls `compare`
# with pointers to two elements.
extern void qsort(void* base, ulong count, ulong size, extern (void*, void*) => int compare);

ascending(a: __ptr<void>, b: __ptr<void>): int {
    __ptr<int> x = a;
    __ptr<int> y = b;
    return x[0] - y[0];
}

main(): int {
    __ptr<int> numbers = _pep_malloc(4 * sizeof<int>());
    numbers[0] = 30;
    numbers[1] = 10;
    numbers[2] = 40;
    numbers[3] = 20;

    # A named function converts to the C function pointer type.
    qsort(numbers, 4, sizeof<int>(), ascending);
    assert(numbers[0] == 10 && numbers[3] == 40, "ascending");

    # So does a closure that captures nothing; its parameter types come
    # from the C function pointer type.
    qsort(numbers, 4, sizeof<int>(), (a, b) => {
        __ptr<int> x = a;
        __ptr<int> y = b;
        return y[0] - x[0];
    });
    assert(numbers[0] == 40 && numbers[3] == 10, "descending");

    # A C function pointer is a value like a number: store it, call it.
    var compare: extern (__ptr<void>, __ptr<void>) => int = ascending;
    assert(compare(numbers, numbers + 1) > 0, "40 sorts after 30");

    _pep_free(numbers);
    return 0;
}
  • What converts. A named free function, an extern function, a static method (Order.descending), or a closure literal that captures nothing, whose parameter and return types match the C function pointer type exactly. A closure literal takes its parameter types from the type, as it does from any function type.

  • Nothing captured. C calls the function with its arguments alone, so a closure that captures a value, or a bound method (p.get), cannot convert:

    main.pep:4:43: error: closure passed as C function pointer type 'extern (__ptr<void>, __ptr<void>) => int' captures 'bias'; a C function pointer cannot hold captured values
        4 |     qsort(_pep_malloc(4), 0, 4, (a, b) => bias);
          |                                           ^~~~
    main.pep:3:9: note: pass what the callback needs through the C function's context argument instead
        3 |     var bias = 1;
          |         ^~~~
    

    This is a design decision, not a missing feature: a C function pointer is a bare address, and giving each capturing closure an address of its own would mean writing executable code at run time, which some platforms forbid. A callback that needs state gets it another way (see Callbacks that need state).

    A function value held in a variable does not convert either, since it may be a closure.

  • C types only. C knows nothing of reference counts, so the parameter and return types must be numbers, bool, pointers, or other C function pointer types. A struct is an error: C function pointer type 'extern (String) => int' cannot use 'String'; C passes only numbers, 'bool' and pointers. Write pointers as void* in an extern declaration and as __ptr<void> elsewhere, as for any pointer.

  • A value like a number. A C function pointer has no reference count. Store it in a variable, field or module-scope const, return it, pass it to other threads, or call it: compare(a, b) calls through it with the C calling convention. Calling a null one (from C) panics. It converts to void* but not to a Pepper function type; wrap it in a closure that calls it, (x) => f(x), to use it as one.

__function_pointer(name) also gives a function’s address, as an untyped __ptr<void>. It takes a free function, or a static method of the struct whose method uses it. Inside a generic struct the static method is the one for the current type arguments, which is how Thread.spawn gives the operating system its thread entry point. Prefer a C function pointer type where the signature is known, since the compiler then checks it.

Callbacks that need state

Since a C function pointer holds no captured values, a callback that needs state gets it another way:

  • The C function’s context argument. Most C APIs that take a callback also take a void* context argument that they hand back to it; pass a pointer to the data the callback needs through that.
  • A thread-local or module-scope value. Set a [ThreadLocal] variable, an async.ThreadLocal<T> or an Atomic<T> before calling C, and read it in the callback, which is a named function:
# A C function pointer holds no captured values, so a callback that needs
# state reads it from somewhere C does not have to carry: here, a
# thread-local the caller sets before handing the callback to C.
extern void qsort(void* base, ulong count, ulong size, extern (void*, void*) => int compare);

# The value to sort around, for the calling thread.
[ThreadLocal]
var pivot: int = 0;

distance(x: int): int {
    var d = x - pivot;
    return d < 0 ? -d : d;
}

closest_first(a: __ptr<void>, b: __ptr<void>): int {
    __ptr<int> x = a;
    __ptr<int> y = b;
    return distance(x[0]) - distance(y[0]);
}

main(): int {
    __ptr<int> numbers = _pep_malloc(4 * sizeof<int>());
    numbers[0] = 10;
    numbers[1] = 40;
    numbers[2] = 22;
    numbers[3] = 31;

    pivot = 30;
    qsort(numbers, 4, sizeof<int>(), closest_first);
    assert(numbers[0] == 31 && numbers[3] == 10, "closest to 30 first");

    _pep_free(numbers);
    return 0;
}

Binding to a different symbol name

[Link(name = "symbol")] binds an extern to a C symbol whose name differs from the declared one. Pepper code calls the extern by its declared name, and the object file refers to symbol. Use it to give a C function a clearer name, to avoid clashing with a Pepper name, or, together with [Target], to bind one Pepper name to a different symbol on each platform. lib names the library that defines the symbol in the same attribute, as [Link("library")] does on its own:

# [Link(name = "symbol")] binds a Pepper name to a C symbol with a different name.

# llabs takes a 64-bit long long on every platform: `i64` in an extern is
# 64 bits everywhere, where `long` follows the size of a pointer.
[Link(name = "llabs")]
extern i64 absolute(i64 value);

# C's long is 64 bits on Linux and macOS but 32 bits on Windows and
# WebAssembly, so the 64-bit labs is only right on the first two.
[Target(OS(Windows) || OS(Wasi))]
[Link(name = "llabs")]
extern i64 abs64(i64 value);
[Target(OS(Linux) || OS(Mac))]
[Link(name = "labs")]
extern long abs64(long value);

# `lib` names the library that defines the symbol, as [Link("m")] does.
[Target(OS(Linux))]
[Link(lib = "m", name = "cbrt")]
extern double cube_root(double value);

main(): int {
    assert(absolute(-7) == 7, "absolute calls llabs");
    assert(abs64(-5000000000) == 5000000000, "abs64 calls a 64-bit abs");
    [Target(OS(Linux))]
    {
        var root = cube_root(27.0);
        assert(root > 2.999 && root < 3.001, "cube_root calls cbrt from libm");
    }
    return 0;
}

Real uses of the per-platform form include macOS on Intel, where the 64-bit-inode version of stat is named stat$INODE64:

[Target(OS(Mac) && Arch(x64))]
[Link(name = "stat$INODE64")]
extern int stat(char* path, void* buf);
[Target(OS(Mac) && Arch(aarch64))]
extern int stat(char* path, void* buf);

[Link] takes the library as its one positional argument or as lib, and the symbol as name; each is a non-empty string, and an extern names at most one symbol. Anything else is an error, such as:

main.pep:1:7: error: 'Link' has no argument named 'symbol'; use 'lib', 'path' or 'name'
    1 | [Link(symbol = "labs")] extern long pep_abs(long v);
      |       ^~~~~~

The earlier [LinkName("symbol")] was removed; it is an error that suggests the [Link(name = "symbol")] that replaces it.

LLVM intrinsics

An extern whose [Link(name = ...)] symbol starts with llvm. is not a C function but an LLVM intrinsic: a call to it compiles to the operation itself, usually one instruction, and nothing is linked. The runtime’s bit and math methods (Bit operations) are written this way; declare one yourself for an intrinsic they do not cover, such as a CPU-specific one:

# An extern bound to an LLVM intrinsic: a call is the instruction itself.
[Link(name = "llvm.umin")]
extern ulong smaller(ulong a, ulong b);

# `llvm.ctlz` takes a second argument that must be a constant.
[Link(name = "llvm.ctlz")]
extern ushort leading_zeros(ushort value, bool is_zero_poison);

main(): int {
    assert(smaller(7, 3) == 3, "llvm.umin.i64");
    assert(leading_zeros(1, false) == 15, "llvm.ctlz.i16");
    return 0;
}
  • The extern’s prototype is the intrinsic’s signature, in the types Pepper maps to LLVM’s: byte and char are i8, ushort and short i16, uint and int i32, ulong and long i64, bool i1, float and double themselves, any pointer ptr, and a void return is void. Intrinsics that take or return vectors or structs cannot be declared yet.

  • Name an overloaded intrinsic by its base name (llvm.ctpop): the overload is picked from the prototype (llvm.ctpop.i32 for a uint). The mangled name works too.

  • A prototype that matches no overload is an error at the extern, with LLVM’s reason:

    main.pep:2:13: error: 'popcount' does not match the signature of LLVM intrinsic 'llvm.ctpop': intrinsic has incorrect argument type
        2 | extern long popcount(int value);
          |             ^~~~~~~~
    
  • Some arguments must be constants, such as llvm.ctlz’s is_zero_poison; passing a variable is an error at the argument:

    main.pep:5:19: error: argument 2 to 'leading_zeros' must be a constant
        5 |     return leading_zeros(1, zero_poison);
          |                             ^~~~~~~~~~~
    

An unknown name (llvm.nonsense) is an error too. A target-specific intrinsic (llvm.x86.*, llvm.aarch64.*) only exists on its architecture, so pair it with [Target(Arch(...))].

Linking the library

A C function is only callable if the library that defines it is linked into your program. The C library is always linked. For anything else, attach [Link("name")] to the extern or pass -l name to the compiler. The Linking chapter covers both.

[Target(OS(Windows))]
[Link("gdi32")]
extern ulong GetStockObject(int kind);

Calling Pepper from C

C code can call Pepper functions: mark each one [Export], and build the program as a library with --lib static or --lib shared. The compiler writes a C header beside the library that declares every exported function.

# A library C code calls: build it with `pepper build counter.pep --lib
# static` (or `--lib shared`), which also writes `counter.h`.
#
# `main` is not needed in a library; this one calls the exported functions
# from Pepper, so the example runs as a program too.

[Export]
add(a: int, b: int): int {
    return a + b;
}

# A struct crosses to C as an opaque handle, `Counter*`.
struct Counter {
    var count: int;

    construct(this.count = 0) {}
}

# The C name can differ from the Pepper name.
[Export(name = "counter_new")]
make_counter(start: int): Counter {
    return Counter(start);
}

[Export]
counter_increment(counter: Counter): int {
    counter.count += 1;
    return counter.count;
}

# A C function pointer, called back.
[Export]
apply(f: extern (int) => int, x: int): int {
    return f(x);
}

# A String arrives as UTF-8 bytes and their size, and leaves as a
# NUL-terminated copy that C frees with `pepper_string_free`.
[Export]
greet(name: String): String {
    return "hello, " + name;
}

main(): int {
    var counter = make_counter(41);
    assert(counter_increment(counter) == 42, "the counter counts");
    assert(add(2, 3) == 5, "add adds");
    assert(greet("C") == "hello, C", "greet greets");
    return 0;
}
pepper build counter.pep --lib static     # libcounter.a and counter.h
pepper build counter.pep --lib shared     # libcounter.so and counter.h

The header declares the functions with C types:

typedef struct Counter Counter;

void counter_retain(Counter* handle);
void counter_release(Counter* handle);

void pepper_string_free(char* text);

int32_t add(int32_t a, int32_t b);
int32_t apply(int32_t (*f)(int32_t), int32_t x);
int32_t counter_increment(Counter* counter);
Counter* counter_new(int32_t start);
char* greet(const char* name, size_t name_size);

and a C program calls them like any C function:

#include <stdio.h>
#include "counter.h"

static int32_t twice(int32_t x) { return 2 * x; }

int main(void) {
    Counter* counter = counter_new(41);
    printf("%d\n", counter_increment(counter));   /* 42 */
    counter_release(counter);
    printf("%d\n", apply(twice, add(2, 3)));      /* 10 */
    char* greeting = greet("C", 1);
    printf("%s\n", greeting);                       /* hello, C */
    pepper_string_free(greeting);
    return 0;
}
cc main.c -I. -L. -lcounter -lm -o main    # Linux; macOS needs no -lm

Exporting a function

  • [Export] goes on a free function that is not generic. Its C name is its Pepper name; [Export(name = "c_name")] gives it another, which must be a C identifier. Two exports with one C name are an error.
  • [Export] anywhere else (a struct, a method, an extern, a variable, a generic function) is a warning, and does nothing.
  • An exported function is still a Pepper function: Pepper code calls it by its Pepper name.
  • Do not confuse [Export] with the export keyword, which makes a declaration visible to other Pepper files (see Visibility). A function can be either, both or neither.

What crosses to C

Each parameter and the result must be a type C can hold:

PepperC (in the header)
boolbool
char, i8, u8, short, ushort, int, uint, long, ulong (and their i16… spellings)char, int8_t, uint8_t, int16_t, uint16_t, int32_t, uint32_t, int64_t, uint64_t
float, doublefloat, double
__ptr<T>T* for a number type, else void*
extern (P...) => Ra C function pointer, R (*name)(P...)
Stringa parameter: const char* name, size_t name_size; a result: char* (see Strings)
a structan opaque handle, Name*

Anything else is an error at the parameter or result that C cannot hold:

main.pep:2:15: error: cannot export 'total': C cannot hold 'Array<int>'
    2 | total(values: Array<int>): int {
      |               ^~~~~~~~~~
note: only a non-generic struct passes to C, as a handle

A generic struct (Array<int>), a value struct, an enum, a trait, a tuple, Optional and a Pepper function type cannot cross. Pass an array as a pointer to its elements and a count, or wrap it in a struct of your own and pass a handle.

Strings

A String crosses as UTF-8 bytes, copied each way, so neither side keeps a pointer into the other’s memory:

  • A String parameter is two C parameters: a pointer to the bytes and their size, const char* name, size_t name_size. The bytes need no NUL terminator, and may contain NULs. The function gets its own copy for the call; C keeps its buffer. A size of 0 (with any pointer, NULL included) is the empty string.
  • A String result is a NUL-terminated copy of its bytes, as char*, which the C code owns and frees with pepper_string_free, never with free. An empty String is "", never NULL. A string with NULs inside ends at its first one for C; return a pointer and a size instead when that matters.
  • The header declares pepper_string_free when some export returns a String.

Handles

A struct reaches C as a pointer to an incomplete C type (Counter*), which C code passes back but never looks inside. Handles follow Pepper’s reference counting:

  • A handle a function returns is one reference, which the C code owns. It releases it with <name>_release (counter_release) when it is done; the last release frees the object, running its destructor.
  • A handle C passes in stays C’s: the function borrows it for the call.
  • <name>_retain adds a reference, for C code that keeps a handle in two places; each retain needs a release.
  • The names are the struct’s name in snake case: ByteBuffer gets byte_buffer_retain and byte_buffer_release. The header declares them for every struct an export passes.

A handle must not be null, and is freed at most once: C gets no checks on either.

Why a string is freed but a handle is released

The two look alike from C, a pointer Pepper hands over, but they are different things, and the names say which:

  • A handle is a live Pepper object. Pepper may hold references to it too: a field, a global, another thread. C holds one or more of those references, so it uses the same counting Pepper does: <name>_retain adds one, <name>_release drops one, and the object is destroyed only when the last reference anywhere goes away. Releasing is not freeing.
  • A returned string is not a Pepper object. It is a plain copy of the bytes, made at the boundary like strdup makes one, with no count and no other owner. Pepper keeps no reference to it, so there is nothing to retain or share; the C code owns it outright and gives it back once with pepper_string_free.
  • Why not free: the copy comes from Pepper’s allocator, which tracks live allocations, serves short strings from its own small block pools (see Where objects live) and, on Windows, may use a different C runtime’s heap than the caller’s. pepper_string_free returns it to the allocator that made it.

A String parameter needs neither: Pepper copies the bytes for the call, and C’s buffer stays C’s.

Building the library

--lib static writes an archive of the program’s objects: libname.a (name.lib on Windows). --lib shared links a shared library: libname.so, libname.dylib on macOS, or name.dll on Windows, with its import library name.lib. -o chooses another name. Either way the header is written beside the library, named for it without the lib prefix (counter.h).

The compiler writes the archive itself, in the format of the platform it builds for (with --target, another one’s: GNU’s for Linux and Android, the BSD format Apple’s linkers read for macOS and iOS, the COFF archive for Windows), with the index of the symbols its objects define, so no archiver needs to be installed and a static library for another platform needs no system root. A shared library links like an executable, with clang. With --lto=full the library is optimized as one module first, as an executable is (see --lto in pep-build).

  • A library needs no main. One that has a main keeps it, and C code that links the library cannot have its own.
  • Only the exported functions and their handle functions are visible outside a shared library; everything else in it is hidden.
  • A program that links the static library must also link the C libraries Pepper’s runtime uses. The header lists them in a comment at the top, such as /* Link the static library with: -lm */ on Linux. A shared library links them itself.
  • On Windows, --lib static and --lib shared both write name.lib (the archive, or the DLL’s import library), so build them into different folders.
  • A C program links at most one Pepper static library: each holds its own copy of the runtime, and two would define it twice. Shared libraries keep their runtimes to themselves, so a program may load several.

Initialization, panics and threads

  • Module-scope variables are initialized when the library is loaded, before C calls any exported function (a static library’s initializer runs with the program’s other static constructors).
  • A panic in Pepper code called from C prints its message and aborts the process, as it does in a Pepper program: it never unwinds into C.
  • Exported functions may be called from any thread, including threads C created. The thread-safety rules of threads still hold: a handle to a struct that is not [Shared] must stay on one thread at a time, and C does not check that.

Platform selection

Some code only makes sense on one platform: a Windows API call, a Linux-specific constant, a type whose size differs between operating systems. The [Target(...)] attribute keeps a declaration or a block only when you compile for a matching platform, so one source file can hold every platform’s version side by side.

The target is the platform you compile on, unless you name another with --target (see Cross-compiling below).

Writing a condition

Target takes one boolean condition built from these predicates:

  • OS(...) is true on the named operating system: Windows, Mac (or MacOS), Linux, Wasi (WebAssembly, see below), IOS or Android (see iOS and Android). As in C, where TARGET_OS_MAC holds on every Apple OS and __linux__ on Android, OS(Mac) is also true on iOS and OS(Linux) on Android: they share the kernel and most of the C library, so code for macOS or Linux usually works there unchanged. Write OS(Mac) && !OS(IOS) for macOS alone, and OS(Linux) && !OS(Android) for desktop and server Linux alone.
  • Arch(...) is true on the named processor architecture: x64, aarch64, or wasm32.
  • BuildOption(NAME) is true when the build defines NAME with -D, and can be compared with a literal (BuildOption(LOG_LEVEL) >= 2); see Build options.

Values are matched case-insensitively, so OS(windows) works too. Combine predicates with && (or and), || (or or), !, and parentheses:

[Target(OS(Windows))]                     # Windows
[Target(!OS(Windows))]                    # Linux and macOS
[Target(OS(Linux) && Arch(x64))]          # Linux on x64 only
[Target(OS(Windows) || OS(Mac))]          # Windows and macOS
[Target(!(OS(Windows) || OS(Mac)))]       # Linux

An item may carry several attributes. They are combined with “and”: the item is kept only when every Target on it matches. Other attributes on the same item, such as [Link(...)], do not affect the selection.

What it applies to

Put [Target(...)] before any top-level declaration: a function, an extern, a struct, an enum, a trait, an extension, a type alias, a module-scope variable, an import, or a re-export. It also works on the methods inside a struct, trait or extension, and on an enum’s cases. When two declarations share a name, give them conditions that never match on the same platform, so exactly one survives:

# One name, a different declaration per platform.

# Type aliases: a Windows HANDLE is a pointer, a POSIX descriptor an int.
[Target(OS(Windows))] type Handle = __ptr<void>;
[Target(!OS(Windows))] type Handle = int;

# Module-scope variables (const and thread-safe, like every global).
[Target(OS(Windows))] const max_path = 260;
[Target(!OS(Windows))] const max_path = 4096;

# Functions.
[Target(OS(Windows))]
platform_name(): String { return "Windows"; }

[Target(OS(Mac))]
platform_name(): String { return "macOS"; }

[Target(OS(Linux))]
platform_name(): String { return "Linux"; }

# Structs, and methods inside them.
[Target(Arch(x64))]
struct Cpu {
    var name: String = "x64";
}

[Target(Arch(aarch64))]
struct Cpu {
    var name: String = "aarch64";
}

struct Word {
    [Target(Arch(x64) || Arch(aarch64))]
    bits(): int { return 64; }
}

main(): int {
    var name = platform_name();
    print(name);
    print(Cpu().name);
    assert(Word().bits() == 64, "every supported target is 64-bit");

    [Target(OS(Windows))] {
        assert(sizeof<Handle>() == 8, "a HANDLE is a pointer");
        assert(name == "Windows", "the Windows function was kept");
    }
    [Target(!OS(Windows))] {
        assert(sizeof<Handle>() == 4, "a descriptor is an int");
        assert(max_path == 4096, "the POSIX variable was kept");
    }
    return 0;
}

The attribute goes before export, not after it: [Target(OS(Windows))] export f(). Writing export [Target(OS(Windows))] f() is a parse error.

Blocks inside a function

Inside a function body, attach [Target(...)] to a block { ... }. The block is kept or removed like a declaration. Blocks may nest, and may appear inside if and loop bodies.

This is the usual way to wrap C functions that differ per platform: declare each platform’s extern with its own condition, and call each one from a block with the same condition.

# A portable wrapper over a C function that has a different name on
# each platform. kernel32 is linked by default on Windows, and getpid is
# in the C library everywhere else.

[Target(OS(Windows))] extern uint GetCurrentProcessId();
[Target(!OS(Windows))] extern int getpid();

process_id(): long {
    [Target(OS(Windows))] {
        return GetCurrentProcessId() as long;
    }
    [Target(!OS(Windows))] {
        return getpid() as long;
    }
}

main(): int {
    var id = process_id();
    print($"process {id}");
    assert(id > 0, "a running process has a positive id");
    return 0;
}

What happens to code that does not match

Code whose condition does not match is removed before type checking. That is what makes the examples above work: a call to GetCurrentProcessId, which exists only on Windows, is fine in a block that is removed everywhere else. Removed code is not checked for errors beyond parsing, so a typo in it goes unnoticed until you build on the platform that keeps it. The VS Code extension shows it dimmed, from its attribute to its end, with a hint saying it is not compiled for the platform you are building on. A file that is mostly code for another OS (a Linux-only file opened on Windows) is still checked as that OS’s build would check it, so hover and go-to-definition work in it. In such a file, an error only your own platform’s build would report (portable code calling a function only the other OS has) appears when you build, not in the editor.

It still has to parse, and every Target condition is validated on every platform, even one inside removed code. A misspelled value is always an error, never a silent “no match”:

[Target(OS(Darwin))]
f(): int { return 1; }
main.pep:1:1: error: unknown OS value `Darwin` (expected Windows, Mac, MacOS, Linux, Wasi, IOS, Android) in [Target(OS(Darwin))]
    1 | [Target(OS(Darwin))]
      | ^~~~~~~~~~~~~~~~~~~~

Other mistakes the compiler reports:

  • Target takes exactly one condition: [Target(OS(Windows), Arch(x64))] is an error; write [Target(OS(Windows) && Arch(x64))].
  • Only OS(...), Arch(...) and BuildOption(...) may appear inside it. A bare name ([Target(Windows)]), another call ([Target(Foo(x))]), not in place of !, or a nested Target is an error.
  • OS(...) and Arch(...) are only valid inside Target(...), and the brackets hold one attribute: [OS(Windows)] and [Target(OS(Windows)) && Export] are parse errors.
  • Struct fields cannot carry [Target(...)]. Select the whole struct instead, as Cpu does in the first example.

Cross-compiling

--target <triple> compiles for another platform: [Target(...)] selects that platform’s code, and the compiler emits that platform’s machine code and object format. The supported targets are each OS on each architecture:

TripleOSArch
x86_64-unknown-linux-gnuLinuxx64
aarch64-unknown-linux-gnuLinuxaarch64
x86_64-apple-darwinmacOSx64
aarch64-apple-darwinmacOSaarch64
x86_64-pc-windows-msvcWindowsx64
aarch64-pc-windows-msvcWindowsaarch64
wasm32-wasip1Wasiwasm32
aarch64-apple-iosiOSaarch64
aarch64-apple-ios-simulatoriOS (simulator)aarch64
x86_64-apple-ios-simulatoriOS (simulator)x64
aarch64-linux-androidAndroidaarch64
x86_64-linux-androidAndroidx64

clang’s other spellings work too: arm64 for aarch64, amd64 for x86_64, any vendor, and an OS version (arm64-apple-macosx14.0, aarch64-apple-ios17.0, aarch64-linux-android33). Any other triple is an error that lists these. wasm32-wasi, wasm32-unknown-wasi and wasm32-unknown-wasip1 all mean wasm32-wasip1. The iOS and Android targets, their versions and their SDKs are described in iOS and Android.

pepper build --target aarch64-unknown-linux-gnu main.pep -o main

Linking an executable for another platform needs that platform’s system libraries and LLVM’s linker, lld: the compiler looks for it beside clang, in its LLVM install and on the PATH, also under the versioned name Ubuntu’s lld-20 package uses (ld.lld-20). Name the directory that holds the libraries (the sysroot) with --sysroot <dir>, or set PEPPER_SYSROOT_<TRIPLE>, the triple in capitals with _ for each - (PEPPER_SYSROOT_AARCH64_APPLE_DARWIN):

  • Linux: a directory with the target’s glibc and GCC runtime (/usr/aarch64-linux-gnu from Debian’s or Ubuntu’s libc6-dev-arm64-cross and libgcc-13-dev-arm64-cross, which clang also finds without --sysroot). From another OS, copy a Linux machine’s at the same paths: the C startup files and libc, libm, libgcc_s and the dynamic loader from /usr/lib/x86_64-linux-gnu, and crtbegin*.o, crtend*.o and libgcc* from /usr/lib/gcc/x86_64-linux-gnu/<version>.

  • macOS: a macOS SDK (MacOSX.sdk, xcrun --show-sdk-path on a Mac). Linking reads only its SDKSettings.json and the library stubs in usr/lib.

  • Windows: a directory laid out like a Visual Studio install, with the MSVC libraries and the Windows SDK, as xwin writes it:

    xwin --accept-license --arch x86_64 splat --use-winsysroot-style --preserve-ms-arch-notation --output ~/winsysroot
    

CI links each of these from the other two (from Linux x64, macOS arm64 and Windows x64, for x86_64-unknown-linux-gnu, aarch64-apple-darwin and x86_64-pc-windows-msvc) and runs the programs on their own OS; its scripts, scripts/ci-export-sysroot.sh and scripts/ci-provision-cross.sh, show the setup end to end.

When the link fails, the error ends with a note naming what is missing:

note: linking for 'x86_64-pc-windows-msvc' needs that platform's system libraries and LLVM's linker (lld): pass '--sysroot <dir>' or set PEPPER_SYSROOT_X86_64_PC_WINDOWS_MSVC

The -m options are read for the target: -mcpu is an AArch64 option and -march=x86-64-v3 an x86 one, and native, which names the CPU of the machine you compile on, is an error for a target of another architecture.

The compiler only builds for the target; copy the executable to a machine of that platform to run it.

WebAssembly

--target wasm32-wasip1 compiles to a WebAssembly module for WASI preview 1, which runtimes such as wasmtime run from the command line:

pep-build --target wasm32-wasip1 --sysroot /opt/wasi-sdk main.pep -o main.wasm
wasmtime run --dir . main.wasm

Linking needs a wasi-sdk (its sysroot holds wasi-libc and clang’s builtins) and LLVM’s wasm-ld, which the compiler finds as it finds lld (also as wasm-ld-20). Name the SDK directory, or its share/wasi-sysroot, with --sysroot, or set PEPPER_SYSROOT_WASM32_WASIP1 or WASI_SDK_PATH. wasi-sdk 25 is the version tested. The program needs a runtime’s --dir to reach a directory and -S inherit-env (wasmtime) to see the environment. Directory.temp() is TMPDIR, else /tmp, so grant one (--dir /tmp) to use it.

WASI preview 1 is a small system, and wasm32 is a 32-bit target, so some things work differently:

  • OS(Wasi) code selects the WASI versions of the runtime’s platform layer. Code written for !OS(Windows) also compiles for WASI, so check that it only calls what wasi-libc has.
  • There are no threads and no stack switching: Thread.spawn panics, and so does starting tasks (async.run, async.run_with). Mutexes, channels and atomics still work, as long as nothing waits for another thread.
  • Sockets, TLS, HTTP connections and starting processes return an IoError saying they are not supported on WASI.
  • Files work through the directories the runtime grants. Metadata has no permission bits (read_only is false) and no creation time, and set_permissions and set_read_only return an error; set_executable and the mode of OpenOptions and Directory.create do nothing. Timestamps and symlinks work.
  • os reports OperatingSystem.Wasi, "wasm32", an empty host name and version, and no total memory.
  • Pointers and C’s long and size_t are 32 bits; see 32-bit targets.

iOS and Android

Pepper compiles natively for iOS and Android through the same LLVM backend it uses everywhere else: --target picks the platform, and the result is ordinary machine code for an iPhone, an iPad, the iOS simulator, or an Android phone. An app calls the Pepper code through C, as any C library is called: mark the functions [Export], build a library with --lib static (see Calling Pepper from C), and call it from Swift directly, or from Kotlin and Java through JNI.

Targets

TripleRuns on
aarch64-apple-iosiPhone and iPad
aarch64-apple-ios-simulatorthe iOS simulator on an Apple silicon Mac
x86_64-apple-ios-simulatorthe iOS simulator on an Intel Mac
aarch64-linux-androidAndroid phones and tablets (arm64-v8a)
x86_64-linux-androidthe Android emulator on x64 (x86_64)
  • Versions. A triple can name the oldest OS version the code runs on: aarch64-apple-ios17.0, aarch64-apple-ios17.0-simulator, aarch64-linux-android33. Without one, iOS code runs on iOS 15 and later and Android code on API level 30 (Android 11) and later. Pepper’s runtime needs API level 30, so an older one is an error:

    error: unsupported target 'aarch64-linux-android24': Pepper's runtime needs Android API level 30 or later (write 'android30')
    
  • Spellings. arm64 works for aarch64, as do Rust’s -sim for -simulator and any vendor (aarch64-unknown-linux-android). x86_64-apple-ios means the simulator, since no iPhone is x64.

  • CPU. Code for an iPhone runs on every arm64 iPhone (clang’s apple-a7); add -mcpu=apple-a14 to use what newer ones have. Android x64 code uses SSE4.2 and POPCNT, which the Android x86_64 ABI requires.

Building

A library for an app needs no SDK, only the compiler:

pepper build --target aarch64-apple-ios --lib static greeter.pep -o ios/libgreeter.a
pepper build --target aarch64-linux-android --lib static greeter.pep -o android/libgreeter.a

The compiler writes each archive itself, in the format the platform’s linkers read (BSD’s for iOS, GNU’s for Android), so nothing from Xcode or the NDK is needed to make it. Each writes greeter.h beside the library. Build the library once for each target the app ships: the device and the simulator on iOS, and each Android ABI.

Linking, for a shared library or an executable to run in the simulator or an emulator, needs the platform’s SDK:

  • iOS links with LLVM’s ld64.lld against an iOS SDK. On a Mac with Xcode the compiler asks xcrun for it (iphoneos, or iphonesimulator for the simulator). Elsewhere name it with --sysroot, or set PEPPER_SYSROOT_AARCH64_APPLE_IOS (and PEPPER_SYSROOT_AARCH64_APPLE_IOS_SIMULATOR, and so on): the variable is named for the triple without its version.
  • Android links with the NDK’s own clang, which brings the NDK’s linker, compiler-rt and unwinder. Name the NDK with --sysroot <ndk> or PEPPER_SYSROOT_AARCH64_LINUX_ANDROID, or set ANDROID_NDK_HOME or ANDROID_NDK_ROOT; otherwise the newest NDK in the Android SDK ($ANDROID_HOME/ndk/<version>, as Android Studio installs it) is used. NDK r27 is the version tested.

When the SDK is missing, the link error says where to point the compiler:

note: linking for 'aarch64-linux-android30' needs the Android NDK (https://developer.android.com/ndk/downloads): pass '--sysroot <ndk>' or set PEPPER_SYSROOT_AARCH64_LINUX_ANDROID, ANDROID_NDK_HOME or ANDROID_HOME

Selecting code for a platform

[Target(OS(IOS))] and [Target(OS(Android))] select code for one of them. Most code needs neither: iOS is Darwin, like macOS, and Android runs the Linux kernel, so OS(Mac) is true on iOS and OS(Linux) on Android, as TARGET_OS_MAC and __linux__ are in C (see Platform selection). The runtime’s macOS and Linux code serves them, with a few declarations narrowed where they differ. At run time, os.system_os() returns OperatingSystem.IOS or OperatingSystem.Android.

Calling Pepper from Swift

Swift calls C directly. Give swiftc the generated header as the bridging header, and link the library:

xcrun --sdk iphonesimulator swiftc -target arm64-apple-ios15.0-simulator -parse-as-library \
  -import-objc-header greeter.h App.swift -L . -lgreeter -o App.app/App

In an Xcode project, add the library to the target’s Link Binary With Libraries phase and #include "greeter.h" in the target’s bridging header. A String crosses as a pointer and a size, and a returned string is freed with pepper_string_free:

func pepperGreeting(_ name: String) -> String {
    let result = name.withCString { greet($0, strlen($0)) }!
    defer { pepper_string_free(result) }
    return String(cString: result)
}

Calling Pepper from Kotlin or Java

Kotlin and Java reach C through JNI: a shared library holds the Pepper code and a C function for each native method, named for its class, which converts Java’s types to the C ones the header declares. Link both with the NDK’s clang into the .so the app loads:

pepper build --target aarch64-linux-android --lib static greeter.pep -o pepper/libgreeter.a
$NDK/toolchains/llvm/prebuilt/*/bin/clang --target=aarch64-linux-android30 -shared -fPIC \
  -I pepper jni.c pepper/libgreeter.a -lm -o jniLibs/arm64-v8a/libgreeter.so
JNIEXPORT jstring JNICALL
Java_dev_pepper_demo_MainActivity_greet(JNIEnv* env, jclass cls, jstring name) {
    const char* bytes = (*env)->GetStringUTFChars(env, name, NULL);
    char* result = greet(bytes, strlen(bytes));
    (*env)->ReleaseStringUTFChars(env, name, bytes);
    jstring greeting = (*env)->NewStringUTF(env, result);
    pepper_string_free(result);
    return greeting;
}

The Kotlin side declares the function external and loads the library once:

class MainActivity : Activity() {
    companion object {
        init { System.loadLibrary("greeter") }
        @JvmStatic external fun greet(name: String): String
    }
}

With Gradle, put each ABI’s .so in src/main/jniLibs/<abi>/ (arm64-v8a, x86_64), or build it from the module’s CMakeLists.txt with the static library as an imported target.

Threads

Exported functions may be called from any thread (see Initialization, panics and threads), so the app decides where Pepper code runs. Keep long work off the main thread (iOS’s main queue, Android’s UI thread): call it from a background queue, a coroutine on Dispatchers.Default, or a Pepper thread or task the library starts itself. Pepper code never calls back into UIKit or Android’s views on its own; hand results back to the main thread on the app’s side.

What works differently

The runtime runs the documentation’s examples on both platforms, with these differences:

  • Starting processes. An iOS app cannot start other programs, and the process module’s Command.spawn returns an IoError there. On Android it works, but an app has few programs to start, and below API level 34 Command.working_directory makes spawn return an IoError (bionic lacks posix_spawn_file_actions_addchdir_np there).
  • TLS. The tls module (and HTTPS) loads OpenSSL at run time, which neither platform ships, so making a TLS context returns an IoError unless the app bundles libssl. Use the platform’s own networking (URLSession, OkHttp) for HTTPS for now.
  • Files. An app reads and writes inside its own container: pass the path the platform gives (FileManager’s documents directory, Context.getFilesDir()) to the Pepper code. os.home_directory() and the temporary directory point into the container on iOS. Android has no /tmp: without TMPDIR, io.Directory.temp() is /data/local/tmp, which an app cannot write, so an app passes its cache directory. A Unix socket’s path is at most 104 bytes there, and the container’s own path takes most of them, so give a socket there a short name. On Android, an app creates Unix socket files in its own data folder (Context.getFilesDir()); SELinux refuses them elsewhere, and from an adb shell program everywhere, with a permission error.
  • Backtraces. Android’s backtraces come from the NDK’s unwinder (bionic’s backtrace needs API level 33), and both platforms name functions from the binary’s own symbols, so strip a release build only after keeping a copy for symbolication.
  • Standard output goes to the Xcode console and the simulator’s --stdout file on iOS. On Android it goes nowhere in an app: log with the platform’s logger instead (__android_log_write through an extern).

Shipping

  • Pepper compiles ahead of time, so iOS’s ban on JIT compilation does not apply. Bitcode is no longer required by the App Store.
  • A static library is linked into the app’s own binary, so its code is covered by the app’s signature; strip the release build’s symbols as usual (Xcode’s Strip Linked Product, the Android Gradle plugin’s strip of jniLibs). A Pepper library adds the runtime parts the program uses: the prototype app’s library below is about 1 MB unstripped.
  • Each Android ABI needs its own .so; Google Play requires arm64-v8a.

Debugging

-g writes DWARF, which LLDB reads in both Xcode and Android Studio. Build the library with -g and the app in its debug configuration, and set breakpoints in the .pep files by path.

The prototype apps

docs/examples/mobile/ holds a small app for each platform that calls the same Pepper library, greeter.pep:

  • ios/: a SwiftUI app (PepperDemo.swift), and build.sh, which builds it with swiftc for the simulator and for devices, installs it in a booted simulator, launches it and checks its output.
  • android/: an activity in Java with the JNI functions in jni.c, and build.sh, which builds the APK with the Android SDK’s own tools (javac, d8, aapt2, apksigner; Gradle is not needed), installs it in the emulator, starts it and checks its log.

scripts/mobile-test.sh runs both on a Mac with Xcode and the Android SDK: it builds every documentation example for the iOS simulator and for Android, runs each in the simulator (xcrun simctl spawn) and in the emulator (adb shell), then builds and runs the two apps.

bash scripts/mobile-test.sh target/bin/pep-build /tmp/mobile all

Not supported yet

  • TLS through the platform: Security.framework on iOS, and on Android the system’s BoringSSL through Conscrypt or a bundled OpenSSL.
  • Generated bindings: the JNI layer and the Swift wrappers are written by hand; a generator could write them from the header.
  • Packaging: an .xcframework holding the device and simulator libraries, a Swift package, and an Android library (.aar).
  • Other targets: 32-bit Android (armeabi-v7a, x86), Mac Catalyst, tvOS, watchOS and visionOS.

Build options

Some code should be optional at build time: linking a library such as libcrypto or zlib when it is there, tracing that only a debug build wants, a backend chosen when the program is built. Build options set that on the command line with -D, as in C compilers, and the code tests them with BuildOption.

pep-build main.pep -D USE_LIBCRYPTO -D LOG_LEVEL=2 -D BACKEND=zlib -o app
  • -D NAME defines an option. It counts as NAME=1, as in C.
  • -D NAME=VALUE defines it with a value, which may be any text.
  • Repeat -D for more options. When two name the same option, the later one wins.

pep-build --test defines TEST, which keeps the [Test] functions and can mark code only tests use (see Unit tests).

A name is a letter or _, then letters, digits and _. Any other name is an error:

error: invalid build option name '2FAST' in '-D 2FAST'; a name is a letter or '_' followed by letters, digits and '_'
# Build options: compile with `pep-build main.pep -D FAST_MATH -D LOG_LEVEL=2`
# to pick the other versions.

# `[BuildOption("NAME")]` keeps a declaration when `-D NAME` is given, and
# `!BuildOption(NAME)` inside a `Target` keeps its fallback otherwise.
[BuildOption("FAST_MATH")]
scale(x: int): int {
    return x << 1;
}

[Target(!BuildOption(FAST_MATH))]
scale(x: int): int {
    return x * 2;
}

# A `static` block reads an option's value while compiling, with a default
# for a build that does not set it.
log_level(): int {
    var level = 1;
    static (level) {
        if (reflect.build_option("LOG_LEVEL", "1") == "2") {
            level = 2;
        }
    }
    return level;
}

main(): int {
    if (scale(21) != 42) {
        return 1;
    }
    var level = log_level();
    if (level < 1 || level > 2) {
        return 2;
    }
    return 0;
}

Selecting code

[BuildOption("NAME")] keeps a declaration or a block only when the build defines NAME. It is shorthand for [Target(BuildOption(NAME))], and it goes everywhere [Target(...)] goes: functions, externs, structs, methods, module-scope variables, imports, re-exports and { } blocks in a function (see Platform selection).

Inside Target, BuildOption(NAME) combines with OS(...), Arch(...), !, &&, || and parentheses. Used alone, it is true when NAME is defined, whatever its value, and false when it is not, with no warning:

[BuildOption("USE_LIBCRYPTO")]
[Link("crypto")]
extern int SHA256_Init(__ptr<void> context);

[Target(!BuildOption(USE_LIBCRYPTO))]
_sha256_compress(...) { ... }   # the portable fallback

[Target(BuildOption(TRACE) && OS(Linux))]
import "linux_trace";

Several attributes on one item combine with “and”, as [Target] does.

Options the runtime reads

OptionEffect
USE_LIBCRYPTOdigest’s SHA-256 and SHA-1 use libcrypto (CNG on Windows); see Cryptographic hashes
USE_ZLIBcompress uses zlib; see Compression
TESTdefined by pep-build --test; see Unit tests

Comparing values

BuildOption(NAME) may be compared with a literal using ==, !=, <, <=, > and >=, on either side. The literal’s type says how the value is read:

LiteralThe value is read asExample
integera signed 64-bit integer, decimal or 0x hexBuildOption(LOG_LEVEL) >= 2
floata doubleBuildOption(SCALE) > 1.5
true / falsetrue/false, 1/0, on/off or yes/no, in any case (== and != only)BuildOption(TRACE) == true
stringtext, compared byte for byteBuildOption(BACKEND) == "zlib"
[Target(BuildOption(LOG_LEVEL) >= 2 && OS(Linux))]
_trace(message: String) { ... }

[Target(BuildOption(BACKEND) == "zlib")]
import "zlib_backend";

A value the literal’s type cannot read is an error at the comparison:

main.pep:1:28: error: build option 'LEVEL' is 'high', which is not an integer
    1 | [Target(BuildOption(LEVEL) >= 2)]
      |                            ^~~~

Comparing two build options, or a build option with anything but a literal, is an error too.

A comparison with an option the build does not define is false, whatever the operator, and the compiler warns at it, since a missing option there is usually a mistake:

main.pep:3:13: warning: build option 'LOG_LEVEL' is not defined; the comparison is false
    3 |     [Target(BuildOption(LOG_LEVEL) >= 2)] {
      |             ^~~~~~~~~~~~~~~~~~~~~~
note: define it with '-D LOG_LEVEL=<value>'

Reading values in static blocks

A static block reads build options while compiling:

  • reflect.has_build_option("NAME") is whether the build defines NAME.
  • reflect.build_option("NAME") is its value, as a String. It is an error when the build does not define NAME.
  • reflect.build_option("NAME", "default") is its value, or default when the build does not define it.

The build cache and the editor

The options and their values are part of the build cache’s key, so building with other options never reuses objects built with different ones.

The language server analyzes with the options in the VS Code setting pepper.buildOptions, a list written as -D takes them (["USE_ZLIB", "LOG_LEVEL=2"]). It has none by default, so code that needs an option shows dimmed, and comparisons with options warn until the setting names them.

Not yet

  • A pep.toml table declaring the options a package takes, with their types and defaults.
  • Arithmetic on options, and comparing two options.

Linking

The Pepper compiler turns your program into a native executable in two steps: it compiles your files and the runtime into object files (one per source file; see Output files), and then links them with the system libraries it needs. When you call C functions, linking is where their definitions come from, so this chapter explains what is linked by default and how to add more.

The compiler uses clang as its link driver, rather than calling the platform’s linker directly, because clang already knows each platform’s startup files, C library and SDK locations. It uses the clang from the LLVM installation the compiler was built with when there is one, and otherwise the first clang on your PATH.

Every program is linked with:

  • Linux: the C library, which clang adds, and the math library (m), which the Pepper compiler adds (a floating-point ** calls its pow), and, when the compiler runs on a glibc older than 2.34, pthread and dl, where that glibc keeps threads and dlsym (a newer one has them in the C library).
  • macOS: libSystem, which contains the C library, plus the SDK and platform-version flags. clang adds all of these, using the Xcode Command Line Tools.
  • Windows: the Microsoft C runtime (the UCRT, vcruntime and the CRT startup library), which clang adds, plus kernel32, user32 and shell32, which the Pepper compiler adds. A program that uses async also links synchronization, which the runtime names with [Link] for its locks (WaitOnAddress). clang finds the Visual Studio and Windows SDK libraries itself, so you do not need a Developer Command Prompt.

Any library file named lib<name>.a in the runtime directory is linked too.

Anything else must be linked explicitly. On Linux, that includes pthread on older systems. Android keeps pthread and rt in its C library and has no library of either name, so the compiler drops -l pthread and -l rt when linking for Android: one [Link("pthread")] serves both.

Attach [Link("name")] (or [Link(lib = "name")]) to an extern declaration to link the library that defines it. The compiler passes -lname to the linker, just like -l on the command line. Name the library the way the linker does: m for libm, gdi32 for gdi32.lib.

# [Link("name")] on an extern links the library that defines it.

# gdi32 is not one of the libraries Windows programs link by default.
[Target(OS(Windows))] [Link("gdi32")] extern ulong GetStockObject(int kind);

# pthread_self is in libpthread on older Linux systems, in libc on newer
# ones, and in libSystem on macOS; linking pthread works on all of them.
[Target(!OS(Windows))] [Link("pthread")] extern ulong pthread_self();

# The math library is separate from the C library on Linux.
[Target(OS(Linux))] [Link("m")] extern double pow(double base, double exponent);
[Target(!OS(Linux))] extern double pow(double base, double exponent);

main(): int {
    [Target(OS(Windows))] {
        # 5 is NULL_BRUSH, a stock object that always exists.
        assert(GetStockObject(5) != 0, "gdi32 was linked");
    }
    [Target(!OS(Windows))] {
        assert(pthread_self() != 0, "pthread was linked");
    }
    assert(pow(2.0, 10.0) == 1024.0, "the math library was linked");
    return 0;
}
  • The library is linked only if the extern survives platform selection. Combining [Link] with [Target], as above, links a library only on the platforms that need it.
  • Each library is linked once, however many externs name it.
  • The library is linked even when nothing calls the extern.
  • The same attribute can also bind the extern to a differently named C symbol: [Link(lib = "m", name = "cbrt")] (see Binding to a different symbol name).

Putting [Link] in the source keeps the knowledge of which library a function comes from next to its declaration, so nobody building your program has to remember a command-line flag.

Linking a library from the command line: -l

The -l option links a library for one build:

pepper build main.pep -l m -o main

Repeat it for more libraries (-l m -l pthread).

A library the linker does not find on its own (one outside the system’s library directories, such as a Homebrew install on macOS) needs its folder on the search path. -L dir adds one, like -L for clang:

pepper build main.pep -L /opt/homebrew/opt/llvm@20/lib -o main

Repeat it for more folders. The folders are searched for -l libraries and [Link] libraries alike, in the order given, before the default ones. On Windows, clang passes each one to the linker as /LIBPATH:.

Library search directories in the source and the environment

A library that ships with your program, such as a vendored build in a vendor/ folder, can name its folder in the [Link] attribute with path:

[Link(lib = "probe", path = "vendor/lib")]
extern int probe_version();

A relative path is relative to the folder of the file that declares the extern, not to where you run the compiler, so the program builds the same from anywhere. Like the library, the folder is searched only when the extern survives platform selection, so a [Target] can give each platform its own folder.

The PEPPER_LIBRARY_PATH environment variable adds folders for every build, separated like PATH (:, or ; on Windows):

PEPPER_LIBRARY_PATH=/opt/homebrew/lib pepper build main.pep -o main

The linker searches -L folders first, then [Link] paths, then PEPPER_LIBRARY_PATH, then the runtime’s folder and the system’s.

When linking fails

If a function you declared with extern is not defined by any linked library, compilation succeeds and the link fails. The compiler prints what the linker said, which names the missing symbol:

error: Link failed: clang exited with exit status: 1
/usr/bin/ld: .pep/build/main.units/main.o: in function `main':
main:(.text+0x25ba): undefined reference to `no_such_function'
clang: error: linker command failed with exit code 1 (use -v to see invocation)

The object the linker names is the one for the file that made the call, in .pep/build/<name>.units/. Symbols of your own functions appear as the compiler spells them for the linker: main and a method such as Point.length keep their names, and any other name is written _P followed by the name with _ doubled and other characters as _ and two hex digits, so a function parse appears as _Pparse and Array<int>.count as _PArray_3cint_3e.count. That way a Pepper function never collides with a C library function of the same name, such as exit. extern functions keep their C names.

To fix it, check the spelling of the extern against the C function (or use [Link(name = "symbol")] when the names differ), then add the library that defines it with [Link] or -l.

If the compiler cannot run clang at all, the error says Link failed: cannot run clang. Install clang (on Linux, from your package manager) or the Xcode Command Line Tools on macOS.

Warnings from the linker are passed through unchanged, even when the link succeeds.

Output files

With no -o, the executable is named after the first source file, without an extension, in the directory where you run the compiler. Windows will not run a file without .exe, so pass -o main.exe there. The object files and other intermediate files go in .pep/build/ under the current directory, or in the directory you pass to --build: one object per file of the program in <name>.units/, and <name>.o, which runs the program’s global initializers. Unchanged objects come from the build cache in .pep/cache/ (--no-cache skips it).

With --lib static or --lib shared, the output is a library and a C header instead (see Building the library).

Compiler errors

When the compiler cannot build your program, it tells you what is wrong and exactly where, in the same format as clang. This chapter shows how to read an error and lists some common ones.

Reading an error

Here is a program with a mistake in it:

struct Point {
    var x: int;
    var y: int;
}

main(): int {
    var p = Point();
    var total = p.x + p.z;
    return total;
}

Compiling it prints:

main.pep:8:25: error: no member named 'z' in 'Point'
    8 |     var total = p.x + p.z;
      |                         ^

An error has three parts:

  1. The header: the file, line and column, then error: and the message. Editors and terminals recognize file:line:column and can jump to it.
  2. The source line the error is on, with its line number.
  3. A caret (^) under the exact token you need to change, with ~ under the rest of the token when it is longer than one character. Here, the member name z, not the whole statement.

Messages follow clang’s style: they start in lowercase, put names and types in single quotes ('z', 'Point'), and say what to do when there is an obvious fix. The fixed program is:

# The program from "Reading an error", after the fix.

struct Point {
    var x: int;
    var y: int;
}

main(): int {
    var p = Point();
    p.x = 3;
    p.y = 4;
    var total = p.x + p.y;
    assert(total == 7, "x + y");
    return 0;
}

Notes

Some errors are explained by a second place in your code. The compiler shows that place as a note: after the error, in the same format. When one file calls a function that another file did not export, the note points at the declaration:

main.pep:4:12: error: 'helper' is private to lib.pep; declare it with 'export' to use it from another file
    4 |     return helper();
      |            ^~~~~~
lib.pep:1:1: note: declared here
    1 | helper(): int { return 1; }
      | ^~~~~~

When a brace is never closed, the error is at the end of the file, and the note shows the brace that opened the block:

main.pep:5:1: error: expected '}'
    5 | 
      | ^
main.pep:1:13: note: to match this '{'
    1 | main(): int {
      |             ^

One error at a time

The compiler stops at the first error it finds and exits with status 1, so fix that error and compile again. It never produces an executable from a program with an error.

On a terminal, errors are colored like clang’s. Set the NO_COLOR environment variable to turn colors off. Output that goes to a file or a pipe is never colored.

Warnings do not stop the build, and the compiler prints them whether or not it also finds an error. Besides the linker’s own warnings, which the compiler passes through (see Linking), the warnings are: deprecations; a method whose parameter names differ from its trait’s (see Traits); an else arm of a match that every case already handles (see Enums); a comparison with a build option that is not defined (see Build options); and an [Export] that has no effect, or a --lib build that exports nothing (see Calling Pepper from C).

Deprecations

A use of a declaration marked [Deprecated("message")] warns at the name, with the message and a note at the declaration. When the attribute names a replacement, the warning shows the rename under the code, and the language server offers it as a quick fix. See Deprecation.

main.pep:9:15: warning: 'clone' is deprecated: use 'copy' instead [deprecated]
    9 |     var b = a.clone();
      |               ^~~~~
      |               copy
main.pep:4:5: note: 'clone' declared here
    4 |     clone(): Counter { return this.copy(); }
      |     ^~~~~

Syntax of the language itself that is on its way out keeps compiling for a while too, with a warning that names its replacement and shows the fix under the code it replaces:

main.pep:3:5: warning: `new` is deprecated; constructors are spelled `construct`, matching `destruct` [deprecated]
    3 |     new(this.x = 1) {}
      |     ^~~
      |     construct
DeprecatedReplacement
new(...) constructorconstruct(...)
A struct, enum, type alias, trait or variable that two files of one module both declare, privately (now an error: files of a module share their names)one declaration, or two names
A declaration of another file of the module, used without importing that file (now an error)import "file"; in the file that uses it
io.Path.join_str(String)io.Path.join(Path), which accepts a String
String.clone()String.copy()
ThreadLocal<T>.get() for a T that is not thread-safe (now an error)with (var value = local.enter()) { ... }
io.Path.is_dir()io.Path.is_directory()
io.File.set_len(ulong)io.File.set_length(ulong)
io.File.write_string(String)io.File.write_all(String)
io.TcpStream.write_string(String), io.UnixStream.write_string(String), tls.TlsStream.write_string(String)write_all(String)
io.File.seek(Offset, int)io.File.seek(io.SeekFrom)
io.File.std_file(int, String)io.File.std_file(io.StdStream, String)
HashMap.cap(), HashSet.cap()the capacity property
arguments(), exit(code) in the preludeprocess.arguments(), process.exit(code) (import process)
Channel.send(x), try_send(x) with a variable something else references (now an error: send no longer copies)send(move(x)), or send(copy(x)) to keep using x
[Sending] parameter attribute (now an error)[Move]
io.Print traitio.Writer (which also needs write(data))
Atomic<T> without an import (now an error: it moved to the async module)async.Atomic<T> after import async, or import [Atomic] from async
math.parse_int(text, base)long.parse(text, base), with no import
Optional<T>() (now an error: Optional is an enum)Optional<T>.Empty
Optional cases None and Some(v) (now an error: renamed)Empty and Value(v)
Optional<T>.some(v), Optional<T>.none()the cases Optional<T>.Value(v), Optional<T>.Empty
Optional.is_some(), Optional.is_none()is_value(), is_empty()
[LinkName("symbol")] on an extern (now an error)[Link(name = "symbol")]
A parameter’s former name, kept by [Rename("old", "new")]the new name (see named arguments)
Copyable<T> implemented as copy(copies: Copies): T, with copies.copy(x) and copies.register(original, made)copy(): T, with copy(x) and register_copy(original, made) (see copying)
hint.likely(condition), hint.unlikely(condition)[Likely] or [Unlikely] above the if or match arm (see branch hints)

Errors in the editor

The Pepper language server, used by the VS Code extension, reports the same errors at the same places while you type. It underlines the token the caret would point at, and shows each note as related information that you can click to jump to.

Common errors

These are some errors you are likely to meet, with the exact text the compiler prints.

A name that was never declared, often a typo:

main.pep:3:16: error: use of undeclared identifier 'nope'
    3 |     return a + nope;
      |                ^~~~
main.pep:2:12: error: call to undeclared function 'frob'
    2 |     return frob(1);
      |            ^~~~

A type name that does not exist, often a misspelled type or a missing import:

main.pep:2:12: error: unknown type name 'Frob'
    2 |     var x: Frob = 1;
      |            ^~~~

A field or method the struct does not have:

main.pep:6:7: error: no member named 'move' in 'Point'
    6 |     p.move(1);
      |       ^~~~

The wrong number of arguments. When the function has default parameters, the message gives the range:

main.pep:4:12: error: too few arguments to 'add': expected 2, have 1
    4 |     return add(1);
      |            ^~~
main.pep:3:12: error: too many arguments to 'f': expected at most 2, have 3
    3 |     return f(1, 2, 3) + f();
      |            ^

A call no overload fits, or that two fit equally well. Each note is a candidate, and says why it does not fit (see Overloading):

main.pep:3:22: error: call to 'f' is ambiguous
    3 | main(): int { return f(1); }
      |                      ^
main.pep:1:1: note: candidate function
    1 | f(x: long): int { return 1; }
      | ^
main.pep:2:1: note: candidate function
    2 | f(x: short): int { return 2; }
      | ^

A struct used as a condition without a truth-test operator. A struct is never tested for null implicitly: declare !!(): bool on it, or compare with __null (see Truth tests and conditions):

main.pep:6:9: error: 'Node' cannot be used as a condition: declare '!!(): bool' on it, or compare with '__null'
    6 |     if (node) {
      |         ^~~~
main.pep:1:8: note: 'Node' declared here
    1 | struct Node {
      |        ^~~~

A syntax error. The parser says what it expected at the first place the code stopped making sense:

main.pep:2:14: error: expected ')'
    2 |     if (true {
      |              ^
main.pep:2:13: error: expected expression
    2 |     var x = ;
      |             ^

A value of the wrong type. Numbers convert among themselves, as in C, but a number is never a struct and one struct type is never another, except that a struct converts to a trait it declares, and a value converts to a struct whose constructor from its type is [Implicit]. A raw pointer is never a struct either: a __ptr<char> is not a String (copy it with String(p)), and a String is not a __ptr<char> (pass its c_str()). Two specializations of one generic struct are different types: Array<int> is not Array<String> and Array<Derived> is not Array<Base>. Type arguments compare exactly, at every depth, after aliases resolve and omitted arguments take their defaults (Result<int> is Result<int, Error>). The check applies wherever a value flows into a typed place: a variable’s initializer, an assignment, an argument, a return, and each arm of c ? a : b, whose false arm must convert to the true arm’s type:

main.pep:2:13: error: cannot initialize variable 'x' of type 'int' with a value of type 'String'
    2 |     int x = "hello";
      |             ^~~~~~~
main.pep:3:12: error: cannot pass a value of type 'i32' as argument 1 of 'g', which has type 'String'
    3 |     return g(5);
      |            ^
main.pep:3:19: error: cannot pass a value of type '__ptr<char>' as argument 2 of 'assert', which has type 'String'; use 'String(...)' to copy a C string
    3 |     assert(false, p);
      |                   ^
main.pep:2:12: error: cannot return a value of type 'String' from 'f', which returns 'int'
    2 |     return "a";
      |            ^~~
main.pep:2:20: error: cannot initialize variable 'a' of type 'Array<int>' with a value of type 'Array<String>'
    2 |     Array<int> a = Array<String>();
      |                    ^~~~~~~~~~~~~
main.pep:3:35: error: incompatible operand types ('Array<int>' and 'Array<String>')
    3 |     var a = flag ? Array<int>() : Array<String>();
      |                                   ^~~~~~~~~~~~~

A struct used as a trait it does not declare (see Traits as types):

main.pep:11:20: error: 'Circle' does not conform to trait 'Shape'
   11 |     var s: Shape = Circle();
      |                    ^~~~~~
main.pep:6:8: note: declare it with 'extend Circle with Shape'
    6 | struct Circle {
      |        ^~~~~~

A value that is not thread-safe given to another thread (see Threads): here a thread body, a shared function value, that captures a String:

main.pep:5:46: error: closure passed as 'shared () => void' captures 'name' of type 'String', which is not thread-safe
    5 |     var t = async.Thread.spawn(() => { print(name); });
      |                                              ^~~~
main.pep:4:9: note: capture a thread-safe value instead, such as an 'async.Atomic<T>' or a '[Shared]' struct
    4 |     var name = "worker";
      |         ^~~~

The same name declared twice in one scope:

main.pep:3:9: error: redefinition of 'n'
    3 |     var n = 2;
      |         ^
main.pep:2:9: note: previous definition is here
    2 |     var n = 1;
      |         ^

The same holds for top-level types: two structs, traits, enums or type aliases of one name in a file, or exported by two prelude modules, report redefinition of 'Point'.

Two functions that differ only in their return type, in one file or beside a runtime function of the name, which a call could not tell apart:

main.pep:1:1: error: functions that differ only in their return type cannot be overloaded
    1 | to_string(value: int): int { return value; }
      | ^~~~~~~~~
runtime/string.pep:1621:8: note: previous declaration is here
 1621 | export to_string(value: int): String {
      |        ^~~~~~~~~

Looping over something that cannot be iterated (see Iteration):

main.pep:3:19: error: type 'int' is not iterable: implement Iterable<T> (iterate()) or Iterator<T> (next() and current())
    3 |     for (var i in 5) {
      |                   ^

An import that names nothing (see Modules and imports):

main.pep:1:8: error: cannot find "nope.pep" or the module folder "nope" next to this file
    1 | import "nope";
      |        ^~~~~~

Code nested too deeply. Parentheses, blocks, calls, and each operator of a chain (a + b + c is two levels) nest the program one level deeper, and a file may nest at most 256 levels. The error points at the token that crosses the limit, here the 255th + of a long sum:

main.pep:2:1030: error: code is nested too deeply: the limit is 256 levels; split it into smaller expressions or functions
    2 |     return 0 + 1 + 1 + ... + 1;
      |                             ^

Split the expression into steps (var part = ...;), or move the inner part into a function.

A generic that asks for a larger copy of itself (see Generics). Each type argument makes a separate copy of a generic, so a Foo<T> holding a Foo<Array<T>> never runs out of copies to make. The compiler stops 64 levels deep, at the type that asks for the next level, with a note where the chain started:

main.pep:2:16: error: 'Foo' is specialized 65 levels deep, past the limit of 64; a generic whose instance asks for a larger instance of itself never ends
    2 |     var inner: Foo<Array<T>>;
      |                ^~~~~~~~~~~~~
main.pep:6:13: note: asked for by 'main' here
    6 |     var f = Foo<int>();
      |             ^~~~~~~~

A cast that reinterprets memory. as converts values, like static_cast (see Casting with as); a pointer to an integer, or to a pointer of another type, needs unsafe_cast:

main.pep:3:15: error: cannot cast '__ptr<int>' to 'ulong' with 'as'; use 'unsafe_cast<ulong>(...)' to reinterpret
    3 |     var a = p as ulong;
      |               ^~

Errors while the program runs

Some problems can only be found while the program runs, such as an array index out of range, an integer overflow, a division by zero, or a failed assert. These are not compiler errors: the program prints a message and stops immediately (it aborts, so it exits with a failure status instead of returning from main). See assert and panic.

Deprecation

When an API is on its way out, mark it [Deprecated("message")]. Code that uses it still compiles, but every use gets a warning that shows your message, the same way clang reports [[deprecated]]. That gives the code that depends on the API time to move before you remove it.

struct Temperature {
    int celsius;

    # A method on its way out: it still works, but every call warns.
    [Deprecated("use 'fahrenheit' instead", "fahrenheit")]
    to_f(): int { return fahrenheit(); }

    fahrenheit(): int { return celsius * 9 / 5 + 32; }
}

# The second argument names the replacement: the warning carries a fix that
# renames the call.
[Deprecated("use 'boiling' instead", "boiling")]
boiling_point(): Temperature { return boiling(); }

boiling(): Temperature {
    var t = Temperature();
    t.celsius = 100;
    return t;
}

main(): int {
    # Both calls warn, and both still work.
    var old = boiling_point();
    assert(old.to_f() == 212, "the old API still works");

    var t = boiling();
    assert(t.fahrenheit() == 212, "100 C is 212 F");
    return 0;
}

Compiling this prints a warning at each use of a deprecated name, with a note at its declaration. The second of the two is:

main.pep:25:16: warning: 'to_f' is deprecated: use 'fahrenheit' instead [deprecated]
   25 |     assert(old.to_f() == 212, "the old API still works");
      |                ^~~~
      |                fahrenheit
main.pep:6:5: note: 'to_f' declared here
    6 |     to_f(): int { return fahrenheit(); }
      |     ^~~~

Warnings never stop the build. The program above compiles and runs.

The replacement

The attribute takes an optional second argument: the name that replaces the deprecated one.

[Deprecated("use 'copy' instead", "copy")]
clone(): Counter { ... }

With a replacement, the warning comes with a fix that renames the use. The compiler shows the new name under the old one, as in the warning above, and the language server offers the rename as a quick fix. The fix only renames, so give a replacement only when the new declaration can be used the same way. Otherwise leave it out and explain the change in the message.

An empty message is allowed. The warning then says only 'name' is deprecated.

What it applies to

Put [Deprecated(...)] before any declaration other code refers to by name:

  • a function or an extern
  • a struct, a trait, or a type alias
  • a module-scope variable
  • a field, a method, or a constructor of a struct, or a method of an enum
  • a method in a trait or in an extension (put it on each method, not on the extend block)

A doc comment goes above the attribute, as it does for any attribute.

To rename a parameter while calls still name the old one, put [Rename("old", "new")] on the function instead (see Other names for a parameter).

Which uses warn

Every use of the declaration warns, at the name you wrote:

  • a call, a reference to a function or variable, or a construction S() (a deprecated constructor is reported at the struct’s name)
  • a type name, anywhere a type is written
  • a member access x.name, a method call x.name(), a static call S.name(), and a bare field or method name inside the struct’s own methods
  • an entry in an import list, import [name] from m, and a qualified use m.name, where the warning points at name
  • a call the compiler writes for you, at the token that stands for it: a for loop’s next() and current() at for, a with block’s enter() at with, and an operator overload at its operator (a + b at +; a deprecated == also warns at a != that uses it). These warnings offer no rename fix.

These do not warn:

  • the declaration itself
  • a use inside a declaration that is deprecated too, including the methods of a deprecated struct and closures written inside a deprecated function, so old code can keep calling old code without noise
  • a re-export, export name as other or export [name] from source, which passes the name on without using it

Mistakes in the attribute

A malformed attribute is an error at the attribute:

main.pep:1:1: error: 'Deprecated' expects a message string: [Deprecated("message")]
    1 | [Deprecated]
      | ^~~~~~~~~~~~

The other errors are 'Deprecated' expects the replacement as a string, the replacement 'a b' is not a name, too many arguments to 'Deprecated': expected at most 2, have 3, duplicate 'Deprecated' attribute, and 'Deprecated' cannot be applied to an extension or a destructor.

Documentation comments

Comments explain code to the people who read it. Pepper has one comment syntax, #, and a convention on top of it for documentation: lines starting with ## directly above a declaration document that declaration. Your editor shows them when you hover over a use of the name, and the standard library reference in this book is generated from them.

Comments

A comment starts with # and runs to the end of the line. It can take a whole line or follow code:

# Count the lines that are not blank.
var count = 0;   # blank lines are skipped below

Pepper has no block comment syntax: to comment out several lines, start each with #. (// is not a comment in Pepper.)

Doc comments

A doc comment is a run of consecutive lines that start with ##, directly above the declaration it documents:

## Returns the larger of `a` and `b`.
##
## @param a the first value
## @param b the second value
## @returns `a` when it is at least `b`, otherwise `b`
larger(a: int, b: int): int {
    return a >= b ? a : b;
}
  • The text of each line is what follows ## . A line that is just ## is a blank line in the documentation, which separates paragraphs.
  • The comment must sit directly above the declaration, with no blank line in between. Attributes and export or static may come between the comment and the declaration, so put the comment above the attributes.
  • Only lines that start with exactly two # count. A single # is an ordinary comment, and a line starting with three or more, such as a decorative #######, is not documentation.
  • A doc comment works on functions, structs, enums and their cases, traits, extensions, type aliases, module-scope variables, and the fields, properties, constructors and methods inside a struct, enum, trait or extension.

What to write

Start with a description: what the declaration does, in the third person (“Returns …”, “Appends …”), followed by what a caller needs to know, such as edge cases, who owns what, how long it takes, and differences between platforms. The description is Markdown, so put code in backticks.

After the description come tags, each starting a line with @. A tag’s text runs until the next tag.

TagUse it for
@typeParam T descriptioneach generic parameter of a generic struct, trait, type alias or function
@param name descriptioneach parameter of a function, method or constructor
@returns descriptionwhat the function returns, whenever it returns a value
@panics descriptioneach way a caller can make the function panic (a failed assert, a call to panic)
@examplesample code, indented on the lines that follow; line breaks are kept
@see namea related declaration
@note texta remark that does not fit elsewhere

Write tag descriptions like clang writes errors: lowercase start, no trailing period. A field or a type alias usually needs only one line: ## The number of bytes in the buffer.

Keep notes that matter only to the people maintaining the code in ordinary # comments inside the body. The doc comment is the contract with callers.

Here is a small documented library:

# A small library, documented the way the standard library is.
# This line and the one above are ordinary comments: they start with a
# single '#'. Doc comments start with '##'.

## A last-in, first-out collection.
##
## Elements come out in the reverse of the order they went in.
##
## @typeParam T the type of the elements
## @example
##     var s = Stack<int>();
##     s.push(1);
##     s.push(2);
##     var top = s.pop();   # top == 2
struct Stack<T> {
    ## The elements, bottom first.
    var items: Array<T> = Array<T>();

    ## Adds `value` on top of the stack.
    ##
    ## @param value the element to add
    push(value: T) {
        items.append(value);
    }

    ## Removes and returns the element on top of the stack.
    ##
    ## @returns the element that was pushed last
    ## @panics if the stack is empty
    ## @see Stack.push
    pop(): T {
        assert(items.count > 0, "pop() on an empty Stack");
        return items.remove_at(items.count - 1);
    }

    ## Returns the number of elements.
    ##
    ## @returns how many elements the stack holds
    count(): ulong {
        return items.count;
    }
}

## Returns the larger of `a` and `b`.
##
## @param a the first value
## @param b the second value
## @returns `a` when it is at least `b`, otherwise `b`
## @note ties return `a`
larger(a: int, b: int): int {
    # An implementation note for maintainers, not part of the docs.
    return a >= b ? a : b;
}

main(): int {
    var s = Stack<int>();
    s.push(1);
    s.push(2);
    assert(s.pop() == 2, "the last element comes out first");
    assert(s.count() == 1, "one element is left");
    assert(larger(3, 7) == 7, "larger returns the larger value");
    return 0;
}

Where doc comments appear

  • In your editor. The Pepper language server shows a declaration’s doc comment when you hover over its name, anywhere it is used. The description comes first, followed by sections for the type parameters, parameters, return value, panics, related declarations, notes and examples.
  • In the standard library reference. The reference pages are generated from the doc comments in the runtime, one page per runtime file. A ## block at the very top of a file, followed by a blank line, introduces that file’s page. Building this documentation explains how to regenerate them.

The standard library is fully documented

Every public declaration in the runtime must have a doc comment: every exported declaration whose name does not start with _, and every field, constructor and method of such a struct, trait or extension whose name does not start with _. Names starting with _ are internals shared between runtime files and are exempt.

A test enforces this, and reports a clang-style error for each gap: a missing doc comment, a comment with no description, a missing @param, @typeParam or @returns, a tag with no description, a @returns on a function that returns nothing, or a @param naming a parameter that does not exist. If you contribute to the runtime, run the test suite before you send a change. The compiler does not check the doc comments in your own programs, but following the same format gets you the same editor support.

Naming conventions

Pepper code, including the standard library, follows one set of naming rules. The compiler does not enforce them, but every example in this book, the runtime, and the tools in this repository follow them, and code you contribute should too.

WhatStyleExamples
Structs, traits, type aliasesPascalCaseString, HashMap, IoResult
Generic parametersone capital letter, or PascalCaseT, K, V, TValue, TError
Data members (fields)snake_caseunit_name, is_file
Methods and free functionssnake_caseappend, remove_at, read_to_string
Parameters and local variablessnake_casecapacity, best_so_far
Constants (a const holding a fixed value)SCREAMING_CASEMAX_REQUESTS, LINE_WIDTH
Other module-scope variablessnake_caserequest_count
Private data members and methods_ then snake_case_readings, _is_empty
# Types are PascalCase; acronyms are written as words (`HttpClient`, not `HTTPClient`).
struct TemperatureLog {
    # Public data members are snake_case.
    var unit_name: String = "celsius";
    # Private data members start with `_`.
    var _readings: Array<int> = Array<int>();
    var _total: int = 0;

    # Methods and their parameters are snake_case.
    add_reading(degrees: int) {
        _readings.append(degrees);
        _total = _total + degrees;
    }

    average(): int {
        return _is_empty() ? 0 : _total / (_readings.count as int);
    }

    # A private method starts with `_` too.
    _is_empty(): bool {
        return _readings.count == 0;
    }
}

# Generic parameters are a single capital letter or PascalCase.
struct Pair<TFirst, TSecond> {
    var first: TFirst;
    var second: TSecond;

    construct(TFirst first, TSecond second) {
        this.first = first;
        this.second = second;
    }
}

# Free functions and local variables are snake_case.
largest_of<T>(values: Array<T>): T {
    var best_so_far = values[0];
    for (var value in values) {
        if (value > best_so_far) {
            best_so_far = value;
        }
    }
    return best_so_far;
}

main(): int {
    var log = TemperatureLog();
    log.add_reading(20);
    log.add_reading(24);
    assert(log.average() == 22, "average of the readings");
    assert(log.unit_name == "celsius", "public member");

    var pair = Pair<String, int>("answer", 42);
    assert(pair.second == 42, "generic struct");

    var numbers = Array<int>();
    numbers.append(3);
    numbers.append(9);
    numbers.append(4);
    assert(largest_of<int>(numbers) == 9, "generic function");
    return 0;
}

Types

A type name is PascalCase: each word starts with a capital letter, and there are no underscores. Write an acronym as a word, so IoError and HttpClient, not IOError or HTTPClient.

An enum’s cases are PascalCase too (Color.Red, Status.NotFound).

A generic parameter is either a single capital letter (T, U, K, V) or a PascalCase name, which by convention starts with T (TValue, TError). Use a single letter when the parameter’s role is obvious, and a name when a declaration has several parameters that a reader has to tell apart.

Members, functions, and variables

Everything else a program names is snake_case: lowercase words joined by underscores. That covers a struct’s data members, its methods, free functions, parameters, local variables, and module-scope variables.

The exception is a constant: a const, at module scope or in a function, whose value is fixed, such as a number, string, or character. Its name is SCREAMING_CASE, uppercase words joined by underscores (MAX_REQUESTS, LINE_WIDTH). A module-scope const that holds an object, such as const request_count = async.Atomic<int>(0);, is a variable and stays snake_case.

Spell words out. A name is read far more often than it is typed, so prefer iterate, index, and directory to iter, idx, and dir. Keep an abbreviation only when it is the usual name for the thing, such as io or c_str.

An extern declaration keeps the name the C library gives the function, whatever its style (GetCurrentProcessId), because that name is what the linker looks up. The same goes for the fields of a struct that mirrors a C struct.

Built-in type names

Unlike the conventions above, this rule is enforced. No top-level declaration may take the name of a built-in type: void, bool, char, byte, short, ushort, int, uint, long, ulong, float, double, and their sized spellings (i8, u8, i16, u16, i32, u32, i64, u64, f32, f64). Names resolve by spelling across the whole program, so such a declaration would take the type’s place in every file, the runtime’s included. The compiler reports it at the name:

main.pep:1:7: error: 'double' is the name of a built-in type; choose another name
    1 | const double = (x: int): int => x * 2;
      |       ^~~~~~

Locals, parameters and members may still use these names, since they are visible only where they are declared.

Private members

Pepper’s private keyword applies only to top-level declarations (see Visibility); any code that can reach an object can name all of its members. A leading _ marks a data member or method as private to its struct instead: code outside the struct, and its extensions, should not use it. The rest of the name is snake_case, as for a public member (_readings, _is_empty).

The standard library relies on this convention: its generated reference leaves _ members out, and doc comments are required only on members without the prefix.

A top-level declaration does not need the prefix to be private: it is internal to its module (or, outside any module, private to its file) unless it is exported, and private keeps it to its file (see Visibility). The runtime also uses the prefix on top-level helpers it exports but that are not part of its public API, such as _pep_malloc, which the compiler calls by name, or a helper one runtime module shares with another: the reference leaves them out too.

Installing Pepper

Each release publishes the Pepper toolchain prebuilt for Linux, macOS and Windows, with an install script that downloads the right one. You need no LLVM development install to use it; building from source is for working on Pepper itself (see Getting started).

# Linux and macOS
curl -fsSL https://raw.githubusercontent.com/justinboswell/pepper/main/scripts/install.sh | sh
# Windows (PowerShell)
irm https://raw.githubusercontent.com/justinboswell/pepper/main/scripts/install.ps1 | iex

Each release also carries both scripts as assets, beside its archives.

The script only bootstraps. It picks this machine’s archives (linux-x64, macos-arm64, macos-x64 or windows-x64) from the newest release that has them: the toolchain, pepper-<version>-<target>.tar.gz (.zip on Windows), and the installer, pepper-install-<version>-<target>.tar.gz, which holds only pep-install. It downloads both, checks the installer against its .sha256, unpacks pep-install into a temporary folder and runs pep-install setup --archive <toolchain>, which:

  1. checks the toolchain archive against its .sha256;
  2. unpacks it into ~/.pepper/toolchains/<version> (%LOCALAPPDATA%\pepper on Windows), makes it the default toolchain, and puts the entry point pepper and pep-install in ~/.pepper/bin; toolchains installed before stay (see Toolchains: pep-install);
  3. adds that folder’s bin to the PATH: in ~/.profile, ~/.bashrc, ~/.bash_profile and ~/.zshrc and fish’s conf.d, or in the Windows user’s PATH;
  4. checks that programs can be linked (below), offering to install what is missing;
  5. builds and runs a small program with the new install, and fails if it cannot.

The script downloads the toolchain itself, rather than leaving it to pep-install, because pep-install downloads with OpenSSL 3, which a fresh Mac and RHEL 8 do not have.

Open a new terminal afterwards so the PATH change takes effect, then pepper build hello.pep -o hello works as in Getting started.

What it installs

~/.pepper/
  bin/
    pepper              the entry point (The pepper command), which runs a toolchain's tools
    pep-install         installs and removes toolchains (pepper install)
  toolchains/
    0.4.0/
      bin/
        pep-build       the compiler, with LLVM inside it (on Windows, LLVM-C.dll beside it)
        pep-fmt  pep-test  pep-package  pep-profile  pep-run  pep-lsp  pep-doc  pepper  pep-install
      runtime/          the runtime, which pep-build finds from its own folder
      LICENSE.md
  default-toolchain     the default toolchain's version

pepper runs the tools of the toolchain a folder uses: the default, unless pepper +<version>, PEPPER_TOOLCHAIN or the project’s pep.toml names another (see Toolchains). pepper install add, use and update install more and change the default; update also updates pepper and pep-install themselves.

Running the script again installs the newest release beside the others and makes it the default. Only releases that carry the installer archive can be installed this way; pepper install add installs older ones into a home that is set up. A release from before toolchains (0.3.0 and earlier) cannot be set up at all, and neither is an install from before toolchains, with bin and runtime straight in ~/.pepper, moved: uninstall it first.

On Linux the binaries need glibc 2.28 or newer (RHEL 8, Debian 10, Ubuntu 18.10 and later); systems that use musl, such as Alpine, are not supported. There are no prebuilt binaries for Linux or Windows on arm64 yet. The macOS binaries are not signed: an archive downloaded with a browser makes Gatekeeper warn before it runs; one the script downloads does not.

What linking needs

pep-build compiles with the LLVM inside it, but links each program with clang, which also needs the platform’s linker and C library:

PlatformNeedsThe script offers
Linuxclang and the C library’s development filesapt-get install clang build-essential, dnf/yum install clang gcc glibc-devel, pacman -S clang gcc or zypper install clang gcc glibc-devel, whichever manager is present
macOSXcode’s command line toolsxcode-select --install
WindowsLLVM (clang) and the MSVC libraries and Windows SDKLLVM with winget install LLVM.LLVM, else scoop install llvm; the Visual Studio Build Tools’ C++ workload with winget

It asks before installing anything, unless given --yes (-Yes). With no terminal to ask on it installs nothing and says what to install.

Programs that use the tls module also need OpenSSL 3 (libssl.so.3 on Linux, Homebrew’s openssl@3 on macOS; Windows uses SChannel). The script says so when it finds none, and on macOS offers brew install openssl@3.

pep-build looks for clang in this order, and uses the first it finds:

  1. in the LLVM named by PEPPER_LLVM_PREFIX, else by LLVM_SYS_<major>1_PREFIX (LLVM_SYS_201_PREFIX for LLVM 20);
  2. beside pep-build itself;
  3. on the PATH, as clang, then as clang-<major> for the LLVM it is built with (what Ubuntu’s clang-20 package installs);
  4. where LLVM’s packages put it when they leave it off the PATH: C:\Program Files\LLVM\bin and Scoop’s llvm on Windows; Homebrew’s llvm and llvm@<major> on macOS; /usr/lib/llvm-<major>/bin, then the newest /usr/lib/llvm-*/bin, on Linux.

When there is none, linking fails with an error that names what to install.

Options

install.sh [--version <x.y.z> | --archive <file>] [--prefix <dir>]
           [--yes] [--no-modify-path] [--no-prerequisites]
install.sh --uninstall [--prefix <dir>]
install.shinstall.ps1What it does
--version <x.y.z>-Version <x.y.z>Installs that release instead of the newest, or a prerelease such as 0.4.0-dev, the build of main (see below), which installing again refreshes
--archive <file>-Archive <file>Installs an archive already downloaded, checked against <file>.sha256 beside it, with the installer archive pepper-install-<version>-<target> (and its .sha256) beside it too. One not named for a release, such as a dev build, installs as the version its pep-build --version reports
--prefix <dir>-Prefix <dir>Installs into <dir> instead of ~/.pepper (%LOCALAPPDATA%\pepper)
-y, --yes-YesInstalls missing prerequisites without asking
--no-modify-path-NoModifyPathLeaves the PATH alone
--no-prerequisites-NoPrerequisitesNeither checks for nor installs what linking needs
--uninstall-UninstallRemoves the install, every toolchain in it, and the PATH entries the script added, with the home’s own pep-install uninstall, else with the newest release’s installer
-h, --helpGet-Help .\install.ps1Shows the usage

Piped into a shell, the script still takes options: curl ... | sh -s -- --version 0.4.0. irm ... | iex takes none, so install.ps1 reads two environment variables in their place:

VariableInstead of
PEPPER_HOME--prefix / -Prefix (both scripts)
PEPPER_VERSION-Version (install.ps1)

While the repository is private, its releases need a GitHub token to download: set GITHUB_TOKEN (or GH_TOKEN) to one that can read it, for example GITHUB_TOKEN=$(gh auth token). The raw script URLs above need it too, so download the script with gh first:

gh api repos/justinboswell/pepper/contents/scripts/install.sh -H "Accept: application/vnd.github.raw" > install.sh
GITHUB_TOKEN=$(gh auth token) sh install.sh

Uninstalling

sh install.sh --uninstall           # or: curl ... | sh -s -- --uninstall
.\install.ps1 -Uninstall

This removes the install folder (only when it holds bin/pep-build) and the PATH lines the script added. Prerequisites it installed, such as clang, stay.

How releases are built

The Release workflow (.github/workflows/release.yml) publishes:

  • the build of main, on each merge into main that changes the compiler, the runtime or the tools: main holds the version being developed as a prerelease (0.4.0-dev in VERSION and each tool’s version()), and the workflow publishes it on the prerelease v0.4.0-dev, moving its tag to the merge and replacing its archives. CI runs it on the merge before its tests, which then install that build. CI and the workers install the newest published toolchain, that prerelease or a release with a higher version, whatever VERSION holds, instead of building the compiler. The newest release, the default, never picks a prerelease.
  • a release, on a vX.Y.Z tag, made by hand. Its stage 0, the compiler that builds it, is the newest published toolchain too, so the release may take any version, not only the one main was developing:
    1. A pull request runs scripts/set-version.sh 0.9.0, which sets the version everywhere a tool reads it; the same pull request sets the VS Code extension’s package.json and package-lock.json to match.
    2. Tag that merge v0.9.0 and push the tag. The workflow checks that the commit holds that version, builds it and publishes the release; the Docs workflow publishes the docs from the same tag, and once the release’s archives are up the workflow dispatches VSIX on the tag, which builds the extension with them and publishes it as vsix-v<extension version>.
    3. Once it is published, the workflow opens a pull request that starts the next minor version on main (0.10.0-dev) and merges itself once its checks pass, unless main has already moved on; a major version is started by hand. A pull request the workflow opens with its own token starts no workflows, so it is opened with the RELEASE_BUMP_TOKEN secret when that is set, and otherwise the workflow dispatches CI on its branch.

For each platform, scripts/release-build.sh builds the tools, lays them out as above, archives them as pepper-<version>-<target>.tar.gz (.zip on Windows) with a .sha256, packs pep-install alone as the installer archive, pepper-install-<version>-<target>.tar.gz (.zip) with its own .sha256, and scripts/release-smoke-test.sh proves the archives work: unpacked outside the repository with no LLVM variables set, pepper build must build and run a program, and the binaries must load nothing beyond the system’s C and C++ runtimes (on Linux, nothing newer than glibc 2.28: the Linux leg builds in a manylinux_2_28 container), and the installer’s pep-install must run. The workflow then installs each archive with the install script. Last, it uploads the archives, their checksums, a SHA256SUMS and both scripts to the release. A pull request that changes these scripts runs every leg too, without publishing.

The pepper command

pepper is the entry point to every Pepper tool, the way go and git are: pepper <command> [arguments] runs a separate program named pep-<command> with the remaining arguments.

pepper build main.pep -o main     # runs pep-build main.pep -o main
pepper build                      # builds the project pep.toml describes
pepper run hello.pep a b          # builds hello.pep and runs it with a b
pepper fmt -w src                 # runs pep-fmt -w src
pepper -C my_app build            # runs pep-build in my_app
pepper +0.2 build                 # runs the installed 0.2.x toolchain's pep-build
pepper help                       # lists the commands it can find
pepper help build                 # runs pep-build --help
pepper --version                  # pepper's version; -v adds each command's

pepper is itself written in Pepper (tools/pepper/main.pep). The compiler is the program pep-build, so pepper build takes exactly the options the compiler does (see The compiler: pep-build).

Usage and options

pepper [+<version>] [-C <dir>]... <command> [arguments]
pepper help [<command>]
pepper --version [-v]
FormWhat it does
pepper <command> [arguments]Runs pep-<command> with the arguments (see How it finds a command)
+<version>Runs the command with that installed toolchain, such as +0.3.0 or +0.3 (see Toolchains)
-C <dir>Changes to <dir> before running the command (see Running in another folder)
pepper help, -h, --helpPrints the usage, these options, and the commands it can find
pepper help <command>Runs pep-<command> --help
--versionPrints pepper and its version, and in a Pepper home the toolchain the folder uses and why
--version -v, --version --verboseAlso prints the version each command reports (its --version’s first line, or (no version))

Options go before the command: everything after the command belongs to it, so pepper fmt --help is pep-fmt --help. pepper with no arguments, or with only -C and +<version> options, prints the usage to standard error and exits with status 2; so does a -C with no folder after it; an option other than -v or --verbose after --version is an error that exits with 2. An unknown command exits with 1. Otherwise the exit status is the command’s.

The commands in this repository

CommandProgramWhat it does
pepper buildpep-buildCompiles a program, or with no file the project (The compiler: pep-build, Projects: pep.toml)
pepper runpep-runBuilds a program and runs it, like python script.py (Running a program: pep-run)
pepper installpep-installInstalls toolchains side by side and picks the default (Toolchains: pep-install)
pepper packagepep-packageAdds, removes, updates and fetches a project’s dependencies (The package manager: pep-package)
pepper fmtpep-fmtFormats source (Formatting with pep-fmt)
pepper profilepep-profileProfiles a program and draws a flame graph (Profiling with pep-profile)
pepper testpep-testBuilds and runs the [Test] functions (Testing with pep-test)
pepper lsppep-lspRuns the language server, which editors start (The language server: pep-lsp)
pepper docpep-docGenerates the library reference and builds this book (The documentation builder: pep-doc)

Every one of them, pep-build included, is a Pepper program under tools/, built with pep-build.

Building it

Build pepper with a pep-build (an installed one, or one you built from tools/compiler; see Building from source) and put it next to it:

target/bin/pep-build tools/pepper/main.pep -o target/bin/pepper

On Windows, name the output target\bin\pepper.exe.

How it finds a command

Outside a Pepper home, pepper <command> looks for pep-<command> (pep-<command>.exe on Windows) in the directory that holds the running pepper first, then in each directory on the PATH, and runs the first it finds. So the tools installed next to pepper win over others with the same name, and any program named pep-<name> on the PATH adds the command <name>: a tool of your own becomes pepper <name> once it is installed. On Linux and macOS only files with an execute permission count.

The command takes pepper’s place, with its standard input, output and error: on Linux and macOS pepper execs it, so signals such as Ctrl-C reach the command directly and its exit status is pepper’s. Windows cannot replace a process, so there pepper waits for the command, leaves Ctrl-C to it, and exits with its exit status. A command name is lowercase letters, digits, - and _; anything else, such as a path, is not a command.

An unknown command is an error that suggests the closest command and lists the ones pepper found:

$ pepper buidl main.pep
pepper: error: 'buidl' is not a pepper command; did you mean 'build'?

Commands:
  build
  fmt

Running in another folder

-C <dir> runs the command as if pepper had been started in <dir>, like git -C: pepper -C my_app build builds the project in my_app from anywhere. Repeated, each -C is relative to the one before, so pepper -C a -C b build runs in a/b; an empty -C "" changes nothing. A folder that cannot be entered is an error that exits with status 1:

$ pepper -C nowhere build
pepper: error: cannot change to 'nowhere': No such file or directory

-C and +<version> are the only options that go before a command. They work with help and the old form too.

Toolchains

Installed by pep-install, pepper lives in the Pepper home’s bin folder (~/.pepper/bin, %LOCALAPPDATA%\pepper\bin on Windows), beside the installed toolchains, and runs the tools of one of them, or of a toolchain in a folder PEPPER_TOOLCHAIN_PATH lists (see More toolchain folders). The first of these that names a toolchain picks it:

  1. +<version> before the command: pepper +0.3.0 build;
  2. the PEPPER_TOOLCHAIN environment variable;
  3. toolchain = "0.3" in [package] of the nearest pep.toml at or above the folder the command runs in (after -C), so a project pins the compiler it builds with (see Projects: pep.toml);
  4. the default, which pepper install use sets.

A version is exact (0.3.0), or a prefix that picks the newest installed toolchain that starts so (0.3, 0), or latest for the newest installed. pepper then looks for pep-<command> in that toolchain’s bin folder first, then beside pepper, then on the PATH, and runs it with PEPPER_TOOLCHAIN set to the exact version, so a pepper that command runs uses the same toolchain. pepper install always runs the home’s own pep-install, which needs no toolchain.

When the toolchain picked is not installed, or none is installed, pepper says so and how to install it, and exits with status 1:

$ pepper build
pepper: error: toolchain 0.4 (pinned by /work/app/pep.toml) is not installed; install it with 'pepper install add 0.4'

pepper install which shows which toolchain a folder uses and why. Outside a Pepper home, as when it is built from this repository, pepper runs the tools beside it, and +<version> is an error.

The old form

Before pepper build, pepper was the compiler. A first argument that is a .pep file or an option still runs the compiler with the whole command line, after a warning:

$ pepper main.pep -o main
pepper: warning: 'pepper main.pep ...' is deprecated; use 'pepper build main.pep ...'

The old form will be removed in a later release.

Toolchains: pep-install

pep-install installs Pepper toolchains side by side and picks the default one, the way rustup and nvm do. pepper install runs it with the same arguments:

pepper install list               # the installed toolchains, marking the default and the one in use here
pepper install list --available   # the releases with an archive for this machine
pepper install add 0.3            # installs the newest 0.3.x; the default stays as it is
pepper install use 0.3.0          # makes it the default
pepper install update             # installs the newest release and makes it the default
pepper install add --force 0.3.0  # installs 0.3.0 again, replacing it
pepper install remove 0.2.1
pepper install which              # the toolchain this folder uses, why, and its pep-build
pepper install setup              # sets this machine up: installs, sets the default and the PATH, checks
pepper install uninstall          # removes the home and its PATH changes
pep-install <command> [arguments]

A toolchain is one release of the compiler, its runtime and the tools (pep-build, pep-fmt, pep-test, …). The pepper command that pep-install sits beside runs the tools of the toolchain a folder uses: the one pepper +<version> names, else PEPPER_TOOLCHAIN’s, else the one the project’s pep.toml pins with toolchain = "0.3", else the default (see Toolchains).

Options and commands

Command or optionWhat it does
listLists the installed toolchains, newest first, marking the default and the one the current folder uses, with why
list --available, -aLists the releases that have an archive for this machine instead, marking those installed
add <version>Installs the newest release <version> names, unless it is installed; it becomes the default only when there was none
add --force <version>, -fInstalls it even when it is installed, replacing the home’s copy
use <version>Makes the newest installed toolchain <version> names the default
updateInstalls the newest release, unless it is installed, and makes it the default; older toolchains stay installed. Then updates the home’s pepper and pep-install from it
update --force, -fInstalls the newest release even when it is installed, replacing the home’s copy
remove <version>Removes the installed toolchain <version> names; it must name just one, and not the default
whichPrints the toolchain the current folder uses and why, then the path of its pep-build; fails when that toolchain is not installed
setup [<version>]Sets this machine up for Pepper: installs the newest release <version> names (default latest), makes it the default, puts its pepper and pep-install in the home’s bin, adds that to the PATH, offers to install what linking needs, and builds and runs a program to check (see Setting a machine up)
setup --archive <file>Installs the downloaded release archive <file> instead, checked against <file>.sha256 beside it
setup --yes, -yInstalls missing prerequisites without asking
setup --no-modify-pathLeaves the PATH and the shell startup files alone
setup --no-prerequisitesNeither checks for nor installs clang and the other prerequisites
uninstallRemoves the Pepper home and the PATH changes setup made for it
--path <dir>Also finds toolchains in <dir>, before those PEPPER_TOOLCHAIN_PATH lists; repeatable, before or after the command (see More toolchain folders)
-h, --helpPrints the commands; after a command, that command’s options
-V, --versionPrints the version

A <version> is exact (0.3.0), or a prefix (0.3, 0) for the newest release, or installed toolchain, that starts so, or latest for the newest. A prerelease such as 0.4.0-dev, the build of main that each merge replaces, is picked only by naming it exactly; prefixes, latest and update leave prereleases out. Only releases are installed; there is no nightly channel.

The exit status is 0 on success, 1 when a command fails, and 2 for a mistake on the command line.

Where toolchains go

The Pepper home holds them:

~/.pepper/bin/pepper                 the entry point
~/.pepper/bin/pep-install
~/.pepper/toolchains/0.3.0/bin/      a toolchain's pep-build, pep-fmt, ...
~/.pepper/toolchains/0.3.0/runtime/  and its runtime
~/.pepper/default-toolchain          the default toolchain's version

The install scripts set up the home by running setup (see Setting a machine up): it installs the newest release this way and puts its pepper and pep-install in bin.

update replaces the home’s pepper and pep-install with the new toolchain’s when they differ. Each is written beside the old one and renamed into place, the old one first moved aside to <name>.old; on Windows, where a running program cannot be removed, that .old file is removed by the next update.

add --force and update --force install a toolchain that is already installed again, the way a fresh one is: downloaded, checked and unpacked beside its folder. The old folder is then moved aside, the new one put in its place and the old one removed, so no stale files remain, and a failed download or unpack leaves the old toolchain as it was. On Windows a toolchain one of whose programs is running (a pep-lsp the editor started, say) cannot be moved, and the install fails, leaving it in place.

The home is the folder above the running pep-install when that is a home (it has a toolchains folder), else PEPPER_HOME, else %LOCALAPPDATA%\pepper on Windows and ~/.pepper elsewhere.

Setting a machine up

setup is what the install scripts run, from the installer archive each release carries (pepper-install-<version>-<target>.tar.gz, .zip on Windows, holding only pep-install). It does what add and use do, then:

  • puts the toolchain’s pepper and pep-install in the home’s bin, as update does;
  • unless that folder is on the PATH already or --no-modify-path is given, adds it: on Windows to the user’s PATH, elsewhere with a line in ~/.profile, and in ~/.bashrc, ~/.bash_profile and ~/.zshrc when they exist (~/.zshrc also when the shell is zsh), and in fish’s conf.d/pepper.fish when fish is installed. Each line follows a # Added by the Pepper installer comment;
  • unless --no-prerequisites is given, checks for what linking a program needs and offers to install what is missing, asking on the terminal first unless --yes is given: Xcode’s command line tools on macOS, the Visual Studio Build Tools’ C++ workload on Windows (with winget), and clang when the check program’s build finds none (apt, dnf, yum, pacman or zypper on Linux, through sudo when not root; winget or Scoop on Windows). It notes when OpenSSL 3, which the tls module loads, is missing, and on macOS offers brew install openssl@3;
  • builds and runs a small program with the home’s pepper, from an empty folder, and fails when it cannot.

A toolchain from before side-by-side toolchains, which has no pepper to put in bin, cannot be set up. An archive named for no release (a dev build, pepper-dev-<target>) is installed under the version its pep-build reports.

uninstall removes the home, refusing a folder that holds neither toolchains nor bin/pep-build, and the PATH lines setup added for that home’s bin.

More toolchain folders

Toolchains are also found in the folders PEPPER_TOOLCHAIN_PATH lists, separated as PATH separates them (; on Windows, : elsewhere), and for pep-install in each --path folder, which come first. Each is laid out as the home’s toolchains folder is, a folder per toolchain named by its version:

/opt/pepper/0.3.0/bin/pep-build
/opt/pepper/0.3.0/runtime/
PEPPER_TOOLCHAIN_PATH=/opt/pepper pepper +0.3.0 build main.pep
pepper install --path /opt/pepper use 0.3.0

The home’s own toolchains folder is searched first, so a toolchain there wins over one of the same version elsewhere. list marks a toolchain found elsewhere with its folder; use, which and pepper +<version> take it as they take one in the home, and add and update count it as installed (--force installs into the home all the same). remove removes only toolchains in the home: a toolchain in another folder is left to whoever put it there. A project’s pep.toml cannot add folders, since where toolchains are differs from one machine to the next.

Editors

The VS Code extension runs the language server of the toolchain the workspace uses, as pepper install which reports it, and offers to install Pepper, the toolchain the workspace needs, or a newer default when they are missing or old; see pep-lsp.

Downloads

add and update find this machine’s archive (linux-x64, macos-arm64, macos-x64 or windows-x64) among the GitHub releases, as the install scripts do, download it and check it against its .sha256. The archive is unpacked beside the toolchain’s folder first, so a download that fails or does not match its checksum leaves nothing behind.

VariableWhat it does
PEPPER_HOMEWhere toolchains go, when pep-install does not run from a home
PEPPER_TOOLCHAIN_PATHMore folders to find toolchains in (see More toolchain folders)
PEPPER_INSTALL_RELEASES_URLLists the releases from this URL instead of GitHub’s API, for a mirror; it serves the same JSON
GITHUB_TOKEN, GH_TOKENA token sent to GitHub’s API while the repository is private, and to nothing else

The compiler: pep-build

pep-build compiles a Pepper program into an executable, or into a library for C code to link. pepper build runs it with the same arguments, so everything on this page applies to both spellings:

pepper build main.pep -o main          # the same as: pep-build main.pep -o main
pep-build [OPTIONS] [<entry.pep>]

This page lists every option. pep-build --help prints the same list in short, and pepper help build runs it.

All options at a glance

OptionWhat it doesDefault
<entry.pep>The program’s entry filethe project’s, from pep.toml
-I <dir>Add a folder to search for imported modulesthe runtime’s folder only
--import <name>Load a module into every file of the programnone
--entry <name>The function the program starts in, instead of main; it takes no parameters or one String[], and every main is left out (not with --test or --lib)main
--no-runtimeCompile without the standard librarythe runtime is used
-o <file>Name the executable or library (with --emit-static, the folder)<entry stem> in the current folder; a project’s name in its folder
--lib static, --lib sharedBuild a library for C instead of an executablean executable
--testBuild the [Test] functions of the entry file (or of several files of one folder) into a test programoff
--build <dir>Folder for intermediate files.pep/build (in a project’s folder when building it)
--emit-llvmWrite LLVM IR instead of an executableoff
--emit-static, --emit-static=keepWrite the source with each static block expanded, instead of compilingoff
--emit-ast[=text|json]Print the syntax tree instead of compilingoff
--emit-linkedPrint every file’s syntax tree after linking, as JSON, instead of compilingoff
--emit-checkedPrint what the checks after linking report, instead of compilingoff
--emit-modelPrint the program’s sema model before any sema pass, as JSON, instead of compilingoff
--emit-model-step <N>With --emit-model, print the model after the first N sema steps0
--emit-stepsPrint the names of the sema steps, one per line, instead of compilingoff
-O, -O0 to -O3Optimization level-O0, or PEPPER_OPT_LEVEL
--lto=full, --lto=offLink-time optimizationoff
-march=, -mcpu=, -mtune=, -m<feature>, -mno-<feature> (-m <option>)The CPU to generate code forclang’s default for the target
-g, --emit-debugEmit debug infooff
--debug-static=source, --debug-static=expandedWith -g, step through static blocks as written or expandedsource
--frame-pointersKeep every function’s frame pointer, for profilersoff
--target <triple>Compile for another platformthe machine compiling
--sysroot <dir>The target’s system librariesPEPPER_SYSROOT_<TRIPLE>
-l <lib>Link a C librarythe platform’s defaults
-L <dir>Add a folder the linker searchesPEPPER_LIBRARY_PATH
--MSVCUse the MSVC linker (not ported)off
-D <name>[=<value>]Define a build optionnone
--no-runtime-checks=<list>Remove kinds of runtime checkevery check on
--no-cacheDo not use the build cachethe cache is used
--cache <dir>Folder for the build cachePEPPER_CACHE_DIR, else .pep/cache (in a project’s folder when building it)
--verify-cacheCheck cache hits against fresh codeoff, or PEPPER_CACHE_VERIFY
--no-verify-cacheDo not check cache hits
-T <channel>, --TallPrint timings and cache decisionsoff
-v, --verboseAccepted; prints nothing more yetoff
--compile-part <dir>, --part <N>=<object>, --compilation-unit-worker <dir>Internal: what a build runs in the children it starts
-h, --helpPrint the options
-V, --versionPrint the version

An option that takes a value accepts it as the next argument or attached: -I lib, -Ilib, --target aarch64-apple-darwin and --target=aarch64-apple-darwin are all the same. -O, --emit-ast and --emit-static take a value only attached (-O3, --emit-ast=json), so the argument after them is never read as their value. Short options that take no value combine (-gv is -g -v). Options may come before or after the entry file, and every argument after -- is a file. An unknown option is an error, and the compiler exits with status 2 after a short usage message.

Inputs and imports

The entry file

pepper build main.pep

The file that holds main. The compiler finds the files it imports on its own (see Modules and imports), so a program of many files is compiled by naming its entry file alone. Only the first file named is compiled; any others are ignored.

With no file, the compiler builds the project whose pep.toml is in the current folder or the nearest folder above it: its [build] table names the entry file and the output, and the build goes in the project’s .pep (see Projects: pep.toml). With no file and no pep.toml, it stops with error: no source files provided, and no pep.toml in <folder> or a folder above it.

cd my_app/src
pepper build              # builds my_app/pep.toml's program into my_app/

An entry file inside a project (under a folder with a pep.toml) imports the project’s dependencies, and its [build] table’s include folders and link libraries apply to it as well.

Before compiling a program in a project, the compiler fetches whatever pep.lock lists and the project’s .pep/deps lacks or holds at another commit, and the dependencies pep.toml names that pep.lock does not list yet, by running pep-package fetch (beside pep-build, else on the PATH). Without pep-package, it checks the missing commits out with git itself, and asks for pepper package fetch to move a checkout at another commit. A dependency checked out on a branch is left as it is. See The package manager: pep-package.

-I <dir>

Adds a folder to search for modules imported by name (import geometry; looks for geometry/module.pep). The -I folders are searched in the order given, then a project’s [build] include folders, then the runtime’s folder. A project’s dependencies are found through its pep.toml instead. See Dependencies and search paths.

pepper build tools/pep-fmt/main.pep -o pep-fmt

--import <name>

Loads a module (<name>/module.pep) or a file (<name>.pep), found on the search paths, into every file of the program, the way the standard library’s prelude is loaded. Its definitions come ahead of the runtime’s, so one that redefines a runtime export wins. Repeat it for more modules. A name that cannot be found is an error that lists the folders searched. See The runtime prelude and --import.

--no-runtime

Compiles without the standard library: the runtime is neither looked for nor loaded, so only the program’s own files and what -I reaches are compiled. For testing the compiler; ordinary programs need the runtime (print, String and Array all live there).

Without this option the compiler finds the runtime as Getting started describes, and PEPPER_RUNTIME names its folder outright.

Output

-o <file>

The path of the executable, or with --lib of the library. Without it, the output goes to the current folder, named after the entry file with no extension (main for main.pep); with --lib, the library’s usual file name for that stem (libmain.a). Building a project with no file, it is the [build] table’s output, else the package’s name in the project’s folder (libname.a with --lib), with .exe added when building for Windows and the name has no extension. Windows runs only files ending in .exe, so otherwise name the output there: -o main.exe.

With --emit-static, it is the folder the expanded source goes into.

--lib static, --lib shared

Builds a library for C code to link instead of an executable, with a C header beside it that declares the program’s [Export]ed functions. No main is needed.

  • --lib static writes an archive: libname.a (name.lib on Windows), holding the objects of the whole program, the runtime’s included, with the index of their symbols that linkers search. The compiler writes it itself in the target’s format (GNU’s for Linux and Android, the BSD format Apple’s linkers read for macOS and iOS, the COFF archive for Windows), so no archiver needs to be installed, and building one for another platform needs no system root: nothing is linked. A program linking it also links the libraries the program does, which the header names in a comment (/* Link the static library with: -lm */).
  • --lib shared links a shared library: libname.so, libname.dylib on macOS, name.dll (and its import library name.lib) on Windows. Its soname is its file name; on macOS and iOS its install name is @rpath/<file name>, so a program that links it finds it on its run path. Linking one for another platform needs its system root, as an executable does (--sysroot).

The header goes beside the library, named for it without the lib prefix (libname.a has name.h). A library that exports nothing is built with a warning. See Calling Pepper from C.

--test

Builds the entry file’s tests into a test program, which pep-test runs. A build without it leaves every [Test] function out. With it:

  • the [Test] functions are kept, and the build option TEST is defined (as by -D TEST), for code only tests use ([BuildOption("TEST")]);
  • the runtime’s testing module is loaded into every file (as by --import testing);
  • the entry file’s main is replaced by one that answers --list (one line per test: name, file:line, returns or panics, and the text a [ShouldPanic] panic must contain, separated by tabs) and --run NAME [--timeout SECONDS] (run that test; a test still running after the timeout ends the program with exit status 124).

Only the entry file’s [Test] functions become tests. Given several files of one folder (pep-build --test a_test.pep b_test.pep), it builds the tests of them all into one program: a generated entry file in that folder (_pep_test_main.pep, never written to disk) imports each file and registers its tests, and each file keeps the scope it has in any program. --list then gives each test’s own file, and --run also takes FILE::NAME, as --list spells the file, to tell apart tests of one name in two files. It cannot be combined with --lib. See Unit tests.

--build <dir>

The folder for intermediate files: the program’s objects and, with --emit-llvm, its IR. The default is .pep/build in the current folder, or in the project’s folder when building a project with no file.

--emit-llvm

Writes the whole program’s LLVM IR to <build>/<name>.ll (.pep/build/main.ll for main.pep), optimized at the -O level, and stops there: nothing is linked and no executable is written.

pepper build main.pep -O --emit-llvm      # .pep/build/main.ll

--emit-static, --emit-static=keep

Writes a copy of the program’s source with each static block replaced by the code it emits, like a C compiler’s -E, and stops there: nothing is compiled to code. Use it to see what a reflection loop, a serializer or a library’s block does to the program (see Seeing what a static block emits).

pepper build main.pep --emit-static              # main.static/main.pep
pepper build main.pep --emit-static -o expanded  # expanded/main.pep
pepper build expanded/main.pep -o main           # the same program
  • Every file of the program in the entry file’s folder (and the folders under it) is written into the output folder at the same relative path, so the folder builds as a whole program; files with no block are copied unchanged. The runtime and libraries found elsewhere are not copied. The originals are never overwritten.
  • The folder is -o’s, else <entry stem>.static in the current folder.
  • Each expansion is a { ... } block between # static (out, value) expanded from main.pep:12:5 and # end static. --emit-static=keep also keeps the block as written, as # comments above it; --emit-static=expand is the default, the same as --emit-static.
  • A block in a generic function expands once per specialization. The generic function stays as written, and after it comes a concrete override for each type the program uses it with (describe<Point>(value: Point)), its blocks expanded, so the copy still builds into the same program. A block in a method of a generic struct, or in a generic method, stays, with each specialization’s expansion above it as # comments.
  • When pep-fmt is next to the compiler or on PATH, it formats the copies, wrapping long lines.
  • Errors a block reports stop it, as they stop a build. Errors in the code a block emits are found when the copy is built, at the copy’s line.

--emit-ast

Parses the entry file only and prints its syntax tree to standard output, instead of compiling. Imports are not followed and nothing is checked.

--emit-ast and --emit-ast=text print the tree as source-like text. --emit-ast=json prints it as one line of JSON: each node an object with its fields in declaration order, each enum case an object with the case’s name as its one key, and each span {"start": ..., "end": ...} in bytes. The compiler’s parser written in Pepper (tools/compiler) prints the same JSON, and its test compares the two over every source file in the repository.

--emit-linked

Loads the program as a build does (the entry file, every file its imports reach, and the runtime), links it, and prints it to standard output as one line of JSON instead of compiling. Nothing after linking is checked, so a program with type errors prints too; a file that cannot be read or parsed stops it, as it stops a build.

The JSON is an object with three keys:

  • files: every file in load order (the entry file first), each with its name as the program reached it, its namespace, its module_root folder (or null), whether it is part of the prelude (the runtime and --import files) or a prelude_part (another file of a forced module), the index of the file each of its imports names (or null when one did not resolve), which of them are folder_imports (a local import of a module folder), and its module, the syntax tree as --emit-ast=json prints it after linking renamed what the file imports and gave its private declarations their program-wide names (helper@io/file).
  • errors: the linking errors, by file and position, each with its file index, span, message, whether it is an unresolved import, and its note ([span, message], or null).
  • overload_unions: each overload set that spans files, by name, with its functions grouped by module.

-I, --import, --no-runtime, -D and --test apply as for a build. The compiler’s linker written in Pepper (tools/compiler) prints the same JSON, and its test compares the two.

--emit-checked

Loads and links the program as --emit-linked does, runs the checks that come before type checking, and prints what they report instead of compiling: the errors, rendered as a build reports them, then the warnings. The errors are the linking errors; when there are none, a [__BuiltIn] declaration outside the runtime; when there is none of those either, every use of a declaration another file keeps private or this file does not import. The warnings are deprecated syntax, comparisons with a build option -D does not define, and uses of declarations marked [Deprecated]. A program with neither prints an empty line.

-I, --import, --no-runtime, -D and --test apply as for a build. The compiler written in Pepper (tools/compiler) prints the same text, and its test compares the two.

--emit-model

Loads, links and checks the program as --emit-checked does, builds the sema model of each file and joins them, and prints the model as one line of JSON instead of compiling: what the sema builder made of the syntax trees (for, with, match, ?, destructuring, ranges, collection literals and properties lowered), before any sema pass runs. Object keys are sorted, and so are the sets (private_functions and the extension_scopes). When the checks or the builder reject the program, it prints the rendered error and fails.

-I, --import, --no-runtime, -D and --test apply as for a build. The compiler written in Pepper (tools/compiler) prints the same JSON, and its test compares the two.

--emit-model-step <N>

With --emit-model, runs the first N steps of the sema passes before printing the model. A step is one pass run, in the order the compiler runs them (platform filters first, then [Equatable], closure lifting and the rest); --emit-steps lists them. When a step rejects the program, it prints the rendered error and fails. The compiler written in Pepper runs the same steps, and its test compares the model after each.

--emit-steps

Loads, links, checks and builds the program as --emit-model does, runs every sema pass, and prints the name of each step run, one per line, instead of compiling: the numbering --emit-model-step uses.

Optimization and code generation

-O, -O<level>

The optimization level: -O0 (no optimization, the default), -O1, -O2 or -O3. -O alone is -O2, and it does not take the next argument as its level, so pepper build -O main.pep works. Each level runs the pipeline clang runs at that level. Without the option, the level is PEPPER_OPT_LEVEL (0 to 3) when that is set and not empty.

At -O0 a panic prints a backtrace after its message; at any other level it prints the message alone, unless the program runs with PEPPER_BACKTRACE=1 (see Backtraces on panic).

--lto=full, --lto=off

Link-time optimization. With --lto=full, once every file is compiled the whole program is optimized again as one module, so a call into another file (the runtime included) can be inlined. --lto=off optimizes each file alone, which links sooner. The default is off: full optimizes the whole program again on every build, an edit’s too, so it suits a release build. --lto=full at -O0 is an error that asks for -O.

With it, each file is optimized by LLVM’s pre-link pipeline and kept as bitcode (in the build cache, as an object is), then the compiler links them into one module, makes every symbol internal but main and the C exports of [Export] functions, runs LLVM’s link-time pipeline over the whole program, and writes its objects, one per logical CPU in parallel, which link as usual. No linker plugin is involved, so it works on every target. It applies to a library too (--lib).

-march=<cpu>, -mcpu=<cpu>, -mtune=<cpu>, -m<feature>, -mno-<feature>

The -m options choose the CPU the code is generated for, with clang’s names and meanings (-m <option>, with a space, is the same as -m<option>):

  • On x86-64, -march=<cpu> names the CPU: x86-64-v3, znver4, or native for the machine compiling.
  • On AArch64, -mcpu=<cpu> names the CPU (apple-m2, native) and -march=<arch> the architecture (armv8.2-a+crypto).
  • -mtune=<cpu> changes only how instructions are scheduled.
  • -m<feature> adds a CPU feature (-mavx2), and -mno-<feature> removes one (-mno-avx2).

Without them the program runs on any machine of its kind: clang’s default CPU for the target (x86-64; apple-m1 on Apple arm64). An option the target’s architecture does not take is an error worded as clang words it: -mcpu= on x86-64 (unsupported option '-mcpu=' for target ...; use '-march' or '-mtune'), -march= on WebAssembly, an AArch64 -march= that is not armv<version>-a (invalid arch name), native for another platform’s target, or an empty value. A CPU or feature name LLVM does not know is passed on, and LLVM warns about it. The last of each option wins; the features come after those the CPU implies. The chosen CPU, its features and its tuning are part of the build cache’s key.

-g, --emit-debug

Emits debug info for gdb, lldb, Visual Studio and WinDbg: DWARF 4 in the executable on Linux and macOS, and CodeView on Windows, where the linker writes <output>.pdb beside the executable. On macOS it also runs dsymutil to write <output>.dSYM beside the executable, when the program is built for the Mac it runs on. It works with -O. See Debugging.

A Windows build without -g also writes <output>.pdb, holding only the program’s function names, which is where backtraces read them.

--debug-static=source, --debug-static=expanded

With -g, chooses which source the debugger shows for the code a static block emits (see Stepping through a static block):

  • source, the default: the block as written. Stepping stops on a compile-time loop’s header once per pass, then on its body.
  • expanded: a copy of each file with its blocks expanded, as --emit-static writes it (unformatted), saved beside the output as <name>.static.pep (main.static.pep for main.pep; a file in a folder under the entry’s keeps that folder). The debugger steps through the expanded statements, sum = sum + p.y;, and a breakpoint on one of its lines stops in that pass. The rest of the program stays in its own files. A build with it does not use the build cache.
pepper build -g --debug-static=expanded main.pep -o main   # main, main.static.pep

--frame-pointers

Keeps the frame pointer in every function, even when optimizing, as clang’s -fno-omit-frame-pointer does. A sampling profiler then walks the stack of each sample by following frame pointers, which is fast and finds every frame of Pepper code; without them, optimized code reuses the register and a profiler loses the callers of whatever was running. It costs a register and a few instructions per call. pep-profile builds with it, -O and -g. It is part of the build cache’s key.

Targets and cross-compiling

--target <triple>

Compiles for another platform, named by an LLVM target triple: x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, aarch64-apple-darwin, x86_64-apple-darwin, x86_64-pc-windows-msvc, aarch64-pc-windows-msvc, wasm32-wasip1, aarch64-apple-ios, aarch64-apple-ios-simulator, x86_64-apple-ios-simulator, aarch64-linux-android or x86_64-linux-android, with a version allowed where clang allows one (arm64-apple-macosx14.0, aarch64-apple-ios17.0, aarch64-linux-android33). An unsupported triple is an error that lists the supported ones. [Target(...)] selects code for the target. The default is the machine the compiler runs on. See Cross-compiling and iOS and Android.

--sysroot <dir>

The folder that holds the target’s system headers and libraries, passed to the link driver, for linking with --target. Without it, the folder named by PEPPER_SYSROOT_<TRIPLE> when that is set: the triple in capitals, with _ for each character that is not a letter or digit (PEPPER_SYSROOT_AARCH64_APPLE_DARWIN for aarch64-apple-darwin). An iOS or Android triple’s variable leaves out the OS version (PEPPER_SYSROOT_AARCH64_APPLE_IOS_SIMULATOR for aarch64-apple-ios17.0-simulator). For iOS it is an iOS SDK, which on a Mac defaults to the one xcrun names; for Android it is the NDK (see ANDROID_NDK_HOME below).

Linking

-l <lib>

Links a C library, as -l does for clang: -l m links libm. Repeat it for more libraries. A project’s [build] table adds its link libraries. A program’s [Link] attributes name libraries from the source instead. See Linking.

-L <dir>

Adds a folder the linker searches for libraries. Folders given with -L come first, then those of [Link(path = ...)], then those in PEPPER_LIBRARY_PATH (separated like PATH: :, or ; on Windows). See Library search directories.

pepper build main.pep -L libs -l foo        # links libs/libfoo

--MSVC

Reserved for linking with Microsoft’s linker. It is not ported, so it is an error on every platform; Windows executables link with clang and lld.

The link driver is clang. When PEPPER_LLVM_PREFIX, else LLVM_SYS_<major>1_PREFIX (for the LLVM the compiler was built with, such as LLVM_SYS_201_PREFIX for LLVM 20), names an LLVM install, its bin/clang and lld are used; else a clang beside pep-build, then one on the PATH (clang, then clang-<major>), then one where LLVM’s packages install it off the PATH. With none, the link fails with an error naming what to install. Installing Pepper lists the folders searched.

Build options

-D <name>, -D <name>=<value>

Defines a build option. A bare name is name=1, as in C. [BuildOption] and [Target(BuildOption(...))] select code by it, and static blocks read it with reflect.build_option. Repeat it for more options; of two that name the same option, the later wins. A name must be an identifier (-D 2FAST is an error). Build options are part of the build cache’s key. See Build options.

pepper build main.pep -D USE_ZLIB -D LOG_LEVEL=2

Runtime checks

--no-runtime-checks=<list>

Integer overflow, division by zero, an oversized shift, an index out of range and a call through a null reference panic at run time, at every optimization level. This option removes the kinds of check it names, as a comma-separated list, from an optimized build:

KindWhat is no longer checkedWhat happens instead
overflowInteger overflowArithmetic wraps
divideDivision by zero and MIN / -1Undefined behavior
shiftA shift count of at least the widthThe count is masked
boundsArray and string indexingUndefined behavior
nullCalls and matches through a null referenceUndefined behavior
allAll of the above

Removing bounds or null gives up memory safety. At -O0 the option has no effect and prints warning: '--no-runtime-checks' has no effect at -O0. To remove checks from one function at every level, mark it [Unsafe("overflow,bounds")] instead. See Integer overflow.

pepper build -O main.pep --no-runtime-checks=overflow,shift

The build cache

The compiler checks a program one compilation unit at a time: a file, or the files that import each other in a cycle, with the runtime as one. The build cache keeps each compilation unit’s checked result and object, and each file’s own object: a file whose sources, and whose imports’ declarations, did not change is not compiled or checked again. PEPC_COMPILATION_UNIT_JOBS sets how many compilation units are checked at once (one per processor by default; PEPC_LAYER_JOBS, its old name, still works). The optimization level, link-time optimization, debug info, CPU, removed runtime checks and build options are part of each object’s key. Deleting the cache folder is always safe.

--no-cache

Compiles every file without reading or writing the build cache.

--cache <dir>

Keeps the build cache in <dir>. Without it, the folder named by PEPPER_CACHE_DIR when that is set and not empty, else .pep/cache in the current folder, or in the project’s folder when building a project with no file.

--verify-cache, --no-verify-cache

--verify-cache checks the cache, for testing the compiler: every file the cache would reuse is compiled anyway, and the build fails if its code differs from the cached object’s, or if skipping the reused files’ bodies changes another file’s code. Setting PEPPER_CACHE_VERIFY to anything but empty or 0 does the same. --no-verify-cache turns it off even when PEPPER_CACHE_VERIFY is set. The two options cannot be combined. It builds the program a second time, with an empty cache of its own in the build folder, and compares each object that build makes with the one the cache holds under the same key, so a check costs about a build from scratch. A failure names the cached objects that differ.

Tracing and diagnostics

-T <channel>, --Tall

-T turns on a trace channel, which prints to standard error. Repeat it for more channels; --Tall turns on every one.

  • -T time prints how long each stage of the compile took (time: check and build 470.763 ms).
  • -T passes prints how long each pass of the checker took, summed over its runs, slowest first (pass: 3116.171 ms 16x resolve_implicit_types), the passes run checking the runtime and each module included.
  • -T cache prints how the build cache supplied each object: hit (its sources, and its imports’ declarations, are unchanged), hit (same code) (compiled again, to the same code), or miss; and whether linking was skipped.

Any other channel name is accepted and ignored.

-v, --verbose

Accepted for compatibility with other compilers; it prints nothing more yet.

Errors and warnings are printed like clang’s, colored when standard error is a terminal; set NO_COLOR to turn color off. See Compiler errors.

Internal options

A build starts copies of the compiler to work side by side, and gives them these options; they are listed so a failed child’s command line can be rerun by hand (the build prints it).

  • --compile-part <dir> --part <N>=<object>... compiles parts of the program a build split into <dir>: each --part names a part by its number and the object to write it to. Nothing else is read.
  • --compilation-unit-worker <dir>, after the build’s own arguments, checks the compilation units the build passes through <dir> and compiles their objects.

Help and version

  • -h, --help prints every option with a line of description.
  • -V, --version prints pep-build and its version.

Environment variables

VariableWhat it doesOption that overrides it
PEPPER_RUNTIMEThe folder holding the standard library; nothing else is searched--no-runtime skips the runtime
PEPPER_OPT_LEVELThe optimization level (0 to 3) without -O; also the level the test suite’s JIT uses-O<level>
PEPPER_LIBRARY_PATHMore folders the linker searches, after -L and [Link(path = ...)]-L comes first
PEPPER_SYSROOT_<TRIPLE>The system libraries for linking with --target <triple>--sysroot
ANDROID_NDK_HOME, ANDROID_NDK_ROOTThe Android NDK for linking with an Android --target, when neither --sysroot nor PEPPER_SYSROOT_<TRIPLE> names one--sysroot
ANDROID_HOME, ANDROID_SDK_ROOTThe Android SDK, whose newest ndk/<version> links an Android --target when nothing above names an NDK--sysroot
WASI_SDK_PATHThe wasi-sdk for linking with --target wasm32-wasip1, when neither --sysroot nor PEPPER_SYSROOT_WASM32_WASIP1 names one--sysroot
PEPPER_CACHE_DIRThe build cache folder--cache, --no-cache
PEPPER_CACHE_VERIFYAnything but empty or 0 checks the build cache--no-verify-cache
PEPPER_LLVM_PREFIXThe LLVM install whose clang and lld link the program
LLVM_SYS_<major>1_PREFIXThe same, when PEPPER_LLVM_PREFIX is not set (LLVM_SYS_201_PREFIX for LLVM 20)
PEPPER_PACKAGE_GITHUB_URL, PEPPER_PACKAGE_GITLAB_URL, PEPPER_PACKAGE_CODEBERG_URLWhere a build fetching a project’s locked dependencies clones that host’s repositories from, when pep-package is not installed (see pep-package)
NO_COLORSet and not empty: errors are not colored

Exit status

pep-build exits with 0 when the program compiled, 1 when it did not (the errors are on standard error), and 2 for a command line it cannot parse. A crash of the compiler itself exits with 101 after printing what went wrong; please report it.

Projects: pep.toml

A pep.toml file marks the root of a project. It names the project, says how pepper build builds it, and lists the packages it depends on, so a project builds with pepper build and nothing else:

cd my_app
pepper build          # builds the program pep.toml describes
./my_app
[package]
name = "my_app"
version = "0.1.0"
description = "Draws shapes"
authors = ["Ada <ada@example.com>"]
license = "MIT"
repository = "https://github.com/me/my_app"

[build]
entry = "src/main.pep"

[dependencies]
geometry  = { github = "someone/geometry", version = "1.2" }
json      = { gitlab = "group/pepper-json", version = "0.4.x" }
fast_json = { github = "other/json", version = "2" }   # renamed: 'json' was taken
http      = { codeberg = "someone/http", branch = "main" }
shapes    = { path = "../shapes" }

A package is a project other projects depend on: a repository (or folder) with a pep.toml at its root. Its root module.pep is the module its dependency name imports, and any folder inside it holding a module.pep is a module of the package too.

[package]

KeyWhat it is
nameThe project’s name: letters, digits and _, not starting with a digit. Required. pepper build names the executable after it.
versionIts version, major.minor.patch
descriptionOne line saying what it is
authorsAn array of names
licenseIts license, such as MIT
repositoryWhere its source lives
toolchainThe toolchain it builds with: 0.3.0, 0.3 for the newest installed 0.3.x, or latest. A pepper installed by pep-install runs that toolchain’s tools in the project (see Toolchains); the compiler itself only checks that it is a version

[build]

pepper build with no file builds the project: it finds the nearest pep.toml in the current folder or a folder above it, and builds the [build] table’s entry file into its output. Every path is relative to the folder holding pep.toml, and the build’s intermediate files and cache go in that folder’s .pep, wherever pepper build runs.

KeyWhat it isDefault
entryThe file that holds mainmain.pep
outputThe executable; building for Windows adds .exe to a name with no extensionthe package’s name
includeAn array of extra folders to search for modules, as -I addsnone
linkAn array of C libraries to link, as -l addsnone

Options on the command line still apply: pepper build -O builds the project optimized, and -o names another output. A file argument builds that file instead (pepper build tools/gen.pep); the project’s dependencies, include folders and link libraries still apply to it, since it is in the project.

[dependencies]

Each key is the name source code imports, and its value says where the package comes from and which revision to use:

[dependencies]
geometry = { github = "someone/geometry", version = "1.2" }
import geometry;
import geometry.solids;     # the module in the package's solids/ folder

main(): int {
    print("{0} {1}", geometry.area(3, 4), solids.volume(2));
    return 0;
}
KeyWhat it is
github, gitlab, codebergThe repository, owner/name (on GitLab, group/subgroup/name too)
pathA folder on disk, relative to this pep.toml, instead of a repository
versionA semver range of the repository’s version tags: 1.2, 0.4.x, >=1.0, <2
branchA branch
tagA tag
commitA commit

A dependency has exactly one source, and at most one of version, branch, tag and commit; with none, it follows the repository’s default branch. A path dependency takes none of them.

Imports

import geometry imports the package’s root module, its module.pep. import geometry.solids imports the module in its solids folder, and binds the last name, so its declarations are solids.volume; as names it otherwise (import geometry.solids as s). A dotted import whose first name is not a dependency looks for the nested folder on the search paths: import a.b finds a/b/module.pep.

Because the name lives only in pep.toml, a package that moves to another host or organization, or a switch to a fork, changes one line of pep.toml and no source file. Two packages whose repositories share a name get different keys:

json      = { gitlab = "group/pepper-json" }
fast_json = { github = "other/json" }

Where dependencies live

A dependency from a Git host lives in the project’s .pep/deps/<host>/<owner>/<name>, such as .pep/deps/github.com/someone/geometry. One copy serves the whole project: each package’s own pep.toml names the packages it needs under its own keys, and an import in one of its files resolves against those keys, to the same copies.

pepper package fetches them there and records the commit of each, the dependencies’ dependencies included, in pep.lock beside pep.toml; commit pep.lock with pep.toml. pepper build fetches what pep.lock lists and .pep/deps lacks or holds at another commit, so a fresh clone builds with pepper build alone, with git or, where git is missing, by downloading each locked commit’s archive over HTTPS and checking it against the checksum pep.lock records. See The package manager: pep-package, which also says how versions are chosen when several packages need one.

pepper package add someone/geometry       # adds it to pep.toml and fetches it
pepper build

[fetch]

[fetch]
hosts = ["codeberg", "github"]
KeyWhat it isDefault
hostsThe hosts pepper package add owner/name looks a repository up on, in order, when the argument names none; it adds the first that has it["github", "gitlab", "codeberg"]

Errors

A problem in pep.toml is an error located at the key or value to change:

pep.toml:9:1: error: dependency 'io' has the name of the runtime module 'io'; give it another name, which source code then imports
    9 | io = { github = "someone/io" }
      | ^~
  • Two dependencies with the same name, or a dependency named like a runtime module (io, async), which it would otherwise hide.
  • A dependency name that is not an identifier (fast-json): it is the name import uses.
  • A dependency with no source or two, a repository not written owner/name, or more than one of version, branch, tag and commit.
  • A table or key pep.toml does not have, or a value of the wrong type.

An import of a dependency that is not on disk says which folder is missing:

main.pep:1:8: error: dependency 'geometry' is not fetched: no /home/you/my_app/.pep/deps/github.com/someone/geometry
    1 | import geometry;
      |        ^~~~~~~~

The language server reads pep.toml the same way, so imports of fetched dependencies resolve in the editor with no -I.

Running a program: pep-run

pep-run builds a program and runs it straight away, the way python script.py runs a script: there is no -o to choose and no executable to clean up. pepper run runs it with the same arguments.

pepper run hello.pep               # build hello.pep, then run it
pepper run args.pep a b            # main sees a and b
pepper run -O2 -D FAST main.pep    # build options go before the file
pepper run -- --port 8080          # the project pep.toml describes
pepper run --entry bench tool.pep  # start in bench() instead of main()
pep-run [BUILD OPTIONS] [<entry.pep>] [ARGUMENTS]...
pep-run [BUILD OPTIONS] -- [ARGUMENTS]...

Arguments

  • The arguments before the entry file are pep-build’s options (-O2, -D NAME, -I DIR, -l LIB, …; see The compiler: pep-build), and they apply as they do for pepper build, as do a project’s [build] table and its [Link] libraries.
  • The entry file is the first argument that ends in .pep and does not start with -. Everything after it goes to the program’s main, options included: in pepper run tool.pep --help, --help is the program’s.
  • -- ends the build options too. With no entry file, pep-run builds the project whose pep.toml is in the current folder or the nearest folder above it, as pepper build does, and the arguments after -- go to it.
  • The options that choose what pep-build writes are not taken: -o, --lib, --test, --target and the --emit-... options are an error that exits with status 2.
OptionWhat it does
--entry <name>Starts the program in the function name instead of main, as pep-build --entry does; it takes no parameters or one String[] (before the entry file only)
-h, --helpPrints the usage (before the entry file only)
-V, --versionPrints the version (before the entry file only)

How it runs

  1. pep-run runs pep-build (beside pep-run, else on the PATH) to build the program into a folder of its own: <Pepper home>/run/<key>/<entry stem> (main for a project), where the key is a hash of the current folder, the build options and the entry file, and the Pepper home is PEPPER_HOME, else ~/.pepper (%LOCALAPPDATA%\pepper on Windows; see Toolchains: pep-install).
  2. The build cache is PEPPER_CACHE_DIR when it is set, else <Pepper home>/cache, which every run shares, so the standard library is compiled once for all your scripts. When nothing changed, pep-build finds the program up to date and the run starts at once; when something did, only what changed is compiled again.
  3. A compile error is reported exactly as pepper build reports it, nothing runs, and pep-run exits with pep-build’s status.
  4. Otherwise the program runs in the folder pep-run was started in, so a relative path such as data.txt is found there, with pep-run’s standard input, output and error. On Linux and macOS it takes pep-run’s place, so signals such as Ctrl-C reach it directly; on Windows pep-run waits for it. Either way pep-run exits with the program’s exit status.

Two runs of one program at the same time are safe: the linker writes the executable to a temporary file and renames it into place, so neither ever starts a half-written program.

The run folders are not removed on their own; delete <Pepper home>/run to reclaim the space.

The package manager: pep-package

pep-package fetches a project’s dependencies and keeps its pep.toml and pep.lock. pepper package runs it with the same arguments:

pepper package add someone/geometry          # the newest version, imported as 'geometry'
pepper package add other/json@2.x --name fast_json
pepper package remove geometry
pepper package update                        # every dependency, as far as the ranges allow
pepper package update --verify               # one at a time, keeping the updates that build
pepper package fetch                         # what pep.lock lists
pep-package [-C <dir>] <command> [arguments]

It works on the project whose pep.toml is in the current folder or the nearest folder above it (see Projects: pep.toml). Packages are fetched with git when it is on the PATH; signing in to a private repository is git’s concern (its credential helpers and SSH keys), so whatever lets git clone reach a repository lets pep-package fetch it. Without git, pep-package downloads the host’s archive of each commit pep.lock pins over HTTPS instead (see Fetching without git), so a project with a pep.lock builds on a machine with no git.

pepper build fetches on its own what pep.lock lists and .pep/deps lacks, so a fresh clone of a project builds with pepper build alone.

Options and commands

Command or optionWhat it does
-C <dir>, --directory <dir>Works on the project at or above <dir> instead of the current folder
fetchFetches what pep.lock lists, and resolves what pep.toml changed since pep.lock was written
add <owner/name>[@<range>]Adds a dependency at its newest version, or at a range
add ... --name <name>, -n <name>The name source code imports it by
remove <name>Removes a dependency, by its name or its owner/name
update [<name>...]Moves dependencies to the newest versions their ranges allow; every one when none is named
update --verifyUpdates one dependency at a time and builds after each, keeping only the updates that build
-h, --helpPrints the options; after a command, that command’s
-V, --versionPrints the version

The exit status is 0 on success, 1 when a command fails, and 2 for a mistake on the command line.

fetch

Fetches every package in pep.lock into the project’s .pep/deps at its locked commit. When pep.toml names a dependency pep.lock does not list, or asks for a revision the locked one does not satisfy, fetch resolves it and updates pep.lock; everything else keeps its locked revision.

add <owner/name>[@<range>]

Adds a dependency to pep.toml’s [dependencies] and fetches it, keeping the file’s comments and layout. An argument that starts with github:, gitlab: or codeberg: (gitlab:group/pepper-json) names its host. Without one, add looks the repository up on each host in the order pep.toml’s [fetch] hosts lists (GitHub, GitLab, then Codeberg by default; see Projects: pep.toml) and uses the first that has it. When more than one does, it says so:

pep-package: other/numbers is on github and codeberg; adding github:other/numbers (prefix it with 'codeberg:' for another)
pepper package add someone/geometry        # geometry = { github = "someone/geometry", version = "1.4.2" }
pepper package add someone/geometry@1.2.x  # ... version = "1.2.x"
pepper package add codeberg:third/shapes   # only Codeberg, without looking elsewhere

Without a range, the dependency gets the newest version tag (as a range that accepts compatible later versions: 1.4.2 accepts 1.4.2 up to, not including, 2.0.0); a repository with no version tags follows its default branch. The name source code imports defaults to the repository’s last part, with - and . turned into _ (other/json adds json); --name gives another. A name already in pep.toml, or the name of a runtime module (io, async), is an error that suggests --name.

remove <name>

Removes the dependency with that name (or that owner/name) from pep.toml, and drops from pep.lock every package nothing needs any more.

update [<name>...]

Moves the named dependencies (every one, when none is named) to the newest version their ranges allow, and says which other package holds one back when one does:

pep-package: github:other/numbers stays at 1.3.1, not 2.0.0: geometry 1.1.0 wants 1

A dependency on a branch moves to the branch’s newest commit.

update --verify

Updates one dependency at a time and runs pep-build (beside pep-package, else on the PATH) on the project after each. An update that does not build is rolled back, and the command lists those it rolled back.

pep.lock

pep.lock, beside pep.toml, records the exact commit of every package in the dependency graph, its dependencies’ dependencies included, and the sha256 checksum of its files. Commit it with pep.toml: it is what makes a fresh clone build the same code.

version = 1

[[package]]
name = "geometry"
source = "github:someone/geometry"
version = "1.1.0"
commit = "0582024b9cd7a71044ee1aaa7099286f59e2df46"
sha256 = "7546a83a7f23d13454fd8b8f94281b6fd35f8dce49e9db9729c81208d35dacc8"

The checksum covers the files of the commit as the host’s archive of it holds them, not the archive’s bytes, which a host may compress differently from one download to the next. It is the SHA-256 of every file, symlink and hard link, in path order, each written as its kind (f, x for a file anyone may run, l, h), its path, its length and its contents. With git, pep-package computes it from git archive of the checkout, which holds the same files, so a lock written with git verifies a later fetch without it. A lock from before checksums were kept gains them on the next fetch.

A package whose files do not match is an error that names it and both checksums:

pep-package: error: cannot fetch github:other/numbers: the files of github:other/numbers 2c7a9e01d4f3 do not match pep.lock: sha256 9f2c..., expected 41b0...

Fetching without git

When git is not on the PATH, pep-package fetches each commit pep.lock pins as the host’s archive of it, over HTTPS, checks its files against the lock’s sha256 and unpacks it, without its top-level folder, into .pep/deps:

HostArchive
GitHubhttps://codeload.github.com/<owner>/<name>/tar.gz/<commit>
GitLabhttps://gitlab.com/<owner>/<name>/-/archive/<commit>/<name>-<commit>.tar.gz
Codeberghttps://codeberg.org/<owner>/<name>/archive/<commit>.tar.gz

An unpacked package has a .pep-commit file naming its commit in place of .git. Choosing a revision still needs git, since it reads which tags and branches a repository offers: add, update, and a fetch that must resolve a dependency pep.lock does not pin (or a tag, which may have moved) fail without it, saying so. Private repositories need git too, as authentication is git’s concern.

Resolving versions

The whole project uses one version of each package: one checkout of github:other/numbers serves every package that depends on it, whatever name each imports it by.

  • A version range picks the newest version tag (v1.2.3 or 1.2.3) that every package’s range accepts. When none does, resolution fails and names each package’s requirement and the versions the repository offers.
  • A branch, tag or commit must be the same in every package that names it.
  • The project’s own pep.toml overrides its dependencies: a package it names gets the revision it asks for, even outside the range a dependency declares, and pep-package says which requirement it overrode.
  • A locked revision stays while it still satisfies every requirement; update is what moves it.

Environment variables

VariableWhat it does
PEPPER_PACKAGE_GITHUB_URL, PEPPER_PACKAGE_GITLAB_URL, PEPPER_PACKAGE_CODEBERG_URLFetches that host’s repositories from another address (a mirror, or a folder of repositories as a file:// URL): <address>/<owner>/<name>.git
PEPPER_PACKAGE_GITHUB_ARCHIVE_URL, PEPPER_PACKAGE_GITLAB_ARCHIVE_URL, PEPPER_PACKAGE_CODEBERG_ARCHIVE_URLDownloads that host’s archives, when git is missing, from another http:// or https:// address (a mirror): <address>/<owner>/<name>/<commit>.tar.gz
PEPPER_RUNTIMEThe runtime folder whose modules a dependency may not be named like, as for the compiler

Building it

pep-package is written in Pepper (tools/pep-package) and reads and edits pep.toml and pep.lock with the runtime’s toml module, so building it needs no network:

pepper build tools/pep-package/main.pep -o pep-package

Formatting with pep-fmt

pep-fmt rewrites Pepper source in one standard style, so that every Pepper file reads the same and nobody has to argue about layout. It changes only whitespace, line breaks, the order of imports, and the form of function declarations; the program means exactly what it meant before.

pep-fmt is written in Pepper. It reads files with the Pepper compiler’s own parser (tools/compiler/parse), so it accepts exactly the syntax the compiler does.

Running it

Build it with the compiler, with the tools folder on the import path:

pepper build tools/pep-fmt/main.pep -o pep-fmt

Give it files or directories, like gofmt:

pep-fmt main.pep            # print the formatted file to standard output
pep-fmt -w main.pep src/    # rewrite each file that is not formatted
pep-fmt --check src/        # list each file that is not formatted
pep-fmt < main.pep          # no files: standard input to standard output

Options

pep-fmt [OPTIONS] [paths]...
OptionWhat it does
<paths>Files to format, and directories to search for .pep files; none means standard input
-w, --writeRewrite each file that is not formatted, instead of printing it
--checkPrint the path of each file that is not formatted, and exit with 1 if there is one
-h, --helpPrint the options
-V, --versionPrint pep-fmt and its version

-w and --check need at least one path. pepper fmt runs pep-fmt with the same arguments.

A directory stands for every .pep file under it, in name order, skipping directories whose names start with . (such as .git and the .pep build cache). -w (or --write) rewrites only the files whose formatting changes. --check changes nothing, prints the path of each file that -w would rewrite, and exits with status 1 if there is one, which suits a CI step. The two can be combined.

If a file does not parse, pep-fmt leaves it untouched, prints the first syntax error to standard error, and exits with status 1 after the other files. Standard input is named <stdin>:

main.pep:2:14: error: expected ')'

Errors the compiler finds after parsing, such as a number too large for its type, do not stop pep-fmt. A few mistakes that parse, but whose text the syntax tree does not keep (a second destructor, a second get or set in a property, a second else arm, a property’s value written before its accessors), are reported as cannot lay out this file and leave the file untouched.

A file that cannot be read or written also exits with status 1, and an unknown option, or -w or --check without a path, exits with status 2 after printing the usage (pep-fmt -h prints the full help). pep-fmt parses its options with the cli module.

The style

Layout. Indentation is 4 spaces. A brace goes on the same line as the declaration or statement that owns it, and else goes between the braces:

if count > 0 {
    print($"{count}");
} else {
    print("none");
}

A block stays on one line only when it holds a single break, continue or return and was written on one line: if done { return; }.

Line width. Lines are at most 120 columns. A list that does not fit (arguments, parameters, an import list) moves to its own indented line, and if it still does not fit, puts one item on each line:

report_values(
    first_argument, second_argument, third_argument, fourth_argument, fifth_argument, a_much_longer_argument
);
report_values(
    first_argument,
    second_argument,
    an_argument_so_long_that_the_whole_list_no_longer_fits_on_one_line_even_after_moving_it_down_one
);

A long expression breaks before each operator, and a chain of three or more method calls that does not fit breaks before each .:

if (first_condition_that_is_long(value) and second_condition_that_is_long(value)
    or third_condition_that_is_long(value)) {
    run();
}
total = first_value_that_is_long_enough
    + second_value_that_is_long_enough
    + third_value_that_is_long_and_longer_still;
var count = words
    .iterate()
    .filter((w) => w.length > 4)
    .map((w) => w.length * 2)
    .count_them_all();

Comments and string literals are never reflowed, so a line can stay longer than 120 columns when it has nowhere to break.

Attributes go one per line, each on its own line above the declaration:

[Target(OS(Linux))]
[Deprecated("use 'open' instead", "open")]
export open_file(path: String): int { return 0; }

A parameter’s attributes are the exception: they stay on the parameter’s line, in front of it, as in construct([Sending] T value).

Declarations. Pepper functions and methods are written with the parameter types after the names and the return type last, add(a: int, b: int): int. The C-style form, int add(int a, int b), is kept for extern functions, which mirror C prototypes; pep-fmt rewrites it everywhere else, including operator methods (bool ==(Point other) becomes ==(other: Point): bool, and bool !!() becomes !!(): bool), indexers (T [](ulong index) becomes [](index: ulong): T) and constructor parameters. A void return type is left out when the function has a body (void run() and run(): void both become run()); a declaration without a body, such as a trait method, keeps it.

Blank lines. At most one blank line in a row. Top-level structs, traits, extensions and functions with a body are separated by one blank line.

Comments stay where they are, attached to the code next to them. Trailing comments on consecutive lines are aligned, two spaces after the longest line:

var total = 0;      # the running sum
var count = 0;      # how many values
var average = 0.0;  # total / count

Imports that are next to each other (no blank line or comment between them) are sorted: modules first (import io;), then files of this module (import "util";), each by name.

Kept as written: parentheses around conditions (if x and if (x) are both fine), fields declared either way (int x; or var x: int;), and the file’s line endings (\n or \r\n).

Turning it off for a section

Code between a # format: off comment and a # format: on comment is left exactly as written, for layouts the style would undo, such as a table of numbers:

# format: off
const IDENTITY = matrix(1, 0, 0,
                        0, 1, 0,
                        0, 0, 1);
# format: on

Each comment goes on its own line, between declarations or statements, and the pair must be in the same block (or both at the top level). Without a # format: on, the region runs to the end of the block or file.

Profiling with pep-profile

pep-profile runs a program under a sampling profiler and writes a flame graph of where it spends its time: an SVG that any browser opens, with one box per function, as wide as the share of samples the function was on the stack for, above the function that called it. Hover over a box to see its numbers; click it to zoom in, and click “Reset zoom” to zoom back out.

pepper profile main.pep                     # build it, run it, write main.svg
pepper profile ./server -- --port 8080      # an executable, with its arguments
pepper profile --pid 4242 --duration 10     # a running process, for 10 seconds
pepper profile -i main.json -o main.svg     # draw a recording made earlier

pep-profile records with a profiler that is already installed rather than sampling the program itself:

ProfilerPlatformsInstall
samplyLinux, macOS, Windowscargo install --locked samply
perfLinuxyour distribution’s linux-tools (or perf) package
samplemacOScomes with macOS

It uses samply when it is installed, and otherwise perf on Linux or sample on macOS; on Windows, samply is the one supported. When none is installed it says what to install. --profiler picks one.

On Windows, samply records the system’s ETW events, which needs administrator rights: run pep-profile from an Administrator terminal (“Run as administrator”). Without them it stops at once and says so, rather than waiting on a UAC prompt. Drawing a recording with --input needs no rights.

pep-profile is written in Pepper (tools/pep-profile). Build it with the compiler, with tools/ on the import path, and put it next to pepper so that pepper profile finds it:

target/bin/pep-build tools/pep-profile/main.pep -o target/bin/pep-profile

Usage and options

pep-profile [options] <program.pep | executable> [-- arguments...]
pep-profile [options] --pid <PID>
pep-profile [options] --input <FILE>
OptionWhat it doesDefault
<program>The .pep file or executable to profile, then its arguments
-o <file>, --output <file>The flame graph to writethe program’s name with .svg
-F <hz>, --frequency <hz>Samples per second999
-d <seconds>, --duration <seconds>Stop recording after this longwhen the program ends; 10 s with --pid
-p <pid>, --pid <pid>Record a running process instead of starting one
-i <file>, --input <file>Draw a recording made earlier instead of recording
--profiler <name>Record with samply, perf or sample, or the profiler at a paththe first installed
--symbolizer <path>The llvm-symbolizer that names frames, or nonefound on the PATH
-l, --linesEnd each frame’s name with its file and lineoff
--idleKeep the samples of threads that were waiting, not running (samply)off
--title <text>The flame graph’s titleFlame graph: <name>
--renderer <name>What draws the flame graph: builtin, inferno or flamegraph.plbuiltin
--folded <file>Also write the folded stacks
--raw <file>Also keep the profiler’s own recording
-I <dir>, --import-path <dir>A folder pep-build searches for imports, when building a .pep file
--openOpen the flame graph when it is writtenoff
-h, --helpPrint the options
-V, --versionPrint the version

Give exactly one of a program, --pid and --input. The program’s own arguments go after it; put -- before them when any starts with -, so that pep-profile does not read them as its own options. pep-profile exits with 0 once the flame graph is written, whatever the program’s own exit status, with 1 when it cannot record or draw one, and with 2 for a usage error.

Profiling a program

Given a .pep file, pep-profile first builds it with pep-build, found next to pep-profile or on the PATH, as

pep-build main.pep -O -g --frame-pointers -o <scratch>/main

-O profiles the code that ships; -g gives the frames their names and lines; and --frame-pointers keeps the frame pointer in every function, so that the profiler walks each sampled stack quickly and completely (see pep-build). Pass -I for each folder the program imports from. An executable is run as it is: build it with the same options to get the same detail.

The program runs with the terminal’s standard input and output. Recording stops when it exits, or after --duration seconds. With perf, --duration stops the program with timeout; with samply and sample the program keeps running once recording stops, and sample then ends it.

--pid records a process that is already running, for --duration seconds (10 by default). samply and perf need permission to do that: on Linux, kernel.perf_event_paranoid must allow it (samply asks for 1 or lower) or the profiler must run as root; on macOS, sample can attach to your own processes.

samply also records each thread while it waits, as samples that used no CPU. On Windows, a thread pool that Windows starts in every process sits waiting the whole run, and alone would fill half the graph. So pep-profile leaves out every sample of a samply recording in which its thread used no CPU, and the graph shows where the program ran. --idle keeps them, to see where threads block. It changes nothing with perf or sample.

Names in the flame graph

Every frame is named the way the source names it:

  • A function or method by its name: work, Point.length, Box<int>.get. A generic specialization keeps its type arguments.
  • A closure by where it is written: closure at main.pep:14.
  • A function the optimizer inlined still has a frame of its own, inside the function it was inlined into.
  • With --lines, a frame also gives the file and line it is at: work (main.pep:17). A function then gets one frame per line that was running, which shows where in a long function the time goes.

These names come from the program’s debug info, read by llvm-symbolizer, which LLVM installs: pep-profile looks for llvm-symbolizer and its versioned names (llvm-symbolizer-20) on the PATH, then in $PEPPER_LLVM_PREFIX/bin, $LLVM_SYS_<major>1_PREFIX/bin and the usual LLVM install folders. --symbolizer names one, and --symbolizer none skips it. Without one, frames take the profiler’s symbol names, which pep-profile demangles: Pepper’s linker name _PArray_3cint_3e.count shows as Array<int>.count, and a closure as closure #0 in main. Inlined functions then have no frames of their own, and --lines adds nothing.

Recordings

--raw <file> keeps the profiler’s recording: samply’s profile (with its symbols beside it, in <file> with .syms.json in place of .json), perf’s perf.data, or sample’s report. A samply profile opens in the Firefox Profiler (samply load <file>), which shows the samples over time; perf.data opens in perf report.

--input <file> draws a flame graph from such a recording instead of making a new one, telling the format from what the file holds:

  • a samply profile (JSON);
  • a perf.data file (read with perf script), or the text perf script -F ip,sym,symoff,dso writes;
  • the report sample writes;
  • folded stacks, as --folded writes them.

Frames are named from the libraries the recording names, when they are still on this machine.

--folded <file> writes the folded stacks the flame graph is drawn from, in the “collapsed” format other flame graph tools read: one line per distinct stack, its frames from the outermost in, joined by ;, then a space and its sample count.

_start;__libc_start_main_impl;main;work;Box<int>.get 533

Renderers

The flame graph is drawn by a renderer built into pep-profile, so it needs nothing else installed. --renderer inferno draws it with inferno’s inferno-flamegraph (cargo install inferno), and --renderer flamegraph.pl with Brendan Gregg’s flamegraph.pl, each found on the PATH, for their extra features such as search.

Testing with pep-test

pep-test builds the [Test] functions of Pepper files and runs them, each in a child process of its own, reporting which pass. Writing tests is covered in Unit tests.

pepper test                          # every test under the current folder
pepper test src/words_test.pep       # the tests of one file
pepper test -f words src             # only tests whose name contains "words"
pepper test --list src               # list them without running them
pepper test -I . geometry/tests      # tests that import the module geometry
running 3 tests
test counts_the_words ... ok (4 ms)
test an_empty_string_has_no_words ... ok (3 ms)
test reading_past_the_last_word_panics ... ok (5 ms)
3 passed, 0 failed

pep-test is written in Pepper (tools/pep-test). Build it with the compiler and put it next to pepper and pep-build, so that pepper test finds it and it finds the compiler:

target/bin/pep-build tools/pep-test/main.pep -o target/bin/pep-test

Usage and options

pep-test [options] [path]...
OptionWhat it doesDefault
<path>A .pep file to test, or a folder to search for files holding [Test] functions; repeatablethe current folder
-f <text>, --filter <text>Run only the tests whose name contains the text; repeated, any of themevery test
-l, --listList the tests instead of running them
-j <count>, --jobs <count>How many tests run at onceone per logical CPU
-t <seconds>, --timeout <seconds>Fail a test still running after this long; 0 for no limit60
-I <dir>, --import-path <dir>A folder pep-build searches for imports; repeatable
-L <dir>, --library-path <dir>A folder the linker searches for libraries, for pep-build; repeatable
-D <option>, --define <option>A build option for pep-build, NAME or NAME=VALUE; repeatable
-h, --helpPrints the usage and these options
-V, --versionPrints pep-test and its version

The exit status is 0 when every test program builds and every test passes, 1 when a test fails or a program does not build, and 2 for a usage error.

What it does

  1. Finds the files. A file named on the command line is tested as it is. In a folder, and the folders below it (except those whose names start with .), it takes each .pep file with a line that starts with [Test].
  2. Builds one test program per folder, of that folder’s test files, with pep-build --test (found next to pep-test, else on the PATH), in parallel. Each file keeps the scope it has in any program, so test files in a module’s folder see each other’s names, and a helper two of them declare must be private (see Unit tests). A program that does not build is reported with the compiler’s errors, and the other folders’ tests still run.
  3. Lists each program’s tests with --list and keeps those that match --filter. With --list it prints them and stops: the test’s name, or file::name when the tests come from more than one file.
  4. Runs each test as program --run FILE::NAME --timeout SECONDS, each in its own process, up to --jobs at once, and prints test NAME ... ok (TIME) or ... FAILED (TIME) as each ends (in order with --jobs 1). TIME is how long the test’s process ran: whole milliseconds (12 ms), or seconds with two decimals from one second up (1.25 s). A test passes when its process exits with 0; a [ShouldPanic] test passes when its process panics with a message that contains the attribute’s text.
  5. Reports the failures, each with what the test printed (the panic’s message and location) and why it failed, then a summary: 2 passed, 1 failed, with the number of tests --filter left out and of test programs that did not build when there are any.

The test programs are built in a temporary folder that is removed at the end. A test runs in the current folder, with PEPPER_BACKTRACE=0 unless PEPPER_BACKTRACE is set, so a panic prints its message without a backtrace. Each test gets a temporary folder of its own (TMPDIR, and TMP and TEMP for Windows, name it), removed when the test ends, so what a test that panics or times out leaves there, such as a TempFolder whose destructor never ran, is removed too.

The test program

pep-build --test builds a folder’s test program (see pep-build), which pep-test runs with one of two requests:

  • --list prints a line per test, its fields separated by tabs: the name, file:line, returns or panics (a [ShouldPanic] test), and the text the panic must contain.
  • --run NAME [--timeout SECONDS] runs one test (NAME, or FILE::NAME with the file as --list gives it) and exits with 0 when it returns having released everything it allocated; a test that leaves an allocation behind (or releases one too many) exits with 1 and says so on standard error. A panic aborts the program; a test that runs past the timeout ends it with exit status 124. However the program ends (the test returns, panics, crashes or times out), every process the test started ends with it (a job object on Windows, a process group elsewhere): one left running would hold the pipes to its output open, and pep-test would wait on it.

On Windows, a test program turns off Windows Error Reporting for its crashes and abort(), so a test that panics or crashes ends at once, without a dialog.

The language server: pep-lsp

pep-lsp is Pepper’s language server. An editor starts it and talks to it over standard input and output with the Language Server Protocol: it reports compiler errors and warnings as you type, and provides hover, completion, go to definition, document outlines and inlay hints. It checks code with the compiler’s own front end, and finds the standard library the way the compiler does (see Where the compiler finds the standard library).

Warnings in the files a document imports show on those files too, such as a use of a deprecated function in a library the document calls. A file open in the editor shows its own analysis instead.

The VS Code extension bundles it. When pepper install manages your Pepper home, the extension instead runs the pep-lsp of the toolchain the first workspace folder uses, as pepper install which reports it there, so the editor checks code with the same compiler and standard library as pepper build. If that fails (say the project pins a toolchain that is not installed), a warning says why and the bundled server runs. Run Pepper: Restart Language Server after switching toolchains.

The extension also offers to install what is missing, running it as a task whose terminal shows the progress and any error, then offers to reload the window to pick the toolchain up:

  • with no Pepper home, Install Pepper runs the install script;
  • when the toolchain the workspace uses is not installed, Install runs the pepper install add <version> (or pepper install update) that pepper install which suggests;
  • when the default toolchain is older than the extension, Update runs pepper install update. A toolchain a project pins, or PEPPER_TOOLCHAIN names, is left as it is.

Don’t Ask Again stops an offer for good. For another editor, run the toolchain’s pep-lsp (or pepper lsp) for .pep files.

The server is written in Pepper, in tools/pep-lsp, on the lsp package, a framework for writing a language server for any language in Pepper. To build it from a checkout of the repository, with this checkout’s standard library:

PEPPER_RUNTIME=$PWD/runtime pep-build -O tools/pep-lsp/main.pep -o target/pep-lsp/pep-lsp

When the server crashes

pep-lsp runs the server in a child process (pep-lsp --serve) and passes messages between the editor and it. If the server crashes (a bug in the compiler or the server ends it with a panic), pep-lsp answers the requests it left unanswered with an empty result, logs the server ended ...; restarting it to the editor’s output, and starts a new one, which gets the open documents again. If the new one crashes too before you change anything, the document you changed last is left out until you change it again, so one text that crashes the server cannot keep it down; after five crashes in a row pep-lsp gives up and exits.

Checking as you type

The server checks a file as the root of its own program, the way pepper build checks one: one compilation unit at a time, starting with the standard library and working up through each file’s imports. A compilation unit is one file, or the files that import each other in a cycle. It saves what it learned about each compilation unit in the build cache, in the .pep/cache folder of the file’s project (the folder holding its pep.toml, else the file’s own folder), or in $PEPPER_CACHE_DIR when that is set. When you edit a file, only the file you are editing is checked again. Every other compilation unit loads from the cache, unless its files changed on disk or a declaration it depends on changed. The cache outlives the server, so a restarted editor answers quickly once the files have been checked once. Delete the folder to start over.

The first time, when the cache has nothing yet (it lacks even the standard library’s compilation unit), the server checks the whole program at once, which is quicker than one compilation unit after another, and meanwhile a copy of itself (pep-lsp --fill-cache) fills the cache: it checks the compilation units the file depends on in parallel, in more copies (pep-lsp --compilation-unit-worker), one per processor at most, as pepper build does. Until it is done, an edit is checked again on the record of the whole program the first check left: only the bodies and declarations the edit changed or added are checked. An edit that does more, or that has an error or a warning, waits for the copy, then loads the compilation units it checked. When the cache has some compilation units but not others, the server checks the missing ones that way itself.

Command-line options

pep-lsp [--stdio]
OptionWhat it does
--stdioTalk over standard input and output: the default, accepted for editors that pass it
--serveInternal: run the server in this process, without the supervisor that restarts it. The supervisor starts it so
--compilation-unit-worker <dir>Internal: check compilation units for the server that started it, which passes them through <dir>. The server starts these itself. --layer-worker is its deprecated spelling
--fill-cache <dir>Internal: check the compilation units the build cache lacks, in --compilation-unit-worker copies, for the server that started it, which passes the program through <dir>. The server starts it itself when the cache is empty
-h, --helpPrint the options and exit
-V, --versionPrint pep-lsp and its version, and exit

Any other argument is an error, and the server exits with status 2 without starting.

Initialization options

The server’s settings arrive in the initializationOptions of the LSP initialize request, read once when it starts:

{
  "inlayHints": { "implicitConversions": true, "parameterNames": true },
  "buildOptions": ["USE_ZLIB", "LOG_LEVEL=2"]
}
OptionTypeDefaultWhat it does
inlayHints.implicitConversionsbooleantrueShow the constructor call an [Implicit] conversion inserts, such as Path( … ) around a String passed where a Path is expected
inlayHints.parameterNamesbooleantrueShow the parameter name before a literal argument passed by position (resize(width: 800, height: 600))
buildOptionsarray of strings[]The build options to analyze with, as the compiler’s -D takes them: NAME or NAME=VALUE. One that does not parse is left out. A document that declares a [Test] function is also analyzed with TEST, and testing loaded, as pep-test builds it

VS Code settings

The extension passes its settings to the server as the initialization options above, and restarts the server when one changes:

SettingDefaultWhat it does
pepper.lspPathemptyThe pep-lsp to run; empty uses the project’s toolchain’s, else the one bundled with the extension. The server restarts whenever that file is rebuilt
pepper.inlayHints.implicitConversionstrueinlayHints.implicitConversions
pepper.inlayHints.parameterNamestrueinlayHints.parameterNames
pepper.buildOptions[]buildOptions

Inlay hints also need VS Code’s own editor.inlayHints.enabled. The command Pepper: Restart Language Server (pepper.restartServer) restarts the server on demand.

The documentation builder: pep-doc

pep-doc builds this book. It generates the standard library reference from the runtime’s documentation comments, checks the book, and renders it with mdBook, which must be on the PATH (a binary from its releases).

It is a Pepper program (tools/pep-doc), and it works on the Pepper checkout that holds the folder it is run in. scripts/pepper-tests.sh builds it beside pep-build, with the other Pepper tools, and pepper doc runs it. To build it alone, from the repository root:

pepper build -O tools/pep-doc/main.pep -o target/bin/pep-doc
target/bin/pep-doc reference
pep-doc [<command>] [options]

Commands

pep-doc reference

Regenerates docs/src/reference/ and the reference’s entries in docs/src/SUMMARY.md from the doc comments under runtime/, and prints each file it changed. Run it after changing a runtime doc comment, and commit the result.

pep-doc check

Changes nothing, and fails (exit status 1) when:

  • the committed reference does not match the runtime, naming the stale pages;
  • a chapter has Markdown that renders broken without an mdBook error, such as an include indented inside a list item;
  • a public declaration in runtime/ (or tools/syntax) has no doc comment, or one without a description or a tag the documentation standard requires, as a clang-style error at the declaration;
  • a tool’s option, command, option value, trace channel, environment variable or setting is missing from the tool’s page in this book, naming the tool and the page;
  • a Pepper tool’s version() (in tools/<tool>/main.pep, or pep-lsp’s in tools/pep-lsp/arguments.pep) is not the version in the repository’s VERSION file, which scripts/set-version.sh sets them all to.

The tools’ options come from their --help: pep-build‘s and the Pepper tools’ (pepper, pep-fmt, pep-profile, pep-package, pep-install, pep-run, pep-test), which scripts/pepper-tests.sh builds in target/pepper-tests/bin beside a link to its pep-build, so run it first.

  • --tools <DIR>: the folder holding the built tools (default: target/pepper-tests/bin under $PEPPER_TARGET_DIR or target/).

CI runs it, after the tests.

pep-doc build

Regenerates the reference, renders the book into <site>/<version>/, and refreshes the site’s versions.json and index.html. Other versions in the site are left as they are, so every published version stays available. This is the command when none is given.

  • --version <VERSION>: the version to build, X.Y.Z or dev (the default). Anything else is an error.
  • --site <DIR>: the site folder (default: target/docs-site).
pep-doc build --version 0.3.0 --site site     # renders into site/0.3.0/

pep-doc serve

Regenerates the reference and serves the book locally with mdbook serve, opening it in the browser and rebuilding as chapters change.

pep-doc help

Prints the commands and options, as -h and --help do.

Options

OptionWhat it does
--version <VERSION>With build: the version to build
--site <DIR>With build: the site folder
-h, --helpPrint the commands and options
-V, --versionAs the only argument: print pep-doc and its version

An unknown command is an error. Each command exits with 0 on success and 1 on failure, with the reason on standard error.

See Building this documentation for how the book is organized and published.

Debugging

Build with -g to debug a Pepper program in gdb, lldb, Visual Studio or WinDbg. The compiler then describes the program to the debugger, which can:

  • stop at a file:line breakpoint;
  • step through the source one statement at a time;
  • show a backtrace with the source line of every Pepper frame;
  • print parameters, locals, module-scope variables and struct fields.
pepper build -g main.pep -o main        # Linux: DWARF in the executable
pepper build -g main.pep -o main        # macOS: DWARF, and main.dSYM beside it
pepper build -g main.pep -o main.exe    # Windows: CodeView, and main.pdb beside it

On macOS the linker leaves the DWARF in the object files, so -g also runs dsymutil (from Xcode’s command line tools) to gather it into main.dSYM. lldb finds the bundle beside the program; so do the program’s own backtraces, which read their source lines from it. Without dsymutil the program still builds, and its backtraces name functions but not lines.

-g and -O work together. Optimized code still steps and shows a backtrace, and a call the optimizer inlined still appears as its own frame. A variable the optimizer removed shows as optimized out.

A session

This program builds an array of points and adds up the first one:

# Build with `-g` to debug this program: break on line 18 and print `first`.
struct Point {
    var x: int = 0;
    var y: int = 0;
}

total(p: Point): int {
    return p.x + p.y;
}

main(): int {
    var points = Array<Point>();
    var p = Point();
    p.x = 1;
    p.y = 2;
    points.append(p);
    var first = points[0];
    return total(first) - 3;
}

Stop on line 18, after first is assigned, and look around. In gdb:

$ pepper build -g debugging.pep -o debugging
$ gdb ./debugging
(gdb) break debugging.pep:18
(gdb) run
Breakpoint 1, main () at debugging.pep:18
18	    return total(first) - 3;
(gdb) info locals
points = 0x5555555592f0
p = 0x5555555592c0
first = 0x5555555592c0
(gdb) print *first
$1 = {__refcount = 3, __weakcount = 1, x = 1, y = 2}
(gdb) step
total (p=0x5555555592c0) at debugging.pep:8
8	    return p.x + p.y;
(gdb) backtrace
#0  total (p=0x5555555592c0) at debugging.pep:8
#1  main () at debugging.pep:18

lldb takes the same steps with b debugging.pep:18, r, frame variable and bt. In Visual Studio, open the executable as a project (File > Open > Project/Solution), set a breakpoint in the source file and start debugging. The .pdb beside the executable is found automatically.

What the debugger sees

  • Functions under the names you wrote: total, Point.length, Array<int>.append. Methods the compiler writes for a struct, such as __acquire and __release, are marked artificial and placed on the struct’s line. A closure is named for where it is written: closure at main.pep:14.

  • Structs. A variable of a struct type holds a reference, so the debugger shows an address. Dereference it (print *first) to see the fields. The fields include the __refcount and __weakcount head, which helps when chasing an ownership bug.

  • Numbers under their Pepper names (int, ulong, u8).

  • Enums. A plain enum shows its case by name. An enum with payloads shows as a struct holding its tag, __tag, whose value names the case (Rect), and the payload fields of every case, each named Case__field (Rect__width), which a debugger expression can name: print shape.Rect__width. With Pepper’s formatters loaded (see below) it shows only its case and that case’s values instead. A value enum ([Value], such as Optional and Result) is that struct itself, held inline, with no reference counts.

  • Tuples as a struct with members 0, 1, and so on.

  • Trait values as the object they refer to and its vtable.

  • Generics. Each instantiation is its own type, such as Array<long>.

  • Module-scope variables under their names.

  • String, Array and Optional read naturally once the debugger loads Pepper’s formatters (see below): a String as its text, an Array as its count and elements, an Optional as its case, Empty or Value, and its value. A String and an Array also show their reference counts as [ref count] (strong references) and [weak count].

  • Other enums with payloads read the same way with the formatters: as their case and that case’s values, such as Rect with width and height.

Stepping through a static block

A static block unrolls its compile-time loops into one pass per element, and the debugger steps through those passes as it would a loop that runs: it stops on the for (or while) line at the start of each pass, then on each line of the body. A compile-time if stops once on its line, then in the branch it kept. The compile-time variables (the loop’s field, a var of the block) are locals of each pass, so the debugger shows which pass it is in:

# Build with `-g` and step through `total`: each pass of the `static`
# block's loop stops on the `for` line, then on its body, with `field`
# holding the pass's member.
struct Point {
    var x: int = 1;
    var y: int = 2;
    var z: int = 3;
}

total(p: Point): int {
    var sum = 0;
    static (sum, p) {
        for (var field in reflect.properties<Point>()) {
            sum = sum + field.get(p);
        }
    }
    return sum;
}

main(): int {
    return total(Point()) - 6;
}
$ pepper build -g debugging-static.pep -o debugging-static
$ gdb ./debugging-static
(gdb) break total
(gdb) run
(gdb) next
11	    var sum = 0;
(gdb) next
13	        for (var field in reflect.properties<Point>()) {
(gdb) next
14	            sum = sum + field.get(p);
(gdb) print field
$1 = {name = 0x55555559073f "x", type_name = 0x555555590a57 "int", index = 0, is_last = false, is_const = false, is_property = false}
(gdb) next
13	        for (var field in reflect.properties<Point>()) {
(gdb) next
14	            sum = sum + field.get(p);
(gdb) print field.name
$2 = 0x5555555909ba "y"

To step through the code each pass emits instead, with the compile-time values filled in, build with --debug-static=expanded. Beside the program it writes debugging-static.static.pep, the source with the block expanded as --emit-static writes it, and the debugger shows that file for the block’s code:

$ pepper build -g --debug-static=expanded debugging-static.pep -o debugging-static
$ gdb ./debugging-static
(gdb) break debugging-static.static.pep:18
(gdb) run
Breakpoint 1, total (p=0x55555559c6b0) at debugging-static.static.pep:18
18	            sum = sum + p.y;
(gdb) print field.name
$1 = 0x5555555909ba "y"

The rest of the function, and the program, stays in its own files.

A breakpoint on a line of the body stops in every pass. A member or case shows as a structure of what reflect reads from it (name, index, is_last, and so on); a list does not show. The stop on the header is a nop the compiler adds only to unoptimized -g builds; optimized code steps through the body’s lines without it.

Formatters for the runtime’s types

The runtime ships formatters for each debugger in runtime/debuggers/. The sessions below stop in a program holding var name = "pepper";, an Array<int> values of 3 and 4, and var found = Optional<int>.Value(7);.

  • gdb. On Linux a -g program carries gdb_printers.py in its .debug_gdb_scripts section. gdb loads it from directories on its auto-load safe path, so add the program’s directory to it, on the command line or in ~/.gdbinit:

    gdb -iex "add-auto-load-safe-path ." ./debugging
    
    (gdb) print name
    $1 = "pepper" = {[ref count] = 1, [weak count] = 1}
    (gdb) print values
    $2 = Array<int> of count 2 = {[ref count] = 1, [weak count] = 1, [0] = 3, [1] = 4}
    (gdb) print found
    $3 = Value = {value = 7}
    
  • lldb. Import lldb_formatters.py, once per session or in ~/.lldbinit:

    (lldb) command script import runtime/debuggers/lldb_formatters.py
    (lldb) frame variable name *values found
    (String *) name = 0x00005555555592c0 "pepper"
    (Array<int>) *values = count=2 {
      [ref count] = 1
      [weak count] = 1
      [0] = 3
      [1] = 4
    }
    (Optional<int>) found = Value {
      value = 7
    }
    
  • Visual Studio and WinDbg. A -g program’s .pdb carries pepper.natvis, and an entry for each of the program’s enums with payloads, so nothing needs loading.

Building with -g

Debug info changes the code each file compiles to, so -g builds keep their own objects in the build cache, apart from builds without it. With -g, editing a file recompiles the files that import it, even when the edit only moves a declaration: their code records the line numbers of the generics they instantiate from it.

Backtraces without a debugger

A program can print its own stack: a panic does in a build without -O, and the debug module’s capture() and print_backtrace() do on request. debug.output, debug.is_debugger_attached and debug.breakpoint talk to an attached debugger. See Backtraces and the debugger.

Standard library reference

The standard library is the Pepper runtime: the files under runtime/ that every program is compiled with. The pages listed first (from Built-in functions to URI) are the prelude, available without an import. Each module after them (io, async, json, …) is brought in with an import, such as import io or import [File, Path] from io; its other pages are listed under it.

These pages are generated from the doc comments in the runtime’s source (see Documentation comments), so they always match the version of Pepper they were built with. Use the version picker at the top of the page to read the reference for another release.

The prelude

The prelude is the part of the standard library every program gets without an import: the files at the top of runtime/. Each module after it (io, async, json, …) is brought in with an import.

  • Built-in functions: assert and panic, which every program can call without an import.
  • Built-in attributes: The built-in attributes, declared as [Attribute] structs so a static block can read them like its own: field.has_attribute<Deprecated>() tests for one and function.attribute<Deprecated>().message reads its value (see the reflection chapter).
  • The reflect module (compile time): reflect exists only inside a static block, which runs while compiling.
  • Compiler built-ins: The types and functions the compiler provides itself: raw pointers, weak references, atomics, checked arithmetic and the other __ intrinsics the runtime is built on.
  • Arguments and exit (deprecated): The running program’s arguments and exit, kept for the deprecated prelude arguments() and exit(); the process module has them now.
  • String: A mutable, reference-counted string of UTF-8 text.
  • Numbers: Reading numbers from text, and arithmetic that says what to do on overflow.
  • Comparable: Ordering: Comparable, the trait of types whose values sort in one order, which every number type, char and String declare.
  • Ranges: Ranges of numbers: a..b and a..>b.
  • Array: A growable, contiguous, zero-indexed sequence of T values.
  • HashMap and HashSet: Hashing: hash<T>(value), the 64-bit hash HashMap and HashSet use for their keys, and the hash maps and sets themselves.
  • Map and Set: Sorted collections: Map<K, V> and Set<K>, kept in key order on a red-black tree, and compare<T>, the ordering they sort keys by.
  • OrderedMap: OrderedMap<K, V>: a hash map that keeps its keys in the order they were first inserted.
  • BitSet: BitSet: a set of small non-negative integers, stored one bit each.
  • Deque: Deque: a double-ended queue over a growable ring buffer.
  • PriorityQueue: PriorityQueue: a binary heap that hands out its greatest element first.
  • Iterators: Something a for loop can step through, one element at a time.
  • Scoped: The scope protocol behind with (var x = scope) { ... } else { ... }.
  • Weak: Weak references: Weak<T> points at an object without keeping it alive.
  • Handle: Handle<T>: owns a resource that Pepper’s reference counting does not know about, such as a C library’s object, and releases it when the last reference to the handle goes away, like C#’s SafeHandle.
  • Result: Result<T, E>: the value of an operation that can fail, either Ok holding its value or Err holding its error, like Rust’s Result.
  • Optional: A value of type T that may be absent: either Value, holding a T, or Empty, holding nothing, like Rust’s Option<T> and Swift’s Optional.
  • Copying: Deep copies: copy(value) makes a copy that shares no object that is not thread-safe with the original, so it can be handed to another thread; Copyable<T> lets a type say how it is copied.
  • URI: URI references (RFC 3986): parse any URI, such as https://example.com/a?b#c, mailto:someone@example.com or /items?page=2, into its parts.
  • Test checks: Checks for tests: expect_equal, expect_not_equal, expect_true and expect_false, which every program can call without an import.

Built-in functions

assert and panic, which every program can call without an import.

Source: runtime/internals.pep

assert

assert(condition: bool, message: String)

Panics with message when condition is false; otherwise does nothing.

The check always runs; there is no release mode that removes it. A string literal message picks the __ptr<char> overload instead, which builds no String. Any other message is evaluated before the call.

Parameters

  • condition — the condition that must hold
  • message — the text to print on failure; a null String prints a generic message

Panics

  • if condition is false: prints the location and message, and aborts (see panic)

Example

assert(count > 0, "count must be positive");

assert

assert(condition: bool, message: __ptr<char>)

Checks that condition holds; if not, panics with message, a NUL-terminated C string. A string literal message picks this overload, so an assertion that holds builds no String.

Parameters

  • condition — the condition that must hold
  • message — the text to print on failure, or __null for a generic message

Panics

  • if condition is false: prints the location and message, and aborts (see panic)

Example

assert(count > 0, "count must be positive");

panic

panic(message: String)

Prints message and aborts the program. It never returns.

Flushes every C output stream, so output printed so far is not lost, then writes location: panic: message and a newline to standard error (“Assertion failed with no additional information” when message is null), followed in a build without -O by a backtrace of the calls that led there (PEPPER_BACKTRACE overrides the build; see the debug module’s print_backtrace), and calls C abort(). No destructors or releases run, and the process ends abnormally (SIGABRT on POSIX).

The location is the call’s file:line:col, which the compiler passes in a hidden parameter, the way a C++ std::source_location default argument works. A function that panics on its caller’s behalf, as the runtime’s indexers do, declares that parameter itself and passes it on, so the panic names its caller’s line:

check_positive(value: int, location: __ptr<char> = __caller_location()) {
    if (value <= 0) {
        panic("expected a positive value", location);
    }
}

Parameters

  • message — the text to print

Panics

  • always

Example

panic("unreachable");

panic

panic(message: __ptr<char>)

Reports message, a NUL-terminated C string (a generic message when it is __null), as a panic and aborts, as panic(String) does. A string literal message picks this overload, so it allocates nothing.

Parameters

  • message — the text to print, or __null

Panics

  • always

Example

panic("unreachable");

Built-in attributes

The built-in attributes, declared as [Attribute] structs so a static block can read them like its own: field.has_attribute<Deprecated>() tests for one and function.attribute<Deprecated>().message reads its value (see the reflection chapter). The compiler acts on each attribute itself; these declarations only describe the values it records.

Source: runtime/attributes.pep

Attribute

[Attribute] struct Attribute

Marks a struct as an attribute, which can then be written above a declaration and read back in a static block.

Deprecated

[Attribute] struct Deprecated

Marks a public declaration as deprecated: each use outside a deprecated declaration warns with message.

Fields

message

const message: String

What the warning says, such as “use ‘open’ instead”.

replacement

const replacement: String = ""

The name that replaces the declaration, offered as a fix; empty when there is none.

Alias

[Attribute] struct Alias

Gives a parameter a second permanent name that a call may use: [Alias("size", "count")] lets a call name count as size.

Fields

alias

const alias: String

The second name.

parameter

const parameter: String

The parameter it names.

Rename

[Attribute] struct Rename

Keeps a parameter’s former name working behind a deprecation warning: [Rename("n", "count")].

Fields

old_name

const old_name: String

The former name.

new_name

const new_name: String

The parameter’s name now.

[Attribute] struct Link

Names the C library an extern links against, its C symbol, or both: [Link("m")], [Link(name = "sym")], [Link("ssl", path = "vendor")].

Fields

lib

const lib: String = ""

The library; empty when only the symbol is given.

path

const path: String = ""

A folder the linker searches for libraries, relative to the file that declares the extern unless absolute; empty for none.

name

const name: String = ""

The C symbol, when it differs from the declared name; empty otherwise.

Target

[Attribute] struct Target

Keeps a declaration only on the platforms its condition matches: [Target(OS(Linux))], [Target(!OS(Windows))].

Fields

condition

const condition: String

The condition as written, such as !OS(Windows).

BuildOption

[Attribute] struct BuildOption

Keeps a declaration only when the build defines a build option with -D: [BuildOption("USE_ZLIB")] is [Target(BuildOption(USE_ZLIB))].

Fields

name

const name: String

The build option’s name, such as USE_ZLIB.

Likely

[Attribute] struct Likely

Marks the if or match arm below it as the branch usually taken, so the optimizer lays it out as the straight-line path.

Unlikely

[Attribute] struct Unlikely

Marks the if or match arm below it as a branch rarely taken, such as error handling, so the optimizer moves it out of the fast path.

NotCopyable

[Attribute] struct NotCopyable

Forbids copying a struct with copy(value), giving the reason in the error.

Fields

reason

const reason: String

Why the struct cannot be copied.

Value

[Attribute] struct Value

Makes a struct a value: stored inline and copied on assignment, with no reference count.

Equatable

[Attribute] struct Equatable

Makes a struct, or an enum whose cases carry values, compare and hash by its fields, like a tuple of them, instead of by identity: the compiler derives its == and its hash, so equal values find each other as HashMap and HashSet keys.

On a field of such a struct, it says whether the field takes part: [Equatable(exclude = true)] or [Equatable(include = false)] leaves it out of both == and the hash. A field with neither takes part. On the struct itself it takes no arguments.

Fields

include

bool include = true

Whether the field takes part in == and the hash; false leaves it out.

exclude

bool exclude = false

Whether the field is left out of == and the hash.

Shared

[Attribute] struct Shared

Makes a struct safe to share between threads: its counts step atomically and its fields must be const and thread-safe.

ThreadLocal

[Attribute] struct ThreadLocal

Gives a module-scope var a separate value on every thread, like C++ thread_local: each thread starts from the initializer, which must be a constant, and reads and writes only its own copy, with no locks or atomics.

The variable’s type must be thread-safe (a number, a raw pointer, a [Shared] struct, a shared function type); for any other type, use async.ThreadLocal<T>. A struct value is released when a thread started with async.Thread.spawn exits; the main thread’s, like every other module-scope variable, is never released.

Example

[ThreadLocal]
var calls: int = 0;

count_call() {
    calls += 1;
}

Isolated

[Attribute] struct Isolated

Makes a struct a guard that can only be the scope of a with block and never escapes it.

Implicit

[Attribute] struct Implicit

Lets a one-parameter constructor convert implicitly wherever a value of its parameter’s type flows into the struct’s type.

Move

[Attribute] struct Move

Marks a parameter that takes its argument’s reference: the caller’s variable may not be used again.

MoveSource

[Attribute] struct MoveSource

Marks the parameter of a function that moves a value out of its source, as move does.

Locked

[Attribute] struct Locked

Marks a function-typed parameter that is called while a lock is held, so it may use the locked value.

Unsafe

[Attribute] struct Unsafe

Removes runtime checks from one function’s body, as --no-runtime-checks removes them from a whole build: [Unsafe("overflow")], [Unsafe("overflow,bounds")] or [Unsafe("all")]. It applies at every optimization level. Calls the function makes keep their own checks, except the index check that an Array or String method makes for its caller, which bounds removes too. Removing bounds or null gives up memory safety in that function.

Fields

checks

const checks: String

The checks to remove, comma-separated: overflow, divide, shift, bounds, null, or all.

Cold

[Attribute] struct Cold

Marks a function rarely called, such as an allocator’s slow path or error handling, so the optimizer never inlines it and its callers’ fast paths stay small enough to inline themselves.

Test

[Attribute] struct Test

Marks a function as a unit test: pep-test (pepper test) builds it into a test program and runs it in a child process of its own. The function takes no parameters and returns nothing; it passes when it returns and fails when it panics (see expect_equal). A build without --test leaves [Test] functions out.

ShouldPanic

[Attribute] struct ShouldPanic

Marks a [Test] function as one that passes only when it panics: [ShouldPanic] takes any panic, and [ShouldPanic("index out of range")] only a panic whose message contains that text.

Fields

message

const message: String = ""

Text the panic’s message must contain; empty for any panic.

Export

[Attribute] struct Export

Makes a free function callable from C, in a library built with --lib static or --lib shared: [Export], or [Export(name = "c_name")] for a C symbol other than its name.

Fields

name

const name: String = ""

The C symbol, when it differs from the function’s name; empty otherwise.

__BuiltIn

[Attribute] struct __BuiltIn

Declares a compiler built-in in the runtime, for the reference and the editor: the compiler ignores the declaration, whose functions end in ;. [__BuiltIn("reflect.name")] names the built-in when it is not the declaration’s own name. Only the runtime may use it.

Fields

name

const name: String = ""

The built-in’s name, when it differs from the declaration’s; empty otherwise.

The reflect module (compile time)

reflect exists only inside a static block, which runs while compiling. It needs no import. Everything here is evaluated by the compiler: the values are compile-time numbers, bools, strings, lists, and the members, cases, functions and attributes below. The Reflection and attributes chapter explains how a block mixes them with the code it emits.

The compiler provides reflect itself; this file only declares it, with [__BuiltIn], for this reference and the editor.

Source: runtime/reflect.pep

reflect.name

reflect.name<T>(): String

The name of type T as it is written in source, with its type arguments ("Box<int>", "Array<String>").

Type parameters

  • T — the type to name

Returns

  • the type’s name

reflect.properties

reflect.properties<T>(): List<Member>

The public fields and properties of struct T, in declaration order, as members.

Type parameters

  • T — a struct

Returns

  • one member per public field or property

reflect.fields

reflect.fields<T>(): List<Member>

Every field of struct T, private ones and a property’s storage included, in declaration order, as members. It is meant for code a struct’s author writes, such as a field-by-field ==, so it is available only in the file that declares T; anywhere else it is an error that points at properties.

Type parameters

  • T — a struct declared in this file

Returns

  • one member per field

reflect.cases

reflect.cases<E>(): List<Case>

The cases of enum E, in declaration order.

Type parameters

  • E — an enum

Returns

  • one case per enum case

reflect.functions_with

reflect.functions_with<A>(): List<Function>

Every free function in the program that carries attribute A.

Type parameters

Returns

  • one function per function marked [A]

reflect.is_struct

reflect.is_struct<T>(): bool

Whether T is a struct. A number, bool or char is not, nor is an enum or a trait.

Type parameters

  • T — the type to test

Returns

  • whether T is a struct

reflect.is_enum

reflect.is_enum<T>(): bool

Whether T is an enum.

Type parameters

  • T — the type to test

Returns

  • whether T is an enum

reflect.is_trait

reflect.is_trait<T>(): bool

Whether T is a trait.

Type parameters

  • T — the type to test

Returns

  • whether T is a trait

reflect.is_reference

reflect.is_reference<T>(): bool

Whether T is a reference type, whose slots can be null: a struct, or an enum whose cases carry values, unless it is [Value].

Type parameters

  • T — the type to test

Returns

  • whether T is a reference type

reflect.is_same

reflect.is_same<A, B>(): bool

Whether A and B are the same type, after aliases (int and i32 are). A member’s Type can be either argument: reflect.is_same<field.Type, String>().

Type parameters

  • A — the first type
  • B — the second type

Returns

  • whether they are the same type

reflect.has_attribute

reflect.has_attribute<T, A>(): bool

Whether struct or enum T carries attribute A. A member, case or function has the same test as a method with one type argument (field.has_attribute<A>()).

Type parameters

  • T — the struct or enum
  • A — the attribute struct

Returns

  • whether T carries A

reflect.attribute

reflect.attribute<T, A>(): A

The value of attribute A on struct or enum T: each field of attribute struct A by name (reflect.attribute<T, Deprecated>().message). It is an error when T does not carry A. A member, case or function has the same as a method with one type argument (field.attribute<A>()).

Type parameters

  • T — the struct or enum
  • A — the attribute struct

Returns

  • the attribute’s value

reflect.has_build_option

reflect.has_build_option(name: String): bool

Whether the build defines build option name (-D NAME or -D NAME=value).

Parameters

  • name — the option’s name: a letter or _, then letters, digits and _

Returns

  • whether it is defined

reflect.build_option

reflect.build_option(name: String): String

The value of build option name, as a string (-D NAME alone is "1"). It is an error when the build does not define the option.

Parameters

  • name — the option’s name

Returns

  • the option’s value

reflect.build_option

reflect.build_option(name: String, default: String): String

The value of build option name, as a string, or default when the build does not define it.

Parameters

  • name — the option’s name
  • default — the value when the build does not define it

Returns

  • the option’s value, else default

reflect.print

reflect.print<T>(value: T)

Writes value as a note each time the compiler runs the call, on every compile, including one that succeeds; in an unrolled loop the note says which pass printed it. See Seeing what a block does.

Type parameters

  • T — the value’s compile-time kind

Parameters

  • value — any compile-time value

reflect.error

reflect.error(message: String)

Stops the compile with an error at the call.

Parameters

  • message — the error’s text; an interpolated string can show the values that matter

reflect.assert

reflect.assert(condition: bool, message: String)

Stops the compile with an error at condition when it is false.

Parameters

  • condition — a compile-time condition
  • message — the error’s text

reflect.Member

struct reflect.Member

A field or property, from properties or fields. A compile-time value: no program can name its type.

Fields

name

const name: String

Its name.

index

const index: int

Its position in the list, from 0.

is_last

const is_last: bool

Whether it is the last in the list.

type_name

const type_name: String

Its type, as a string.

Type

const Type: Type

Its type, usable wherever a type is written (var copy: field.Type = ...).

is_const

const is_const: bool

Whether it is a const field.

is_property

const is_property: bool

Whether it is a property rather than a field.

Methods

has_attribute

has_attribute<A>(): bool

Whether it carries attribute A.

Type parameters

  • A — the attribute struct

Returns

  • whether it carries A

attribute

attribute<A>(): A

The value of attribute A on it; an error when it does not carry A.

Type parameters

  • A — the attribute struct

Returns

  • the attribute’s value

get

get<T>(value: T): Type

Emits value.name, a read of this member of value.

Type parameters

  • T — the struct

Parameters

  • value — the struct to read from

Returns

  • the member’s value

set

set<T>(value: T, x: Type)

Emits value.name = x, a store to this member of value.

Type parameters

  • T — the struct

Parameters

  • value — the struct to store into
  • x — the value to store

reflect.Case

struct reflect.Case

An enum case, from cases. A compile-time value: no program can name its type.

Fields

name

const name: String

Its name.

value

const value: int

Its integer value, or the tag of a case that carries values.

index

const index: int

Its position, from 0.

is_last

const is_last: bool

Whether it is the last case.

values

const values: List<Member>

The values it carries, as members, in order; empty when it carries none. A value’s get(v) reads it from v, which must hold this case.

Methods

has_attribute

has_attribute<A>(): bool

Whether it carries attribute A.

Type parameters

  • A — the attribute struct

Returns

  • whether it carries A

attribute

attribute<A>(): A

The value of attribute A on it; an error when it does not carry A.

Type parameters

  • A — the attribute struct

Returns

  • the attribute’s value

matches

matches<E>(v: E): bool

Emits a test of whether v holds this case.

Type parameters

  • E — the enum

Parameters

  • v — the enum value to test

Returns

  • whether v holds this case

get

get<E, TValues>(values: TValues): E

Emits the case made with values (case.get() for one that carries none); at module scope, the case itself.

Type parameters

  • E — the enum
  • TValues — the types of the values

Parameters

  • values — the values the case carries, one argument each

Returns

  • the enum value

reflect.Function

struct reflect.Function

A function, from functions_with. A compile-time value: no program can name its type.

Fields

name

const name: String

Its name.

Methods

has_attribute

has_attribute<A>(): bool

Whether it carries attribute A.

Type parameters

  • A — the attribute struct

Returns

  • whether it carries A

attribute

attribute<A>(): A

The value of attribute A on it; an error when it does not carry A.

Type parameters

  • A — the attribute struct

Returns

  • the attribute’s value

call

call<R, TArguments>(arguments: TArguments): R

Emits a direct call of the function with arguments.

Type parameters

  • R — what the function returns
  • TArguments — the types of the arguments

Parameters

  • arguments — the call’s arguments, one argument each

Returns

  • what the call returns

reflect.List

struct reflect.List<T>

What properties, fields, cases, functions_with and a case’s values return. A compile-time for unrolls over it.

Type parameters

  • T — a member, case or function

Fields

count

const count: int

How many items it holds.

is_empty

const is_empty: bool

Whether it holds none.

Methods

operator []

[](i: int): T

The item at index i.

Parameters

  • i — its position, from 0

Returns

  • the item

reflect.String

struct reflect.String

A compile-time string: a literal, a member’s name, or the result of name, + or interpolation. It joins with + and interpolation.

Fields

length

const length: int

How many characters it holds.

is_empty

const is_empty: bool

Whether it holds none.

Methods

starts_with

starts_with(s: String): bool

Whether it starts with s.

Parameters

  • s — the prefix

Returns

  • whether it starts with s

ends_with

ends_with(s: String): bool

Whether it ends with s.

Parameters

  • s — the suffix

Returns

  • whether it ends with s

contains

contains(s: String): bool

Whether s occurs in it.

Parameters

  • s — the text to find

Returns

  • whether it contains s

operator +

+(rhs: String): String

A new compile-time string: this one followed by rhs.

Parameters

  • rhs — the string to append

Returns

  • the joined string

Compiler built-ins

The types and functions the compiler provides itself: raw pointers, weak references, atomics, checked arithmetic and the other __ intrinsics the runtime is built on. Every program can use them without an import, but most code should not: the runtime wraps each in a safe API (Atomic<T>, Weak<T>, math.checked_add, …).

The compiler provides these itself; this file only declares them, with [__BuiltIn], for this reference and the editor.

Source: runtime/builtins.pep

__ptr

struct __ptr<T>

A raw pointer to a T in memory the program manages itself, as C’s T*. It is how the runtime and C interop reach memory directly; nothing checks that it is valid.

p[i] reads or writes the ith element, p + n and p - n move by n elements (bytes for __ptr<void>), and == and != compare addresses. A pointer is true when it is not null (__null). as converts to and from __ptr<void>; unsafe_cast converts to any other pointer or to an integer. A string literal converts to __ptr<char>.

Type parameters

  • T — the type pointed at; void for untyped memory

Methods

operator []

[](index: ulong): T

The element index places after the one pointed at, as a place that can be read or assigned.

Parameters

  • index — how many elements past the pointer

Returns

  • the element

operator +

+(n: long): __ptr<T>

A pointer n elements further on (bytes for __ptr<void>).

Parameters

  • n — how many elements to move

Returns

  • the moved pointer

operator -

-(n: long): __ptr<T>

A pointer n elements back (bytes for __ptr<void>).

Parameters

  • n — how many elements to move

Returns

  • the moved pointer

operator ==

==(other: __ptr<T>): bool

Whether both point at the same address.

Parameters

  • other — the pointer to compare with

Returns

  • whether the addresses are equal

operator !=

!=(other: __ptr<T>): bool

Whether they point at different addresses.

Parameters

  • other — the pointer to compare with

Returns

  • whether the addresses differ

__weak

struct __weak<T>

A weak reference to a struct object, like C++’s std::weak_ptr<T>: it keeps the object’s memory but not the object alive. Copies step the weak count; make one with __weak_from, read it with __weak_upgrade. Use Weak<T>, which wraps it.

Type parameters

  • T — the struct or trait type pointed at

__null

const __null: __ptr<void>

The null pointer or reference: a __ptr<T> that points nowhere, or an empty slot of a struct type.

sizeof

sizeof<T>(): ulong

The size of T in bytes, as C’s sizeof; a struct reference is the size of a pointer. Written sizeof<T> or sizeof<T>().

Type parameters

  • T — the type to measure

Returns

  • its size in bytes

unsafe_cast

unsafe_cast<T, TFrom>(value: TFrom): T

Reinterprets value as a T: a pointer as another pointer or an integer, an integer as a pointer, or a number as another of the same width. It never applies to a reference-counted type. Prefer as, which checks the conversion.

Type parameters

  • T — the type to reinterpret as
  • TFrom — the type of value

Parameters

  • value — the value to reinterpret

Returns

  • the same bits as a T

__caller_location

__caller_location(): __ptr<char>

The place of the call, as "file:line:col". As a parameter’s default (location: __ptr<char> = __caller_location()) it is the place of the call that left the parameter out, which assert and panic report.

Returns

  • the location, a NUL-terminated string that lives as long as the program

__function_pointer

__function_pointer<F>(function: F): __ptr<void>

The C-callable address of function: a free function, a static method of the enclosing struct, or this.m. It is how a C library is handed a callback.

Type parameters

  • F — the function’s type

Parameters

  • function — the function, by name

Returns

  • its address

__weak_from

__weak_from<T>(target: T): __weak<T>

Makes a weak reference to target’s object, stepping its weak count.

Type parameters

  • T — the struct or trait type

Parameters

  • target — the object; null makes a reference that is always expired

Returns

  • the weak reference

__weak_upgrade

__weak_upgrade<T>(weak: __weak<T>): T

A strong reference to the object weak points at, or null once it has been destroyed.

Type parameters

  • T — the struct or trait type

Parameters

  • weak — the weak reference

Returns

  • a new reference to the object, or null

__weak_expired

__weak_expired<T>(weak: __weak<T>): bool

Whether the object weak points at has been destroyed (or weak is null).

Type parameters

  • T — the struct or trait type

Parameters

  • weak — the weak reference

Returns

  • whether the object is gone

__default

__default<T>(): T

The zero value of T: 0, false, null, a value struct with every field zero, or an enum’s default case.

Type parameters

  • T — the type

Returns

  • its zero value

__is_pod

__is_pod<T>(): bool

Whether T is plain data, with no reference counts to step (a number, bool or pointer, say). Decided while compiling.

Type parameters

  • T — the type

Returns

  • whether T is plain data

__refcount

__refcount<T>(value: T): uint

The reference count of value’s object; 0 for plain data or null. For tests and debugging.

Type parameters

  • T — the type

Parameters

  • value — the value

Returns

  • its strong count

__address

__address<T>(value: T): ulong

A hash of value’s identity: an object’s address, the fields of an [Equatable] struct, or the bits of plain data.

Type parameters

  • T — the type

Parameters

  • value — the value to hash

Returns

  • the hash

__copy

__copy<T>(value: T, copies: _Copies): T

A deep copy of value, made in the copy context copies so that an object reached twice is copied once. copy(value) calls it.

Type parameters

  • T — the type

Parameters

  • value — the value to copy
  • copies — the copy context

Returns

  • the copy

__copy_object

__copy_object<T>(value: T): T

A field-by-field copy of struct object value, through its generated __copy().

Type parameters

  • T — the struct

Parameters

  • value — the object to copy

Returns

  • the copy

__census

__census<T>(slot: __ptr<T>, waiting: ulong): __ptr<T>

Under PEP_CHECK_SYNC, checks that a Mutex<T>’s value at slot is not reachable from outside the lock; otherwise does nothing.

Type parameters

  • T — the locked type

Parameters

  • slot — where the value is stored
  • waiting — how many threads are waiting on the lock

Returns

  • slot

__construct_at

__construct_at<T>(slot: T, value: T)

Stores value into slot, a raw element (p[i]) known to be empty: it takes a reference to value and does not release what the slot held.

Type parameters

  • T — the element type

Parameters

  • slot — the empty element, written p[i]
  • value — the value to store

__atomic_load

__atomic_load<T>(pointer: __ptr<T>): T

Reads *pointer atomically, with acquire ordering. A struct value comes with a new reference.

Type parameters

  • T — a number, bool, pointer or struct type

Parameters

  • pointer — where the value is

Returns

  • the value

__atomic_store

__atomic_store<T>(pointer: __ptr<T>, value: T)

Writes value to *pointer atomically, with release ordering. A struct value is swapped in, and the old one released.

Type parameters

  • T — a number, bool, pointer or struct type

Parameters

  • pointer — where to store
  • value — the value to store

__atomic_cmpxchg

__atomic_cmpxchg<T>(pointer: __ptr<T>, expected: T, desired: T): bool

Replaces *pointer with desired if it holds expected, atomically (acquire-release).

Type parameters

  • T — a number, bool, pointer or struct type

Parameters

  • pointer — where the value is
  • expected — the value it must hold
  • desired — the value to store

Returns

  • whether it was replaced

__atomic_fetch_add

__atomic_fetch_add<T>(pointer: __ptr<T>, value: T): T

Adds value to *pointer atomically.

Type parameters

  • T — an integer type

Parameters

  • pointer — where the value is
  • value — the amount to add

Returns

  • the value before the addition

__atomic_fetch_sub

__atomic_fetch_sub<T>(pointer: __ptr<T>, value: T): T

Subtracts value from *pointer atomically.

Type parameters

  • T — an integer type

Parameters

  • pointer — where the value is
  • value — the amount to subtract

Returns

  • the value before the subtraction

__atomic_fetch_and

__atomic_fetch_and<T>(pointer: __ptr<T>, value: T): T

Sets *pointer to its bitwise and with value, atomically.

Type parameters

  • T — an integer type

Parameters

  • pointer — where the value is
  • value — the bits to keep

Returns

  • the value before the change

__atomic_fetch_or

__atomic_fetch_or<T>(pointer: __ptr<T>, value: T): T

Sets *pointer to its bitwise or with value, atomically.

Type parameters

  • T — an integer type

Parameters

  • pointer — where the value is
  • value — the bits to set

Returns

  • the value before the change

__atomic_fetch_xor

__atomic_fetch_xor<T>(pointer: __ptr<T>, value: T): T

Sets *pointer to its bitwise exclusive or with value, atomically.

Type parameters

  • T — an integer type

Parameters

  • pointer — where the value is
  • value — the bits to flip

Returns

  • the value before the change

__wrapping_add

__wrapping_add<T>(lhs: T, rhs: T): T

lhs + rhs, wrapping around on overflow instead of panicking.

Type parameters

  • T — an integer type, taken from lhs

Parameters

  • lhs — the first operand
  • rhs — the second operand

Returns

  • the sum, modulo the type’s range

__wrapping_sub

__wrapping_sub<T>(lhs: T, rhs: T): T

lhs - rhs, wrapping around on overflow instead of panicking.

Type parameters

  • T — an integer type, taken from lhs

Parameters

  • lhs — the first operand
  • rhs — the second operand

Returns

  • the difference, modulo the type’s range

__wrapping_mul

__wrapping_mul<T>(lhs: T, rhs: T): T

lhs * rhs, wrapping around on overflow instead of panicking.

Type parameters

  • T — an integer type, taken from lhs

Parameters

  • lhs — the first operand
  • rhs — the second operand

Returns

  • the product, modulo the type’s range

__add_overflows

__add_overflows<T>(lhs: T, rhs: T): bool

Whether lhs + rhs overflows lhs’s type.

Type parameters

  • T — an integer type, taken from lhs

Parameters

  • lhs — the first operand
  • rhs — the second operand

Returns

  • whether the sum overflows

__sub_overflows

__sub_overflows<T>(lhs: T, rhs: T): bool

Whether lhs - rhs overflows lhs’s type.

Type parameters

  • T — an integer type, taken from lhs

Parameters

  • lhs — the first operand
  • rhs — the second operand

Returns

  • whether the difference overflows

__mul_overflows

__mul_overflows<T>(lhs: T, rhs: T): bool

Whether lhs * rhs overflows lhs’s type.

Type parameters

  • T — an integer type, taken from lhs

Parameters

  • lhs — the first operand
  • rhs — the second operand

Returns

  • whether the product overflows

__simd

struct __simd<T, N>

A vector of N lanes of the number type T, as LLVM’s <N x T>: what simd.Vector<T, N> and simd.Mask<N> (with bool lanes) hold. The __simd_ functions below compute on it, each lane at once. It is a value, with no references inside.

Type parameters

  • T — the lane type: an integer, floating-point or bool type
  • N — the lane count, written as a number (__simd<byte, 16>)

__simd_splat

__simd_splat<V>(value: T): V

A vector V with every lane value.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • value — the value of every lane

Returns

  • the vector

__simd_add

__simd_add<V>(lhs: V, rhs: V): V

Adds lane-wise; integer lanes wrap around.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • lhs — the first operand
  • rhs — the second operand

Returns

  • the lane-wise results

__simd_subtract

__simd_subtract<V>(lhs: V, rhs: V): V

Subtracts lane-wise; integer lanes wrap around.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • lhs — the first operand
  • rhs — the second operand

Returns

  • the lane-wise results

__simd_multiply

__simd_multiply<V>(lhs: V, rhs: V): V

Multiplies lane-wise; integer lanes wrap around.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • lhs — the first operand
  • rhs — the second operand

Returns

  • the lane-wise results

__simd_divide

__simd_divide<V>(lhs: V, rhs: V): V

Divides lane-wise. An integer lane of rhs that is 0, or a signed MIN / -1, is undefined: simd.Vector checks first.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • lhs — the first operand
  • rhs — the second operand

Returns

  • the lane-wise results

__simd_remainder

__simd_remainder<V>(lhs: V, rhs: V): V

The lane-wise remainder, with the same undefined cases as __simd_divide.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • lhs — the first operand
  • rhs — the second operand

Returns

  • the lane-wise results

__simd_and

__simd_and<V>(lhs: V, rhs: V): V

The lane-wise bitwise and (integer or bool lanes).

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • lhs — the first operand
  • rhs — the second operand

Returns

  • the lane-wise results

__simd_or

__simd_or<V>(lhs: V, rhs: V): V

The lane-wise bitwise or (integer or bool lanes).

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • lhs — the first operand
  • rhs — the second operand

Returns

  • the lane-wise results

__simd_xor

__simd_xor<V>(lhs: V, rhs: V): V

The lane-wise bitwise exclusive or (integer or bool lanes).

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • lhs — the first operand
  • rhs — the second operand

Returns

  • the lane-wise results

__simd_shift_left

__simd_shift_left<V>(vector: V, count: int): V

Shifts every integer lane left. A count of at least the lane width is undefined: simd.Vector checks first.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • vector — the lanes to shift
  • count — the bits to shift by, less than the lane width

Returns

  • the shifted lanes

__simd_shift_right

__simd_shift_right<V>(vector: V, count: int): V

Shifts every integer lane right: arithmetic for signed lanes, logical for unsigned. A count of at least the lane width is undefined.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • vector — the lanes to shift
  • count — the bits to shift by, less than the lane width

Returns

  • the shifted lanes

__simd_negate

__simd_negate<V>(vector: V): V

Negates every lane; integer lanes wrap around.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • vector — the operand

Returns

  • the lane-wise results

__simd_not

__simd_not<V>(vector: V): V

Flips every bit of every integer or bool lane.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • vector — the operand

Returns

  • the lane-wise results

__simd_equal

__simd_equal<V>(lhs: V, rhs: V): __simd<bool, N>

Compares lane-wise for equality (ordered for floats).

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • lhs — the first operand
  • rhs — the second operand

Returns

  • a bool vector of the results

__simd_not_equal

__simd_not_equal<V>(lhs: V, rhs: V): __simd<bool, N>

Compares lane-wise for inequality (true for a NaN lane).

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • lhs — the first operand
  • rhs — the second operand

Returns

  • a bool vector of the results

__simd_less

__simd_less<V>(lhs: V, rhs: V): __simd<bool, N>

Compares lane-wise, signed or unsigned by the lane type.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • lhs — the first operand
  • rhs — the second operand

Returns

  • a bool vector of the results

__simd_less_equal

__simd_less_equal<V>(lhs: V, rhs: V): __simd<bool, N>

Compares lane-wise, signed or unsigned by the lane type.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • lhs — the first operand
  • rhs — the second operand

Returns

  • a bool vector of the results

__simd_greater

__simd_greater<V>(lhs: V, rhs: V): __simd<bool, N>

Compares lane-wise, signed or unsigned by the lane type.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • lhs — the first operand
  • rhs — the second operand

Returns

  • a bool vector of the results

__simd_greater_equal

__simd_greater_equal<V>(lhs: V, rhs: V): __simd<bool, N>

Compares lane-wise, signed or unsigned by the lane type.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • lhs — the first operand
  • rhs — the second operand

Returns

  • a bool vector of the results

__simd_select

__simd_select<V>(mask: __simd<bool, N>, if_true: V, if_false: V): V

Picks each lane from if_true or if_false by mask.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • mask — which lanes to take from if_true
  • if_true — the lanes where mask is true
  • if_false — the lanes where mask is false

Returns

  • the selected lanes

__simd_minimum

__simd_minimum<V>(lhs: V, rhs: V): V

The lane-wise minimum (llvm.minnum for floats: a NaN lane gives the other).

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • lhs — the first operand
  • rhs — the second operand

Returns

  • the lane-wise results

__simd_maximum

__simd_maximum<V>(lhs: V, rhs: V): V

The lane-wise maximum (llvm.maxnum for floats).

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • lhs — the first operand
  • rhs — the second operand

Returns

  • the lane-wise results

__simd_abs

__simd_abs<V>(vector: V): V

The lane-wise absolute value; the lowest signed value stays itself.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • vector — the operand

Returns

  • the lane-wise results

__simd_rotate_left

__simd_rotate_left<V>(vector: V, count: int): V

Rotates every integer lane left (llvm.fshl).

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • vector — the lanes to rotate
  • count — the bits to rotate by, modulo the lane width

Returns

  • the rotated lanes

__simd_rotate_right

__simd_rotate_right<V>(vector: V, count: int): V

Rotates every integer lane right (llvm.fshr).

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • vector — the lanes to rotate
  • count — the bits to rotate by, modulo the lane width

Returns

  • the rotated lanes

__simd_square_root

__simd_square_root<V>(vector: V): V

The square root of every float lane.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • vector — the operand

Returns

  • the lane-wise results

__simd_floor

__simd_floor<V>(vector: V): V

Every float lane rounded down.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • vector — the operand

Returns

  • the lane-wise results

__simd_ceiling

__simd_ceiling<V>(vector: V): V

Every float lane rounded up.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • vector — the operand

Returns

  • the lane-wise results

__simd_sum

__simd_sum<V>(vector: V): T

The sum of the lanes: wrapping for integers, in lane order for floats.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • vector — the operand

Returns

  • the sum

__simd_reduce_minimum

__simd_reduce_minimum<V>(vector: V): T

The smallest lane.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • vector — the operand

Returns

  • the minimum

__simd_reduce_maximum

__simd_reduce_maximum<V>(vector: V): T

The largest lane.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • vector — the operand

Returns

  • the maximum

__simd_any

__simd_any<V>(mask: V): bool

Whether any lane of a bool vector is true.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • mask — a bool vector

Returns

  • whether a lane is true

__simd_all

__simd_all<V>(mask: V): bool

Whether every lane of a bool vector is true.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • mask — a bool vector

Returns

  • whether every lane is true

__simd_to_bits

__simd_to_bits<V>(mask: V): ulong

Packs a bool vector into bits: bit i is lane i.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • mask — a bool vector of at most 64 lanes

Returns

  • the lanes as bits

__simd_extract

__simd_extract<V>(vector: V, index: ulong): T

Reads one lane. An index of at least N gives an undefined value: simd.Vector checks first.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • vector — the vector
  • index — the lane, less than N

Returns

  • the lane’s value

__simd_insert

__simd_insert<V>(vector: V, index: ulong, value: T): V

Replaces one lane. An index of at least N is undefined: simd.Vector checks first.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • vector — the vector
  • index — the lane, less than N
  • value — the lane’s new value

Returns

  • the vector with the lane replaced

__simd_shuffle

__simd_shuffle<V>(vector: V, indices: __simd<int, N>): V

Picks lanes by index, each taken modulo N. Constant indices fold to one shufflevector.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • vector — the lanes to pick from
  • indices — the lane to pick for each lane, modulo N

Returns

  • the picked lanes

__simd_reverse

__simd_reverse<V>(vector: V): V

The lanes in reverse order.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • vector — the operand

Returns

  • the lane-wise results

__simd_interleave_low

__simd_interleave_low<V>(lhs: V, rhs: V): V

Interleaves the low halves: lhs[0], rhs[0], lhs[1], ....

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • lhs — the even lanes’ source
  • rhs — the odd lanes’ source

Returns

  • the interleaved lanes

__simd_interleave_high

__simd_interleave_high<V>(lhs: V, rhs: V): V

Interleaves the high halves: lhs[N / 2], rhs[N / 2], ....

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • lhs — the even lanes’ source
  • rhs — the odd lanes’ source

Returns

  • the interleaved lanes

__simd_load

__simd_load<V>(pointer: __ptr<T>): V

Reads N values from memory aligned to T.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • pointer — the first of N values

Returns

  • the values

__simd_load_aligned

__simd_load_aligned<V>(pointer: __ptr<T>): V

Reads N values from memory aligned to the vector’s size.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • pointer — the first of N values, aligned to the vector’s size

Returns

  • the values

__simd_store

__simd_store<V>(pointer: __ptr<T>, vector: V)

Writes the lanes to memory aligned to T.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • pointer — the first of N values
  • vector — the lanes to write

__simd_store_aligned

__simd_store_aligned<V>(pointer: __ptr<T>, vector: V)

Writes the lanes to memory aligned to the vector’s size.

Type parameters

  • V — the vector type, __simd<T, N>

Parameters

  • pointer — the first of N values, aligned to the vector’s size
  • vector — the lanes to write

__simd_convert

__simd_convert<R>(vector: __simd<U, N>): R

Converts every lane to R’s lane type, as as does; float to integer saturates (llvm.fptosi.sat), and NaN gives 0.

Type parameters

  • R — the vector type, __simd<T, N> of the result

Parameters

  • vector — the lanes to convert

Returns

  • the converted lanes

__simd_reinterpret

__simd_reinterpret<R>(vector: __simd<U, M>): R

Reads a vector’s bits as another vector type of the same size.

Type parameters

  • R — the vector type, __simd<T, N> of the result

Parameters

  • vector — the vector, the same size as R

Returns

  • the same bits as R

__simd_lanes

__simd_lanes<V>(): int

The lane count of V.

Type parameters

  • V — the vector type, __simd<T, N>

Returns

  • N

__simd_lane_bits

__simd_lane_bits<V>(): int

The width of V’s lane type in bits.

Type parameters

  • V — the vector type, __simd<T, N>

Returns

  • the lane’s width in bits

__simd_is_signed

__simd_is_signed<V>(): bool

Whether V’s lanes are a signed integer type.

Type parameters

  • V — the vector type, __simd<T, N>

Returns

  • whether the lanes are signed integers

Arguments and exit (deprecated)

The running program’s arguments and exit, kept for the deprecated prelude arguments() and exit(); the process module has them now.

Source: runtime/process.pep

arguments

[Deprecated("use 'process.arguments' instead (import process)")] arguments(): Array<String>

Deprecated: use process.arguments instead.

Returns the program’s command-line arguments, as process.arguments() does.

Returns

  • the program name followed by its arguments

exit

[Deprecated("use 'process.exit' instead (import process)")] exit(code: int)

Deprecated: use process.exit instead.

Ends the program at once with status code, as process.exit(code) does.

Parameters

  • code — the exit status: 0 for success, anything else for failure

String

Source: runtime/string.pep

String

struct String

A mutable, reference-counted string of UTF-8 text. A string literal ("text") is a String wherever a String is expected; its own type is StringConstant.

Two measures describe a String, and each API says which one it uses:

  • Code points (Unicode characters): length, is_empty, the indexer s[i], substr, and iteration.
  • Bytes of UTF-8: size, find, starts_with, and ends_with. The byte size is the authoritative length, so the data may contain NUL bytes. It is never derived from a terminator.

A String’s bytes are not guaranteed to be followed by a NUL. Only c_str() guarantees one, so call it to hand the text to C; anything else that reads the bytes must stop at size.

A String is a reference type: var b = a; shares one object, so append, +=, and grow are visible through every reference. Use copy() or + for an independent copy. Equality (==) compares content.

A StringIterator panics when the String has been changed by append, +=, or grow since the iterator was created, so modifying a String while a for loop iterates it panics.

To build text from values, use an interpolated string ($"{name}: {count:>4}") or String.format("{0}: {1:>4}", name, count), a compiler intrinsic whose braces name its arguments by position (see the String interpolation chapter).

Example

var s = "héllo";
assert(s.length == 5, "5 code points");
assert(s.size == 6, "6 bytes: 'é' is 2");
s.append(" world");
assert(s.find("world") == 7, "find returns a byte offset");
assert(s[1] == "é", "the indexer counts code points");
for (var c in s) {
    print(c);   # one code point per line
}

Properties

length

var length: ulong { get; }

The number of Unicode code points. O(1).

See also

  • size

size

var size: ulong { get; }

The number of bytes of UTF-8. O(1).

See also

  • length

is_empty

var is_empty: bool { get; }

Whether the String has no code points. O(1).

Constructor

String()

construct(data: __ptr<char> = __null, size: ulong = 0, length: ulong = 0)

Creates a String by copying size bytes of data into a new NUL-terminated buffer. With no arguments, it creates an empty String with no buffer; its c_str() is still a valid empty C string.

When data is not __null and size is 0, data is treated as a NUL-terminated C string:

  • with length nonzero, size becomes the byte length of its first length code points (0 if the C string is not valid UTF-8);
  • with length 0, size becomes the C string’s whole byte length.

When length is 0, it is counted from the bytes. A nonzero length is trusted, not checked against the data. Runs in O(size).

Parameters

  • data — the bytes to copy; __null for an empty String (then pass no sizes, because they are stored as given)
  • size — the number of bytes to copy from data; 0 treats data as a NUL-terminated C string
  • length — the number of code points in those bytes

Example

var empty = String();
var s = "abc";
var copy = String(s.c_str(), s.size, s.length);

Static methods

length_of

static length_of(data: __ptr<char>, size: ulong): ulong

Returns the number of Unicode code points in the first size bytes at data. Runs in O(size).

Returns 0 when data is __null, when size is 0, or when the bytes are not valid UTF-8: any malformed or truncated sequence makes the whole count 0.

Parameters

  • data — the UTF-8 bytes to measure
  • size — the number of bytes to measure

Returns

  • the number of code points, or 0 for invalid UTF-8

Methods

bytes

bytes(): u8[]

Returns a new array holding the String’s UTF-8 bytes, without a terminating NUL. Runs in O(size).

Returns

  • a new array of size bytes

Example

var data = "hi".bytes();
assert(data.count == 2 && data[0] == 104, "the bytes of 'h' and 'i'");

grow

grow(new_size: ulong)

Extends the byte buffer to new_size bytes and fills the new bytes with zeros. Does nothing when new_size is not greater than size.

A low-level primitive for append. It updates size but not length, so the zero bytes it adds are not counted as code points until the caller overwrites them and fixes the length. When it grows the buffer, it invalidates running iterators and may reallocate the buffer, which invalidates pointers from c_str(). Runs in O(new_size).

Parameters

  • new_size — the new byte length

append

append(rhs: String)

Appends the content of rhs to this String in place, updating size and length.

Always invalidates running iterators, even when rhs is empty, so appending during iteration panics. It may reallocate the buffer. Every reference to this String sees the change. Runs in O(size + rhs.size).

Parameters

  • rhs — the String to append; it is not modified

Example

var s = "TEST";
s.append("ING");   # s == "TESTING"

operator []

[](idx: int): String

Returns the code point at code-point index idx as a new one-code-point String.

A negative idx counts from the end (s[-1] is the last code point). length is added until the index is non-negative, so any negative index wraps, and s[-7] of a 3-code-point String is s[2]. Runs in O(idx) because it walks the UTF-8 bytes. Use for (var c in s) to visit every code point in O(size).

Parameters

  • idx — the code-point index; negative counts from the end

Returns

  • a new String that holds the one code point

Panics

  • if idx is not less than length after the negative index is normalized, which includes any index into an empty String

Example

var s = "TEST";
assert(s[0] == "T", "first");
assert(s[-1] == "T", "last");

operator ==

==(rhs: String): bool

Returns whether this String and rhs have the same content, compared byte by byte. Strings with different length are unequal without comparing bytes. Runs in O(size).

Parameters

  • rhs — the String to compare with

Returns

  • true when both hold the same bytes

operator ==

==(rhs: StringConstant): bool

Returns whether this String holds the same bytes as the StringConstant rhs, a literal included. Runs in O(size) and copies nothing.

Parameters

  • rhs — the text to compare with

Returns

  • true when both hold the same bytes

operator +=

+=(rhs: String): String

Appends rhs in place, like append, and returns this same String, not a copy. s += t modifies the object that every reference to s shares, and invalidates running iterators.

Parameters

  • rhs — the String to append

Returns

  • this String, after appending

Example

var s = "a";
s += "b";   # s == "ab"

iterate

iterate(): StringIterator

Returns an iterator over the String’s code points, which makes String Iterable<String>: for (var c in s) visits each code point as a one-code-point String, the same value s[i] gives.

The iterator holds a reference to this String. The whole loop runs in O(size). Modifying the String during iteration makes the iterator panic.

Returns

  • a new iterator, positioned before the first code point

Example

var n = 0;
for (var c in "héllo") {
    n = n + 1;   # 5 iterations
}

c_str

c_str(): __ptr<char>

Returns the String’s buffer as a NUL-terminated C string.

This is the one API that guarantees a NUL after the content: a String is not otherwise guaranteed to be NUL-terminated, so code that needs the terminator calls c_str() rather than relying on the buffer.

The pointer is owned by the String. It is valid until the String is destroyed or append, +=, or grow reallocates it. It is never __null: a String built with no data (String()) returns a constant empty C string, so it can go to C as it is. C functions stop at the first NUL byte in the content.

Returns

  • the String’s NUL-terminated byte buffer

copy

copy(): String

Returns a new String with the same content and its own buffer, so later changes to either String do not affect the other. Runs in O(size).

This is also how copy(value) copies a String (the Copyable<String> implementation).

Returns

  • an independent copy of this String

Example

var a = "abc";
var b = a.copy();
b.append("d");   # a is still "abc"

clone

[Deprecated("use 'copy' instead", "copy")] clone(): String

Deprecated: use copy instead. Returns a new String with the same content and its own buffer.

Returns

  • an independent copy of this String

find

find(needle: String, from: ulong = 0): long

Returns the byte offset of the first occurrence of needle, or -1 when it does not occur.

The offset counts bytes, not code points. To use it with s[i] or substr, convert it with String.length_of(s.c_str(), offset). An empty needle is found at 0 unless this String is empty, in which case the result is -1. The result is also -1 when needle is not valid UTF-8, or when this String holds invalid UTF-8 before the first match. Bytes past the match are not checked, so searching on from each match (as split and replace do) reads the String once in all. The search is the C library’s memmem, which compares many bytes per step (Windows, which has none, jumps between matches of the first byte with memchr).

With from, the search starts at that byte offset, which must be a code point boundary (an offset find returned, plus a match’s size, is one); an offset past the end, or inside a code point, finds nothing. The result still counts from the start of the String.

Parameters

  • needle — the text to search for
  • from — the byte offset to start searching at; 0 by default

Returns

  • the byte offset of the first match at or after from, or -1 when there is none

Example

var s = "TESTING";
assert(s.find("STI") == 2, "byte offset");
assert(s.find("xyz") == -1, "absent");
assert("a,b,c".find(",", 2) == 3, "the second comma");

rfind

rfind(needle: String): long

Returns the byte offset of the last place needle occurs in this String, or -1 when it does not occur. An empty needle is found at size. Runs in O(size * needle.size).

Parameters

  • needle — the text to search for

Returns

  • the byte offset of the last match, or -1 when there is none

Example

assert("a/b/c".rfind("/") == 3, "the last slash");
assert("abc".rfind("x") == -1, "absent");

split

split(separator: String): String[]

Splits the String at every occurrence of separator and returns the pieces between them, in order. Adjacent separators give an empty piece, and so does a separator at either end: "a,,b".split(",") is ["a", "", "b"] and ",a".split(",") is ["", "a"]. A String without separator, the empty String included, gives one piece: the whole String. Runs in O(size * separator.size).

Parameters

  • separator — the text to split at

Returns

  • a new Array with one new String per piece

Panics

  • if separator is empty

Example

var parts = "a,b,,c".split(",");
assert(parts.count == 4, "four pieces");
assert(parts[2] == "", "an empty piece");

split_once

split_once(separator: String): (String, String)?

Splits the String at the first occurrence of separator: the text before it and the text after it, or none when it does not occur. Runs in O(size * separator.size).

Parameters

  • separator — the text to split at

Returns

  • the pieces before and after the first separator, or none

Panics

  • if separator is empty

Example

var header = "Host: example.com".split_once(": ");
assert(header.is_value(), "has a colon");
var (name, value) = header.value();
assert(name == "Host" && value == "example.com", "split");

join

join(parts: String[]): String

Returns the pieces of parts joined into one String, with this String between each two of them: ", ".join(names). No parts give an empty String, and one part gives a copy of it. Runs in O(total size).

Parameters

  • parts — the pieces to join

Returns

  • a new String

Example

var parts = Array<String>();
parts.append("a");
parts.append("b");
assert(", ".join(parts) == "a, b", "joined");

replace

replace(from: String, to: String): String

Returns a copy of this String with every occurrence of from replaced by to, scanning left to right without overlaps: "aaa".replace("aa", "b") is "ba". Runs in O(size * from.size) plus the result’s size.

Parameters

  • from — the text to replace
  • to — the text to put in its place

Returns

  • a new String

Panics

  • if from is empty

Example

assert("a\r\nb".replace("\r\n", "\n") == "a\nb", "line endings");

contains

contains(needle: String): bool

Returns whether needle occurs in this String (find(needle) >= 0). Because of that, an empty String contains nothing, not even "". Runs in O(size * needle.size).

Parameters

  • needle — the text to search for

Returns

  • true when needle occurs

starts_with

starts_with(prefix: String): bool

Returns whether this String begins with the bytes of prefix. An empty prefix always matches. Runs in O(prefix.size).

Parameters

  • prefix — the text to test for

Returns

  • true when the first prefix.size bytes equal prefix

ends_with

ends_with(suffix: String): bool

Returns whether this String ends with the bytes of suffix. An empty suffix always matches. Runs in O(suffix.size).

Parameters

  • suffix — the text to test for

Returns

  • true when the last suffix.size bytes equal suffix

substr

substr(start: ulong, count: ulong): String

Returns a new String of up to count code points starting at code-point index start. Both arguments count code points, not bytes.

count is clamped to the code points that remain. When start is at or past length, the result is an empty String. Runs in O(size).

Parameters

  • start — the code-point index of the first code point to copy
  • count — the maximum number of code points to copy

Returns

  • a new String that holds the selected code points

Example

var s = "TEST";
assert(s.substr(1, 2) == "ES", "middle");
assert(s.substr(0, 99) == "TEST", "count is clamped");
assert(s.substr(4, 5) == "", "start past the end");

operator !=

!=(rhs: String): bool

Returns whether this String and rhs differ in content, the negation of ==. Runs in O(size).

Parameters

  • rhs — the String to compare with

Returns

  • true when the contents differ

operator !=

!=(rhs: StringConstant): bool

Returns whether this String differs from the StringConstant rhs.

Parameters

  • rhs — the text to compare with

Returns

  • true when the contents differ

operator +

+(rhs: String): String

Returns a new String that holds this String followed by rhs. Neither operand is modified. Runs in O(size + rhs.size).

Parameters

  • rhs — the String to append to the copy

Returns

  • a new concatenated String

Example

var greeting = "Hello, " + "world";

to_string

to_string(): String

Returns this String, so a String is ToString and can be interpolated ($"name: {name}"). The result is the same object, not a copy. O(1).

Returns

  • this String

byte_at

byte_at(index: ulong): byte

Returns the byte at byte offset index of the UTF-8 data. O(1).

The offset counts bytes, not code points, so a byte inside a multi-byte code point is returned as it is. Compare the result with a character literal (s.byte_at(0) == '#').

Parameters

  • index — the byte offset, less than size

Returns

  • the byte at index

Panics

  • if index is not less than size (“String byte_at index out of bounds”)

Example

var s = "a=b";
assert(s.byte_at(1) == '=', "second byte");

substr_bytes

substr_bytes(start: ulong, size: ulong): String

Returns a new String of the size bytes starting at byte offset start. Both arguments count bytes, not code points, so offsets from find can be used directly. Runs in O(size).

Parameters

  • start — the byte offset of the first byte to copy
  • size — the number of bytes to copy

Returns

  • a new String that holds those bytes

Panics

  • if start + size is greater than size (“String substr_bytes range out of bounds”)
  • if start or start + size falls inside a multi-byte code point (“String substr_bytes splits a code point”)

Example

var s = "key=value";
var eq = s.find("=") as ulong;
assert(s.substr_bytes(0, eq) == "key", "before '='");
assert(s.substr_bytes(eq + 1, s.size - eq - 1) == "value", "after '='");

compare

compare(other: String): int

Compares this String with other byte by byte, for sorting. For valid UTF-8 this is code-point order; a String that is a prefix of the other orders first. Runs in O(min(size, other.size)).

Parameters

  • other — the String to compare with

Returns

  • a negative number when this String orders before other, 0 when both hold the same bytes, and a positive number when it orders after

Example

assert("apple".compare("banana") < 0, "a before b");
assert("ab".compare("abc") < 0, "a prefix orders first");
assert("x".compare("x") == 0, "equal");

operator <

<(other: String): bool

Returns whether this String orders before other, as compare does.

Parameters

  • other — the String to compare with

Returns

  • true when compare(other) < 0

operator <

<(other: StringConstant): bool

Returns whether this String orders before the StringConstant other, byte by byte, a literal included. Copies nothing.

Parameters

  • other — the text to compare with

Returns

  • true when the byte-by-byte order is negative

operator <=

<=(other: String): bool

Returns whether this String orders before other or equals it, as compare does.

Parameters

  • other — the String to compare with

Returns

  • true when compare(other) <= 0

operator <=

<=(other: StringConstant): bool

Returns whether this String orders before or equals the StringConstant other, byte by byte, a literal included. Copies nothing.

Parameters

  • other — the text to compare with

Returns

  • true when the byte-by-byte order is not positive

operator >

>(other: String): bool

Returns whether this String orders after other, as compare does.

Parameters

  • other — the String to compare with

Returns

  • true when compare(other) > 0

operator >

>(other: StringConstant): bool

Returns whether this String orders after the StringConstant other, byte by byte, a literal included. Copies nothing.

Parameters

  • other — the text to compare with

Returns

  • true when the byte-by-byte order is positive

operator >=

>=(other: String): bool

Returns whether this String orders after other or equals it, as compare does.

Parameters

  • other — the String to compare with

Returns

  • true when compare(other) >= 0

operator >=

>=(other: StringConstant): bool

Returns whether this String orders after or equals the StringConstant other, byte by byte, a literal included. Copies nothing.

Parameters

  • other — the text to compare with

Returns

  • true when the byte-by-byte order is not negative

trim_end

trim_end(): String

Returns a new String without the whitespace at the end: spaces, tabs, carriage returns, line feeds, vertical tabs and form feeds (ASCII only). Runs in O(size).

Returns

  • a new String with the trailing whitespace removed

Example

assert("code;  \r\n".trim_end() == "code;", "trailing whitespace");
assert("  indented".trim_end() == "  indented", "leading kept");

trim_start

trim_start(): String

Returns a new String without the whitespace at the start: spaces, tabs, carriage returns, line feeds, vertical tabs and form feeds (ASCII only). Runs in O(size).

Returns

  • a new String with the leading whitespace removed

Example

assert("  \tcode".trim_start() == "code", "leading whitespace");

trim

trim(): String

Returns a new String without the whitespace at either end, as trim_start and trim_end do. Runs in O(size).

Returns

  • a new String with the leading and trailing whitespace removed

Example

assert(" \t value \r\n".trim() == "value", "both ends");

lower

lower(): String

Returns a new String with the ASCII letters A to Z changed to a to z. Every other character, including non-ASCII letters, is kept as it is, so the result is valid UTF-8 of the same size. Runs in O(size).

Returns

  • a new String in lowercase

Example

assert("Content-Type".lower() == "content-type", "ASCII lowered");
assert("Ärger".lower() == "Ärger", "non-ASCII kept");

upper

upper(): String

Returns a new String with the ASCII letters a to z changed to A to Z. Every other character, including non-ASCII letters, is kept as it is, so the result is valid UTF-8 of the same size. Runs in O(size).

Returns

  • a new String in uppercase

Example

assert("get".upper() == "GET", "ASCII raised");

lines

lines(): String[]

Splits the String into lines and returns them without their line endings. A line ends at "\n" or "\r\n". A final line with no ending is included, but the end of the String after a final line ending does not start another line, so "a\nb\n" and "a\nb" both give ["a", "b"], and "" gives no lines. Runs in O(size).

Returns

  • a new Array with one new String per line

Example

var lines = "first\r\nsecond\n\nfourth".lines();
assert(lines.count == 4, "four lines");
assert(lines[1] == "second", "\\r\\n removed");
assert(lines[2] == "", "an empty line");

repeat

repeat(count: ulong): String

Returns a new String that holds this String count times in a row. count 0, or an empty String, gives an empty String. Runs in O(size * count).

Parameters

  • count — how many copies to join

Returns

  • a new String of size * count bytes

Example

assert("ab".repeat(3) == "ababab", "three copies");
var indent = " ".repeat(8);   # eight spaces

StringConstant

[Value] struct StringConstant

The text of a string literal: a pointer to its bytes, which never change and are never freed, with their byte size and code-point length. A string literal’s type is StringConstant, and so is a const with no written type initialized from one (const NAME = "pepper";); where a String is expected, the literal is a String (see the Strings chapter of the book).

A StringConstant is a [Value] type: copying one copies the pointer and steps no reference count, and any thread may read it. Its bytes always end in a NUL byte, so c_str() goes to C as it is. It has String’s read API, measured the same way (length in code points, size and offsets in bytes); the methods that make new text return a String. A StringConstant converts to a String wherever a String is expected, as if String had an [Implicit] constructor from it: the conversion, like String(text), copies the bytes. Where C expects a char*, it passes its own bytes, as c_str() does.

Example

StringConstant name = "pepper";
assert(name.length == 6, "6 code points");
assert(name.starts_with("pep"), "the read API of String");
String copy = name;   # a new String with its own bytes
copy.append("!");

Properties

length

var length: ulong { get; }

The number of Unicode code points. O(1).

See also

  • size

size

var size: ulong { get; }

The number of bytes of UTF-8, not counting the NUL terminator. O(1).

See also

  • length

is_empty

var is_empty: bool { get; }

Whether the text has no code points. O(1).

Constructor

StringConstant()

construct(data: __ptr<char> = __null, size: ulong = 0, length: ulong = 0)

Creates a StringConstant over the size bytes at data. The compiler calls it for each string literal. The bytes must be followed by a NUL byte and must never change or be freed while the program runs; no other bytes meet that, so code outside the runtime has no reason to call it with arguments. With no arguments, it is the empty text.

Parameters

  • data — the constant NUL-terminated bytes
  • size — the number of bytes before the NUL terminator
  • length — the number of code points in those bytes

Methods

c_str

c_str(): __ptr<char>

Returns the text as a NUL-terminated C string, with no copy. It is never __null, and it stays valid for the whole run of the program.

Returns

  • the constant NUL-terminated bytes

to_string

to_string(): String

Returns a new String with a copy of the text. Converting a StringConstant to a String, or calling String(text), calls it. Runs in O(size).

Returns

  • a new String with the same content

bytes

bytes(): u8[]

Returns a copy of the UTF-8 bytes in a new array, as String.bytes does. Runs in O(size).

Returns

  • a new array of size bytes

operator []

[](idx: int): String

Returns the code point at code-point index idx as a new one-code-point String, as String’s indexer does. A negative idx counts from the end. Runs in O(idx).

Parameters

  • idx — the code-point index; negative counts from the end

Returns

  • a new String that holds the one code point

Panics

  • if idx is not less than length after the negative index is normalized

operator ==

==(rhs: StringConstant): bool

Returns whether two texts hold the same bytes, a literal rhs included. Runs in O(size).

Parameters

  • rhs — the text to compare with

Returns

  • true when both hold the same bytes

operator !=

!=(rhs: StringConstant): bool

Returns whether two texts differ.

Parameters

  • rhs — the text to compare with

Returns

  • true when the bytes differ

operator ==

==(rhs: String): bool

Returns whether this text holds the same bytes as the String rhs. Runs in O(size) and copies nothing.

Parameters

  • rhs — the String to compare with

Returns

  • true when both hold the same bytes

operator !=

!=(rhs: String): bool

Returns whether this text differs from the String rhs.

Parameters

  • rhs — the String to compare with

Returns

  • true when the bytes differ

operator +

+(rhs: String): String

Returns a new String: this text followed by rhs.

Parameters

  • rhs — the text to append

Returns

  • a new String

find

find(needle: String, from: ulong = 0): long

Returns the byte offset of the first occurrence of needle at or after byte offset from, or -1, as String.find does.

Parameters

  • needle — the text to search for
  • from — the byte offset to start searching at; 0 by default

Returns

  • the byte offset of the first match, or -1 when there is none

rfind

rfind(needle: String): long

Returns the byte offset of the last occurrence of needle, or -1, as String.rfind does.

Parameters

  • needle — the text to search for

Returns

  • the byte offset of the last match, or -1 when there is none

contains

contains(needle: String): bool

Returns whether needle occurs in the text.

Parameters

  • needle — the text to search for

Returns

  • true when find(needle) is not -1

starts_with

starts_with(prefix: String): bool

Returns whether the text starts with prefix. An empty prefix always matches.

Parameters

  • prefix — the text to look for at the start

Returns

  • true when the first bytes are prefix’s

ends_with

ends_with(suffix: String): bool

Returns whether the text ends with suffix. An empty suffix always matches.

Parameters

  • suffix — the text to look for at the end

Returns

  • true when the last bytes are suffix’s

substr

substr(start: ulong, count: ulong): String

Returns up to count code points starting at code-point index start, as a new String, as String.substr does.

Parameters

  • start — the code-point index of the first code point
  • count — the most code points to take

Returns

  • a new String; empty when start is past the end

byte_at

byte_at(index: ulong): byte

Returns the byte at byte offset index.

Parameters

  • index — the byte offset

Returns

  • the byte at index

Panics

  • if index is not less than size

substr_bytes

substr_bytes(start: ulong, size: ulong): String

Returns the size bytes from byte offset start as a new String, as String.substr_bytes does.

Parameters

  • start — the byte offset of the first byte
  • size — the number of bytes

Returns

  • a new String

Panics

  • if the range is not inside the text
  • if start or start + size falls inside a code point

compare

compare(other: String): int

Compares this text with other byte by byte, for sorting, as String.compare does.

Parameters

  • other — the text to compare with

Returns

  • a negative number, 0, or a positive number when this text sorts before, the same as, or after other

operator <

<(other: StringConstant): bool

Returns whether this text sorts before other, byte by byte.

Parameters

  • other — the text to compare with

Returns

  • true when the byte-by-byte order is negative

operator <

<(other: String): bool

Returns whether this text sorts before the String other, byte by byte.

Parameters

  • other — the String to compare with

Returns

  • true when the byte-by-byte order is negative

operator <=

<=(other: StringConstant): bool

Returns whether this text sorts before or the same as other, byte by byte.

Parameters

  • other — the text to compare with

Returns

  • true when the byte-by-byte order is not positive

operator <=

<=(other: String): bool

Returns whether this text sorts before or the same as the String other, byte by byte.

Parameters

  • other — the String to compare with

Returns

  • true when the byte-by-byte order is not positive

operator >

>(other: StringConstant): bool

Returns whether this text sorts after other, byte by byte.

Parameters

  • other — the text to compare with

Returns

  • true when the byte-by-byte order is positive

operator >

>(other: String): bool

Returns whether this text sorts after the String other, byte by byte.

Parameters

  • other — the String to compare with

Returns

  • true when the byte-by-byte order is positive

operator >=

>=(other: StringConstant): bool

Returns whether this text sorts after or the same as other, byte by byte.

Parameters

  • other — the text to compare with

Returns

  • true when the byte-by-byte order is not negative

operator >=

>=(other: String): bool

Returns whether this text sorts after or the same as the String other, byte by byte.

Parameters

  • other — the String to compare with

Returns

  • true when the byte-by-byte order is not negative

trim_end

trim_end(): String

Returns a new String without the whitespace at the end.

Returns

  • a new String with the trailing whitespace removed

trim_start

trim_start(): String

Returns a new String without the whitespace at the start.

Returns

  • a new String with the leading whitespace removed

trim

trim(): String

Returns a new String without the whitespace at either end.

Returns

  • a new String with the leading and trailing whitespace removed

lower

lower(): String

Returns a new String with the ASCII letters in lowercase.

Returns

  • a new String

upper

upper(): String

Returns a new String with the ASCII letters in uppercase.

Returns

  • a new String

lines

lines(): String[]

Returns the lines of the text, split at "\n" or "\r\n", as String.lines does.

Returns

  • a new array of Strings, without the line breaks

repeat

repeat(count: ulong): String

Returns a new String of count copies of the text.

Parameters

  • count — the number of copies

Returns

  • a new String; empty when count is 0

split

split(separator: String): String[]

Returns the pieces of the text between each separator, as String.split does.

Parameters

  • separator — the text to split at

Returns

  • a new array of Strings

Panics

  • if separator is empty

split_once

split_once(separator: String): (String, String)?

Returns the text before and after the first separator, or Empty when it does not occur, as String.split_once does.

Parameters

  • separator — the text to split at

Returns

  • the two pieces, or Empty

Panics

  • if separator is empty

join

join(parts: String[]): String

Returns parts joined into a new String with this text between each two.

Parameters

  • parts — the Strings to join

Returns

  • a new String

replace

replace(from: String, to: String): String

Returns a new String with every occurrence of from replaced by to, as String.replace does.

Parameters

  • from — the text to replace
  • to — the text to put in its place

Returns

  • a new String

Panics

  • if from is empty

iterate

iterate(): StringIterator

Returns an iterator over the code points, so for (var c in text) visits each one as a one-code-point String.

Returns

  • a new iterator, positioned before the first code point

to_string

to_string(value: long): String

Returns the decimal text of value, with a leading - when it is negative.

to_string is overloaded for every integer type, so a call with any integer picks its own overload.

Parameters

  • value — the number to convert

Returns

  • a new String such as "-42"

Example

assert(to_string(-42) == "-42", "negative");
var where = to_string(line) + ":" + to_string(column);

to_string

to_string(value: ulong): String

Returns the decimal text of value.

Parameters

  • value — the number to convert

Returns

  • a new String such as "18446744073709551615"

to_string

to_string(value: int): String

Returns the decimal text of value, with a leading - when it is negative.

Parameters

  • value — the number to convert

Returns

  • a new String such as "7"

to_string

to_string(value: uint): String

Returns the decimal text of value.

Parameters

  • value — the number to convert

Returns

  • a new String such as "7"

to_string

to_string(value: short): String

Returns the decimal text of value, with a leading - when it is negative.

Parameters

  • value — the number to convert

Returns

  • a new String such as "-7"

to_string

to_string(value: ushort): String

Returns the decimal text of value.

Parameters

  • value — the number to convert

Returns

  • a new String such as "7"

to_string

to_string(value: char): String

Returns the decimal text of value, with a leading - when it is negative.

Parameters

  • value — the number to convert

Returns

  • a new String such as "-7"

to_string

to_string(value: byte): String

Returns the decimal text of value.

Parameters

  • value — the number to convert

Returns

  • a new String such as "7"

to_string

to_string(value: bool): String

Returns "true" or "false".

Parameters

  • value — the value to convert

Returns

  • a new String, "true" or "false"

to_string

to_string(value: double): String

Returns the shortest decimal text that reads back as exactly value, as Swift prints a Double.

A whole number keeps a .0 ("3.0"), so the text always reads as a floating-point number. Values from 1e-4 up to 1e16 print in decimal; others use an exponent ("1e+16", "1.5e-05"). The special values are "nan", "inf" and "-inf". For a fixed number of fraction digits, interpolate with a precision instead: $"{value:.2}".

The digits come from the Ryu algorithm, which takes a fixed, small number of steps per value.

Parameters

  • value — the number to convert

Returns

  • a new String such as "0.1" or "-2.5"

Example

assert(to_string(0.1) == "0.1", "shortest");
assert(to_string(3.0) == "3.0", "keeps the point");
assert(to_string(1.0e20) == "1e+20", "exponent");

to_string

to_string(value: float): String

Returns the shortest decimal text that reads back as exactly value as a float, laid out as to_string(double) lays it out.

Parameters

  • value — the number to convert

Returns

  • a new String such as "0.1"

Example

float third = 1.0 / 3.0;
assert(to_string(third) == "0.33333334", "a float's digits");

to_string

to_string<A, B>(value: (A, B)): String

Returns the text of a pair, "(1, x)": each element’s to_string text, separated by ", ", in parentheses. A String element is its text without quotes. Tuples of up to eight elements convert this way.

Type parameters

  • A — the type of the first element
  • B — the type of the second element

Parameters

  • value — the tuple to convert

Returns

  • a new String such as "(1, two)"

Example

assert(to_string((1, "two")) == "(1, two)", "a pair");
var nested = (1, (2.5, true));
assert($"{nested}" == "(1, (2.5, true))", "nested");

to_string

to_string<A, B, C>(value: (A, B, C)): String

Returns the text of a three-element tuple, as the pair overload does.

Type parameters

  • A — the type of the first element
  • B — the type of the second element
  • C — the type of the third element

Parameters

  • value — the tuple to convert

Returns

  • a new String such as "(1, 2, 3)"

to_string

to_string<A, B, C, D>(value: (A, B, C, D)): String

Returns the text of a four-element tuple, as the pair overload does.

Type parameters

  • A — the type of the first element
  • B — the type of the second element
  • C — the type of the third element
  • D — the type of the fourth element

Parameters

  • value — the tuple to convert

Returns

  • a new String such as "(1, 2, 3, 4)"

to_string

to_string<A, B, C, D, E>(value: (A, B, C, D, E)): String

Returns the text of a five-element tuple, as the pair overload does.

Type parameters

  • A — the type of the first element
  • B — the type of the second element
  • C — the type of the third element
  • D — the type of the fourth element
  • E — the type of the fifth element

Parameters

  • value — the tuple to convert

Returns

  • a new String such as "(1, 2, 3, 4, 5)"

to_string

to_string<A, B, C, D, E, F>(value: (A, B, C, D, E, F)): String

Returns the text of a six-element tuple, as the pair overload does.

Type parameters

  • A — the type of the first element
  • B — the type of the second element
  • C — the type of the third element
  • D — the type of the fourth element
  • E — the type of the fifth element
  • F — the type of the sixth element

Parameters

  • value — the tuple to convert

Returns

  • a new String such as "(1, 2, 3, 4, 5, 6)"

to_string

to_string<A, B, C, D, E, F, G>(value: (A, B, C, D, E, F, G)): String

Returns the text of a seven-element tuple, as the pair overload does.

Type parameters

  • A — the type of the first element
  • B — the type of the second element
  • C — the type of the third element
  • D — the type of the fourth element
  • E — the type of the fifth element
  • F — the type of the sixth element
  • G — the type of the seventh element

Parameters

  • value — the tuple to convert

Returns

  • a new String such as "(1, 2, 3, 4, 5, 6, 7)"

to_string

to_string<A, B, C, D, E, F, G, H>(value: (A, B, C, D, E, F, G, H)): String

Returns the text of an eight-element tuple, as the pair overload does.

Type parameters

  • A — the type of the first element
  • B — the type of the second element
  • C — the type of the third element
  • D — the type of the fourth element
  • E — the type of the fifth element
  • F — the type of the sixth element
  • G — the type of the seventh element
  • H — the type of the eighth element

Parameters

  • value — the tuple to convert

Returns

  • a new String such as "(1, 2, 3, 4, 5, 6, 7, 8)"

print

print(text: String)

Writes text and a line break to standard output.

Any other value prints through print(value), which writes its to_string text. To combine values, interpolate them (print($"total: {n}")), or pass a format string and arguments, which prints what String.format makes of them (print("{0} of {1}", a, b)). A string with no arguments after it is printed as written, braces included.

Each line is flushed as it is printed, as Rust’s println! does, so the output appears as the program runs even when standard output is a pipe or a file (where C would buffer it until the program exits).

Parameters

  • text — the text to print

Example

print("hello");
var n = 3;
print($"n = {n}");
print("{0} + {1} = {2}", 1, 2, 1 + 2);   # 1 + 2 = 3
print<T>(value: T)

Writes the text of value and a line break to standard output: what $"{value}" would make of it, through to_string. That covers numbers, bool (true or false), tuples, and any type with a to_string() method or a to_string<T> override; another type is a compile error at the argument.

Type parameters

  • T — the type of value

Parameters

  • value — the value to print

Example

print(42);          # 42
print(0.5);         # 0.5
print((1, "two"));  # (1, two)

to_string

to_string<T>(value: T): String

Returns the text of value by calling its to_string() method: the overload for any type that is ToString.

Declare a concrete override for a type that has no to_string() method of its own, or whose interpolated text should differ from it; like hash<Point>, an override is used by the whole program, including interpolated strings.

Type parameters

  • T — the type of value; it must have a to_string() method unless an override to_string<T> exists

Parameters

  • value — the value to convert

Returns

  • the String that value.to_string() returns

Example

struct Point {
    var x: int = 0;
    var y: int = 0;
    construct(this.x = 0, this.y = 0) {}
}

to_string<Point>(value: Point): String {
    var x = value.x;
    var y = value.y;
    return $"({x}, {y})";
}

ToString

trait ToString

Something with a text form: a to_string() method returning a String.

An interpolated string ($"total: {a + b}") converts each value it holds with to_string(value), so a struct that implements ToString can be interpolated, as can the integers, bool, and String, which have to_string overloads. String, StringBuilder and io.Path implement it.

Example

struct Point {
    var x: int = 0;
    var y: int = 0;
    construct(this.x = 0, this.y = 0) {}
}

extend Point with ToString {
    to_string(): String {
        return $"({x}, {y})";
    }
}

var p = Point(1, 2);
assert($"p = {p}" == "p = (1, 2)", "interpolated");

Methods

to_string

to_string(): String

Returns the text of this value.

Returns

  • a String

StringBuilder

struct StringBuilder

Builds a String from many pieces without copying the text built so far on every append.

String.append grows its buffer to the exact new size, so a long run of appends can copy the text repeatedly. A StringBuilder doubles its buffer when it is full, so appending is amortized O(piece size). Call to_string() for the result; the builder can keep appending afterward.

Example

var out = StringBuilder();
out.append("line ").append(to_string(3)).append("\n");
var text = out.to_string();   # "line 3\n"

Properties

size

var size: ulong { get; }

The number of bytes of UTF-8 appended so far. O(1).

length

var length: ulong { get; }

The number of code points appended so far. O(1).

is_empty

var is_empty: bool { get; }

Whether nothing has been appended since the builder was created or last cleared. O(1).

Constructor

StringBuilder()

construct(capacity: ulong = 0)

Creates an empty StringBuilder, optionally with room for capacity bytes before it has to grow.

Parameters

  • capacity — the number of bytes to allocate up front

Methods

append

append(text: String): StringBuilder

Appends the text of text and returns this builder, so calls can be chained. Runs in amortized O(text.size).

Parameters

  • text — the String to append; it is not modified

Returns

  • this StringBuilder

Example

var b = StringBuilder();
b.append("a").append("b");   # b.to_string() == "ab"

clear

clear()

Removes all the text and keeps the buffer for reuse. O(1).

to_string

to_string(): String

Returns a new String holding the text appended so far. The builder is unchanged. Runs in O(size).

Returns

  • a new String

StringIterator

[Value] struct StringIterator

An Iterator<String> over a String’s code points, returned by String.iterate() and used by for (var c in s).

Call next() to advance, then current() for the code point it moved to. Both methods panic if the String has been modified since the iterator was created. Create it with String.iterate(): a default-constructed StringIterator() has no String to walk.

Example

var it = "ab".iterate();
while (it.next()) {
    print(it.current());   # "a", then "b"
}

Methods

next

next(): bool

Advances to the next code point. Runs in O(1) per code point.

Returns false once it passes the end, and on every call after that. A malformed UTF-8 sequence is yielded, together with the rest of the bytes, as one final “code point”.

Returns

  • true when positioned on a code point, false at the end

Panics

  • if the String was modified since the iterator was created

current

current(): String

Returns the code point that the last successful next() moved to, as a new one-code-point String.

Returns

  • a new String that holds the current code point

Panics

  • if the String was modified since the iterator was created
  • if next() has not been called yet
  • if next() has returned false (the iterator is past the end)

Numbers

Reading numbers from text, and arithmetic that says what to do on overflow.

Every number type has a static parse: int.parse("42") gives an int?, empty when the text is not a number of that type. The integer types take a base from 2 to 36; float and double read decimal notation. Each spelling of a type is the same type, so i32.parse is int.parse.

Integer +, - and * panic on overflow. Each integer type also has wrapping_add (the result modulo 2^bits), checked_add (none on overflow), saturating_add (clamped to the type’s range) and overflowing_add (the wrapped result and whether it overflowed), with sub and mul forms of each.

Each integer type also has bit operations that compile to one instruction on most CPUs (leading_zeros, trailing_zeros, count_ones, count_zeros, swap_bytes, reverse_bits, rotate_left, rotate_right), and min, max and, when signed, abs. float and double have square_root, floor, ceiling, truncate, round, abs, copy_sign, multiply_add, min and max, the trigonometric, hyperbolic, exponential and logarithmic functions of C’s <math.h> with their names spelled out (sine, arc_tangent2, logarithm10, …), cube_root, hypotenuse, remainder, and is_nan, is_infinite, is_finite and sign_bit. The math module has their constants (math.PI).

Example

var port = int.parse("8080");
assert(port.is_value() && port.value() == 8080, "parsed");
assert(byte.parse("256").is_empty(), "too big for a byte");
int big = 2147483647;
assert(big.wrapping_add(1) == -2147483648, "wraps around");
assert(big.checked_add(1).is_empty(), "does not fit");
assert((40 as uint).trailing_zeros() == 3, "40 is 0b101000");

Source: runtime/number.pep

extend char

extend char

Static methods

parse

static parse(text: String, base: int = 10): char?

Parses text as a char (i8) written in base: an optional + or -, then one or more digits, and nothing else. Digits past 9 are the letters, either case. No spaces, prefixes (0x) or separators are accepted.

Parameters

  • text — the text to parse
  • base — the base the digits are written in, from 2 to 36; 10 by default

Returns

  • the value, or none when text is not an integer in base or the value is outside -128 to 127

Panics

  • if base is less than 2 or greater than 36

Example

assert(i8.parse("-128").value() == -128, "the lowest");

Methods

is_digit

is_digit(): bool

Whether this is an ASCII decimal digit, 0 to 9, like C’s isdigit.

Like every classification method on char, it looks at ASCII only, as C does in the “C” locale: a negative char (a byte of UTF-8 text above 127) is never a digit, letter, space or punctuation. Unlike C’s functions, it is defined for every value, negative ones included.

Returns

  • true for 0 to 9

is_hex_digit

is_hex_digit(): bool

Whether this is an ASCII hexadecimal digit, 0 to 9, a to f or A to F, like C’s isxdigit.

Returns

  • true for a hexadecimal digit, either case

is_alphabetic

is_alphabetic(): bool

Whether this is an ASCII letter, a to z or A to Z, like C’s isalpha.

Returns

  • true for an ASCII letter

is_alphanumeric

is_alphanumeric(): bool

Whether this is an ASCII letter or digit, like C’s isalnum.

Returns

  • true for a to z, A to Z or 0 to 9

is_upper

is_upper(): bool

Whether this is an ASCII uppercase letter, A to Z, like C’s isupper.

Returns

  • true for A to Z

is_lower

is_lower(): bool

Whether this is an ASCII lowercase letter, a to z, like C’s islower.

Returns

  • true for a to z

is_whitespace

is_whitespace(): bool

Whether this is ASCII whitespace: a space, tab (\t), line feed (\n), vertical tab, form feed or carriage return (\r), like C’s isspace. String.trim strips the same set.

Returns

  • true for one of the six whitespace characters

is_blank

is_blank(): bool

Whether this is a space or a tab, like C’s isblank.

Returns

  • true for ' ' and '\t'

is_punctuation

is_punctuation(): bool

Whether this is ASCII punctuation: printable, but not a letter, digit or space, like C’s ispunct. That is !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~.

Returns

  • true for one of the 32 ASCII punctuation characters

is_graphic

is_graphic(): bool

Whether this is printable and not a space, ! to ~, like C’s isgraph.

Returns

  • true for a letter, digit or punctuation

is_printable

is_printable(): bool

Whether this is printable, a space through ~ (0x20 to 0x7E), like C’s isprint.

Returns

  • true for a space, letter, digit or punctuation

is_control

is_control(): bool

Whether this is an ASCII control character, 0x00 to 0x1F or 0x7F (delete), like C’s iscntrl.

Returns

  • true for a control character

is_ascii

is_ascii(): bool

Whether this is ASCII, 0x00 to 0x7F, like C’s isascii.

Returns

  • true for a value from 0 to 127

to_upper

to_upper(): char

Returns the uppercase letter for an ASCII lowercase letter, and this value unchanged for anything else, like C’s toupper.

Returns

  • A to Z for a to z, otherwise this value

to_lower

to_lower(): char

Returns the lowercase letter for an ASCII uppercase letter, and this value unchanged for anything else, like C’s tolower.

Returns

  • a to z for A to Z, otherwise this value

digit_value

digit_value(base: int = 10): int?

Returns the value of this digit in base: 0 to 9 are 0 to 9, and the letters, either case, are 10 and up (a is 10, z is 35).

Parameters

  • base — the base the digit is written in, from 2 to 36; 10 by default

Returns

  • the digit’s value, or none when this is not a digit in base

Panics

  • if base is less than 2 or greater than 36

wrapping_add

wrapping_add(other: char): char

Returns this + other modulo 2^8: on overflow the result wraps around instead of panicking. For hashes, checksums and random number generators, which rely on wrapping.

Parameters

  • other — the value to add

Returns

  • the sum, wrapped to a char

checked_add

checked_add(other: char): char?

Returns this + other, or none when the sum does not fit a char.

Parameters

  • other — the value to add

Returns

  • the sum, or none on overflow

saturating_add

saturating_add(other: char): char

Returns this + other, clamped to the highest or lowest char on overflow.

Parameters

  • other — the value to add

Returns

  • the sum, or the nearest char to it

overflowing_add

overflowing_add(other: char): (char, bool)

Returns this + other modulo 2^8, and whether the operation overflowed.

Parameters

  • other — the value to add

Returns

  • the wrapped sum, and true when it overflowed

wrapping_sub

wrapping_sub(other: char): char

Returns this - other modulo 2^8: on overflow the result wraps around instead of panicking. For hashes, checksums and random number generators, which rely on wrapping.

Parameters

  • other — the value to subtract

Returns

  • the difference, wrapped to a char

checked_sub

checked_sub(other: char): char?

Returns this - other, or none when the difference does not fit a char.

Parameters

  • other — the value to subtract

Returns

  • the difference, or none on overflow

saturating_sub

saturating_sub(other: char): char

Returns this - other, clamped to the highest or lowest char on overflow.

Parameters

  • other — the value to subtract

Returns

  • the difference, or the nearest char to it

overflowing_sub

overflowing_sub(other: char): (char, bool)

Returns this - other modulo 2^8, and whether the operation overflowed.

Parameters

  • other — the value to subtract

Returns

  • the wrapped difference, and true when it overflowed

wrapping_mul

wrapping_mul(other: char): char

Returns this * other modulo 2^8: on overflow the result wraps around instead of panicking. For hashes, checksums and random number generators, which rely on wrapping.

Parameters

  • other — the value to multiply by

Returns

  • the product, wrapped to a char

checked_mul

checked_mul(other: char): char?

Returns this * other, or none when the product does not fit a char.

Parameters

  • other — the value to multiply by

Returns

  • the product, or none on overflow

saturating_mul

saturating_mul(other: char): char

Returns this * other, clamped to the highest or lowest char on overflow.

Parameters

  • other — the value to multiply by

Returns

  • the product, or the nearest char to it

overflowing_mul

overflowing_mul(other: char): (char, bool)

Returns this * other modulo 2^8, and whether the operation overflowed.

Parameters

  • other — the value to multiply by

Returns

  • the wrapped product, and true when it overflowed

leading_zeros

leading_zeros(): int

Returns the number of zero bits above the highest set bit: 8 for zero, 0 when the highest bit is set. One instruction on most CPUs (llvm.ctlz).

Returns

  • the count of leading zero bits, from 0 to 8

Example

assert(((1 as char)).leading_zeros() == 7, "one bit, at the bottom");

trailing_zeros

trailing_zeros(): int

Returns the number of zero bits below the lowest set bit: 8 for zero. One instruction on most CPUs (llvm.cttz), so it finds the first set bit of a mask without a loop.

Returns

  • the count of trailing zero bits, from 0 to 8

Example

assert((8 as char).trailing_zeros() == 3, "8 is 0b1000");

count_ones

count_ones(): int

Returns the number of bits that are set (the population count, llvm.ctpop).

Returns

  • the count of one bits, from 0 to 8

Example

assert((11 as char).count_ones() == 3, "three bits set");

count_zeros

count_zeros(): int

Returns the number of bits that are clear.

Returns

  • the count of zero bits, from 0 to 8

Example

assert((11 as char).count_zeros() == 5, "three bits set");

swap_bytes

swap_bytes(): char

Returns this value with its bytes in reverse order: a one-byte value is itself. Each integer type has this method; see int.swap_bytes.

Returns

  • this value

reverse_bits

reverse_bits(): char

Returns this value with its bits in reverse order: the lowest bit becomes the highest (llvm.bitreverse).

Returns

  • the value with its 8 bits reversed

Example

assert(((1 as char)).reverse_bits().leading_zeros() == 0, "bottom to top");

rotate_left

rotate_left(count: int): char

Returns this value’s bits rotated left by count: the bits shifted out at the top come back in at the bottom (llvm.fshl). count is taken modulo 8, so a negative count rotates right.

Parameters

  • count — how many places to rotate

Returns

  • the rotated value

Example

assert((9 as char).rotate_left(7) == (9 as char).rotate_right(1), "the same rotation");

rotate_right

rotate_right(count: int): char

Returns this value’s bits rotated right by count: the bits shifted out at the bottom come back in at the top (llvm.fshr). count is taken modulo 8, so a negative count rotates left.

Parameters

  • count — how many places to rotate

Returns

  • the rotated value

Example

assert(((1 as char)).rotate_right(1) == ((1 as char)).rotate_left(7), "the bottom bit to the top");

min

min(other: char): char

Returns the smaller of this value and other.

Parameters

  • other — the value to compare with

Returns

  • this when it is not greater than other, else other

Example

assert((3 as char).min(5) == 3, "smaller");

max

max(other: char): char

Returns the larger of this value and other.

Parameters

  • other — the value to compare with

Returns

  • this when it is not less than other, else other

Example

assert((3 as char).max(5) == 5, "larger");

abs

abs(): char

Returns the absolute value: this without its sign. The lowest value, -128, has no positive counterpart in a char, so it panics, as its negation does.

Returns

  • -this when this value is negative, else this

Panics

  • if this value is -128 (“integer overflow in ‘char’ absolute value”)

Example

assert((-5 as char).abs() == 5, "negative");

extend byte

extend byte

Static methods

parse

static parse(text: String, base: int = 10): byte?

Parses text as a byte (u8) written in base: an optional +, then one or more digits, and nothing else. Digits past 9 are the letters, either case. No spaces, prefixes (0x) or separators are accepted.

Parameters

  • text — the text to parse
  • base — the base the digits are written in, from 2 to 36; 10 by default

Returns

  • the value, or none when text is not an integer in base or the value is above 255 (a - sign is never accepted)

Panics

  • if base is less than 2 or greater than 36

Example

assert(byte.parse("ff", 16).value() == 255, "hexadecimal");

Methods

is_digit

is_digit(): bool

Whether this is an ASCII decimal digit, 0 to 9, like C’s isdigit.

Like every classification method on byte, it looks at ASCII only, as C does in the “C” locale: a value above 127 is never a digit, letter, space or punctuation.

Returns

  • true for 0 to 9

Example

assert(('7' as byte).is_digit(), "a digit");
assert(!('x' as byte).is_digit(), "a letter");

is_hex_digit

is_hex_digit(): bool

Whether this is an ASCII hexadecimal digit, 0 to 9, a to f or A to F, like C’s isxdigit.

Returns

  • true for a hexadecimal digit, either case

is_alphabetic

is_alphabetic(): bool

Whether this is an ASCII letter, a to z or A to Z, like C’s isalpha.

Returns

  • true for an ASCII letter

is_alphanumeric

is_alphanumeric(): bool

Whether this is an ASCII letter or digit, like C’s isalnum.

Returns

  • true for a to z, A to Z or 0 to 9

is_upper

is_upper(): bool

Whether this is an ASCII uppercase letter, A to Z, like C’s isupper.

Returns

  • true for A to Z

is_lower

is_lower(): bool

Whether this is an ASCII lowercase letter, a to z, like C’s islower.

Returns

  • true for a to z

is_whitespace

is_whitespace(): bool

Whether this is ASCII whitespace: a space, tab (\t), line feed (\n), vertical tab, form feed or carriage return (\r), like C’s isspace. String.trim strips the same set.

Returns

  • true for one of the six whitespace characters

is_blank

is_blank(): bool

Whether this is a space or a tab, like C’s isblank.

Returns

  • true for ' ' and '\t'

is_punctuation

is_punctuation(): bool

Whether this is ASCII punctuation: printable, but not a letter, digit or space, like C’s ispunct. That is !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~.

Returns

  • true for one of the 32 ASCII punctuation characters

Example

assert((';' as byte).is_punctuation(), "punctuation");
assert(!(' ' as byte).is_punctuation(), "a space is not");

is_graphic

is_graphic(): bool

Whether this is printable and not a space, ! to ~, like C’s isgraph.

Returns

  • true for a letter, digit or punctuation

is_printable

is_printable(): bool

Whether this is printable, a space through ~ (0x20 to 0x7E), like C’s isprint.

Returns

  • true for a space, letter, digit or punctuation

is_control

is_control(): bool

Whether this is an ASCII control character, 0x00 to 0x1F or 0x7F (delete), like C’s iscntrl.

Returns

  • true for a control character

is_ascii

is_ascii(): bool

Whether this is ASCII, 0x00 to 0x7F, like C’s isascii.

Returns

  • true for a value below 128

to_upper

to_upper(): byte

Returns the uppercase letter for an ASCII lowercase letter, and this value unchanged for anything else, like C’s toupper.

Returns

  • A to Z for a to z, otherwise this value

Example

assert(('q' as byte).to_upper() == 'Q', "raised");
assert(('?' as byte).to_upper() == '?', "kept");

to_lower

to_lower(): byte

Returns the lowercase letter for an ASCII uppercase letter, and this value unchanged for anything else, like C’s tolower.

Returns

  • a to z for A to Z, otherwise this value

digit_value

digit_value(base: int = 10): int?

Returns the value of this digit in base: 0 to 9 are 0 to 9, and the letters, either case, are 10 and up (a is 10, z is 35).

Parameters

  • base — the base the digit is written in, from 2 to 36; 10 by default

Returns

  • the digit’s value, or none when this is not a digit in base

Panics

  • if base is less than 2 or greater than 36

Example

assert(('7' as byte).digit_value().value() == 7, "decimal");
assert(('F' as byte).digit_value(16).value() == 15, "hexadecimal");
assert(('9' as byte).digit_value(8).is_empty(), "not octal");

wrapping_add

wrapping_add(other: byte): byte

Returns this + other modulo 2^8: on overflow the result wraps around instead of panicking. For hashes, checksums and random number generators, which rely on wrapping.

Parameters

  • other — the value to add

Returns

  • the sum, wrapped to a byte

checked_add

checked_add(other: byte): byte?

Returns this + other, or none when the sum does not fit a byte.

Parameters

  • other — the value to add

Returns

  • the sum, or none on overflow

saturating_add

saturating_add(other: byte): byte

Returns this + other, clamped to the highest byte on overflow.

Parameters

  • other — the value to add

Returns

  • the sum, or the nearest byte to it

overflowing_add

overflowing_add(other: byte): (byte, bool)

Returns this + other modulo 2^8, and whether the operation overflowed.

Parameters

  • other — the value to add

Returns

  • the wrapped sum, and true when it overflowed

wrapping_sub

wrapping_sub(other: byte): byte

Returns this - other modulo 2^8: on overflow the result wraps around instead of panicking. For hashes, checksums and random number generators, which rely on wrapping.

Parameters

  • other — the value to subtract

Returns

  • the difference, wrapped to a byte

checked_sub

checked_sub(other: byte): byte?

Returns this - other, or none when the difference does not fit a byte.

Parameters

  • other — the value to subtract

Returns

  • the difference, or none on overflow

saturating_sub

saturating_sub(other: byte): byte

Returns this - other, clamped to 0 on overflow.

Parameters

  • other — the value to subtract

Returns

  • the difference, or the nearest byte to it

overflowing_sub

overflowing_sub(other: byte): (byte, bool)

Returns this - other modulo 2^8, and whether the operation overflowed.

Parameters

  • other — the value to subtract

Returns

  • the wrapped difference, and true when it overflowed

wrapping_mul

wrapping_mul(other: byte): byte

Returns this * other modulo 2^8: on overflow the result wraps around instead of panicking. For hashes, checksums and random number generators, which rely on wrapping.

Parameters

  • other — the value to multiply by

Returns

  • the product, wrapped to a byte

checked_mul

checked_mul(other: byte): byte?

Returns this * other, or none when the product does not fit a byte.

Parameters

  • other — the value to multiply by

Returns

  • the product, or none on overflow

saturating_mul

saturating_mul(other: byte): byte

Returns this * other, clamped to the highest byte on overflow.

Parameters

  • other — the value to multiply by

Returns

  • the product, or the nearest byte to it

overflowing_mul

overflowing_mul(other: byte): (byte, bool)

Returns this * other modulo 2^8, and whether the operation overflowed.

Parameters

  • other — the value to multiply by

Returns

  • the wrapped product, and true when it overflowed

leading_zeros

leading_zeros(): int

Returns the number of zero bits above the highest set bit: 8 for zero, 0 when the highest bit is set. One instruction on most CPUs (llvm.ctlz).

Returns

  • the count of leading zero bits, from 0 to 8

Example

assert(((1 as byte)).leading_zeros() == 7, "one bit, at the bottom");

trailing_zeros

trailing_zeros(): int

Returns the number of zero bits below the lowest set bit: 8 for zero. One instruction on most CPUs (llvm.cttz), so it finds the first set bit of a mask without a loop.

Returns

  • the count of trailing zero bits, from 0 to 8

Example

assert((8 as byte).trailing_zeros() == 3, "8 is 0b1000");

count_ones

count_ones(): int

Returns the number of bits that are set (the population count, llvm.ctpop).

Returns

  • the count of one bits, from 0 to 8

Example

assert((11 as byte).count_ones() == 3, "three bits set");

count_zeros

count_zeros(): int

Returns the number of bits that are clear.

Returns

  • the count of zero bits, from 0 to 8

Example

assert((11 as byte).count_zeros() == 5, "three bits set");

swap_bytes

swap_bytes(): byte

Returns this value with its bytes in reverse order: a one-byte value is itself. Each integer type has this method; see int.swap_bytes.

Returns

  • this value

reverse_bits

reverse_bits(): byte

Returns this value with its bits in reverse order: the lowest bit becomes the highest (llvm.bitreverse).

Returns

  • the value with its 8 bits reversed

Example

assert(((1 as byte)).reverse_bits().leading_zeros() == 0, "bottom to top");

rotate_left

rotate_left(count: int): byte

Returns this value’s bits rotated left by count: the bits shifted out at the top come back in at the bottom (llvm.fshl). count is taken modulo 8, so a negative count rotates right.

Parameters

  • count — how many places to rotate

Returns

  • the rotated value

Example

assert((9 as byte).rotate_left(7) == (9 as byte).rotate_right(1), "the same rotation");

rotate_right

rotate_right(count: int): byte

Returns this value’s bits rotated right by count: the bits shifted out at the bottom come back in at the top (llvm.fshr). count is taken modulo 8, so a negative count rotates left.

Parameters

  • count — how many places to rotate

Returns

  • the rotated value

Example

assert(((1 as byte)).rotate_right(1) == ((1 as byte)).rotate_left(7), "the bottom bit to the top");

min

min(other: byte): byte

Returns the smaller of this value and other.

Parameters

  • other — the value to compare with

Returns

  • this when it is not greater than other, else other

Example

assert((3 as byte).min(5) == 3, "smaller");

max

max(other: byte): byte

Returns the larger of this value and other.

Parameters

  • other — the value to compare with

Returns

  • this when it is not less than other, else other

Example

assert((3 as byte).max(5) == 5, "larger");

extend short

extend short

Static methods

parse

static parse(text: String, base: int = 10): short?

Parses text as a short (i16) written in base: an optional + or -, then one or more digits, and nothing else. Digits past 9 are the letters, either case. No spaces, prefixes (0x) or separators are accepted.

Parameters

  • text — the text to parse
  • base — the base the digits are written in, from 2 to 36; 10 by default

Returns

  • the value, or none when text is not an integer in base or the value is outside -32768 to 32767

Panics

  • if base is less than 2 or greater than 36

Example

assert(short.parse("-300").value() == -300, "decimal");

Methods

wrapping_add

wrapping_add(other: short): short

Returns this + other modulo 2^16: on overflow the result wraps around instead of panicking. For hashes, checksums and random number generators, which rely on wrapping.

Parameters

  • other — the value to add

Returns

  • the sum, wrapped to a short

checked_add

checked_add(other: short): short?

Returns this + other, or none when the sum does not fit a short.

Parameters

  • other — the value to add

Returns

  • the sum, or none on overflow

saturating_add

saturating_add(other: short): short

Returns this + other, clamped to the highest or lowest short on overflow.

Parameters

  • other — the value to add

Returns

  • the sum, or the nearest short to it

overflowing_add

overflowing_add(other: short): (short, bool)

Returns this + other modulo 2^16, and whether the operation overflowed.

Parameters

  • other — the value to add

Returns

  • the wrapped sum, and true when it overflowed

wrapping_sub

wrapping_sub(other: short): short

Returns this - other modulo 2^16: on overflow the result wraps around instead of panicking. For hashes, checksums and random number generators, which rely on wrapping.

Parameters

  • other — the value to subtract

Returns

  • the difference, wrapped to a short

checked_sub

checked_sub(other: short): short?

Returns this - other, or none when the difference does not fit a short.

Parameters

  • other — the value to subtract

Returns

  • the difference, or none on overflow

saturating_sub

saturating_sub(other: short): short

Returns this - other, clamped to the highest or lowest short on overflow.

Parameters

  • other — the value to subtract

Returns

  • the difference, or the nearest short to it

overflowing_sub

overflowing_sub(other: short): (short, bool)

Returns this - other modulo 2^16, and whether the operation overflowed.

Parameters

  • other — the value to subtract

Returns

  • the wrapped difference, and true when it overflowed

wrapping_mul

wrapping_mul(other: short): short

Returns this * other modulo 2^16: on overflow the result wraps around instead of panicking. For hashes, checksums and random number generators, which rely on wrapping.

Parameters

  • other — the value to multiply by

Returns

  • the product, wrapped to a short

checked_mul

checked_mul(other: short): short?

Returns this * other, or none when the product does not fit a short.

Parameters

  • other — the value to multiply by

Returns

  • the product, or none on overflow

saturating_mul

saturating_mul(other: short): short

Returns this * other, clamped to the highest or lowest short on overflow.

Parameters

  • other — the value to multiply by

Returns

  • the product, or the nearest short to it

overflowing_mul

overflowing_mul(other: short): (short, bool)

Returns this * other modulo 2^16, and whether the operation overflowed.

Parameters

  • other — the value to multiply by

Returns

  • the wrapped product, and true when it overflowed

leading_zeros

leading_zeros(): int

Returns the number of zero bits above the highest set bit: 16 for zero, 0 when the highest bit is set. One instruction on most CPUs (llvm.ctlz).

Returns

  • the count of leading zero bits, from 0 to 16

Example

assert(((1 as short)).leading_zeros() == 15, "one bit, at the bottom");

trailing_zeros

trailing_zeros(): int

Returns the number of zero bits below the lowest set bit: 16 for zero. One instruction on most CPUs (llvm.cttz), so it finds the first set bit of a mask without a loop.

Returns

  • the count of trailing zero bits, from 0 to 16

Example

assert((8 as short).trailing_zeros() == 3, "8 is 0b1000");

count_ones

count_ones(): int

Returns the number of bits that are set (the population count, llvm.ctpop).

Returns

  • the count of one bits, from 0 to 16

Example

assert((11 as short).count_ones() == 3, "three bits set");

count_zeros

count_zeros(): int

Returns the number of bits that are clear.

Returns

  • the count of zero bits, from 0 to 16

Example

assert((11 as short).count_zeros() == 13, "three bits set");

swap_bytes

swap_bytes(): short

Returns this value with its bytes in reverse order (llvm.bswap), to convert between little-endian and big-endian byte order.

Returns

  • the value with its 2 bytes reversed

Example

assert((0x1234 as short).swap_bytes() == 0x3412, "bytes reversed");

reverse_bits

reverse_bits(): short

Returns this value with its bits in reverse order: the lowest bit becomes the highest (llvm.bitreverse).

Returns

  • the value with its 16 bits reversed

Example

assert(((1 as short)).reverse_bits().leading_zeros() == 0, "bottom to top");

rotate_left

rotate_left(count: int): short

Returns this value’s bits rotated left by count: the bits shifted out at the top come back in at the bottom (llvm.fshl). count is taken modulo 16, so a negative count rotates right.

Parameters

  • count — how many places to rotate

Returns

  • the rotated value

Example

assert((9 as short).rotate_left(15) == (9 as short).rotate_right(1), "the same rotation");

rotate_right

rotate_right(count: int): short

Returns this value’s bits rotated right by count: the bits shifted out at the bottom come back in at the top (llvm.fshr). count is taken modulo 16, so a negative count rotates left.

Parameters

  • count — how many places to rotate

Returns

  • the rotated value

Example

assert(((1 as short)).rotate_right(1) == ((1 as short)).rotate_left(15), "the bottom bit to the top");

min

min(other: short): short

Returns the smaller of this value and other.

Parameters

  • other — the value to compare with

Returns

  • this when it is not greater than other, else other

Example

assert((3 as short).min(5) == 3, "smaller");

max

max(other: short): short

Returns the larger of this value and other.

Parameters

  • other — the value to compare with

Returns

  • this when it is not less than other, else other

Example

assert((3 as short).max(5) == 5, "larger");

abs

abs(): short

Returns the absolute value: this without its sign. The lowest value, -32768, has no positive counterpart in a short, so it panics, as its negation does.

Returns

  • -this when this value is negative, else this

Panics

  • if this value is -32768 (“integer overflow in ‘short’ absolute value”)

Example

assert((-5 as short).abs() == 5, "negative");

extend ushort

extend ushort

Static methods

parse

static parse(text: String, base: int = 10): ushort?

Parses text as a ushort (u16) written in base: an optional +, then one or more digits, and nothing else. Digits past 9 are the letters, either case. No spaces, prefixes (0x) or separators are accepted.

Parameters

  • text — the text to parse
  • base — the base the digits are written in, from 2 to 36; 10 by default

Returns

  • the value, or none when text is not an integer in base or the value is above 65535 (a - sign is never accepted)

Panics

  • if base is less than 2 or greater than 36

Example

assert(ushort.parse("8080").value() == 8080, "a port");

Methods

wrapping_add

wrapping_add(other: ushort): ushort

Returns this + other modulo 2^16: on overflow the result wraps around instead of panicking. For hashes, checksums and random number generators, which rely on wrapping.

Parameters

  • other — the value to add

Returns

  • the sum, wrapped to a ushort

checked_add

checked_add(other: ushort): ushort?

Returns this + other, or none when the sum does not fit a ushort.

Parameters

  • other — the value to add

Returns

  • the sum, or none on overflow

saturating_add

saturating_add(other: ushort): ushort

Returns this + other, clamped to the highest ushort on overflow.

Parameters

  • other — the value to add

Returns

  • the sum, or the nearest ushort to it

overflowing_add

overflowing_add(other: ushort): (ushort, bool)

Returns this + other modulo 2^16, and whether the operation overflowed.

Parameters

  • other — the value to add

Returns

  • the wrapped sum, and true when it overflowed

wrapping_sub

wrapping_sub(other: ushort): ushort

Returns this - other modulo 2^16: on overflow the result wraps around instead of panicking. For hashes, checksums and random number generators, which rely on wrapping.

Parameters

  • other — the value to subtract

Returns

  • the difference, wrapped to a ushort

checked_sub

checked_sub(other: ushort): ushort?

Returns this - other, or none when the difference does not fit a ushort.

Parameters

  • other — the value to subtract

Returns

  • the difference, or none on overflow

saturating_sub

saturating_sub(other: ushort): ushort

Returns this - other, clamped to 0 on overflow.

Parameters

  • other — the value to subtract

Returns

  • the difference, or the nearest ushort to it

overflowing_sub

overflowing_sub(other: ushort): (ushort, bool)

Returns this - other modulo 2^16, and whether the operation overflowed.

Parameters

  • other — the value to subtract

Returns

  • the wrapped difference, and true when it overflowed

wrapping_mul

wrapping_mul(other: ushort): ushort

Returns this * other modulo 2^16: on overflow the result wraps around instead of panicking. For hashes, checksums and random number generators, which rely on wrapping.

Parameters

  • other — the value to multiply by

Returns

  • the product, wrapped to a ushort

checked_mul

checked_mul(other: ushort): ushort?

Returns this * other, or none when the product does not fit a ushort.

Parameters

  • other — the value to multiply by

Returns

  • the product, or none on overflow

saturating_mul

saturating_mul(other: ushort): ushort

Returns this * other, clamped to the highest ushort on overflow.

Parameters

  • other — the value to multiply by

Returns

  • the product, or the nearest ushort to it

overflowing_mul

overflowing_mul(other: ushort): (ushort, bool)

Returns this * other modulo 2^16, and whether the operation overflowed.

Parameters

  • other — the value to multiply by

Returns

  • the wrapped product, and true when it overflowed

leading_zeros

leading_zeros(): int

Returns the number of zero bits above the highest set bit: 16 for zero, 0 when the highest bit is set. One instruction on most CPUs (llvm.ctlz).

Returns

  • the count of leading zero bits, from 0 to 16

Example

assert(((1 as ushort)).leading_zeros() == 15, "one bit, at the bottom");

trailing_zeros

trailing_zeros(): int

Returns the number of zero bits below the lowest set bit: 16 for zero. One instruction on most CPUs (llvm.cttz), so it finds the first set bit of a mask without a loop.

Returns

  • the count of trailing zero bits, from 0 to 16

Example

assert((8 as ushort).trailing_zeros() == 3, "8 is 0b1000");

count_ones

count_ones(): int

Returns the number of bits that are set (the population count, llvm.ctpop).

Returns

  • the count of one bits, from 0 to 16

Example

assert((11 as ushort).count_ones() == 3, "three bits set");

count_zeros

count_zeros(): int

Returns the number of bits that are clear.

Returns

  • the count of zero bits, from 0 to 16

Example

assert((11 as ushort).count_zeros() == 13, "three bits set");

swap_bytes

swap_bytes(): ushort

Returns this value with its bytes in reverse order (llvm.bswap), to convert between little-endian and big-endian byte order.

Returns

  • the value with its 2 bytes reversed

Example

assert((0x1234 as ushort).swap_bytes() == 0x3412, "bytes reversed");

reverse_bits

reverse_bits(): ushort

Returns this value with its bits in reverse order: the lowest bit becomes the highest (llvm.bitreverse).

Returns

  • the value with its 16 bits reversed

Example

assert(((1 as ushort)).reverse_bits().leading_zeros() == 0, "bottom to top");

rotate_left

rotate_left(count: int): ushort

Returns this value’s bits rotated left by count: the bits shifted out at the top come back in at the bottom (llvm.fshl). count is taken modulo 16, so a negative count rotates right.

Parameters

  • count — how many places to rotate

Returns

  • the rotated value

Example

assert((9 as ushort).rotate_left(15) == (9 as ushort).rotate_right(1), "the same rotation");

rotate_right

rotate_right(count: int): ushort

Returns this value’s bits rotated right by count: the bits shifted out at the bottom come back in at the top (llvm.fshr). count is taken modulo 16, so a negative count rotates left.

Parameters

  • count — how many places to rotate

Returns

  • the rotated value

Example

assert(((1 as ushort)).rotate_right(1) == ((1 as ushort)).rotate_left(15), "the bottom bit to the top");

min

min(other: ushort): ushort

Returns the smaller of this value and other.

Parameters

  • other — the value to compare with

Returns

  • this when it is not greater than other, else other

Example

assert((3 as ushort).min(5) == 3, "smaller");

max

max(other: ushort): ushort

Returns the larger of this value and other.

Parameters

  • other — the value to compare with

Returns

  • this when it is not less than other, else other

Example

assert((3 as ushort).max(5) == 5, "larger");

extend int

extend int

Static methods

parse

static parse(text: String, base: int = 10): int?

Parses text as an int (i32) written in base: an optional + or -, then one or more digits, and nothing else. Digits past 9 are the letters, either case ("ff" in base 16 is 255). No spaces, prefixes (0x) or separators are accepted.

Parameters

  • text — the text to parse
  • base — the base the digits are written in, from 2 to 36; 10 by default

Returns

  • the value, or none when text is not an integer in base or the value does not fit an int

Panics

  • if base is less than 2 or greater than 36

Example

assert(int.parse("-42").value() == -42, "decimal");
assert(int.parse("ff", 16).value() == 255, "hexadecimal");
assert(int.parse("12x").is_empty(), "not a number");

Methods

wrapping_add

wrapping_add(other: int): int

Returns this + other modulo 2^32: on overflow the result wraps around instead of panicking. For hashes, checksums and random number generators, which rely on wrapping.

Parameters

  • other — the value to add

Returns

  • the sum, wrapped to a int

checked_add

checked_add(other: int): int?

Returns this + other, or none when the sum does not fit a int.

Parameters

  • other — the value to add

Returns

  • the sum, or none on overflow

saturating_add

saturating_add(other: int): int

Returns this + other, clamped to the highest or lowest int on overflow.

Parameters

  • other — the value to add

Returns

  • the sum, or the nearest int to it

overflowing_add

overflowing_add(other: int): (int, bool)

Returns this + other modulo 2^32, and whether the operation overflowed.

Parameters

  • other — the value to add

Returns

  • the wrapped sum, and true when it overflowed

wrapping_sub

wrapping_sub(other: int): int

Returns this - other modulo 2^32: on overflow the result wraps around instead of panicking. For hashes, checksums and random number generators, which rely on wrapping.

Parameters

  • other — the value to subtract

Returns

  • the difference, wrapped to a int

checked_sub

checked_sub(other: int): int?

Returns this - other, or none when the difference does not fit a int.

Parameters

  • other — the value to subtract

Returns

  • the difference, or none on overflow

saturating_sub

saturating_sub(other: int): int

Returns this - other, clamped to the highest or lowest int on overflow.

Parameters

  • other — the value to subtract

Returns

  • the difference, or the nearest int to it

overflowing_sub

overflowing_sub(other: int): (int, bool)

Returns this - other modulo 2^32, and whether the operation overflowed.

Parameters

  • other — the value to subtract

Returns

  • the wrapped difference, and true when it overflowed

wrapping_mul

wrapping_mul(other: int): int

Returns this * other modulo 2^32: on overflow the result wraps around instead of panicking. For hashes, checksums and random number generators, which rely on wrapping.

Parameters

  • other — the value to multiply by

Returns

  • the product, wrapped to a int

checked_mul

checked_mul(other: int): int?

Returns this * other, or none when the product does not fit a int.

Parameters

  • other — the value to multiply by

Returns

  • the product, or none on overflow

saturating_mul

saturating_mul(other: int): int

Returns this * other, clamped to the highest or lowest int on overflow.

Parameters

  • other — the value to multiply by

Returns

  • the product, or the nearest int to it

overflowing_mul

overflowing_mul(other: int): (int, bool)

Returns this * other modulo 2^32, and whether the operation overflowed.

Parameters

  • other — the value to multiply by

Returns

  • the wrapped product, and true when it overflowed

leading_zeros

leading_zeros(): int

Returns the number of zero bits above the highest set bit: 32 for zero, 0 when the highest bit is set. One instruction on most CPUs (llvm.ctlz).

Returns

  • the count of leading zero bits, from 0 to 32

Example

assert(((1 as int)).leading_zeros() == 31, "one bit, at the bottom");

trailing_zeros

trailing_zeros(): int

Returns the number of zero bits below the lowest set bit: 32 for zero. One instruction on most CPUs (llvm.cttz), so it finds the first set bit of a mask without a loop.

Returns

  • the count of trailing zero bits, from 0 to 32

Example

assert((8 as int).trailing_zeros() == 3, "8 is 0b1000");

count_ones

count_ones(): int

Returns the number of bits that are set (the population count, llvm.ctpop).

Returns

  • the count of one bits, from 0 to 32

Example

assert((11 as int).count_ones() == 3, "three bits set");

count_zeros

count_zeros(): int

Returns the number of bits that are clear.

Returns

  • the count of zero bits, from 0 to 32

Example

assert((11 as int).count_zeros() == 29, "three bits set");

swap_bytes

swap_bytes(): int

Returns this value with its bytes in reverse order (llvm.bswap), to convert between little-endian and big-endian byte order.

Returns

  • the value with its 4 bytes reversed

Example

assert((0x12345678 as int).swap_bytes() == 0x78563412, "bytes reversed");

reverse_bits

reverse_bits(): int

Returns this value with its bits in reverse order: the lowest bit becomes the highest (llvm.bitreverse).

Returns

  • the value with its 32 bits reversed

Example

assert(((1 as int)).reverse_bits().leading_zeros() == 0, "bottom to top");

rotate_left

rotate_left(count: int): int

Returns this value’s bits rotated left by count: the bits shifted out at the top come back in at the bottom (llvm.fshl). count is taken modulo 32, so a negative count rotates right.

Parameters

  • count — how many places to rotate

Returns

  • the rotated value

Example

assert((9 as int).rotate_left(31) == (9 as int).rotate_right(1), "the same rotation");

rotate_right

rotate_right(count: int): int

Returns this value’s bits rotated right by count: the bits shifted out at the bottom come back in at the top (llvm.fshr). count is taken modulo 32, so a negative count rotates left.

Parameters

  • count — how many places to rotate

Returns

  • the rotated value

Example

assert(((1 as int)).rotate_right(1) == ((1 as int)).rotate_left(31), "the bottom bit to the top");

min

min(other: int): int

Returns the smaller of this value and other.

Parameters

  • other — the value to compare with

Returns

  • this when it is not greater than other, else other

Example

assert((3 as int).min(5) == 3, "smaller");

max

max(other: int): int

Returns the larger of this value and other.

Parameters

  • other — the value to compare with

Returns

  • this when it is not less than other, else other

Example

assert((3 as int).max(5) == 5, "larger");

abs

abs(): int

Returns the absolute value: this without its sign. The lowest value, -2147483648, has no positive counterpart in a int, so it panics, as its negation does.

Returns

  • -this when this value is negative, else this

Panics

  • if this value is -2147483648 (“integer overflow in ‘int’ absolute value”)

Example

assert((-5 as int).abs() == 5, "negative");

extend uint

extend uint

Static methods

parse

static parse(text: String, base: int = 10): uint?

Parses text as a uint (u32) written in base: an optional +, then one or more digits, and nothing else. Digits past 9 are the letters, either case. No spaces, prefixes (0x) or separators are accepted.

Parameters

  • text — the text to parse
  • base — the base the digits are written in, from 2 to 36; 10 by default

Returns

  • the value, or none when text is not an integer in base or the value does not fit a uint (a - sign is never accepted)

Panics

  • if base is less than 2 or greater than 36

Example

assert(uint.parse("4294967295").value() == 4294967295, "the highest");

Methods

wrapping_add

wrapping_add(other: uint): uint

Returns this + other modulo 2^32: on overflow the result wraps around instead of panicking. For hashes, checksums and random number generators, which rely on wrapping.

Parameters

  • other — the value to add

Returns

  • the sum, wrapped to a uint

checked_add

checked_add(other: uint): uint?

Returns this + other, or none when the sum does not fit a uint.

Parameters

  • other — the value to add

Returns

  • the sum, or none on overflow

saturating_add

saturating_add(other: uint): uint

Returns this + other, clamped to the highest uint on overflow.

Parameters

  • other — the value to add

Returns

  • the sum, or the nearest uint to it

overflowing_add

overflowing_add(other: uint): (uint, bool)

Returns this + other modulo 2^32, and whether the operation overflowed.

Parameters

  • other — the value to add

Returns

  • the wrapped sum, and true when it overflowed

wrapping_sub

wrapping_sub(other: uint): uint

Returns this - other modulo 2^32: on overflow the result wraps around instead of panicking. For hashes, checksums and random number generators, which rely on wrapping.

Parameters

  • other — the value to subtract

Returns

  • the difference, wrapped to a uint

checked_sub

checked_sub(other: uint): uint?

Returns this - other, or none when the difference does not fit a uint.

Parameters

  • other — the value to subtract

Returns

  • the difference, or none on overflow

saturating_sub

saturating_sub(other: uint): uint

Returns this - other, clamped to 0 on overflow.

Parameters

  • other — the value to subtract

Returns

  • the difference, or the nearest uint to it

overflowing_sub

overflowing_sub(other: uint): (uint, bool)

Returns this - other modulo 2^32, and whether the operation overflowed.

Parameters

  • other — the value to subtract

Returns

  • the wrapped difference, and true when it overflowed

wrapping_mul

wrapping_mul(other: uint): uint

Returns this * other modulo 2^32: on overflow the result wraps around instead of panicking. For hashes, checksums and random number generators, which rely on wrapping.

Parameters

  • other — the value to multiply by

Returns

  • the product, wrapped to a uint

checked_mul

checked_mul(other: uint): uint?

Returns this * other, or none when the product does not fit a uint.

Parameters

  • other — the value to multiply by

Returns

  • the product, or none on overflow

saturating_mul

saturating_mul(other: uint): uint

Returns this * other, clamped to the highest uint on overflow.

Parameters

  • other — the value to multiply by

Returns

  • the product, or the nearest uint to it

overflowing_mul

overflowing_mul(other: uint): (uint, bool)

Returns this * other modulo 2^32, and whether the operation overflowed.

Parameters

  • other — the value to multiply by

Returns

  • the wrapped product, and true when it overflowed

leading_zeros

leading_zeros(): int

Returns the number of zero bits above the highest set bit: 32 for zero, 0 when the highest bit is set. One instruction on most CPUs (llvm.ctlz).

Returns

  • the count of leading zero bits, from 0 to 32

Example

assert(((1 as uint)).leading_zeros() == 31, "one bit, at the bottom");

trailing_zeros

trailing_zeros(): int

Returns the number of zero bits below the lowest set bit: 32 for zero. One instruction on most CPUs (llvm.cttz), so it finds the first set bit of a mask without a loop.

Returns

  • the count of trailing zero bits, from 0 to 32

Example

assert((8 as uint).trailing_zeros() == 3, "8 is 0b1000");

count_ones

count_ones(): int

Returns the number of bits that are set (the population count, llvm.ctpop).

Returns

  • the count of one bits, from 0 to 32

Example

assert((11 as uint).count_ones() == 3, "three bits set");

count_zeros

count_zeros(): int

Returns the number of bits that are clear.

Returns

  • the count of zero bits, from 0 to 32

Example

assert((11 as uint).count_zeros() == 29, "three bits set");

swap_bytes

swap_bytes(): uint

Returns this value with its bytes in reverse order (llvm.bswap), to convert between little-endian and big-endian byte order.

Returns

  • the value with its 4 bytes reversed

Example

assert((0x12345678 as uint).swap_bytes() == 0x78563412, "bytes reversed");

reverse_bits

reverse_bits(): uint

Returns this value with its bits in reverse order: the lowest bit becomes the highest (llvm.bitreverse).

Returns

  • the value with its 32 bits reversed

Example

assert(((1 as uint)).reverse_bits().leading_zeros() == 0, "bottom to top");

rotate_left

rotate_left(count: int): uint

Returns this value’s bits rotated left by count: the bits shifted out at the top come back in at the bottom (llvm.fshl). count is taken modulo 32, so a negative count rotates right.

Parameters

  • count — how many places to rotate

Returns

  • the rotated value

Example

assert((9 as uint).rotate_left(31) == (9 as uint).rotate_right(1), "the same rotation");

rotate_right

rotate_right(count: int): uint

Returns this value’s bits rotated right by count: the bits shifted out at the bottom come back in at the top (llvm.fshr). count is taken modulo 32, so a negative count rotates left.

Parameters

  • count — how many places to rotate

Returns

  • the rotated value

Example

assert(((1 as uint)).rotate_right(1) == ((1 as uint)).rotate_left(31), "the bottom bit to the top");

min

min(other: uint): uint

Returns the smaller of this value and other.

Parameters

  • other — the value to compare with

Returns

  • this when it is not greater than other, else other

Example

assert((3 as uint).min(5) == 3, "smaller");

max

max(other: uint): uint

Returns the larger of this value and other.

Parameters

  • other — the value to compare with

Returns

  • this when it is not less than other, else other

Example

assert((3 as uint).max(5) == 5, "larger");

extend long

extend long

Static methods

parse

static parse(text: String, base: int = 10): long?

Parses text as a long (i64) written in base: an optional + or -, then one or more digits, and nothing else. Digits past 9 are the letters, either case. No spaces, prefixes (0x) or separators are accepted.

Parameters

  • text — the text to parse
  • base — the base the digits are written in, from 2 to 36; 10 by default

Returns

  • the value, or none when text is not an integer in base or the value does not fit a long

Panics

  • if base is less than 2 or greater than 36

Example

assert(long.parse("-9000000000").value() == -9000000000, "decimal");
assert(long.parse("9223372036854775808").is_empty(), "too big");

Methods

wrapping_add

wrapping_add(other: long): long

Returns this + other modulo 2^64: on overflow the result wraps around instead of panicking. For hashes, checksums and random number generators, which rely on wrapping.

Parameters

  • other — the value to add

Returns

  • the sum, wrapped to a long

checked_add

checked_add(other: long): long?

Returns this + other, or none when the sum does not fit a long.

Parameters

  • other — the value to add

Returns

  • the sum, or none on overflow

saturating_add

saturating_add(other: long): long

Returns this + other, clamped to the highest or lowest long on overflow.

Parameters

  • other — the value to add

Returns

  • the sum, or the nearest long to it

overflowing_add

overflowing_add(other: long): (long, bool)

Returns this + other modulo 2^64, and whether the operation overflowed.

Parameters

  • other — the value to add

Returns

  • the wrapped sum, and true when it overflowed

wrapping_sub

wrapping_sub(other: long): long

Returns this - other modulo 2^64: on overflow the result wraps around instead of panicking. For hashes, checksums and random number generators, which rely on wrapping.

Parameters

  • other — the value to subtract

Returns

  • the difference, wrapped to a long

checked_sub

checked_sub(other: long): long?

Returns this - other, or none when the difference does not fit a long.

Parameters

  • other — the value to subtract

Returns

  • the difference, or none on overflow

saturating_sub

saturating_sub(other: long): long

Returns this - other, clamped to the highest or lowest long on overflow.

Parameters

  • other — the value to subtract

Returns

  • the difference, or the nearest long to it

overflowing_sub

overflowing_sub(other: long): (long, bool)

Returns this - other modulo 2^64, and whether the operation overflowed.

Parameters

  • other — the value to subtract

Returns

  • the wrapped difference, and true when it overflowed

wrapping_mul

wrapping_mul(other: long): long

Returns this * other modulo 2^64: on overflow the result wraps around instead of panicking. For hashes, checksums and random number generators, which rely on wrapping.

Parameters

  • other — the value to multiply by

Returns

  • the product, wrapped to a long

checked_mul

checked_mul(other: long): long?

Returns this * other, or none when the product does not fit a long.

Parameters

  • other — the value to multiply by

Returns

  • the product, or none on overflow

saturating_mul

saturating_mul(other: long): long

Returns this * other, clamped to the highest or lowest long on overflow.

Parameters

  • other — the value to multiply by

Returns

  • the product, or the nearest long to it

overflowing_mul

overflowing_mul(other: long): (long, bool)

Returns this * other modulo 2^64, and whether the operation overflowed.

Parameters

  • other — the value to multiply by

Returns

  • the wrapped product, and true when it overflowed

leading_zeros

leading_zeros(): int

Returns the number of zero bits above the highest set bit: 64 for zero, 0 when the highest bit is set. One instruction on most CPUs (llvm.ctlz).

Returns

  • the count of leading zero bits, from 0 to 64

Example

assert(((1 as long)).leading_zeros() == 63, "one bit, at the bottom");

trailing_zeros

trailing_zeros(): int

Returns the number of zero bits below the lowest set bit: 64 for zero. One instruction on most CPUs (llvm.cttz), so it finds the first set bit of a mask without a loop.

Returns

  • the count of trailing zero bits, from 0 to 64

Example

assert((8 as long).trailing_zeros() == 3, "8 is 0b1000");

count_ones

count_ones(): int

Returns the number of bits that are set (the population count, llvm.ctpop).

Returns

  • the count of one bits, from 0 to 64

Example

assert((11 as long).count_ones() == 3, "three bits set");

count_zeros

count_zeros(): int

Returns the number of bits that are clear.

Returns

  • the count of zero bits, from 0 to 64

Example

assert((11 as long).count_zeros() == 61, "three bits set");

swap_bytes

swap_bytes(): long

Returns this value with its bytes in reverse order (llvm.bswap), to convert between little-endian and big-endian byte order.

Returns

  • the value with its 8 bytes reversed

Example

assert((0x0102030405060708 as long).swap_bytes() == 0x0807060504030201, "bytes reversed");

reverse_bits

reverse_bits(): long

Returns this value with its bits in reverse order: the lowest bit becomes the highest (llvm.bitreverse).

Returns

  • the value with its 64 bits reversed

Example

assert(((1 as long)).reverse_bits().leading_zeros() == 0, "bottom to top");

rotate_left

rotate_left(count: int): long

Returns this value’s bits rotated left by count: the bits shifted out at the top come back in at the bottom (llvm.fshl). count is taken modulo 64, so a negative count rotates right.

Parameters

  • count — how many places to rotate

Returns

  • the rotated value

Example

assert((9 as long).rotate_left(63) == (9 as long).rotate_right(1), "the same rotation");

rotate_right

rotate_right(count: int): long

Returns this value’s bits rotated right by count: the bits shifted out at the bottom come back in at the top (llvm.fshr). count is taken modulo 64, so a negative count rotates left.

Parameters

  • count — how many places to rotate

Returns

  • the rotated value

Example

assert(((1 as long)).rotate_right(1) == ((1 as long)).rotate_left(63), "the bottom bit to the top");

min

min(other: long): long

Returns the smaller of this value and other.

Parameters

  • other — the value to compare with

Returns

  • this when it is not greater than other, else other

Example

assert((3 as long).min(5) == 3, "smaller");

max

max(other: long): long

Returns the larger of this value and other.

Parameters

  • other — the value to compare with

Returns

  • this when it is not less than other, else other

Example

assert((3 as long).max(5) == 5, "larger");

abs

abs(): long

Returns the absolute value: this without its sign. The lowest value, -9223372036854775808, has no positive counterpart in a long, so it panics, as its negation does.

Returns

  • -this when this value is negative, else this

Panics

  • if this value is -9223372036854775808 (“integer overflow in ‘long’ absolute value”)

Example

assert((-5 as long).abs() == 5, "negative");

extend ulong

extend ulong

Static methods

parse

static parse(text: String, base: int = 10): ulong?

Parses text as a ulong (u64) written in base: an optional +, then one or more digits, and nothing else. Digits past 9 are the letters, either case. No spaces, prefixes (0x) or separators are accepted.

Parameters

  • text — the text to parse
  • base — the base the digits are written in, from 2 to 36; 10 by default

Returns

  • the value, or none when text is not an integer in base or the value does not fit a ulong (a - sign is never accepted)

Panics

  • if base is less than 2 or greater than 36

Example

assert(ulong.parse("ffffffffffffffff", 16).is_value(), "the highest");

Methods

wrapping_add

wrapping_add(other: ulong): ulong

Returns this + other modulo 2^64: on overflow the result wraps around instead of panicking. For hashes, checksums and random number generators, which rely on wrapping.

Parameters

  • other — the value to add

Returns

  • the sum, wrapped to a ulong

checked_add

checked_add(other: ulong): ulong?

Returns this + other, or none when the sum does not fit a ulong.

Parameters

  • other — the value to add

Returns

  • the sum, or none on overflow

saturating_add

saturating_add(other: ulong): ulong

Returns this + other, clamped to the highest ulong on overflow.

Parameters

  • other — the value to add

Returns

  • the sum, or the nearest ulong to it

overflowing_add

overflowing_add(other: ulong): (ulong, bool)

Returns this + other modulo 2^64, and whether the operation overflowed.

Parameters

  • other — the value to add

Returns

  • the wrapped sum, and true when it overflowed

wrapping_sub

wrapping_sub(other: ulong): ulong

Returns this - other modulo 2^64: on overflow the result wraps around instead of panicking. For hashes, checksums and random number generators, which rely on wrapping.

Parameters

  • other — the value to subtract

Returns

  • the difference, wrapped to a ulong

checked_sub

checked_sub(other: ulong): ulong?

Returns this - other, or none when the difference does not fit a ulong.

Parameters

  • other — the value to subtract

Returns

  • the difference, or none on overflow

saturating_sub

saturating_sub(other: ulong): ulong

Returns this - other, clamped to 0 on overflow.

Parameters

  • other — the value to subtract

Returns

  • the difference, or the nearest ulong to it

overflowing_sub

overflowing_sub(other: ulong): (ulong, bool)

Returns this - other modulo 2^64, and whether the operation overflowed.

Parameters

  • other — the value to subtract

Returns

  • the wrapped difference, and true when it overflowed

wrapping_mul

wrapping_mul(other: ulong): ulong

Returns this * other modulo 2^64: on overflow the result wraps around instead of panicking. For hashes, checksums and random number generators, which rely on wrapping.

Parameters

  • other — the value to multiply by

Returns

  • the product, wrapped to a ulong

checked_mul

checked_mul(other: ulong): ulong?

Returns this * other, or none when the product does not fit a ulong.

Parameters

  • other — the value to multiply by

Returns

  • the product, or none on overflow

saturating_mul

saturating_mul(other: ulong): ulong

Returns this * other, clamped to the highest ulong on overflow.

Parameters

  • other — the value to multiply by

Returns

  • the product, or the nearest ulong to it

overflowing_mul

overflowing_mul(other: ulong): (ulong, bool)

Returns this * other modulo 2^64, and whether the operation overflowed.

Parameters

  • other — the value to multiply by

Returns

  • the wrapped product, and true when it overflowed

leading_zeros

leading_zeros(): int

Returns the number of zero bits above the highest set bit: 64 for zero, 0 when the highest bit is set. One instruction on most CPUs (llvm.ctlz).

Returns

  • the count of leading zero bits, from 0 to 64

Example

assert(((1 as ulong)).leading_zeros() == 63, "one bit, at the bottom");

trailing_zeros

trailing_zeros(): int

Returns the number of zero bits below the lowest set bit: 64 for zero. One instruction on most CPUs (llvm.cttz), so it finds the first set bit of a mask without a loop.

Returns

  • the count of trailing zero bits, from 0 to 64

Example

assert((8 as ulong).trailing_zeros() == 3, "8 is 0b1000");

count_ones

count_ones(): int

Returns the number of bits that are set (the population count, llvm.ctpop).

Returns

  • the count of one bits, from 0 to 64

Example

assert((11 as ulong).count_ones() == 3, "three bits set");

count_zeros

count_zeros(): int

Returns the number of bits that are clear.

Returns

  • the count of zero bits, from 0 to 64

Example

assert((11 as ulong).count_zeros() == 61, "three bits set");

swap_bytes

swap_bytes(): ulong

Returns this value with its bytes in reverse order (llvm.bswap), to convert between little-endian and big-endian byte order.

Returns

  • the value with its 8 bytes reversed

Example

assert((0x0102030405060708 as ulong).swap_bytes() == 0x0807060504030201, "bytes reversed");

reverse_bits

reverse_bits(): ulong

Returns this value with its bits in reverse order: the lowest bit becomes the highest (llvm.bitreverse).

Returns

  • the value with its 64 bits reversed

Example

assert(((1 as ulong)).reverse_bits().leading_zeros() == 0, "bottom to top");

rotate_left

rotate_left(count: int): ulong

Returns this value’s bits rotated left by count: the bits shifted out at the top come back in at the bottom (llvm.fshl). count is taken modulo 64, so a negative count rotates right.

Parameters

  • count — how many places to rotate

Returns

  • the rotated value

Example

assert((9 as ulong).rotate_left(63) == (9 as ulong).rotate_right(1), "the same rotation");

rotate_right

rotate_right(count: int): ulong

Returns this value’s bits rotated right by count: the bits shifted out at the bottom come back in at the top (llvm.fshr). count is taken modulo 64, so a negative count rotates left.

Parameters

  • count — how many places to rotate

Returns

  • the rotated value

Example

assert(((1 as ulong)).rotate_right(1) == ((1 as ulong)).rotate_left(63), "the bottom bit to the top");

min

min(other: ulong): ulong

Returns the smaller of this value and other.

Parameters

  • other — the value to compare with

Returns

  • this when it is not greater than other, else other

Example

assert((3 as ulong).min(5) == 3, "smaller");

max

max(other: ulong): ulong

Returns the larger of this value and other.

Parameters

  • other — the value to compare with

Returns

  • this when it is not less than other, else other

Example

assert((3 as ulong).max(5) == 5, "larger");

extend float

extend float

Static methods

parse

static parse(text: String): float?

Parses text as a float written in decimal notation: an optional + or -, digits with an optional . among them ("1.5", ".5", "2."), then an optional exponent ("1e-3", "2.5E8"), and nothing else. The value is the nearest float. No spaces, inf, nan or hexadecimal are accepted.

Parameters

  • text — the text to parse

Returns

  • the value, or none when text is not decimal notation or its value is too large for a float (one too small rounds to zero)

Example

assert(float.parse("0.25").value() == 0.25, "decimal");
assert(float.parse("1e39").is_empty(), "too big");

Methods

square_root

square_root(): float

Returns the square root (llvm.sqrt, one instruction on most CPUs): NaN for a negative value, and -0.0 for -0.0.

Returns

  • the non-negative square root

Example

assert((2.25 as float).square_root() == 1.5, "exact");

floor

floor(): float

Returns the largest whole number not greater than this value (llvm.floor).

Returns

  • the value rounded toward negative infinity

Example

assert((-1.5 as float).floor() == -2.0, "down");

ceiling

ceiling(): float

Returns the smallest whole number not less than this value (llvm.ceil).

Returns

  • the value rounded toward positive infinity

Example

assert((-1.5 as float).ceiling() == -1.0, "up");

truncate

truncate(): float

Returns this value without its fractional part (llvm.trunc).

Returns

  • the value rounded toward zero

Example

assert((-1.5 as float).truncate() == -1.0, "toward zero");

round

round(): float

Returns the nearest whole number, halfway cases away from zero (llvm.round).

Returns

  • the rounded value

Example

assert((2.5 as float).round() == 3.0, "half away from zero");

abs

abs(): float

Returns the absolute value: this value with its sign bit cleared (llvm.fabs).

Returns

  • the value without its sign

Example

assert((-2.0 as float).abs() == 2.0, "negative");

copy_sign

copy_sign(sign: float): float

Returns this value’s magnitude with the sign of sign (llvm.copysign).

Parameters

  • sign — the value whose sign the result takes

Returns

  • abs() of this value, negated when sign is negative

Example

assert((3.0 as float).copy_sign(-0.0) == -3.0, "negative zero is negative");

multiply_add

multiply_add(factor: float, addend: float): float

Returns this * factor + addend with a single rounding, as one fused multiply-add (llvm.fma): more precise than the two operations, and one instruction where the CPU has it.

Parameters

  • factor — the value to multiply by
  • addend — the value to add to the product

Returns

  • the fused product and sum

Example

assert((2.0 as float).multiply_add(3.0, 1.0) == 7.0, "2 * 3 + 1");

min

min(other: float): float

Returns the smaller of this value and other; when one of them is NaN, the other (llvm.minnum).

Parameters

  • other — the value to compare with

Returns

  • the smaller value

Example

assert((1.5 as float).min(-2.0) == -2.0, "smaller");

max

max(other: float): float

Returns the larger of this value and other; when one of them is NaN, the other (llvm.maxnum).

Parameters

  • other — the value to compare with

Returns

  • the larger value

Example

assert((1.5 as float).max(-2.0) == 1.5, "larger");

sine

sine(): float

Returns the sine of this angle, in radians (llvm.sin).

Returns

  • the sine, from -1 to 1; NaN for an infinity or NaN

Example

assert((0.0 as float).sine() == 0.0, "sine of zero");

cosine

cosine(): float

Returns the cosine of this angle, in radians (llvm.cos).

Returns

  • the cosine, from -1 to 1; NaN for an infinity or NaN

Example

assert((0.0 as float).cosine() == 1.0, "cosine of zero");

tangent

tangent(): float

Returns the tangent of this angle, in radians (llvm.tan).

Returns

  • the tangent; NaN for an infinity or NaN

Example

assert((0.0 as float).tangent() == 0.0, "tangent of zero");

arc_sine

arc_sine(): float

Returns the angle, in radians, whose sine is this value (llvm.asin).

Returns

  • the angle, from -π/2 to π/2; NaN when this value is outside -1 to 1

Example

assert((0.0 as float).arc_sine() == 0.0, "arc sine of zero");

arc_cosine

arc_cosine(): float

Returns the angle, in radians, whose cosine is this value (llvm.acos).

Returns

  • the angle, from 0 to π; NaN when this value is outside -1 to 1

Example

assert((1.0 as float).arc_cosine() == 0.0, "arc cosine of one");

arc_tangent

arc_tangent(): float

Returns the angle, in radians, whose tangent is this value (llvm.atan).

Returns

  • the angle, from -π/2 to π/2

Example

assert((0.0 as float).arc_tangent() == 0.0, "arc tangent of zero");

arc_tangent2

arc_tangent2(x: float): float

Returns the angle, in radians, from the positive x axis to the point (x, this value), using both signs to pick the quadrant: C’s atan2(y, x) with this value as y (llvm.atan2).

Parameters

  • x — the x coordinate of the point

Returns

  • the angle, from -π to π

Example

assert((0.0 as float).arc_tangent2(1.0) == 0.0, "along the positive x axis");
assert((1.0 as float).arc_tangent2(0.0) == (1.0 as float).arc_sine(), "straight up is π/2");

hyperbolic_sine

hyperbolic_sine(): float

Returns the hyperbolic sine of this value (llvm.sinh).

Returns

  • the hyperbolic sine; an infinity when it is too large

Example

assert((0.0 as float).hyperbolic_sine() == 0.0, "hyperbolic sine of zero");

hyperbolic_cosine

hyperbolic_cosine(): float

Returns the hyperbolic cosine of this value (llvm.cosh).

Returns

  • the hyperbolic cosine, at least 1; an infinity when it is too large

Example

assert((0.0 as float).hyperbolic_cosine() == 1.0, "hyperbolic cosine of zero");

hyperbolic_tangent

hyperbolic_tangent(): float

Returns the hyperbolic tangent of this value (llvm.tanh).

Returns

  • the hyperbolic tangent, from -1 to 1

Example

assert((0.0 as float).hyperbolic_tangent() == 0.0, "hyperbolic tangent of zero");

arc_hyperbolic_sine

arc_hyperbolic_sine(): float

Returns the value whose hyperbolic sine is this value (C’s asinh).

Returns

  • the inverse hyperbolic sine

Example

assert((0.0 as float).arc_hyperbolic_sine() == 0.0, "inverse of zero");

arc_hyperbolic_cosine

arc_hyperbolic_cosine(): float

Returns the non-negative value whose hyperbolic cosine is this value (C’s acosh).

Returns

  • the inverse hyperbolic cosine; NaN when this value is less than 1

Example

assert((1.0 as float).arc_hyperbolic_cosine() == 0.0, "inverse of one");

arc_hyperbolic_tangent

arc_hyperbolic_tangent(): float

Returns the value whose hyperbolic tangent is this value (C’s atanh).

Returns

  • the inverse hyperbolic tangent; an infinity for -1 or 1, and NaN outside

Example

assert((0.0 as float).arc_hyperbolic_tangent() == 0.0, "inverse of zero");

exponential

exponential(): float

Returns e raised to this value (llvm.exp).

Returns

  • e to the power of this value; an infinity when it is too large

Example

assert((0.0 as float).exponential() == 1.0, "e to the zero");

exponential2

exponential2(): float

Returns 2 raised to this value (llvm.exp2).

Returns

  • 2 to the power of this value

Example

assert((3.0 as float).exponential2() == 8.0, "2 cubed");

exponential_minus_one

exponential_minus_one(): float

Returns e raised to this value, minus 1 (C’s expm1): precise even when this value is near zero, where exponential() - 1 loses its digits.

Returns

  • exponential() - 1, computed without the loss

Example

assert((0.0 as float).exponential_minus_one() == 0.0, "e to the zero, minus one");

logarithm

logarithm(): float

Returns the natural logarithm (base e) of this value (llvm.log).

Returns

  • the logarithm; negative infinity for zero, and NaN for a negative value

Example

assert((1.0 as float).logarithm() == 0.0, "log of one");

logarithm2

logarithm2(): float

Returns the base-2 logarithm of this value (llvm.log2).

Returns

  • the logarithm; negative infinity for zero, and NaN for a negative value

Example

assert((8.0 as float).logarithm2() == 3.0, "2 cubed is 8");

logarithm10

logarithm10(): float

Returns the base-10 logarithm of this value (llvm.log10).

Returns

  • the logarithm; negative infinity for zero, and NaN for a negative value

Example

assert((1000.0 as float).logarithm10() == 3.0, "10 cubed is 1000");

logarithm_one_plus

logarithm_one_plus(): float

Returns the natural logarithm of 1 plus this value (C’s log1p): precise even when this value is near zero, where (1 + x).logarithm() loses its digits.

Returns

  • the logarithm of 1 + this; negative infinity for -1, and NaN below it

Example

assert((0.0 as float).logarithm_one_plus() == 0.0, "log of one");

cube_root

cube_root(): float

Returns the cube root (C’s cbrt), negative for a negative value.

Returns

  • the cube root

Example

assert((-27.0 as float).cube_root() == -3.0, "a negative value has a cube root");

hypotenuse

hypotenuse(other: float): float

Returns (this * this + other * other).square_root(), the length of the hypotenuse of a right triangle with sides this value and other (C’s hypot), without overflowing or underflowing in between.

Parameters

  • other — the length of the other side

Returns

  • the distance from the origin to (this, other)

Example

assert((3.0 as float).hypotenuse(4.0) == 5.0, "a 3-4-5 triangle");

remainder

remainder(divisor: float): float

Returns the IEEE remainder of this value divided by divisor (C’s remainder): this - n * divisor, where n is the quotient rounded to the nearest whole number, ties to even. It is never larger than half of divisor, and may be negative. % gives the remainder of the quotient rounded toward zero instead, with the sign of this value.

Parameters

  • divisor — the value to divide by

Returns

  • the remainder; NaN when divisor is zero or this value is infinite

Example

assert((7.0 as float).remainder(2.0) == -1.0, "7 / 2 = 3.5 rounds to 4");
assert((7.0 as float) % 2.0 == 1.0, "% rounds toward zero");

is_nan

is_nan(): bool

Returns whether this value is NaN (not a number), the one value not equal to itself.

Returns

  • true for NaN

Example

assert(!(1.0 as float).is_nan(), "a number");
assert(((0.0 as float) / 0.0).is_nan(), "zero over zero");

is_infinite

is_infinite(): bool

Returns whether this value is positive or negative infinity.

Returns

  • true for an infinity

Example

assert(((1.0 as float) / 0.0).is_infinite(), "one over zero");

is_finite

is_finite(): bool

Returns whether this value is neither an infinity nor NaN.

Returns

  • true for every other value

Example

assert((1.0 as float).is_finite() && !((1.0 as float) / 0.0).is_finite(), "finite and infinite");

sign_bit

sign_bit(): bool

Returns whether this value’s sign bit is set: true for every negative value, -0.0, and a NaN with its sign bit set, which < 0 does not tell apart.

Returns

  • true when the sign bit is set

Example

assert((-0.0 as float).sign_bit() && !(0.0 as float).sign_bit(), "negative zero");

extend double

extend double

Static methods

parse

static parse(text: String): double?

Parses text as a double written in decimal notation: an optional + or -, digits with an optional . among them ("1.5", ".5", "2."), then an optional exponent ("1e-3", "2.5E8"), and nothing else. The value is the nearest double. No spaces, inf, nan or hexadecimal are accepted.

Parameters

  • text — the text to parse

Returns

  • the value, or none when text is not decimal notation or its value is too large for a double (one too small rounds to zero)

Example

assert(double.parse("-2.5e3").value() == -2500.0, "an exponent");
assert(double.parse("1.5.2").is_empty(), "not a number");

Methods

square_root

square_root(): double

Returns the square root (llvm.sqrt, one instruction on most CPUs): NaN for a negative value, and -0.0 for -0.0.

Returns

  • the non-negative square root

Example

assert((2.25 as double).square_root() == 1.5, "exact");

floor

floor(): double

Returns the largest whole number not greater than this value (llvm.floor).

Returns

  • the value rounded toward negative infinity

Example

assert((-1.5 as double).floor() == -2.0, "down");

ceiling

ceiling(): double

Returns the smallest whole number not less than this value (llvm.ceil).

Returns

  • the value rounded toward positive infinity

Example

assert((-1.5 as double).ceiling() == -1.0, "up");

truncate

truncate(): double

Returns this value without its fractional part (llvm.trunc).

Returns

  • the value rounded toward zero

Example

assert((-1.5 as double).truncate() == -1.0, "toward zero");

round

round(): double

Returns the nearest whole number, halfway cases away from zero (llvm.round).

Returns

  • the rounded value

Example

assert((2.5 as double).round() == 3.0, "half away from zero");

abs

abs(): double

Returns the absolute value: this value with its sign bit cleared (llvm.fabs).

Returns

  • the value without its sign

Example

assert((-2.0 as double).abs() == 2.0, "negative");

copy_sign

copy_sign(sign: double): double

Returns this value’s magnitude with the sign of sign (llvm.copysign).

Parameters

  • sign — the value whose sign the result takes

Returns

  • abs() of this value, negated when sign is negative

Example

assert((3.0 as double).copy_sign(-0.0) == -3.0, "negative zero is negative");

multiply_add

multiply_add(factor: double, addend: double): double

Returns this * factor + addend with a single rounding, as one fused multiply-add (llvm.fma): more precise than the two operations, and one instruction where the CPU has it.

Parameters

  • factor — the value to multiply by
  • addend — the value to add to the product

Returns

  • the fused product and sum

Example

assert((2.0 as double).multiply_add(3.0, 1.0) == 7.0, "2 * 3 + 1");

min

min(other: double): double

Returns the smaller of this value and other; when one of them is NaN, the other (llvm.minnum).

Parameters

  • other — the value to compare with

Returns

  • the smaller value

Example

assert((1.5 as double).min(-2.0) == -2.0, "smaller");

max

max(other: double): double

Returns the larger of this value and other; when one of them is NaN, the other (llvm.maxnum).

Parameters

  • other — the value to compare with

Returns

  • the larger value

Example

assert((1.5 as double).max(-2.0) == 1.5, "larger");

sine

sine(): double

Returns the sine of this angle, in radians (llvm.sin).

Returns

  • the sine, from -1 to 1; NaN for an infinity or NaN

Example

assert((0.0 as double).sine() == 0.0, "sine of zero");

cosine

cosine(): double

Returns the cosine of this angle, in radians (llvm.cos).

Returns

  • the cosine, from -1 to 1; NaN for an infinity or NaN

Example

assert((0.0 as double).cosine() == 1.0, "cosine of zero");

tangent

tangent(): double

Returns the tangent of this angle, in radians (llvm.tan).

Returns

  • the tangent; NaN for an infinity or NaN

Example

assert((0.0 as double).tangent() == 0.0, "tangent of zero");

arc_sine

arc_sine(): double

Returns the angle, in radians, whose sine is this value (llvm.asin).

Returns

  • the angle, from -π/2 to π/2; NaN when this value is outside -1 to 1

Example

assert((0.0 as double).arc_sine() == 0.0, "arc sine of zero");

arc_cosine

arc_cosine(): double

Returns the angle, in radians, whose cosine is this value (llvm.acos).

Returns

  • the angle, from 0 to π; NaN when this value is outside -1 to 1

Example

assert((1.0 as double).arc_cosine() == 0.0, "arc cosine of one");

arc_tangent

arc_tangent(): double

Returns the angle, in radians, whose tangent is this value (llvm.atan).

Returns

  • the angle, from -π/2 to π/2

Example

assert((0.0 as double).arc_tangent() == 0.0, "arc tangent of zero");

arc_tangent2

arc_tangent2(x: double): double

Returns the angle, in radians, from the positive x axis to the point (x, this value), using both signs to pick the quadrant: C’s atan2(y, x) with this value as y (llvm.atan2).

Parameters

  • x — the x coordinate of the point

Returns

  • the angle, from -π to π

Example

assert((0.0 as double).arc_tangent2(1.0) == 0.0, "along the positive x axis");
assert((1.0 as double).arc_tangent2(0.0) == (1.0 as double).arc_sine(), "straight up is π/2");

hyperbolic_sine

hyperbolic_sine(): double

Returns the hyperbolic sine of this value (llvm.sinh).

Returns

  • the hyperbolic sine; an infinity when it is too large

Example

assert((0.0 as double).hyperbolic_sine() == 0.0, "hyperbolic sine of zero");

hyperbolic_cosine

hyperbolic_cosine(): double

Returns the hyperbolic cosine of this value (llvm.cosh).

Returns

  • the hyperbolic cosine, at least 1; an infinity when it is too large

Example

assert((0.0 as double).hyperbolic_cosine() == 1.0, "hyperbolic cosine of zero");

hyperbolic_tangent

hyperbolic_tangent(): double

Returns the hyperbolic tangent of this value (llvm.tanh).

Returns

  • the hyperbolic tangent, from -1 to 1

Example

assert((0.0 as double).hyperbolic_tangent() == 0.0, "hyperbolic tangent of zero");

arc_hyperbolic_sine

arc_hyperbolic_sine(): double

Returns the value whose hyperbolic sine is this value (C’s asinh).

Returns

  • the inverse hyperbolic sine

Example

assert((0.0 as double).arc_hyperbolic_sine() == 0.0, "inverse of zero");

arc_hyperbolic_cosine

arc_hyperbolic_cosine(): double

Returns the non-negative value whose hyperbolic cosine is this value (C’s acosh).

Returns

  • the inverse hyperbolic cosine; NaN when this value is less than 1

Example

assert((1.0 as double).arc_hyperbolic_cosine() == 0.0, "inverse of one");

arc_hyperbolic_tangent

arc_hyperbolic_tangent(): double

Returns the value whose hyperbolic tangent is this value (C’s atanh).

Returns

  • the inverse hyperbolic tangent; an infinity for -1 or 1, and NaN outside

Example

assert((0.0 as double).arc_hyperbolic_tangent() == 0.0, "inverse of zero");

exponential

exponential(): double

Returns e raised to this value (llvm.exp).

Returns

  • e to the power of this value; an infinity when it is too large

Example

assert((0.0 as double).exponential() == 1.0, "e to the zero");

exponential2

exponential2(): double

Returns 2 raised to this value (llvm.exp2).

Returns

  • 2 to the power of this value

Example

assert((3.0 as double).exponential2() == 8.0, "2 cubed");

exponential_minus_one

exponential_minus_one(): double

Returns e raised to this value, minus 1 (C’s expm1): precise even when this value is near zero, where exponential() - 1 loses its digits.

Returns

  • exponential() - 1, computed without the loss

Example

assert((0.0 as double).exponential_minus_one() == 0.0, "e to the zero, minus one");

logarithm

logarithm(): double

Returns the natural logarithm (base e) of this value (llvm.log).

Returns

  • the logarithm; negative infinity for zero, and NaN for a negative value

Example

assert((1.0 as double).logarithm() == 0.0, "log of one");

logarithm2

logarithm2(): double

Returns the base-2 logarithm of this value (llvm.log2).

Returns

  • the logarithm; negative infinity for zero, and NaN for a negative value

Example

assert((8.0 as double).logarithm2() == 3.0, "2 cubed is 8");

logarithm10

logarithm10(): double

Returns the base-10 logarithm of this value (llvm.log10).

Returns

  • the logarithm; negative infinity for zero, and NaN for a negative value

Example

assert((1000.0 as double).logarithm10() == 3.0, "10 cubed is 1000");

logarithm_one_plus

logarithm_one_plus(): double

Returns the natural logarithm of 1 plus this value (C’s log1p): precise even when this value is near zero, where (1 + x).logarithm() loses its digits.

Returns

  • the logarithm of 1 + this; negative infinity for -1, and NaN below it

Example

assert((0.0 as double).logarithm_one_plus() == 0.0, "log of one");

cube_root

cube_root(): double

Returns the cube root (C’s cbrt), negative for a negative value.

Returns

  • the cube root

Example

assert((-27.0 as double).cube_root() == -3.0, "a negative value has a cube root");

hypotenuse

hypotenuse(other: double): double

Returns (this * this + other * other).square_root(), the length of the hypotenuse of a right triangle with sides this value and other (C’s hypot), without overflowing or underflowing in between.

Parameters

  • other — the length of the other side

Returns

  • the distance from the origin to (this, other)

Example

assert((3.0 as double).hypotenuse(4.0) == 5.0, "a 3-4-5 triangle");

remainder

remainder(divisor: double): double

Returns the IEEE remainder of this value divided by divisor (C’s remainder): this - n * divisor, where n is the quotient rounded to the nearest whole number, ties to even. It is never larger than half of divisor, and may be negative. % gives the remainder of the quotient rounded toward zero instead, with the sign of this value.

Parameters

  • divisor — the value to divide by

Returns

  • the remainder; NaN when divisor is zero or this value is infinite

Example

assert((7.0 as double).remainder(2.0) == -1.0, "7 / 2 = 3.5 rounds to 4");
assert((7.0 as double) % 2.0 == 1.0, "% rounds toward zero");

is_nan

is_nan(): bool

Returns whether this value is NaN (not a number), the one value not equal to itself.

Returns

  • true for NaN

Example

assert(!(1.0 as double).is_nan(), "a number");
assert(((0.0 as double) / 0.0).is_nan(), "zero over zero");

is_infinite

is_infinite(): bool

Returns whether this value is positive or negative infinity.

Returns

  • true for an infinity

Example

assert(((1.0 as double) / 0.0).is_infinite(), "one over zero");

is_finite

is_finite(): bool

Returns whether this value is neither an infinity nor NaN.

Returns

  • true for every other value

Example

assert((1.0 as double).is_finite() && !((1.0 as double) / 0.0).is_finite(), "finite and infinite");

sign_bit

sign_bit(): bool

Returns whether this value’s sign bit is set: true for every negative value, -0.0, and a NaN with its sign bit set, which < 0 does not tell apart.

Returns

  • true when the sign bit is set

Example

assert((-0.0 as double).sign_bit() && !(0.0 as double).sign_bit(), "negative zero");

Comparable

Ordering: Comparable, the trait of types whose values sort in one order, which every number type, char and String declare.

Source: runtime/compare.pep

Comparable

trait Comparable

A type whose values have one order: compare says whether a value sorts before, with or after another.

Declaring it (extend Version with Comparable;) gives the type <, <=, > and >= and the methods min, max and clamp, each written in terms of compare, unless the type declares its own. A generic function asks for an order with T: Comparable.

compare returns a negative number when this value sorts before other, zero when the two sort together, and a positive number when it sorts after. The order must be total and consistent: two calls with the same values give the same answer, and a.compare(b) has the opposite sign of b.compare(a). Map, Set and sorting rely on that.

float and double are Comparable with a total order: NaN sorts after every number and with every other NaN, and -0.0 with 0.0. Their < and > keep IEEE behavior (false whenever NaN takes part), while compare, clamp and Map keys follow the total order.

Example

struct Version {
    var major: int;
    var minor: int;

    construct(this.major = 0, this.minor = 0) {}

    compare(other: Version): int {
        var order = major.compare(other.major);
        if (order != 0) {
            return order;
        }
        return minor.compare(other.minor);
    }
}

extend Version with Comparable;

assert(Version(1, 2) < Version(1, 10), "by minor");
assert(Version(2, 0).max(Version(1, 9)).major == 2, "max");

Methods

compare

compare(other: this): int

Returns the order of this value and other.

Parameters

  • other — the value to compare with

Returns

  • a negative number, zero or a positive number as this value sorts before, with or after other

operator <

<(other: this): bool

Returns whether this value sorts before other.

Parameters

  • other — the value to compare with

Returns

  • true when compare(other) < 0

operator <=

<=(other: this): bool

Returns whether this value sorts before or with other.

Parameters

  • other — the value to compare with

Returns

  • true when compare(other) <= 0

operator >

>(other: this): bool

Returns whether this value sorts after other.

Parameters

  • other — the value to compare with

Returns

  • true when compare(other) > 0

operator >=

>=(other: this): bool

Returns whether this value sorts after or with other.

Parameters

  • other — the value to compare with

Returns

  • true when compare(other) >= 0

min

min(other: this): this

Returns the one of this value and other that sorts first.

Parameters

  • other — the value to compare with

Returns

  • this value when it sorts before or with other, else other

Example

assert("pear".min("apple") == "apple", "apple sorts first");

max

max(other: this): this

Returns the one of this value and other that sorts last.

Parameters

  • other — the value to compare with

Returns

  • this value when it sorts after or with other, else other

Example

assert("pear".max("apple") == "pear", "pear sorts last");

clamp

clamp(low: this, high: this): this

Returns this value limited to the range from low to high.

Parameters

  • low — the lowest value to return
  • high — the highest value to return

Returns

  • low when this value sorts before it, high when this value sorts after it, else this value

Panics

  • if low sorts after high (“clamp range is empty”)

Example

assert((15).clamp(0, 10) == 10, "too high");
assert((-3).clamp(0, 10) == 0, "too low");
assert((4).clamp(0, 10) == 4, "in range");

extend char

extend char with Comparable

char values sort by value.

Methods

compare

compare(other: char): int

Returns the order of this value and other: -1, 0 or 1 as this value is less than, equal to or greater than other.

Parameters

  • other — the value to compare with

Returns

  • -1, 0 or 1

extend byte

extend byte with Comparable

byte values sort by value.

Methods

compare

compare(other: byte): int

Returns the order of this value and other: -1, 0 or 1 as this value is less than, equal to or greater than other.

Parameters

  • other — the value to compare with

Returns

  • -1, 0 or 1

extend short

extend short with Comparable

short values sort by value.

Methods

compare

compare(other: short): int

Returns the order of this value and other: -1, 0 or 1 as this value is less than, equal to or greater than other.

Parameters

  • other — the value to compare with

Returns

  • -1, 0 or 1

extend ushort

extend ushort with Comparable

ushort values sort by value.

Methods

compare

compare(other: ushort): int

Returns the order of this value and other: -1, 0 or 1 as this value is less than, equal to or greater than other.

Parameters

  • other — the value to compare with

Returns

  • -1, 0 or 1

extend int

extend int with Comparable

int values sort by value.

Methods

compare

compare(other: int): int

Returns the order of this value and other: -1, 0 or 1 as this value is less than, equal to or greater than other.

Parameters

  • other — the value to compare with

Returns

  • -1, 0 or 1

extend uint

extend uint with Comparable

uint values sort by value.

Methods

compare

compare(other: uint): int

Returns the order of this value and other: -1, 0 or 1 as this value is less than, equal to or greater than other.

Parameters

  • other — the value to compare with

Returns

  • -1, 0 or 1

extend long

extend long with Comparable

long values sort by value.

Methods

compare

compare(other: long): int

Returns the order of this value and other: -1, 0 or 1 as this value is less than, equal to or greater than other.

Parameters

  • other — the value to compare with

Returns

  • -1, 0 or 1

extend ulong

extend ulong with Comparable

ulong values sort by value.

Methods

compare

compare(other: ulong): int

Returns the order of this value and other: -1, 0 or 1 as this value is less than, equal to or greater than other.

Parameters

  • other — the value to compare with

Returns

  • -1, 0 or 1

extend float

extend float with Comparable

float values sort by value, with NaN after every number.

Methods

compare

compare(other: float): int

Returns the order of this value and other in a total order: by value, with NaN after every number and equal to every NaN, and -0.0 equal to 0.0.

Parameters

  • other — the value to compare with

Returns

  • -1, 0 or 1 as this value sorts before, with or after other

Example

var nan = (0.0 / 0.0) as float;
assert((1.0 as float).compare(nan) < 0, "NaN sorts last");
assert(nan.compare(nan) == 0, "NaNs sort together");

extend double

extend double with Comparable

double values sort by value, with NaN after every number.

Methods

compare

compare(other: double): int

Returns the order of this value and other in a total order: by value, with NaN after every number and equal to every NaN, and -0.0 equal to 0.0.

Parameters

  • other — the value to compare with

Returns

  • -1, 0 or 1 as this value sorts before, with or after other

Example

var nan = 0.0 / 0.0;
assert((1.0).compare(nan) < 0, "NaN sorts last");
assert(nan.compare(nan) == 0, "NaNs sort together");

Ranges

Ranges of numbers: a..b and a..>b.

a..b (half-open) holds a up to but not including b; a..>b (closed) includes b. Both make a Range<T>, a value that a for loop steps through without allocating:

for (var i in 0..count) { ... }   # 0, 1, ..., count - 1
for (var i in 1..>10) { ... }     # 1, 2, ..., 10

Source: runtime/range.pep

Range

[Value] struct Range<T>

A run of consecutive numbers, from start up to end, as a..b and a..>b make it.

A range is an iterator: next() steps from start up by one, and a for loop over a range is a counting loop. A range whose start is past its end yields nothing. A closed range ending at the largest value of its type (0..>255 as byte) stops there without overflowing.

Ranges are value structs: copying one copies its position, and looping over a range stored in a variable advances that variable.

Type parameters

  • T — the number type, inferred from the bounds (0..n with a ulong n is a Range<ulong>)

Example

var total = 0;
for (var i in 1..>4) {
    total += i;   # 1 + 2 + 3 + 4
}
var r = 0..10;
assert(r.contains(9) && !r.contains(10), "half-open");

Fields

start

T start

The first number of the range.

end

T end

The bound the range stops at: the last number when inclusive, else one past it.

inclusive

bool inclusive

Whether end is part of the range: true for a..>b, false for a..b.

Properties

is_empty

var is_empty: bool { get; }

Whether the range holds no numbers, whatever the iteration has reached.

Constructor

Range()

construct(start: T, end: T, inclusive: bool = false)

Makes the range from start to end, as start..end (or start..>end when inclusive).

Parameters

  • start — the first number
  • end — the bound: one past the last number, or the last number when inclusive
  • inclusive — whether end is part of the range

Methods

next

next(): bool

Advances to the next number: start on the first call, then one more each time.

Once it returns false it keeps returning false.

Returns

  • true if there is a current number, false once past the end

current

current(): T

Returns the number the last successful next() moved to.

Returns

  • the current number

Panics

  • if next() was never called (“Range.current() before next()”)
  • if next() already returned false (“Range.current() past the end”)

contains

contains(value: T): bool

Returns whether value lies in the range, whatever the iteration has reached.

Parameters

  • value — the number to test

Returns

  • true if start <= value and value is before end (or equal to it, for a closed range)

Array

Source: runtime/array.pep

Array

[__Lazy] struct Array<T>

A growable, contiguous, zero-indexed sequence of T values.

Elements live in one heap buffer that doubles when full (starting at 4 slots), so append is amortized O(1) and indexing is O(1). When T is a struct type, each slot holds a counted reference, exactly like a std::vector<std::shared_ptr<T>>: storing an element acquires it, overwriting or removing one releases the old occupant, and destroying the Array releases every element it still holds. Moving elements within the Array (insert, remove_at, swap, reverse) does not change their reference counts.

Array is itself a struct, so assigning it or passing it to a function shares the same Array rather than copying its elements; use clone() for an independent copy.

An empty Array allocates nothing: Array<T>() makes no object until the Array is first written (append, insert, reserve, …) or shared (assigned, stored, passed or returned), and reading it (count, is_empty, capacity, find, contains, iterating) allocates nothing. Sharing allocates first, so every name for an Array sees the same elements.

An Array is Iterable<T>: for (var x in a) visits each element in index order. Every mutating operation (an element store through [], append, insert, remove_at, pop, clear, a shrinking truncate, swap, fill, retain, resize, split_off, and reverse, sort or dedup of two or more elements) invalidates running iterators and slices (slice), and the next step of such a loop panics with “Array modified during iteration”.

Type parameters

  • T — the element type

See also

  • ArrayIterator

Example

main(): int {
    var a = Array<int>();
    a.append(1);
    a.append(2);
    a[0] = 5;
    var total = 0;
    for (var x in a) {
        total = total + x;
    }
    return total;   # 7
}

Properties

count

var count: ulong { get; }

The number of elements in the Array.

capacity

var capacity: ulong { get; }

The number of elements the Array can hold before its buffer must be reallocated.

is_empty

var is_empty: bool { get; }

Whether the Array has no elements.

Constructor

Array()

construct(capacity: ulong = 0)

Creates an empty Array, optionally preallocating room for capacity elements.

With the default capacity of 0, nothing is allocated, not even the Array itself, until the Array is first written or shared (see Array). Preallocating avoids the reallocations append would otherwise perform while the Array grows to a known size.

Parameters

  • capacity — the number of element slots to allocate up front; count is still 0

Example

var a = Array<int>(16);   # a.count == 0, a.capacity == 16

Methods

iterate

[__Reads] iterate(): ArrayIterator<T>

Returns an iterator over the elements in index order; this is what for (var x in array) calls (the Iterable<T> protocol).

The iterator holds a reference to the Array, so iterating a temporary keeps it alive for the whole loop. Modifying the Array while the iterator is in use makes the iterator’s next next() or current() panic, except when the Array was empty when the iterator was made: the iterator then sees no elements and ends.

Returns

  • a new iterator positioned before the first element

Example

for (var x in a) {
    total = total + x;
}

reserve

reserve(capacity: ulong)

Ensures the buffer has room for at least capacity elements, reallocating it when it is smaller.

Never shrinks the buffer: a capacity at or below the current one does nothing. Elements, their order, and their reference counts are unchanged, and running iterators stay valid. Growing copies the existing elements, which costs O(count).

Parameters

  • capacity — the minimum number of element slots required

Panics

  • if the buffer must grow while an I/O operation in a task is using it

Example

var a = Array<int>();
a.reserve(8);   # a.capacity >= 8
a.reserve(2);   # no effect: capacity never shrinks

grow

grow()

Makes room for one more element: when the buffer is full, doubles its capacity (to at least 4); otherwise does nothing.

append and insert call this themselves; callers rarely need it. Does not change the elements or invalidate iterators.

Panics

  • if the buffer must grow while an I/O operation in a task is using it

operator []

[__Reads] [](idx: ulong): T

Returns the element at idx; this is the indexer read a[idx].

Runs in O(1). For a struct T the result is the stored reference itself; binding it to a variable acquires it like any shared copy.

Parameters

  • idx — the zero-based position to read

Returns

  • the element at idx

Panics

  • if idx < count does not hold (“Array index read out of bounds”)

Example

var first = a[0];

operator []

[](idx: ulong, value: T): T

Replaces the element at idx with value; this is the indexer write a[idx] = value.

For a struct T the slot acquires value and releases its previous occupant; assigning an element to its own slot (a[i] = a[i]) leaves counts unchanged. Only replaces an existing element: use append to add one. Invalidates running iterators, even when the value is unchanged.

Parameters

  • idx — the zero-based position to overwrite
  • value — the new element

Returns

  • the element now stored at idx (that is, value)

Panics

  • if idx < count does not hold (“Array index write out of bounds”)

Example

a[0] = 42;

append

append(item: T)

Adds item to the end of the Array.

Amortized O(1): when the buffer is full it doubles (O(count) for that call). For a struct T the Array acquires a reference to item. Invalidates running iterators.

Parameters

  • item — the element to add

Panics

  • if the buffer must grow while an I/O operation in a task is using it

Example

var a = Array<int>();
a.append(1);
a.append(2);   # a holds [1, 2]

insert

insert(idx: ulong, item: T)

Inserts item at position idx, shifting the element there and every later element up by one.

Runs in O(count - idx), plus a reallocation when the buffer is full. idx == count appends. For a struct T the Array acquires a reference to item; shifted elements keep their reference counts. Invalidates running iterators.

Parameters

  • idx — the position item will occupy; from 0 through count
  • item — the element to insert

Panics

  • if idx <= count does not hold (“Array insert index out of bounds”)
  • if the buffer must grow while an I/O operation in a task is using it

Example

var a = Array<int>();
a.append(1);
a.append(3);
a.insert(1, 2);   # a holds [1, 2, 3]

remove_at

remove_at(idx: ulong): T

Removes and returns the element at idx, shifting every later element down by one.

Runs in O(count - idx). For a struct T the Array’s reference is transferred to the caller, so the element stays alive as long as the result is held; shifted elements keep their reference counts. The capacity is unchanged. Invalidates running iterators.

Parameters

  • idx — the position of the element to remove

Returns

  • the removed element

Panics

  • if idx < count does not hold (“Array remove index out of bounds”)

Example

var a = Array<int>();
a.append(1);
a.append(2);
var x = a.remove_at(0);   # x == 1; a holds [2]

pop

pop(): T

Removes and returns the last element.

Runs in O(1). For a struct T the Array’s reference is transferred to the caller. The capacity is unchanged. Invalidates running iterators.

Returns

  • the element that was last

Panics

  • if the Array is empty (“Array pop on empty Array”)

Example

var top = a.pop();

first

[__Reads] first(): T

Returns the first element without removing it; the same as a[0].

Returns

  • the element at index 0

Panics

  • if the Array is empty (“Array first on empty Array”)

last

[__Reads] last(): T

Returns the last element without removing it; the same as a[a.count - 1].

Returns

  • the element at index count - 1

Panics

  • if the Array is empty (“Array last on empty Array”)

clear

clear()

Removes every element, leaving the Array empty.

Runs in O(count) for a T holding references, each of which is released, and in O(1) for a plain value T (int, u8, a tuple of numbers). The capacity is kept, so refilling the Array does not reallocate. Always invalidates running iterators, even on an empty Array.

truncate

truncate(count: ulong)

Shortens the Array to its first count elements, removing the rest.

Runs in O(removed elements). For a struct T each removed element’s reference is released. A count at or above the current count does nothing (the Array never grows) and leaves iterators valid; an actual shrink invalidates them. The capacity is kept.

Parameters

  • count — the number of leading elements to keep

Example

a.truncate(1);   # keeps only a[0]
a.truncate(5);   # no effect when a has fewer than 5 elements

swap

swap(a: ulong, b: ulong)

Exchanges the elements at positions a and b.

Runs in O(1). Reference counts are unchanged: the two references just trade slots. Swapping a position with itself is allowed and changes no element, but still invalidates running iterators like every swap.

Parameters

  • a — the position of the first element
  • b — the position of the second element

Panics

  • if a < count does not hold (“Array swap index out of bounds”)
  • if b < count does not hold (“Array swap index out of bounds”)

reverse

reverse()

Reverses the order of the elements in place.

Runs in O(count), exchanging elements pairwise, so reference counts are unchanged. An Array of fewer than two elements is left untouched and its iterators stay valid; otherwise running iterators are invalidated.

Example

a.reverse();   # [1, 2, 3] becomes [3, 2, 1]

sort

sort(compare: (T, T) => int)

Sorts the elements in place into the order compare defines. The sort is stable: elements that compare equal keep their relative order.

compare(a, b) returns a negative number when a belongs before b, 0 when their order does not matter, and a positive number when a belongs after b, the convention String.compare follows. It is a merge sort: O(count log count) calls to compare, plus a scratch buffer of count slots. Elements move between the two buffers without changing any reference count. Invalidates running iterators when there are two or more elements.

compare must not modify the Array: its elements are spread over the two buffers while the sort runs.

Parameters

  • compare — returns the order of its two arguments, as above

Panics

  • if compare modifies the Array (“Array modified during sort”)

Example

var names = Array<String>();
names.append("pear");
names.append("apple");
names.sort((a, b) => a.compare(b));   # ["apple", "pear"]
var numbers = Array<int>();
numbers.append(3);
numbers.append(1);
numbers.sort((a, b) => b - a);        # descending: [3, 1]

find

[__Reads] find(item: T): ulong?

Returns the position of the first element equal to item, or none when no element matches.

Runs in O(count), scanning from the front. Elements are compared with ==: when T declares an == operator (as String does) that operator decides; for any other struct T it compares references, so only the very same object matches.

Breaking change: find used to return a long that was -1 when nothing matched. It now returns ulong?: test it with is_some() (or as a condition) and read the index with value().

Parameters

  • item — the value to look for

Returns

  • the index of the first match, or none when no element matches (always none for an empty Array)

Example

var a = [3, 9, 4];
var i = a.find(9);         # i.value() == 1
var missing = a.find(7);   # missing.is_empty() == true

contains

[__Reads] contains(item: T): bool

Reports whether any element equals item, compared as find does.

Runs in O(count).

Parameters

  • item — the value to look for

Returns

  • true when find(item) finds a match

slice

[__Reads] slice(start: ulong, end: ulong): ArraySlice<T>

Returns a read-only view of the elements from start up to but not including end, without copying them.

The slice holds a reference to this Array, so it cannot outlive it, and it is fail-fast like an iterator: once the Array is modified (see Array for which operations count), every later use of the slice panics, so it never reads elements that moved or were freed.

Parameters

  • start — the position of the first element in the slice
  • end — the position one past the last element; from start through count

Returns

  • a view of end - start elements

Panics

  • if start <= end and end <= count do not both hold (“Array slice out of bounds”)

Example

var a = [1, 2, 3, 4, 5];
var middle = a.slice(1, 4);   # views [2, 3, 4]
var second = middle[1];       # 3

binary_search_by

[__Reads] binary_search_by(order: (T) => int): ulong?

Returns the position of the element that order reports equal to the target, searching an Array sorted in the order order follows, or none when there is none.

Runs in O(log count) calls to order, which must not modify the Array. When several elements match, the first of them is found.

Parameters

  • order — returns a negative number when its argument sorts before the target, 0 when it matches, and a positive number when it sorts after

Returns

  • the index of the first matching element, or none

Panics

  • if order modifies the Array (“Array modified while a slice of it is in use”)

Example

var a = [10, 20, 30];
var i = a.binary_search_by((x) => x.compare(20));   # i.value() == 1

fill

fill(value: T)

Replaces every element with value.

Runs in O(count). For a struct T every slot acquires value and releases its previous occupant. Invalidates running iterators.

Parameters

  • value — the value each element becomes

Example

var a = [1, 2, 3];
a.fill(0);   # [0, 0, 0]

retain

retain(keep: (T) => bool)

Removes every element for which keep returns false, keeping the order of the rest.

Runs in O(count) calls to keep, which sees each element once, in index order, and must not modify the Array. For a struct T each removed element is released and kept elements keep their counts. The capacity is unchanged. Invalidates running iterators.

Parameters

  • keep — returns true for an element to keep

Panics

  • if keep modifies the Array (“Array modified during retain”)

Example

var a = [1, 2, 3, 4];
a.retain((x) => x % 2 == 0);   # [2, 4]

dedup

dedup()

Removes each element equal (==) to the one before it, so a run of equal elements keeps only its first. On a sorted Array this removes every duplicate.

Runs in O(count). Elements are compared as find does. For a struct T each removed element is released. Invalidates running iterators when there are two or more elements.

Example

var a = [1, 1, 2, 3, 3, 3, 1];
a.dedup();   # [1, 2, 3, 1]

resize

resize(count: ulong, value: T)

Changes the count to count: removes elements past it, as truncate does, or appends copies of value until there are count elements.

Runs in O(difference in count), plus at most one reallocation. Invalidates running iterators when the count changes.

Parameters

  • count — the number of elements the Array will hold
  • value — the element to append when the Array grows

Panics

  • if the buffer must grow while an I/O operation in a task is using it

Example

var a = [1, 2];
a.resize(4, 0);   # [1, 2, 0, 0]
a.resize(1, 0);   # [1]

split_off

split_off(index: ulong): Array<T>

Removes the elements from index on and returns them as a new Array, leaving this Array with its first index elements.

Runs in O(count - index). The elements move without changing their reference counts. This Array’s capacity is unchanged. Invalidates running iterators when any element moves.

Parameters

  • index — the position of the first element to move; from 0 through count

Returns

  • a new Array holding the elements that were at index and after

Panics

  • if index <= count does not hold (“Array split_off index out of bounds”)

Example

var a = [1, 2, 3, 4];
var tail = a.split_off(1);   # a holds [1]; tail holds [2, 3, 4]

append_all

append_all(rhs: Array<T>)

Appends every element of rhs, in order, to the end of this Array.

Runs in O(count of rhs), growing the buffer at most once and copying the elements in one block. rhs is unchanged, and it may be this same Array (a.append_all(a) doubles a). For a struct T each appended element is acquired once more, since both Arrays now hold it. Invalidates this Array’s running iterators when rhs is not empty.

Parameters

  • rhs — the Array whose elements are copied

Panics

  • if the buffer must grow while an I/O operation in a task is using it

See also

  • +=

clone

[__Reads] clone(): Array<T>

Returns a new Array holding the same elements in the same order.

Runs in O(count). The copy is shallow: for a struct T both Arrays refer to the same element objects (each acquired once more), but adding or removing elements in one does not affect the other. The new Array’s capacity equals this Array’s count.

Returns

  • an independent Array with the same elements

Example

var b = a.clone();
b.append(7);   # a is unchanged

copy

copy(): Array<T>

Returns a deep copy: a new Array holding a copy of each element, made with copy. This is how copy(value) copies an Array (the Copyable<Array<T>> implementation); call copy(array) rather than this method.

Unlike clone(), which shares struct elements, the result shares no element that is not thread-safe with this Array. An element that appears twice is copied once. Runs in O(count) plus the cost of copying the elements.

Returns

  • the copy

Example

var names = Array<String>();
names.append("ada");
var copied = copy(names);   # calls this method

operator ==

[__Reads] ==(rhs: Array<T>): bool

Reports whether both Arrays have the same count and pairwise equal elements, compared with == as find does.

Runs in O(count); stops at the first difference. Two empty Arrays are equal. capacity is not compared.

Parameters

  • rhs — the Array to compare with

Returns

  • true when the Arrays are element-wise equal

operator !=

[__Reads] !=(rhs: Array<T>): bool

Reports whether the Arrays differ in count or in any element; the negation of ==.

Parameters

  • rhs — the Array to compare with

Returns

  • true when the Arrays are not element-wise equal

operator +=

+=(rhs: Array<T>): Array<T>

Appends every element of rhs to this Array in place (a += b); the same as append_all.

a += a is allowed and doubles a.

Parameters

  • rhs — the Array whose elements are appended

Returns

  • this Array, after the append

See also

  • append_all

Example

a += b;   # a now ends with b's elements

operator +

[__Reads] +(rhs: Array<T>): Array<T>

Returns a new Array holding this Array’s elements followed by those of rhs (a + b).

Runs in O(count + count of rhs). Neither operand is modified; like clone, the result shares struct elements with its operands.

Parameters

  • rhs — the Array whose elements come second

Returns

  • the concatenation of this Array and rhs

Example

var c = a + b;

extend Array

extend Array<T: Comparable>

The searches of a sorted Array, for an element type with an order (see Comparable).

Type parameters

  • T — the element type, Comparable

Methods

[__Reads] binary_search(item: T): ulong?

Returns the position of an element equal to item in this Array, which must be sorted in compare order, or none when there is none.

Runs in O(log count). When several elements equal item, the first of them is found. On an Array that is not sorted the result is unspecified (but never out of bounds).

Parameters

  • item — the value to look for

Returns

  • the index of the first element equal to item, or none

Example

var a = [1, 3, 5, 7];
var i = a.binary_search(5);   # i.value() == 2

lower_bound

[__Reads] lower_bound(item: T): ulong

Returns the first position at which item could be inserted while keeping this sorted Array sorted: the index of the first element that does not sort before item (in compare order), or count when every element does.

Runs in O(log count).

Parameters

  • item — the value to place

Returns

  • the index of the first element at or after item

Example

var a = [1, 3, 3, 5];
var i = a.lower_bound(3);   # 1

upper_bound

[__Reads] upper_bound(item: T): ulong

Returns the last position at which item could be inserted while keeping this sorted Array sorted: the index of the first element that sorts after item (in compare order), or count when none does.

Runs in O(log count). upper_bound(x) - lower_bound(x) is the number of elements equal to x.

Parameters

  • item — the value to place

Returns

  • the index of the first element after item

Example

var a = [1, 3, 3, 5];
var i = a.upper_bound(3);   # 3

is_sorted

[__Reads] is_sorted(): bool

Reports whether the elements are in compare order: no element sorts after the one that follows it.

Runs in O(count). An Array of fewer than two elements is sorted.

Returns

  • true when the Array is sorted

ArrayIterator

[Value] struct ArrayIterator<T>

An Iterator<T> over the elements of an Array, in index order; returned by Array.iterate() and driven by for (var x in array).

Holds a reference to the Array, so iterating a temporary keeps it alive for the whole loop. The iterator is fail-fast: if the Array is modified after the iterator was created (see Array for which operations count), its next next() or current() panics instead of skipping or repeating elements (an iterator over an Array that was empty when it was made just ends).

Type parameters

  • T — the element type of the source Array

See also

  • Array.iterate

Example

var it = a.iterate();
while (it.next()) {
    var x = it.current();
}

Methods

next

next(): bool

Advances to the next element.

The first call moves to index 0. Once it returns false it keeps returning false.

Returns

  • true when there is a current element, false once past the end

Panics

  • if the Array was modified since the iterator was created (“Array modified during iteration”)

current

current(): T

Returns the element the last successful next() moved to.

Returns

  • the current element

Panics

  • if the Array was modified since the iterator was created (“Array modified during iteration”)
  • if next() was never called (“ArrayIterator.current() before next()”)
  • if next() already returned false (“ArrayIterator.current() past the end”)

ArraySlice

[Value] struct ArraySlice<T>

A read-only view of a run of an Array’s elements, made by Array.slice, that lets a function take “some of the elements” without copying them.

A slice holds a reference to its Array, so the Array lives at least as long as the slice. It is fail-fast like ArrayIterator: once the Array is modified (see Array for which operations count), every later use of the slice panics with “Array modified while a slice of it is in use” instead of reading elements that moved or were freed. Make a new slice after changing the Array.

A slice is Iterable<T>: for (var x in slice) visits its elements in order.

Type parameters

  • T — the element type of the Array

See also

  • Array.slice

Example

total(values: ArraySlice<int>): int {
    var sum = 0;
    for (var x in values) {
        sum = sum + x;
    }
    return sum;
}

var a = [1, 2, 3, 4];
var tail_sum = total(a.slice(2, 4));   # 7

Properties

count

var count: ulong { get; }

The number of elements in the slice.

is_empty

var is_empty: bool { get; }

Whether the slice has no elements.

Methods

operator []

[](index: ulong): T

Returns the element at index in the slice, index 0 being the slice’s first element; this is the indexer read slice[index].

Parameters

  • index — the zero-based position in the slice

Returns

  • the element at index

Panics

  • if the Array was modified since the slice was made (“Array modified while a slice of it is in use”)
  • if index < count does not hold (“ArraySlice index out of bounds”)

slice

slice(start: ulong, end: ulong): ArraySlice<T>

Returns a view of this slice’s elements from start up to but not including end, positions counted within this slice.

Parameters

  • start — the position of the first element
  • end — the position one past the last element; from start through count

Returns

  • a view of end - start elements of the same Array

Panics

  • if the Array was modified since the slice was made (“Array modified while a slice of it is in use”)
  • if start <= end and end <= count do not both hold (“ArraySlice slice out of bounds”)

iterate

iterate(): ArraySliceIterator<T>

Returns an iterator over the slice’s elements in order; this is what for (var x in slice) calls.

Returns

  • a new iterator positioned before the first element

to_array

to_array(): Array<T>

Returns a new Array holding a copy of the slice’s elements, in order.

The copy is shallow, as Array.clone is: for a struct T the new Array shares the element objects.

Returns

  • an independent Array with the slice’s elements

Panics

  • if the Array was modified since the slice was made (“Array modified while a slice of it is in use”)

find

find(item: T): ulong?

Returns the position in the slice of the first element equal (==) to item, or none, compared as Array.find does.

Parameters

  • item — the value to look for

Returns

  • the index of the first match, or none when no element matches

Panics

  • if the Array was modified since the slice was made (“Array modified while a slice of it is in use”)

contains

contains(item: T): bool

Reports whether any element equals item, compared as find does.

Parameters

  • item — the value to look for

Returns

  • true when find(item) finds a match

Panics

  • if the Array was modified since the slice was made (“Array modified while a slice of it is in use”)

binary_search_by

binary_search_by(order: (T) => int): ulong?

Returns the position of the first element that order reports equal to the target, in a slice sorted in the order order follows, or none; see Array.binary_search_by.

Parameters

  • order — returns a negative number when its argument sorts before the target, 0 when it matches, and a positive number when it sorts after

Returns

  • the index of the first matching element, or none

Panics

  • if the Array was modified since the slice was made, order included (“Array modified while a slice of it is in use”)

extend ArraySlice

extend ArraySlice<T: Comparable>

The searches of a sorted ArraySlice, for an element type with an order (see Comparable).

Type parameters

  • T — the element type, Comparable

Methods

binary_search

binary_search(item: T): ulong?

Returns the position of the first element equal to item in this slice, which must be sorted in compare order, or none; see Array.binary_search.

Parameters

  • item — the value to look for

Returns

  • the index of the first element equal to item, or none

Panics

  • if the Array was modified since the slice was made (“Array modified while a slice of it is in use”)

lower_bound

lower_bound(item: T): ulong

Returns the index of the first element of this sorted slice that does not sort before item, or count; see Array.lower_bound.

Parameters

  • item — the value to place

Returns

  • the index of the first element at or after item

Panics

  • if the Array was modified since the slice was made (“Array modified while a slice of it is in use”)

upper_bound

upper_bound(item: T): ulong

Returns the index of the first element of this sorted slice that sorts after item, or count; see Array.upper_bound.

Parameters

  • item — the value to place

Returns

  • the index of the first element after item

Panics

  • if the Array was modified since the slice was made (“Array modified while a slice of it is in use”)

is_sorted

is_sorted(): bool

Reports whether the elements are in compare order; see Array.is_sorted.

Returns

  • true when no element sorts after the one that follows it

Panics

  • if the Array was modified since the slice was made (“Array modified while a slice of it is in use”)

ArraySliceIterator

[Value] struct ArraySliceIterator<T>

An Iterator<T> over the elements of an ArraySlice, in order; returned by ArraySlice.iterate() and driven by for (var x in slice).

Type parameters

  • T — the element type of the slice

See also

  • ArraySlice.iterate

Methods

next

next(): bool

Advances to the next element.

Returns

  • true when there is a current element, false once past the end

Panics

  • if the Array was modified since the slice was made (“Array modified while a slice of it is in use”)

current

current(): T

Returns the element the last successful next() moved to.

Returns

  • the current element

Panics

  • if the Array was modified since the slice was made (“Array modified while a slice of it is in use”)
  • if next() was never called (“ArraySliceIterator.current() before next()”)
  • if next() already returned false (“ArraySlice index out of bounds”)

HashMap and HashSet

Hashing: hash<T>(value), the 64-bit hash HashMap and HashSet use for their keys, and the hash maps and sets themselves.

Source: runtime/hash.pep

hash

hash<T>(value: T): u64

Returns a 64-bit hash of value, the hash HashMap and HashSet use for their keys.

  • POD types (integers, floats, pointers, bool) mix the value’s own bits, so equal values hash equally.
  • Struct types hash by identity: the object’s address, mixed so that aligned addresses spread across buckets. Two distinct objects with equal fields therefore hash differently and are distinct keys. A null reference hashes to the FNV-1a offset basis (0xcbf29ce484222325).

Every input bit reaches the low bits of the result, which a power-of-two table masks with. The hash is not keyed, so it does not resist inputs chosen to collide.

  • String hashes by content through the concrete hash<String> override, so equal strings at different addresses collide.

A struct whose keys should match by content is marked [Equatable], which derives its == and its hash from its fields, or declares an == operator and overrides this function for itself, hashing the fields == compares: hash<Point>(value: Point): u64. Keys that are == must hash equally (see the “Hashing and equality” chapter of the book).

The result is deterministic within and across runs of a program for POD values and strings; for other struct types it depends on where the object was allocated. Reading the address does no refcount work, and a temporary argument is released afterwards.

Type parameters

  • T — the type of the value to hash

Parameters

  • value — the value to hash

Returns

  • the 64-bit hash of value

See also

  • hash<String>

Example

var a = hash<int>(5);
var b = hash<int>(5);   # a == b

hash

hash<String>(value: String): u64

Returns a 64-bit hash of a String’s bytes, so equal strings hash equally wherever they are stored. Keeps equal String keys of a HashMap or HashSet in one bucket.

Hashes the size bytes of the String (not the NUL terminator) 8 bytes at a time, in O(size). An empty String hashes to the FNV-1a offset basis (0xcbf29ce484222325).

Parameters

  • value — the String to hash; it must not be a null reference

Returns

  • the 64-bit hash of the String’s content

Example

assert(hash<String>("hello") == hash<String>("hello"), "content hash");

hash

hash<StringConstant>(value: StringConstant): u64

Returns a 64-bit hash of a StringConstant’s bytes, the same hash hash<String> gives a String with the same content, so StringConstant keys work in a HashMap or HashSet. Runs in O(size).

Parameters

  • value — the text to hash

Returns

  • the 64-bit hash of the text’s content

HashMap

struct HashMap<K, V>

A hash table that maps keys of type K to values of type V.

Keys are hashed with hash<K> and compared with ==: POD keys by value, String keys by content, and other struct keys by identity (two distinct objects with equal fields are different keys) unless the struct declares an == operator and a matching hash<K> override. Any type can be a key.

The map holds one reference to each stored key and value. It acquires them when they are stored and releases them when they are overwritten, removed, cleared, or when the map is destroyed.

Lookups, inserts, and removals take expected O(1) time. Before an insert of a new key would fill half the table (live entries plus removed-slot tombstones), the table is rebuilt: at double the size, or at the same size when tombstones make up at least half of that load, so churn (many inserts and removals of different keys) does not keep growing it. It never shrinks.

iterate() visits the entries as (key, value) tuples, in no defined order.

Type parameters

  • K — the key type; hashed with hash<K> and compared with ==
  • V — the value type

Example

var ages = HashMap<String, int>();
ages["ada"] = 36;
ages.insert("alan", 41);
assert(ages["ada"] == 36, "lookup");
assert(ages.get("grace") == 0, "a missing key reads as the default");
assert(ages.remove("alan"), "removed");
assert(ages.count == 1, "one entry left");

Properties

count

var count: int { get; }

The number of entries in the map. O(1).

is_empty

var is_empty: bool { get; }

Whether the map has no entries. O(1).

capacity

var capacity: ulong { get; }

The number of slots in the table, a power of two and at least 8. The map grows before its entries and tombstones reach half of it. O(1).

Constructor

HashMap()

construct(initial_capacity: ulong = 8)

Creates an empty map whose table has room for at least initial_capacity slots.

The slot count is rounded up to a power of two, with a minimum of 8. The map holds up to half its slot count before the first grow, so pass about twice the expected number of entries to avoid regrowing. Allocates three arrays of that many slots.

Parameters

  • initial_capacity — the minimum number of slots in the table

Example

var m = HashMap<int, int>(64);   # 64 slots, 32 entries before growing

Methods

grow

grow()

Doubles the table and reinserts every entry, dropping all tombstones.

insert calls it automatically. Calling it directly is safe but only costs memory. Runs in O(capacity). It replaces the table’s arrays and moves entries to new slots. Reference counts are unchanged overall: each entry is acquired by the new arrays and released by the old ones.

cap

[Deprecated("use the 'capacity' property instead")] cap(): ulong

Deprecated: use capacity instead. Returns the number of slots in the table.

Returns

  • the same as capacity

clear

clear()

Removes every entry and releases the map’s references to their keys and values. The table keeps its capacity. Runs in O(capacity).

copy

copy(): HashMap<K, V>

Returns a deep copy: a new map holding a copy of each key and value, made with copy. This is how copy(value) copies a HashMap (the Copyable<HashMap<K, V>> implementation); call copy(map) rather than this method.

The copied keys are hashed again, so a map keyed by struct identity finds its entries under the copied keys. Runs in O(capacity) plus the cost of copying the entries.

Returns

  • the copy

contains

contains(key: K): bool

Returns whether the map has an entry for key. Expected O(1).

Parameters

  • key — the key to look up

Returns

  • true when key is present

insert

insert(key: K, value: V): bool

Maps key to value, adding a new entry or overwriting the value of an existing one.

When the key is already present, only the value is replaced: the map keeps the key object it already stores and releases the old value. A new entry acquires both key and value, and may first grow the table (see grow). Expected O(1); a grow costs O(capacity).

Parameters

  • key — the key to add or update
  • value — the value to store for key

Returns

  • true when a new key was added, false when an existing key’s value was overwritten

Example

var m = HashMap<int, int>();
m.insert(1, 10);   # true: added
m.insert(1, 20);   # false: overwritten; m.get(1) == 20

get

get(key: K): V

Returns the value stored for key, or the default of V when key is absent.

The default is 0 for numbers and a null reference for struct types, including String. A missing key is not an error and is not inserted. When V is a struct type, call contains first rather than using a null result. The caller gets its own reference to the stored value. Expected O(1).

Parameters

  • key — the key to look up

Returns

  • the value for key, or the default of V when absent

try_get

try_get(key: K): V?

Returns the value stored for key, or none when key is absent. Expected O(1).

Parameters

  • key — the key to look up

Returns

  • the value for key, or none when absent

Example

if (ages.try_get("ada") is Value(age)) {
    print($"{age}");
}

remove

remove(key: K): bool

Removes the entry for key, releasing the map’s references to its key and value. Does nothing when key is absent.

The slot becomes a tombstone: occupied for probing, empty for lookup. It counts toward the grow threshold until an insert reuses it or the table grows. Expected O(1).

Parameters

  • key — the key to remove

Returns

  • true when key was present and removed, false when absent

operator []

[](key: K): V

Reads m[key]: returns the value for key, or the default of V (0, or a null reference for struct types) when key is absent. The same as get. A missing key is not inserted.

Parameters

  • key — the key to look up

Returns

  • the value for key, or the default of V when absent

Example

var m = HashMap<String, int>();
var n = m["missing"];   # 0; m is still empty

operator []

[](key: K, value: V): V

Writes m[key] = value: inserts key or overwrites its value, the same as insert.

Parameters

  • key — the key to add or update
  • value — the value to store for key

Returns

  • value, the value of the assignment expression

Example

var m = HashMap<String, int>();
m["hello"] = 5;

iterate

iterate(): HashMapIterator<K, V>

Returns an iterator over the entries as (key, value) tuples, in no defined order; for (var (key, value) in map) drives it.

The iterator holds a reference to the map. Changing a value through an existing key while iterating is allowed; adding or removing an entry makes the iterator’s next next() or current() panic.

Returns

  • an iterator positioned before the first entry

Example

var total = 0;
for (var (name, age) in ages) {
    total += age;
}

HashSet

struct HashSet<K>

A hash set of distinct elements of type K, built on the same table as HashMap but without values.

Elements are hashed with hash<K> and compared with ==: POD elements by value, String elements by content, and other struct elements by identity (two distinct objects with equal fields are both members) unless the struct declares an == operator and a matching hash<K> override. The set holds one reference to each member. It acquires the reference on insert and releases it on remove, on clear, or when the set is destroyed.

Membership tests, inserts, and removals take expected O(1) time. Before an insert of a new element would fill half the table (members plus tombstones), the table is rebuilt: at double the size, or at the same size when tombstones make up at least half of that load. It never shrinks. iterate() visits the members, in no defined order. The lowercase fields and the _find_slot and grow methods are the table’s internal state: they are public but may change, and writing to them breaks the set.

Type parameters

  • K — the element type; hashed with hash<K> and compared with ==

Example

var seen = HashSet<String>();
seen.insert("a");
seen["b"] = "b";   # the same as seen.insert("b")
assert(seen.contains("a"), "member");
assert(!seen.insert("a"), "already a member");
assert(seen.count == 2, "two members");

Properties

count

var count: int { get; }

The number of members in the set. O(1).

is_empty

var is_empty: bool { get; }

Whether the set has no members. O(1).

capacity

var capacity: ulong { get; }

The number of slots in the table, a power of two and at least 8. O(1).

Constructors

HashSet()

construct(initial_capacity: ulong = 8)

Creates an empty set whose table has room for at least initial_capacity slots.

The slot count is rounded up to a power of two, with a minimum of 8. The set holds up to half its slot count before the first grow.

Parameters

  • initial_capacity — the minimum number of slots in the table

HashSet()

[Implicit] construct(items: Array<K>)

Creates a set holding each element of items once.

The constructor is [Implicit]: an array converts to a set wherever a set is expected, so an array literal can initialize one.

Parameters

  • items — the elements to insert; duplicates are kept once

Example

HashSet<String> keywords = ["if", "else", "while"];
assert(keywords.contains("else"), "a member");

Methods

grow

grow()

Doubles the table and reinserts every member, dropping all tombstones.

insert calls it automatically. Calling it directly is safe but only costs memory. Runs in O(capacity). It replaces the table’s arrays and moves members to new slots.

cap

[Deprecated("use the 'capacity' property instead")] cap(): ulong

Deprecated: use capacity instead. Returns the number of slots in the table.

Returns

  • the same as capacity

clear

clear()

Removes every member and releases the set’s references to them. The table keeps its capacity. Runs in O(capacity).

contains

contains(key: K): bool

Returns whether key is a member of the set. Expected O(1).

Parameters

  • key — the element to look up

Returns

  • true when key is a member

insert

insert(key: K): bool

Adds key to the set if it is not already a member.

A new member is acquired, and the insert may first grow the table (see grow). When an equal member already exists, the set keeps the member it has and does not store key. Expected O(1); a grow costs O(capacity).

Parameters

  • key — the element to add

Returns

  • true when key was added, false when it was already a member

remove

remove(key: K): bool

Removes key from the set, releasing the set’s reference to the member. Does nothing when key is not a member. The slot becomes a tombstone (see _tombstones). Expected O(1).

Parameters

  • key — the element to remove

Returns

  • true when key was a member and was removed, false otherwise

operator []

[](key: K): K

Reads s[key]: returns the stored member equal to key, or the default of K (0, or a null reference for struct types) when key is not a member.

For String elements this returns the instance the set stores, which may be a different object than key with the same content. A missing element is not inserted.

Parameters

  • key — the element to look up

Returns

  • the stored member equal to key, or the default of K

operator []

[](key: K, value: K): K

Writes s[key] = value: makes key a member, the same as insert(key).

A set stores only keys, so value is ignored.

Parameters

  • key — the element to add
  • value — ignored

Returns

  • key (the argument, not an existing equal member), the value of the assignment expression

iterate

iterate(): HashSetIterator<K>

Returns an iterator over the members, in no defined order; for (var x in set) drives it.

The iterator holds a reference to the set. Adding or removing a member while iterating makes the iterator’s next next() or current() panic.

Returns

  • an iterator positioned before the first member

Example

for (var name in seen) {
    print(name);
}

copy

copy(): HashSet<K>

Returns a deep copy: a new set holding a copy of each element, made with copy. This is how copy(value) copies a HashSet (the Copyable<HashSet<K>> implementation); call copy(set) rather than this method.

The copied elements are hashed again, so a set of structs compared by identity finds the copied elements. Runs in O(capacity) plus the cost of copying the elements.

Returns

  • the copy

HashMapIterator

[Value] struct HashMapIterator<K, V>

An Iterator<(K, V)> over the entries of a HashMap, as (key, value) tuples in no defined order; returned by HashMap.iterate() and driven by for (var (key, value) in map).

Holds a reference to the map. It is fail-fast: adding or removing an entry after the iterator was created makes its next next() or current() panic.

Type parameters

  • K — the key type of the map
  • V — the value type of the map

See also

  • HashMap.iterate

Constructor

HashMapIterator()

construct(source: HashMap<K, V>)

Creates an iterator positioned before the first entry of source.

Parameters

  • source — the map to iterate

Methods

next

next(): bool

Advances to the next entry.

Returns

  • true when there is a current entry, false once every entry was visited

Panics

  • if an entry was added or removed since the iterator was created (“HashMap modified during iteration”)

current

current(): (K, V)

Returns the entry the last successful next() moved to.

Returns

  • the current entry as a (key, value) tuple

Panics

  • if an entry was added or removed since the iterator was created (“HashMap modified during iteration”)
  • if next() was never called or already returned false (“HashMapIterator.current() without an entry”)

HashSetIterator

[Value] struct HashSetIterator<K>

An Iterator<K> over the members of a HashSet, in no defined order; returned by HashSet.iterate() and driven by for (var x in set).

Holds a reference to the set. It is fail-fast: adding or removing a member after the iterator was created makes its next next() or current() panic.

Type parameters

  • K — the element type of the set

See also

  • HashSet.iterate

Constructor

HashSetIterator()

construct(source: HashSet<K>)

Creates an iterator positioned before the first member of source.

Parameters

  • source — the set to iterate

Methods

next

next(): bool

Advances to the next member.

Returns

  • true when there is a current member, false once every member was visited

Panics

  • if a member was added or removed since the iterator was created (“HashSet modified during iteration”)

current

current(): K

Returns the member the last successful next() moved to.

Returns

  • the current member

Panics

  • if a member was added or removed since the iterator was created (“HashSet modified during iteration”)
  • if next() was never called or already returned false (“HashSetIterator.current() without a member”)

Map and Set

Sorted collections: Map<K, V> and Set<K>, kept in key order on a red-black tree, and compare<T>, the ordering they sort keys by.

Source: runtime/map.pep

compare

compare<T>(a: T, b: T): int

Returns a negative number when a sorts before b, zero when they are equal, and a positive number when a sorts after b: the order Map and Set keep their keys in.

The generic version compares with <, so it works for every Comparable type (see Comparable), every integer type, char, and any struct that declares a < operator. Two values neither of which is < the other are equal keys. String, float and double go through concrete overrides that call their compare, so a NaN key sorts after every number instead of breaking the order.

A struct with no < operator declares Comparable, or overrides this function for itself, to be a Map key or Set element: compare<Point>(a: Point, b: Point): int. The order must be a strict weak order (consistent across calls), or the tree loses track of its keys.

Type parameters

  • T — the type of the values to compare

Parameters

  • a — the first value
  • b — the second value

Returns

  • a negative number, zero or a positive number as a sorts before, equal to or after b

See also

  • compare<String>

Example

assert(compare<int>(1, 2) < 0, "1 sorts first");
assert(compare<int>(2, 2) == 0, "equal");

compare

compare<String>(a: String, b: String): int

Compares two Strings by content, the order Map and Set keep String keys in: the same as a.compare(b).

Parameters

  • a — the first String
  • b — the second String

Returns

  • a negative number, zero or a positive number as a sorts before, equal to or after b

Example

assert(compare<String>("apple", "pear") < 0, "apple sorts first");

compare

compare<float>(a: float, b: float): int

Compares two floats in float.compare’s total order: by value, with NaN after every number.

Parameters

  • a — the first value
  • b — the second value

Returns

  • -1, 0 or 1 as a sorts before, with or after b

compare

compare<double>(a: double, b: double): int

Compares two doubles in double.compare’s total order: by value, with NaN after every number.

Parameters

  • a — the first value
  • b — the second value

Returns

  • -1, 0 or 1 as a sorts before, with or after b

Map

struct Map<K, V>

A map from keys of type K to values of type V that keeps its keys sorted.

Keys are ordered by compare<K>: numbers by value, String by content, and other structs by their < operator or a compare<K> override. Two keys that compare equal are the same key. Lookups, inserts and removals take O(log count) time on a red-black tree.

iterate() visits the entries as (key, value) tuples in ascending key order, reversed() in descending order, and range(from, to) the keys from from up to (not including) to. first, last, lower_bound and upper_bound find single entries by order.

The map holds one reference to each stored key and value. It acquires them when they are stored and releases them when they are overwritten, removed, cleared, or when the map is destroyed.

For keys kept in the order they were inserted, use OrderedMap; for expected O(1) lookups in no order, HashMap.

Type parameters

  • K — the key type; ordered by compare<K>
  • V — the value type

Example

var scores = Map<String, int>();
scores["carol"] = 91;
scores["alice"] = 84;
scores["bob"] = 77;
for (var (name, score) in scores) {
    print($"{name}: {score}");   # alice, bob, carol
}

Properties

count

var count: int { get; }

The number of entries in the map. O(1).

is_empty

var is_empty: bool { get; }

Whether the map has no entries. O(1).

Constructor

Map()

construct()

Creates an empty map.

Methods

clear

clear()

Removes every entry and releases the map’s references to their keys and values. O(count).

contains

contains(key: K): bool

Returns whether the map has an entry for key. O(log count).

Parameters

  • key — the key to look up

Returns

  • true when key is present

insert

insert(key: K, value: V): bool

Maps key to value, adding a new entry or overwriting the value of an existing one.

When the key is already present, only the value is replaced: the map keeps the key object it already stores and releases the old value. O(log count).

Parameters

  • key — the key to add or update
  • value — the value to store for key

Returns

  • true when a new key was added, false when an existing key’s value was overwritten

get

get(key: K): V

Returns the value stored for key, or the default of V (0, or a null reference for struct types) when key is absent. A missing key is not inserted. O(log count).

Parameters

  • key — the key to look up

Returns

  • the value for key, or the default of V when absent

See also

  • try_get

try_get

try_get(key: K): V?

Returns the value stored for key, or none when key is absent. O(log count).

Parameters

  • key — the key to look up

Returns

  • the value for key, or none when absent

Example

if (scores.try_get("alice") is Value(score)) {
    print($"{score}");
}

remove

remove(key: K): bool

Removes the entry for key, releasing the map’s references to its key and value. Does nothing when key is absent. O(log count).

Parameters

  • key — the key to remove

Returns

  • true when key was present and removed, false when absent

operator []

[](key: K): V

Reads m[key]: the same as get. A missing key reads as the default of V and is not inserted.

Parameters

  • key — the key to look up

Returns

  • the value for key, or the default of V when absent

operator []

[](key: K, value: V): V

Writes m[key] = value: the same as insert.

Parameters

  • key — the key to add or update
  • value — the value to store for key

Returns

  • value, the value of the assignment expression

first

first(): (K, V)?

Returns the entry with the smallest key. O(log count).

Returns

  • the first (key, value) in key order, or none when the map is empty

last

last(): (K, V)?

Returns the entry with the largest key. O(log count).

Returns

  • the last (key, value) in key order, or none when the map is empty

lower_bound

lower_bound(key: K): (K, V)?

Returns the first entry whose key is not less than key: the entry for key itself when present, else the next one after it. O(log count).

Parameters

  • key — the key to search from

Returns

  • the first (key, value) with a key >= key, or none when every key is less

Example

var after_b = scores.lower_bound("b");   # ("bob", 77)

upper_bound

upper_bound(key: K): (K, V)?

Returns the first entry whose key is greater than key. O(log count).

Parameters

  • key — the key to search from

Returns

  • the first (key, value) with a key > key, or none when no key is greater

iterate

iterate(): SortedMapIterator<K, V>

Returns an iterator over the entries as (key, value) tuples in ascending key order; for (var (key, value) in map) drives it.

The iterator holds a reference to the map. Changing a value through an existing key while iterating is allowed; adding or removing an entry makes the iterator’s next next() or current() panic.

Returns

  • an iterator positioned before the first entry

reversed

reversed(): SortedMapIterator<K, V>

Returns an iterator over the entries in descending key order, under the same rules as iterate.

Returns

  • an iterator positioned before the last entry

Example

for (var (name, score) in scores.reversed()) {
    print(name);   # carol, bob, alice
}

range

range(from: K, to: K): SortedMapIterator<K, V>

Returns an iterator over the entries whose keys are at least from and less than to, in ascending key order, under the same rules as iterate. It is empty when from is not less than to.

Parameters

  • from — the smallest key to visit
  • to — the key to stop before

Returns

  • an iterator positioned before the first entry in the range

Example

for (var (name, score) in scores.range("a", "c")) {
    print(name);   # alice, bob
}

copy

copy(): Map<K, V>

Returns a deep copy: a new map holding a copy of each key and value, made with copy. This is how copy(value) copies a Map (the Copyable<Map<K, V>> implementation); call copy(map) rather than this method. O(count log count) plus the cost of copying the entries.

Returns

  • the copy

SortedMapIterator

[Value] struct SortedMapIterator<K, V>

An iterator over the entries of a Map as (key, value) tuples, in key order or reversed; returned by Map.iterate(), reversed() and range(from, to), and driven by for (var (key, value) in ...).

Holds a reference to the map. It is fail-fast: adding or removing an entry after the iterator was created makes its next next() or current() panic.

Type parameters

  • K — the key type of the map
  • V — the value type of the map

See also

  • Map.iterate

Constructor

SortedMapIterator()

construct(source: Map<K, V>, start: ulong, stop: ulong, reverse: bool)

Creates an iterator over source from node start up to node stop. Internal: call Map.iterate, reversed or range instead.

Parameters

  • source — the map to iterate
  • start — the first node to visit
  • stop — the node to stop before, 0 for the end
  • reverse — whether to step in descending key order

Methods

next

next(): bool

Advances to the next entry.

Returns

  • true when there is a current entry, false once every entry was visited

Panics

  • if an entry was added or removed since the iterator was created (“Map modified during iteration”)

current

current(): (K, V)

Returns the entry the last successful next() moved to.

Returns

  • the current entry as a (key, value) tuple

Panics

  • if an entry was added or removed since the iterator was created (“Map modified during iteration”)
  • if next() was never called or already returned false (“SortedMapIterator.current() without an entry”)

Set

struct Set<K>

A set of distinct elements of type K kept in sorted order, on the same red-black tree as Map but without values.

Elements are ordered by compare<K> (see Map); two elements that compare equal are the same member. Membership tests, inserts and removals take O(log count) time. iterate() visits the members in ascending order, reversed() in descending order, and range(from, to) the members from from up to (not including) to.

The set holds one reference to each member, acquired on insert and released on remove, on clear, or when the set is destroyed.

Type parameters

  • K — the element type; ordered by compare<K>

Example

Set<int> primes = [7, 2, 5, 3];
for (var p in primes) {
    print($"{p}");   # 2, 3, 5, 7
}

Properties

count

var count: int { get; }

The number of members. O(1).

is_empty

var is_empty: bool { get; }

Whether the set has no members. O(1).

Constructors

Set()

construct()

Creates an empty set.

Set()

[Implicit] construct(items: Array<K>)

Creates a set holding each element of items once.

The constructor is [Implicit]: an array converts to a set wherever a set is expected, so an array literal can initialize one.

Parameters

  • items — the elements to insert; duplicates are kept once

Example

Set<String> keywords = ["while", "if", "else"];
assert(keywords.first().value() == "else", "sorted");

Methods

clear

clear()

Removes every member and releases the set’s references to them. O(count).

contains

contains(key: K): bool

Returns whether key is a member. O(log count).

Parameters

  • key — the element to look up

Returns

  • true when key is a member

insert

insert(key: K): bool

Adds key to the set if it is not already a member. When an equal member exists, the set keeps the member it has. O(log count).

Parameters

  • key — the element to add

Returns

  • true when key was added, false when it was already a member

remove

remove(key: K): bool

Removes key from the set, releasing the set’s reference to the member. Does nothing when key is not a member. O(log count).

Parameters

  • key — the element to remove

Returns

  • true when key was a member and was removed, false otherwise

first

first(): K?

Returns the smallest member. O(log count).

Returns

  • the smallest member, or none when the set is empty

last

last(): K?

Returns the largest member. O(log count).

Returns

  • the largest member, or none when the set is empty

lower_bound

lower_bound(key: K): K?

Returns the smallest member that is not less than key. O(log count).

Parameters

  • key — the element to search from

Returns

  • the first member >= key, or none when every member is less

upper_bound

upper_bound(key: K): K?

Returns the smallest member greater than key. O(log count).

Parameters

  • key — the element to search from

Returns

  • the first member > key, or none when no member is greater

iterate

iterate(): SortedSetIterator<K>

Returns an iterator over the members in ascending order; for (var x in set) drives it.

The iterator holds a reference to the set. Adding or removing a member while iterating makes the iterator’s next next() or current() panic.

Returns

  • an iterator positioned before the smallest member

reversed

reversed(): SortedSetIterator<K>

Returns an iterator over the members in descending order, under the same rules as iterate.

Returns

  • an iterator positioned before the largest member

range

range(from: K, to: K): SortedSetIterator<K>

Returns an iterator over the members that are at least from and less than to, in ascending order, under the same rules as iterate. It is empty when from is not less than to.

Parameters

  • from — the smallest member to visit
  • to — the element to stop before

Returns

  • an iterator positioned before the first member in the range

copy

copy(): Set<K>

Returns a deep copy: a new set holding a copy of each member, made with copy. This is how copy(value) copies a Set (the Copyable<Set<K>> implementation); call copy(set) rather than this method.

Returns

  • the copy

SortedSetIterator

[Value] struct SortedSetIterator<K>

An Iterator<K> over the members of a Set, in order or reversed; returned by Set.iterate(), reversed() and range(from, to), and driven by for (var x in ...).

Holds a reference to the set. It is fail-fast: adding or removing a member after the iterator was created makes its next next() or current() panic.

Type parameters

  • K — the element type of the set

See also

  • Set.iterate

Constructor

SortedSetIterator()

construct(source: Set<K>, start: ulong, stop: ulong, reverse: bool)

Creates an iterator over source from node start up to node stop. Internal: call Set.iterate, reversed or range instead.

Parameters

  • source — the set to iterate
  • start — the first node to visit
  • stop — the node to stop before, 0 for the end
  • reverse — whether to step in descending order

Methods

next

next(): bool

Advances to the next member.

Returns

  • true when there is a current member, false once every member was visited

Panics

  • if a member was added or removed since the iterator was created (“Set modified during iteration”)

current

current(): K

Returns the member the last successful next() moved to.

Returns

  • the current member

Panics

  • if a member was added or removed since the iterator was created (“Set modified during iteration”)
  • if next() was never called or already returned false (“SortedSetIterator.current() without a member”)

OrderedMap

OrderedMap<K, V>: a hash map that keeps its keys in the order they were first inserted.

Source: runtime/ordered_map.pep

OrderedMap

struct OrderedMap<K, V>

A hash map that iterates its entries in the order their keys were first inserted, like Python’s dict.

Keys are hashed with hash<K> and compared with ==, as in HashMap. Lookups, inserts and removals take expected O(1) time. Setting the value of a key that is already present keeps its position; removing a key and inserting it again moves it to the end.

iterate() visits the entries as (key, value) tuples in insertion order. The map holds one reference to each stored key and value, acquired when they are stored and released when they are overwritten, removed, cleared, or when the map is destroyed.

Type parameters

  • K — the key type; hashed with hash<K> and compared with ==
  • V — the value type

Example

var config = OrderedMap<String, String>();
config["name"] = "pepper";
config["version"] = "0.2";
config["license"] = "MIT";
for (var (key, value) in config) {
    print($"{key} = {value}");   # name, version, license
}

Properties

count

var count: int { get; }

The number of entries in the map. O(1).

is_empty

var is_empty: bool { get; }

Whether the map has no entries. O(1).

Constructor

OrderedMap()

construct(initial_capacity: ulong = 8)

Creates an empty map whose table has room for at least initial_capacity slots (see HashMap).

Parameters

  • initial_capacity — the minimum number of slots in the table

Methods

clear

clear()

Removes every entry and releases the map’s references to their keys and values. O(capacity).

contains

contains(key: K): bool

Returns whether the map has an entry for key. Expected O(1).

Parameters

  • key — the key to look up

Returns

  • true when key is present

insert

insert(key: K, value: V): bool

Maps key to value. A new key is added at the end of the order; an existing key keeps its position (and the key object the map already stores), and only its value is replaced. Expected O(1).

Parameters

  • key — the key to add or update
  • value — the value to store for key

Returns

  • true when a new key was added, false when an existing key’s value was overwritten

get

get(key: K): V

Returns the value stored for key, or the default of V (0, or a null reference for struct types) when key is absent. A missing key is not inserted. Expected O(1).

Parameters

  • key — the key to look up

Returns

  • the value for key, or the default of V when absent

See also

  • try_get

try_get

try_get(key: K): V?

Returns the value stored for key, or none when key is absent. Expected O(1).

Parameters

  • key — the key to look up

Returns

  • the value for key, or none when absent

remove

remove(key: K): bool

Removes the entry for key, releasing the map’s references to its key and value. The other entries keep their order. Does nothing when key is absent. Amortized expected O(1).

Parameters

  • key — the key to remove

Returns

  • true when key was present and removed, false when absent

operator []

[](key: K): V

Reads m[key]: the same as get. A missing key reads as the default of V and is not inserted.

Parameters

  • key — the key to look up

Returns

  • the value for key, or the default of V when absent

operator []

[](key: K, value: V): V

Writes m[key] = value: the same as insert.

Parameters

  • key — the key to add or update
  • value — the value to store for key

Returns

  • value, the value of the assignment expression

iterate

iterate(): OrderedMapIterator<K, V>

Returns an iterator over the entries as (key, value) tuples in insertion order; for (var (key, value) in map) drives it.

The iterator holds a reference to the map. Changing a value through an existing key while iterating is allowed; adding or removing an entry makes the iterator’s next next() or current() panic.

Returns

  • an iterator positioned before the first entry

copy

copy(): OrderedMap<K, V>

Returns a deep copy: a new map holding a copy of each key and value, in the same order, made with copy. This is how copy(value) copies an OrderedMap (the Copyable<OrderedMap<K, V>> implementation); call copy(map) rather than this method.

Returns

  • the copy

OrderedMapIterator

[Value] struct OrderedMapIterator<K, V>

An iterator over the entries of an OrderedMap as (key, value) tuples in insertion order; returned by OrderedMap.iterate() and driven by for (var (key, value) in map).

Holds a reference to the map. It is fail-fast: adding or removing an entry after the iterator was created makes its next next() or current() panic.

Type parameters

  • K — the key type of the map
  • V — the value type of the map

See also

  • OrderedMap.iterate

Constructor

OrderedMapIterator()

construct(source: OrderedMap<K, V>)

Creates an iterator positioned before the first entry of source.

Parameters

  • source — the map to iterate

Methods

next

next(): bool

Advances to the next entry.

Returns

  • true when there is a current entry, false once every entry was visited

Panics

  • if an entry was added or removed since the iterator was created (“OrderedMap modified during iteration”)

current

current(): (K, V)

Returns the entry the last successful next() moved to.

Returns

  • the current entry as a (key, value) tuple

Panics

  • if an entry was added or removed since the iterator was created (“OrderedMap modified during iteration”)
  • if next() was never called or already returned false (“OrderedMapIterator.current() without an entry”)

BitSet

BitSet: a set of small non-negative integers, stored one bit each.

Source: runtime/bitset.pep

BitSet

struct BitSet

A growable set of non-negative integers, one bit per possible member.

Member n is bit n % 64 of word n / 64, so a set whose largest member is m takes about m / 8 bytes whatever its count: a good fit for visited flags, masks, and sieve or liveness tables over small integers. For sparse or large integers use a HashSet<int>.

insert grows the set as needed and reserve grows it ahead of time; the set never shrinks. contains and remove take O(1); count, is_empty, ==, hash, iteration and the set operations take O(capacity / 64).

Two sets are == when they hold the same members, whatever their capacity, and equal sets hash equally, so a BitSet works as a HashMap or HashSet key. A BitSet is not [Shared]: keep each one on one thread.

Example

BitSet primes = [2, 3, 5, 7];
primes.insert(11);
assert(primes.contains(5), "a member");
assert(primes.count == 5, "five members");
for (var p in primes) {
    print($"{p}");   # 2, 3, 5, 7, 11
}

Properties

capacity

var capacity: int { get; }

The number of members the set holds without growing, a multiple of 64.

count

var count: int { get; }

The number of members in the set. O(capacity / 64).

is_empty

var is_empty: bool { get; }

Whether the set has no members. O(capacity / 64).

Constructors

BitSet()

construct(capacity: int = 0)

Creates an empty set with room for the members 0 up to (not including) capacity before it grows.

Parameters

  • capacity — the number of members to make room for; rounded up to a multiple of 64

Panics

  • if capacity is negative

BitSet()

[Implicit] construct(items: int[])

Creates a set holding each element of items.

The constructor is [Implicit]: an array converts to a set wherever a set is expected, so an array literal can initialize one.

Parameters

  • items — the members to insert; duplicates are kept once

Panics

  • if an element of items is negative

Example

BitSet flags = [1, 4, 4];
assert(flags.count == 2, "4 is kept once");

Methods

reserve

reserve(capacity: int)

Makes room for the members 0 up to (not including) capacity, so inserting them does not grow the set. Never shrinks it.

Parameters

  • capacity — the number of members to make room for

Panics

  • if capacity is negative

clear

clear()

Removes every member. The set keeps its capacity.

contains

contains(n: int): bool

Returns whether n is a member. O(1).

Parameters

  • n — the integer to look up; a negative one is never a member

Returns

  • true when n is a member

insert

insert(n: int): bool

Adds n to the set, growing it when n is past its capacity.

Parameters

  • n — the integer to add

Returns

  • true when n was added, false when it was already a member

Panics

  • if n is negative

remove

remove(n: int): bool

Removes n from the set. Does nothing when n is not a member.

Parameters

  • n — the integer to remove

Returns

  • true when n was a member and was removed, false otherwise

iterate

iterate(): BitSetIterator

Returns an iterator over the members in ascending order; for (var n in set) drives it.

The iterator holds a reference to the set. Changing the set while iterating is safe: the iterator sees the members above its position as they are when it reaches them.

Returns

  • an iterator positioned before the smallest member

union_with

union_with(other: BitSet)

Adds every member of other to this set.

Parameters

  • other — the set whose members to add

intersect_with

intersect_with(other: BitSet)

Keeps only the members that other also holds.

Parameters

  • other — the set to intersect with

difference_with

difference_with(other: BitSet)

Removes every member that other holds.

Parameters

  • other — the set whose members to remove

symmetric_difference_with

symmetric_difference_with(other: BitSet)

Keeps the members that exactly one of the two sets holds: removes the members other also holds and adds those only other holds.

Parameters

  • other — the set to compare with

union

union(other: BitSet): BitSet

Returns a new set holding the members of either set.

Parameters

  • other — the other set

Returns

  • the union of the two sets

intersection

intersection(other: BitSet): BitSet

Returns a new set holding the members both sets hold.

Parameters

  • other — the other set

Returns

  • the intersection of the two sets

difference

difference(other: BitSet): BitSet

Returns a new set holding the members of this set that other does not hold.

Parameters

  • other — the set whose members to leave out

Returns

  • this set minus other

symmetric_difference

symmetric_difference(other: BitSet): BitSet

Returns a new set holding the members that exactly one of the two sets holds.

Parameters

  • other — the other set

Returns

  • the symmetric difference of the two sets

is_subset

is_subset(other: BitSet): bool

Returns whether every member of this set is also a member of other. The empty set is a subset of every set.

Parameters

  • other — the set to compare with

Returns

  • true when this set holds no member that other lacks

is_disjoint

is_disjoint(other: BitSet): bool

Returns whether the two sets have no member in common.

Parameters

  • other — the set to compare with

Returns

  • true when no integer is a member of both sets

operator ==

==(rhs: BitSet): bool

Reports whether both sets hold the same members. Capacity is not compared.

Parameters

  • rhs — the set to compare with

Returns

  • true when the sets have the same members

operator !=

!=(rhs: BitSet): bool

Reports whether the sets differ in any member; the negation of ==.

Parameters

  • rhs — the set to compare with

Returns

  • true when one set holds a member the other lacks

copy

copy(): BitSet

Returns a new set with the same members and capacity. This is how copy(value) copies a BitSet (the Copyable<BitSet> implementation).

Returns

  • the copy

hash

hash<BitSet>(value: BitSet): u64

Returns a hash of the members of value, so equal sets hash equally whatever their capacity and a BitSet works as a HashMap or HashSet key. O(capacity / 64).

Parameters

  • value — the set to hash

Returns

  • the 64-bit hash of the set’s members

BitSetIterator

[Value] struct BitSetIterator

An Iterator<int> over the members of a BitSet in ascending order; returned by BitSet.iterate() and driven by for (var n in set).

See also

  • BitSet.iterate

Constructor

BitSetIterator()

construct(source: BitSet)

Creates an iterator positioned before the smallest member of source.

Parameters

  • source — the set to iterate

Methods

next

next(): bool

Advances to the next larger member.

Returns

  • true when there is a current member, false once every member was visited

current

current(): int

Returns the member the last successful next() moved to.

Returns

  • the current member

Panics

  • if next() was never called or already returned false (“BitSetIterator.current() without a member”)

Deque

Deque: a double-ended queue over a growable ring buffer.

Source: runtime/deque.pep

Deque

struct Deque<T>

A double-ended queue: a sequence that grows and shrinks at both ends in amortized O(1).

Elements live in a ring buffer that doubles when full (starting at 4 slots), so push_front, push_back, pop_front and pop_back are amortized O(1), and indexing is O(1) with index 0 at the front. Use it for first-in first-out queues, breadth-first search, sliding windows and undo history; an Array takes O(count) to remove its first element.

For a struct T the deque holds a counted reference to each element, as an Array does: pushing acquires it, and popping hands the deque’s reference to the caller. Deque is itself a struct, so assigning it shares the same deque. A Deque is not [Shared]: keep each one on one thread.

A Deque is Iterable<T>: for (var x in d) visits the elements from front to back. Every change (a push, a pop, an element store, clear) invalidates running iterators, and the next step of such a loop panics with “Deque modified during iteration”. An array converts to a deque, so an array literal can initialize one.

Type parameters

  • T — the element type

Example

Deque<int> queue = [1, 2];
queue.push_back(3);
queue.push_front(0);
assert(queue.pop_front() == 0, "front first");
assert(queue.pop_back() == 3, "then the back");
assert(queue[0] == 1 and queue.count == 2, "1 and 2 remain");

Properties

count

var count: ulong { get; }

The number of elements.

is_empty

var is_empty: bool { get; }

Whether the deque has no elements.

capacity

var capacity: ulong { get; }

The number of elements the deque holds without growing.

Constructors

Deque()

construct(capacity: ulong = 0)

Creates an empty deque with room for capacity elements before it grows.

Parameters

  • capacity — the number of slots to allocate up front

Deque()

[Implicit] construct(items: T[])

Creates a deque holding the elements of items, front to back in index order. The array is copied, not shared.

The constructor is [Implicit]: an array converts to a deque wherever a deque is expected, so an array literal can initialize one.

Parameters

  • items — the elements, the first at the front

Example

Deque<String> names = ["ada", "grace"];
assert(names.front() == "ada", "the first element is the front");

Methods

reserve

reserve(capacity: ulong)

Makes room for capacity elements in all, so pushing up to that many does not grow the deque. Never shrinks it.

Parameters

  • capacity — the number of elements to make room for

push_back

push_back(item: T)

Adds item at the back. Amortized O(1). Invalidates running iterators.

Parameters

  • item — the element to add

push_front

push_front(item: T)

Adds item at the front, so it becomes element 0. Amortized O(1). Invalidates running iterators.

Parameters

  • item — the element to add

pop_front

pop_front(): T

Removes and returns the front element. O(1). Invalidates running iterators.

Returns

  • the element that was at the front

Panics

  • if the deque is empty (“Deque pop_front on empty Deque”)

pop_back

pop_back(): T

Removes and returns the back element. O(1). Invalidates running iterators.

Returns

  • the element that was at the back

Panics

  • if the deque is empty (“Deque pop_back on empty Deque”)

front

front(): T

Returns the front element without removing it; the same as d[0].

Returns

  • the element at index 0

Panics

  • if the deque is empty (“Deque front on empty Deque”)

back

back(): T

Returns the back element without removing it; the same as d[d.count - 1].

Returns

  • the element at index count - 1

Panics

  • if the deque is empty (“Deque back on empty Deque”)

operator []

[](index: ulong): T

Returns the element at index, counting from 0 at the front; this is the indexer read d[index]. O(1).

Parameters

  • index — the position to read

Returns

  • the element at index

Panics

  • if index < count does not hold (“Deque index read out of bounds”)

operator []

[](index: ulong, value: T): T

Replaces the element at index; this is the indexer write d[index] = value. Invalidates running iterators.

Parameters

  • index — the position to overwrite
  • value — the new element

Returns

  • the element now stored at index (that is, value)

Panics

  • if index < count does not hold (“Deque index write out of bounds”)

clear

clear()

Removes every element, releasing each one. The deque keeps its capacity. Invalidates running iterators.

iterate

iterate(): DequeIterator<T>

Returns an iterator over the elements from front to back; for (var x in d) drives it.

Returns

  • an iterator positioned before the front element

DequeIterator

[Value] struct DequeIterator<T>

An Iterator<T> over the elements of a Deque from front to back; returned by Deque.iterate() and driven by for (var x in d).

Type parameters

  • T — the element type of the deque

See also

  • Deque.iterate

Constructor

DequeIterator()

construct(source: Deque<T>)

Creates an iterator positioned before the front element of source.

Parameters

  • source — the deque to iterate

Methods

next

next(): bool

Advances to the next element.

Returns

  • true when there is a current element, false once past the back

Panics

  • if the deque changed since the iterator was created (“Deque modified during iteration”)

current

current(): T

Returns the element the last successful next() moved to.

Returns

  • the current element

Panics

  • if the deque changed since the iterator was created (“Deque modified during iteration”)
  • if next() was never called or already returned false (“DequeIterator.current() without an element”)

PriorityQueue

PriorityQueue: a binary heap that hands out its greatest element first.

Source: runtime/priority_queue.pep

PriorityQueue

struct PriorityQueue<T>

A priority queue: push adds elements in any order and pop removes the greatest one.

A binary heap over an Array: push and pop take O(log count), peek takes O(1), and building a queue from an array takes O(count). Use it for Dijkstra’s and A* searches, schedulers, timers and top-k selection.

Elements are ordered by compare<T> (numbers by value, String by content, a Comparable struct by its compare), or by a comparer given to the constructor, which returns a negative number, zero or a positive number as its first argument sorts before, with or after its second. The greatest element comes out first; reverse the comparer for the smallest first. Elements that sort together come out in no particular order.

For a struct T the queue holds a counted reference to each element: pushing acquires it, and popping hands the queue’s reference to the caller. A PriorityQueue is not [Shared]: keep each one on one thread.

Type parameters

  • T — the element type

Example

PriorityQueue<int> queue = [3, 1, 4];
queue.push(5);
assert(queue.pop() == 5, "the greatest first");
assert(queue.peek() == 4, "then 4");

# The smallest first, through a reversed comparer.
var earliest = PriorityQueue<int>((a, b) => b.compare(a));
earliest.push(30);
earliest.push(10);
assert(earliest.pop() == 10, "the smallest first");

Properties

count

var count: ulong { get; }

The number of elements.

is_empty

var is_empty: bool { get; }

Whether the queue has no elements.

Constructors

PriorityQueue()

construct()

Creates an empty queue ordered by compare<T>.

PriorityQueue()

construct(compare: (T, T) => int)

Creates an empty queue ordered by compare.

Parameters

  • compare — returns a negative number, zero or a positive number as its first argument sorts before, with or after its second

Example

var by_length = PriorityQueue<String>((a, b) => a.length.compare(b.length));

PriorityQueue()

[Implicit] construct(items: T[])

Creates a queue holding the elements of items, ordered by compare<T>. O(count). The array is copied, not shared.

The constructor is [Implicit]: an array converts to a queue wherever a queue is expected, so an array literal can initialize one.

Parameters

  • items — the elements, in any order

PriorityQueue()

construct(items: T[], compare: (T, T) => int)

Creates a queue holding the elements of items, ordered by compare. O(count). The array is copied, not shared.

Parameters

  • items — the elements, in any order
  • compare — returns a negative number, zero or a positive number as its first argument sorts before, with or after its second

Methods

push

push(item: T)

Adds item. O(log count), amortized.

Parameters

  • item — the element to add

pop

pop(): T

Removes and returns the greatest element. O(log count).

Returns

  • the greatest element

Panics

  • if the queue is empty (“PriorityQueue pop on empty PriorityQueue”)

peek

peek(): T

Returns the greatest element without removing it. O(1).

Returns

  • the greatest element

Panics

  • if the queue is empty (“PriorityQueue peek on empty PriorityQueue”)

clear

clear()

Removes every element, releasing each one.

into_sorted_array

into_sorted_array(): T[]

Removes every element and returns them sorted from smallest to greatest. O(count log count). The queue is left empty, keeping its comparer.

Returns

  • the elements in ascending order

Example

PriorityQueue<int> queue = [2, 3, 1];
var sorted = queue.into_sorted_array();   # [1, 2, 3]

Iterators

Source: runtime/iterator.pep

Iterator

trait Iterator<T>

Something a for loop can step through, one element at a time.

for (var x in source) drives a source that declares next() and current() directly: it calls next() before each pass and binds current() to the loop variable while next() returns true. A type whose for loop should go through a separate iterator implements Iterable<T> instead. A type with neither protocol is a compile error in a for loop.

The loop goes by the methods a type has, so declaring the trait is optional. Declaring it (extend Foo with Iterator<int>;) checks that the methods match and lets the type be used as an Iterator<int> value, which a for loop drives through the trait.

Type parameters

  • T — the type of the elements produced

See also

  • Iterable

Example

struct Countdown {
    int remaining;
    int now;

    construct(this.remaining = 0) {}

    bool next() {
        if (remaining == 0) {
            return false;
        }
        now = remaining;
        remaining = remaining - 1;
        return true;
    }

    int current() {
        return now;
    }
}

extend Countdown with Iterator<int>;

main(): int {
    var total = 0;
    for (int n in Countdown(3)) {
        total = total + n;   # visits 3, 2, 1
    }
    return total;
}

Methods

next

next(): bool

Advances to the next element.

The first call moves to the first element, if any. Once it returns false it must keep returning false.

Returns

  • true if there is a current element, false when the elements are exhausted

current

current(): T

Returns the element the last successful next() moved to.

Calling it before the first next(), or after next() returned false, is an error; the runtime’s iterators panic in that case.

Returns

  • the current element

map

map<U>(transform: (T) => U): MapIterator<T, U>

Returns an iterator over transform applied to each element, in order.

Lazy: transform runs once per element, as the result is advanced, and the result advances this iterator. Every type that declares Iterator<T> gets this method.

Type parameters

  • U — the type transform returns

Parameters

  • transform — the function applied to each element

Returns

  • a MapIterator<T, U> over the transformed elements

Example

var names = Array<String>();
for (var size in names.iterate().map((name) => name.size)) {
    print(size);
}

filter

filter(keep: (T) => bool): FilterIterator<T>

Returns an iterator over the elements for which keep returns true, in order.

Lazy: keep runs once per element, as the result is advanced, and the result advances this iterator. Every type that declares Iterator<T> gets this method.

Parameters

  • keep — the test an element must pass

Returns

  • a FilterIterator<T> over the elements that pass

Example

var nums = Array<int>();
for (var even in nums.iterate().filter((n) => n % 2 == 0)) {
    print(even);
}

enumerate

enumerate<TIndex=ulong>(): EnumerateIterator<T, TIndex>

Returns an iterator over the elements paired with their positions: (0, first), (1, second), and so on.

Lazy, like map. Destructure the pair in the loop to name both parts. The positions count in ulong, like Array.count; name another integer type to count in it (enumerate<int>()). Every type that declares Iterator<T> gets this method.

Type parameters

  • TIndex — the integer type of the positions, ulong by default

Returns

  • an EnumerateIterator<T, TIndex> over (index, element) tuples

Example

var names = ["Ada", "Grace"];
for (var (i, name) in names.iterate().enumerate()) {
    print($"{i}: {name}");   # 0: Ada, then 1: Grace
}

zip

zip<U>(other: Iterator<U>): ZipIterator<T, U>

Returns an iterator over pairs of this iterator’s elements and other’s, in step: (first, other's first), and so on. It stops when either runs out.

Lazy, like map. Every type that declares Iterator<T> gets this method.

Type parameters

  • U — the type of other’s elements

Parameters

  • other — the iterator whose elements form the second half of each pair

Returns

  • a ZipIterator<T, U> over (element, other element) tuples

Example

var names = ["Ada", "Grace"];
var ages = [36, 45];
for (var (name, age) in names.iterate().zip(ages.iterate())) {
    print($"{name} is {age}");
}

to_array

to_array(): Array<T>

Returns a new Array holding the remaining elements, in order.

Runs the iteration to the end. Like the adapters, it works on a copy of the iterator, so a value iterator (every runtime iterator is one) is left where it was. Every type that declares Iterator<T> gets this method.

Returns

  • the elements, possibly none

Example

var evens = (1..>10).filter((n) => n % 2 == 0).to_array();   # [2, 4, 6, 8, 10]

MapIterator

[Value] struct MapIterator<T, U>

The iterator Iterator.map returns: each element of the source iterator, passed through the transform function.

Type parameters

  • T — the type of the source’s elements
  • U — the type of the elements produced

See also

  • Iterator.map

Constructor

MapIterator()

construct(source: Iterator<T>, transform: (T) => U)

Makes an iterator over transform applied to the elements of source.

Parameters

  • source — the iterator to transform
  • transform — the function applied to each element

Methods

next

next(): bool

Advances to the next element, and applies the transform function to it.

Returns

  • true if there is a current element, false when the source is exhausted

current

current(): U

Returns the transformed element the last successful next() produced.

Returns

  • the current element

FilterIterator

[Value] struct FilterIterator<T>

The iterator Iterator.filter returns: the elements of the source iterator that the test function accepts.

Type parameters

  • T — the type of the elements

See also

  • Iterator.filter

Constructor

FilterIterator()

construct(source: Iterator<T>, keep: (T) => bool)

Makes an iterator over the elements of source that keep accepts.

Parameters

  • source — the iterator to filter
  • keep — the test an element must pass

Methods

next

next(): bool

Advances to the next element that the test function accepts.

Returns

  • true if there is a current element, false when the source is exhausted

current

current(): T

Returns the element the last successful next() moved to.

Returns

  • the current element

EnumerateIterator

[Value] struct EnumerateIterator<T, TIndex>

The iterator Iterator.enumerate returns: each element of the source iterator, paired with its position.

Type parameters

  • T — the type of the source’s elements
  • TIndex — the integer type of the positions

See also

  • Iterator.enumerate

Constructor

EnumerateIterator()

construct(source: Iterator<T>)

Makes an iterator over the elements of source, numbered from 0.

Parameters

  • source — the iterator to number

Methods

next

next(): bool

Advances to the next element.

Returns

  • true if there is a current element, false when the source is exhausted

current

current(): (TIndex, T)

Returns the element the last successful next() moved to, with its index.

Returns

  • the current (index, element) pair

ZipIterator

[Value] struct ZipIterator<T, U>

The iterator Iterator.zip returns: the elements of two iterators, paired in step, until either runs out.

Type parameters

  • T — the type of the first iterator’s elements
  • U — the type of the second iterator’s elements

See also

  • Iterator.zip

Constructor

ZipIterator()

construct(first: Iterator<T>, second: Iterator<U>)

Makes an iterator over the elements of first and second, paired.

Parameters

  • first — the iterator giving the first half of each pair
  • second — the iterator giving the second half of each pair

Methods

next

next(): bool

Advances both iterators. second is not advanced once first has run out.

Returns

  • true if both have a current element, false when either is exhausted

current

current(): (T, U)

Returns the pair the last successful next() moved to.

Returns

  • the current (first element, second element) pair

Iterable

trait Iterable<T>

Something that hands out an Iterator<T> over its elements, such as Array<T> and String (which yields one code point at a time, as a String).

for (var x in source) calls source.iterate() once, then drives the returned iterator with next() and current(). When a type declares both iterate() and next()/current(), the loop uses iterate().

Type parameters

  • T — the type of the elements produced

See also

  • Iterator

Example

var nums = Array<int>();
nums.append(1);
nums.append(2);
var total = 0;
for (var x in nums) {
    total = total + x;
}

Methods

iterate

iterate(): Iterator<T>

Returns a new iterator positioned before the first element.

The result may be any type that satisfies Iterator<T> (for example ArrayIterator<T>); each call starts a fresh, independent pass.

Returns

  • an iterator over the elements

Scoped

The scope protocol behind with (var x = scope) { ... } else { ... }.

A with block holds scope in a hidden local, binds x to scope.enter(), and runs the body. When enter() returns a struct reference that is null, the body is skipped and the optional else block runs instead. Both locals are released on every path out of the block (falling off the end, return, break, continue), the binding first. Cleanup is the scope object’s destructor, which calls exit().

Source: runtime/scoped.pep

Scoped

trait Scoped<T>

Something a with block can enter.

enter() runs once when the block is entered, and its result is what the header binds. exit() is the cleanup: implementations call it from their destructor (destruct { this.exit(); }), so it runs when the scope object’s last reference is released. For a scope object created in the with header that is the end of the block; for one that is also held elsewhere, it is when that last owner lets go.

A with block with no binding (with (scope) { ... }) still holds the result of enter() until the block ends. An enter() that returns void has nothing to bind.

Type parameters

  • T — the type enter() returns

Example

var open = 0;

struct Session {
    construct() {
        open = open + 1;
    }

    destruct {
        this.exit();
    }

    Session enter() {
        return this;
    }

    exit() {
        open = open - 1;
    }
}

extend Session with Scoped<Session>;

main(): int {
    with (var session = Session()) {
        # open == 1 here
    }
    return open;   # 0: the session was destroyed at the end of the block
}

Methods

enter

enter(): T

Enters the scope.

A null struct reference means there is nothing to use: the with body is skipped and its else block runs instead.

Returns

  • the value the with header binds

exit

exit()

Leaves the scope. Implementations call it from their destructor.

Weak

Weak references: Weak<T> points at an object without keeping it alive.

Source: runtime/weak.pep

Weak

struct Weak<T>

A reference to a struct object, or to the struct behind a trait value (Weak<Shape>), that does not keep it alive, for back pointers (a child’s parent, an observer’s subject) that would otherwise form a reference cycle and leak.

The object is reached only through a with block, which upgrades the weak reference to an ordinary strong one for the length of the block. Once the object has been destroyed the upgrade fails: the body is skipped and the else block runs instead.

A Weak<T> never changes what it points at, so copies of it can be shared freely.

Type parameters

  • T — the struct or trait type pointed at

See also

  • Scoped

Example

struct Child {
    Weak<Parent> owner;

    construct(Parent owner) {
        this.owner = Weak<Parent>(owner);
    }

    String owner_name() {
        with (var parent = this.owner) {
            return parent.name;
        } else {
            return "(gone)";
        }
        return "";
    }
}

Constructor

Weak()

construct(target: T)

Creates a weak reference to target’s object.

Parameters

  • target — the object to point at; a null reference makes a weak reference that is always expired

Methods

expired

expired(): bool

Reports whether the object has been destroyed.

A false result can be out of date by the time it is used; enter the reference with with to use the object.

Returns

  • true once the object has been destroyed, or if the reference was made from null

enter

enter(): T

Upgrades the weak reference for a with block.

Returns

  • a new strong reference to the object, or null once it has been destroyed

exit

exit()

Leaves the scope. The strong reference enter() returned is released by the with block itself, so there is nothing to do.

Handle

Handle<T>: owns a resource that Pepper’s reference counting does not know about, such as a C library’s object, and releases it when the last reference to the handle goes away, like C#’s SafeHandle.

Source: runtime/handle.pep

Handle

struct Handle<T>

Owns a raw resource and calls a release function on it once, when the handle is destroyed or release() is called, whichever comes first.

Use it to tie a C library’s object (a __ptr<void> from an extern, a file descriptor) to Pepper’s lifetimes: hold a Handle in a struct field, and the resource is freed when the struct is. A handle is a reference like any struct, so copies share one resource, and it is released when the last copy goes away. detach() takes the resource back without releasing it.

release is an ordinary function value, so a closure may capture what the release needs (such as an owner that must outlive the resource: holding it in the closure keeps it alive until the release runs).

Type parameters

  • T — the type of the raw resource, usually __ptr<void> or an integer

Example

extern void* malloc(ulong size);
extern void free(void* p);

main(): int {
    var buffer = Handle<__ptr<void>>(malloc(16), (p) => free(p));
    # use buffer.value; free runs when `buffer` goes out of scope
    return 0;
}

Properties

value

var value: T { get; }

The resource. It stays readable after the handle is released or detached, but it no longer refers to anything the handle owns.

Constructor

Handle()

construct(value: T, release: (T) => void)

Creates a handle that owns value and calls release(value) when it is destroyed.

Parameters

  • value — the resource to own
  • release — the function that frees value; it is called at most once

Methods

is_owned

is_owned(): bool

Reports whether the handle still owns its resource: false after release() or detach().

Returns

  • true while the resource has not been released or detached

release

release()

Releases the resource now instead of when the handle is destroyed. Calling it again, or after detach(), does nothing.

detach

detach(): T

Gives up ownership without releasing the resource, which the caller then frees itself.

Returns

  • the resource

Result

Result<T, E>: the value of an operation that can fail, either Ok holding its value or Err holding its error, like Rust’s Result.

Source: runtime/result.pep

Error

struct Error

The default error type of Result: a human-readable message.

Example

var r = Result<int>.err(Error("boom"));
var n = r.error().message.length;   # 4

Fields

message

String message

The description of what went wrong; empty when none was given.

Constructor

Error()

[Implicit] construct(message: String = "")

Creates an Error carrying message.

The constructor is [Implicit]: a String converts to an Error wherever an Error is expected, as in Result<int>.err("bad input").

Parameters

  • message — the description of the failure; defaults to the empty string

Result

[Value] enum Result<TValue, TError=Error>

The outcome of an operation that can fail: either Ok, holding a TValue, or Err, holding a TError, like Rust’s Result<T, E>.

An enum with the cases Ok(value) and Err(error). Build one with a case (Result<int>.Ok(42)) or the static ok or err functions, and read it with match or is (if (r is Err(e)) { ... }), or with is_ok(), is_err(), value() and error(); value() and error() panic on the wrong kind of result. TError defaults to Error, so Result<int> is Result<int, Error>.

Result<void> is the result of an operation that returns nothing on success, like Rust’s Result<(), E>. A void type argument erases what it types: that instance’s Ok carries nothing (Result<void>.Ok, and the pattern Ok), ok() takes no argument, and value() returns nothing, so it only checks that the result is Ok.

Type parameters

  • TValue — the type of the value an Ok result carries; void when there is none
  • TError — the type of the error an Err result carries; defaults to Error

Example

main(): int {
    var a = Result<int>.ok(42);
    if (a.value() != 42) { return 1; }
    var b = Result<int>.err(Error("boom"));
    if (!b.is_err()) { return 2; }
    var n = match (b) { Ok(v) => v, Err(e) => e.message.length };   # 4
    return b.ok_or(0);   # 0
}

Cases

Ok

Ok(value: TValue)

The operation succeeded with value.

Parameters

  • value — the success value

Err

Err(error: TError)

The operation failed with error.

Parameters

  • error — the failure value

Static methods

ok

static ok(v: TValue): Result<TValue, TError>

Returns an Ok result holding v; the same as Result.Ok(v).

For a struct TValue the result acquires a reference to v.

For Result<void>, ok() takes no argument.

Parameters

  • v — the success value

Returns

  • an Ok result whose value() is v

Example

var r = Result<int>.ok(42);
var done = Result<void>.ok();

err

static err(e: TError): Result<TValue, TError>

Returns an Err result holding e; the same as Result.Err(e).

For a struct TError the result acquires a reference to e.

Parameters

  • e — the failure value

Returns

  • an Err result whose error() is e

Example

var r = Result<int>.err(Error("boom"));

Methods

is_ok

is_ok(): bool

Reports whether this result is Ok.

Returns

  • true when the result holds a value

is_err

is_err(): bool

Reports whether this result is Err.

Returns

  • true when the result holds an error

value

value(): TValue

Returns the success value, panicking if this result is Err.

For a struct TValue the caller gets its own reference; the result keeps its reference too.

For Result<void> it returns nothing: calling it only checks that the result is Ok.

Returns

  • the value of an Ok result

Panics

  • if the result is Err (“Result.value on an Err value”)

Example

var n = Result<int>.ok(42).value();   # 42

error

error(): TError

Returns the failure value, panicking if this result is Ok.

For a struct TError the caller gets its own reference; the result keeps its reference too.

Returns

  • the error of an Err result

Panics

  • if the result is Ok (“Result.error on an Ok value”)

Example

var e = Result<int>.err(Error("boom")).error();   # e.message == "boom"

ok_or

ok_or(fallback: TValue): TValue

Returns the success value, or fallback if this result is Err.

Never panics. fallback is evaluated by the caller whether or not it is used.

Parameters

  • fallback — the value to return when the result is Err

Returns

  • the value when the result is Ok, otherwise fallback

Example

var n = Result<int>.err(Error("e")).ok_or(-1);   # -1

Optional

Source: runtime/optional.pep

Optional

[Value] enum Optional<T>

A value of type T that may be absent: either Value, holding a T, or Empty, holding nothing, like Rust’s Option<T> and Swift’s Optional.

Use it instead of a null reference or a sentinel (-1, "") wherever a value may be missing: the type says so, and reading the value goes through value(), which checks, or value_or(), which cannot fail. It works for every type, including those that have no null, such as int.

T? is shorthand for Optional<T>, and a variable or field of an optional type that is not given a value starts as Empty.

Build one with its cases (Optional<int>.Value(1), Optional<int>.Empty). Test it with is_value() or is_empty(), as a condition by itself (if (found)), or with match and is on its cases (if (found is Value(v))).

An optional is a value enum: it is stored inline in whatever holds it, with no allocation, and copying one gives an independent optional. It is never null, and has no identity, so compare optionals with ==, not ===.

Type parameters

  • T — the type of the value a Value optional holds

Example

find_even(values: Array<int>): int? {
    for (var v in values) {
        if (v % 2 == 0) { return Optional<int>.Value(v); }
    }
    return Optional<int>.Empty;
}

main(): int {
    var values = Array<int>();
    values.append(3);
    values.append(4);
    var found = find_even(values);
    if (!found) { return 1; }
    return found.value() - 4;   # 0
}

Cases

Empty

Empty

Holds no value. It is the first case, so an optional that is never given a value is Empty.

Value

Value(value: T)

Holds a value.

Parameters

  • value — the value held

Static methods

some

[Deprecated("use the case 'Value' instead", "Value")] static some(value: T): Optional<T>

Deprecated: use the Value case, Optional<T>.Value(value), instead.

Returns a Value optional holding value.

Parameters

  • value — the value to hold

Returns

  • a Value optional whose value is value

none

[Deprecated("use the case 'Optional<T>.Empty' instead")] static none(): Optional<T>

Deprecated: use the Empty case, Optional<T>.Empty, instead.

Returns an Empty optional, one holding no value.

Returns

  • an Empty optional

Methods

is_value

is_value(): bool

Reports whether this optional holds a value.

Returns

  • true when the optional is Value

Example

var found = Optional<int>.Value(7);
if (found.is_value()) { print(found.value()); }   # prints 7

is_empty

is_empty(): bool

Reports whether this optional holds no value.

Returns

  • true when the optional is Empty

Example

var missing = Optional<int>.Empty;
if (missing.is_empty()) { print("missing"); }

is_some

[Deprecated("use 'is_value' instead", "is_value")] is_some(): bool

Deprecated: use is_value instead.

Reports whether this optional holds a value.

Returns

  • true when the optional is Value

is_none

[Deprecated("use 'is_empty' instead", "is_empty")] is_none(): bool

Deprecated: use is_empty instead.

Reports whether this optional holds no value.

Returns

  • true when the optional is Empty

operator !!

!!(): bool

Reports whether this optional holds a value: the truth test, so an optional can stand as a condition by itself.

if (found) means if (found.is_value()), !found means found.is_empty(), and the same holds in while, &&, || and ?:.

Returns

  • true when the optional is Value

Example

var found = Optional<int>.Value(7);
if (found) { print(found.value()); }   # prints 7

value

value(): T

Returns the value, panicking if this optional is Empty.

For a struct T the caller gets its own reference; the optional keeps its reference too.

Returns

  • the value of a Value optional

Panics

  • if the optional is Empty (“Optional.value on an Empty optional”)

Example

var n = Optional<int>.Value(42).value();   # 42

value_or

value_or(fallback: T): T

Returns the value, or fallback if this optional is Empty.

Never panics. fallback is evaluated by the caller whether or not it is used.

Parameters

  • fallback — the value to return when the optional is Empty

Returns

  • the value when the optional is Value, otherwise fallback

Example

var n = Optional<int>.Empty.value_or(-1);   # -1

Copying

Deep copies: copy(value) makes a copy that shares no object that is not thread-safe with the original, so it can be handed to another thread; Copyable<T> lets a type say how it is copied.

Source: runtime/copy.pep

copy

copy<T>(value: T): T

Returns a copy of value that shares no non-thread-safe object with it, so the copy can be handed to another thread.

Every struct reachable from value whose type is not thread-safe is copied once. Objects referenced twice stay shared inside the copy, and cycles are preserved. Thread-safe objects ([Shared] structs, shared function values) are not copied: the copy references the same ones. A weak reference to a copied object points at its copy; a weak reference to an object outside the copied value becomes null.

A struct is copied field by field, without running its constructor, unless it implements Copyable<T>. A struct with a destructor or a raw pointer field must implement Copyable<T> to be copied, since copying its fields would share what it owns; String, Array, HashMap and HashSet do. Copying such a struct that does not, or a plain function value, is a compile error.

Called inside a Copyable<T>.copy() implementation while a copy is running, copy continues that copy: an object it already copied is not copied again.

A trait value is copied by copying the struct behind it, which is known only at run time: when that struct cannot be copied, copy panics.

Runs in O(size of the value) and allocates each copied object.

Type parameters

  • T — the type of the value

Parameters

  • value — the value to copy

Returns

  • a copy that shares no non-thread-safe object with value; value itself when T is thread-safe

Panics

  • if value reaches a trait value holding a struct that cannot be copied

Example

var names = Array<String>();
names.append("ada");
var copied = copy(names);
copied[0].append(" lovelace");   # names[0] is still "ada"

register_copy

register_copy<T>(original: T, made: T)

Records made as the copy of original in the running copy, so that copying original again returns made. A Copyable<T>.copy() implementation calls it before copying the values it holds when one of them may lead back to it; outside a copy it does nothing.

Type parameters

  • T — the type of the value, a struct

Parameters

  • original — the object being copied
  • made — its copy

Example

copy(): Node {
    var made = Node(value);
    register_copy(this, made);   # a cycle back to this finds made
    made.next = copy(next);
    return made;
}

move

move<T>([MoveSource] value: T): T

Hands over the reference value holds and leaves it null, with no copy and no change to any count: the result is the same object.

value must be a local variable (or a [Move] parameter) that holds the only reference to its value: nothing else may refer to it or to anything it reaches. The compiler checks that, and that the variable is not used again until it is assigned a new value. Use it to pass a value you built to a parameter that takes a new value, such as Channel.send or Mutex.replace, without the cost of copy.

Type parameters

  • T — the type of the value

Parameters

  • value — the variable to move from

Returns

  • the value value held, which nothing else references

Example

var lines = Array<String>();
lines.append("first");
var log = async.Mutex<Array<String>>(move(lines));   # lines is null now

Copyable

trait Copyable<T>

A type that says how copy copies it, for a struct that copy cannot copy field by field: one with a destructor or a raw pointer field, such as a container that owns a buffer.

Declare it with extend Buffer with Copyable<Buffer>; and give the struct a copy(): Buffer method. The method builds the copy and copies each struct value it holds with copy(value), which, called while a copy is running, reuses a copy already made, so objects referenced twice stay shared.

Type parameters

  • T — the implementing type

Example

struct Buffer {
    __ptr<u8> data;
    ulong size;

    construct(ulong size = 0) {
        data = _pep_malloc(size);
        this.size = size;
    }

    destruct {
        _pep_free(data);
    }

    copy(): Buffer {
        var made = Buffer(size);
        var i: ulong = 0;
        while (i < size) {
            made.data[i] = data[i];
            i = i + 1;
        }
        return made;
    }
}

extend Buffer with Copyable<Buffer>;

Methods

copy

copy(): T

Returns a copy of this value, for copy.

Copy each struct value this one holds with copy(value). A value that can hold a reference back to itself calls register_copy(this, made) before copying what it holds, so the cycle leads back to the copy. Call copy(x), not x.copy(), to copy a Copyable value: copy(x) keeps objects shared inside one copy.

Returns

  • the copy

Copies

[Deprecated("implement 'Copyable<T>' as 'copy(): T', which copies what it holds with 'copy(value)'")] type Copies = _Copies

Deprecated: implement Copyable<T> as copy(): T, which copies what it holds with copy(value) and register_copy(original, made).

The context of a running copy, which a Copyable<T> implementation once took as copy(copies: Copies): T. Such an implementation still works for this release.

URI

URI references (RFC 3986): parse any URI, such as https://example.com/a?b#c, mailto:someone@example.com or /items?page=2, into its parts.

Source: runtime/uri.pep

Uri

struct Uri

A URI reference split into its RFC 3986 components: scheme, authority (user information, host and port), path, query and fragment.

Uri.parse accepts any scheme, and a reference with no scheme (a relative reference, such as /items?page=2, which is what an HTTP server sees as a request’s target). The components keep their percent-escapes as written; Uri.decode turns an escaped component into text. The scheme is lowercased, since schemes are case-insensitive; nothing else is changed. to_string() puts the parts back together.

A component that can be absent is optional, so an empty one (http://h/? has an empty query) differs from a missing one (http://h/ has none).

Example

var uri = Uri.parse("HTTPS://user@example.com:8443/a%20b?x=1#top").value();
assert(uri.scheme == "https", "the scheme is lowercased");
assert(uri.user_info.value() == "user", "user information");
assert(uri.host.value() == "example.com", "host");
assert(uri.port.value() == 8443, "port");
assert(uri.path == "/a%20b", "the path keeps its escapes");
assert(Uri.decode(uri.path).value() == "/a b", "decoded");
assert(uri.query.value() == "x=1" && uri.fragment.value() == "top", "query and fragment");

Fields

scheme

var scheme: String

The scheme, lowercased, such as https; empty for a relative reference.

user_info

var user_info: String?

The user information before @ in the authority, if any.

host

var host: String?

The host, without the brackets of an IPv6 address; Empty when the URI has no authority (no //), and empty for one such as file:///tmp. An IPv6 scope keeps its escaped % (RFC 6874): the host of http://[fe80::1%25eth0]/ is fe80::1%25eth0, which Uri.decode turns into the fe80::1%eth0 that io.IpAddress.parse reads.

port

var port: u16?

The port after the host, if the authority gives one.

path

var path: String

The path, possibly empty, with its percent-escapes.

query

var query: String?

The query after ?, without the ?, if any.

fragment

var fragment: String?

The fragment after #, without the #, if any.

Constructor

Uri()

construct()

Creates an empty relative reference: no scheme, no authority, and an empty path.

Static methods

parse

static parse(text: String): Result<Uri>

Parses text as a URI reference: an absolute URI with any scheme, or a relative reference.

Characters outside printable ASCII (spaces, controls, and every non-ASCII byte) must be percent-encoded, and each % must be followed by two hexadecimal digits.

Parameters

  • text — the URI reference

Returns

  • the parsed URI, or an error saying what is wrong: an invalid scheme, a character that must be encoded, a bad escape, an IPv6 host with no ], or a port that is not a number from 0 to 65535

Example

var mail = Uri.parse("mailto:someone@example.com").value();
assert(mail.scheme == "mailto" && mail.host.is_empty(), "no authority");
assert(mail.path == "someone@example.com", "the path holds the address");
assert(Uri.parse("a b").is_err(), "a space must be encoded");

decode

static decode(text: String): Result<String>

Returns text with each percent-escape (%20) replaced by the byte it stands for, decoded as UTF-8. A + is kept as it is; a query that uses + for a space needs that replaced first.

Parameters

  • text — a component with percent-escapes, such as path

Returns

  • the decoded text, where each byte sequence that is not valid UTF-8 becomes U+FFFD, the replacement character, or an error when a % is not followed by two hexadecimal digits

Example

assert(Uri.decode("caf%C3%A9%21").value() == "café!", "decoded");
assert(Uri.decode("100%").is_err(), "a lone '%'");

encode

static encode(text: String): String

Returns text with each byte outside RFC 3986’s unreserved set (A-Z a-z 0-9 - . _ ~) written as a percent-escape, so the result is safe as one path segment or one query value: /, ?, &, = and % are escaped too. decode reverses it.

Parameters

  • text — the component to escape, such as a file name or a search term

Returns

  • the escaped text, with uppercase hexadecimal digits

Example

assert(Uri.encode("a b/c&d") == "a%20b%2Fc%26d", "escaped");
assert(Uri.encode("café") == "caf%C3%A9", "UTF-8 bytes");

encode_form

static encode_form(pairs: Array<(String, String)>): String

Returns pairs as an application/x-www-form-urlencoded body or query, the way an HTML form sends them: name=value joined with &, each side escaped like encode except that a space is +.

Parameters

  • pairs — the names and values, in order; a name may repeat

Returns

  • the encoded text, such as q=pepper+lang&page=2

Example

assert(Uri.encode_form([("q", "pepper lang"), ("page", "2")]) == "q=pepper+lang&page=2", "a query");

decode_form

static decode_form(text: String): Result<Array<(String, String)>>

Returns the names and values of an application/x-www-form-urlencoded body or query, such as a request’s query, in order. Each + is a space and percent-escapes are decoded; a pair with no = has an empty value, and empty pairs (a=1&&b=2) are skipped.

Parameters

  • text — the encoded pairs, such as q=pepper+lang&page=2

Returns

  • the decoded pairs, or an error when a % is not followed by two hexadecimal digits

Example

var pairs = Uri.decode_form("q=pepper+lang&page=2").value();
assert(pairs.count == 2 && pairs[0].1 == "pepper lang", "decoded");

Methods

is_absolute

is_absolute(): bool

Returns whether this URI has a scheme (an absolute URI rather than a relative reference).

Returns

  • true when scheme is not empty

authority

authority(): String?

Returns the authority as it appears after //: user information, host (in brackets when it is an IPv6 address) and port.

Returns

  • the authority, such as user@example.com:8443, or Empty when host is Empty

path_and_query

path_and_query(): String

Returns the path and query, as an HTTP request line names its target: the path (/ when it is empty), then ? and the query if there is one. The fragment is left out.

Returns

  • the path and query, such as /items?page=2

resolve

resolve(reference: Uri): Uri

Resolves reference against this URI, its base, as RFC 3986 (section 5.2) describes and as a browser resolves a link on a page: an absolute reference stands as it is, //host/path keeps only the base’s scheme, /path keeps its authority too, and a relative path replaces the base’s last segment. . and .. segments are removed. The fragment is always the reference’s.

Parameters

  • reference — the reference to resolve, such as an HTTP Location

Returns

  • the resolved URI

Example

var base = Uri.parse("https://example.com/docs/guide/intro?x=1").value();
assert(base.resolve(Uri.parse("next").value()).to_string() == "https://example.com/docs/guide/next", "a sibling");
assert(base.resolve(Uri.parse("../api").value()).to_string() == "https://example.com/docs/api", "up one");
assert(base.resolve(Uri.parse("/").value()).to_string() == "https://example.com/", "the root");
assert(base.resolve(Uri.parse("?y=2").value()).to_string() == "https://example.com/docs/guide/intro?y=2", "a new query");

to_string

to_string(): String

Returns the URI as text, putting its components back together. For a parsed URI this is the text it was parsed from, except that the scheme is lowercased.

Returns

  • the URI reference as a String

Test checks

Checks for tests: expect_equal, expect_not_equal, expect_true and expect_false, which every program can call without an import. Each panics when its check fails, as assert does, but reports the values it compared, at the line of the call:

math_test.pep:12:5: panic: expect_equal failed
  actual:   3
  expected: 4

A [Test] function fails on that panic (see the testing chapter).

Source: runtime/expect.pep

expect_equal

expect_equal<T>(actual: T, expected: T)

Panics unless actual == expected, reporting both values.

Type parameters

  • T — the type of the values; it needs == and a text form (see to_string)

Parameters

  • actual — the value the code under test produced
  • expected — the value it should have produced

Panics

  • if the values differ: prints the location of the call and both values, and aborts (see panic)

Example

var numbers = Array<int>();
numbers.append(1);
expect_equal(numbers.count, 1);

expect_not_equal

expect_not_equal<T>(actual: T, unexpected: T)

Panics if actual == unexpected, reporting the value.

Type parameters

  • T — the type of the values; it needs == and a text form (see to_string)

Parameters

  • actual — the value the code under test produced
  • unexpected — the value it must not be

Panics

  • if the values are equal: prints the location of the call and the value, and aborts (see panic)

Example

expect_not_equal("a".repeat(2), "a");

expect_true

expect_true(condition: bool)

Panics unless condition holds. Unlike assert, it needs no message: the location names the check.

Parameters

  • condition — the condition that must hold

Panics

  • if condition is false: prints the location of the call, and aborts (see panic)

Example

expect_true("pepper".starts_with("pep"));

expect_false

expect_false(condition: bool)

Panics if condition holds.

Parameters

  • condition — the condition that must not hold

Panics

  • if condition is true: prints the location of the call, and aborts (see panic)

Example

expect_false("pepper".is_empty);

The io module

Files, directories, paths, and sockets, modeled on Rust’s std::fs, std::path and std::net.

Unlike the rest of the standard library, io is a module: bring it in with import io (and write io.File, io.Path) or import names directly with import [File, Path] from io. Every fallible operation returns an IoResult<T>, a Result whose error is an IoError.

  • Path: building, splitting, and inspecting paths
  • File: opening, reading, writing, and locking files, whole-file helpers, and FileTransfer
  • Directory: listing, creating, and removing directories
  • Network addresses: IpAddress, SocketAddress, and resolve
  • Sockets: TcpListener, Incoming, SharedTcpListener, TcpStream, SocketTransfer, and UdpSocket
  • Unix domain sockets: UnixListener, SharedUnixListener, UnixStream, and UnixDatagram
  • Streams: the Reader and Writer traits, lines() and chunks()
  • Streams in memory: ByteReader and ByteWriter
  • Print: deprecated; use Writer
  • Errors and platform types: IoError, ErrorKind, IoResult, and handle types

Example

import io;

main(): int {
    var path = io.Directory.temp().join("greeting.txt");
    assert(io.File.write_text(path, "hello").is_ok(), "write");
    assert(io.File.read_to_string(path).value() == "hello", "read back");
    io.File.remove(path);
    return 0;
}

Source: runtime/io/module.pep

Errors and platform types

Source: runtime/io/types.pep

FileHandle

[Target(!OS(Windows))] type FileHandle = int

The raw operating-system handle of an open file: a POSIX file descriptor.

File.Handle holds one. A valid descriptor is >= 0; a File that is closed (or was never opened) holds -1. The standard streams are descriptors 0, 1 and 2. On Windows this is a HANDLE instead.

See also

  • File

Note

  • code that must build for every target should not rely on this being an int

SocketHandle

[Target(!OS(Windows))] type SocketHandle = int

The raw operating-system handle of a socket: a POSIX file descriptor.

TcpListener, TcpStream and UdpSocket hold one. A valid descriptor is >= 0; a closed socket holds -1. On Windows this is a SOCKET instead.

See also

  • TcpStream

Note

  • code that must build for every target should not rely on this being an int

ErrorCode

[Target(!OS(Windows))] type ErrorCode = int

The raw platform error code carried by IoError.code: a POSIX errno value such as ENOENT (2). On Windows this is a DWORD from GetLastError() instead.

The numbers differ between platforms (and a few between Linux and macOS), so portable code classifies an error with IoError.kind() or its predicates (is_not_found(), …) rather than comparing codes.

See also

  • IoError

Mode

[Target(!OS(Windows))] type Mode = uint

File mode bits as reported in Metadata.Permissions: on POSIX the mode_t permission bits mode & 07777 (the rwx bits for owner, group and others plus setuid, setgid and sticky; the file-type bits are removed). On Windows the same field holds FILE_ATTRIBUTE_* bits instead.

See also

  • Metadata

FileHandle

[Target(OS(Windows))] type FileHandle = __ptr<void>

The raw operating-system handle of an open file: a Windows HANDLE.

File.Handle holds one. A File that is closed (or was never opened) holds null; neither null nor INVALID_HANDLE_VALUE (-1) is a valid handle. The standard streams are the handles from GetStdHandle. On POSIX this is an int file descriptor instead.

See also

  • File

Note

  • code that must build for every target should not rely on this being a __ptr<void>

SocketHandle

[Target(OS(Windows))] type SocketHandle = ulong

The raw operating-system handle of a socket: a Winsock SOCKET, an unsigned pointer-sized integer.

TcpListener, TcpStream and UdpSocket hold one. A closed socket holds INVALID_SOCKET (all bits set). On POSIX this is an int file descriptor instead.

See also

  • TcpStream

Note

  • code that must build for every target should not rely on this being a ulong

ErrorCode

[Target(OS(Windows))] type ErrorCode = uint

The raw platform error code carried by IoError.code: a Windows DWORD error code from GetLastError(), such as ERROR_FILE_NOT_FOUND (2). On POSIX this is an errno value instead.

The numbers differ between platforms, so portable code classifies an error with IoError.kind() or its predicates (is_not_found(), …) rather than comparing codes.

See also

  • IoError

Mode

[Target(OS(Windows))] type Mode = uint

File attribute bits as reported in Metadata.Permissions: on Windows the whole FILE_ATTRIBUTE_* word of the file (FILE_ATTRIBUTE_READONLY is 1, FILE_ATTRIBUTE_DIRECTORY 16, FILE_ATTRIBUTE_REPARSE_POINT 1024, …). On POSIX the same field holds mode_t permission bits instead.

See also

  • Metadata

Offset

type Offset = long

A byte position or distance within a file, as taken and returned by the File.seek* methods: a signed 64-bit integer on every target (off_t on POSIX, LARGE_INTEGER on Windows), so files larger than 4 GiB work everywhere.

ErrorKind

enum ErrorKind

The portable kind of an IoError, as IoError.kind() returns it: the platform’s error code sorted into the cases a program usually handles.

Each case except Other has a matching IoError predicate (NotFound and is_not_found(), …), whose documentation lists the codes it covers on each platform. A code that fits none of them, such as the EIO of a short write or the EBADF of a closed File, is Other.

See also

  • IoError.kind

Example

import io;

var r = io.File.open(io.Path("settings.txt"));
if (r.is_err()) {
    match (r.error().kind()) {
        NotFound => { print("no settings file"); }
        PermissionDenied => { print("settings file is not readable"); }
        else => { print(r.error().message); }
    }
}

Cases

NotFound

NotFound

The file, directory or host name does not exist (is_not_found()).

PermissionDenied

PermissionDenied

Access was denied (is_permission_denied()).

AlreadyExists

AlreadyExists

The target already exists (is_already_exists()).

NotADirectory

NotADirectory

A directory was expected but something else was found (is_not_a_directory()).

IsADirectory

IsADirectory

A file was expected but a directory was found (is_is_a_directory()).

DirectoryNotEmpty

DirectoryNotEmpty

A directory to remove still has entries (is_directory_not_empty()).

InvalidInput

InvalidInput

An argument or the data was invalid (is_invalid_input()).

Interrupted

Interrupted

The operation was interrupted before it finished (is_interrupted()).

ConnectionRefused

ConnectionRefused

Nothing accepted the connection (is_connection_refused()).

ConnectionReset

ConnectionReset

The peer reset, aborted or closed the connection (is_connection_reset()).

AddressInUse

AddressInUse

Another socket already uses the address (is_address_in_use()).

AddressNotAvailable

AddressNotAvailable

The address cannot be used on this machine (is_address_not_available()).

TimedOut

TimedOut

The operation timed out (is_timed_out()).

Cancelled

Cancelled

Cancelling the task ended the operation (is_cancelled()).

Other

Other

Any other code, including 0 (no error).

IoError

struct IoError

An I/O failure: the raw platform error code plus a human-readable message.

kind() sorts the code into an ErrorKind, and a predicate per kind (is_not_found(), is_permission_denied(), …) tests for one of them; each knows the matching codes on the current platform. Test with those rather than comparing code against numbers. Failures that Pepper detects itself (invalid UTF-8, an empty path, a short write, a closed File) use the platform’s own code for the kind they are (EINVAL / ERROR_INVALID_PARAMETER for invalid input, ENOENT / ERROR_FILE_NOT_FOUND for an empty path, …), so the kinds hold for them too. A short write uses EIO / ERROR_WRITE_FAULT and a closed File uses EBADF / ERROR_INVALID_HANDLE; those are ErrorKind.Other.

See also

  • IoResult
  • ErrorKind

Example

import io;

var r = io.File.open(io.Path("settings.txt"));
if (r.is_err()) {
    if (r.error().is_not_found()) {
        print("no settings file");
    } else {
        print(r.error().message);
    }
}

Fields

code

ErrorCode code

The raw platform error code: an errno value on POSIX, a GetLastError() code on Windows; 0 means no error.

message

String message

A human-readable description: the platform’s text for code (strerror on POSIX, FormatMessageW on Windows, without its trailing period and line break), or Pepper’s own text such as "empty path" for errors it detects itself.

Constructor

IoError()

construct(code: ErrorCode = 0, message: String = "")

Creates an error from a platform code and a message.

The default, IoError(), has code 0 and an empty message; no predicate holds for it.

Parameters

  • code — the raw platform error code (errno on POSIX, GetLastError() on Windows)
  • message — the human-readable description

Static methods

last

static last(): IoError

Captures the calling thread’s last platform error now: errno on POSIX, GetLastError() on Windows, with the platform’s message for it.

Call it immediately after the failing system call; any call in between (including one that allocates) may overwrite the code.

Returns

  • an error holding the current errno / GetLastError() code and its message

invalid_input

static invalid_input(message: String): IoError

Makes an error for data or an argument that is not valid, such as a damaged file a library parses, with the platform’s code for invalid input, so is_invalid_input() holds.

Parameters

  • message — what is wrong

Returns

  • the error

Example

import io;

var error = io.IoError.invalid_input("data.bin: bad header");
assert(error.is_invalid_input(), "the kind");

Methods

kind

kind(): ErrorKind

Returns the portable kind of the error: code sorted by the codes the current platform uses for each kind. Each predicate below is kind() == ErrorKind.X for its kind.

Returns

  • the kind of code; ErrorKind.Other for a code that fits no kind, and for 0

Example

import io;

var r = io.File.open(io.Path("missing.txt"));
if (r.is_err() && r.error().kind() == io.ErrorKind.NotFound) {
    print("no such file");
}

is_not_found

is_not_found(): bool

Returns whether the error means the file or directory does not exist.

Matches ENOENT on POSIX, and ERROR_FILE_NOT_FOUND or ERROR_PATH_NOT_FOUND on Windows. An empty path fails with this kind on POSIX (with the message "empty path"), and in File.open_with on every platform.

resolve and TcpStream.connect_host fail with this kind too when a host name has no address.

Returns

  • true when a path, or a directory on the way to it, is missing, or a host name has no address

is_permission_denied

is_permission_denied(): bool

Returns whether the operation was refused for lack of permission.

Matches EACCES or EPERM on POSIX, and ERROR_ACCESS_DENIED on Windows (which Windows also reports for, among others, deleting a read-only file).

Returns

  • true when access was denied

is_already_exists

is_already_exists(): bool

Returns whether the operation failed because its target already exists, such as Directory.create on an existing path or opening with create_new.

Matches EEXIST on POSIX, and ERROR_FILE_EXISTS or ERROR_ALREADY_EXISTS on Windows.

Returns

  • true when the target already exists

is_not_a_directory

is_not_a_directory(): bool

Returns whether a path that had to name a directory names something else, such as Directory.open or Directory.remove on a file (or, on POSIX, a file used as a directory in the middle of a path).

Matches ENOTDIR on POSIX, and ERROR_DIRECTORY (267, “the directory name is invalid”) on Windows.

Returns

  • true when a directory was expected

is_is_a_directory

is_is_a_directory(): bool

Returns whether an operation that needs a file was given a directory, such as reading from a directory opened as a File.

Matches EISDIR on POSIX, and ERROR_DIRECTORY_NOT_SUPPORTED on Windows (which File reads report for a directory handle).

Returns

  • true when a directory was given where a file was expected

is_directory_not_empty

is_directory_not_empty(): bool

Returns whether a directory could not be removed because it still has entries (Directory.remove on a non-empty directory).

Matches ENOTEMPTY on POSIX (39 on Linux, 66 on macOS) and ERROR_DIR_NOT_EMPTY on Windows.

Returns

  • true when the directory is not empty

is_invalid_input

is_invalid_input(): bool

Returns whether an argument was invalid: bad options (such as opening with no access mode), data that is not valid UTF-8 where text was expected, or a malformed path.

Matches EINVAL on POSIX, and ERROR_INVALID_PARAMETER or ERROR_INVALID_NAME on Windows.

Returns

  • true when the input was invalid

is_interrupted

is_interrupted(): bool

Returns whether the operation was interrupted before it finished.

Matches EINTR on POSIX and WSAEINTR on Windows. An aborted Windows operation (ERROR_OPERATION_ABORTED) is is_cancelled() instead. The io functions retry interrupted POSIX opens, reads and writes themselves, so callers rarely see this.

Returns

  • true when the operation was interrupted

is_connection_refused

is_connection_refused(): bool

Returns whether a connection attempt was refused: nothing listens on that port (the peer answered with a reset).

Matches ECONNREFUSED on POSIX and WSAECONNREFUSED on Windows.

Returns

  • true when the connection was refused

is_connection_reset

is_connection_reset(): bool

Returns whether an established connection was broken by the peer: reset, aborted, or (when writing) already closed.

Matches ECONNRESET, ECONNABORTED or EPIPE on POSIX, and WSAECONNRESET or WSAECONNABORTED on Windows.

Returns

  • true when the connection was reset or closed by the peer

is_address_in_use

is_address_in_use(): bool

Returns whether binding failed because another socket already uses the address and port.

Matches EADDRINUSE on POSIX and WSAEADDRINUSE on Windows.

Returns

  • true when the address is in use

is_address_not_available

is_address_not_available(): bool

Returns whether an address cannot be used here: binding to an IP address that is not one of this machine’s, or connecting to port 0.

Matches EADDRNOTAVAIL on POSIX and WSAEADDRNOTAVAIL on Windows.

Returns

  • true when the address is not available

is_timed_out

is_timed_out(): bool

Returns whether an operation gave up because the peer did not answer in time, such as a connection attempt to an unreachable host, or because an async.timeout scope around it expired.

Matches ETIMEDOUT on POSIX and WSAETIMEDOUT on Windows.

Returns

  • true when the operation timed out

is_cancelled

is_cancelled(): bool

Returns whether an operation stopped because the task waiting for it was cancelled (see async.Task.cancel).

Matches ECANCELED on POSIX, and ERROR_OPERATION_ABORTED or ERROR_CANCELLED on Windows.

Returns

  • true when cancellation ended the operation

IoResult

type IoResult<T> = Result<T, IoError>

The result of an io operation: a value on success, an IoError on failure. Check is_ok() / is_err() before calling value() or error(); operations that produce nothing return IoResult<void>.

Type parameters

  • T — the value an operation produces on success

See also

  • IoError

Example

import io;

var cwd = io.Directory.current();
if (cwd.is_ok()) {
    print(cwd.value().to_string());
} else {
    print(cwd.error().message);
}

Reader and Writer

Reader and Writer: the traits of byte streams, such as a File, a TcpStream, a UnixStream or a tls.TlsStream, so code can read from or write to any of them.

Source: runtime/io/stream.pep

Reader

trait Reader

A stream of bytes that can be read: a File, TcpStream, UnixStream or tls.TlsStream, or any type with read(buffer, max).

A type has the trait by providing read; it then gets lines() and chunks(size), which read the stream as a for loop asks for more. Take a Reader (or <R: Reader>) to accept any stream.

Example

import io;

count_lines(source: io.Reader): int {
    var count = 0;
    for (var line in source.lines()) {
        if (line.is_err()) {
            break;
        }
        count += 1;
    }
    return count;
}

Methods

read

read(buffer: u8[], max: ulong): IoResult<ulong>

Waits until some bytes are available, then appends up to max of them to buffer.

Parameters

  • buffer — the array the bytes are appended to
  • max — the most bytes to read

Returns

  • the number of bytes appended, 0 at the end of the stream; an error when the stream fails

lines

lines(): Lines

Returns an iterator over the rest of the stream’s lines, read as it goes, so a for loop handles a large file without reading it all.

Each element is an IoResult<String>: a line without its line ending (\n, or \r\n). The last line counts even without a final line ending, and a stream that ends with one has no empty line after it. A read error, or a line that is not valid UTF-8 (is_invalid_input()), is yielded as an error and ends the lines. The stream is read in 64 KiB chunks, so a file’s position after the loop is not the end of the last line returned.

Returns

  • a new Lines positioned before the first line

Example

var f = File.open(Path("notes.txt")).value();
for (var line in f.lines()) {
    print(line.value());
}

chunks

chunks(size: ulong): Chunks

Returns an iterator over the rest of the stream in chunks of at most size bytes, each read as the loop asks for it.

Each element is an IoResult<u8[]>: a new array of what one read returned, which may be fewer than size bytes (a socket returns what has arrived). The iteration ends at the end of the stream; a read error is yielded and ends it.

Parameters

  • size — the most bytes in one chunk

Returns

  • a new Chunks positioned before the first chunk

Panics

  • if size is 0 (“Reader.chunks() needs a size above 0”)

Example

var total = 0;
for (var chunk in stream.chunks(4096)) {
    total += chunk.value().count;
}

Writer

trait Writer

A stream of bytes that can be written: a File, TcpStream, UnixStream or tls.TlsStream, or any type with write and write_all.

A type has the trait by providing write and write_all(data: u8[]); it then gets print and print_line, which write the text to_string makes of a value (the text $"{value}" would hold).

Example

import io;

main(): int {
    var path = io.Directory.temp().join("report.txt");
    var file = io.File.create(path).value();
    file.print("total: ");
    file.print_line(42);
    file.close();
    assert(io.File.read_to_string(path).value() == "total: 42\n", "printed");
    io.File.remove(path);
    return 0;
}

Methods

write

write(data: u8[]): IoResult<ulong>

Writes what the stream takes in one call, which may be less than all of data.

Parameters

  • data — the bytes to write

Returns

  • the number of bytes written; an error when the stream fails

write_all

write_all(data: u8[]): IoResult<void>

Writes all of data.

Parameters

  • data — the bytes to write

Returns

  • an Ok result once every byte is written; an error when the stream fails
print<T>(value: T): IoResult<void>

Writes the text of value, with no line break after it: a String as it is, anything else through to_string.

Type parameters

  • T — the type of value

Parameters

  • value — the value to print

Returns

  • an Ok result once the text is written; an error as for write_all

Example

file.print("n = ");
file.print(3);   # the file now holds "n = 3"
print_line<T>(value: T): IoResult<void>

Writes the text of value and a line break (\n), as the global print does on standard output.

Type parameters

  • T — the type of value

Parameters

  • value — the value to print

Returns

  • an Ok result once the line is written; an error as for write_all

Example

file.print_line($"total: {total}");
file.print_line((1, "two"));   # (1, two)

Lines

struct Lines

The iterator Reader.lines() returns: the lines of a stream, read in chunks as the loop asks for them, each as an IoResult<String>.

See also

  • Reader.lines

Example

var f = File.open(Path("notes.txt")).value();
var it = f.lines();
while (it.next()) {
    var line = it.current();
}

Constructor

Lines()

construct(source: Reader)

Makes an iterator over the lines of source, from what it reads next.

Parameters

  • source — the open stream to read

Methods

next

next(): bool

Reads the next line.

Once it returns false it keeps returning false.

Returns

  • true if there is a current element (a line, or the error that ends the lines), false at the end of the stream

current

current(): IoResult<String>

Returns the element the last successful next() moved to.

Returns

  • the current line, or the error that ended the lines

Panics

  • if next() was never called, or already returned false (“Lines.current() without a line”)

Chunks

struct Chunks

The iterator Reader.chunks(size) returns: the rest of a stream, one read of at most size bytes at a time, each as an IoResult<u8[]>.

See also

  • Reader.chunks

Constructor

Chunks()

construct(source: Reader, size: ulong)

Makes an iterator over source in chunks of at most size bytes.

Parameters

  • source — the open stream to read
  • size — the most bytes in one chunk

Methods

next

next(): bool

Reads the next chunk.

Once it returns false it keeps returning false.

Returns

  • true if there is a current element (a chunk, or the error that ends the chunks), false at the end of the stream

current

current(): IoResult<u8[]>

Returns the element the last successful next() moved to.

Returns

  • the current chunk, or the error that ended the chunks

Panics

  • if next() was never called, or already returned false (“Chunks.current() without a chunk”)

Path

Source: runtime/io/path.pep

Path

struct Path

An owned file-system path: the text of the path plus operations on it (Rust’s Path and PathBuf in one type).

Every method except exists, is_file, is_dir, metadata, symlink_metadata and canonicalize is pure text manipulation: it never touches the file system, so .. is not resolved against symlinks and nothing needs to exist. A part that may be missing comes back as an optional, as in Rust: parent() of a root is none.

Breaking change: parent(), file_name(), file_stem(), extension() and dot_index() used to return an empty Path or String (or name.size) for “none”; they now return Path?, String? and ulong?.

Separators: on POSIX only / separates components (\ is an ordinary byte of a name). On Windows both / and \ separate, and a path may start with a prefix: a drive (C:) or a UNC share (\\server\share). Methods that build a path (join, normalize, with_extension) insert the platform separator (separator()), but the rest of the text is kept as written. Paths are UTF-8 Strings, scanned byte by byte; separators and dots are ASCII, so splitting at them never cuts a character in two.

Example

import io;

var p = io.Path("docs").join("guide.md");   # "docs/guide.md"
var name = p.file_name().value();               # "guide.md"
var ext = p.extension().value_or("");           # "md"
var dir = p.parent();                           # some io.Path("docs")
if (p.is_file()) {
    print(p.to_string());
}

Properties

is_empty

var is_empty: bool { get; }

Whether the path has no text at all. File-system operations on the empty path fail.

Constructor

Path()

[Implicit] construct(value: String = "")

Creates a path holding value as is: no validation, normalization or separator conversion.

The constructor is [Implicit]: a String (or a string literal) converts to a Path wherever a Path is expected, so every API that takes a Path also accepts text.

Parameters

  • value — the path’s text; the default, "", is the empty path

Example

import io;

var p = io.Path("/usr/lib");
var empty = io.Path();   # is_empty is true
var q: io.Path = "docs";   # converted implicitly

Static methods

separator

static separator(): String

Returns the separator this platform inserts when it builds a path.

Returns

  • "/" on POSIX, "\" on Windows (where / is also accepted as a separator on input)

dot_index

static dot_index(name: String): ulong?

Returns the byte offset of the dot that starts the extension in the file name name: the last . that is not the first byte. A leading dot (.bashrc) starts the name, not an extension.

Breaking change: this used to return name.size for “no extension”; it now returns none.

Parameters

  • name — a file name (one component, as file_name() returns)

Returns

  • the index of the extension’s dot, or none when the name has no such dot (noext, .bashrc, the empty name)

Methods

to_string

to_string(): String

Returns the path’s text, the same String the Path holds, not a copy.

Returns

  • the path as a String

is_absolute

is_absolute(): bool

Returns whether the path is absolute, meaning it does not depend on the current directory. Pure text: the path need not exist.

On POSIX a path is absolute when it starts with /. On Windows it is absolute when it has a drive followed by a root separator (C:\x, C:/x) or is a UNC path (\\server\share\x); a root without a drive (\x) and a drive without a root (C:x) are both relative. The empty path is relative.

Returns

  • true when the path is absolute

is_relative

is_relative(): bool

Returns whether the path is relative: the opposite of is_absolute(). The empty path is relative.

Returns

  • true when the path is not absolute

join

join(other: Path): Path

Returns this path with other appended as further components.

A platform separator is inserted between the two unless this path already ends in one. If other is absolute, or (on Windows) starts with a root separator or a drive or UNC prefix, it replaces this path instead, as in Rust. Joining an empty other returns a copy of this path; joining onto the empty path returns other. Nothing is normalized: a joined with ../b is a/../b.

Parameters

  • other — the path to append

Returns

  • a new Path; this path is not changed

Example

import io;

var p = io.Path("usr").join(io.Path("lib"));   # "usr/lib" ("usr\lib" on Windows)
var q = io.Path("a/").join(io.Path("b"));      # "a/b"
var r = io.Path("a").join(io.Path("/abs"));    # "/abs"
var so = io.Path("usr").join("lib").join("x.so");   # a String converts to a Path

join_str

[Deprecated("use 'join', which accepts a String", "join")] join_str(other: String): Path

Returns this path with the String other appended, exactly like join(Path(other)). Deprecated: join accepts a String directly, since a String converts to a Path implicitly.

Parameters

  • other — the path text to append

Returns

  • a new Path; this path is not changed

See also

  • join

Example

import io;

var so = io.Path("usr").join("lib").join("x.so");   # "usr/lib/x.so"

has_root_or_prefix

has_root_or_prefix(): bool

Returns whether the path starts with a root separator or, on Windows, a drive or UNC prefix. This is what makes join replace instead of append; unlike is_absolute() it is also true for Windows paths such as \x and C:x. On POSIX it equals is_absolute().

Returns

  • true when the path has a root separator or a prefix

parent

parent(): Path?

Returns the path without its last component. Pure text: .. is not resolved, so the parent of a/.. is a.

Trailing and repeated separators before the last component are dropped (a/b/ and a//b both give a). The parent of a top-level name keeps the root and prefix (/a gives /, C:\x gives C:\, C:x gives C:).

Breaking change: this used to return the empty path for “no parent”; it now returns none.

Returns

  • the parent path, or none for a root (/, C:\, a bare prefix such as C:), a bare name with no root (a, ., ..), or the empty path

Example

import io;

var p = io.Path("/a/b/c.txt").parent().value();   # "/a/b"
var root = io.Path("/a").parent().value();        # "/"
var none = io.Path("/").parent();                 # none

file_name

file_name(): String?

Returns the last component of the path, ignoring trailing separators (a/b/ gives b).

Breaking change: this used to return "" for “no file name”; it now returns none.

Returns

  • the last component, or none for a root (/, C:\), the empty path, or a path whose last component is . or ..

Example

import io;

var name = io.Path("/a/b/c.txt").file_name().value();   # "c.txt"
var none = io.Path("a/..").file_name();                 # none

file_stem

file_stem(): String?

Returns the file name without its extension: everything before the last dot, unless that dot is the first character.

Breaking change: this used to return "" for “no file name”; it now returns none.

Returns

  • the stem (c.tar for c.tar.gz, .bashrc for .bashrc, foo for foo., noext for noext), or none when there is no file name (see file_name())

extension

extension(): String?

Returns the file name’s extension, without its dot: the text after the last dot, unless that dot is the first character. Only the file name counts; a dot in a directory name (a.b/c) is not an extension.

Breaking change: this used to return "" for “no extension”; it now returns none. As in Rust, a name ending in a dot (foo.) has the empty extension, which is not none.

Returns

  • the extension (gz for c.tar.gz, "" for foo.), or none when the file name has no dot after its first character (noext, .bashrc) or there is no file name

with_extension

with_extension(ext: String): Path

Returns this path with its file name’s extension replaced by ext, added when it has none, or removed when ext is empty. Trailing separators after the file name are dropped.

Parameters

  • ext — the new extension without a leading dot (a dot is inserted; ".md" would give b..md), or "" to remove the extension

Returns

  • the new path, or a copy of this path unchanged when it has no file name (a root, ., .., or the empty path)

Example

import io;

var md = io.Path("a/b.txt").with_extension("md");   # "a/b.md"
var rs = io.Path("a/b").with_extension("rs");       # "a/b.rs"
var bare = io.Path("a/b.txt").with_extension("");   # "a/b"

components

components(): Array<String>

Returns the path split into its components: the prefix (Windows drive or UNC share, such as C:), then the root separator (one character, as written), then each non-empty part between separators.

Repeated and trailing separators produce no empty parts. . parts are dropped, except a leading one in a path with no prefix or root (./a gives . and a). .. parts are kept as they are.

Returns

  • the components in order; empty for the empty path

Example

import io;

var c = io.Path("/usr//lib/./x/").components();   # ["/", "usr", "lib", "x"]
var d = io.Path("./a/b").components();            # [".", "a", "b"]

normalize

normalize(): Path

Returns the path cleaned up lexically, without touching the file system: . parts and repeated or trailing separators are dropped, and each .. removes the part before it.

A .. cannot climb above a root (/../x gives /x) and is kept at the start of a relative path (../a/.. gives ..). The prefix is kept, and every separator becomes the platform separator. Because symlinks are not consulted, link/.. becomes . even when link points elsewhere; use canonicalize() for the path the file system resolves.

Returns

  • the normalized path; . when the path cleans to nothing (a/.., ./), and the empty path for the empty path

See also

  • canonicalize

Example

import io;

var p = io.Path("a/./b/../c").normalize();   # "a/c" ("a\c" on Windows)
var q = io.Path("a/..").normalize();         # "."

exists

exists(): bool

Returns whether something exists at this path, following symlinks. A relative path is resolved against the current directory.

Any error counts as not existing: a dangling symlink, a path under a directory that cannot be read, and the empty path all give false. Use metadata() to tell those apart.

Returns

  • true when the path (or a symlink’s target) can be stat’ed

is_file

is_file(): bool

Returns whether the path names a regular file, following symlinks.

Returns

  • true for a regular file or a symlink to one; false for anything else, and on any error (including a missing path)

is_directory

is_directory(): bool

Returns whether the path names a directory, following symlinks.

Returns

  • true for a directory or a symlink to one; false for anything else, and on any error (including a missing path)

is_dir

[Deprecated("use 'is_directory' instead", "is_directory")] is_dir(): bool

Deprecated: use is_directory instead. Returns whether the path names a directory, following symlinks.

Returns

  • the same as is_directory()

metadata

metadata(): IoResult<Metadata>

Returns the metadata of the file or directory at this path, following symlinks: the result describes the target (so on POSIX is_symlink is always false). stat / statx on POSIX; on Windows the path is opened for attributes only.

Returns

  • the Metadata of the path (or of a symlink’s target); an error when the path cannot be stat’ed: is_not_found() when it (or a symlink’s target) is missing or the path is empty, is_permission_denied() when a directory on the way cannot be searched, and on POSIX is_not_a_directory() when a file is used as a directory in the middle of the path

See also

  • symlink_metadata

Example

import io;

var m = io.Path("data.bin").metadata();
if (m.is_ok()) {
    var size = m.value().size;
}
symlink_metadata(): IoResult<Metadata>

Returns the metadata of the path itself without following a symlink at the end of it, so for a symlink is_symlink is true and the rest describes the link rather than its target. lstat / statx with AT_SYMLINK_NOFOLLOW on POSIX; on Windows the path is opened with FILE_FLAG_OPEN_REPARSE_POINT, and any reparse point (junctions included) reports is_symlink, together with is_directory for a directory link. For anything that is not a symlink it equals metadata().

Returns

  • the Metadata of the path itself; an error as for metadata(), except that a dangling symlink succeeds

See also

  • metadata

canonicalize

canonicalize(): IoResult<Path>

Returns the absolute path the file system resolves this path to: relative to the current directory, with every symlink, . and .. resolved. Unlike normalize(), the path must exist.

POSIX uses realpath. Windows opens the path and asks for its final name; the result uses \, and the \\?\ prefix Windows reports is removed (\\?\UNC\server\share becomes \\server\share).

Returns

  • the canonical absolute path; an error when the path cannot be resolved: is_not_found() when it (or a symlink’s target) is missing or the path is empty, is_permission_denied() when a directory on the way cannot be searched

See also

  • normalize

Example

import io;

var real = io.Path("a/../b.txt").canonicalize();
if (real.is_ok()) {
    print(real.value().to_string());
}
read_link(): IoResult<Path>

Returns the target a symbolic link points to, as stored in the link (readlink on POSIX; on Windows the reparse point’s target, junctions included). Only the link itself is read: a relative target stays relative to the directory holding the link, and the target need not exist.

On Windows the \??\ prefix of an absolute target is removed (\??\UNC\server\share becomes \\server\share).

Returns

  • the link’s target; an error with is_invalid_input() when the path is not a symlink, is_not_found() when it is missing or empty, or another platform error

See also

  • File.symlink
  • Directory.symlink

Example

import io;

var target = io.Path("current").read_link();
if (target.is_ok()) {
    print(target.value().to_string());
}

set_permissions

set_permissions(mode: Mode): IoResult<void>

Sets the permission bits of the file or directory at this path, following symlinks (chmod on POSIX).

mode holds the raw platform bits, as Metadata.permissions reports them: on POSIX the mode_t bits mode & 07777 (493, octal 755, for rwxr-xr-x). A directory’s bits control listing it (r), creating and removing entries in it (w) and reaching anything inside it (x). On Windows mode is the FILE_ATTRIBUTE_* word, and only FILE_ATTRIBUTE_READONLY (1) is applied. WASI has no permission bits, and this fails. set_read_only and set_executable change one property portably.

Parameters

  • mode — the new permission bits

Returns

  • an Ok result once they are set; an error with is_not_found() when the path is missing or empty, is_permission_denied() when the caller does not own it, or another platform error

See also

  • File.set_permissions

Example

import io;

io.Path("deploy.sh").set_permissions(493);   # octal 755: rwxr-xr-x

set_read_only

set_read_only(read_only: bool): IoResult<void>

Makes the file or directory at this path read-only, or writable again, following symlinks: the portable part of set_permissions.

On POSIX, read-only clears every write bit (octal 222), and writable sets only the owner’s (octal 200), so nothing becomes writable by group or others this way. On Windows it sets or clears FILE_ATTRIBUTE_READONLY, which Windows ignores on a directory. Fails on WASI, which has no permission bits.

Parameters

  • read_only — true to make it read-only, false to make it writable by its owner

Returns

  • an Ok result once it is changed; an error as for metadata() or set_permissions

See also

  • Metadata.read_only

Example

import io;

var config = io.Path("settings.toml");
config.set_read_only(true);
var ro = config.metadata().value().read_only;   # true

set_executable

set_executable(executable: bool): IoResult<void>

Makes the file at this path executable, or not, following symlinks, without raw permission bits.

On POSIX, executable adds an execute bit for each class (owner, group, others) that may read the file, as chmod +x does with the usual umask; not executable clears all three. Windows has no execute bit (an extension such as .exe decides) and WASI no permission bits, so there this does nothing and succeeds.

Parameters

  • executable — true to make the file executable, false to stop it

Returns

  • an Ok result once it is changed; an error as for metadata() or set_permissions

Example

import io;

io.File.write_text(io.Path("hello.sh"), "#!/bin/sh\necho hello\n");
io.Path("hello.sh").set_executable(true);

set_modified

set_modified(time: long): IoResult<void>

Sets the modification time of the file or directory at this path, following symlinks and leaving its access time alone (utimensat on POSIX, SetFileTime on Windows, where the time is kept to 100 ns).

A directory’s modification time changes whenever an entry is added or removed, so set it after its contents are final.

Parameters

  • time — the new time, in nanoseconds since the Unix epoch, as Metadata.modified reports it

Returns

  • an Ok result once the time is set; an error with is_not_found() when the path is missing or empty, is_permission_denied() when the caller may not change it, or another platform error

See also

  • File.set_modified

Example

import io;

var source = io.Path("a.txt").metadata().value();
io.Path("b.txt").set_modified(source.modified);

set_accessed

set_accessed(time: long): IoResult<void>

Sets the access time of the file or directory at this path, following symlinks and leaving its modification time alone (utimensat on POSIX, SetFileTime on Windows, where the time is kept to 100 ns).

Parameters

  • time — the new time, in nanoseconds since the Unix epoch, as Metadata.accessed reports it

Returns

  • an Ok result once the time is set; an error as for set_modified

See also

  • File.set_accessed

operator ==

==(rhs: Path): bool

Returns whether two paths have exactly the same text. The comparison is byte by byte, with no normalization and case-sensitive on every platform: a/b differs from a//b, from a/b/, and (on Windows) from a\b and A/b. Compare normalize() or canonicalize() results to ignore spelling differences.

Parameters

  • rhs — the path to compare with

Returns

  • true when both paths’ texts are equal

operator !=

!=(rhs: Path): bool

Returns whether two paths differ in their text: the opposite of ==, with the same byte-by-byte comparison.

Parameters

  • rhs — the path to compare with

Returns

  • true when the paths’ texts differ

File

Source: runtime/io/file.pep

OpenOptions

struct OpenOptions

Describes how File.open_with (or OpenOptions.open) opens a file: which access it asks for, and whether the file is created or truncated.

A builder in the style of Rust’s std::fs::OpenOptions. A new OpenOptions has every option off; each setter stores its flag and returns this same OpenOptions, so calls chain. The rules, checked when the file is opened:

  • At least one of read, write or append must be set, or opening fails with is_invalid_input().
  • create, create_new and truncate need write or append, or opening fails with is_invalid_input().
  • append implies write access, and every write goes to the current end of the file whatever the position; it wins over write.
  • create opens an existing file as it is (combine with truncate to empty it) and creates a missing one.
  • create_new creates the file and fails with is_already_exists() when it already exists, atomically (O_EXCL on POSIX, CREATE_NEW on Windows); create is then irrelevant.
  • truncate empties an existing file on open.
  • Without create or create_new, a missing file fails with is_not_found().

On POSIX a created file gets mode 0666 (or the mode set) less the process umask, and the descriptor is opened close-on-exec. On Windows every file is opened sharing read, write and delete access with other openers.

See also

  • File.open_with

Example

var r = OpenOptions().read(true).write(true).create(true).open(Path("data.bin"));
if (r.is_err()) {
    return 1;
}
var f = r.value();

Constructor

OpenOptions()

construct()

Creates an OpenOptions with every option off. Set at least one of read, write or append before opening.

Methods

read

read(v: bool): OpenOptions

Sets whether the file is opened for reading.

Parameters

  • v — true to request read access

Returns

  • this OpenOptions, for chaining

write

write(v: bool): OpenOptions

Sets whether the file is opened for writing. Writes go to the current position and overwrite what is there.

Parameters

  • v — true to request write access

Returns

  • this OpenOptions, for chaining

append

append(v: bool): OpenOptions

Sets append mode: the file is opened for writing and every write goes to the current end of the file, whatever the position (seeking still moves the position for reads). Implies write access, and takes precedence over write.

Parameters

  • v — true to open in append mode

Returns

  • this OpenOptions, for chaining

truncate

truncate(v: bool): OpenOptions

Sets whether an existing file is truncated to 0 bytes when it is opened. Requires write or append.

Parameters

  • v — true to truncate an existing file

Returns

  • this OpenOptions, for chaining

create

create(v: bool): OpenOptions

Sets whether a missing file is created. An existing file is opened as it is unless truncate is also set. Requires write or append.

Parameters

  • v — true to create the file when it is missing

Returns

  • this OpenOptions, for chaining

create_new

create_new(v: bool): OpenOptions

Sets whether the file must be newly created: opening fails with is_already_exists() when the path already exists. The check and the creation are one atomic step. Requires write or append.

Parameters

  • v — true to require that the file does not exist yet

Returns

  • this OpenOptions, for chaining

mode

mode(mode: Mode): OpenOptions

Sets the permission bits a file gets when this open creates it, so it never exists with the default mode: on POSIX the mode_t bits passed to open, less the process umask, as Metadata.permissions reports them (493, octal 755, for rwxr-xr-x). An existing file keeps its mode. Ignored on Windows and WASI; use File.set_read_only there.

Parameters

  • mode — the permission bits for a new file; 438 (octal 666) by default

Returns

  • this OpenOptions, for chaining

Example

var script = OpenOptions().write(true).create_new(true).mode(493).open(Path("run.sh"));

open

open(path: Path): IoResult<File>

Opens path with these options. The same as File.open_with(path, this).

Parameters

  • path — the file to open

Returns

  • the open File, positioned at the start; an error when the options break a rule (is_invalid_input()), the path is empty or missing (is_not_found()), it exists under create_new (is_already_exists()), or the system refuses (is_permission_denied() and others)

See also

  • File.open_with

Example

var log = OpenOptions().append(true).create(true).open(Path("log.txt")).value();
log.write_all("started");

FileType

enum FileType

The kind of a file system object, as Metadata.file_type and DirEntry.file_type report it.

A symlink is reported as Symlink only where links are not followed (Path.symlink_metadata() and directory listings); on Windows that is any reparse point, junctions included, even one that points to a directory.

See also

  • Metadata

Example

import io;

var m = io.Path("notes.txt").metadata();
if (m.is_ok()) {
    match (m.value().file_type) {
        File => { print("a file"); }
        Directory => { print("a directory"); }
        Symlink, Other => { print("something else"); }
    }
}

Cases

File

File

A regular file. On Windows, anything that is neither a directory nor a reparse point.

Directory

Directory

A directory.

Symlink

A symbolic link itself (on Windows, any reparse point).

Other

Other

Anything else, such as a FIFO, socket or device on POSIX.

SeekFrom

enum SeekFrom

Where File.seek measures its offset from, with the offset itself.

See also

  • File.seek

Example

var f = File.open(Path("data.bin")).value();
var length = f.seek(SeekFrom.End(0)).value();     # at the end
f.seek(SeekFrom.Start(4));                        # 4 bytes in
var at = f.seek(SeekFrom.Current(-2)).value();    # 2

Cases

Start

Start(offset: Offset)

offset bytes from the start of the file.

Parameters

  • offset — the new position; negative is an error

Current

Current(offset: Offset)

offset bytes from the current position.

Parameters

  • offset — the distance to move; negative moves back

End

End(offset: Offset)

offset bytes from the end of the file.

Parameters

  • offset — the distance from the end; negative moves back from it

StdStream

enum StdStream

One of the process’s standard streams, as File.std_file takes it.

See also

  • File.std_file

Cases

Input

Input

Standard input: file descriptor 0 on POSIX, STD_INPUT_HANDLE on Windows.

Output

Output

Standard output: file descriptor 1 on POSIX, STD_OUTPUT_HANDLE on Windows.

Error

Error

Standard error: file descriptor 2 on POSIX, STD_ERROR_HANDLE on Windows.

Metadata

struct Metadata

Describes a file system object: its kind, size, read-only flag, permission bits and timestamps, as a snapshot taken when it was queried.

Returned by File.metadata(), Path.metadata() (which follows symlinks), Path.symlink_metadata() (which does not) and DirEntry.metadata(). The timestamps are nanoseconds since the Unix epoch (1970-01-01 00:00:00 UTC). On POSIX the metadata comes from statx (Linux) or stat (macOS); on Windows from GetFileInformationByHandle.

Example

var m = Path("notes.txt").metadata();
if (m.is_ok() && m.value().is_file) {
    var bytes = m.value().size;
}

Fields

size

ulong size

The file’s length in bytes.

file_type

FileType file_type

The kind of object: File, Directory, Symlink or Other. It agrees with is_file, is_directory and is_symlink, except that a Windows reparse point to a directory is Symlink with both is_directory and is_symlink set.

is_file

bool is_file

Whether this is a regular file. On Windows, anything that is neither a directory nor a reparse point (symlink) counts as a file.

is_directory

bool is_directory

Whether this is a directory.

bool is_symlink

Whether this is a symbolic link itself. Only metadata that does not follow links (Path.symlink_metadata()) can report one. On Windows this is any reparse point (FILE_ATTRIBUTE_REPARSE_POINT), which includes junctions.

read_only

bool read_only

Whether the file is read-only: on POSIX, no write permission bit (0222) is set for anyone; on Windows, FILE_ATTRIBUTE_READONLY is set.

modified

long modified

The time of the last modification of the contents, in nanoseconds since the Unix epoch.

accessed

long accessed

The time of the last access, in nanoseconds since the Unix epoch (file systems mounted with noatime or relatime update it rarely or never).

created

long? created

The creation (birth) time, in nanoseconds since the Unix epoch; none where the platform does not report one, such as on Linux file systems that do not record it.

permissions

Mode permissions

The raw permission bits: on POSIX the mode_t permission bits (mode & 07777, for example 256 for owner read); on Windows the FILE_ATTRIBUTE_* bits.

Constructor

Metadata()

construct()

Creates a Metadata with every number zero, every flag false, file_type Other and created none. Metadata is normally obtained from a query such as File.metadata().

File

[NotCopyable( "owns an operating-system handle and cannot be copied; send its path, and open the file again on the receiving thread" )] struct File

An open file, or a standard stream, read and written through its platform handle (a file descriptor on POSIX, a HANDLE on Windows).

Open one with File.open (read-only), File.create (write-only, created or truncated), or File.open_with / OpenOptions.open for anything else. The static conveniences read_bytes, read_to_string, write_bytes, write_text, copy, rename and remove work on a whole file by path.

I/O is unbuffered: each read and write is one system call. Reads and writes start at the current position and move it forward; the seek_* methods move it explicitly. A write to a standard stream first flushes the output print has buffered, so the two never come out of order.

Closing: the File’s destructor closes an owned handle when the last reference to the File is released, and ignores any error. Call close() to close earlier or to observe the error. The standard streams (stdin(), stdout(), stderr()) are never closed by a File. Every operation on a closed File fails with an error that none of the IoError predicates identify (EBADF on POSIX, ERROR_INVALID_HANDLE on Windows).

Errors are IoError values carrying the platform’s code; use its predicates (is_not_found(), is_permission_denied(), …) to tell kinds apart portably.

Example

var f = File.create(Path("out.txt")).value();
f.write_all("hello");
f.close();
var text = File.read_to_string(Path("out.txt")).value();   # "hello"

Fields

path

Path path

The path the File was opened with; <stdin>, <stdout> or <stderr> for a standard stream, and empty for a File() never opened.

Constructor

File()

construct()

Creates a closed File that owns nothing yet: is_open() is false and every I/O method fails. Use File.open, File.create or File.open_with to get an open one.

Static methods

open

static open(path: Path): IoResult<File>

Opens an existing file for reading only. The position starts at 0.

A directory can be opened this way too, but reading it fails with is_is_a_directory().

Parameters

  • path — the file to open

Returns

  • the open File; an error with is_not_found() when the path is empty or does not exist, is_permission_denied() when access is refused, or another platform error

Example

var r = File.open(Path("config.txt"));
if (r.is_err() && r.error().is_not_found()) {
    return 0;
}
var text = r.value().read_string();

create

static create(path: Path): IoResult<File>

Opens a file for writing only, creating it if it is missing and truncating it to 0 bytes if it exists.

On POSIX a created file gets mode 0666 less the process umask.

Parameters

  • path — the file to create or truncate

Returns

  • the open File, empty and positioned at 0; an error with is_not_found() when the path is empty or its parent directory does not exist, is_permission_denied() when access is refused, or another platform error (on POSIX, is_is_a_directory() when the path is a directory)

Example

var f = File.create(Path("report.txt")).value();
f.write_all("done");

open_with

static open_with(path: Path, options: OpenOptions): IoResult<File>

Opens path as options describe. OpenOptions.open is the same call written as a builder.

The options are checked first, as in Rust: at least one of read, write or append is required, and create, create_new or truncate require write or append. The position starts at 0 (append mode still writes at the end).

Parameters

  • path — the file to open
  • options — the access mode and creation behavior

Returns

  • the open File; an error with is_invalid_input() when no access mode is set or create, create_new or truncate is set without write or append; is_not_found() when the path is empty, or the file (or a parent directory) is missing and neither create nor create_new is set; is_already_exists() when create_new is set and the path exists; is_permission_denied() when access is refused; or another platform error

Example

var opts = OpenOptions().write(true).create_new(true);
var r = File.open_with(Path("lock"), opts);
if (r.is_err() && r.error().is_already_exists()) {
    return 1;
}

std_file

static std_file(stream: StdStream, name: String): File

Returns a File for one of the process’s standard streams. The File does not own the handle, so neither close() nor the destructor closes the stream. stdin(), stdout() and stderr() are the usual way to call this.

On Windows a process without a console or redirection may get a null handle, and the File is then not open.

Parameters

  • stream — the stream
  • name — the display name stored in the File’s path, such as <stdout>

Returns

  • a File on that stream, that does not close the stream when released

Example

var err = File.std_file(StdStream.Error, "<stderr>");
err.write_all("warning");

std_file

[Deprecated("use 'std_file' with a 'StdStream' instead")] static std_file(which: int, name: String): File

Deprecated: use std_file(StdStream, String) instead. Returns a File for the standard stream numbered which, as std_file does for the matching StdStream.

Parameters

  • which — the stream: 0 for standard input, 1 for standard output, 2 for standard error
  • name — the display name stored in the File’s path

Returns

  • a File on that stream; a File that is not open for any other which

stdin

static stdin(): File

Returns a File reading the process’s standard input. Each call returns a new File on the same stream; the stream is never closed by a File. A read returns 0 at end of input (on Windows, also when the writing end of a pipe has closed).

Returns

  • a File on standard input, named <stdin>

stdout

static stdout(): File

Returns a File writing to the process’s standard output. Each call returns a new File on the same stream, and close() only marks that File closed: the stream stays usable. Writes are unbuffered, and each one first flushes what print has buffered, so output from print and from this File comes out in program order.

Returns

  • a File on standard output, named <stdout>

Example

var out = File.stdout();
out.write_all("hello");

stderr

static stderr(): File

Returns a File writing to the process’s standard error. Each call returns a new File on the same stream, and close() only marks that File closed: the stream stays usable. Writes are unbuffered, and each one first flushes what print has buffered.

Returns

  • a File on standard error, named <stderr>

adopt

static adopt(transfer: FileTransfer): IoResult<File>

Takes the handle out of transfer (see into_transfer) into a new File on the calling thread, whose path is empty. Only the first adopt of a token gets the handle.

Parameters

  • transfer — the token from into_transfer

Returns

  • the File; an error when the token is empty (already adopted, or made from a closed File)

read_bytes

static read_bytes(path: Path): IoResult<u8[]>

Reads the whole file at path into a byte array (Rust’s fs::read).

Parameters

  • path — the file to read

Returns

  • the file’s bytes; an error as for File.open (such as is_not_found()) or read_all

Example

var r = File.read_bytes(Path("image.png"));
if (r.is_ok()) {
    var n = r.value().count;
}

read_to_string

static read_to_string(path: Path): IoResult<String>

Reads the whole file at path as UTF-8 text (Rust’s fs::read_to_string).

Parameters

  • path — the file to read

Returns

  • the file’s text; an error with is_invalid_input() when it is not valid UTF-8, or an error as for File.open (such as is_not_found()) or read_all

Example

var r = File.read_to_string(Path("notes.txt"));
if (r.is_err()) {
    return 1;
}
var text = r.value();

write_bytes

static write_bytes(path: Path, data: u8[]): IoResult<void>

Writes data as the whole contents of the file at path, creating it or replacing what it held (Rust’s fs::write), then closes it.

Not atomic: the file is truncated first, so a failure part way leaves it truncated or partly written.

Parameters

  • path — the file to write
  • data — the new contents

Returns

  • an Ok result once the data is written and the file closed; an error as for File.create (such as is_not_found() for a missing parent directory), write_all, or close

Example

var data = Array<u8>();
data.append(1 as u8);
File.write_bytes(Path("one.bin"), data);

write_text

static write_text(path: Path, s: String): IoResult<void>

Writes s as UTF-8 as the whole contents of the file at path, creating it or replacing what it held, then closes it. No newline is added.

Not atomic: the file is truncated first, so a failure part way leaves it truncated or partly written.

Parameters

  • path — the file to write
  • s — the new contents

Returns

  • an Ok result once the text is written and the file closed; an error as for File.create (such as is_not_found() for a missing parent directory), write_all, or close

Note

  • this is the design proposal’s write_to_string; a static write would clash with the instance write

Example

var r = File.write_text(Path("hello.txt"), "hello, world");
if (r.is_err()) {
    return 1;
}

copy

static copy(from: Path, to: Path): IoResult<ulong>

Copies the contents of the file from to to, creating to or truncating it first, in 64 KiB chunks.

Only the contents are copied: to keeps its own permissions and timestamps (a new file gets the defaults of File.create). A failure part way leaves to partly written.

Parameters

  • from — the file to copy
  • to — the destination file

Returns

  • the number of bytes copied; is_invalid_input() when from and to are the same file (after resolving symlinks), which truncating to would empty before it was read; otherwise an error as for File.open on from (such as is_not_found()), File.create on to, or a read, write or close failure

Example

var n = File.copy(Path("a.txt"), Path("b.txt")).value();

rename

static rename(from: Path, to: Path): IoResult<void>

Renames or moves from to to, replacing an existing file at to (rename on POSIX, MoveFileExW with MOVEFILE_REPLACE_EXISTING on Windows).

Works for directories too. Moving between file systems (volumes) fails rather than copying.

Parameters

  • from — the existing path
  • to — the new path

Returns

  • an Ok result on success; an error with is_not_found() when from (or the parent of to) does not exist or, on POSIX, either path is empty, is_permission_denied() when access is refused, or another platform error

Example

File.rename(Path("draft.txt"), Path("final.txt"));
static symlink(target: Path, link: Path): IoResult<void>

Creates a symbolic link at link that points to the file target (symlink on POSIX, CreateSymbolicLinkW on Windows). Use Directory.symlink for a link to a directory: Windows makes the two kinds differently, and a file link to a directory does not open there. POSIX makes the same link either way.

target is stored as given and need not exist; a relative target is resolved against the directory that holds the link, not the current directory. On Windows, making a symlink needs Developer Mode or an administrator, and fails with is_permission_denied() otherwise.

Parameters

  • target — the path the link points to
  • link — where to create the link

Returns

  • an Ok result once the link exists; an error with is_already_exists() when something is already at link, is_not_found() when its parent is missing or a path is empty, is_permission_denied(), or another platform error

See also

  • Path.read_link

Example

File.symlink(Path("app-1.2.so"), Path("lib/app.so"));

remove

static remove(path: Path): IoResult<void>

Deletes the file at path. A symlink is removed itself, not its target. Directories are not removed: use Directory.remove or Directory.remove_all.

Parameters

  • path — the file to delete

Returns

  • an Ok result on success; an error with is_not_found() when the path does not exist (on POSIX, also when it is empty); when it is a directory, is_is_a_directory() on Linux and is_permission_denied() on macOS and Windows; is_permission_denied() when access is refused (on Windows also for a read-only file); or another platform error

Example

var r = File.remove(Path("old.txt"));
if (r.is_err() && !r.error().is_not_found()) {
    return 1;
}

Methods

is_open

is_open(): bool

Returns whether the File holds a valid handle: true after a successful open, false after close() and for a File() never opened.

Returns

  • true while the File is open

check_open

check_open(): IoResult<void>

Checks that the File is open, for use before an operation.

Returns

  • an Ok result when the File is open; an error with the message “file is closed” otherwise (code EBADF on POSIX, ERROR_INVALID_HANDLE on Windows; no IoError predicate holds)

read

read(buffer: u8[], max: ulong): IoResult<ulong>

Reads up to max bytes from the current position and appends them to buf, growing it; the bytes already in buf are kept. Advances the position by the count read.

One system call: the read may be partial, returning fewer than max bytes before the end of the file (always so for pipes and terminals), and a single call moves at most about 2 GiB on POSIX or 1 GiB on Windows. Loop until it returns 0 to read everything, or use read_all. On POSIX an interrupted read (EINTR) is retried. On failure buffer keeps its previous contents.

Parameters

  • buffer — the array the bytes are appended to
  • max — the most bytes to read; 0 reads nothing and returns 0

Returns

  • the number of bytes appended, 0 at end of file; an error when the File is closed, or is_is_a_directory() when it is a directory, or another platform error

Example

var buf = Array<u8>();
while (true) {
    var r = f.read(buf, 4096);
    if (r.is_err() || r.value() == 0) {
        break;
    }
}

read_all

read_all(): IoResult<u8[]>

Reads everything from the current position to the end of the file, in 64 KiB chunks, leaving the position at the end.

Returns

  • a new array of the bytes read (empty at end of file); an error when the File is closed, is_is_a_directory() when it is a directory, or another platform error, in which case the bytes read so far are discarded

read_string

read_string(): IoResult<String>

Reads everything from the current position to the end of the file, as read_all does, and returns it as UTF-8 text.

The bytes must be strictly valid UTF-8 (RFC 3629: no overlong forms, surrogates, or code points past U+10FFFF). A byte-order mark is kept as part of the text.

Returns

  • the text read (empty at end of file); an error with is_invalid_input() when the bytes are not valid UTF-8, or any error read_all returns

write

write(data: u8[]): IoResult<ulong>

Writes bytes from data at the current position (or at the end, in append mode) with a single system call, and advances the position.

The write may be partial: it can write fewer than data.count bytes, and one call moves at most about 2 GiB on POSIX or 1 GiB on Windows. Use write_all to write everything. On POSIX an interrupted write (EINTR) is retried.

Parameters

  • data — the bytes to write

Returns

  • the number of bytes written, which may be less than data.count; an error when the File is closed or was not opened for writing, or another platform error

write_all

write_all(data: u8[]): IoResult<void>

Writes all of data, repeating the write until every byte is written. Advances the position by data.count.

If an error occurs part of data may already have been written.

Parameters

  • data — the bytes to write

Returns

  • an Ok result once every byte is written; an error when the File is closed or was not opened for writing, when a write makes no progress (“failed to write the whole buffer”, with code EIO on POSIX or ERROR_WRITE_FAULT on Windows), or another platform error

write_all

write_all(s: String): IoResult<void>

Writes all of the UTF-8 bytes of s, as write_all does for bytes. No newline or terminator is added.

Parameters

  • s — the text to write

Returns

  • an Ok result once every byte is written; an error as for write_all(u8[])

Example

var f = File.create(Path("greeting.txt")).value();
var r = f.write_all("hello, world");
if (r.is_err()) {
    return 1;
}

write_string

[Deprecated("use 'write_all' instead", "write_all")] write_string(s: String): IoResult<void>

Deprecated: use write_all instead. Writes all of the UTF-8 bytes of s, exactly like write_all(s).

Parameters

  • s — the text to write

Returns

  • an Ok result once every byte is written; an error as for write_all

See also

  • write_all

seek

seek(from: SeekFrom): IoResult<Offset>

Moves the position as from says: to an offset from the start, from the current position, or from the end of the file.

Seeking past the end is allowed; a later write there fills the gap with zeros. A position before the start of the file is an error. Seeking a stream that has no position (a pipe or a terminal) fails on POSIX and has platform-defined results on Windows.

Parameters

  • from — the origin and the offset from it

Returns

  • the new position, in bytes from the start of the file; an error when the File is closed, the resulting position would be negative (on POSIX, with is_invalid_input()), or the stream cannot seek

Example

var f = File.open(Path("data.bin")).value();
var length = f.seek(SeekFrom.End(0)).value();
f.seek(SeekFrom.Start(0));

seek_start

seek_start(pos: Offset): IoResult<Offset>

Moves the position to pos bytes from the start of the file, like seek(SeekFrom.Start(pos)) without allocating the SeekFrom.

Seeking past the end is allowed; a later write there fills the gap with zeros.

Parameters

  • pos — the new position, in bytes from the start

Returns

  • the new position, in bytes from the start of the file; an error as for seek

See also

  • seek

seek_end

seek_end(delta: Offset): IoResult<Offset>

Moves the position to delta bytes from the end of the file, like seek(SeekFrom.End(delta)). seek_end(0) moves to the end and returns the file’s length.

Parameters

  • delta — the offset from the end of the file, in bytes; negative moves back from the end

Returns

  • the new position, in bytes from the start of the file; an error as for seek

See also

  • seek

seek_current

seek_current(delta: Offset): IoResult<Offset>

Moves the position by delta bytes from where it is, like seek(SeekFrom.Current(delta)). seek_current(0) returns the current position without moving it.

Parameters

  • delta — the offset from the current position, in bytes; negative moves back

Returns

  • the new position, in bytes from the start of the file; an error as for seek

See also

  • seek

seek

[Deprecated("use 'seek' with a 'SeekFrom' instead")] seek(offset: Offset, whence: int): IoResult<Offset>

Deprecated: use seek(SeekFrom) instead. Moves the position by offset relative to the origin whence.

Parameters

  • offset — the offset in bytes from the origin; may be negative
  • whence — the origin: 0 the start of the file, 1 the current position, 2 the end of the file

Returns

  • the new position, in bytes from the start of the file; an error as for seek(SeekFrom), or when whence is not 0, 1 or 2

set_length

set_length(size: ulong): IoResult<void>

Truncates or extends the file to exactly size bytes; bytes added are zeros. The position is kept, even when it is now past the end.

The File must have been opened for writing.

Parameters

  • size — the new length, in bytes

Returns

  • an Ok result on success; an error when the File is closed, was not opened for writing, or the system refuses the size

set_len

[Deprecated("use 'set_length' instead", "set_length")] set_len(size: ulong): IoResult<void>

Deprecated: use set_length instead. Truncates or extends the file to exactly size bytes.

Parameters

  • size — the new length, in bytes

Returns

  • the same as set_length(size)

sync

sync(): IoResult<void>

Flushes the file’s contents and metadata to the storage device, waiting until they are written (fsync on POSIX, FlushFileBuffers on Windows). Writes are already unbuffered in the process; this forces them out of the operating system’s cache.

On Windows the File must have been opened with write access.

Returns

  • an Ok result once the data is durable; an error when the File is closed or the device reports a failure, or another platform error

lock

lock(): IoResult<void>

Takes an exclusive advisory lock on the whole file, waiting until no other holder has a lock on it, shared or exclusive. Modeled on Rust’s File::lock.

The lock belongs to this File: another File opened on the same path (in this process or another) contends with it, and it is released by unlock, by close, or when the File is destroyed. Taking a lock while this File already holds one is platform-defined, as in Rust: POSIX converts the lock, Windows may wait forever. The lock is advisory on POSIX (flock(LOCK_EX)): it binds only those who also lock. On Windows (LockFileEx) it also stops other handles from reading or writing the file while held. The wait blocks the calling thread, also inside a task. WASI has no file locks.

Returns

  • an Ok result once the lock is held; an error when the File is closed, on WASI, or another platform error

See also

  • try_lock
  • unlock

Example

var f = OpenOptions().write(true).create(true).open(Path(".pep/fetch.lock")).value();
if (f.lock().is_ok()) {
    # ... work no other locker does at the same time ...
    f.unlock();
}

lock_shared

lock_shared(): IoResult<void>

Takes a shared advisory lock on the whole file, waiting until no other holder has an exclusive lock on it. Any number of Files can hold a shared lock at once. Modeled on Rust’s File::lock_shared; otherwise as lock (flock(LOCK_SH) on POSIX, LockFileEx without LOCKFILE_EXCLUSIVE_LOCK on Windows).

Returns

  • an Ok result once the lock is held; an error as for lock

See also

  • lock

try_lock

try_lock(): IoResult<bool>

Takes an exclusive advisory lock on the whole file if no other holder has a lock on it, without waiting. Modeled on Rust’s File::try_lock; otherwise as lock.

Returns

  • true when the lock is now held, false when another holder has a lock on the file (Rust’s TryLockError::WouldBlock); an error as for lock

See also

  • lock

Example

var held = f.try_lock();
if (held.is_ok() && !held.value()) {
    print("another process holds the lock\n");
}

try_lock_shared

try_lock_shared(): IoResult<bool>

Takes a shared advisory lock on the whole file if no other holder has an exclusive lock on it, without waiting. Modeled on Rust’s File::try_lock_shared; otherwise as lock_shared.

Returns

  • true when the lock is now held, false when another holder has an exclusive lock on the file; an error as for lock

See also

  • lock_shared

unlock

unlock(): IoResult<void>

Releases the lock this File holds, shared or exclusive. Releasing a File that holds none is not an error. Closing or destroying the File releases its lock too. Modeled on Rust’s File::unlock.

Returns

  • an Ok result once the lock is released; an error when the File is closed, on WASI, or another platform error

See also

  • lock

metadata

metadata(): IoResult<Metadata>

Queries the metadata of the open file itself (the target, when it was opened through a symlink): size, kind, read-only flag, permission bits and timestamps.

Returns

  • the file’s Metadata; an error when the File is closed, or another platform error

Example

var size = f.metadata().value().size;

set_permissions

set_permissions(mode: Mode): IoResult<void>

Sets the permission bits of the open file (fchmod on POSIX).

mode holds the raw platform bits, as Metadata.permissions reports them: on POSIX the mode_t bits mode & 07777 (493, octal 755, for rwxr-xr-x). On Windows it is the FILE_ATTRIBUTE_* word, and only FILE_ATTRIBUTE_READONLY (1) is applied; the File needs write access there. WASI has no permission bits, and this fails. set_read_only and set_executable change one property portably.

Parameters

  • mode — the new permission bits

Returns

  • an Ok result once they are set; an error when the File is closed, is_permission_denied() when the caller does not own the file (or on Windows the File was opened without write access), or another platform error

See also

  • Path.set_permissions

Example

var f = File.create(Path("run.sh")).value();
f.set_permissions(493);   # octal 755: rwxr-xr-x

set_read_only

set_read_only(read_only: bool): IoResult<void>

Makes the open file read-only, or writable again: the portable part of set_permissions.

On POSIX, read-only clears every write bit (octal 222), and writable sets only the owner’s (octal 200), so a file never becomes writable by group or others this way. On Windows it sets or clears FILE_ATTRIBUTE_READONLY, and the File needs write access. Fails on WASI, which has no permission bits.

Parameters

  • read_only — true to make the file read-only, false to make it writable by its owner

Returns

  • an Ok result once the file is changed; an error as for metadata() or set_permissions

See also

  • Metadata.read_only

set_executable

set_executable(executable: bool): IoResult<void>

Makes the open file executable, or not, without raw permission bits.

On POSIX, executable adds an execute bit for each class (owner, group, others) that may read the file, as chmod +x does with the usual umask; not executable clears all three. Windows has no execute bit (an extension such as .exe decides) and WASI no permission bits, so there this does nothing and succeeds.

Parameters

  • executable — true to make the file executable, false to stop it

Returns

  • an Ok result once the file is changed; an error as for metadata() or set_permissions

Example

var f = File.create(Path("run.sh")).value();
f.write_all("#!/bin/sh\necho hi\n");
f.set_executable(true);

set_modified

set_modified(time: long): IoResult<void>

Sets the open file’s modification time, leaving its access time alone (futimens on POSIX, SetFileTime on Windows).

On Windows the File needs write access, and the time is kept to 100 ns. A later write to the file sets its modification time again, so call this after the last write.

Parameters

  • time — the new time, in nanoseconds since the Unix epoch, as Metadata.modified reports it

Returns

  • an Ok result once the time is set; an error when the File is closed, is_permission_denied() when the caller does not own the file (or on Windows the File was opened without write access), or another platform error

See also

  • Path.set_modified

set_accessed

set_accessed(time: long): IoResult<void>

Sets the open file’s access time, leaving its modification time alone (futimens on POSIX, SetFileTime on Windows).

On Windows the File needs write access, and the time is kept to 100 ns.

Parameters

  • time — the new time, in nanoseconds since the Unix epoch, as Metadata.accessed reports it

Returns

  • an Ok result once the time is set; an error as for set_modified

See also

  • Path.set_accessed

close

close(): IoResult<void>

Closes the File now, to observe the error the system reports (for example a deferred write failure). Without it the destructor closes the handle and ignores errors.

Idempotent: closing a closed File succeeds. The File counts as closed afterwards even when closing reports an error, so the handle is never closed twice. A standard stream is only marked closed: the stream stays open, and File.stdout() and the like still work.

Returns

  • an Ok result on success or when already closed; the platform error when closing an owned handle fails

Example

var f = File.create(Path("out.bin")).value();
f.write_all("data");
if (f.close().is_err()) {
    return 1;
}

into_transfer

into_transfer(): FileTransfer

Moves the file’s handle into a FileTransfer, which another thread takes with File.adopt, and leaves this File closed. A File is not thread-safe, so this is how a thread hands one to another, such as the pipe from a child process’s standard output to the thread that reads it while this one writes to the child.

Returns

  • the token; for a closed File, one that adopt fails on

Example

import async;
import [File, Path] from io;

var transfer = File.open(Path("notes.txt")).value().into_transfer();
var reader = async.Thread.spawn(() => {
    var mine = File.adopt(transfer).value();
    var text = mine.read_all();
});
reader.join();

FileTransfer

[Shared] struct FileTransfer

A file’s handle on its way from one thread to another: made by File.into_transfer, taken by File.adopt. Unlike a File, it is thread-safe, so a shared function (a body for async.spawn or Thread.spawn) can capture it and a Channel can carry it. If no one adopts it, the handle is closed when the last reference goes, unless it is one the File did not own (a standard stream).

Constructor

FileTransfer()

construct(handle: FileHandle, owned: bool)

Wraps a file handle. File.into_transfer makes these; use it.

Parameters

  • handle — the handle, or an invalid one for an empty token
  • owned — whether the File the token came from closed its handle

Directory

Source: runtime/io/directory.pep

DirEntry

struct DirEntry

One entry of a directory listing, as Directory.entries() returns it.

The kind flags come from the listing itself and describe the entry without following symlinks. On POSIX a symlink has is_symlink set and is_file and is_directory clear; on Windows a symlink or junction to a directory has both is_directory and is_symlink set. Entries that are none of these (a FIFO, socket or device on POSIX) have all three flags clear. Call metadata() for the details of what the entry points to.

Example

import io;

var list = io.Directory.open(io.Path("src")).value().entries().value();
ulong i = 0;
while (i < list.count) {
    if (list[i].is_file) {
        print(list[i].Name);
    }
    i = i + 1;
}

Fields

path

Path path

The entry’s full path: the listed directory’s path joined with name (relative when the directory’s path was relative).

name

String name

The entry’s own file name, without any directory part.

file_type

FileType file_type

The kind of the entry, without following symlinks: Symlink for a symlink (on Windows, any reparse point, even one to a directory), Other for anything that is not a file, directory or symlink.

is_file

bool is_file

Whether the entry is a regular file (never true for a symlink).

is_directory

bool is_directory

Whether the entry is a directory. On POSIX never true for a symlink; on Windows also true for a symlink or junction to a directory.

bool is_symlink

Whether the entry is a symlink (on Windows, any reparse point, junctions included), which is not followed.

Constructor

DirEntry()

construct()

Creates an empty entry: empty path and name, all kind flags false and file_type Other. Entries are normally obtained from Directory.entries().

Methods

metadata

metadata(): IoResult<Metadata>

Returns the metadata of the entry, following symlinks, exactly like Path.metadata() on the entry’s path. The file system is queried again now, so the result reflects any change since the listing was read.

Returns

  • the entry’s Metadata (of the target, for a symlink); an error when it cannot be stat’ed, with is_not_found() when the entry was removed since the listing or is a dangling symlink

See also

  • Path.metadata

Directory

struct Directory

A directory on the file system, identified by its path, together with static functions that create, remove and inspect directories and the process’s current and temporary directories.

A Directory holds no operating-system handle: open only checks that the path is a directory, and each call to entries() reads the listing afresh.

Example

import io;

var dir = io.Directory.temp().join("scratch").join("logs");
if (io.Directory.create_all(dir).is_ok()) {
    var d = io.Directory.open(dir).value();
    var count = d.entries().value().count;
    io.Directory.remove_all(io.Directory.temp().join("scratch"));
}

Fields

path

Path path

The directory’s path, as given to open.

Constructor

Directory()

construct()

Creates a Directory with an empty path, which names no directory. Use Directory.open to get one for an existing directory.

Static methods

open

static open(path: Path): IoResult<Directory>

Checks that path exists and is a directory (following symlinks) and returns a Directory for it. Nothing is kept open, so the directory can still be renamed or removed afterwards.

Parameters

  • path — the directory’s path; a relative path is resolved against the current directory whenever the Directory is used

Returns

  • the Directory; an error when path cannot be stat’ed (is_not_found() for a missing or empty path, is_permission_denied()) or is not a directory (is_not_a_directory(), message "not a directory")

Example

import io;

var d = io.Directory.open(io.Path("src"));
if (d.is_err() && d.error().is_not_a_directory()) {
    print("src is a file");
}

create

static create(path: Path, mode: Mode = 511): IoResult<void>

Creates one directory (mkdir on POSIX, with mode less the umask; CreateDirectoryW on Windows). Its parent must already exist.

Giving the mode here means the directory never exists with another one. mode is the POSIX mode_t permission bits (448, octal 700, for a directory only its owner may use); it is ignored on Windows and WASI.

Parameters

  • path — the directory to create
  • mode — the permission bits on POSIX, less the umask; 511 (octal 777) by default

Returns

  • an Ok result on success; an error when the directory cannot be created: is_already_exists() when anything (a file too) is already at path, is_not_found() when the parent is missing or the path is empty, is_permission_denied() when the parent is not writable

See also

  • create_all

Example

import io;

var keys = io.Directory.create(io.Path("keys"), mode = 448);   # octal 700

create_all

static create_all(path: Path, mode: Mode = 511): IoResult<void>

Creates a directory and any missing parents, like mkdir -p. Succeeds without doing anything when path is empty or already a directory (or a symlink to one). Parents are found lexically with Path.parent(); a relative path is created under the current directory. It is safe against another process creating the same directories at the same time.

mode applies to path itself when it is created, as mkdir -p -m does: missing parents get the default, 511 (octal 777) less the umask, so the new directory can always be made inside them. An existing directory keeps its mode. Ignored on Windows and WASI.

Parameters

  • path — the directory to create, with its parents
  • mode — the permission bits of path on POSIX, less the umask; 511 (octal 777) by default

Returns

  • an Ok result on success; an error when a directory cannot be created: is_already_exists() when a file (not a directory) is at path or at one of its parents, is_permission_denied(), and so on. Parents created before the failure are left in place

Example

import io;

var r = io.Directory.create_all(io.Path("build").join("out").join("logs"));
static symlink(target: Path, link: Path): IoResult<void>

Creates a symbolic link at link that points to the directory target (symlink on POSIX; CreateSymbolicLinkW with SYMBOLIC_LINK_FLAG_DIRECTORY on Windows). Use File.symlink for a link to a file: Windows makes the two kinds differently. POSIX makes the same link either way.

target is stored as given and need not exist; a relative target is resolved against the directory that holds the link, not the current directory. On Windows, making a symlink needs Developer Mode or an administrator, and fails with is_permission_denied() otherwise.

Parameters

  • target — the directory the link points to
  • link — where to create the link

Returns

  • an Ok result once the link exists; an error with is_already_exists() when something is already at link, is_not_found() when its parent is missing or a path is empty, is_permission_denied(), or another platform error

See also

  • Path.read_link

Example

import io;

io.Directory.symlink(io.Path("releases/1.2"), io.Path("current"));

remove

static remove(path: Path): IoResult<void>

Removes an empty directory (rmdir on POSIX, RemoveDirectoryW on Windows).

Parameters

  • path — the directory to remove; it must be empty

Returns

  • an Ok result on success; an error when it cannot be removed: is_directory_not_empty() when it still has entries, is_not_found() when it is missing or the path is empty, is_not_a_directory() when path is a file, is_permission_denied()

See also

  • remove_all

remove_all

static remove_all(path: Path): IoResult<void>

Removes path and, if it is a directory, everything inside it, recursively. Symlinks are removed, never followed, so nothing outside the tree is touched. A file or symlink at path itself is simply removed.

Stops at the first failure, leaving whatever was not yet removed; on Windows a read-only file inside the tree is such a failure (is_permission_denied()).

Parameters

  • path — the file, symlink or directory tree to remove

Returns

  • an Ok result on success; an error when something cannot be removed: is_not_found() when path does not exist (unlike rm -rf, a missing path is an error) or is empty, is_permission_denied(), and so on

See also

  • remove

Example

import io;

var tree = io.Directory.temp().join("my-scratch");
var r = io.Directory.remove_all(tree);
if (r.is_err() && !r.error().is_not_found()) {
    print(r.error().message);
}

current

static current(): IoResult<Path>

Returns the process’s current directory, against which relative paths are resolved (getcwd on POSIX, GetCurrentDirectoryW on Windows).

Returns

  • the current directory as an absolute path; an error when it cannot be determined, such as is_not_found() on POSIX when the directory has been removed

See also

  • set_current

set_current

static set_current(path: Path): IoResult<void>

Changes the process’s current directory (chdir on POSIX, SetCurrentDirectoryW on Windows). This affects the whole process, every thread included: relative paths used anywhere afterwards resolve against the new directory.

Parameters

  • path — the new current directory; a relative path is resolved against the old one

Returns

  • an Ok result on success; an error when the directory cannot be entered: is_not_found() when it is missing or the path is empty, is_not_a_directory() when it is a file, is_permission_denied()

See also

  • current

temp

static temp(): Path

Returns the directory for temporary files. It is not created or checked, so it may not exist.

On POSIX it is $TMPDIR with any trailing / removed, or /tmp when TMPDIR is unset or empty (/data/local/tmp on Android, which has no /tmp; an app should use its cache directory instead). On Windows it is what GetTempPathW reports (from TMP, TEMP, USERPROFILE, or the Windows directory) with its trailing separator removed unless it is a root, or . if that call fails.

Returns

  • the temporary directory’s path, never an error

Example

import io;

var scratch = io.Directory.temp().join("my-tool");

Methods

entries

entries(): IoResult<DirEntry[]>

Reads the directory’s listing now, all at once: every entry except . and .., in the order the operating system returns them (not sorted). Uses opendir/readdir on POSIX and FindFirstFileW/FindNextFileW on Windows. To read the entries one at a time instead, loop over the Directory itself (see iterate).

Returns

  • the entries, possibly none; an error when the directory cannot be read: is_not_found() when it no longer exists (or, on POSIX, path is empty), is_permission_denied(), or on POSIX is_not_a_directory() when path now names a file

Example

import io;

var list = io.Directory.open(io.Path(".")).value().entries();
if (list.is_ok()) {
    var n = list.value().count;
}

iterate

iterate(): DirectoryIterator

Returns an iterator that reads the directory’s listing one entry at a time, as for (var entry in dir) does, without building an array.

Each element is an IoResult<DirEntry>. The listing is opened by the first next(), so a directory that cannot be read yields one error (the same errors as entries()) and then ends; an error while reading also ends it. The entries come in the order the operating system returns them, without . and ... The listing is closed when it ends, or when the iterator is released, so leaving the loop early with break or return closes it too.

Returns

  • a new DirectoryIterator positioned before the first entry

Example

import io;

var dir = io.Directory.open(io.Path(".")).value();
for (var entry in dir) {
    if (entry.is_err()) {
        print(entry.error().message);
        break;
    }
    print(entry.value().name);
}

DirectoryIterator

struct DirectoryIterator

The iterator Directory.iterate() returns, and a for loop over a Directory drives: the directory’s entries, read one at a time, each as an IoResult<DirEntry>.

It holds the open listing (a DIR* on POSIX, a find handle on Windows) from the first next() until the listing ends, an error ends it, or the iterator is released.

See also

  • Directory.iterate

Example

import io;

var it = io.Directory.open(io.Path(".")).value().iterate();
while (it.next()) {
    var entry = it.current();
}

Constructor

DirectoryIterator()

construct(path: Path)

Makes an iterator over the entries of the directory at path. Nothing is opened until the first next().

Parameters

  • path — the directory to list

Methods

next

next(): bool

Reads the next entry, opening the listing on the first call.

Once it returns false it keeps returning false.

Returns

  • true if there is a current element (an entry, or the error that ends the listing), false once the listing has ended

current

current(): IoResult<DirEntry>

Returns the element the last successful next() moved to.

Returns

  • the current entry, or the error that ended the listing

Panics

  • if next() was never called, or already returned false (“DirectoryIterator.current() without an entry”)

Network addresses

Network addresses: IpAddress (IPv4 or IPv6), SocketAddress (an IP address and a port), and resolve, which turns a host name into socket addresses.

Source: runtime/io/address.pep

IpAddress

struct IpAddress

An IPv4 or IPv6 address.

Make one with IpAddress.v4, IpAddress.v6, IpAddress.parse, or one of the well-known addresses (loopback_v4(), unspecified_v6(), …). IpAddress() is the IPv4 unspecified address 0.0.0.0.

to_string() prints IPv4 in dotted decimal (127.0.0.1) and IPv6 in the RFC 5952 form (::1, 2001:db8::8a2e:370:7334); an IPv4-mapped IPv6 address prints as ::ffff:1.2.3.4. An IPv4 address and the IPv6 address that maps it are different values.

An IPv6 address can carry a scope id, the network interface a link-local address such as fe80::1 belongs to. Its text form is fe80::1%eth0 (RFC 4007), and a socket address carries it to the system as sin6_scope_id.

See also

  • SocketAddress

Example

import io;

var a = io.IpAddress.parse("192.168.1.10").value();
var b = io.IpAddress.v6(8193, 3512, 0, 0, 0, 0, 0, 1);   # 2001:db8::1
print(a.is_v4());         # true
print(b.to_string());     # 2001:db8::1

Constructor

IpAddress()

construct()

Creates the IPv4 unspecified address, 0.0.0.0.

Static methods

v4

static v4(a: u8, b: u8, c: u8, d: u8): IpAddress

Creates an IPv4 address from its four bytes, a.b.c.d.

Parameters

  • a — the first byte
  • b — the second byte
  • c — the third byte
  • d — the fourth byte

Returns

  • the address a.b.c.d

Example

var home = IpAddress.v4(127, 0, 0, 1);

v6

static v6(a: u16, b: u16, c: u16, d: u16, e: u16, f: u16, g: u16, h: u16): IpAddress

Creates an IPv6 address from its eight 16-bit groups, a:b:c:d:e:f:g:h.

Parameters

  • a — the first group
  • b — the second group
  • c — the third group
  • d — the fourth group
  • e — the fifth group
  • f — the sixth group
  • g — the seventh group
  • h — the eighth group

Returns

  • the address

Example

var loopback = IpAddress.v6(0, 0, 0, 0, 0, 0, 0, 1);   # ::1

loopback_v4

static loopback_v4(): IpAddress

Returns the IPv4 loopback address, 127.0.0.1.

Returns

  • 127.0.0.1

loopback_v6

static loopback_v6(): IpAddress

Returns the IPv6 loopback address, ::1.

Returns

  • ::1

unspecified_v4

static unspecified_v4(): IpAddress

Returns the IPv4 unspecified address, 0.0.0.0. Binding to it listens on every IPv4 interface.

Returns

  • 0.0.0.0

unspecified_v6

static unspecified_v6(): IpAddress

Returns the IPv6 unspecified address, ::. Binding to it listens on every IPv6 interface (and, where the system allows dual-stack sockets by default, such as Linux, on every IPv4 interface too).

Returns

  • ::

parse

static parse(text: String): IoResult<IpAddress>

Parses an IPv4 address in dotted decimal (192.168.0.1) or an IPv6 address in the RFC 4291 text form (::1, fe80::1:2, ::ffff:10.0.0.1), with an optional scope (fe80::1%eth0).

IPv4 needs exactly four decimal parts from 0 to 255 without leading zeros. IPv6 takes up to eight groups of one to four hex digits (either case), at most one ::, and an optional IPv4 address as the last 32 bits. Brackets and surrounding spaces are not accepted.

An IPv6 address may end in % and a scope: a decimal scope id (%3), or the name of a network interface (%eth0), which is looked up on this machine (so it fails where there is no such interface).

Parameters

  • text — the address to parse

Returns

  • the address; an error with is_invalid_input() when text is not a valid IPv4 or IPv6 address or names an unknown interface

Example

var r = IpAddress.parse("10.0.0.300");
print(r.is_err());   # true: 300 is out of range
var scoped = IpAddress.parse("fe80::1%7").value();
print(scoped.scope_id());   # 7

Methods

is_v4

is_v4(): bool

Returns whether this is an IPv4 address.

Returns

  • true for IPv4, false for IPv6

is_v6

is_v6(): bool

Returns whether this is an IPv6 address (including an IPv4-mapped one, ::ffff:a.b.c.d).

Returns

  • true for IPv6, false for IPv4

scope_id

scope_id(): uint

Returns the scope id of an IPv6 address: the index of the network interface it belongs to (the 3 or eth0 of fe80::1%3 or fe80::1%eth0), or 0 when it has none. Always 0 for IPv4.

Returns

  • the scope id, 0 for none

with_scope_id

with_scope_id(id: uint): IpAddress

Returns this address with scope id id, such as the index of the interface a link-local address is reached through; 0 removes the scope.

Parameters

  • id — the scope id, 0 for none

Returns

  • a copy of this address with that scope id

Panics

  • if this is an IPv4 address and id is not 0

Example

var a = IpAddress.parse("fe80::1").value().with_scope_id(2);
print(a.to_string());   # fe80::1%eth0 where interface 2 is eth0

is_loopback

is_loopback(): bool

Returns whether this is a loopback address: 127.0.0.0/8 for IPv4, ::1 for IPv6.

Returns

  • true for a loopback address

is_unspecified

is_unspecified(): bool

Returns whether this is the unspecified address, 0.0.0.0 or ::.

Returns

  • true for the unspecified address

to_bytes

to_bytes(): u8[]

Returns the address’s bytes in network order: 4 for IPv4, 16 for IPv6.

Returns

  • a new array of the address bytes

Example

var bytes = IpAddress.v4(10, 0, 0, 1).to_bytes();   # [10, 0, 0, 1]

to_string

to_string(): String

Returns the address as text: dotted decimal for IPv4, the RFC 5952 form for IPv6 (lowercase hex, leading zeros dropped, the longest run of two or more zero groups written ::, ::ffff:a.b.c.d for an IPv4-mapped address). A scope id follows as % and the name of its interface (fe80::1%eth0), or the number when no interface on this machine has that index (fe80::1%9).

Returns

  • the text form, which IpAddress.parse reads back

operator ==

==(other: IpAddress): bool

Returns whether two addresses are the same: same family, same bytes and same scope id. An IPv4 address never equals an IPv6 one, not even the IPv6 address that maps it.

Parameters

  • other — the address to compare with

Returns

  • true when both are equal

operator !=

!=(other: IpAddress): bool

Returns whether two addresses differ.

Parameters

  • other — the address to compare with

Returns

  • true when the family, any byte or the scope id differs

SocketAddress

struct SocketAddress

An IP address and a port: where a socket is bound, or where it connects or sends.

Make one with SocketAddress(address, port) or SocketAddress.parse, or get a list from resolve. SocketAddress() is 0.0.0.0:0. The text form is address:port for IPv4 and [address]:port for IPv6.

See also

  • IpAddress
  • resolve

Example

import io;

var any = io.SocketAddress(io.IpAddress.unspecified_v4(), 8080);
var local = io.SocketAddress.parse("[::1]:443").value();
print(local.port());   # 443

Constructors

SocketAddress()

construct()

Creates 0.0.0.0:0, the IPv4 unspecified address with port 0.

SocketAddress()

construct(address: IpAddress, port: u16)

Creates a socket address from an IP address and a port.

Port 0 asks the system to choose a free port when binding; read the one it chose with the socket’s local_address().

Parameters

  • address — the IP address
  • port — the port

Static methods

parse

static parse(text: String): IoResult<SocketAddress>

Parses a.b.c.d:port (IPv4) or [ipv6]:port (IPv6), with the address syntax of IpAddress.parse (a scope goes inside the brackets, [fe80::1%eth0]:80) and a decimal port from 0 to 65535.

Parameters

  • text — the socket address to parse

Returns

  • the socket address; an error with is_invalid_input() when the text is malformed

Example

var a = SocketAddress.parse("127.0.0.1:8080").value();
var b = SocketAddress.parse("[::1]:8080").value();

Methods

address

address(): IpAddress

Returns the IP address.

Returns

  • the IP address part

port

port(): u16

Returns the port.

Returns

  • the port, 0 to 65535

is_v4

is_v4(): bool

Returns whether the IP address is IPv4.

Returns

  • true for an IPv4 socket address

is_v6

is_v6(): bool

Returns whether the IP address is IPv6.

Returns

  • true for an IPv6 socket address

to_string

to_string(): String

Returns the text form: a.b.c.d:port for IPv4, [ipv6]:port for IPv6.

Returns

  • the text form, which SocketAddress.parse reads back

operator ==

==(other: SocketAddress): bool

Returns whether two socket addresses have equal IP addresses and the same port.

Parameters

  • other — the socket address to compare with

Returns

  • true when both are equal

operator !=

!=(other: SocketAddress): bool

Returns whether two socket addresses differ.

Parameters

  • other — the socket address to compare with

Returns

  • true when the address or the port differs

resolve

resolve(host: String, port: u16): IoResult<SocketAddress[]>

Looks up the socket addresses of a host, with port attached to each.

A host that is already an IP address (as IpAddress.parse reads it) is returned as is, without a lookup. Anything else goes to the system resolver (getaddrinfo on POSIX, GetAddrInfoW on Windows), which consults the hosts file and DNS and blocks until it answers. The addresses come in the resolver’s order of preference, IPv4 and IPv6 mixed, each at most once.

Parameters

  • host — a host name such as "example.com" or "localhost", or an IP address
  • port — the port to put in every result

Returns

  • at least one address; an error with is_not_found() when the name has no address, is_invalid_input() when host is empty, or another resolver error

Example

import io;

var r = io.resolve("localhost", 8080);
if (r.is_ok()) {
    for (var address in r.value()) {
        print(address.to_string());   # 127.0.0.1:8080, [::1]:8080, ...
    }
}

Sockets

TCP and UDP sockets: TcpListener accepts connections, TcpStream is one connection, UdpSocket sends and receives datagrams.

Every call blocks the calling thread until it completes (in a task, it parks only the task), or until the socket’s read or write timeout expires. Addresses are SocketAddress values; resolve turns a host name into them.

Source: runtime/io/socket.pep

TcpListener

[NotCopyable("owns an operating-system socket and cannot be copied")] struct TcpListener

A TCP socket that listens for connections.

TcpListener.bind opens one on a local address; each accept() waits for the next connection and returns it as a TcpStream together with the peer’s address. Binding to port 0 lets the system choose a free port; local_address() tells which.

On POSIX the listener sets SO_REUSEADDR, so a server can restart while connections from its previous run linger in TIME_WAIT. An IPv6 listener keeps the system’s default for accepting IPv4 too (on Linux and macOS it does, on Windows it does not).

Closing: the destructor closes the socket when the last reference goes; close() closes it earlier and reports errors. Every operation on a closed listener fails with an error no IoError predicate identifies (EBADF on POSIX, ERROR_INVALID_HANDLE on Windows).

See also

  • TcpStream

Example

import io;

var listener = io.TcpListener.bind(io.SocketAddress.parse("127.0.0.1:0").value()).value();
print(listener.local_address().value().to_string());   # 127.0.0.1:<port>
while (true) {
    var (stream, peer) = listener.accept().value();
    stream.write_all("hello " + peer.to_string());
}

Constructor

TcpListener()

construct()

Creates a closed listener: is_open() is false and every operation fails. Use TcpListener.bind to get an open one.

Static methods

bind

static bind(address: SocketAddress): IoResult<TcpListener>

Opens a TCP socket bound to address and starts listening on it.

Parameters

  • address — the local address to listen on: a specific interface (127.0.0.1:8080), every interface (0.0.0.0:8080, [::]:8080), or port 0 for any free port

Returns

  • the listening socket; an error with is_address_in_use() when another socket has the port, is_address_not_available() when the address is not one of this machine’s, is_permission_denied() for a privileged port, or another platform error

Methods

is_open

is_open(): bool

Returns whether the listener is open.

Returns

  • false after close() and for a TcpListener() never bound

accept

accept(): IoResult<(TcpStream, SocketAddress)>

Waits for the next incoming connection and returns it.

Returns

  • the connected stream and the peer’s address; an error when the listener is closed, or another platform error

incoming

incoming(): Incoming

Returns an iterator that accepts connections one at a time, for a for loop over incoming streams.

Each step waits in accept() and yields the connected stream, or the error accept() returned; an error does not end the iteration, so a server can log it and keep going, or break. The iteration ends once the listener is closed. Use accept() instead when the peer’s address is needed.

Returns

  • a new Incoming positioned before the first connection

Example

import io;

var listener = io.TcpListener.bind(io.SocketAddress.parse("127.0.0.1:8080").value()).value();
for (var connection in listener.incoming()) {
    if (connection.is_err()) {
        print(connection.error().message);
        continue;
    }
    connection.value().write_all("hello");
}

local_address

local_address(): IoResult<SocketAddress>

Returns the address the listener is bound to, with the port the system chose when it was bound to port 0.

Returns

  • the local address; an error when the listener is closed, or another platform error

close

close(): IoResult<void>

Closes the listener now. Closing a closed listener does nothing. Connections already accepted stay open.

Returns

  • an Ok result on success or when already closed; the platform error when closing fails

Incoming

struct Incoming

The iterator TcpListener.incoming() returns: each connection the listener accepts, as an IoResult<TcpStream>.

next() waits for the next connection; an accept error is yielded as an item rather than ending the iteration. The iteration ends once the listener is closed. The iterator holds a reference to the listener, so the listener stays open while the iterator lives.

Constructor

Incoming()

construct(listener: TcpListener)

Makes an iterator over the connections listener accepts.

Parameters

  • listener — the listener to accept on

Methods

next

next(): bool

Waits for the next connection.

Returns

  • true with the connection or the accept error as the current item; false once the listener is closed

current

current(): IoResult<TcpStream>

Returns the item the last successful next() moved to.

Returns

  • the accepted stream, or the error accepting it

Panics

  • if next() was never called or returned false (“Incoming.current() without a connection”)

SharedTcpListener

[Shared] struct SharedTcpListener

A TCP listening socket that any thread can accept connections on: the thread-safe counterpart of TcpListener, for servers that accept on every worker of async.run at once (see async.spawn_on_each_worker). Each connection goes to one of the threads waiting in accept, and the stream it returns belongs to that thread.

A shared function may capture it. There is no close(), since another thread may be waiting on it: the socket closes when the last reference goes, which, for accept loops in tasks, is once async.run has cancelled them (accept then fails and the loop returns).

See also

  • TcpListener

Example

import async;
import io;

main(): int {
    async.run(() => {
        var listener = io.SharedTcpListener.bind(io.SocketAddress.parse("0.0.0.0:7000").value()).value();
        async.spawn_on_each_worker(() => {
            while (true) {
                var accepted = listener.accept();
                if (accepted.is_err()) {
                    return;
                }
                var (stream, peer) = accepted.value();
                stream.write_all("hello");
            }
        });
    });
    return 0;
}

Constructor

SharedTcpListener()

construct(handle: SocketHandle)

Wraps a listening socket. Use SharedTcpListener.bind.

Parameters

  • handle — the listening socket, or an invalid handle for none

Static methods

bind

static bind(address: SocketAddress): IoResult<SharedTcpListener>

Opens a TCP socket bound to address and starts listening on it, as TcpListener.bind does.

Parameters

  • address — the local address to listen on (port 0 for any free port)

Returns

  • the listening socket, or the errors TcpListener.bind returns

Methods

accept

accept(): IoResult<(TcpStream, SocketAddress)>

Waits for the next incoming connection and returns it, as TcpListener.accept does. Several threads may wait at once; each connection wakes one of them (or, on some systems, several, and the rest keep waiting).

Returns

  • the connected stream, owned by the calling thread, and the peer’s address; an error when the waiting task is cancelled, or another platform error

local_address

local_address(): IoResult<SocketAddress>

Returns the address the listener is bound to, with the port the system chose when it was bound to port 0.

Returns

  • the local address, or a platform error

TcpStream

[NotCopyable("owns an operating-system socket and cannot be copied")] struct TcpStream

A TCP connection: a reliable, ordered stream of bytes in both directions.

Get one from TcpStream.connect (to an address), TcpStream.connect_host (to a host name) or TcpListener.accept. read appends what has arrived to a byte array and returns 0 once the peer has closed its side; write sends bytes.

Each call is one system call and blocks until it can make progress: a read returns as soon as some bytes are there, not when max bytes are, and a write may send fewer bytes than given. read_exact and write_all loop until done. Sending never raises SIGPIPE; writing to a connection the peer has closed fails with is_connection_reset().

Closing: the destructor closes the socket when the last reference goes; close() closes it earlier and reports errors. Every operation on a closed stream fails with an error no IoError predicate identifies (EBADF on POSIX, ERROR_INVALID_HANDLE on Windows).

See also

  • TcpListener

Example

import io;

var stream = io.TcpStream.connect_host("example.com", 80).value();
stream.write_all("HEAD / HTTP/1.0\r\n\r\n");
var reply = Array<u8>();
while (stream.read(reply, 4096).value() > 0) {
}

Constructor

TcpStream()

construct()

Creates a closed stream: is_open() is false and every operation fails. Use TcpStream.connect or TcpListener.accept to get an open one.

Static methods

connect

static connect(address: SocketAddress): IoResult<TcpStream>

Opens a connection to address, waiting until it is established.

Parameters

  • address — the address to connect to

Returns

  • the connected stream; an error with is_connection_refused() when nothing listens there, is_timed_out() when the peer does not answer, or another platform error

connect_timeout

static connect_timeout(address: SocketAddress, milliseconds: ulong): IoResult<TcpStream>

Opens a connection to address like connect, but gives up with is_timed_out() when it is not established within milliseconds, instead of after the system’s own limit (often minutes).

Parameters

  • address — the address to connect to
  • milliseconds — the longest wait, at most 4294967295 (about 49 days); 0 waits as long as connect does

Returns

  • the connected stream, with no read or write timeout; an error with is_timed_out() when the time runs out, is_invalid_input() when milliseconds is too large, or an error as for connect

Example

var r = TcpStream.connect_timeout(SocketAddress.parse("192.0.2.1:80").value(), 2000);
if (r.is_err() && r.error().is_timed_out()) {
    print("no answer within 2 seconds");
}

connect_host

static connect_host(host: String, port: u16): IoResult<TcpStream>

Resolves host with resolve and connects to the first of its addresses that accepts, trying them in order.

Parameters

  • host — a host name or an IP address
  • port — the port to connect to

Returns

  • the connected stream; the error from resolve when the name has no address, or the error from the last address tried

Example

var r = TcpStream.connect_host("localhost", 8080);
if (r.is_err() && r.error().is_connection_refused()) {
    print("nothing is listening on 8080");
}

adopt

static adopt(transfer: SocketTransfer): IoResult<TcpStream>

Takes the connection out of transfer (see into_transfer) into a new stream on the calling thread. Only the first adopt of a token gets the connection.

Parameters

  • transfer — the token from into_transfer

Returns

  • the stream; an error when the token is empty (already adopted, or made from a closed stream)

Methods

is_open

is_open(): bool

Returns whether the stream is open.

Returns

  • false after close() and for a TcpStream() never connected

read

read(buffer: u8[], max: ulong): IoResult<ulong>

Waits until data arrives, then appends up to max bytes of it to buffer with a single system call, like File.read.

Parameters

  • buffer — the array the bytes are appended to
  • max — the most bytes to read; 0 reads nothing and returns 0

Returns

  • the number of bytes appended, 0 once the peer has closed its side of the connection; an error with is_connection_reset() when the connection was reset, an error when the stream is closed, or another platform error

Example

var buffer = Array<u8>();
var n = stream.read(buffer, 4096).value();   # buffer.count == n

read_exact

read_exact(buffer: u8[], count: ulong): IoResult<void>

Reads until exactly count more bytes are appended to buffer.

Parameters

  • buffer — the array the bytes are appended to
  • count — the number of bytes to read

Returns

  • an Ok result once count bytes are appended; an error when the peer closes the connection first (“connection closed after n of count bytes”, code EIO on POSIX, ERROR_HANDLE_EOF on Windows, which no predicate matches), or an error as for read. The bytes read before an error stay in buffer.

write

write(data: u8[]): IoResult<ulong>

Sends bytes from data with a single system call, waiting until the system can take at least some of them.

The send may be partial: it can send fewer than data.count bytes. Use write_all to send everything.

Parameters

  • data — the bytes to send

Returns

  • the number of bytes sent, which may be less than data.count; an error with is_connection_reset() when the peer has closed or reset the connection, an error when the stream is closed, or another platform error

write_all

write_all(data: u8[]): IoResult<void>

Sends all of data, repeating the send until every byte is sent.

If an error occurs part of data may already have been sent.

Parameters

  • data — the bytes to send

Returns

  • an Ok result once every byte is sent; an error as for write

write_all

write_all(text: String): IoResult<void>

Sends all of the UTF-8 bytes of text, as write_all(u8[]) does. No newline or terminator is added.

Parameters

  • text — the text to send

Returns

  • an Ok result once every byte is sent; an error as for write

Example

stream.write_all("ping");

write_string

[Deprecated("use 'write_all' instead", "write_all")] write_string(text: String): IoResult<void>

Deprecated: use write_all instead. Sends all of the UTF-8 bytes of text, exactly like write_all(text).

Parameters

  • text — the text to send

Returns

  • an Ok result once every byte is sent; an error as for write_all

See also

  • write_all

shutdown_write

shutdown_write(): IoResult<void>

Closes the sending side of the connection: the peer’s reads return 0 once it has read everything sent before. This side can still read.

Returns

  • an Ok result on success; an error when the stream is closed, or another platform error

set_no_delay

set_no_delay(enabled: bool): IoResult<void>

Turns Nagle’s algorithm off (true) or on (false) with TCP_NODELAY. With it off, small writes go out at once instead of being held back to combine with later ones: lower latency for request/response traffic, more packets.

Parameters

  • enabled — true to send small writes immediately

Returns

  • an Ok result on success; an error when the stream is closed, or another platform error

set_read_timeout

set_read_timeout(milliseconds: ulong): IoResult<void>

Limits how long a read waits: when no data arrives for milliseconds, the read fails with is_timed_out() and the stream stays open for another try. Each wait counts on its own, so a read_exact may take longer in all. 0, the default, waits as long as it takes. It works outside a task and in one, where the task’s async.timeout can end the wait sooner.

Parameters

  • milliseconds — the longest wait, at most 4294967295 (about 49 days); 0 for none

Returns

  • an Ok result; an error with is_invalid_input() when milliseconds is too large, or an error when the stream is closed

Example

stream.set_read_timeout(5000);   # give up after 5 seconds of silence

read_timeout

read_timeout(): ulong

Returns the read timeout set_read_timeout set.

Returns

  • the timeout in milliseconds, 0 for none

set_write_timeout

set_write_timeout(milliseconds: ulong): IoResult<void>

Limits how long a write waits for the system to take more data: when it can take none for milliseconds (the peer is not reading), the write fails with is_timed_out(), possibly after sending part of the data. Each wait counts on its own. 0, the default, waits as long as it takes.

Parameters

  • milliseconds — the longest wait, at most 4294967295 (about 49 days); 0 for none

Returns

  • an Ok result; an error with is_invalid_input() when milliseconds is too large, or an error when the stream is closed

write_timeout

write_timeout(): ulong

Returns the write timeout set_write_timeout set.

Returns

  • the timeout in milliseconds, 0 for none

local_address

local_address(): IoResult<SocketAddress>

Returns this side’s address of the connection.

Returns

  • the local address; an error when the stream is closed, or another platform error

peer_address

peer_address(): IoResult<SocketAddress>

Returns the peer’s address.

Returns

  • the peer’s address; an error when the stream is closed, or another platform error

into_transfer

into_transfer(): SocketTransfer

Moves the connection out of this stream, into a token another thread can take it from with TcpStream.adopt. The stream is closed afterwards (is_open() is false), and a task waiting on it on this worker wakes with an error.

Inside async.run a socket belongs to the worker that uses it; this is how a task hands a connection to a task on another worker, such as one started with async.spawn.

Returns

  • the token; for a closed stream, one that adopt fails on

Example

import async;
import io;

serve(listener: io.TcpListener) {
    var (stream, peer) = listener.accept().value();
    var transfer = stream.into_transfer();
    async.spawn<void>(() => {
        var mine = io.TcpStream.adopt(transfer).value();
        mine.write_all("hello from another worker");
    });
}

close

close(): IoResult<void>

Closes the connection now. Closing a closed stream does nothing.

Returns

  • an Ok result on success or when already closed; the platform error when closing fails

SocketTransfer

[Shared] struct SocketTransfer

A connection on its way from one thread to another: made by TcpStream.into_transfer (or UnixStream.into_transfer), taken by TcpStream.adopt (or UnixStream.adopt, for a Unix domain connection). Unlike a stream, it is thread-safe, so a shared function (a body for async.spawn or Thread.spawn) can capture it and a Channel can carry it. If no one adopts it, the connection is closed when the last reference goes.

Constructor

SocketTransfer()

construct(handle: SocketHandle)

Wraps a socket handle. TcpStream.into_transfer makes these; use it.

Parameters

  • handle — the socket, or an invalid handle for an empty token

UdpSocket

[NotCopyable("owns an operating-system socket and cannot be copied")] struct UdpSocket

A UDP socket: sends and receives datagrams, each delivered whole or not at all, in no guaranteed order.

UdpSocket.bind opens one on a local address (port 0 for any free port). send_to and receive_from name the peer on each call; after connect, send and receive use the connected peer and ignore datagrams from anyone else.

Each receive takes one datagram. A datagram longer than max is truncated to max bytes and the rest is lost, so size max for the largest datagram expected (at most 65507 bytes over IPv4).

Closing: the destructor closes the socket when the last reference goes; close() closes it earlier and reports errors.

Example

import io;

var socket = io.UdpSocket.bind(io.SocketAddress.parse("0.0.0.0:9000").value()).value();
var buffer = Array<u8>();
var (n, from) = socket.receive_from(buffer, 65507).value();
socket.send_to(buffer, from);   # echo it back

Constructor

UdpSocket()

construct()

Creates a closed socket: is_open() is false and every operation fails. Use UdpSocket.bind to get an open one.

Static methods

bind

static bind(address: SocketAddress): IoResult<UdpSocket>

Opens a UDP socket bound to address.

Parameters

  • address — the local address to receive on; port 0 for any free port

Returns

  • the bound socket; an error with is_address_in_use() when another socket has the port, is_address_not_available() when the address is not one of this machine’s, or another platform error

Methods

is_open

is_open(): bool

Returns whether the socket is open.

Returns

  • false after close() and for a UdpSocket() never bound

send_to

send_to(data: u8[], address: SocketAddress): IoResult<ulong>

Sends data as one datagram to address.

Parameters

  • data — the datagram’s bytes
  • address — where to send it

Returns

  • the number of bytes sent (all of data); an error when the datagram is too large, when the socket is closed, or another platform error

receive_from

receive_from(buffer: u8[], max: ulong): IoResult<(ulong, SocketAddress)>

Waits for the next datagram and appends up to max of its bytes to buffer.

Parameters

  • buffer — the array the bytes are appended to
  • max — the most bytes to keep; the rest of a longer datagram is lost. 0 reads nothing and returns at once.

Returns

  • the number of bytes appended and the sender’s address; an error when the socket is closed, or another platform error

connect

connect(address: SocketAddress): IoResult<void>

Sets the socket’s default peer: send goes to address, and receive and receive_from only take datagrams from it. No packet is sent.

Parameters

  • address — the peer

Returns

  • an Ok result on success; an error when the socket is closed, or another platform error

send

send(data: u8[]): IoResult<ulong>

Sends data as one datagram to the connected peer.

Parameters

  • data — the datagram’s bytes

Returns

  • the number of bytes sent; an error when the socket is not connected or is closed, or another platform error

receive

receive(buffer: u8[], max: ulong): IoResult<ulong>

Waits for the next datagram from the connected peer and appends up to max of its bytes to buffer.

Parameters

  • buffer — the array the bytes are appended to
  • max — the most bytes to keep; the rest of a longer datagram is lost. 0 reads nothing and returns at once.

Returns

  • the number of bytes appended; an error when the socket is closed, or another platform error

set_broadcast

set_broadcast(enabled: bool): IoResult<void>

Allows (true) or forbids (false) sending to a broadcast address such as 255.255.255.255 (SO_BROADCAST).

Parameters

  • enabled — true to allow broadcasts

Returns

  • an Ok result on success; an error when the socket is closed, or another platform error

set_read_timeout

set_read_timeout(milliseconds: ulong): IoResult<void>

Limits how long a receive waits: when no datagram arrives for milliseconds, receive or receive_from fails with is_timed_out() and the socket stays open for another try. 0, the default, waits as long as it takes. It works outside a task and in one, where the task’s async.timeout can end the wait sooner.

Parameters

  • milliseconds — the longest wait, at most 4294967295 (about 49 days); 0 for none

Returns

  • an Ok result; an error with is_invalid_input() when milliseconds is too large, or an error when the socket is closed

Example

socket.set_read_timeout(5000);   # give up after 5 seconds of silence

read_timeout

read_timeout(): ulong

Returns the read timeout set_read_timeout set.

Returns

  • the timeout in milliseconds, 0 for none

set_write_timeout

set_write_timeout(milliseconds: ulong): IoResult<void>

Limits how long a send waits for room in the system’s buffer: when there is none for milliseconds, the send fails with is_timed_out(). 0, the default, waits as long as it takes.

Parameters

  • milliseconds — the longest wait, at most 4294967295 (about 49 days); 0 for none

Returns

  • an Ok result; an error with is_invalid_input() when milliseconds is too large, or an error when the socket is closed

write_timeout

write_timeout(): ulong

Returns the write timeout set_write_timeout set.

Returns

  • the timeout in milliseconds, 0 for none

local_address

local_address(): IoResult<SocketAddress>

Returns the address the socket is bound to, with the port the system chose when it was bound to port 0.

Returns

  • the local address; an error when the socket is closed, or another platform error

peer_address

peer_address(): IoResult<SocketAddress>

Returns the connected peer’s address.

Returns

  • the peer’s address; an error when the socket is not connected or is closed, or another platform error

close

close(): IoResult<void>

Closes the socket now. Closing a closed socket does nothing.

Returns

  • an Ok result on success or when already closed; the platform error when closing fails

Unix domain sockets

Unix domain sockets: UnixListener accepts connections on a path in the file system, UnixStream is one connection, and UnixDatagram sends and receives datagrams.

They work like TcpListener, TcpStream and UdpSocket, for processes on the same machine: a path names the socket instead of an IP address and a port. Linux, macOS and Windows (10 version 1803 and later) all have the stream sockets; UnixDatagram is Linux and macOS only.

Binding creates a socket file at the path, and closing the listener does not remove it: remove it with File.remove when the server is done, and before binding again, since binding to a path that exists fails with is_address_in_use().

Source: runtime/io/unix.pep

UnixListener

[NotCopyable("owns an operating-system socket and cannot be copied")] struct UnixListener

A Unix domain socket that listens for connections on a path.

UnixListener.bind creates the socket file and listens on it; each accept() waits for the next connection and returns it as a UnixStream.

Closing: the destructor closes the socket when the last reference goes; close() closes it earlier and reports errors. Neither removes the socket file. Every operation on a closed listener fails with an error no IoError predicate identifies (EBADF on POSIX, ERROR_INVALID_HANDLE on Windows).

See also

  • UnixStream

Example

import io;

var path = io.Directory.temp().join("server.sock");
io.File.remove(path);   # left over from an earlier run
var listener = io.UnixListener.bind(path).value();
while (true) {
    var stream = listener.accept().value();
    stream.write_all("hello");
}

Constructor

UnixListener()

construct()

Creates a closed listener: is_open() is false and every operation fails. Use UnixListener.bind to get an open one.

Static methods

bind

static bind(path: Path): IoResult<UnixListener>

Creates a Unix domain socket at path and starts listening on it.

Parameters

  • path — where to create the socket file: at most 107 bytes (103 on macOS)

Returns

  • the listening socket; an error with is_address_in_use() when a file already exists at path, is_invalid_input() when the path is empty or too long, is_not_found() when its directory does not exist, is_permission_denied() when the directory cannot be written, or another platform error

Methods

is_open

is_open(): bool

Returns whether the listener is open.

Returns

  • false after close() and for a UnixListener() never bound

path

path(): Path

Returns the path the listener was bound to.

Returns

  • the path given to bind; an empty path for a UnixListener() never bound

accept

accept(): IoResult<UnixStream>

Waits for the next incoming connection and returns it.

Returns

  • the connected stream; an error when the listener is closed, or another platform error

close

close(): IoResult<void>

Closes the listener now. Closing a closed listener does nothing. Connections already accepted stay open, and the socket file stays.

Returns

  • an Ok result on success or when already closed; the platform error when closing fails

SharedUnixListener

[Shared] struct SharedUnixListener

A Unix domain listening socket that any thread can accept connections on: the thread-safe counterpart of UnixListener, as SharedTcpListener is of TcpListener. Each connection goes to one of the threads waiting in accept, and the stream it returns belongs to that thread.

A shared function may capture it. There is no close(), since another thread may be waiting on it: the socket closes when the last reference goes. The socket file stays.

See also

  • UnixListener

Example

import async;
import io;

main(): int {
    async.run(() => {
        var listener = io.SharedUnixListener.bind(io.Path("/tmp/server.sock")).value();
        async.spawn_on_each_worker(() => {
            while (true) {
                var accepted = listener.accept();
                if (accepted.is_err()) {
                    return;
                }
                accepted.value().write_all("hello");
            }
        });
    });
    return 0;
}

Constructor

SharedUnixListener()

construct(handle: SocketHandle)

Wraps a listening socket. Use SharedUnixListener.bind.

Parameters

  • handle — the listening socket, or an invalid handle for none

Static methods

bind

static bind(path: Path): IoResult<SharedUnixListener>

Creates a Unix domain socket at path and starts listening on it, as UnixListener.bind does.

Parameters

  • path — where to create the socket file

Returns

  • the listening socket, or the errors UnixListener.bind returns

Methods

accept

accept(): IoResult<UnixStream>

Waits for the next incoming connection and returns it, as UnixListener.accept does. Several threads may wait at once; each connection wakes one of them (or, on some systems, several, and the rest keep waiting).

Returns

  • the connected stream, owned by the calling thread; an error when the waiting task is cancelled, or another platform error

UnixStream

[NotCopyable("owns an operating-system socket and cannot be copied")] struct UnixStream

A Unix domain stream connection: a reliable, ordered stream of bytes in both directions between two processes (or threads) on this machine.

Get one from UnixStream.connect or UnixListener.accept. Reading and writing work exactly as on a TcpStream: read appends what has arrived and returns 0 once the peer has closed its side, write may send part of the data, and read_exact and write_all loop until done. Sending never raises SIGPIPE; writing to a connection the peer has closed fails with is_connection_reset() (or a broken-pipe error no predicate matches, on some systems).

Closing: the destructor closes the socket when the last reference goes; close() closes it earlier and reports errors. Every operation on a closed stream fails with an error no IoError predicate identifies.

See also

  • UnixListener

Example

import io;

var stream = io.UnixStream.connect(io.Path("/tmp/server.sock")).value();
stream.write_all("ping");
var reply = Array<u8>();
stream.read_exact(reply, 4).value();

Constructor

UnixStream()

construct()

Creates a closed stream: is_open() is false and every operation fails. Use UnixStream.connect or UnixListener.accept to get an open one.

Static methods

connect

static connect(path: Path): IoResult<UnixStream>

Connects to the listener at path.

Parameters

  • path — the socket file a UnixListener is bound to

Returns

  • the connected stream; an error with is_not_found() when no file exists at path, is_connection_refused() when the file is there but nothing listens on it, is_invalid_input() when the path is empty or too long, or another platform error

adopt

static adopt(transfer: SocketTransfer): IoResult<UnixStream>

Takes the connection out of transfer (see into_transfer) into a new stream on the calling thread. Only the first adopt of a token gets the connection.

Parameters

  • transfer — the token from into_transfer

Returns

  • the stream; an error when the token is empty (already adopted, or made from a closed stream)

Methods

is_open

is_open(): bool

Returns whether the stream is open.

Returns

  • false after close() and for a UnixStream() never connected

read

read(buffer: u8[], max: ulong): IoResult<ulong>

Waits until data arrives, then appends up to max bytes of it to buffer with a single system call, like TcpStream.read.

Parameters

  • buffer — the array the bytes are appended to
  • max — the most bytes to read; 0 reads nothing and returns 0

Returns

  • the number of bytes appended, 0 once the peer has closed its side of the connection; an error when the stream is closed, or another platform error

read_exact

read_exact(buffer: u8[], count: ulong): IoResult<void>

Reads until exactly count more bytes are appended to buffer.

Parameters

  • buffer — the array the bytes are appended to
  • count — the number of bytes to read

Returns

  • an Ok result once count bytes are appended; an error when the peer closes the connection first (“connection closed after n of count bytes”), or an error as for read. The bytes read before an error stay in buffer.

write

write(data: u8[]): IoResult<ulong>

Sends bytes from data with a single system call, waiting until the system can take at least some of them. The send may be partial; use write_all to send everything.

Parameters

  • data — the bytes to send

Returns

  • the number of bytes sent, which may be less than data.count; an error when the peer has closed the connection, when the stream is closed, or another platform error

write_all

write_all(data: u8[]): IoResult<void>

Sends all of data, repeating the send until every byte is sent. If an error occurs part of data may already have been sent.

Parameters

  • data — the bytes to send

Returns

  • an Ok result once every byte is sent; an error as for write

write_all

write_all(text: String): IoResult<void>

Sends all of the UTF-8 bytes of text, as write_all(u8[]) does. No newline or terminator is added.

Parameters

  • text — the text to send

Returns

  • an Ok result once every byte is sent; an error as for write

Example

stream.write_all("ping");

write_string

[Deprecated("use 'write_all' instead", "write_all")] write_string(text: String): IoResult<void>

Deprecated: use write_all instead. Sends all of the UTF-8 bytes of text, exactly like write_all(text).

Parameters

  • text — the text to send

Returns

  • an Ok result once every byte is sent; an error as for write_all

See also

  • write_all

shutdown_write

shutdown_write(): IoResult<void>

Closes the sending side of the connection: the peer’s reads return 0 once it has read everything sent before. This side can still read.

Returns

  • an Ok result on success; an error when the stream is closed, or another platform error

set_read_timeout

set_read_timeout(milliseconds: ulong): IoResult<void>

Limits how long a read waits: when no data arrives for milliseconds, the read fails with is_timed_out() and the stream stays open for another try. Each wait counts on its own, so a read_exact may take longer in all. 0, the default, waits as long as it takes. It works outside a task and in one, where the task’s async.timeout can end the wait sooner.

Parameters

  • milliseconds — the longest wait, at most 4294967295 (about 49 days); 0 for none

Returns

  • an Ok result; an error with is_invalid_input() when milliseconds is too large, or an error when the stream is closed

Example

stream.set_read_timeout(5000);   # give up after 5 seconds of silence

read_timeout

read_timeout(): ulong

Returns the read timeout set_read_timeout set.

Returns

  • the timeout in milliseconds, 0 for none

set_write_timeout

set_write_timeout(milliseconds: ulong): IoResult<void>

Limits how long a write waits for the system to take more data: when it can take none for milliseconds (the peer is not reading), the write fails with is_timed_out(), possibly after sending part of the data. Each wait counts on its own. 0, the default, waits as long as it takes.

Parameters

  • milliseconds — the longest wait, at most 4294967295 (about 49 days); 0 for none

Returns

  • an Ok result; an error with is_invalid_input() when milliseconds is too large, or an error when the stream is closed

write_timeout

write_timeout(): ulong

Returns the write timeout set_write_timeout set.

Returns

  • the timeout in milliseconds, 0 for none

into_transfer

into_transfer(): SocketTransfer

Moves the connection out of this stream, into a token another thread can take it from with UnixStream.adopt, as TcpStream.into_transfer does. The stream is closed afterwards.

Returns

  • the token; for a closed stream, one that adopt fails on

close

close(): IoResult<void>

Closes the connection now. Closing a closed stream does nothing.

Returns

  • an Ok result on success or when already closed; the platform error when closing fails

UnixDatagram

[Target(OS(Linux) || OS(Mac))] [NotCopyable("owns an operating-system socket and cannot be copied")] struct UnixDatagram

A Unix domain datagram socket (SOCK_DGRAM on AF_UNIX): like a UdpSocket for processes on the same machine, with a path in place of an address. Linux and macOS only; Windows has no Unix datagram sockets.

Each send_to delivers one whole datagram, in order, and the system never drops one silently. While the receiver’s queue is full, a send waits on Linux; on macOS it fails at once with the platform’s “No buffer space available” (ENOBUFS) and sends nothing, so try again later. UnixDatagram.bind creates a socket file at a path, which closing does not remove (as for UnixListener); UnixDatagram.unbound makes a socket that can send but has no path for replies; UnixDatagram.pair makes two connected sockets.

Closing: the destructor closes the socket when the last reference goes; close() closes it earlier and reports errors. Every operation on a closed socket fails with an error no IoError predicate identifies (EBADF).

See also

  • UdpSocket
  • UnixStream

Example

import io;

var path = io.Directory.temp().join("events.sock");
io.File.remove(path);   # left over from an earlier run
var server = io.UnixDatagram.bind(path).value();
var client = io.UnixDatagram.unbound().value();
client.send_to("ping".bytes(), path);
var buffer = Array<u8>();
var (n, from) = server.receive_from(buffer, 1024).value();   # 4, an empty path

Constructor

UnixDatagram()

construct()

Creates a closed socket: is_open() is false and every operation fails. Use UnixDatagram.bind, unbound or pair to get an open one.

Static methods

bind

static bind(path: Path): IoResult<UnixDatagram>

Creates a datagram socket at path, to receive datagrams sent there.

Parameters

  • path — where to create the socket file: at most 107 bytes (103 on macOS)

Returns

  • the bound socket; an error with is_address_in_use() when a file already exists at path, is_invalid_input() when the path is empty or too long, is_not_found() when its directory does not exist, is_permission_denied() when the directory cannot be written, or another platform error

unbound

static unbound(): IoResult<UnixDatagram>

Creates a datagram socket with no path. It can send, but a receiver sees an empty path as the sender and cannot reply.

Returns

  • the socket; an error when the system is out of descriptors, or another platform error

pair

static pair(): IoResult<(UnixDatagram, UnixDatagram)>

Creates two datagram sockets connected to each other, with no path (socketpair(2)): what one sends with send, the other receives. A common way to talk to a child thread or process.

Returns

  • the two sockets; an error when the system is out of descriptors, or another platform error

Example

var (a, b) = io.UnixDatagram.pair().value();
a.send("hi".bytes());
var buffer = Array<u8>();
b.receive(buffer, 16);   # buffer holds "hi"

Methods

is_open

is_open(): bool

Returns whether the socket is open.

Returns

  • false after close() and for a UnixDatagram() never opened

path

path(): Path

Returns the path the socket was bound to.

Returns

  • the path given to bind; an empty path for an unbound or paired socket

send_to

send_to(data: u8[], path: Path): IoResult<ulong>

Sends data as one datagram to the socket bound at path.

Parameters

  • data — the datagram’s bytes
  • path — the socket file a UnixDatagram is bound to

Returns

  • the number of bytes sent (all of data); an error with is_not_found() when no socket file exists at path, is_connection_refused() when nothing is bound to it any more, is_invalid_input() when the path is empty or too long, or another platform error (such as a datagram larger than the system allows)

receive_from

receive_from(buffer: u8[], max: ulong): IoResult<(ulong, Path)>

Waits for the next datagram and appends up to max of its bytes to buffer.

Parameters

  • buffer — the array the bytes are appended to
  • max — the most bytes to keep; the rest of a longer datagram is lost. 0 reads nothing and returns at once.

Returns

  • the number of bytes appended and the sender’s path (empty when the sender is unbound); an error when the socket is closed, or another platform error

connect

connect(path: Path): IoResult<void>

Sets the socket’s default peer: send goes to the socket at path, and receive and receive_from only take datagrams from it. No datagram is sent.

Parameters

  • path — the socket file a UnixDatagram is bound to

Returns

  • an Ok result on success; an error with is_not_found() when no socket file exists at path, is_connection_refused() when nothing is bound to it, or another platform error

send

send(data: u8[]): IoResult<ulong>

Sends data as one datagram to the connected peer (see connect and pair).

Parameters

  • data — the datagram’s bytes

Returns

  • the number of bytes sent; an error when the socket is not connected or is closed, or another platform error

receive

receive(buffer: u8[], max: ulong): IoResult<ulong>

Waits for the next datagram from the connected peer and appends up to max of its bytes to buffer.

Parameters

  • buffer — the array the bytes are appended to
  • max — the most bytes to keep; the rest of a longer datagram is lost. 0 reads nothing and returns at once.

Returns

  • the number of bytes appended; an error when the socket is closed, or another platform error

set_read_timeout

set_read_timeout(milliseconds: ulong): IoResult<void>

Limits how long a receive waits: when no datagram arrives for milliseconds, receive or receive_from fails with is_timed_out() and the socket stays open for another try. 0, the default, waits as long as it takes. It works outside a task and in one, where the task’s async.timeout can end the wait sooner.

Parameters

  • milliseconds — the longest wait, at most 4294967295 (about 49 days); 0 for none

Returns

  • an Ok result; an error with is_invalid_input() when milliseconds is too large, or an error when the socket is closed

Example

socket.set_read_timeout(5000);   # give up after 5 seconds of silence

read_timeout

read_timeout(): ulong

Returns the read timeout set_read_timeout set.

Returns

  • the timeout in milliseconds, 0 for none

set_write_timeout

set_write_timeout(milliseconds: ulong): IoResult<void>

Limits how long a send waits for the receiver to take more: when its queue stays full for milliseconds (it is not receiving), send or send_to fails with is_timed_out() and sends nothing. 0, the default, waits as long as it takes. On macOS a send to a full queue fails at once instead (see UnixDatagram).

Parameters

  • milliseconds — the longest wait, at most 4294967295 (about 49 days); 0 for none

Returns

  • an Ok result; an error with is_invalid_input() when milliseconds is too large, or an error when the socket is closed

write_timeout

write_timeout(): ulong

Returns the write timeout set_write_timeout set.

Returns

  • the timeout in milliseconds, 0 for none

close

close(): IoResult<void>

Closes the socket now. Closing a closed socket does nothing. The socket file stays.

Returns

  • an Ok result on success or when already closed; the platform error when closing fails

Streams in memory

ByteReader and ByteWriter: a Reader over bytes already in memory, and a Writer that collects what is written into memory, for code that takes a stream when the data is in an array.

Source: runtime/io/memory.pep

ByteReader

struct ByteReader

A Reader over an array of bytes: each read takes the next bytes of the array, and the stream ends after its last byte.

The reader keeps a reference to the array and does not copy it; changing the array while reading changes what is read.

Example

import io;

var data = "one\ntwo\n".bytes();
var count = 0;
for (var line in io.ByteReader(data).lines()) {
    count += 1;
}
assert(count == 2, "two lines");

Properties

remaining

var remaining: ulong { get; }

The number of bytes not read yet.

Constructor

ByteReader()

construct(data: u8[])

Makes a reader positioned at the first byte of data.

Parameters

  • data — the bytes to read

Methods

read

read(buffer: u8[], max: ulong): IoResult<ulong>

Appends up to max of the next bytes to buffer.

Parameters

  • buffer — the array the bytes are appended to
  • max — the most bytes to read

Returns

  • the number of bytes appended, 0 once every byte has been read

ByteWriter

struct ByteWriter

A Writer that appends everything written to an array in memory, bytes.

Example

import io;

var out = io.ByteWriter();
out.print("n = ");
out.print_line(3);
assert(out.bytes == "n = 3\n".bytes(), "collected");

Fields

bytes

var bytes: u8[]

The bytes written so far, in order.

Constructor

ByteWriter()

construct()

Makes a writer with nothing written yet.

Methods

write

write(data: u8[]): IoResult<ulong>

Appends all of data to bytes.

Parameters

  • data — the bytes to write

Returns

  • the number of bytes written: always data.count

write_all

write_all(data: u8[]): IoResult<void>

Appends all of data to bytes.

Parameters

  • data — the bytes to write

Returns

  • an Ok result; writing to memory does not fail

Print

Print, deprecated: Writer gives print and print_line to every stream.

Source: runtime/io/print.pep

[Deprecated("use 'Writer' instead", "Writer")] trait Print

Deprecated: use Writer instead.

A byte stream that values can be printed to, as the global print prints to standard output.

A type has the trait by providing write_all(data: u8[]); it then gets print and print_line, which write the text to_string makes of a value (the text $"{value}" would hold).

Example

import io;

main(): int {
    var path = io.Directory.temp().join("report.txt");
    var file = io.File.create(path).value();
    file.print("total: ");
    file.print_line(42);
    file.close();
    assert(io.File.read_to_string(path).value() == "total: 42\n", "printed");
    io.File.remove(path);
    return 0;
}

Methods

write_all

write_all(data: u8[]): IoResult<void>

Writes all of data.

Parameters

  • data — the bytes to write

Returns

  • an Ok result once every byte is written; an error when the stream fails
print<T>(value: T): IoResult<void>

Writes the text of value, with no line break after it: a String as it is, anything else through to_string.

Type parameters

  • T — the type of value

Parameters

  • value — the value to print

Returns

  • an Ok result once the text is written; an error as for write_all

Example

file.print("n = ");
file.print(3);   # the file now holds "n = 3"
print_line<T>(value: T): IoResult<void>

Writes the text of value and a line break (\n), as the global print does on standard output.

Type parameters

  • T — the type of value

Parameters

  • value — the value to print

Returns

  • an Ok result once the line is written; an error as for write_all

Example

file.print_line($"total: {total}");
file.print_line((1, "two"));   # (1, two)

The async module

Threads and concurrency, modeled on Rust’s std::thread and C++ std::thread, and tasks: green threads that take turns on a few worker threads.

Like io, async is a module: bring it in with import async (and write async.Thread, async.logical_cpus()) or import names directly with import [Thread, ThreadLocal] from async.

  • Atomic: Atomic<T>, a value threads read and update without a lock
  • Thread: starting, naming and joining threads, ThreadId and ThreadBody
  • ThreadLocal: a variable with a separate value on every thread, and its ThreadLocalGuard<T>
  • Processor counts: logical_cpus() and physical_cpus()
  • Channel: Channel<T>, a queue that carries values between threads, and its ChannelIterator<T>
  • Mutex: Mutex<T>, the MutexGuard<T> a with block holds, and Condition<T>
  • Once: Once, which runs a function once, and Lazy<T>, a value built on first use
  • Tasks: run, Task<T>, spawn, spawn_on_each_worker, run_blocking, sleep, yield_now, timeout and cancellation, for many tasks taking turns on a few worker threads and parking on sockets
  • TaskLocal: a variable with a separate value in every task, and its TaskLocalGuard<T>
  • I/O backends: Backend, how a runtime waits for sockets

Only thread-safe values can be shared between threads: numbers, shared function values (such as a thread’s body), and [Shared] structs such as Atomic<T>, Thread, ThreadLocal<T>, Channel<T> and Mutex<T>. Any other value crosses threads only as a new value that nothing else references (Channel.send(move(x)), or send(copy(x))), or stays inside a Mutex, whose with blocks the compiler keeps apart from the rest of the program. Lazy<T> holds thread-safe values only. See the language guide’s Threads chapter.

Example

import async;

const hits = async.Atomic<long>(0);

work() {
    hits.fetch_add(1);
}

main(): int {
    var workers = Array<async.Thread>();
    var n = async.logical_cpus();
    var i: ulong = 0;
    while (i < n) {
        workers.append(async.Thread.spawn(work));
        i = i + 1;
    }
    for (var worker in workers) {
        worker.join();
    }
    assert(hits.load() == n as long, "every worker ran");
    return 0;
}

Source: runtime/async/module.pep

Atomic

Source: runtime/async/atomic.pep

Atomic

[Shared] struct Atomic<T: Shared>

A value of type T that threads can read and update without data races, like C++ std::atomic<T>.

T may be a numeric type, a pointer, or a struct type. For a struct T the Atomic holds a counted reference and every operation follows std::shared_ptr rules: store and a successful compare_exchange acquire the new reference and release the one they replace, and load hands the caller its own reference. Memory ordering: load is an acquire, store a release, and compare_exchange and the fetch_* operations are acquire-release.

Atomic is a [Shared] struct, so copies of it share one slot and may be used from several threads at once: pass the Atomic around (or keep it in a const global) to share the value between threads, so T is constrained Shared: a number, bool, an enum, a [Shared] struct, or another thread-safe type. async.Atomic<String> is a compile error at String.

Type parameters

  • T — the type of the stored value, which must be thread-safe (T: Shared)

Example

main(): int {
    var counter = async.Atomic<long>(0);
    var before = counter.fetch_add(5);   # before == 0
    if (counter.load() != 5) { return 1; }
    return 0;
}

Constructor

Atomic()

construct(value: T = __default<T>())

Creates an Atomic holding value.

For a struct T the Atomic acquires a reference to value.

Parameters

  • value — the initial value; defaults to zero for a POD T and null for a struct T

Example

var a = async.Atomic<int>(41);
var p = async.Atomic<Point>();   # holds null until a store

Methods

load

load(): T

Atomically reads the current value, with acquire ordering.

For a struct T the returned reference is acquired for the caller, so it stays valid even if another thread stores a new value afterwards.

Returns

  • the value currently stored

store

store(v: T)

Atomically replaces the stored value with v, with release ordering.

For a struct T this is a shared_ptr assignment: the slot acquires v and releases its previous occupant, and the swap is a single atomic exchange, so concurrent stores never leak or double-release.

Parameters

  • v — the new value

compare_exchange

compare_exchange(expected: T, desired: T): bool

Atomically replaces the stored value with desired if and only if it currently equals expected (a compare-and-swap).

Equality is bitwise: a struct T compares references (the same object, not equal contents), and a floating-point T compares bit patterns, so 0.0 and -0.0 differ. Unlike C++ compare_exchange_strong, expected is not updated on failure; call load() to retry. On success a struct slot acquires desired and releases the old occupant; on failure no reference count changes.

Parameters

  • expected — the value the slot must hold for the exchange to happen
  • desired — the value to store when it does

Returns

  • true if the slot held expected and now holds desired, false if it was left unchanged

Example

var cur = counter.load();
while (!counter.compare_exchange(cur, cur + 1)) {
    cur = counter.load();
}

exchange

exchange(v: T): T

Atomically replaces the stored value with v and returns the value it replaced.

Implemented as a compare_exchange retry loop, so under heavy contention it may retry several times. For a struct T the slot acquires v, and the caller owns the returned reference.

Parameters

  • v — the new value

Returns

  • the value stored immediately before the exchange

Example

var e = async.Atomic<int>(1);
var old = e.exchange(2);   # old == 1, e.load() == 2

is_lock_free

is_lock_free(): bool

Reports whether this Atomic’s slot fits a native atomic instruction, so its operations never need a lock or an atomic library call.

True for every POD T of 8 bytes or less and for every struct T (whose slot is one pointer). The answer depends only on T, never on the stored value.

Returns

  • true when the operations are lock-free

fetch_add

fetch_add(v: T): T

Atomically adds v to the stored value and returns the value before the addition, like std::atomic<T>::fetch_add.

Integer T only. Overflow wraps around. For a non-integer T (a floating-point or struct type) the call compiles but aborts the program when it runs.

Parameters

  • v — the amount to add

Returns

  • the value stored immediately before the addition

Example

var c = async.Atomic<long>(10);
var old = c.fetch_add(5);   # old == 10, c.load() == 15

fetch_sub

fetch_sub(v: T): T

Atomically subtracts v from the stored value and returns the value before the subtraction, like std::atomic<T>::fetch_sub.

Integer T only. Overflow wraps around. For a non-integer T the call compiles but aborts the program when it runs.

Parameters

  • v — the amount to subtract

Returns

  • the value stored immediately before the subtraction

fetch_and

fetch_and(v: T): T

Atomically replaces the stored value with its bitwise AND with v and returns the value before the operation, like std::atomic<T>::fetch_and.

Integer T only. For a non-integer T the call compiles but aborts the program when it runs.

Parameters

  • v — the mask to AND with

Returns

  • the value stored immediately before the operation

fetch_or

fetch_or(v: T): T

Atomically replaces the stored value with its bitwise OR with v and returns the value before the operation, like std::atomic<T>::fetch_or.

Integer T only. For a non-integer T the call compiles but aborts the program when it runs.

Parameters

  • v — the bits to OR in

Returns

  • the value stored immediately before the operation

fetch_xor

fetch_xor(v: T): T

Atomically replaces the stored value with its bitwise XOR with v and returns the value before the operation, like std::atomic<T>::fetch_xor.

Integer T only. For a non-integer T the call compiles but aborts the program when it runs.

Parameters

  • v — the bits to XOR with

Returns

  • the value stored immediately before the operation

Thread

Threads: starting, naming, identifying and joining operating-system threads.

Source: runtime/async/thread.pep

ThreadId

type ThreadId = ulong

Identifies a thread for as long as the program runs: every thread, whether started with Thread.spawn or not, gets a distinct id the first time it is asked for one, and ids are never reused.

Ids are assigned by the runtime in the order threads ask for them, starting at 1; they are not the operating system’s thread ids. A ThreadId is a number, so it can be compared, hashed and used as a HashMap key.

See also

  • Thread.id

ThreadBody

type ThreadBody = shared () => void

The type of a thread’s body: a function that takes no arguments and returns nothing. Thread.spawn runs one on a new thread.

It is a shared function type, because the body runs on another thread: a named function, or a closure that captures only thread-safe values (numbers, Atomic<T>, [Shared] structs, other shared function values). A closure capturing a String, say, is a compile error.

See also

  • Thread.spawn

Thread

[Shared] struct Thread

An operating-system thread.

Start one with Thread.spawn, which runs a function on a new thread and returns its Thread, and wait for it to finish with join(). Every thread, including the main thread, can get its own Thread with Thread.current().

A Thread is [Shared]: the thread that started it and the thread itself can both hold it. Dropping every reference without joining detaches the thread: it keeps running, and its resources are freed when it finishes.

The body runs until it returns; there is no way to stop a thread from the outside. A panic or failed assert on any thread ends the whole program.

Example

import async;

const total = Atomic<long>(0);

add_ten() {
    total.fetch_add(10);
}

main(): int {
    var a = async.Thread.spawn(add_ten, "adder a");
    var b = async.Thread.spawn(add_ten, "adder b");
    a.join();
    b.join();
    assert(total.load() == 20, "both threads ran");
    return 0;
}

Constructor

Thread()

construct(name: String, body: ThreadBody)

Creates a Thread value for a thread that is already running. Programs get threads from spawn and current() instead; a Thread made directly is not attached to any thread and cannot be joined.

Parameters

  • name — the thread’s name; empty for an unnamed thread
  • body — the function the thread runs

Static methods

spawn

static spawn(body: ThreadBody, name: String = ""): Thread

Starts a new thread that runs body.

The new thread starts right away and runs until body returns. Its Thread.current() is the Thread returned here.

Parameters

  • body — the function to run on the new thread
  • name — a name for the thread, returned by name(); empty by default

Returns

  • the new thread, to join() or query

Panics

  • if the operating system cannot create another thread

Example

var worker = Thread.spawn(do_work, "worker");
worker.join();

current

static current(): Thread

Returns the Thread of the thread that calls it.

For a thread started with spawn this is the Thread that spawn returned. Any other thread (the main thread, or one started by C code) gets a Thread the first time it asks and the same one afterwards; the main thread’s is named main, the others are unnamed. Such a Thread cannot be joined.

Returns

  • the calling thread

Example

var me = Thread.current();
print(me.name(), "\n");   # "main" on the main thread

sleep

static sleep(milliseconds: ulong)

Pauses the calling thread for at least milliseconds milliseconds.

The operating system may sleep longer, but never shorter. In a task (see run), it is async.sleep: the task waits and its worker thread runs other tasks, and a cancel or the task’s timeout ends the wait early.

Parameters

  • milliseconds — how long to sleep; 0 returns right away

Methods

id

id(): ThreadId

Returns the thread’s id, which no other thread in the program has.

Returns

  • the thread’s id

name

name(): String

Returns the name the thread was given.

Returns

  • the name passed to spawn, main for the main thread, or an empty String for an unnamed thread

is_finished

is_finished(): bool

Reports whether the thread’s body has returned.

A thread that is not finished may finish at any moment, so a false result can be out of date by the time it is used. Always false for a Thread that was not started by spawn.

Returns

  • true once the body has returned

is_joinable

is_joinable(): bool

Reports whether join() can be called: the thread was started by spawn and has not been joined yet.

Returns

  • true if the thread can be joined

join

join()

Waits for the thread’s body to return.

Returns right away if it already has. A thread can be joined once; a Thread from current() for a thread not started by spawn cannot be joined at all. Everything the thread did before it finished is visible to the caller after join() returns.

Panics

  • if the thread cannot be joined (see is_joinable()), or if a thread tries to join itself

ThreadLocal

Per-thread values: ThreadLocal<T> gives every thread its own copy of a variable.

Source: runtime/async/thread_local.pep

ThreadLocal

[Shared] struct ThreadLocal<T>

A variable with a separate value on every thread, like C++ thread_local or Rust’s thread_local!.

Each thread that calls set stores its own value. When a thread exits, its value is released.

For a thread-safe T (a number, a [Shared] struct), get returns the calling thread’s value (the default of T, zero or null, until that thread sets one). For any other T, such as a String, the value is reached through a with block, with (var value = local.enter()) { ... }, and get is a compile error; set then takes a new value (a constructor call, a string literal, copy(x)). Inside the block the value is kept apart from the rest of the program, as for a Mutex.

A ThreadLocal is [Shared], so one object can be used from every thread; it is usually a module-scope variable. T does not have to be thread-safe, because each value stays on the thread that stored it.

Destroying a ThreadLocal releases the calling thread’s value; values that other threads still hold are released when those threads exit.

A thread’s values are released when it exits if Thread.spawn started it (the async runtime’s workers too). The main thread’s are released only by clear(), and a thread started by C code leaks the values it did not clear.

Type parameters

  • T — the type of the per-thread value

Example

import async;

const calls = async.ThreadLocal<int>();

count_call() {
    calls.set(calls.get() + 1);
}

main(): int {
    count_call();
    count_call();
    assert(calls.get() == 2, "two calls on this thread");
    return 0;
}

Constructor

ThreadLocal()

construct()

Creates a thread-local variable. No thread has a value yet.

Methods

get

get(): T

Returns the calling thread’s value.

For a struct T the caller gets its own reference. Only for a thread-safe T: for any other T, use with (var value = local.enter()).

Returns

  • the value this thread last stored, or the default of T (zero or null) if it has not stored one

set

set([Move] value: T)

Stores value as the calling thread’s value, replacing (and, for a struct T, releasing) the one it held. Other threads’ values are not affected.

For a T that is not thread-safe, value must be a new value (a constructor call, a string literal, copy(x)), which the compiler checks.

Parameters

  • value — the new value for this thread

enter

enter(): ThreadLocalGuard<T>

Returns the guard that a with block enters to reach the calling thread’s value: with (var value = local.enter()) { ... }. The body runs with the value bound; the else block runs when this thread has no value (for a struct T).

Returns

  • the guard; its enter() returns this thread’s value

Example

const names = async.ThreadLocal<Array<String>>();

remember(String name) {
    if (!names.is_set()) {
        names.set(Array<String>());
    }
    with (var list = names.enter()) {
        list.append(name.copy());
    }
}

is_set

is_set(): bool

Reports whether the calling thread has stored a value since it started or last called clear().

Returns

  • true if this thread has a value

clear

clear()

Removes the calling thread’s value, releasing it for a struct T. get() returns the default of T afterwards.

ThreadLocalGuard

[Isolated] struct ThreadLocalGuard<T>

The access to a thread’s ThreadLocal<T> value that a with block holds: enter() returns the value. Get one from ThreadLocal.enter().

A guard is [Isolated]: it can only be the scope of a with header, and the compiler checks the block with the confinement rule (see Mutex).

Type parameters

  • T — the type of the per-thread value

Constructor

ThreadLocalGuard()

construct(local: ThreadLocal<T>)

Creates the guard for the calling thread’s value of local; the same as local.enter().

Parameters

  • local — the thread-local variable

Methods

enter

enter(): T

Returns the calling thread’s value, for the with header to bind.

Returns

  • this thread’s value, or the default of T (null for a struct) when it has none

exit

exit()

Does nothing: the value stays with the thread.

Mutex and Condition

Mutual exclusion: Mutex<T> owns a value that one thread at a time can use, MutexGuard<T> is the lock a with block holds, and Condition<T> lets a thread holding a Mutex sleep until another thread changes its value.

Source: runtime/async/mutex.pep

Mutex

[Shared] struct Mutex<T>

A value that one thread at a time can use, like Rust’s Mutex<T> or a C++ std::mutex paired with the data it protects.

The Mutex owns its value, and the way to reach it is a with block: with (var value = mutex.lock()) { ... } waits until no other thread holds the lock, binds the value, and unlocks when the block ends by any path (falling off the end, return, break, continue). Everything a thread did while holding the lock is visible to the next thread that takes it.

T is a struct type, such as Array<String> or a struct of your own. Inside the with block the compiler keeps the locked value and the rest of the program apart: a value that is not thread-safe (a String, an Array) cannot move from one to the other, and no part of the locked value can be returned from the block or stored outside it. Pass copy(x) to move a value either way; new values (a constructor call, a string literal, a call the compiler infers returns a fresh value) and thread-safe values such as numbers pass freely. Mutex<int> is a compile error: use Atomic<int>.

A Mutex is [Shared]: keep it in a module-scope variable or pass it to threads. It is not re-entrant: locking it again on the thread that holds it panics instead of deadlocking. A panic while it is held ends the program, so a Mutex is never left “poisoned”.

In a task (see run), waiting for the lock parks the task, not its worker thread, so the other tasks there keep running, including one that holds the lock. Each task counts as its own holder, and a task may hold the lock across a wait of its own (a sleep, a socket call). A cancel does not end the wait for the lock.

Type parameters

  • T — the protected value’s type, a struct type

Example

import async;

[Shared]
struct Account {
    const checking: Atomic<long>;
    const savings: Atomic<long>;
    construct(long checking = 0, long savings = 0) {
        this.checking = Atomic<long>(checking);
        this.savings = Atomic<long>(savings);
    }
}

const account = async.Mutex<Account>(Account(100, 0));

save(long amount) {
    with (var a = account.lock()) {
        # No other thread sees the money in neither account.
        a.checking.fetch_sub(amount);
        a.savings.fetch_add(amount);
    }
}

Constructor

Mutex()

construct([Move] value: T)

Creates an unlocked Mutex that owns value.

value must be a new value, such as a constructor call, copy(x) or move(x): nothing outside the Mutex may reference it. The caller hands its reference over, so nothing is copied.

Parameters

  • value — the value to protect; a new value, which the compiler checks

Example

const names = async.Mutex<Array<String>>(Array<String>());

Methods

lock

lock(): MutexGuard<T>

Waits until the calling thread holds the lock, and returns the guard that a with block enters to reach the value.

Use it as the scope of a with header: with (var v = mutex.lock()). The lock is released when the guard is destroyed, which for a guard created in the header is the end of the block. A guard kept anywhere else holds the lock until its last reference is released.

Returns

  • the guard; its enter() returns the value, and destroying it unlocks

Panics

  • if the calling thread already holds the lock

Example

with (var account = accounts.lock()) {
    account.balance.fetch_add(10);
}

try_lock

try_lock(): MutexGuard<T>

Takes the lock only if no thread holds it right now, without waiting.

In with (var v = mutex.try_lock()) { ... } else { ... }, the body runs when the lock was taken and the else block runs when it was not. The calling thread already holding the lock counts as busy.

Returns

  • the guard; its enter() returns the value, or null when the lock was busy

Example

with (var account = accounts.try_lock()) {
    account.balance.fetch_add(10);
} else {
    print("busy; try later\n");
}

replace

replace([Move] value: T): T

Replaces the whole value and returns the one the Mutex held, which the caller then owns outright: nothing else references it any more.

Waits for the lock like lock(). value must be a new value, as for the constructor, and is handed over without a copy.

Parameters

  • value — the new value; a new value, which the compiler checks

Returns

  • the value the Mutex held

Panics

  • if the calling thread already holds the lock

Example

var done = pending.replace(Array<Job>());   # take every job at once

is_held

is_held(): bool

Reports whether the caller holds the lock: the calling task, in a task, or else the calling thread.

Returns

  • true between a lock() (or a successful try_lock()) by the caller and the guard’s destruction

condition

condition(): Condition<T>

Returns a new condition variable that waits on this Mutex.

Create it once and share it, as the Mutex is shared: every thread that waits or notifies must use the same Condition.

Returns

  • a new Condition bound to this Mutex

Example

const queue = async.Mutex<Jobs>(Jobs());
const not_empty = queue.condition();

MutexGuard

[Isolated] struct MutexGuard<T>

The lock on a Mutex<T> that a with block holds: enter() returns the Mutex’s value, and destroying the guard unlocks.

Get one from Mutex.lock() or Mutex.try_lock() in a with header. The block releases the guard on every path out, so the lock cannot be left held by mistake. A guard belongs to the thread that locked it; do not pass it to another thread.

A guard is [Isolated]: it can only be the scope of a with header, so the lock cannot be kept past the block, and the compiler checks the block with the confinement rule (see Mutex).

Type parameters

  • T — the protected value’s type

Example

with (var totals = stats.lock()) {
    totals.requests.fetch_add(1);
}   # unlocked here

Fields

locked

bool locked

Whether the guard holds the lock: false once exit() has run, and for a try_lock() that found the lock busy.

Constructor

MutexGuard()

construct(source: Mutex<T>, wait: bool = true)

Locks source and creates the guard that holds it; the same as source.lock() (or source.try_lock() when wait is false).

Parameters

  • source — the Mutex to lock
  • wait — true to wait for the lock; false to take it only if it is free right now

Panics

  • if wait is true and the calling thread already holds the lock

Methods

enter

enter(): T

Returns the Mutex’s value, for the with header to bind.

For a struct T the caller gets its own reference.

Returns

  • the protected value, or the default of T (null for a struct) when the guard does not hold the lock

exit

exit()

Unlocks the Mutex if the guard holds it. The destructor calls it; calling it again does nothing.

Condition

[Shared] struct Condition<T>

Lets threads that hold a Mutex sleep until another thread changes the Mutex’s value, like C++ std::condition_variable. Get one from Mutex.condition().

wait() must run while the calling thread holds the Mutex, normally inside with (var v = mutex.lock()). It unlocks, sleeps until notified, and locks again before returning. A waiter can also wake without a notification (a spurious wakeup), so always wait in a loop that re-checks the value.

A thread that changes the value calls notify_one() or notify_all(), usually while it still holds the lock.

A Condition is [Shared], like its Mutex.

In a task (see run), wait() parks the task, not its worker thread. A cancel does not end it, since the caller’s loop would only wait again; wait_for() still returns when its time runs out.

Type parameters

  • T — the Mutex’s value type

Example

import async;

[Shared]
struct Jobs {
    const waiting: Atomic<long>;
    construct() { waiting = Atomic<long>(0); }
}

const jobs = async.Mutex<Jobs>(Jobs());
const has_work = jobs.condition();

take_job() {
    with (var j = jobs.lock()) {
        while (j.waiting.load() == 0) {
            has_work.wait();
        }
        j.waiting.fetch_sub(1);
    }
}

add_job() {
    with (var j = jobs.lock()) {
        j.waiting.fetch_add(1);
        has_work.notify_one();
    }
}

Constructor

Condition()

construct(mutex: Mutex<T>)

Creates a condition variable that waits on mutex. mutex.condition() does the same.

Parameters

  • mutex — the Mutex that waiters hold

Methods

wait

wait()

Unlocks the Mutex, sleeps until another thread calls notify_one() or notify_all(), and locks the Mutex again before returning.

The thread may also wake without being notified, so call it in a loop that re-checks what it waits for.

Panics

  • if the calling thread does not hold the Mutex

Example

with (var q = queue.lock()) {
    while (q.waiting.load() == 0) {
        not_empty.wait();
    }
}

wait_for

wait_for(milliseconds: ulong): bool

Like wait(), but gives up after about milliseconds milliseconds. The Mutex is locked again before it returns either way.

Parameters

  • milliseconds — the longest time to sleep

Returns

  • true if the thread was woken (by a notification or spuriously), false if the time ran out

Panics

  • if the calling thread does not hold the Mutex

wait_while

wait_while([Locked] predicate: (T) => bool)

Waits while predicate holds for the Mutex’s value: calls it with the value, and while it returns true, waits as wait() does and asks again. Returns once it returns false, with the Mutex still locked.

Wakeups without a notification are handled here: the predicate is asked again after every wake.

predicate is written at the call, as a closure: the compiler checks its body as it checks a with block’s, with its parameter as the locked value, so it cannot keep any part of the value or bring in a value from outside.

Parameters

  • predicate — called with the locked value; true to keep waiting

Panics

  • if the caller does not hold the Mutex

Example

with (var queue = jobs.lock()) {
    not_empty.wait_while((queue: Array<Job>): bool => queue.count == 0);
    var job = queue.remove_at(0);
}

wait_while_for

wait_while_for(milliseconds: ulong, [Locked] predicate: (T) => bool): bool

Like wait_while(), but gives up after about milliseconds milliseconds. The Mutex is locked when it returns either way.

Parameters

  • milliseconds — the longest time to wait
  • predicate — called with the locked value; true to keep waiting

Returns

  • true once predicate returned false, false if the time ran out first

Panics

  • if the caller does not hold the Mutex

notify_one

notify_one()

Wakes one thread or task waiting on this condition, if any. The caller does not need to hold the Mutex, but usually does, having just changed the value.

notify_all

notify_all()

Wakes every thread and task waiting on this condition. Each locks the Mutex in turn before its wait() returns.

Semaphore, Barrier and Latch

Coordination between threads: Semaphore bounds how many threads do something at once, Barrier makes a group of threads meet before any goes on, and Latch lets threads wait until a count of events has happened.

Source: runtime/async/coordination.pep

Semaphore

[Shared] struct Semaphore

A count of permits that threads take and give back, like Java’s Semaphore or C++20 std::counting_semaphore: at most permits threads hold one at a time.

acquire() waits until a permit is free and takes it; release() gives one back. The usual form is a with block, which gives the permit back on every path out: with (semaphore.permit()) { ... }.

A Semaphore is [Shared]: keep it in a module-scope variable or pass it to threads. Permits are not tied to a thread: any thread may release a permit that another acquired, and release() may raise the count above where it started.

In a task (see run), waiting for a permit parks the task, not its worker thread.

Example

import async;

# At most two downloads at once.
const downloads = async.Semaphore(2);

download(url: String) {
    with (downloads.permit()) {
        # ... fetch url ...
    }
}

Constructor

Semaphore()

construct(permits: ulong)

Creates a Semaphore with permits free permits.

Parameters

  • permits — how many permits are free at first; 0 makes every acquire() wait for a release()

Methods

acquire

acquire()

Waits until a permit is free, and takes it.

Give it back with release(), or use permit() in a with block, which gives it back for you.

try_acquire

try_acquire(): bool

Takes a permit only if one is free right now, without waiting.

Returns

  • true if the caller now holds a permit, false if none was free

release

release()

Gives a permit back, waking one thread waiting in acquire() if any.

permit

permit(): SemaphorePermit

Waits until a permit is free, takes it, and returns a guard that gives it back when destroyed: at the end of with (semaphore.permit()) { ... } by any path.

Returns

  • the guard holding the permit

Example

with (connections.permit()) {
    serve(client);
}   # the permit is given back here

SemaphorePermit

struct SemaphorePermit

A permit taken from a Semaphore, given back when the guard is destroyed. Get one from Semaphore.permit() in a with header.

Example

with (var permit = pool.permit()) {
    # holds one of the pool's permits
}

Constructor

SemaphorePermit()

construct(semaphore: Semaphore)

Waits for a permit from semaphore and holds it; the same as semaphore.permit().

Parameters

  • semaphore — the Semaphore to take a permit from

Methods

enter

enter(): SemaphorePermit

Returns this guard, for the with header.

Returns

  • this permit

exit

exit()

Gives the permit back if it is still held. The destructor calls it; calling it again does nothing.

Barrier

[Shared] struct Barrier

A meeting point for a fixed number of threads, like Rust’s std::sync::Barrier: each thread that calls wait() sleeps until count threads have called it, then all of them go on together.

Exactly one thread of each group, the leader, gets true from wait(), so it can do the work that must happen once per round. The barrier then resets, and the next count calls make the next round.

A Barrier is [Shared]. In a task (see run), wait() parks the task, not its worker thread; the other tasks of a round must then run on other workers or later on the same one.

Example

import async;

const step_done = async.Barrier(4);

work(id: long) {
    var step = 0;
    while (step < 10) {
        # ... this thread's share of the step ...
        if (step_done.wait()) {
            print($"step {step} done\n");   # once per step
        }
        step = step + 1;
    }
}

Constructor

Barrier()

construct(count: ulong)

Creates a barrier for groups of count threads.

Parameters

  • count — how many threads each round waits for; 0 counts as 1, so every wait() returns at once as the leader

Methods

wait

wait(): bool

Waits until count threads, this one included, have called wait() in this round.

Returns

  • true on exactly one thread of each round, the last to arrive; false on the others

Latch

[Shared] struct Latch

A one-shot countdown, like C++20 std::latch: threads wait until count_down() has been called count times. Once open it stays open.

Unlike a Barrier, the threads that count down need not wait, and the threads that wait need not count down: a typical use is a thread that starts workers and waits for each to report that it is ready.

A Latch is [Shared]. In a task (see run), wait() parks the task, not its worker thread.

Example

import async;

const ready = async.Latch(3);

worker() {
    # ... set up ...
    ready.count_down();
    # ... work ...
}

main(): int {
    var threads = Array<async.Thread>();
    var i = 0;
    while (i < 3) {
        threads.append(async.Thread.spawn(worker));
        i = i + 1;
    }
    ready.wait();   # every worker has set up
    for (var t in threads) {
        t.join();
    }
    return 0;
}

Constructor

Latch()

construct(count: ulong)

Creates a latch that opens after count calls to count_down().

Parameters

  • count — how many calls open it; 0 makes a latch that is open from the start

Methods

count_down

count_down()

Counts one event, and opens the latch, waking every waiter, if it was the last one.

Panics

  • if the latch is already open

wait

wait()

Waits until the latch is open. Returns at once if it already is.

is_ready

is_ready(): bool

Reports whether the latch is open, without waiting.

Returns

  • true once count_down() has been called count times

RwLock

Reader/writer locking: RwLock<T> owns a thread-safe value that many threads may read at once or one thread may write, with RwLockReadGuard<T> and RwLockWriteGuard<T> as the locks a with block holds.

Source: runtime/async/rwlock.pep

RwLock

[Shared] struct RwLock<T: Shared>

A thread-safe value that many threads may read at once or one thread may write, like Rust’s RwLock<T> or C++ std::shared_mutex.

with (var v = lock.read()) { ... } waits until no writer holds or is waiting for the lock, then binds the value; any number of threads may be reading at once. with (var v = lock.write()) { ... } waits until no other thread holds the lock at all. Both unlock when the block ends by any path, and everything a writer did is visible to the threads that lock after it.

T must be thread-safe, as for Atomic<T>: typically a [Shared] struct whose Atomic fields a writer changes together, so readers never see them half changed. A value that is not thread-safe, such as an Array<String>, is a compile error: readers holding it at once would race on its reference counts. Use a Mutex<T> for such a value.

Writers come first: while a writer waits, new readers wait too, so readers cannot keep a writer out forever. A thread that already reads must not ask to read again, since a writer may be waiting between the two. Asking to write while holding the lock in either mode panics instead of deadlocking.

In a task (see run), waiting for the lock parks the task, not its worker thread.

Type parameters

  • T — the protected value’s type, which must be thread-safe (T: Shared)

Example

import async;

[Shared]
struct Range {
    const low: Atomic<long>;
    const high: Atomic<long>;
    construct() {
        low = Atomic<long>(0);
        high = Atomic<long>(0);
    }
}

const range = async.RwLock<Range>(Range());

widen(by: long) {
    with (var r = range.write()) {
        r.low.fetch_sub(by);
        r.high.fetch_add(by);
    }
}

width(): long {
    with (var r = range.read()) {
        return r.high.load() - r.low.load();   # never half widened
    }
    return 0;
}

Constructor

RwLock()

construct(value: T)

Creates an unlocked RwLock holding value.

Parameters

  • value — the value to protect

Methods

read

read(): RwLockReadGuard<T>

Waits until no writer holds or waits for the lock, takes it for reading, and returns the guard that a with block enters to reach the value. Other threads may read at the same time.

Returns

  • the guard; its enter() returns the value, and destroying it unlocks

Panics

  • if the calling thread holds the lock for writing

Example

with (var config = settings.read()) {
    print($"{config.port.load()}\n");
}

try_read

try_read(): RwLockReadGuard<T>

Takes the lock for reading only if that needs no wait: no writer holds it or waits for it.

In with (var v = lock.try_read()) { ... } else { ... }, the body runs when the lock was taken and the else block runs when it was not.

Returns

  • the guard; its enter() returns the value, or null when the lock was not free for reading

write

write(): RwLockWriteGuard<T>

Waits until no other thread holds the lock, takes it for writing, and returns the guard that a with block enters to reach the value.

Returns

  • the guard; its enter() returns the value, and destroying it unlocks

Panics

  • if the calling thread already holds the lock for writing

Example

with (var config = settings.write()) {
    config.port.store(8080);
}

try_write

try_write(): RwLockWriteGuard<T>

Takes the lock for writing only if no thread holds it right now, without waiting.

Returns

  • the guard; its enter() returns the value, or null when the lock was busy

replace

replace(value: T): T

Replaces the whole value under the write lock and returns the one the RwLock held.

Parameters

  • value — the new value

Returns

  • the value the RwLock held

Panics

  • if the calling thread already holds the lock for writing

RwLockReadGuard

struct RwLockReadGuard<T: Shared>

The read lock on an RwLock<T> that a with block holds: enter() returns the value, and destroying the guard unlocks. Get one from RwLock.read() or RwLock.try_read() in a with header.

Type parameters

  • T — the protected value’s type

Example

with (var stats = counters.read()) {
    total = stats.hits.load() + stats.misses.load();
}   # unlocked here

Fields

locked

bool locked

Whether the guard holds the lock: false once exit() has run, and for a try_read() that could not take it.

Constructor

RwLockReadGuard()

construct(source: RwLock<T>, wait: bool = true)

Takes source for reading and creates the guard that holds it; the same as source.read() (or source.try_read() when wait is false).

Parameters

  • source — the RwLock to lock
  • wait — true to wait for the lock; false to take it only if that needs no wait

Panics

  • if wait is true and the calling thread holds the lock for writing

Methods

enter

enter(): T

Returns the RwLock’s value, for the with header to bind.

Returns

  • the protected value, or the default of T (null for a struct) when the guard does not hold the lock

exit

exit()

Unlocks if the guard holds the lock. The destructor calls it; calling it again does nothing.

RwLockWriteGuard

struct RwLockWriteGuard<T: Shared>

The write lock on an RwLock<T> that a with block holds: enter() returns the value, and destroying the guard unlocks. Get one from RwLock.write() or RwLock.try_write() in a with header.

Type parameters

  • T — the protected value’s type

Example

with (var stats = counters.write()) {
    stats.hits.store(0);
    stats.misses.store(0);
}   # unlocked here

Fields

locked

bool locked

Whether the guard holds the lock: false once exit() has run, and for a try_write() that found the lock busy.

Constructor

RwLockWriteGuard()

construct(source: RwLock<T>, wait: bool = true)

Takes source for writing and creates the guard that holds it; the same as source.write() (or source.try_write() when wait is false).

Parameters

  • source — the RwLock to lock
  • wait — true to wait for the lock; false to take it only if it is free right now

Panics

  • if wait is true and the calling thread already holds the lock for writing

Methods

enter

enter(): T

Returns the RwLock’s value, for the with header to bind.

Returns

  • the protected value, or the default of T (null for a struct) when the guard does not hold the lock

exit

exit()

Unlocks if the guard holds the lock. The destructor calls it; calling it again does nothing.

Once and Lazy

One-time initialization: Once runs a function exactly once however many threads ask, and Lazy<T> builds a value on first use.

Source: runtime/async/once.pep

Once

[Shared] struct Once

Runs a function exactly once, however many threads ask, like C++ std::call_once or Rust’s std::sync::Once.

The first call runs its body. Callers that arrive while it runs wait for it to finish, and later callers return right away. Every caller sees everything the body did once call returns.

A Once is [Shared]: keep it in a module-scope variable or pass it to threads.

Example

import async;

const setup = async.Once();
const setups = Atomic<long>(0);

initialize() {
    setups.fetch_add(1);
}

work() {
    setup.call(initialize);   # only the first caller runs it
}

Constructor

Once()

construct()

Creates a Once whose body has not run.

Methods

call

call(body: ThreadBody)

Runs body if no call has run a body yet; otherwise waits until that run has finished. Everything the body did is visible to the caller when this returns.

Only one body ever runs, even if later calls pass different ones.

Parameters

  • body — the function to run once

Panics

  • if body itself calls call on the same Once (it would wait for itself forever)

Example

config_once.call(load_config);

is_done

is_done(): bool

Reports whether a body has finished running.

Returns

  • true once a call has completed its body on some thread

Lazy

[Shared] struct Lazy<T: Shared>

A value built on first use by whichever thread asks first, like Rust’s LazyLock. The usual way to give a module-scope variable a value that is expensive to build, or that cannot be built before main runs.

The first get() runs the initializer; threads that call get() while it runs wait for it, and every later call returns the same value.

T is constrained Shared (a thread-safe type such as a number, a [Shared] struct, or a shared function type), because every thread gets the same value; Lazy<String> is a compile error.

Type parameters

  • T — the value’s type, which must be thread-safe (T: Shared)

Example

import async;

[Shared]
struct Settings {
    const workers: long;
    construct(this.workers = 1) {}
}

load_settings(): Settings {
    return Settings(async.logical_cpus() as long);
}

const settings = async.Lazy<Settings>(load_settings);

main(): int {
    var workers = settings.get().workers;   # built here, on first use
    return 0;
}

Constructor

Lazy()

construct(initializer: shared () => T)

Creates a Lazy whose value initializer builds on the first get(). Nothing runs yet.

Parameters

  • initializer — builds the value; runs at most once, on the thread that first calls get()

Methods

get

get(): T

Returns the value, building it with the initializer on the first call.

For a struct T the caller gets its own reference.

Returns

  • the value the initializer built

Panics

  • if the initializer itself calls get() on the same Lazy

is_built

is_built(): bool

Reports whether the value has been built.

Returns

  • true once a get() has finished running the initializer

Channel

Channels: Channel<T> carries values from threads that send to threads that receive, and ChannelIterator<T> receives in a for loop.

Source: runtime/async/channel.pep

Channel

[Shared] struct Channel<T>

A queue that carries values from threads that send to threads that receive, like Go channels or Rust’s mpsc, with any number of each.

Values arrive in the order they were sent (from any one sender). A bounded channel holds at most capacity values, and send waits while it is full; Channel<T>(0), the default, is unbounded and send never waits.

close() ends the stream: later sends fail, and receivers take what is left and then get an error. Only close() closes a channel; dropping it or the threads that use it does not. for (var v in channel) receives until the channel is closed and empty.

send takes a new value, which nothing on the sending thread references, and hands it to the receiver without copying it: a constructor call, a literal, a call that returns a fresh value, move(x) (a variable that holds the only reference) or copy(x) (a copy, when the sender keeps using x). Sending a variable that something else still references is a compile error. A thread-safe value (a number, a [Shared] struct, a shared function value such as a ThreadBody) passes freely.

A Channel is [Shared]: keep it in a module-scope variable or pass it to threads.

Type parameters

  • T — the type of the values carried

Example

import async;

const jobs = async.Channel<long>(64);
const total = Atomic<long>(0);

worker() {
    for (var job in jobs) {
        total.fetch_add(job);
    }
}

main(): int {
    var thread = async.Thread.spawn(worker);
    jobs.send(20);
    jobs.send(22);
    jobs.close();
    thread.join();
    assert(total.load() == 42, "the worker received both jobs");
    return 0;
}

Properties

count

var count: ulong { get; }

The number of values waiting to be received. Other threads may change it as soon as it returns.

Constructor

Channel()

construct(capacity: ulong = 0)

Creates an open, empty channel.

Parameters

  • capacity — how many values may wait before send blocks; 0 (the default) for no limit

Example

var bounded = async.Channel<long>(16);
var unbounded = async.Channel<long>();

Methods

send

send([Move] value: T): bool

Queues value, first waiting while a bounded channel is full.

value must be a new value: nothing else may reference it. The caller hands its reference over and the value itself is queued, with no copy. Pass move(x) to send a variable’s value, or copy(x) to keep using x.

In a task (see run), waiting parks the task and its worker runs other tasks; a cancel or the task’s timeout ends the wait.

Parameters

  • value — the value to send; a new value, which the compiler checks

Returns

  • true if the value was queued, false if the channel is closed or the task’s wait was cancelled or timed out (the value is dropped)

Example

var line = "hello";
lines.send(copy(line));
line.append(" again");   # the receiver still gets "hello"
lines.send(move(line));   # line is null now

try_send

try_send([Move] value: T): bool

Queues value only if there is room right now, without waiting. The value is dropped when it is not queued.

Parameters

  • value — the value to send; a new value, as for send

Returns

  • true if the value was queued, false if the channel is full or closed

receive

receive(): Result<T>

Takes the next value, waiting until one arrives.

In a task (see run), waiting parks the task and its worker runs other tasks; a cancel or the task’s timeout ends the wait.

Returns

  • the value, or an error once the channel is closed and empty, or when the task’s wait was cancelled or timed out

Example

var job = jobs.receive();
if (job.is_ok()) {
    run(job.value());
}

try_receive

try_receive(): Result<T>

Takes the next value if one is waiting, without waiting.

Returns

  • the value, or an error if none is waiting (see is_closed() to tell an empty channel from a closed one)

receive_for

receive_for(milliseconds: ulong): Result<T>

Like receive(), but gives up after about milliseconds milliseconds.

Parameters

  • milliseconds — the longest time to wait

Returns

  • the value, or an error if the time ran out, once the channel is closed and empty, or when the task’s wait was cancelled

close

close()

Closes the channel and wakes every waiting sender and receiver.

Later sends fail. Values already queued stay, and receivers still get them; after the last one, receive() returns an error and for loops end. Closing a closed channel does nothing.

is_closed

is_closed(): bool

Reports whether close() has been called.

Returns

  • true once the channel is closed, even while values are still queued

iterate

iterate(): ChannelIterator<T>

Returns an iterator that receives until the channel is closed and empty. A for loop over the channel calls it.

Several threads may loop over one channel at once; each value goes to exactly one of them.

Returns

  • the iterator a for loop drives

Example

for (var line in lines) {
    print(line, "\n");
}   # ends once `lines` is closed and empty

ChannelIterator

[Value] struct ChannelIterator<T>

Receives from a Channel<T> in a for loop: next() waits for the next value and returns false once the channel is closed and empty.

Channel.iterate() returns one; a for loop over a channel uses it without naming it. It belongs to the thread that runs the loop.

Type parameters

  • T — the type of the values carried

Example

var values = jobs.iterate();
while (values.next()) {
    total = total + values.current();
}

Constructor

ChannelIterator()

construct(source: Channel<T>)

Creates an iterator that receives from source. source.iterate() does the same.

Parameters

  • source — the channel to receive from

Methods

next

next(): bool

Waits for the next value.

Returns

  • true if a value arrived (read it with current()), false once the channel is closed and empty

current

current(): T

Returns the value the last successful next() received.

Returns

  • the current value

Select

Waiting on several channels at once: Select<E> takes the first value any of them receives, or sends a value to the first that has room, and turns what happened into a case of your own enum, which a match then handles.

Source: runtime/async/select.pep

Select

struct Select<E>

Waits on several channels at once and takes the first value any of them receives, or sends to the first that has room, like Go’s select.

Each channel to receive from is added with receive(channel, wrap), where wrap turns one of its values into an E, usually a case of an enum of your own that carries it. A value to send is added with send(channel, value, sent), where sent returns the E that says it was sent. wait() then fires whichever arm is ready first and returns its E, for a match to handle. The channels may carry different types.

  • The value is taken under the channel’s own lock, so no other receiver can take it between “ready” and “take”.
  • A send arm’s value goes into its channel only when that arm fires; until then the select holds it. Once sent, the arm leaves the select, so the next wait() does not send it again. A value never sent is dropped with the select.
  • When several channels have values, each wait starts after the channel that fired last, so a busy channel cannot starve the others.
  • A channel that is closed and empty is skipped, and so is a send arm whose channel is closed. Once every arm is, wait() returns an error.
  • In a task (see run), waiting parks the task and its worker runs other tasks; a cancel or the task’s timeout ends the wait.

A Select is not [Shared]: use it from the thread (or task) that made it.

Type parameters

  • E — the type every channel’s values are turned into

Example

enum Event {
    Job(job: Job),
    Tick(count: long),
    Stop,
}

var select = async.Select<Event>();
select.receive(jobs, (job: Job): Event => Event.Job(job));
select.receive(ticks, (count: long): Event => Event.Tick(count));
select.receive(quit, (stop: bool): Event => Event.Stop);
var running = true;
while (running) {
    match (select.wait()) {
        Ok(event) => {
            match (event) {
                Job(job) => { run(job); }
                Tick(count) => { report(count); }
                Stop => { running = false; }
            }
        }
        Err(error) => { running = false; }   # every channel closed
    }
}

Constructor

Select()

construct()

Creates a select with no channels yet.

Methods

receive

receive<T>(channel: Channel<T>, wrap: (T) => E)

Adds channel: its values, turned into an E by wrap, are among those wait() returns.

Type parameters

  • T — the type of the channel’s values

Parameters

  • channel — the channel to receive from
  • wrap — turns a value received from channel into an E; it runs on the waiting thread

Example

select.receive(lines, (line: String): Event => Event.Line(line));

send

send<T>(channel: Channel<T>, [Move] value: T, sent: () => E)

Adds a send arm: wait() sends value to channel once the channel has room (a bounded channel that is full waits), and returns what sent returns.

value must be a new value, as for Channel.send: the select holds it and moves it into the channel only when this arm fires, so it is never sent if another arm fires first. Once sent, the arm leaves the select. A send arm whose channel is closed never fires, and its value is dropped with the select.

Type parameters

  • T — the type of the channel’s values

Parameters

  • channel — the channel to send to
  • value — the value to send; a new value, which the compiler checks
  • sent — returns the E that wait() returns once the value is sent; it runs on the waiting thread

Example

select.send(results, copy(line), (): Event => Event.Sent);

wait

wait(): Result<E>

Fires the first arm that is ready, waiting until one is: takes the next value any of the channels receives, or sends a send arm’s value to a channel with room.

Returns

  • the arm’s E (the value received, turned into an E, or what a send arm’s sent returned); or an error once every channel is closed (and empty, for a receive arm), when the select has no arms, or when the task’s wait was cancelled or timed out

wait_for

wait_for(milliseconds: ulong): Result<E>

Like wait(), but gives up after about milliseconds milliseconds.

Parameters

  • milliseconds — the longest time to wait

Returns

  • the arm’s E, as for wait(); or an error if the time ran out, once every channel is closed (and empty, for a receive arm), or when the task’s wait was cancelled

try_wait

try_wait(): Result<E>

Fires an arm if one is ready now, without waiting: takes a value if a channel has one waiting, or sends a send arm’s value if its channel has room.

Returns

  • the arm’s E, as for wait(); or an error if no arm is ready, or once every channel is closed (and empty, for a receive arm)

Processor counts

Processor counts.

Source: runtime/async/cpus.pep

logical_cpus

logical_cpus(): ulong

Returns the number of logical processors the operating system can run threads on: every hardware thread, so a core with hyperthreading (SMT) counts two or more times.

This is the upper bound on how many threads can run at the same moment, and a sensible default size for a pool of CPU-bound worker threads. Inside a virtual machine it counts the processors the guest sees. On Linux, a CPU quota on the process’s cgroup or any cgroup above it (cgroup v2’s cpu.max, or v1’s cpu.cfs_quota_us over cpu.cfs_period_us, as docker run --cpus sets) caps the count, rounded up to whole processors, as Go and the JVM do: a quota of 1.5 processors counts as 2.

Returns

  • the number of logical processors, at least 1

See also

  • physical_cpus

Example

import async;

main(): int {
    print(async.logical_cpus(), " logical processors\n");
    return 0;
}

physical_cpus

physical_cpus(): ulong

Returns the number of physical processor cores: hardware threads that share a core through hyperthreading (SMT) count once.

On Linux the count comes from the kernel’s CPU topology (/sys/devices/system/cpu), on macOS from hw.physicalcpu, and on Windows from GetLogicalProcessorInformationEx. Inside a virtual machine the guest cannot see the host’s hardware, so this counts the cores the hypervisor presents. Where the topology is not available, it returns logical_cpus().

Returns

  • the number of physical cores, at least 1 and at most logical_cpus()

See also

  • logical_cpus

Tasks

Tasks: functions that run concurrently on a few worker threads, each on its own small stack, switching only where they wait.

run(body) turns the calling thread into the first worker, starts the others, and runs body as the first task. A task starts others with Task<T>.spawn_local (on its own worker) or spawn (on any worker), and gives its worker to the other tasks there whenever it waits: sleep, yield_now, and Task.join, whenever a socket call would block, and whenever it waits on a Mutex, Condition or Channel. Nothing preempts a task, so a task that computes for a long time without waiting keeps every other task on its worker waiting too.

A task is a green thread (a fiber): an ordinary function with its own stack, so code that waits looks the same as code that does not, and a function called in a task can wait without being marked in any way.

Source: runtime/async/task.pep

TaskError

struct TaskError

Why Task.join did not return the task’s value.

Fields

message

const message: String

A description of what went wrong.

Constructor

TaskError()

construct(message: String)

Creates a task error.

Parameters

  • message — a description of what went wrong

Methods

is_cancelled

is_cancelled(): bool

Returns whether the task was cancelled, or the task waiting for it was.

Returns

  • true when cancellation ended the wait

is_timed_out

is_timed_out(): bool

Returns whether a timeout around the join expired before the task finished. The task itself carries on.

Returns

  • true when a timeout ended the wait

RuntimeOptions

struct RuntimeOptions

Settings for run_with, built by chaining: RuntimeOptions().stack_size(65536).

Constructor

RuntimeOptions()

construct()

Creates the default options: one worker per logical CPU, 256 KiB of stack per task, the system’s best I/O backend, and up to 64 threads for run_blocking.

Methods

workers

workers(count: ulong): RuntimeOptions

Sets how many workers (threads that run tasks) the runtime has. The thread that calls run_with is the first; the rest are new threads. Tasks from spawn_local run on the worker that started them; tasks from spawn run on whichever worker is free. The default is logical_cpus().

Parameters

  • count — the number of workers

Returns

  • these options

Panics

  • if count is 0

blocking_threads

blocking_threads(count: ulong): RuntimeOptions

Sets how many threads run_blocking may start. The runtime starts them one at a time, when a blocking call finds none idle, and keeps them until run_with returns. Once there are count, further calls wait for one of them to be free. The default is 64.

Parameters

  • count — the most threads for blocking calls

Returns

  • these options

Panics

  • if count is 0

backend

backend(kind: Backend): RuntimeOptions

Sets which I/O backend the runtime waits on sockets with. The default, Backend.Automatic, picks the system’s best (or the one the PEPPER_IO_BACKEND environment variable names).

Parameters

  • kind — the backend

Returns

  • these options

Note

  • run_with panics if the system does not have the backend

stack_size

stack_size(bytes: ulong): RuntimeOptions

Sets how much stack each task reserves. Only the pages a task touches use memory; the rest is address space. A task that uses more than this crashes the program, as a thread that overflows its stack does.

Parameters

  • bytes — the stack size in bytes (rounded up to whole pages)

Returns

  • these options

Panics

  • if bytes is less than 16 KiB

Task

struct Task<T>

A task started with spawn_local or spawn, and the value its body returns.

Type parameters

  • T — the type of the body’s result; void when there is none

Example

import async;

main(): int {
    async.run(() => {
        var a = async.Task<int>.spawn_local(() => { return 20; });
        var b = async.Task<int>.spawn_local(() => { return 22; });
        print(a.join().value() + b.join().value());   # 42
    });
    return 0;
}

Constructor

Task()

construct(core: _TaskCore)

Wraps the scheduler’s record of a task. Only spawn_local and spawn have one; start tasks with those.

Parameters

  • core — the scheduler’s record of the task

Static methods

spawn_local

static spawn_local(body: () => T): Task<T>

Starts a task on the current worker that runs body. It runs the next time the running task waits, and may use anything the running task can, since it stays on this thread.

Parameters

  • body — the task’s code

Returns

  • the task

Panics

  • outside run

Methods

join

join(): Result<T, TaskError>

Waits for the task to finish and returns its value. Other tasks run meanwhile.

Returns

  • the body’s result, or a TaskError if the task was cancelled before it finished, the task calling join was cancelled while it waited, or a timeout around the join expired first

Panics

  • if a task joins itself, or if called outside run on a task that has not finished

cancel

cancel()

Asks the task to stop: from now on is_cancelled() is true inside it, sleep returns at once, and its join calls fail. A task is never stopped by force; it finishes when its body returns.

is_finished

is_finished(): bool

Returns whether the task’s body has returned.

Returns

  • true once the task has finished

run

run(body: () => void)

Runs body as the first task of a new runtime, and returns once body has returned and every task it started has finished. The calling thread is the first worker; the runtime starts one more thread per logical CPU after it (see RuntimeOptions.workers). When body returns, the tasks still running are cancelled (see Task.cancel), on every worker, and run waits for them to finish.

Parameters

  • body — the first task

Panics

  • if called inside a task, or if every remaining task is waiting on another and none can continue

Example

import async;

main(): int {
    async.run(() => {
        var t = async.Task<void>.spawn_local(() => {
            async.sleep(10);
            print("second");
        });
        print("first");
        t.join();
    });
    return 0;
}

run_with

run_with(options: RuntimeOptions, body: () => void)

Runs body like run, with the given settings.

Parameters

  • options — the runtime’s settings
  • body — the first task

Panics

  • if called inside a task, if every remaining task is waiting on another and none can continue, or if the system does not have the backend options asks for

spawn

spawn<T: Shared>(body: shared () => T): Task<T>

Starts a task that runs body on whichever worker is free first, and returns it. Unlike Task<T>.spawn_local, the task may run on another thread, in parallel with the calling task, so the body must be a shared function (it may capture only thread-safe values) and its result crosses threads, so T is constrained Shared: a thread-safe type such as a number, a [Shared] struct, or a shared function type (spawn<String> is a compile error at String).

A task, once started, stays on the worker that started it. Joining and cancelling work as for spawn_local.

Type parameters

  • T — the type of the body’s result, which must be thread-safe (T: Shared); void when there is none

Parameters

  • body — the task’s code

Returns

  • the task

Panics

  • outside run

Example

import async;

sum(from: long, to: long): long {
    var total: long = 0;
    var i = from;
    while (i < to) {
        total = total + i;
        i = i + 1;
    }
    return total;
}

main(): int {
    async.run(() => {
        var low = async.spawn<long>(() => { return sum(0, 500000); });
        var high = async.spawn<long>(() => { return sum(500000, 1000000); });
        print(low.join().value() + high.join().value());   # 499999500000
    });
    return 0;
}

spawn_on_each_worker

spawn_on_each_worker(body: shared () => void): Array<Task<void>>

Starts one task on every worker, each running body, and returns them in worker order. Use it to give each worker its own copy of a loop, such as a server’s accept loop on an io.SharedTcpListener, so that every worker takes part. Each task runs on its own worker, the calling task’s worker included; they are otherwise like tasks from spawn, and body must be a shared function.

Parameters

  • body — the code each worker runs

Returns

  • one task per worker, the first on the first worker

Panics

  • outside run

Example

import async;
import io;

main(): int {
    async.run(() => {
        var listener = io.SharedTcpListener.bind(io.SocketAddress.parse("0.0.0.0:7000").value()).value();
        var loops = async.spawn_on_each_worker(() => {
            while (true) {
                var accepted = listener.accept();
                if (accepted.is_err()) {
                    return;
                }
                var (stream, peer) = accepted.value();
                async.Task<void>.spawn_local(() => {
                    stream.write_all("hello");
                });
            }
        });
        for (var loop in loops) {
            loop.join();
        }
    });
    return 0;
}

run_blocking

run_blocking<T: Shared>(body: shared () => T): T

Runs body on a separate thread and returns what it returns, parking the calling task meanwhile so the worker keeps running other tasks. Use it for work that would otherwise hold the worker up: a call that blocks in the operating system (reading a file, resolving a host name, a C library that waits) or a long computation.

The runtime keeps a pool of threads for these calls, started as needed up to RuntimeOptions.blocking_threads. body runs on one of them, so it must be a shared function and T is constrained Shared, as for spawn. On a pool thread, sockets block the thread, and sleep and the other task functions behave as outside run.

body always runs to the end: cancelling the calling task, or a timeout around the call expiring, takes effect once run_blocking has returned. Outside a task, run_blocking just calls body.

Type parameters

  • T — the type of the body’s result, which must be thread-safe (T: Shared); void when there is none

Parameters

  • body — the blocking code

Returns

  • what body returns

Example

import async;

main(): int {
    async.run(() => {
        var ticker = async.Task<void>.spawn_local(() => {
            async.sleep(10);
            print("the worker kept running");
        });
        var answer = async.run_blocking<int>(() => {
            async.Thread.sleep(50);   # blocks a pool thread, not the worker
            return 42;
        });
        print(answer);
        ticker.join();
    });
    return 0;
}

yield_now

yield_now()

Lets every other ready task run before the calling task continues. Does nothing outside a task.

sleep

sleep(milliseconds: ulong)

Waits at least milliseconds. In a task, other tasks run meanwhile, and a cancelled task returns at once, as does one whose timeout expires first; outside a task, the thread sleeps.

Parameters

  • milliseconds — how long to wait

sleep

sleep(duration: os.Duration)

Waits at least duration, the same as sleep(milliseconds). A duration that is not a whole number of milliseconds rounds up, so the wait is never shorter than asked.

Parameters

  • duration — how long to wait

Example

async.sleep(os.Duration.from_seconds(1));

is_cancelled

is_cancelled(): bool

Returns whether the running task has been cancelled (see Task.cancel), so a long computation can stop early. False outside a task.

Returns

  • true when the running task was asked to stop

Deadline

struct Deadline

A time limit on the waits inside a with block, made by timeout.

While the block runs, a socket call that has to wait fails with an IoError for which is_timed_out() holds once the limit passes, Task.join fails with a TaskError for which is_timed_out() holds, and sleep returns early. Code that does not wait is never interrupted. Timeouts nest: an inner one cannot extend an outer one.

Constructor

Deadline()

construct(milliseconds: ulong)

Creates a limit milliseconds from now. Use timeout instead.

Parameters

  • milliseconds — how long from now the limit passes

Methods

enter

enter(): Deadline

Starts applying the limit to the running task; a with block calls it.

Returns

  • this deadline

Panics

  • outside a task

exit

exit()

Stops applying the limit; the end of the with block calls it.

is_expired

is_expired(): bool

Returns whether the limit has passed.

Returns

  • true once the time is up

timeout

timeout(milliseconds: ulong): Deadline

Returns a time limit for a with block: the waits inside it give up milliseconds from now (see Deadline).

Parameters

  • milliseconds — how long the block’s waits may take in all

Returns

  • the limit, for a with header

Example

import async;
import io;

main(): int {
    async.run(() => {
        var listener = io.TcpListener.bind(io.SocketAddress.parse("127.0.0.1:0").value()).value();
        with (var deadline = async.timeout(50)) {
            var accepted = listener.accept();   # nobody connects
            assert(accepted.error().is_timed_out(), "timed out");
        }
    });
    return 0;
}

current_backend

current_backend(): Backend

Returns the I/O backend the running runtime waits on sockets with (see RuntimeOptions.backend).

Returns

  • the backend; never Backend.Automatic

Panics

  • outside run

TaskLocal

Per-task values: TaskLocal<T> gives every task its own copy of a variable.

Source: runtime/async/task_local.pep

TaskLocal

[Shared] struct TaskLocal<T>

A variable with a separate value in every task, the task counterpart of ThreadLocal<T>.

Each task that calls set stores its own value, which is released when the task finishes. A task starts with no value, including one started by a task that has one: values are not inherited. Tasks that take turns on one worker thread each see their own value, which a ThreadLocal cannot give them.

Code that does not run in a task (before async.run, or on a plain Thread) has one value per thread, as with a ThreadLocal.

For a thread-safe T (a number, a [Shared] struct), get returns the calling task’s value (the default of T, zero or null, until it sets one). For any other T, such as a String, the value is reached through a with block, with (var value = local.enter()) { ... }, and get is a compile error; set then takes a new value (a constructor call, a string literal, copy(x)).

A TaskLocal is [Shared], so one object can be used from every task and thread; it is usually a module-scope variable. Destroying one releases only the caller’s value, so destroy it only after the tasks that used it have finished.

Type parameters

  • T — the type of the per-task value

See also

  • ThreadLocal

Example

import async;

const request_id = async.TaskLocal<long>();

handle(id: long) {
    request_id.set(id);
    async.yield_now();   # the other tasks run meanwhile
    assert(request_id.get() == id, "still this task's id");
}

main(): int {
    async.run(() => {
        var tasks = Array<async.Task<void>>();
        for (var id in [1, 2, 3]) {
            tasks.append(async.spawn<void>(() => {
                handle(id);
            }));
        }
        for (var task in tasks) {
            task.join();
        }
    });
    return 0;
}

Constructor

TaskLocal()

construct()

Creates a task-local variable. No task has a value yet.

Panics

  • if the operating system has no thread-local keys left

Methods

get

get(): T

Returns the calling task’s value.

For a struct T the caller gets its own reference. Only for a thread-safe T: for any other T, use with (var value = local.enter()).

Returns

  • the value this task last stored, or the default of T (zero or null) if it has not stored one

set

set([Move] value: T)

Stores value as the calling task’s value, replacing (and, for a struct T, releasing) the one it held. Other tasks’ values are not affected.

For a T that is not thread-safe, value must be a new value (a constructor call, a string literal, copy(x)), which the compiler checks.

Parameters

  • value — the new value for this task

enter

enter(): TaskLocalGuard<T>

Returns the guard that a with block enters to reach the calling task’s value: with (var value = local.enter()) { ... }. The body runs with the value bound; the else block runs when this task has no value (for a struct T).

Returns

  • the guard; its enter() returns this task’s value

Example

const trail = async.TaskLocal<Array<String>>();

note(step: String) {
    if (!trail.is_set()) {
        trail.set(Array<String>());
    }
    with (var steps = trail.enter()) {
        steps.append(step.copy());
    }
}

is_set

is_set(): bool

Reports whether the calling task has stored a value since it started or last called clear().

Returns

  • true if this task has a value

clear

clear()

Removes the calling task’s value, releasing it for a struct T. get() returns the default of T afterwards.

TaskLocalGuard

[Isolated] struct TaskLocalGuard<T>

The access to a task’s TaskLocal<T> value that a with block holds: enter() returns the value. Get one from TaskLocal.enter().

A guard is [Isolated]: it can only be the scope of a with header, and the compiler checks the block with the confinement rule (see Mutex).

Type parameters

  • T — the type of the per-task value

Constructor

TaskLocalGuard()

construct(local: TaskLocal<T>)

Creates the guard for the calling task’s value of local; the same as local.enter().

Parameters

  • local — the task-local variable

Methods

enter

enter(): T

Returns the calling task’s value, for the with header to bind.

Returns

  • this task’s value, or the default of T (null for a struct) when it has none

exit

exit()

Does nothing: the value stays with the task.

I/O backends

I/O backends: how a task runtime waits for sockets.

Source: runtime/async/driver.pep

Backend

enum Backend

The I/O backend a runtime waits on sockets with (see RuntimeOptions.backend).

Cases

Automatic

Automatic

The best one the system has: epoll on Linux, kqueue on macOS, IOCP on Windows. The PEPPER_IO_BACKEND environment variable (epoll, io_uring, kqueue, iocp or poll) picks another.

Epoll

Epoll

Linux’s epoll, edge-triggered.

IoUring

IoUring

Linux’s io_uring (kernel 5.11 or later), waiting for sockets with one multishot poll request each. Not the default: container seccomp profiles and hardened kernels often refuse it.

Kqueue

Kqueue

The BSD and macOS kqueue, edge-triggered.

Iocp

Iocp

A Windows I/O completion port, fed readiness by AFD poll requests.

Poll

Poll

poll() (WSAPoll on Windows): available everywhere, but each wait passes every waiting socket to the system, so it suits few connections. The reference the others are tested against.

The process module

Child processes, and what belongs to the running one, modeled on Rust’s std::process.

process is a module: bring it in with import process (and write process.Command) or import names directly with import [Command, Stream] from process. arguments() and exit() stay in the prelude, and the module passes them on as well.

  • Command: Command describes a program to start; run() waits for it and collects its output, spawn() starts it and returns a Child, and exec() runs it in place of the running program
  • Child: a running child process, its piped streams, and its ExitStatus
  • The running process: id(), executable_path(), and fork(), which starts a copy of it
  • Finding programs: find_executable() looks a program up on the PATH, find_companion() looks beside the running program first, and is_executable() checks a file
  • Signals: signals() returns a channel that receives Ctrl-C, a request to terminate or a hangup, for a clean shutdown

Example

import process;

main(): int {
    var output = process.Command("git").argument("--version").run();
    if (output.is_err()) {
        print("git is not installed");
        return 1;
    }
    print(output.value().stdout);
    return 0;
}

Source: runtime/process/module.pep

Command

Starting a program: Command names it and its arguments, environment, working directory and standard streams; spawn() starts it and run() also waits for it and collects what it wrote.

Source: runtime/process/command.pep

Stream

enum Stream

Where a child’s standard input, output or error goes (see Command.stdin, Command.stdout and Command.stderr).

Cases

Inherit

Inherit

The child shares the parent’s stream: it reads the parent’s input, or writes where the parent writes.

Pipe

Pipe

A pipe connects the stream to the parent, which reaches it through Child.stdin(), Child.stdout() or Child.stderr().

Null

Null

The stream is the null device: reading it finds the end at once, and what is written to it is thrown away.

Output

struct Output

What a program Command.run() waited for left behind: how it ended and what it wrote.

Fields

status

var status: ExitStatus

How the program ended.

stdout

var stdout: String

Everything the program wrote to standard output, decoded as UTF-8 (each byte sequence that is not valid UTF-8 becomes U+FFFD); empty when standard output was not piped.

stderr

var stderr: String

Everything the program wrote to standard error, decoded the same way; empty when standard error was not piped.

Constructor

Output()

construct(status: ExitStatus, stdout: String, stderr: String)

Creates an Output from its parts.

Parameters

  • status — how the program ended
  • stdout — what it wrote to standard output
  • stderr — what it wrote to standard error

Command

struct Command

Describes a program to start: the program, its arguments, its environment, its working directory, and where its standard streams go.

A builder in the style of Rust’s std::process::Command: each setter stores its value and returns this same Command, so calls chain, and one Command can start the program any number of times.

The program is found the way a shell finds it: a name with no path separator is searched for on the PATH (on Windows, also in the directory of the running program and the system directories, and .exe is added when the name has no extension); anything else is a path, relative to the parent’s current directory.

The child inherits the parent’s environment, changed by environment, remove_environment and clear_environment. By default it also inherits the parent’s current directory and its three standard streams; run() instead pipes standard output and error, and gives standard input the null device, unless they were set.

Example

import process;

main(): int {
    var output = process.Command("clang").arguments(["--version"]).run().value();
    if (!output.status.success()) {
        print(output.stderr);
        return 1;
    }
    return 0;
}

Fields

program

var program: String

The program to start, as given to the constructor.

Constructor

Command()

construct(program: String)

Creates a Command that starts program with no arguments, the parent’s environment and current directory, and inherited streams.

Parameters

  • program — the program: a name to search for on the PATH, or a path

Methods

argument

argument(value: String): Command

Adds one argument after those already added. It reaches the program as one element of its arguments, whatever it holds: no shell splits or expands it. On Windows, where a program receives one command line, it is quoted so that the C runtime splits it back out as it was.

Parameters

  • value — the argument

Returns

  • this Command

arguments

arguments(values: Array<String>): Command

Adds each of values as an argument, in order.

Parameters

  • values — the arguments

Returns

  • this Command

environment

environment(name: String, value: String): Command

Sets the environment variable name to value for the child, replacing any value it would inherit. On Windows, names are compared without regard to case.

Parameters

  • name — the variable’s name
  • value — its value

Returns

  • this Command

remove_environment

remove_environment(name: String): Command

Leaves the environment variable name out of the child’s environment, including a value an earlier environment set.

Parameters

  • name — the variable’s name

Returns

  • this Command

clear_environment

clear_environment(): Command

Starts the child with an empty environment, dropping every variable the parent has and every change made so far; later calls to environment add to it.

Returns

  • this Command

working_directory

working_directory(path: io.Path): Command

Starts the child in path instead of the parent’s current directory. A relative program path is still resolved against the parent’s current directory.

Parameters

  • path — the child’s current directory

Returns

  • this Command

stdin

stdin(stream: Stream): Command

Sets where the child’s standard input comes from.

Parameters

  • stream — Stream.Inherit, Stream.Pipe or Stream.Null

Returns

  • this Command

stdout

stdout(stream: Stream): Command

Sets where the child’s standard output goes.

Parameters

  • stream — Stream.Inherit, Stream.Pipe or Stream.Null

Returns

  • this Command

stderr

stderr(stream: Stream): Command

Sets where the child’s standard error goes.

Parameters

  • stream — Stream.Inherit, Stream.Pipe or Stream.Null

Returns

  • this Command

spawn

spawn(): io.IoResult<Child>

Starts the program and returns at once, without waiting for it.

Each stream that was not set is inherited. A piped stream is reached through the Child; close a piped standard input when done writing, or a child that reads to the end never finishes. Wait for the child with Child.wait(): a child that is never waited for stays in the process table until the parent ends.

Returns

  • the running child; an error when the program cannot be started: is_not_found() when it is not found, is_permission_denied() when it is not executable, or another platform error

Example

var child = process.Command("sort").stdin(process.Stream.Pipe).stdout(process.Stream.Pipe).spawn().value();
child.stdin().write_all("pear\napple\n");
child.stdin().close();
var sorted = child.stdout().read_string().value();   # "apple\npear\n"
child.wait();

run

run(): io.IoResult<Output>

Starts the program, waits for it to end, and returns how it ended with what it wrote.

Standard output and error are piped and collected unless they were set, and standard input is the null device unless it was set. Both outputs are read at once, so a child that fills one pipe while the parent reads the other does not stall. In a task, the reads and the wait park the task rather than blocking its worker.

Returns

  • the exit status and output; an error when the program cannot be started (as for spawn()) or its output cannot be read

Example

var output = process.Command("echo").argument("hello").run().value();
assert(output.status.success(), "echo succeeds");
assert(output.stdout == "hello\n", "and prints its argument");

exec

exec(): io.IoError

Replaces the running program with this one, as a shell’s exec does: the program receives this process’s signals, and its exit status becomes this process’s own. A launcher that only hands its command line on to another program, such as pepper, ends this way.

On POSIX the program takes over this process (execvp): it keeps the process id, the standard streams and every other descriptor not marked close-on-exec, and nothing after the call runs. Windows cannot replace a process, so there the program is started with the standard streams inherited and waited for, and this process then ends with its exit status, as process.exit does. While it waits, this process ignores Ctrl-C and Ctrl-Break, which the program, sharing the console, receives and handles itself.

Output print has buffered is flushed first. The standard streams must be left inherited: no process remains to hold a pipe’s other end. When the call fails, the process carries on unchanged, in its own working directory.

Returns

  • only when the program cannot be started: why, as for spawn(), or an error with is_invalid_input() when a standard stream was set to something other than Stream.Inherit

Example

import process;

main(args: String[]): int {
    # Run git with this program's arguments, as if it were git.
    var git = process.Command("git");
    ulong index = 1;
    while (index < args.count) {
        git.argument(args[index]);
        index += 1;
    }
    var error = git.exec();
    print($"cannot run git: {error.message}");
    return 1;
}

Child

A running child process (Child), the pipes to it, and how it ended (ExitStatus).

Source: runtime/process/child.pep

ExitStatus

struct ExitStatus

How a child process ended: with an exit code, or (on POSIX) killed by a signal.

Example

var status = process.Command("false").run().value().status;
assert(!status.success(), "false fails");
assert(status.code().value() == 1, "with code 1");

Constructor

ExitStatus()

construct(code: int = 0, signal: int = 0)

Creates an ExitStatus.

Parameters

  • code — the exit code, when the process exited
  • signal — the signal that ended the process, or 0 when it exited

Methods

success

success(): bool

Returns whether the process exited with code 0.

Returns

  • true for exit code 0

code

code(): int?

Returns the exit code: the value the process passed to exit or returned from main. On POSIX only its low 8 bits reach the parent, so it is from 0 to 255; on Windows it is the whole 32-bit code (an unhandled exception gives an NTSTATUS such as 0xC0000005, a negative number here).

Returns

  • the exit code, or none when a signal ended the process

signal

signal(): int?

Returns the signal that ended the process (POSIX only; a Windows process always has an exit code).

Returns

  • the signal number, such as 9 for SIGKILL, or none when the process exited

to_string

to_string(): String

Returns how the process ended as text.

Returns

  • exit code N or signal N

Child

[NotCopyable("owns a child process handle and cannot be copied")] struct Child

A child process started by Command.spawn(): its id, the pipes to the streams that were piped, and its exit status once it ends.

Wait for every child with wait() (or until try_wait() returns a status): on POSIX a child that ended but was never waited for stays in the process table until the parent ends. Releasing the last reference to a Child neither waits for it nor kills it; the process runs on.

Example

import process;

main(): int {
    var child = process.Command("sleep").argument("10").spawn().value();
    child.kill();
    var status = child.wait().value();
    assert(!status.success(), "killed");
    return 0;
}

Constructor

Child()

construct()

Creates a Child that stands for no process: id() is 0, its streams are closed, and wait(), try_wait() and kill() fail. Use Command.spawn() to start one.

Methods

id

id(): ulong

Returns the child’s process id, as the operating system knows it. After the child has been waited for, the system may give the id to a new process.

Returns

  • the process id

stdin

stdin(): io.File

Returns the pipe to the child’s standard input, for writing. Close it when done (close()), so that a child reading to the end sees the end.

Returns

  • the pipe, or a closed File when standard input was not Stream.Pipe

stdout

stdout(): io.File

Returns the pipe from the child’s standard output, for reading. A read returns 0 once the child has ended (or closed its standard output) and everything it wrote has been read.

Returns

  • the pipe, or a closed File when standard output was not Stream.Pipe

stderr

stderr(): io.File

Returns the pipe from the child’s standard error, for reading.

Returns

  • the pipe, or a closed File when standard error was not Stream.Pipe

wait

wait(): io.IoResult<ExitStatus>

Waits for the child to end and returns how it ended. Standard input, when piped, is closed first, so a child reading it to the end can finish. Waiting again returns the same status.

In a task, the wait parks the task (it runs on a run_blocking thread) rather than blocking its worker. Read piped output before waiting: a child stalls when a pipe it writes to is full.

Returns

  • the exit status, or an error from the system

try_wait

try_wait(): io.IoResult<ExitStatus?>

Returns how the child ended if it has, without waiting.

Returns

  • the exit status, or none while the child runs; or an error from the system

kill

kill(): io.IoResult<void>

Ends the child at once: SIGKILL on POSIX, TerminateProcess (with exit code 1) on Windows. The child still has to be waited for. Killing a child that has already been waited for does nothing.

Returns

  • Ok, or an error from the system

The running process

The running process: its arguments, id, executable and working directory, and how it ends.

Two functions are listed only here, since the module exports them under names the deprecated prelude functions still hold:

  • arguments(): Array<String> returns the program’s command-line arguments: element 0 is the program as it was started, and the arguments typed after it follow in order. main receives the same array when it is declared main(args: String[]): int. On Windows they are in the process’s ANSI code page.
  • exit(code: int) ends the program at once with status code (only its low 8 bits reach a POSIX parent), after flushing standard output and the other C output streams. Nothing else runs: locals are not released and destructors do not run.

Source: runtime/process/current.pep

fork

fork(): io.IoResult<Child?>

Starts a copy of the running process (POSIX fork): the copy has the same memory, open files and code, and both go on from here. In the parent, returns the copy as a Child to wait for; in the copy, returns none. The copy has only the calling thread, so fork before starting threads or tasks, and end the copy with exit().

The C standard streams are flushed first, so what print buffered is written once. Not on Windows or WASI, which start processes only from a program: there it returns an error.

Returns

  • the child in the parent, none in the copy, or an error

Example

import process;

main(): int {
    var forked = process.fork();
    if (forked.is_err()) {
        return 0;
    }
    if (!forked.value().is_value()) {
        process.exit(3);
    }
    var status = forked.value().value().wait().value();
    return status.code().value() == 3 ? 0 : 1;
}

id

id(): ulong

Returns the running process’s id, as the operating system knows it.

Returns

  • the process id (getpid on POSIX, GetCurrentProcessId on Windows)

executable_path

executable_path(): io.IoResult<io.Path>

Returns the path of the running program’s executable file, as an absolute path with symbolic links resolved (on Linux the target of /proc/self/exe; on macOS _NSGetExecutablePath, resolved; on Windows GetModuleFileNameW). A tool installed alongside others finds them next to it with executable_path().value().parent().

Returns

  • the executable’s path, or an error when the system cannot report it

Example

import process;

main(): int {
    var path = process.executable_path().value();
    print(path.to_string());
    return 0;
}

working_directory

working_directory(): io.IoResult<io.Path>

Returns the process’s current directory, against which relative paths are resolved. The same as io.Directory.current().

Returns

  • the current directory as an absolute path; an error when it cannot be determined, such as is_not_found() on POSIX when the directory has been removed

change_working_directory

change_working_directory(path: io.Path): io.IoResult<void>

Changes the process’s current directory. This affects the whole process, every thread included. The same as io.Directory.set_current.

Parameters

  • path — the new current directory; a relative path is resolved against the old one

Returns

  • an Ok result; an error when the directory cannot be entered: is_not_found() when it is missing, is_not_a_directory() when it is a file, or is_permission_denied()

Finding programs

Finding programs: find_executable looks a program up on the PATH, as a shell does before running it, find_companion looks beside the running program first, and is_executable tells whether a file can be run.

Source: runtime/process/find.pep

find_executable

find_executable(name: String): io.Path?

Returns the program name would run as a command: the first file named name in the directories PATH lists, in order, that this process may run. A name that holds a path separator is not looked up: it is returned when it names such a file itself. On Windows, a name with no extension also matches name.exe.

Parameters

  • name — the program’s file name, such as "perf"

Returns

  • the program’s path, or none when no directory on the PATH holds it

Example

import process;

main(): int {
    var git = process.find_executable("git");
    print(git.is_value() ? git.value().to_string() : "git is not installed");
    return 0;
}

is_executable

is_executable(path: io.Path): bool

Returns whether path is a file this process may run: on Linux and macOS, a file with an execute permission bit set; on Windows, any file.

Parameters

  • path — the file to check

Returns

  • true when path names a file that may be run

find_companion

find_companion(name: String): io.Path?

Returns the program name installed beside the running one, else the one find_executable finds on the PATH: how a tool finds the other tools of its suite, as pep-test finds pep-build. On Windows, .exe is added to name when looking beside the running program.

Parameters

  • name — the program’s file name, such as "pep-build"

Returns

  • the program’s path, or none when neither place holds it

Example

import process;

main(): int {
    var compiler = process.find_companion("pep-build");
    print(compiler.is_value() ? compiler.value().to_string() : "pep-build is not installed");
    return 0;
}

Signals

Signals: Ctrl-C, a request to terminate and a hangup, received as values on a Channel so a program can shut down cleanly.

signals() returns a channel that receives each signal it names. No program code runs inside an operating-system signal handler: on POSIX the handler only writes a byte to a pipe, and a runtime thread turns that byte into a channel message; on Windows the console control handler already runs on a thread of its own and sends the message itself.

Source: runtime/process/signals.pep

Signal

enum Signal

A signal a program can receive from signals().

Cases

Interrupt

Interrupt

Ctrl-C: POSIX SIGINT, or Windows CTRL_C_EVENT and CTRL_BREAK_EVENT.

Terminate

Terminate

A request to end: POSIX SIGTERM, or Windows CTRL_CLOSE_EVENT, CTRL_LOGOFF_EVENT and CTRL_SHUTDOWN_EVENT.

Hangup

Hangup

The terminal closed: POSIX SIGHUP. Windows never sends it.

signals

signals(wanted: Array<Signal>): async.Channel<Signal>

Returns a new channel that receives each of wanted when the process gets it, instead of the signal’s default action (ending the program).

Receive from it like any channel: receive() waits outside a task and suspends inside one, and a task can select on it. Every channel that names a signal receives it. Closing the channel stops it receiving (the runtime lets go of it at the next signal or signals() call); once no open channel names a signal, the signal’s default action returns.

Nothing runs inside an operating-system signal handler, so the program reacts to a signal only by receiving from the channel. On Windows, the system ends the program a few seconds after Terminate (the console closing, a log-off or a shutdown) whatever it does, so clean up quickly. WASI has no signals: the channel never receives.

Parameters

  • wanted — the signals the channel receives

Returns

  • the channel

Example

import process;
import [Signal] from process;

main(): int {
    var stop = process.signals([Signal.Interrupt, Signal.Terminate]);
    start_serving();
    stop.receive();
    print("shutting down");
    stop_serving();
    return 0;
}

The os module

What the operating system provides beyond the running process: environment variables, clocks and time, and the machine and user.

os is a module: bring it in with import os (and write os.environment("HOME")) or import names directly with import [Instant, Duration] from os.

  • Environment: environment(name), environment_variables(), set_environment(name, value) and remove_environment(name)
  • Time: Duration, Instant (a monotonic clock, for measuring) and SystemTime (the wall clock)
  • Machine: system_os(), system_name(), system_version(), architecture(), hostname(), processor counts, total_memory(), page_size()
  • User: user_name(), home_directory(), temp_directory(), is_elevated()

Example

import os;

main(): int {
    var start = os.Instant.now();
    var runtime = os.environment("PEPPER_RUNTIME").value_or("runtime");
    print($"runtime at {runtime} on {os.hostname()}");
    print($"took {os.Instant.now() - start}");
    return 0;
}

Source: runtime/os/module.pep

Environment

The running process’s environment variables: reading them, and setting and removing them for the process itself.

Every read and change the runtime makes goes through one lock, so Pepper code never races with itself over the environment, on any thread. C code that calls getenv itself is outside that lock: on POSIX setenv races with such a call on another thread, as in every language. To give only a child process a different environment, use process.Command’s environment, remove_environment and clear_environment instead.

Source: runtime/os/environment.pep

environment

environment(name: String): String?

Returns the value of the environment variable name, or none when it is not set.

A variable set to the empty string is some empty String, not none. On Windows names are not case-sensitive (Path finds PATH), and values are read as UTF-16 and converted to UTF-8, so text outside the ANSI code page survives.

Parameters

  • name — the variable’s name

Returns

  • its value, or none when it is not set

Example

import os;

main(): int {
    var home = os.environment("HOME");
    if (home) {
        print(home.value());
    }
    return 0;
}

set_environment

set_environment(name: String, value: String)

Sets the environment variable name to value for the running process, replacing any value it had.

The process’s own later reads see the change, and so do the children it starts afterwards. Safe from any thread for Pepper code; C code that calls getenv on another thread at the same time can race with it on POSIX (see this page’s introduction). On Windows names are not case-sensitive. WASI keeps the change inside the program.

Parameters

  • name — the variable’s name
  • value — its new value; may be empty

Panics

  • if name is empty or contains = or a NUL byte, or value contains a NUL byte

Example

import os;

main(): int {
    os.set_environment("GREETING", "hello");
    print(os.environment("GREETING").value());   # hello
    return 0;
}

remove_environment

remove_environment(name: String)

Removes the environment variable name from the running process.

Removing a variable that is not set does nothing. The same thread-safety note as set_environment’s applies.

Parameters

  • name — the variable’s name

Panics

  • if name is empty or contains = or a NUL byte

Example

import os;

main(): int {
    os.remove_environment("GREETING");
    print(os.environment("GREETING").is_empty());   # true
    return 0;
}

environment_variables

environment_variables(): HashMap<String, String>

Returns every environment variable of the running process, by name.

The map has no order. On Windows it includes the variables whose names start with =, such as =C:, which cmd.exe uses for each drive’s current directory; its keys keep the case the system reports, so look a name up with environment(name) to ignore case. When a POSIX environment holds one name twice, the first value wins, as getenv returns it.

Returns

  • a new map from each variable’s name to its value

Example

import os;

main(): int {
    for (var (name, value) in os.environment_variables()) {
        print($"{name}={value}");
    }
    return 0;
}

Time

Clocks and spans of time: Duration, Instant for measuring, and SystemTime for the wall clock.

Source: runtime/os/time.pep

Duration

[Value] struct Duration

A span of time, counted in nanoseconds, like Rust’s std::time::Duration. It is never negative and reaches about 584 years.

Make one with from_seconds, from_milliseconds, from_microseconds or from_nanoseconds, or by subtracting two Instants. A value struct: it is copied, compared with ==, and allocates nothing.

Example

import os;

main(): int {
    var timeout = os.Duration.from_seconds(2) + os.Duration.from_milliseconds(500);
    print(timeout.milliseconds());   # 2500
    print(timeout);                  # 2.5s
    return 0;
}

Constructor

Duration()

construct()

Makes a zero-length duration.

Static methods

from_nanoseconds

static from_nanoseconds(nanoseconds: ulong): Duration

Returns a duration of nanoseconds nanoseconds.

Parameters

  • nanoseconds — the length in nanoseconds

Returns

  • the duration

from_microseconds

static from_microseconds(microseconds: ulong): Duration

Returns a duration of microseconds microseconds.

Parameters

  • microseconds — the length in microseconds

Returns

  • the duration

Panics

  • if the duration does not fit in a ulong of nanoseconds

from_milliseconds

static from_milliseconds(milliseconds: ulong): Duration

Returns a duration of milliseconds milliseconds.

Parameters

  • milliseconds — the length in milliseconds

Returns

  • the duration

Panics

  • if the duration does not fit in a ulong of nanoseconds

from_seconds

static from_seconds(seconds: ulong): Duration

Returns a duration of seconds seconds.

Parameters

  • seconds — the length in seconds

Returns

  • the duration

Panics

  • if the duration does not fit in a ulong of nanoseconds (about 584 years)

Methods

nanoseconds

nanoseconds(): ulong

Returns the whole number of nanoseconds in this duration.

Returns

  • the length in nanoseconds

microseconds

microseconds(): ulong

Returns the whole number of microseconds in this duration, rounded down.

Returns

  • the length in microseconds

milliseconds

milliseconds(): ulong

Returns the whole number of milliseconds in this duration, rounded down.

Returns

  • the length in milliseconds

seconds

seconds(): ulong

Returns the whole number of seconds in this duration, rounded down.

Returns

  • the length in seconds

seconds_fraction

seconds_fraction(): double

Returns this duration in seconds, with the fraction.

Returns

  • the length in seconds, as a double

is_zero

is_zero(): bool

Returns whether this duration is zero.

Returns

  • true for a zero-length duration

operator +

+(other: Duration): Duration

Returns the sum of two durations.

Parameters

  • other — the duration to add

Returns

  • the longer duration

Panics

  • if the sum does not fit in a ulong of nanoseconds

operator -

-(other: Duration): Duration

Returns this duration less other.

Parameters

  • other — the duration to take away

Returns

  • the difference

Panics

  • if other is longer than this duration: a duration is never negative

operator *

*(times: ulong): Duration

Returns this duration times times over.

Parameters

  • times — the factor

Returns

  • the product

Panics

  • if the product does not fit in a ulong of nanoseconds

operator /

/(parts: ulong): Duration

Returns this duration split into parts equal parts, rounded down to a whole nanosecond.

Parameters

  • parts — the divisor

Returns

  • the quotient

Panics

  • if parts is zero

operator <

<(other: Duration): bool

Returns whether this duration is shorter than other.

Parameters

  • other — the duration to compare with

Returns

  • true when this one is shorter

operator <=

<=(other: Duration): bool

Returns whether this duration is shorter than or as long as other.

Parameters

  • other — the duration to compare with

Returns

  • true when this one is not longer

operator >

>(other: Duration): bool

Returns whether this duration is longer than other.

Parameters

  • other — the duration to compare with

Returns

  • true when this one is longer

operator >=

>=(other: Duration): bool

Returns whether this duration is longer than or as long as other.

Parameters

  • other — the duration to compare with

Returns

  • true when this one is not shorter

to_string

to_string(): String

Returns this duration as text in the largest unit it reaches, with the fraction it needs and no trailing zeros: 2.5s, 15ms, 1.25µs, 40ns, 0s.

Returns

  • the duration as text

Instant

[Value] struct Instant

A moment on a monotonic clock, for measuring how long something takes, like Rust’s std::time::Instant.

The clock only moves forward: setting the system’s date and time does not change it. Its readings mean nothing by themselves, only the Duration between two of them, and they cannot be compared across processes or reboots. Use SystemTime for the date and time.

The clock is CLOCK_MONOTONIC on Linux and macOS and QueryPerformanceCounter on Windows, read to the nanosecond.

Example

import os;

main(): int {
    var start = os.Instant.now();
    var total = 0;
    for (var i in 0..1000000) {
        total = total + i;
    }
    var elapsed = os.Instant.now() - start;
    print($"summed in {elapsed.milliseconds()} ms");
    return 0;
}

Constructor

Instant()

construct()

Makes the instant the clock started from; use now() to read the clock.

Static methods

now

static now(): Instant

Reads the monotonic clock.

Returns

  • the current moment

Methods

elapsed

elapsed(): Duration

Returns how long ago this instant was: Instant.now() - this.

Returns

  • the time since this instant

operator -

-(earlier: Instant): Duration

Returns the time from earlier to this instant.

Parameters

  • earlier — an instant no later than this one

Returns

  • the time between them

Panics

  • if earlier is later than this instant

operator +

+(duration: Duration): Instant

Returns the instant duration after this one.

Parameters

  • duration — how far to move forward

Returns

  • the later instant

Panics

  • if the result does not fit the clock

operator -

-(duration: Duration): Instant

Returns the instant duration before this one.

Parameters

  • duration — how far to move back

Returns

  • the earlier instant

Panics

  • if the result is before the clock’s start

operator <

<(other: Instant): bool

Returns whether this instant is earlier than other.

Parameters

  • other — the instant to compare with

Returns

  • true when this one is earlier

operator <=

<=(other: Instant): bool

Returns whether this instant is earlier than or the same as other.

Parameters

  • other — the instant to compare with

Returns

  • true when this one is not later

operator >

>(other: Instant): bool

Returns whether this instant is later than other.

Parameters

  • other — the instant to compare with

Returns

  • true when this one is later

operator >=

>=(other: Instant): bool

Returns whether this instant is later than or the same as other.

Parameters

  • other — the instant to compare with

Returns

  • true when this one is not earlier

SystemTime

[Value] struct SystemTime

A date and time on the system’s wall clock, counted in nanoseconds from the Unix epoch (1970-01-01 00:00:00 UTC), like Rust’s std::time::SystemTime. It reaches from the year 1677 to 2262.

The wall clock can jump, backwards too, when the system’s time is set, so measure how long something takes with Instant instead. The time module turns it into dates, times of day and time zones, and back.

Example

import os;

main(): int {
    var now = os.SystemTime.now();
    print($"{now.unix_seconds()} seconds since 1970");
    return 0;
}

Constructor

SystemTime()

construct()

Makes the Unix epoch, 1970-01-01 00:00:00 UTC.

Static methods

now

static now(): SystemTime

Reads the wall clock.

Returns

  • the current date and time

from_unix_nanoseconds

static from_unix_nanoseconds(nanoseconds: long): SystemTime

Returns the time nanoseconds nanoseconds after the Unix epoch.

Parameters

  • nanoseconds — nanoseconds since the epoch; negative for earlier times

Returns

  • the time

from_unix_seconds

static from_unix_seconds(seconds: long): SystemTime

Returns the time seconds seconds after the Unix epoch, as file systems and most protocols record it.

Parameters

  • seconds — seconds since the epoch; negative for earlier times

Returns

  • the time

Panics

  • if the time is outside the years 1677 to 2262

Methods

unix_nanoseconds

unix_nanoseconds(): long

Returns the nanoseconds from the Unix epoch to this time.

Returns

  • nanoseconds since the epoch; negative for earlier times

unix_milliseconds

unix_milliseconds(): long

Returns the milliseconds from the Unix epoch to this time, rounded down (towards the past).

Returns

  • milliseconds since the epoch; negative for earlier times

unix_seconds

unix_seconds(): long

Returns the seconds from the Unix epoch to this time, rounded down (towards the past).

Returns

  • seconds since the epoch; negative for earlier times

duration_since

duration_since(earlier: SystemTime): Duration?

Returns the time from earlier to this time, or none when earlier is later: the wall clock can be set back between two readings.

Parameters

  • earlier — the time to measure from

Returns

  • the time between them, or none when earlier is later

elapsed

elapsed(): Duration?

Returns how long ago this time was on the wall clock, or none when it is in the future.

Returns

  • the time since this one, or none when it is later than now

operator +

+(duration: Duration): SystemTime

Returns the time duration after this one.

Parameters

  • duration — how far to move forward

Returns

  • the later time

Panics

  • if the result is after the year 2262

operator -

-(duration: Duration): SystemTime

Returns the time duration before this one.

Parameters

  • duration — how far to move back

Returns

  • the earlier time

Panics

  • if the result is before the year 1677

operator <

<(other: SystemTime): bool

Returns whether this time is earlier than other.

Parameters

  • other — the time to compare with

Returns

  • true when this one is earlier

operator <=

<=(other: SystemTime): bool

Returns whether this time is earlier than or the same as other.

Parameters

  • other — the time to compare with

Returns

  • true when this one is not later

operator >

>(other: SystemTime): bool

Returns whether this time is later than other.

Parameters

  • other — the time to compare with

Returns

  • true when this one is later

operator >=

>=(other: SystemTime): bool

Returns whether this time is later than or the same as other.

Parameters

  • other — the time to compare with

Returns

  • true when this one is not earlier

Machine

The machine the program runs on: its operating system, processor, memory and name.

Source: runtime/os/machine.pep

OperatingSystem

enum OperatingSystem

An operating system Pepper runs on, with the names [Target(OS(...))] uses.

See also

  • system_os

Cases

Linux

Linux

Linux.

Mac

Mac

macOS.

Windows

Windows

Windows.

Wasi

Wasi

WASI: a WebAssembly runtime such as wasmtime (--target wasm32-wasip1).

IOS

IOS

iOS and iPadOS, on a device or in the simulator (--target aarch64-apple-ios). [Target(OS(Mac))] covers it too.

Android

Android

Android (--target aarch64-linux-android). [Target(OS(Linux))] covers it too.

system_os

system_os(): OperatingSystem

Returns the operating system the program was built for.

Returns

  • the operating system

Example

import os;

main(): int {
    var executable = os.system_os() == os.OperatingSystem.Windows ? "tool.exe" : "tool";
    print(executable);
    return 0;
}

system_name

system_name(): String

Returns the name of the operating system the program was built for, in lowercase: "linux", "macos", "windows", "wasi", "ios" or "android", for printing and for file and directory names.

Returns

  • the operating system’s name

See also

  • system_os

system_version

system_version(): String

Returns the operating system’s version: the kernel release on Linux (uname -r, such as 6.8.0-45-generic), the product version on macOS (sw_vers -productVersion, such as 14.5), and the major, minor and build numbers on Windows (such as 10.0.22631). WASI does not report one.

Returns

  • the version, or an empty String when the system does not report it

architecture

[Target(Arch(x64))] architecture(): String

Returns the processor architecture the program was built for: "x64", "aarch64" or "wasm32", the names [Target(Arch(...))] uses.

Returns

  • the architecture’s name

architecture

[Target(Arch(aarch64))] architecture(): String

Returns the processor architecture the program was built for: "x64", "aarch64" or "wasm32", the names [Target(Arch(...))] uses.

Returns

  • the architecture’s name

architecture

[Target(Arch(wasm32))] architecture(): String

Returns the processor architecture the program was built for: "x64", "aarch64" or "wasm32", the names [Target(Arch(...))] uses.

Returns

  • the architecture’s name

hostname

hostname(): String

Returns the machine’s host name, as hostname prints it (on Windows its DNS host name). WASI does not report one.

Returns

  • the host name, or an empty String when the system does not report it

logical_cpus

logical_cpus(): ulong

Returns the number of logical processors: every hardware thread, so a core with hyperthreading counts two or more times. On Linux a cgroup CPU quota (docker run --cpus) caps it, rounded up. The same as async.logical_cpus().

Returns

  • the number of logical processors, at least 1

See also

  • physical_cpus

physical_cpus

physical_cpus(): ulong

Returns the number of physical processor cores: hardware threads that share a core count once. The same as async.physical_cpus().

Returns

  • the number of physical cores, at least 1 and at most logical_cpus()

See also

  • logical_cpus

total_memory

total_memory(): ulong

Returns the machine’s physical memory in bytes. Inside a virtual machine this is what the guest sees. On Linux, a lower memory limit on the process’s cgroup or any cgroup above it (cgroup v2’s memory.max or v1’s memory.limit_in_bytes, as docker run --memory sets) is returned instead, as the JVM does. WASI does not report it.

Returns

  • the total physical memory, or the container’s memory limit when that is lower, in bytes; 0 when the system does not report it

page_size

page_size(): ulong

Returns the size of a page of virtual memory in bytes: the unit the operating system maps memory in, usually 4096 (16384 on Apple silicon; 65536, WebAssembly’s page, on WASI).

Returns

  • the page size in bytes

User

The user the program runs as: their name, directories, and whether they have administrator rights.

Source: runtime/os/user.pep

user_name

user_name(): String?

Returns the name of the user the program runs as: on POSIX the login name of the effective user id (from the user database, else USER), on Windows GetUserNameW’s answer (else USERNAME), and on WASI USER.

Returns

  • the user name, or none when it cannot be found

is_elevated

is_elevated(): bool

Returns whether the program runs with administrator rights: on POSIX, as root (effective user id 0); on Windows, from an elevated (“Run as administrator”) process of a member of Administrators (IsUserAnAdmin); never on WASI. Tools that need them, such as a profiler that reads kernel events, check this to say so up front.

Returns

  • true when the program runs as root or elevated

home_directory

home_directory(): io.Path?

Returns the user’s home directory: on POSIX HOME, or the user database’s entry when HOME is unset or empty; on Windows USERPROFILE; on WASI HOME.

Returns

  • the home directory, or none when it cannot be found

Example

import os;

main(): int {
    var home = os.home_directory();
    if (home) {
        print(home.value().join(".config").to_string());
    }
    return 0;
}

temp_directory

temp_directory(): io.Path

Returns the directory for temporary files: TMPDIR or /tmp on POSIX (/data/local/tmp on Android), GetTempPathW on Windows. The same as io.Directory.temp().

Returns

  • the temporary directory

The time module

Calendar time: dates, times of day, offsets from UTC, time zones, and formatting and parsing them, on the proleptic Gregorian calendar. Leap seconds are ignored, as in Unix time. os.SystemTime is the instant this module turns into dates and back.

time is a module: bring it in with import time (and write time.Date(2024, 2, 29)) or import names directly with import [Date, TimeZone] from time.

  • Dates and times: Date, Time, DateTime, Weekday, is_leap_year and days_in_month, with no time zone
  • Offsets: Offset, a fixed offset from UTC, and OffsetDateTime, an RFC 3339 timestamp, with HTTP dates
  • Time zones: TimeZone (UTC, fixed, local, or named from the system’s database), now() and now_utc()

Every type’s format(pattern) lays it out with C strftime codes:

CodeGivesCodeGives
%Yyear, at least four digits (2024)%Hhour, 00 to 23
%Ccentury (20)%Ihour, 01 to 12
%yyear in the century, 00 to 99%pAM or PM
%mmonth, 01 to 12%Mminute, 00 to 59
%dday, 01 to 31%Ssecond, 00 to 59
%eday, space-padded ( 1)%Nnanoseconds, nine digits; %3N milliseconds
%jday of the year, 001 to 366%T%H:%M:%S
%F%Y-%m-%d%R%H:%M
%aweekday, Mon%zoffset, +0100
%Aweekday, Monday%:zoffset, +01:00
%b, %hmonth, Jan%sUnix seconds
%Bmonth, January%uISO weekday, 1 (Monday) to 7
%GISO 8601 week-numbering year%wweekday, 0 (Sunday) to 6
%VISO 8601 week, 01 to 53%n, %t, %%newline, tab, %

Names are English, whatever the locale. %z, %:z and %s need an offset, so only OffsetDateTime.format takes them.

Example

import os;
import time;

main(): int {
    var now = time.now();
    print(now);                                  # 2024-03-01T09:00:00.25+01:00
    print(now.format("%A %e %B %Y, %H:%M"));     # Friday  1 March 2024, 09:00
    print(now.to_http_date());                   # Fri, 01 Mar 2024 08:00:00 GMT
    var date = time.Date.parse("2024-02-29").value();
    print(date.add_years(1));                    # 2025-02-28
    return 0;
}

Source: runtime/time/module.pep

Dates and times

Calendar dates and times of day with no time zone: Date, Time, DateTime and Weekday, on the proleptic Gregorian calendar, plus is_leap_year and days_in_month.

Source: runtime/time/civil.pep

is_leap_year

is_leap_year(year: int): bool

Returns whether year is a leap year on the Gregorian calendar: divisible by 4, except centuries not divisible by 400. Year 0 (1 BC) is one.

Parameters

  • year — the year

Returns

  • true when February of year has 29 days

Example

assert(time.is_leap_year(2024) && !time.is_leap_year(1900), "leap years");

days_in_month

days_in_month(year: int, month: int): int

Returns the number of days in month of year: 28 to 31.

Parameters

  • year — the year, for February
  • month — the month, 1 (January) to 12 (December)

Returns

  • the number of days in that month

Panics

  • if month is not from 1 to 12

Weekday

enum Weekday

A day of the week. Each case’s value is its ISO 8601 number, Monday 1 to Sunday 7, so day as int gives that number, and day.name() its English name.

Cases

Monday

Monday = 1

The first day of the ISO week.

Tuesday

Tuesday

The second day.

Wednesday

Wednesday

The third day.

Thursday

Thursday

The fourth day.

Friday

Friday

The fifth day.

Saturday

Saturday

The sixth day.

Sunday

Sunday

The seventh and last day of the ISO week.

Methods

to_string

to_string(): String

Returns the weekday’s English name, as name() does: Monday.

Returns

  • the name

Date

[Value] struct Date

A date on the proleptic Gregorian calendar, which extends the calendar’s rules to years before it was adopted: a year, a month (1 to 12) and a day of the month (1 to 31), with no time of day or time zone.

A value struct: copied, compared with ==, < and the other comparisons, and never allocated. Years run from -999999 to 999999; year 0 is 1 BC. Its text is ISO 8601’s 2024-02-29.

Example

import time;

main(): int {
    var day = time.Date(2024, 2, 28).add_days(1);
    print(day);               # 2024-02-29
    print(day.weekday());     # Thursday
    return 0;
}

Properties

year

var year: int { get; }

The year: 2024, or 0 for 1 BC and -1 for 2 BC.

month

var month: int { get; }

The month, 1 (January) to 12 (December).

day

var day: int { get; }

The day of the month, 1 to 31.

Constructor

Date()

construct(year: int = 1970, month: int = 1, day: int = 1)

Makes the date year-month-day; with no arguments, the Unix epoch’s 1970-01-01.

Parameters

  • year — the year, from -999999 to 999999
  • month — the month, 1 to 12
  • day — the day of the month, 1 to the month’s last day

Panics

  • if the parts do not name a date: use checked for parts that come from outside the program

Static methods

checked

static checked(year: int, month: int, day: int): Date?

Returns the date year-month-day, or none when the parts do not name one, such as February 30.

Parameters

  • year — the year, from -999999 to 999999
  • month — the month, 1 to 12
  • day — the day of the month

Returns

  • the date, or none

from_days

static from_days(days: long): Date

Returns the date days days after 1970-01-01.

Parameters

  • days — days since the Unix epoch; negative for earlier dates

Returns

  • the date

Panics

  • if the date is outside the years -999999 to 999999

parse

static parse(text: String): Result<Date>

Returns the date in the ISO 8601 form 2024-02-29, or none when text is not a date. Years outside 0 to 9999 take a sign and more digits (+10000-01-01, -0001-12-31).

Parameters

  • text — the text to read

Returns

  • the date, or an error saying what is wrong with text

Methods

days

days(): long

Returns the number of days from 1970-01-01 to this date.

Returns

  • days since the Unix epoch; negative for earlier dates

weekday

weekday(): Weekday

Returns the day of the week.

Returns

  • the weekday

day_of_year

day_of_year(): int

Returns the day of the year, 1 for January 1 to 366 for December 31 of a leap year.

Returns

  • the ordinal day

is_leap_year

is_leap_year(): bool

Returns whether this date’s year is a leap year.

Returns

  • true when the year has a February 29

days_in_month

days_in_month(): int

Returns the number of days in this date’s month.

Returns

  • 28 to 31

add_days

add_days(days: long): Date

Returns the date days days later, or earlier when days is negative.

Parameters

  • days — the number of days to move

Returns

  • the date

Panics

  • if the date is outside the years -999999 to 999999

add_months

add_months(months: long): Date

Returns the date months months later, or earlier when months is negative. A day past the end of the new month becomes its last day: a month after January 31 is February 28 or 29.

Parameters

  • months — the number of months to move

Returns

  • the date

Panics

  • if the date is outside the years -999999 to 999999

add_years

add_years(years: long): Date

Returns the date years years later, or earlier when years is negative. February 29 becomes February 28 in a year that is not a leap year.

Parameters

  • years — the number of years to move

Returns

  • the date

Panics

  • if the date is outside the years -999999 to 999999

days_since

days_since(earlier: Date): long

Returns the number of days from earlier to this date: negative when earlier is later.

Parameters

  • earlier — the date to count from

Returns

  • the number of days between them

at

at(time: Time = Time()): DateTime

Returns the start of this date, midnight, as a DateTime.

Parameters

  • time — the time of day; midnight by default

Returns

  • the date and time

operator <

<(other: Date): bool

Returns whether this date is earlier than other.

Parameters

  • other — the date to compare with

Returns

  • true when this one is earlier

operator <=

<=(other: Date): bool

Returns whether this date is earlier than or the same as other.

Parameters

  • other — the date to compare with

Returns

  • true when this one is not later

operator >

>(other: Date): bool

Returns whether this date is later than other.

Parameters

  • other — the date to compare with

Returns

  • true when this one is later

operator >=

>=(other: Date): bool

Returns whether this date is later than or the same as other.

Parameters

  • other — the date to compare with

Returns

  • true when this one is not earlier

format

format(pattern: String): String

Returns the date as text laid out by pattern, whose % codes are those of C’s strftime (see the time module). The time of day is midnight, and an offset code (%z) panics.

Parameters

  • pattern — the layout, such as "%d %B %Y"

Returns

  • the formatted date

Panics

  • if pattern has an unknown code, or an offset code

to_string

to_string(): String

Returns the date in ISO 8601 form: 2024-02-29.

Returns

  • the date as text

Time

[Value] struct Time

A time of day with no date or time zone: an hour (0 to 23), a minute, a second and a nanosecond. Leap seconds are not represented, as in Unix time.

A value struct: copied, compared with ==, < and the other comparisons, and never allocated. Its text is ISO 8601’s 08:30:00, with a fraction of a second when there is one (08:30:00.25).

Example

import time;

main(): int {
    var lunch = time.Time(12, 30);
    print(lunch);   # 12:30:00
    return 0;
}

Properties

hour

var hour: int { get; }

The hour, 0 to 23.

minute

var minute: int { get; }

The minute, 0 to 59.

second

var second: int { get; }

The second, 0 to 59.

nanosecond

var nanosecond: int { get; }

The nanosecond within the second, 0 to 999999999.

Constructor

Time()

construct(hour: int = 0, minute: int = 0, second: int = 0, nanosecond: int = 0)

Makes the time hour:minute:second; with no arguments, midnight.

Parameters

  • hour — the hour, 0 to 23
  • minute — the minute, 0 to 59
  • second — the second, 0 to 59
  • nanosecond — the nanosecond within the second, 0 to 999999999

Panics

  • if a part is out of range: use checked for parts that come from outside the program

Static methods

checked

static checked(hour: int, minute: int, second: int = 0, nanosecond: int = 0): Time?

Returns the time hour:minute:second, or none when a part is out of range.

Parameters

  • hour — the hour, 0 to 23
  • minute — the minute, 0 to 59
  • second — the second, 0 to 59
  • nanosecond — the nanosecond within the second

Returns

  • the time, or none

from_nanoseconds

static from_nanoseconds(nanoseconds: long): Time

Returns the time nanoseconds nanoseconds after midnight.

Parameters

  • nanoseconds — nanoseconds since midnight, less than a day’s

Returns

  • the time

Panics

  • if nanoseconds is negative or a day or more

parse

static parse(text: String): Result<Time>

Returns the time in the ISO 8601 form 08:30:00, with an optional fraction of a second (08:30:00.25), or an error when text is not a time. The seconds may be left out (08:30), as TOML 1.1 allows.

Parameters

  • text — the text to read

Returns

  • the time, or an error saying what is wrong with text

Methods

nanoseconds

nanoseconds(): long

Returns the number of nanoseconds since midnight.

Returns

  • nanoseconds since midnight

seconds

seconds(): long

Returns the number of whole seconds since midnight.

Returns

  • seconds since midnight

operator <

<(other: Time): bool

Returns whether this time is earlier in the day than other.

Parameters

  • other — the time to compare with

Returns

  • true when this one is earlier

operator <=

<=(other: Time): bool

Returns whether this time is earlier than or the same as other.

Parameters

  • other — the time to compare with

Returns

  • true when this one is not later

operator >

>(other: Time): bool

Returns whether this time is later in the day than other.

Parameters

  • other — the time to compare with

Returns

  • true when this one is later

operator >=

>=(other: Time): bool

Returns whether this time is later than or the same as other.

Parameters

  • other — the time to compare with

Returns

  • true when this one is not earlier

format

format(pattern: String): String

Returns the time as text laid out by pattern, whose % codes are those of C’s strftime (see the time module). The date is 1970-01-01, and an offset code (%z) panics.

Parameters

  • pattern — the layout, such as "%H:%M"

Returns

  • the formatted time

Panics

  • if pattern has an unknown code, or an offset code

to_string

to_string(): String

Returns the time in ISO 8601 form: 08:30:00, with the fraction of a second when it is not zero, without trailing zeros (08:30:00.25).

Returns

  • the time as text

DateTime

[Value] struct DateTime

A date and a time of day with no time zone, such as a meeting’s 2024-03-01 09:00 written down without saying where. TOML calls it a local date-time. Attach an offset with OffsetDateTime, or a zone’s rules with TimeZone.at_local, to name an instant.

A value struct: copied, compared with ==, < and the other comparisons, and never allocated. Its text is ISO 8601’s 2024-03-01T09:00:00.

Example

import time;

main(): int {
    var start = time.DateTime(2024, 3, 1, 9, 0);
    var end = start + os.Duration.from_seconds(90 * 60);
    print(end);   # 2024-03-01T10:30:00
    return 0;
}

Properties

date

var date: Date { get; }

The date.

time

var time: Time { get; }

The time of day.

year

var year: int { get; }

The year, as date.year.

month

var month: int { get; }

The month, 1 to 12, as date.month.

day

var day: int { get; }

The day of the month, as date.day.

hour

var hour: int { get; }

The hour, 0 to 23, as time.hour.

minute

var minute: int { get; }

The minute, as time.minute.

second

var second: int { get; }

The second, as time.second.

nanosecond

var nanosecond: int { get; }

The nanosecond within the second, as time.nanosecond.

Constructor

DateTime()

construct( year: int = 1970, month: int = 1, day: int = 1, hour: int = 0, minute: int = 0, second: int = 0, nanosecond: int = 0 )

Makes the date and time from its parts; with no arguments, the Unix epoch’s 1970-01-01T00:00:00.

Parameters

  • year — the year, from -999999 to 999999
  • month — the month, 1 to 12
  • day — the day of the month
  • hour — the hour, 0 to 23
  • minute — the minute, 0 to 59
  • second — the second, 0 to 59
  • nanosecond — the nanosecond within the second

Panics

  • if the parts do not name a date and time

Static methods

combine

static combine(date: Date, time: Time): DateTime

Returns date at time.

Parameters

  • date — the date
  • time — the time of day

Returns

  • the date and time

from_unix_nanoseconds

static from_unix_nanoseconds(nanoseconds: long): DateTime

Returns the date and time nanoseconds nanoseconds after 1970-01-01T00:00:00, counting every day as 86400 seconds.

Parameters

  • nanoseconds — nanoseconds since the epoch; negative for earlier

Returns

  • the date and time

from_unix_seconds

static from_unix_seconds(seconds: long, nanosecond: int = 0): DateTime

Returns the date and time seconds seconds after 1970-01-01T00:00:00, counting every day as 86400 seconds, as Unix time does. Reaches further than a SystemTime.

Parameters

  • seconds — seconds since the epoch; negative for earlier
  • nanosecond — the nanosecond within the second

Returns

  • the date and time

Panics

  • if nanosecond is not from 0 to 999999999, or the date is outside the years -999999 to 999999

from_system_time

static from_system_time(time: os.SystemTime): DateTime

Returns the date and time in UTC of a wall-clock reading.

Parameters

  • time — the instant

Returns

  • its date and time in UTC

parse

static parse(text: String): Result<DateTime>

Returns the date and time in the ISO 8601 form 2024-03-01T09:00:00, or an error when text is not one. A space or a lowercase t may stand for the T, as TOML allows, and the seconds may be left out.

Parameters

  • text — the text to read

Returns

  • the date and time, or an error saying what is wrong with text

Methods

unix_seconds

unix_seconds(): long

Returns the number of seconds from 1970-01-01T00:00:00 to this date and time, rounded down, counting every day as 86400 seconds.

Returns

  • seconds since the epoch; negative for earlier

to_system_time

to_system_time(): os.SystemTime

Returns this date and time as a SystemTime, reading it as UTC.

Returns

  • the instant

Panics

  • if the date is outside what a SystemTime reaches, the years 1677 to 2262

weekday

weekday(): Weekday

Returns the day of the week.

Returns

  • the weekday

operator +

+(duration: os.Duration): DateTime

Returns the date and time duration later.

Parameters

  • duration — how far to move forward

Returns

  • the later date and time

Panics

  • if the result is past the year 999999

operator -

-(duration: os.Duration): DateTime

Returns the date and time duration earlier.

Parameters

  • duration — how far to move back

Returns

  • the earlier date and time

Panics

  • if the result is before the year -999999

operator <

<(other: DateTime): bool

Returns whether this date and time is earlier than other.

Parameters

  • other — the date and time to compare with

Returns

  • true when this one is earlier

operator <=

<=(other: DateTime): bool

Returns whether this date and time is earlier than or the same as other.

Parameters

  • other — the date and time to compare with

Returns

  • true when this one is not later

operator >

>(other: DateTime): bool

Returns whether this date and time is later than other.

Parameters

  • other — the date and time to compare with

Returns

  • true when this one is later

operator >=

>=(other: DateTime): bool

Returns whether this date and time is later than or the same as other.

Parameters

  • other — the date and time to compare with

Returns

  • true when this one is not earlier

format

format(pattern: String): String

Returns the date and time as text laid out by pattern, whose % codes are those of C’s strftime (see the time module). An offset code (%z) panics.

Parameters

  • pattern — the layout, such as "%Y-%m-%d %H:%M"

Returns

  • the formatted date and time

Panics

  • if pattern has an unknown code, or an offset code

to_string

to_string(): String

Returns the date and time in ISO 8601 form: 2024-03-01T09:00:00, with the fraction of a second when it is not zero.

Returns

  • the date and time as text

Offsets

Fixed offsets from UTC, Offset, and the date and time at one, OffsetDateTime: an instant written the way RFC 3339 and TOML write it.

Source: runtime/time/offset.pep

Offset

[Value] struct Offset

A fixed offset from UTC, east positive: +02:00 for Central European Summer Time, -05:00 for US Eastern Standard Time, +00:00 for UTC.

A value struct: copied, compared with ==, and never allocated. It is less than a day either way, to the second. Its text is +02:00, with the seconds when there are any (+00:19:32).

Example

import time;

main(): int {
    var india = time.Offset.from_hours(5, 30);
    print(india);            # +05:30
    print(india.seconds);    # 19800
    return 0;
}

Properties

seconds

var seconds: int { get; }

The offset in seconds, east of UTC positive.

Constructor

Offset()

construct(seconds: int = 0)

Makes the offset seconds seconds east of UTC (west when negative); with no argument, UTC itself.

Parameters

  • seconds — the offset in seconds, east positive

Panics

  • if the offset is a day or more either way

Static methods

utc

static utc(): Offset

Returns UTC, the zero offset.

Returns

  • the zero offset

from_hours

static from_hours(hours: int, minutes: int = 0): Offset

Returns the offset hours hours and minutes minutes from UTC. Both take the offset’s sign: from_hours(-3, -30) is -03:30, and from_hours(0, -30) is -00:30.

Parameters

  • hours — whole hours, east positive
  • minutes — minutes on top of the hours, with the same sign

Returns

  • the offset

Panics

  • if the offset is a day or more either way

parse

static parse(text: String): Result<Offset>

Returns the offset written as +02:00, -0530, +02 or Z (UTC), or an error when text is not one.

Parameters

  • text — the text to read

Returns

  • the offset, or an error saying what is wrong with text

Methods

is_utc

is_utc(): bool

Returns whether this is UTC, the zero offset.

Returns

  • true for +00:00

to_string

to_string(): String

Returns the offset as text: +02:00, -05:30, +00:00, with the seconds when there are any (+00:19:32).

Returns

  • the offset as text

OffsetDateTime

[Value] struct OffsetDateTime

A date and time at a fixed offset from UTC, such as 2024-03-01T09:00:00+01:00: an instant, together with the local date and time it was written in. This is RFC 3339’s timestamp and TOML’s offset date-time.

A value struct: copied and never allocated. == compares the date, time and offset, so the same instant at two offsets is not ==; compare instants with to_system_time(), or with < and the other comparisons, which compare instants. Its text is RFC 3339’s, with Z for UTC.

Example

import time;

main(): int {
    var meeting = time.OffsetDateTime.parse("2024-03-01T09:00:00+01:00").value();
    print(meeting.to_utc());   # 2024-03-01T08:00:00Z
    print(meeting.to_http_date());   # Fri, 01 Mar 2024 08:00:00 GMT
    return 0;
}

Properties

date_time

var date_time: DateTime { get; }

The local date and time at offset.

offset

var offset: Offset { get; }

The offset from UTC that date_time is at.

date

var date: Date { get; }

The local date, as date_time.date.

time

var time: Time { get; }

The local time of day, as date_time.time.

Constructor

OffsetDateTime()

construct(date_time: DateTime = DateTime(), offset: Offset = Offset())

Makes the date and time date_time at offset; with no arguments, the Unix epoch, 1970-01-01T00:00:00Z.

Parameters

  • date_time — the local date and time
  • offset — its offset from UTC

Static methods

from_system_time

static from_system_time(time: os.SystemTime, offset: Offset = Offset()): OffsetDateTime

Returns the date and time of a wall-clock reading at offset.

Parameters

  • time — the instant
  • offset — the offset to show it at; UTC by default

Returns

  • the instant’s date and time at offset

parse

static parse(text: String): Result<OffsetDateTime>

Returns the RFC 3339 timestamp in text, such as 2024-03-01T09:00:00+01:00 or 2024-03-01T08:00:00.5Z, or an error when it is not one. A space or a lowercase t may stand for the T and a lowercase z for the Z, and the seconds may be left out, as TOML allows.

Parameters

  • text — the text to read

Returns

  • the date and time, or an error saying what is wrong with text

parse_http_date

static parse_http_date(text: String): Result<OffsetDateTime>

Returns the HTTP date in text, as RFC 9110 defines it, or an error when it is not one. It reads the preferred IMF-fixdate (Sun, 06 Nov 1994 08:49:37 GMT) and the two obsolete forms that servers must still accept: RFC 850’s (Sunday, 06-Nov-94 08:49:37 GMT, where a two-digit year from 70 is in the 1900s and below 70 in the 2000s) and C’s asctime (Sun Nov 6 08:49:37 1994). The weekday must match the date.

Parameters

  • text — the text to read

Returns

  • the date and time in UTC, or an error saying what is wrong with text

Methods

unix_seconds

unix_seconds(): long

Returns the seconds from the Unix epoch to this instant, rounded down.

Returns

  • seconds since 1970-01-01T00:00:00Z; negative for earlier

to_system_time

to_system_time(): os.SystemTime

Returns this instant as a SystemTime.

Returns

  • the instant

Panics

  • if the instant is outside what a SystemTime reaches, the years 1677 to 2262

to_offset

to_offset(offset: Offset): OffsetDateTime

Returns the same instant at another offset.

Parameters

  • offset — the offset to show the instant at

Returns

  • the date and time at offset

to_utc

to_utc(): OffsetDateTime

Returns the same instant in UTC.

Returns

  • the date and time at offset zero

weekday

weekday(): Weekday

Returns the day of the week of the local date.

Returns

  • the weekday

operator +

+(duration: os.Duration): OffsetDateTime

Returns the instant duration later, at the same offset.

Parameters

  • duration — how far to move forward

Returns

  • the later date and time

operator -

-(duration: os.Duration): OffsetDateTime

Returns the instant duration earlier, at the same offset.

Parameters

  • duration — how far to move back

Returns

  • the earlier date and time

operator <

<(other: OffsetDateTime): bool

Returns whether this instant is earlier than other, whatever their offsets.

Parameters

  • other — the instant to compare with

Returns

  • true when this one is earlier

operator <=

<=(other: OffsetDateTime): bool

Returns whether this instant is earlier than or the same as other.

Parameters

  • other — the instant to compare with

Returns

  • true when this one is not later

operator >

>(other: OffsetDateTime): bool

Returns whether this instant is later than other, whatever their offsets.

Parameters

  • other — the instant to compare with

Returns

  • true when this one is later

operator >=

>=(other: OffsetDateTime): bool

Returns whether this instant is later than or the same as other.

Parameters

  • other — the instant to compare with

Returns

  • true when this one is not earlier

format

format(pattern: String): String

Returns the date and time as text laid out by pattern, whose % codes are those of C’s strftime (see the time module). %z is the offset as +0200, %:z as +02:00, and %s the Unix seconds.

Parameters

  • pattern — the layout, such as "%Y-%m-%d %H:%M %z"

Returns

  • the formatted date and time

Panics

  • if pattern has an unknown code

to_http_date

to_http_date(): String

Returns the instant as an HTTP date in UTC, the IMF-fixdate of RFC 9110 that Date and Last-Modified headers carry: Fri, 01 Mar 2024 08:00:00 GMT. The fraction of a second is dropped.

Returns

  • the date as an HTTP date

to_string

to_string(): String

Returns the date and time as an RFC 3339 timestamp: 2024-03-01T09:00:00+01:00, Z for UTC, with the fraction of a second when it is not zero.

Returns

  • the date and time as text

Time zones

Time zones: TimeZone, the rules that give an instant’s offset from UTC, for UTC, a fixed offset, the system’s local zone, or a named zone such as Europe/Paris from the system’s time zone database; and now().

Source: runtime/time/zone.pep

TimeZone

struct TimeZone

The rules that give the offset from UTC at each instant in one place: UTC, a fixed offset, the system’s local time zone, or a named zone from the IANA time zone database, such as Europe/Paris or America/New_York, with its daylight saving time and its history.

Named zones come from the system’s database, nothing is bundled: the files under /usr/share/zoneinfo on Linux and macOS, and ICU’s (icu.dll, Windows 10 version 1903 and later) on Windows. On every system, a folder of database files that the TZDIR environment variable names takes their place. Leap seconds are ignored, as in Unix time.

A zone is a reference, and it does not change once made, so copies share it.

Example

import os;
import time;

main(): int {
    var paris = time.TimeZone.named("Europe/Paris").value();
    var instant = time.OffsetDateTime.parse("2024-07-14T10:00:00Z").value();
    print(paris.at(instant.to_system_time()));   # 2024-07-14T12:00:00+02:00
    var local = paris.at_local(time.DateTime(2024, 12, 25, 18, 0));
    print(local.offset);                         # +01:00
    return 0;
}

Properties

name

var name: String { get; }

The zone’s name: UTC, Local, the offset of a fixed zone (+05:30), or the database name of a named one (Europe/Paris).

Constructor

TimeZone()

construct()

Makes UTC; the same as TimeZone.utc().

Static methods

utc

static utc(): TimeZone

Returns UTC.

Returns

  • the zone whose offset is always zero

fixed

static fixed(offset: Offset): TimeZone

Returns a zone whose offset is always offset.

Parameters

  • offset — the offset from UTC

Returns

  • the zone, named after the offset (+05:30)

local

static local(): TimeZone

Returns the system’s local time zone, as the C library’s localtime sees it: the TZ environment variable or /etc/localtime on Linux and macOS, and the zone in Windows’ settings. WebAssembly (WASI) has no local zone, so it is UTC there.

Returns

  • the local zone, named Local

named

static named(name: String): Result<TimeZone>

Returns the zone name from the system’s time zone database, such as Europe/Paris, America/New_York or Asia/Kolkata, or an error when the system does not know it. UTC is always known.

Parameters

  • name — the IANA name of the zone, which is case-sensitive

Returns

  • the zone, or an error saying it was not found

Methods

offset_at

offset_at(time: os.SystemTime): Offset

Returns the offset from UTC in this zone at time.

Parameters

  • time — the instant

Returns

  • the offset in effect then

at

at(time: os.SystemTime): OffsetDateTime

Returns the date and time in this zone at time.

Parameters

  • time — the instant

Returns

  • its local date and time, with the offset in effect

Example

var here = time.TimeZone.local().at(os.SystemTime.now());

at_local

at_local(local: DateTime): OffsetDateTime

Returns the instant that a local date and time names in this zone, with its offset.

Where the clocks go back, a local time happens twice; this returns the earlier, at the offset from before the change. Where they go forward, a local time never happens; this moves it forward by the length of the gap, so 02:30 on a night that skips from 02:00 to 03:00 is 03:30. This is what Java’s ZonedDateTime.of does.

Parameters

  • local — the date and time on the zone’s clocks

Returns

  • the date and time with the zone’s offset at that instant

now

now(): OffsetDateTime

Returns the current date and time in the system’s local time zone.

Returns

  • now, at the local offset

Example

print(time.now());   # such as 2024-03-01T09:00:00.123456789+01:00

now_utc

now_utc(): OffsetDateTime

Returns the current date and time in UTC.

Returns

  • now, at offset zero

The math module

Numbers beyond the operators: the math library as it grows.

Bring it in with import math. The floating-point functions (sine, logarithm, cube_root, is_nan, …) are methods of float and double, which every program has without an import; this module holds the constants and the integer functions.

  • Constants: PI, TAU, E, INFINITY, NAN, and the epsilon and limits of float and double
  • Integers: gcd and lcm
  • Parsing: parse_int, deprecated: every number type has a static parse (long.parse(text, base)) without an import

Example

import math;
var radius = 2.0;
var area = math.PI * radius ** 2;
assert((math.PI / 2).sine() == 1.0, "a quarter turn");

Source: runtime/math/module.pep

Constants

Constants for floating-point math: π, e, the infinities and NaN, and the limits of float and double.

Each is a double unless its name starts with FLOAT_; write math.PI as float for a float.

Source: runtime/math/constants.pep

PI

const PI: double = 3.141592653589793

π, the ratio of a circle’s circumference to its diameter.

TAU

const TAU: double = 6.283185307179586

τ = 2π, the angle of a full turn in radians.

E

const E: double = 2.718281828459045

e, the base of the natural logarithm.

INFINITY

const INFINITY: double = 1.0 / 0.0

Positive infinity; -math.INFINITY is negative infinity.

NAN

const NAN: double = 0.0 / 0.0

A NaN (not a number). It is not equal to anything, itself included, so test for it with is_nan(), never ==.

DOUBLE_EPSILON

const DOUBLE_EPSILON: double = 2.220446049250313e-16

The gap between 1 and the next larger double, 2^-52.

DOUBLE_MAX

const DOUBLE_MAX: double = 1.7976931348623157e308

The largest finite double.

DOUBLE_MIN_POSITIVE

const DOUBLE_MIN_POSITIVE: double = 2.2250738585072014e-308

The smallest positive normal double, 2^-1022.

FLOAT_EPSILON

const FLOAT_EPSILON: float = 1.1920928955078125e-7

The gap between 1 and the next larger float, 2^-23.

FLOAT_MAX

const FLOAT_MAX: float = 3.4028234663852886e38

The largest finite float.

FLOAT_MIN_POSITIVE

const FLOAT_MIN_POSITIVE: float = 1.1754943508222875e-38

The smallest positive normal float, 2^-126.

Integers

Greatest common divisors and least common multiples of integers.

For integer powers, use **: 3 ** 4 is 81, and it panics on overflow.

Source: runtime/math/integers.pep

gcd

gcd(a: long, b: long): long

Returns the greatest common divisor of a and b: the largest integer that divides both. It is never negative, and gcd(0, 0) is 0.

Parameters

  • a — the first integer
  • b — the second integer

Returns

  • the greatest common divisor

Panics

  • if a or b is the smallest long, whose magnitude does not fit

Example

assert(math.gcd(12, -18) == 6, "divides both");

lcm

lcm(a: long, b: long): long

Returns the least common multiple of a and b: the smallest non-negative integer both divide. It is 0 when either is 0.

Parameters

  • a — the first integer
  • b — the second integer

Returns

  • the least common multiple

Panics

  • if the result does not fit a long

Example

assert(math.lcm(4, 6) == 12, "both divide 12");

Parsing integers

Reading integers from text.

Source: runtime/math/parse.pep

parse_int

[Deprecated("use 'long.parse' instead")] parse_int(text: String, base: int = 10): long?

Deprecated: use long.parse instead, which every program has without an import (each number type has a static parse).

Parses text as an integer written in base: an optional + or -, then one or more digits, and nothing else.

Parameters

  • text — the text to parse
  • base — the base the digits are written in, from 2 to 36; 10 by default

Returns

  • the value, or none when text is not an integer in base or the value does not fit a long

Panics

  • if base is less than 2 or greater than 36

The hint module

Hints to the optimizer about memory a loop will soon read or write.

Bring it in with import hint. A hint never changes what a program computes, only how fast: a prefetch starts loading a cache line early. Where the CPU has no prefetch instruction it does nothing.

To say which way a branch usually goes, write [Likely] or [Unlikely] above the if or match arm; likely and unlikely here are deprecated.

Example

import hint;

main(): int {
    var text = "a long text that a loop will read";
    hint.prefetch_read(text.c_str());
    assert(text.length > 0, "a prefetch changes nothing");
    return 0;
}

Source: runtime/hint/module.pep

likely

[Deprecated("write '[Likely]' above the 'if' instead")] likely(condition: bool): bool

Deprecated: write [Likely] above the if instead.

Returns condition, telling the optimizer it is usually true (llvm.expect).

Parameters

  • condition — the condition to test

Returns

  • condition

unlikely

[Deprecated("write '[Unlikely]' above the 'if' instead")] unlikely(condition: bool): bool

Deprecated: write [Unlikely] above the if instead.

Returns condition, telling the optimizer it is usually false (llvm.expect).

Parameters

  • condition — the condition to test

Returns

  • condition

prefetch_read

prefetch_read(address: __ptr<void>)

Starts loading the cache line holding address for reading, so a read of it soon after does not wait on memory (llvm.prefetch). It never faults, even for an address that is not mapped, and does nothing where the CPU has no prefetch instruction.

Parameters

  • address — memory that will be read soon

Example

hint.prefetch_read(text.c_str());

prefetch_write

prefetch_write(address: __ptr<void>)

Starts loading the cache line holding address for writing, so a store to it soon after does not wait on memory (llvm.prefetch). It never faults, even for an address that is not mapped, and does nothing where the CPU has no prefetch instruction.

Parameters

  • address — memory that will be written soon

The simd module

SIMD vectors: Vector<T, N> holds N numbers of type T and computes on all of them at once, in the CPU’s vector registers.

Bring it in with import simd. T is an integer or floating-point type (byte, char, short, ushort, int, uint, long, ulong, float, double) and N, the lane count, is a power of two from 1 to 64, written as a number: simd.Vector<byte, 16>. A vector is a value ([Value]): it is stored inline, copied like a number, and safe to share between threads.

The operations compile to the target’s vector instructions: SSE2 on x86-64 (AVX2 with -march=x86-64-v3), NEON on arm64, and simd128 on WebAssembly with -msimd128. A target without vector registers gets the same results from scalar code. Sixteen bytes (Vector<byte, 16>, Vector<int, 4>, Vector<double, 2>) is the width every target handles well; wider vectors use several registers where the target has no wider ones.

Lane-wise comparisons (less, equal, …) give a Mask<N>, which selects lanes (select), answers any and all, and packs into a bit mask with to_bits, whose trailing_zeros finds the first match.

Example

import simd;

main(): int {
    var a = simd.Vector<int, 4>(10);
    var b = a + simd.Vector<int, 4>(5);
    assert(b[0] == 15 && b.sum() == 60, "lane-wise add");
    var over = b.greater(12);
    assert(over.all() && over.to_bits() == 0xF, "a mask per lane");
    return 0;
}

Source: runtime/simd/module.pep

Lane

trait Lane

The number types a Vector holds: every integer and floating-point type. The runtime declares the conformances; another type cannot be a lane.

IntegerLane

trait IntegerLane

The integer lane types, whose vectors have checked division, the bitwise operators, shifts and rotates.

FloatLane

trait FloatLane

The floating-point lane types, whose vectors have floating-point division, square_root, floor and ceiling.

Vector

[Value] struct Vector<T: Lane, N>

N lanes of the number type T, computed on together.

Integer +, - and * wrap around on overflow in each lane, as vector instructions do, where scalar arithmetic panics. Integer / and % panic on a zero divisor lane; shifts panic on a count outside the lane’s width. Every other operation is defined for every input.

Type parameters

  • T — the lane type: an integer or floating-point type
  • N — the lane count: a power of two from 1 to 64

Properties

count

var count: int { get; }

The number of lanes, N.

Constructors

Vector()

construct()

Makes a vector with every lane 0.

Vector()

construct(value: T)

Makes a vector with every lane value.

Parameters

  • value — the value of every lane

Static methods

load

static load(pointer: __ptr<T>): Vector<T, N>

Reads N values from pointer, which need not be aligned.

Parameters

  • pointer — the first of N readable values

Returns

  • a vector of the values, the first in lane 0

load_aligned

static load_aligned(pointer: __ptr<T>): Vector<T, N>

Reads N values from pointer, which must be aligned to the vector’s size (N times the size of T).

Parameters

  • pointer — the first of N readable values

Returns

  • a vector of the values, the first in lane 0

Panics

  • if pointer is not aligned to the vector’s size

load

static load(array: Array<T>, index: ulong): Vector<T, N>

Reads the N elements of array from index on.

Parameters

  • array — the array to read
  • index — the element that goes in lane 0

Returns

  • a vector of array[index] through array[index + N - 1]

Panics

  • if array has fewer than index + N elements

Methods

store

store(pointer: __ptr<T>)

Writes the lanes to the N values at pointer, which need not be aligned.

Parameters

  • pointer — the first of N writable values

store_aligned

store_aligned(pointer: __ptr<T>)

Writes the lanes to the N values at pointer, which must be aligned to the vector’s size.

Parameters

  • pointer — the first of N writable values

Panics

  • if pointer is not aligned to the vector’s size

store

store(array: Array<T>, index: ulong)

Writes the lanes to the N elements of array from index on.

Parameters

  • array — the array to write
  • index — the element lane 0 goes to

Panics

  • if array has fewer than index + N elements

operator []

[](index: ulong): T

Returns lane index.

Parameters

  • index — the lane to read

Returns

  • the value in lane index

Panics

  • if index is not less than N

operator []

[](index: ulong, value: T): T

Sets lane index to value.

Parameters

  • index — the lane to write
  • value — the new value of the lane

Returns

  • value

Panics

  • if index is not less than N

operator +

+(other: Vector<T, N>): Vector<T, N>

Adds each lane of other to the same lane of this vector. Integer lanes wrap around on overflow.

Parameters

  • other — the vector to add

Returns

  • the lane-wise sums

operator +

+(other: T): Vector<T, N>

Adds other to every lane. Integer lanes wrap around on overflow.

Parameters

  • other — the value to add

Returns

  • the lane-wise sums

operator -

-(other: Vector<T, N>): Vector<T, N>

Subtracts each lane of other from the same lane of this vector. Integer lanes wrap around on overflow.

Parameters

  • other — the vector to subtract

Returns

  • the lane-wise differences

operator -

-(other: T): Vector<T, N>

Subtracts other from every lane. Integer lanes wrap around on overflow.

Parameters

  • other — the value to subtract

Returns

  • the lane-wise differences

operator *

*(other: Vector<T, N>): Vector<T, N>

Multiplies each lane by the same lane of other. Integer lanes wrap around on overflow.

Parameters

  • other — the vector to multiply by

Returns

  • the lane-wise products

operator *

*(other: T): Vector<T, N>

Multiplies every lane by other. Integer lanes wrap around on overflow.

Parameters

  • other — the value to multiply by

Returns

  • the lane-wise products

operator -

-(): Vector<T, N>

Negates every lane. Integer lanes wrap around: the lowest value stays itself.

Returns

  • the negated lanes

operator ==

==(other: Vector<T, N>): bool

Whether every lane equals the same lane of other. A NaN lane equals nothing. For a mask per lane, use equal.

Parameters

  • other — the vector to compare with

Returns

  • true when all lanes are equal

operator !=

!=(other: Vector<T, N>): bool

Whether some lane differs from the same lane of other.

Parameters

  • other — the vector to compare with

Returns

  • true when any lane differs

equal

equal(other: Vector<T, N>): Mask<N>

Compares each lane with the same lane of other for equality.

Parameters

  • other — the vector to compare with

Returns

  • a mask, true in each lane that is equal

equal

equal(other: T): Mask<N>

Compares every lane with other for equality.

Parameters

  • other — the value to compare with

Returns

  • a mask, true in each lane equal to other

not_equal

not_equal(other: Vector<T, N>): Mask<N>

Compares each lane with the same lane of other for inequality. A NaN lane differs from everything.

Parameters

  • other — the vector to compare with

Returns

  • a mask, true in each lane that differs

not_equal

not_equal(other: T): Mask<N>

Compares every lane with other for inequality.

Parameters

  • other — the value to compare with

Returns

  • a mask, true in each lane that differs from other

less

less(other: Vector<T, N>): Mask<N>

Tests whether each lane is less than the same lane of other.

Parameters

  • other — the vector to compare with

Returns

  • a mask, true in each lane that is less

less

less(other: T): Mask<N>

Tests whether every lane is less than other.

Parameters

  • other — the value to compare with

Returns

  • a mask, true in each lane less than other

less_equal

less_equal(other: Vector<T, N>): Mask<N>

Tests whether each lane is less than or equal to the same lane of other.

Parameters

  • other — the vector to compare with

Returns

  • a mask, true in each lane that is less or equal

less_equal

less_equal(other: T): Mask<N>

Tests whether every lane is less than or equal to other.

Parameters

  • other — the value to compare with

Returns

  • a mask, true in each lane less than or equal to other

greater

greater(other: Vector<T, N>): Mask<N>

Tests whether each lane is greater than the same lane of other.

Parameters

  • other — the vector to compare with

Returns

  • a mask, true in each lane that is greater

greater

greater(other: T): Mask<N>

Tests whether every lane is greater than other.

Parameters

  • other — the value to compare with

Returns

  • a mask, true in each lane greater than other

greater_equal

greater_equal(other: Vector<T, N>): Mask<N>

Tests whether each lane is greater than or equal to the same lane of other.

Parameters

  • other — the vector to compare with

Returns

  • a mask, true in each lane that is greater or equal

greater_equal

greater_equal(other: T): Mask<N>

Tests whether every lane is greater than or equal to other.

Parameters

  • other — the value to compare with

Returns

  • a mask, true in each lane greater than or equal to other

min

min(other: Vector<T, N>): Vector<T, N>

The smaller of each lane and the same lane of other. For floats, a NaN lane gives the other lane.

Parameters

  • other — the vector to compare with

Returns

  • the lane-wise minimums

max

max(other: Vector<T, N>): Vector<T, N>

The larger of each lane and the same lane of other. For floats, a NaN lane gives the other lane.

Parameters

  • other — the vector to compare with

Returns

  • the lane-wise maximums

abs

abs(): Vector<T, N>

The absolute value of every lane. For a signed integer T the lowest value has no positive counterpart and stays itself.

Returns

  • the lane-wise absolute values

sum

sum(): T

The sum of the lanes. Integer lanes wrap around on overflow; float lanes are added in lane order.

Returns

  • the sum of all lanes

minimum

minimum(): T

The smallest lane.

Returns

  • the minimum over all lanes

maximum

maximum(): T

The largest lane.

Returns

  • the maximum over all lanes

shuffle

shuffle(indices: Vector<int, N>): Vector<T, N>

Picks lanes by index: lane i of the result is lane indices[i] of this vector. Each index is taken modulo N, so every index picks a lane. When the indices are constants, an optimized build compiles this to one shuffle instruction.

Parameters

  • indices — the lane to pick for each lane of the result

Returns

  • the picked lanes

reverse

reverse(): Vector<T, N>

The lanes in reverse order.

Returns

  • a vector whose lane i is lane N - 1 - i of this one

interleave_low

interleave_low(other: Vector<T, N>): Vector<T, N>

Interleaves the low halves of this vector and other: lanes this[0], other[0], this[1], other[1], ....

Parameters

  • other — the vector whose lanes go in the odd lanes

Returns

  • the interleaved lanes

interleave_high

interleave_high(other: Vector<T, N>): Vector<T, N>

Interleaves the high halves of this vector and other: lanes this[N / 2], other[N / 2], this[N / 2 + 1], ....

Parameters

  • other — the vector whose lanes go in the odd lanes

Returns

  • the interleaved lanes

convert

convert<U: Lane>(): Vector<U, N>

Converts every lane to U, as as converts a number: integers truncate or extend, and floats round toward zero into integers. A float lane outside U’s range gives U’s nearest value, and NaN gives 0.

Type parameters

  • U — the new lane type

Returns

  • the converted lanes

reinterpret

reinterpret<U: Lane, M>(): Vector<U, M>

Reads the vector’s bits as M lanes of type U. The two must have the same size in bytes, or it is a compile-time error.

Type parameters

  • U — the new lane type
  • M — the new lane count

Returns

  • the same bits as a vector of U

extend Vector<T, N>

extend Vector<T: IntegerLane, N>

The operations of vectors of integers: checked division, the bitwise operators, shifts and rotates.

Type parameters

  • T — the integer lane type
  • N — the lane count

Methods

operator /

/(other: Vector<T, N>): Vector<T, N>

Divides each lane by the same lane of other. Division truncates toward zero.

Parameters

  • other — the vector to divide by

Returns

  • the lane-wise quotients

Panics

  • if a lane of other is 0, or T is signed and a lane divides the lowest value by -1

operator /

/(other: T): Vector<T, N>

Divides every lane by other. Division truncates toward zero.

Parameters

  • other — the value to divide by

Returns

  • the lane-wise quotients

Panics

  • if other is 0, or T is signed and a lane divides the lowest value by -1

operator %

%(other: Vector<T, N>): Vector<T, N>

The remainder of dividing each lane by the same lane of other, with the sign of this vector’s lane.

Parameters

  • other — the vector to divide by

Returns

  • the lane-wise remainders

Panics

  • if a lane of other is 0, or T is signed and a lane divides the lowest value by -1

operator %

%(other: T): Vector<T, N>

The remainder of dividing every lane by other, with the sign of the lane.

Parameters

  • other — the value to divide by

Returns

  • the lane-wise remainders

Panics

  • if other is 0, or T is signed and a lane divides the lowest value by -1

operator &

&(other: Vector<T, N>): Vector<T, N>

The bitwise AND of each lane with the same lane of other.

Parameters

  • other — the vector to combine with

Returns

  • the lane-wise ANDs

operator &

&(other: T): Vector<T, N>

The bitwise AND of every lane with other.

Parameters

  • other — the value to combine with

Returns

  • the lane-wise ANDs

operator |

|(other: Vector<T, N>): Vector<T, N>

The bitwise OR of each lane with the same lane of other.

Parameters

  • other — the vector to combine with

Returns

  • the lane-wise ORs

operator |

|(other: T): Vector<T, N>

The bitwise OR of every lane with other.

Parameters

  • other — the value to combine with

Returns

  • the lane-wise ORs

operator ^

^(other: Vector<T, N>): Vector<T, N>

The bitwise XOR of each lane with the same lane of other.

Parameters

  • other — the vector to combine with

Returns

  • the lane-wise XORs

operator ^

^(other: T): Vector<T, N>

The bitwise XOR of every lane with other.

Parameters

  • other — the value to combine with

Returns

  • the lane-wise XORs

operator <<

<<(count: int): Vector<T, N>

Shifts every lane left by count bits.

Parameters

  • count — the number of bits to shift by

Returns

  • the shifted lanes

Panics

  • if count is negative or not less than the lane’s width in bits

operator >>

>>(count: int): Vector<T, N>

Shifts every lane right by count bits: arithmetic (copying the sign bit) for a signed T, logical (filling with zeros) for an unsigned one.

Parameters

  • count — the number of bits to shift by

Returns

  • the shifted lanes

Panics

  • if count is negative or not less than the lane’s width in bits

operator ~

~(): Vector<T, N>

Flips every bit of every lane.

Returns

  • the complemented lanes

rotate_left

rotate_left(count: int): Vector<T, N>

Rotates the bits of every lane left by count, taken modulo the lane’s width (a negative count rotates right).

Parameters

  • count — the number of bits to rotate by

Returns

  • the rotated lanes

rotate_right

rotate_right(count: int): Vector<T, N>

Rotates the bits of every lane right by count, taken modulo the lane’s width (a negative count rotates left).

Parameters

  • count — the number of bits to rotate by

Returns

  • the rotated lanes

extend Vector<T, N>

extend Vector<T: FloatLane, N>

The operations of vectors of floating-point numbers: division and rounding.

Type parameters

  • T — the floating-point lane type
  • N — the lane count

Methods

operator /

/(other: Vector<T, N>): Vector<T, N>

Divides each lane by the same lane of other, as floating-point division does: a zero divisor gives an infinity or NaN.

Parameters

  • other — the vector to divide by

Returns

  • the lane-wise quotients

operator /

/(other: T): Vector<T, N>

Divides every lane by other, as floating-point division does.

Parameters

  • other — the value to divide by

Returns

  • the lane-wise quotients

operator %

%(other: Vector<T, N>): Vector<T, N>

The remainder of dividing each lane by the same lane of other (C’s fmod), with the sign of this vector’s lane.

Parameters

  • other — the vector to divide by

Returns

  • the lane-wise remainders

operator %

%(other: T): Vector<T, N>

The remainder of dividing every lane by other (C’s fmod).

Parameters

  • other — the value to divide by

Returns

  • the lane-wise remainders

square_root

square_root(): Vector<T, N>

The square root of every lane (NaN for a negative lane).

Returns

  • the lane-wise square roots

floor

floor(): Vector<T, N>

Every lane rounded down to a whole number.

Returns

  • the lane-wise floors

ceiling

ceiling(): Vector<T, N>

Every lane rounded up to a whole number.

Returns

  • the lane-wise ceilings

Mask

[Value] struct Mask<N>

N booleans, one per vector lane: what lane-wise comparisons give.

Type parameters

  • N — the lane count: a power of two from 1 to 64

Properties

count

var count: int { get; }

The number of lanes, N.

Constructors

Mask()

construct()

Makes a mask with every lane false.

Mask()

construct(value: bool)

Makes a mask with every lane value.

Parameters

  • value — the value of every lane

Methods

operator []

[](index: ulong): bool

Returns lane index.

Parameters

  • index — the lane to read

Returns

  • the value in lane index

Panics

  • if index is not less than N

any

any(): bool

Whether any lane is true.

Returns

  • true when at least one lane is true

all

all(): bool

Whether every lane is true.

Returns

  • true when no lane is false

to_bits

to_bits(): ulong

The lanes packed into bits: bit i is lane i. Its trailing_zeros() is the first true lane (64 when none is), and its count_ones() the number of true lanes.

Returns

  • the lanes as a bit mask

operator &

&(other: Mask<N>): Mask<N>

The lane-wise AND of the two masks.

Parameters

  • other — the mask to combine with

Returns

  • true in each lane true in both

operator |

|(other: Mask<N>): Mask<N>

The lane-wise OR of the two masks.

Parameters

  • other — the mask to combine with

Returns

  • true in each lane true in either

operator ^

^(other: Mask<N>): Mask<N>

The lane-wise XOR of the two masks.

Parameters

  • other — the mask to combine with

Returns

  • true in each lane true in exactly one

operator ~

~(): Mask<N>

The lane-wise negation of the mask.

Returns

  • true in each lane that is false here

select

select<T: Lane>(if_true: Vector<T, N>, if_false: Vector<T, N>): Vector<T, N>

Picks each lane from if_true where this mask is true, and from if_false where it is false.

Type parameters

  • T — the lane type of the vectors

Parameters

  • if_true — the lanes to take where the mask is true
  • if_false — the lanes to take where the mask is false

Returns

  • the selected lanes

The debug module

Stack walking, panic backtraces, and talking to a debugger.

debug is a module: bring it in with import debug (and write debug.capture()).

  • Backtraces: capture() records the calls that led to the current point, cheaply; Backtrace.frames() resolves each return address to its function and, in a -g build, its source line. print_backtrace() writes the current stack to standard error.
  • The debugger: output() writes to the platform’s debugger channel, is_debugger_attached() asks whether a debugger is watching, and breakpoint() stops in it.

A panic prints a backtrace after its message in a build without -O; the PEPPER_BACKTRACE environment variable turns that on (1) or off (0) in any build (see print_backtrace).

Example

import debug;

main(): int {
    var trace = debug.capture();
    var frames = trace.frames();
    assert(frames.count > 0, "main is on the stack");
    debug.output("captured a backtrace");
    return 0;
}

Source: runtime/debug/module.pep

Backtraces

Capturing the call stack (capture, Backtrace) and resolving it to functions and source lines (StackFrame).

Capturing only records return addresses, so it is cheap enough to do whenever something might need reporting later. The addresses are resolved, from the program’s symbol table and its debug info, only when Backtrace.frames() or to_string() asks for them.

A function name needs the program’s symbol table, which every build keeps unless the executable is stripped. A file, line and column need the debug info a -g build carries:

  • Linux: the executable’s ELF symbol table and DWARF line table.
  • macOS: the executable’s symbol table, and the DWARF line table of the .dSYM bundle a -g build writes beside it.
  • Windows: DbgHelp, which reads the .pdb every build writes beside the executable: function names only, or with -g source lines too (no columns).
  • WASI: a WebAssembly program cannot walk its stack, so a backtrace is empty.

A function the optimizer inlined into its caller has no frame of its own, so each address is expanded into its chain of inlined calls, from the debug info of a -g build: one StackFrame per call, marked inlined.

Frames inside the runtime’s own capture and panic code are left out, and so are the runtime’s operator methods (Array<int>.__index_get, what a[i] calls) between a panic and the code that caused it, and the C runtime’s frames below main. frames(full = true), or PEPPER_BACKTRACE=full for a panic’s backtrace, keeps them all.

Source: runtime/debug/backtrace.pep

StackFrame

struct StackFrame

One call on the stack: where it returns to, and the function and source position that address belongs to.

A call the optimizer inlined is a frame too, with inlined set: it shares its address with the frames of its callers, up to the first frame that is not inlined, which is the real one.

Example

import debug;

main(): int {
    var frame = debug.capture().frames()[0];
    assert(frame.function == "main", "the innermost frame is main");
    print(frame.to_string());   # main at main.pep:4:21 in a -g build
    return 0;
}

Fields

address

var address: ulong

The return address: the instruction the call returns to.

function

var function: String

The function’s name, such as main or Point.length; empty when the program has no symbol for the address.

file

var file: String

The source file, as it was named to the compiler; empty without debug info.

line

var line: int

The 1-based source line of the call; 0 without debug info.

column

var column: int

The 1-based source column of the call; 0 when unknown.

inlined

var inlined: bool

Whether the optimizer inlined this call into its caller, the next frame: the function has no frame of its own on the stack.

Constructor

StackFrame()

construct(address: ulong = 0)

Creates a StackFrame for address, with no function or source position yet.

Parameters

  • address — the return address

Methods

to_string

to_string(): String

Returns the frame as text: function at file:line:column, with the parts that are unknown left out, the address in hexadecimal when the function is unknown, and (inlined) after an inlined call.

Returns

  • the frame as text

Backtrace

struct Backtrace

The call stack at the point capture() ran, as return addresses, innermost first.

Example

import debug;

main(): int {
    var trace = debug.capture();
    for (var frame in trace.frames()) {
        print(frame.to_string());
    }
    return 0;
}

Fields

addresses

var addresses: Array<ulong>

The return address of each call, innermost first, as captured: it includes the frames of capture itself, which frames() leaves out.

Constructor

Backtrace()

construct()

Creates an empty Backtrace. Use capture() to record the stack.

Methods

frames

frames(full: bool = false): Array<StackFrame>

Resolves each return address to its function and source position. Runs in time proportional to the program’s symbol table and debug info, which it reads each call.

In a -g build, an address inside code the optimizer inlined becomes a frame for each inlined call, innermost first, each marked inlined, followed by the real frame (Linux and macOS read the DWARF, Windows asks DbgHelp).

Unless full, leaves out, at the top, the frames of capture and of the runtime’s panic code and the runtime’s operator methods (Array<int>.__index_get for a[i]) a panic came through; and at the bottom, the C runtime’s frames below main.

Parameters

  • full — whether to keep every frame

Returns

  • the frames, innermost first

to_string

to_string(): String

Returns the frames as text, one per line, each numbered and indented ( 0: main at main.pep:4:5), with a newline after the last.

Returns

  • the frames as text

capture

capture(): Backtrace

Records the call stack: the return address of every call that led here. Cheap: the addresses are resolved only when Backtrace.frames() or to_string() asks for them.

Records at most 128 calls, the innermost ones. On WASI the Backtrace is empty.

Returns

  • the stack, innermost call first

Example

import debug;

main(): int {
    var trace = debug.capture();
    print(trace.to_string());
    return 0;
}
print_backtrace()

Writes the current call stack to standard error, a line per frame, after a backtrace: line: what a panic prints after its message.

A panic prints it in a build without -O. Set the PEPPER_BACKTRACE environment variable to 1 to print it in an optimized build too, to full to also print the runtime frames it leaves out (see Backtrace.frames), or to 0 to never print it.

Example

import debug;

main(): int {
    debug.print_backtrace();
    return 0;
}

The debugger

Talking to a debugger: output() writes to the platform’s debugger channel, is_debugger_attached() asks whether a debugger is watching, and breakpoint() stops in it.

Source: runtime/debug/debugger.pep

output

output(text: String)

Writes text to the platform’s debugger channel, followed by a newline when it does not end in one.

  • Windows: OutputDebugStringW, which Visual Studio’s Output window and WinDbg show (and DebugView, with no debugger attached). Nothing is written anywhere else.
  • Linux, macOS and WASI: standard error, which debuggers show; these systems have no separate channel.

Parameters

  • text — the text to write

Example

import debug;

main(): int {
    debug.output("starting");
    return 0;
}

is_debugger_attached

is_debugger_attached(): bool

Returns whether a debugger is attached to the program: the TracerPid of /proc/self/status on Linux, P_TRACED on macOS, and IsDebuggerPresent on Windows. Always false on WASI.

Returns

  • true while a debugger is attached

Example

import debug;

main(): int {
    if (debug.is_debugger_attached()) {
        debug.output("hello, debugger");
    }
    return 0;
}

breakpoint

breakpoint()

Stops in the debugger, as a breakpoint set on this line would: the processor’s debug trap (int3 on x64, brk on AArch64), through llvm.debugtrap. The debugger can then continue the program.

With no debugger attached the trap ends the program: SIGTRAP on Linux and macOS, a breakpoint exception on Windows. Guard it with is_debugger_attached() to stop only under a debugger. Does nothing on WASI.

Example

import debug;

main(): int {
    if (debug.is_debugger_attached()) {
        debug.breakpoint();
    }
    return 0;
}

The log module

Leveled, structured logging: lines with a level, a message, key/value fields and the place of the call, written whole to standard error or to a Writer of your choice, from any thread or task.

log is a module: bring it in with import log (and write log.info("started")).

  • error, warning, info, debug and trace write a line at their Level. Each takes optional fields (made with field) and a target, the part of the program the line is about.
  • set_level picks the least important level shown, for every target or for one; enabled asks whether a line would be written.
  • set_output sends lines to a Writer (a file, a socket) in a Format: readable text or JSON Lines.

By default lines go to standard error as text, in color when it is a terminal (unless NO_COLOR is set), and info and above are shown. The PEPPER_LOG environment variable sets the levels when the program first logs: comma-separated items, each a level for every target or target=level for one, such as PEPPER_LOG=warning,http=debug. The levels are off, error, warning (or warn), info, debug and trace.

A text line reads:

2026-10-17T23:40:01.123Z INFO  [http] request done status=200 path="/" (main.pep:5:5)

Example

import log;

main(): int {
    log.info("started");
    log.warning("disk almost full", [log.field("free_mb", 120)], target = "storage");
    log.debug("not shown unless PEPPER_LOG=debug");
    return 0;
}

Source: runtime/log/module.pep

Logging

Levels, fields, the output, and the functions that write a line.

Source: runtime/log/log.pep

Level

enum Level

How much a line matters, most important first.

As the level a target shows (set_level, PEPPER_LOG), each level also shows the ones above it: Info shows errors, warnings and info lines.

Cases

Off

Off

Nothing: as the level a target shows, turns its lines off.

Error

Error

Something failed (error).

Warning

Warning

Something looks wrong but the program carries on (warning).

Info

Info

What the program is doing, at the level of whole operations (info).

Debug

Debug

Detail for finding a bug (debug).

Trace

Trace

Step-by-step detail (trace).

Format

enum Format

How each line is written.

Cases

Text

Text

One readable line: the UTC time, the level, the target in brackets, the message, key=value fields, and the call’s location in parentheses.

Json

Json

One JSON object per line (JSON Lines), with the keys time, level, target (when there is one), message and location, then each field.

Field

struct Field

A key and value attached to a line. Make one with field.

Fields

key

var key: String = ""

The key.

value

var value: String = ""

The value as JSON text: 200, "ann", true.

Constructor

Field()

construct(key: String, value: String)

Creates a field from a key and the JSON text of its value; field makes the text from any value.

Parameters

  • key — the key
  • value — the value’s JSON text

field

field<T>(key: String, value: T): Field

Makes a field for a line: log.info("done", [log.field("ms", 12)]).

The value is turned into JSON (see json.encode) when the field is made, whether or not the line is written; guard costly fields with enabled.

Type parameters

  • T — the type of value

Parameters

  • key — the key
  • value — the value

Returns

  • the field

Example

import log;

main(): int {
    log.info("request done", [log.field("status", 200), log.field("path", "/")]);
    return 0;
}

set_level

set_level(level: Level, target: String = "")

Sets the level a target shows, or with no target the level every target without its own shows. The default is Level.Info, unless the PEPPER_LOG environment variable says otherwise.

Parameters

  • level — the least important level to show; Level.Off shows nothing
  • target — the target, as the logging functions’ target; empty for the global level

Example

import log;

main(): int {
    log.set_level(log.Level.Debug);
    log.set_level(log.Level.Warning, "http");
    log.debug("shown");
    log.info("not shown", target = "http");
    return 0;
}

set_output

set_output([Move] writer: io.Writer, format: Format = Format.Text)

Sends every line from now on to writer in format, instead of to standard error. Lines are written whole, one write_all each, under a lock, so lines from many threads and tasks never interleave. A failed write is ignored. Colors are off.

Parameters

  • writer — where lines go: a new value, or one handed over with move(x), since the log keeps it
  • format — how each line is written

Example

import io;
import log;

main(): int {
    var path = io.Directory.temp().join("app.log");
    var file = io.File.create(path).value();
    log.set_output(move(file), log.Format.Json);
    log.info("started");
    return 0;
}

enabled

enabled(level: Level, target: String = ""): bool

Reports whether a line at level for target would be written, so a caller can skip building a costly message or field.

Cheap when no target shows level: two atomic loads, with no lock.

Parameters

  • level — the line’s level
  • target — the line’s target; empty for none

Returns

  • true when target shows level; false for Level.Off

Example

import log;

main(): int {
    if (log.enabled(log.Level.Debug)) {
        log.debug($"state: {[1, 2, 3]}");
    }
    return 0;
}

error

error( message: String, fields: Array<Field> = Array<Field>(), target: String = "" )

Writes an error line: something failed.

Parameters

  • message — the message
  • fields — keys and values to add to the line (see field)
  • target — the part of the program the line is about, which set_level and PEPPER_LOG can show or hide on its own; empty for none

Example

log.error("cannot open the database", [log.field("path", path)]);

warning

warning( message: String, fields: Array<Field> = Array<Field>(), target: String = "" )

Writes a warning line: something looks wrong but the program carries on.

Parameters

  • message — the message
  • fields — keys and values to add to the line (see field)
  • target — the part of the program the line is about; empty for none

Example

log.warning("retrying", [log.field("attempt", 2)], target = "http");

info

info( message: String, fields: Array<Field> = Array<Field>(), target: String = "" )

Writes an info line: what the program is doing.

Parameters

  • message — the message
  • fields — keys and values to add to the line (see field)
  • target — the part of the program the line is about; empty for none

Example

log.info("listening", [log.field("port", 8080)]);

debug

debug( message: String, fields: Array<Field> = Array<Field>(), target: String = "" )

Writes a debug line: detail for finding a bug. Not shown by default.

Parameters

  • message — the message
  • fields — keys and values to add to the line (see field)
  • target — the part of the program the line is about; empty for none

Example

log.debug("cache miss", [log.field("key", key)], target = "cache");

trace

trace( message: String, fields: Array<Field> = Array<Field>(), target: String = "" )

Writes a trace line: step-by-step detail. Not shown by default.

Parameters

  • message — the message
  • fields — keys and values to add to the line (see field)
  • target — the part of the program the line is about; empty for none

Example

log.trace("read a frame", [log.field("bytes", 512)], target = "http");

The text module

Converting text to and from bytes in a given encoding.

Bring it in with import text. A String always holds UTF-8; these types encode one into the bytes of an encoding (u8[]) and decode bytes back. Each decode is strict and gives none when the bytes are not valid in the encoding (code page 437’s always succeeds, since every byte is a character); each decode_lossy replaces what is invalid with U+FFFD and always succeeds.

Example

import text;

main(): int {
    var bytes = text.Utf8.encode("héllo");
    assert(bytes.count == 6, "é takes two bytes");
    assert(text.Utf8.decode(bytes).value() == "héllo", "round trip");
    return 0;
}

Source: runtime/text/module.pep

UTF-8

UTF-8, the encoding a String holds.

Source: runtime/text/utf8.pep

Utf8

struct Utf8

Encodes a String to UTF-8 bytes and decodes them back. A String already holds UTF-8, so encoding copies its bytes; decoding checks them.

Example

import text;

var bytes = text.Utf8.encode("hi");
assert(text.Utf8.decode(bytes).value() == "hi", "round trip");

Static methods

encode

static encode(value: String): u8[]

Returns the UTF-8 bytes of value, without a terminating NUL.

Parameters

  • value — the text to encode

Returns

  • a new array of value.size bytes

is_valid

static is_valid(bytes: u8[]): bool

Returns whether bytes is valid UTF-8: no stray continuation bytes, no truncated, overlong or surrogate sequences, and nothing past U+10FFFF.

Parameters

  • bytes — the bytes to check

Returns

  • true when every byte is part of a valid sequence

decode

static decode(bytes: u8[]): String?

Decodes bytes as UTF-8, or gives none when they are not valid UTF-8 (see is_valid). A byte-order mark is kept as part of the text.

Parameters

  • bytes — the bytes to decode

Returns

  • the text, or none when bytes is not valid UTF-8

Example

import text;

var bad = Array<u8>();
bad.append(255);
assert(text.Utf8.decode(bad).is_empty(), "0xFF is never UTF-8");

decode_lossy

static decode_lossy(bytes: u8[]): String

Decodes bytes as UTF-8, replacing each byte that does not start a valid sequence with U+FFFD, so it always succeeds. Valid input gives the same text as decode.

Parameters

  • bytes — the bytes to decode

Returns

  • the text

Example

import text;

var data = Array<u8>();
data.append(104);
data.append(255);
data.append(105);
assert(text.Utf8.decode_lossy(data) == "h\u{FFFD}i", "255 is replaced");

find_invalid

static find_invalid(value: String): ulong?

Returns the byte offset of the first byte of value that does not start a valid UTF-8 sequence (see is_valid), or none when all of it is valid. A String made from checked bytes is always valid; one made from C memory may not be.

Parameters

  • value — the text to check

Returns

  • the offset of the first invalid byte, or none

Example

import text;

assert(text.Utf8.find_invalid("héllo").is_empty(), "valid");

code_point_at

static code_point_at(value: String, offset: ulong): (uint, ulong)

Decodes the code point whose UTF-8 sequence starts at byte offset of value.

Parameters

  • value — the text to read
  • offset — the byte offset where the sequence starts

Returns

  • the code point and the number of bytes its sequence takes, from 1 to 4, so offset plus that is where the next one starts

Panics

  • if offset is not less than value.size (“Utf8.code_point_at offset out of bounds”)
  • if no valid sequence starts at offset (“Utf8.code_point_at found invalid UTF-8”)

Example

import text;

var (point, run) = text.Utf8.code_point_at("é!", 0);
assert(point == 233 and run == 2, "U+00E9 takes two bytes");

append_code_point

static append_code_point(out: u8[], code_point: uint)

Appends the UTF-8 encoding of code_point, from 1 to 4 bytes, to out.

Parameters

  • out — the bytes to append to
  • code_point — the code point to encode

Panics

  • if code_point is a surrogate (U+D800 to U+DFFF) or above U+10FFFF (“Utf8.append_code_point needs a Unicode scalar value”)

Example

import text;

var out = Array<u8>();
text.Utf8.append_code_point(out, 8364);
assert(text.Utf8.decode(out).value() == "€", "U+20AC");

UTF-16

UTF-16, as Windows and Java hold text.

Source: runtime/text/utf16.pep

Utf16

struct Utf16

Encodes a String to UTF-16 bytes and decodes them back. Each code unit takes two bytes, little-endian unless big_endian is set; a code point past U+FFFF takes two units (a surrogate pair). No byte-order mark is written, and one read is kept as part of the text.

Example

import text;

var bytes = text.Utf16.encode("hi");
assert(bytes.count == 4 && bytes[0] == 104 && bytes[1] == 0, "little-endian");
assert(text.Utf16.decode(bytes).value() == "hi", "round trip");

Static methods

encode

static encode(value: String, big_endian: bool = false): u8[]

Returns the UTF-16 bytes of value.

Parameters

  • value — the text to encode
  • big_endian — whether each code unit’s high byte comes first; false (little-endian) by default

Returns

  • a new array of two bytes per code unit

decode

static decode(bytes: u8[], big_endian: bool = false): String?

Decodes UTF-16 bytes, or gives none when they are not valid UTF-16: an odd number of bytes, or a surrogate without its partner.

Parameters

  • bytes — the bytes to decode
  • big_endian — whether each code unit’s high byte comes first; false (little-endian) by default

Returns

  • the text, or none when bytes is not valid UTF-16

decode_lossy

static decode_lossy(bytes: u8[], big_endian: bool = false): String

Decodes UTF-16 bytes, replacing each unpaired surrogate, and a final odd byte, with U+FFFD, so it always succeeds.

Parameters

  • bytes — the bytes to decode
  • big_endian — whether each code unit’s high byte comes first; false (little-endian) by default

Returns

  • the text

ASCII

ASCII, the first 128 code points, one byte each.

Source: runtime/text/ascii.pep

Ascii

struct Ascii

Encodes a String to ASCII bytes and decodes them back. Only code points below 128 have an ASCII byte.

Example

import text;

assert(text.Ascii.encode("ok").value().count == 2, "one byte each");
assert(text.Ascii.encode("é").is_empty(), "not ASCII");

Static methods

encode

static encode(value: String): u8[]?

Returns the ASCII bytes of value, or none when it holds a code point past 127.

Parameters

  • value — the text to encode

Returns

  • a new array of one byte per code point, or none

encode_lossy

static encode_lossy(value: String): u8[]

Returns the ASCII bytes of value, with ? for each code point past 127.

Parameters

  • value — the text to encode

Returns

  • a new array of one byte per code point

is_ascii

static is_ascii(value: String): bool

Returns whether every code point of value is ASCII (below 128).

Parameters

  • value — the text to check

Returns

  • true when value is all ASCII

decode

static decode(bytes: u8[]): String?

Decodes ASCII bytes, or gives none when one is 128 or more.

Parameters

  • bytes — the bytes to decode

Returns

  • the text, or none when bytes is not all ASCII

decode_lossy

static decode_lossy(bytes: u8[]): String

Decodes ASCII bytes, replacing each byte of 128 or more with U+FFFD, so it always succeeds.

Parameters

  • bytes — the bytes to decode

Returns

  • the text

Code page 437

Code page 437, the character set of the original IBM PC, which old zip archives use for entry names.

Source: runtime/text/code_page_437.pep

CodePage437

struct CodePage437

Encodes a String to code page 437 bytes and decodes them back. Bytes below 128 are ASCII; each of the 128 above stands for one character (accented letters, Greek letters, box-drawing and math symbols), so every byte decodes and decode always succeeds. Bytes below 32 decode as the ASCII control characters, not the PC’s graphical glyphs, as zip readers do.

Example

import text;

var bytes = text.CodePage437.encode("café").value();
assert(bytes.count == 4 && bytes[3] == 0x82, "é is 0x82");
assert(text.CodePage437.decode(bytes) == "café", "round trip");

Static methods

encode

static encode(value: String): u8[]?

Returns the code page 437 bytes of value, or none when it holds a character the code page lacks.

Parameters

  • value — the text to encode

Returns

  • a new array of one byte per code point, or none

encode_lossy

static encode_lossy(value: String): u8[]

Returns the code page 437 bytes of value, with ? for each character the code page lacks.

Parameters

  • value — the text to encode

Returns

  • a new array of one byte per code point

decode

static decode(bytes: u8[]): String

Decodes code page 437 bytes. Every byte is a character, so it always succeeds.

Parameters

  • bytes — the bytes to decode

Returns

  • the text

The regex module

Regular expressions: test whether text matches a pattern, find the matches and their capture groups, and replace or split by a pattern.

Bring it in with import regex. The syntax is RE2’s, and matching takes time linear in the size of the text whatever the pattern.

Example

import regex;

main(): int {
    var word = regex.Regex("\\b(\\w)(\\w*)\\b");
    assert(word.replace_all("hello big world", "${1}.") == "h. b. w.", "initials");
    return 0;
}

Source: runtime/regex/module.pep

Regex and Match

Regex, Match and the iterator over every match.

Source: runtime/regex/regex.pep

Regex

[Shared] struct Regex

A compiled regular expression: compile a pattern once, then test, find, replace or split text with it.

The syntax is RE2’s, the one Go and Rust’s regex use. Matching runs in time linear in the text whatever the pattern, because the engine never backtracks; in return there are no backreferences (\1) and no lookaround ((?=...)), and a pattern using them fails to compile.

  • Positions (Match.start(), Match.end(), find_at) are byte offsets, like String.find, so they work with substr_bytes.
  • . and a negated class ([^a]) match one code point, never part of one.
  • \d, \w, \s, \b and case-insensitive matching are ASCII-only.

A Regex never changes once compiled and is [Shared], so one compiled at module scope (const word = regex.Regex("\\w+");) serves every thread.

Example

import regex;

main(): int {
    var date = regex.Regex("(\\d{4})-(\\d{2})-(\\d{2})");
    var found = date.find("due 2026-10-01").value();
    assert(found.group(1).value() == "2026", "the year");
    assert(found.start() == 4, "a byte offset");
    return 0;
}

Constructors

Regex()

construct(pattern: String)

Compiles pattern.

Parameters

  • pattern — the regular expression

Panics

  • if pattern is not a valid regular expression; use parse for a pattern that may be invalid, such as one from user input

Regex()

construct(program: _RegexProgram)

Wraps a compiled program. Use Regex(pattern) or Regex.parse; this constructor is for the runtime.

Parameters

  • program — the compiled pattern

Static methods

parse

static parse(pattern: String): Result<Regex>

Compiles pattern, or reports why it is invalid.

Parameters

  • pattern — the regular expression

Returns

  • the compiled expression, or an error naming the problem and its byte offset in pattern, such as invalid regex '(a': missing ')' at offset 0

Example

import regex;

assert(regex.Regex.parse("[a-z]+").is_ok(), "valid");
assert(regex.Regex.parse("(a").is_err(), "unclosed group");

escape

static escape(text: String): String

Returns text with every character that has a meaning in a pattern escaped, so the result matches text literally.

Parameters

  • text — the text to match

Returns

  • a pattern that matches exactly text

Example

import regex;

assert(regex.Regex.escape("1+1=2?") == "1\\+1=2\\?", "escaped");

Methods

pattern

pattern(): String

Returns the pattern this expression was compiled from.

Returns

  • the pattern text

group_count

group_count(): ulong

Returns the number of capture groups in the pattern, not counting the whole match.

Returns

  • the number of (...) groups, named ones included

is_match

is_match(text: String): bool

Returns whether the expression matches anywhere in text. Faster than find, since it stops at the first match it sees.

Parameters

  • text — the text to search

Returns

  • true when there is a match

Example

import regex;

assert(regex.Regex("^\\d+$").is_match("2026"), "all digits");

find

find(text: String): Match?

Returns the leftmost match in text, or none. Among matches that start at the same place, a greedy repetition prefers the longer one and alternation the earlier branch, as in Perl.

Parameters

  • text — the text to search

Returns

  • the first match, or none

find_at

find_at(text: String, start: ulong): Match?

Returns the leftmost match in text that starts at or after byte offset start, or none. Assertions still see the text before start: ^ does not match at start unless it begins a line, and \b looks at the character before it.

Parameters

  • text — the text to search
  • start — the byte offset to search from, at most text.size

Returns

  • the first match from start, or none

Panics

  • if start is greater than text.size

find_all

find_all(text: String): Matches

Returns an iterator over the matches in text, left to right and without overlaps. An empty match right after the previous match is skipped, so a* finds a twice in "aba", not also between them.

Parameters

  • text — the text to search

Returns

  • an iterator over the matches

Example

import regex;

var total = 0;
for (var number in regex.Regex("\\d+").find_all("1 22 333")) {
    total += number.text().size;   # 1, then 2, then 3
}

replace

replace(text: String, replacement: String): String

Returns text with the first match replaced by replacement, or a copy of text when nothing matches.

In replacement, $1 or ${1} stands for group 1’s text (empty when the group did not take part), ${name} for a named group’s, $0 for the whole match, and $$ for a $. A $ followed by anything else is kept as it is.

Parameters

  • text — the text to search
  • replacement — the text to put in place of the match

Returns

  • a new String

Panics

  • if replacement names a group the pattern does not have

Example

import regex;

var swapped = regex.Regex("(\\w+) (\\w+)").replace("hello world", "$2 $1");
assert(swapped == "world hello", "groups swapped");

replace_all

replace_all(text: String, replacement: String): String

Returns text with every match, as find_all finds them, replaced by replacement, which may name groups as in replace.

Parameters

  • text — the text to search
  • replacement — the text to put in place of each match

Returns

  • a new String

Panics

  • if replacement names a group the pattern does not have

Example

import regex;

var squeezed = regex.Regex("\\s+").replace_all("a  b\t\nc", " ");
assert(squeezed == "a b c", "runs of space replaced");

split

split(text: String): String[]

Splits text at each match, as find_all finds them, and returns the pieces between them. A match at the start or end gives an empty first or last piece, and text with no match gives one piece, the whole text.

Parameters

  • text — the text to split

Returns

  • the pieces, at least one

Example

import regex;

var parts = regex.Regex(",\\s*").split("a, b,c");
assert(parts.count == 3 && parts[1] == "b", "three pieces");

to_string

to_string(): String

Returns the pattern, so print and interpolation show it.

Returns

  • the pattern text

Match

struct Match

One match of a Regex: where it is in the text, and what each capture group matched.

Positions are byte offsets into the searched text. Group 0 is the whole match; groups are numbered from 1 by their opening (, named ones included.

Constructor

Match()

construct(text: String, slots: long[], program: _RegexProgram)

Creates a match over text from its capture slots. Matches come from Regex; this constructor is for the runtime.

Parameters

  • text — the searched text
  • slots — a start and end byte offset for the match and each group, -1 for a group that did not take part
  • program — the compiled pattern, for its group names

Methods

text

text(): String

Returns the matched text.

Returns

  • the text of the whole match

start

start(): ulong

Returns the byte offset where the match starts.

Returns

  • the offset of the match’s first byte

end

end(): ulong

Returns the byte offset just past the end of the match.

Returns

  • the offset after the match’s last byte; equal to start() for an empty match

group_count

group_count(): ulong

Returns the number of capture groups, not counting the whole match.

Returns

  • the number of groups in the pattern

group

group(index: ulong): String?

Returns the text that group index matched, or none when the group did not take part in the match (as in (a)|b matching b).

Parameters

  • index — the group number; 0 is the whole match

Returns

  • the group’s text, or none

Panics

  • if index is greater than group_count()

group

group(name: String): String?

Returns the text that the group named name ((?<name>...) or (?P<name>...)) matched, or none when it did not take part.

Parameters

  • name — the group’s name

Returns

  • the group’s text, or none

Panics

  • if the pattern has no group named name

group_range

group_range(index: ulong): (ulong, ulong)?

Returns the byte offsets where group index starts and ends, or none when it did not take part.

Parameters

  • index — the group number; 0 is the whole match

Returns

  • the start and end offsets, or none

Panics

  • if index is greater than group_count()

to_string

to_string(): String

Returns the matched text.

Returns

  • the text of the whole match

Matches

struct Matches

An iterator over the matches of a Regex in a text, from Regex.find_all: left to right, without overlaps.

Constructor

Matches()

construct(program: _RegexProgram, text: String)

Creates an iterator over the matches of program in text. Use Regex.find_all; this constructor is for the runtime.

Parameters

  • program — the compiled pattern
  • text — the text to search

Methods

next

next(): bool

Advances to the next match.

Returns

  • true when there is another match, false once they are exhausted

current

current(): Match

Returns the match the last successful next() moved to.

Returns

  • the current match

Panics

  • if next() has not returned true

The json module

JSON: parse text into a JsonValue, look values up and change them, and write them back as compact or indented text.

Bring it in with import json. Parsing follows RFC 8259 strictly, and accepts JSONC (comments and trailing commas) on request. Objects keep their keys in the order they were written, so a document round-trips unchanged. encode and decode convert structs to JSON text and back.

Example

import json;

main(): int {
    var parsed = json.parse("{\"name\": \"pepper\", \"port\": 8080}");
    if (parsed.is_err()) {
        print(parsed.error().to_string());
        return 1;
    }
    var root = parsed.value();
    assert(root["name"].as_string().value() == "pepper", "a string");
    assert(root["port"].as_int().value_or(80) == 8080, "a number");
    root["debug"] = json.JsonValue.Bool(true);
    assert(root.to_string() == "{\"name\":\"pepper\",\"port\":8080,\"debug\":true}", "compact");
    return 0;
}

Source: runtime/json/module.pep

JsonValue

JsonValue: a JSON value of any kind, and how it is written as text.

Source: runtime/json/value.pep

JsonValue

enum JsonValue

A JSON value: null, a boolean, a number, a string, an array or an object. json.parse makes one from text and to_string writes it back.

Look values up with value["key"] and value[index]. A lookup that finds nothing (a missing key, an index past the end, or a lookup on a value of another kind) gives Null rather than panicking, so lookups chain: root["server"]["port"]. Then read the value with an as_ method, which gives none when the value is of another kind, or match on its case.

Numbers keep the form they were written in: an integer within ±(2^53 − 1), the range every JSON implementation agrees on (RFC 7493), is an Integer; any other number is a Number, a double.

An Array or Object case holds its array or map by reference, so changing them through value[key] = ..., append or the map from as_object() changes every holder of the value.

Example

import json;

var reply = json.JsonValue.object();
reply["id"] = json.JsonValue.Integer(42);
reply["tags"] = json.JsonValue.array([json.JsonValue.String("a")]);
assert(reply.to_string() == "{\"id\":42,\"tags\":[\"a\"]}", "compact");
assert(reply["id"].as_int().value() == 42, "a lookup");
assert(reply["missing"]["deeper"].is_null(), "lookups chain");

Cases

Null

Null

JSON’s null, and what a lookup gives when it finds nothing.

Bool

Bool(value: bool)

true or false.

Parameters

  • value — the boolean

Integer

Integer(value: long)

A number written without a fraction or an exponent, within ±(2^53 − 1).

Parameters

  • value — the integer

Number

Number(value: double)

Any other number: one with a fraction or an exponent, or an integer outside ±(2^53 − 1).

Parameters

  • value — the number

String

String(value: String)

A string.

Parameters

  • value — the text

Array

Array(items: Array<JsonValue>)

An array, holding its elements in order.

Parameters

  • items — the elements

Object

Object(members: OrderedMap<String, JsonValue>)

An object, holding its members in insertion order: the order the text or the code wrote them, so a document round-trips unchanged.

Parameters

  • members — the members, by key

Static methods

object

static object(): JsonValue

Returns a new, empty object.

Returns

  • an Object with no members

Example

import json;

var config = json.JsonValue.object();
config["name"] = json.JsonValue.String("pepper");

array

static array(items: Array<JsonValue> = Array<JsonValue>()): JsonValue

Returns an array holding items, which it keeps by reference rather than copying.

Parameters

  • items — the elements; an empty array when not given

Returns

  • an Array of items

Example

import json;

var list = json.JsonValue.array([json.JsonValue.Integer(1)]);
list.append(json.JsonValue.Integer(2));
assert(list.to_string() == "[1,2]", "two elements");

Methods

operator []

[](key: String): JsonValue

Reads value[key]: the member named key of an object.

Parameters

  • key — the member’s key

Returns

  • the member, or Null when this is not an object or has no member named key

operator []

[](key: String, member: JsonValue): JsonValue

Writes value[key] = member: sets the member named key of an object. A new key goes at the end; an existing key keeps its place.

Parameters

  • key — the member’s key
  • member — the value to store

Returns

  • member, the value of the assignment expression

Panics

  • if this is not an object (“JsonValue[key] = value needs an object”)

operator []

[](index: ulong): JsonValue

Reads value[index]: an element of an array.

Parameters

  • index — the element’s position, from 0

Returns

  • the element, or Null when this is not an array or index is not less than its count

operator []

[](index: ulong, element: JsonValue): JsonValue

Writes value[index] = element: replaces an element of an array.

Parameters

  • index — the element’s position, from 0
  • element — the value to store

Returns

  • element, the value of the assignment expression

Panics

  • if this is not an array (“JsonValue[index] = value needs an array”)
  • if index is not less than the array’s count (as Array’s indexer does)

append

append(element: JsonValue)

Appends element to the end of an array.

Parameters

  • element — the value to append

Panics

  • if this is not an array (“JsonValue.append needs an array”)

contains

contains(key: String): bool

Returns whether this is an object with a member named key.

Parameters

  • key — the key to look for

Returns

  • true when the member exists, even if its value is Null

remove

remove(key: String): bool

Removes the member named key from an object.

Parameters

  • key — the key to remove

Returns

  • true when this is an object and the member was there

is_null

is_null(): bool

Returns whether this is Null, which is also what a lookup that finds nothing gives.

Returns

  • true for Null

as_bool

as_bool(): bool?

Returns the boolean, when this is one.

Returns

  • the value of a Bool, or none

as_int

as_int(): long?

Returns the number as an integer, when it is one: an Integer, or a Number with no fraction that fits in a long (2.0, 1e3).

Returns

  • the integer, or none

Example

import json;

assert(json.parse("1e3").value().as_int().value() == 1000, "no fraction");
assert(json.parse("1.5").value().as_int().is_empty(), "a fraction");

as_double

as_double(): double?

Returns the number as a double, when this is an Integer or a Number.

Returns

  • the number, or none

as_string

as_string(): String?

Returns the string, when this is one.

Returns

  • the text of a String, or none

as_array

as_array(): Array<JsonValue>?

Returns the elements, when this is an array. The array is the one this value holds, not a copy.

Returns

  • the elements of an Array, or none

as_object

as_object(): OrderedMap<String, JsonValue>?

Returns the members, when this is an object. The map is the one this value holds, not a copy.

Returns

  • the members of an Object, or none

to_string

to_string(): String

Returns the value as compact JSON text, with no spaces or line breaks: {"id":42,"tags":["a","b"]}.

Strings are written as UTF-8, escaping ", \ and the control characters. A Number that is not finite (NaN or an infinity, which JSON cannot spell) is written as null.

Returns

  • the JSON text

to_string

to_string(ascii_only: bool): String

Returns the value as compact JSON text, like to_string(), choosing whether to escape every character outside ASCII as \u (a pair of them past U+FFFF), so the text is plain ASCII.

Parameters

  • ascii_only — whether to escape every non-ASCII character

Returns

  • the JSON text

Example

import json;

var word = json.JsonValue.String("é");
assert(word.to_string(ascii_only = true) == "\"\\u00e9\"", "escaped");

to_pretty_string

to_pretty_string(indent: int = 2, ascii_only: bool = false): String

Returns the value as indented JSON text: each element and member on its own line, indented indent spaces per level, with a space after each :. An empty array or object stays [] or {}.

Parameters

  • indent — the number of spaces per level of nesting
  • ascii_only — whether to escape every non-ASCII character as \u

Returns

  • the JSON text, without a final line break

Panics

  • if indent is negative (“JsonValue.to_pretty_string needs a non-negative indent”)

Example

import json;

var list = json.JsonValue.array([json.JsonValue.Integer(1)]);
assert(list.to_pretty_string() == "[\n  1\n]", "two spaces");

Parsing

parse: reading JSON text into a JsonValue, and JsonError, what it reports when the text is not JSON.

Source: runtime/json/parse.pep

JsonError

struct JsonError

What parse reports when the text is not JSON: what is wrong, and the line and column where it is.

to_string() renders it like a compiler error: config.json:3:17: error: expected ',' or '}'.

Fields

message

var message: String

What is wrong, worded like a compiler error (expected ':').

path

var path: String

The path given to parse, which names the text in to_string(); empty when none was given.

line

var line: ulong

The line of the error, from 1; 0 for an error from from_json, which has no text to point into.

column

var column: ulong

The column of the error, from 1, counting bytes as clang does; 0 when line is.

offset

var offset: ulong

The byte offset of the error in the text, from 0.

value_path

var value_path: String

Where in the document the value an error is about is, as a path from the root $ ($.server.port); empty for an error in the text’s syntax. decode and from_json set it, and start message with it.

Constructor

JsonError()

construct(message: String = "", path: String = "", line: ulong = 1, column: ulong = 1, offset: ulong = 0)

Creates an error.

Parameters

  • message — what is wrong
  • path — the name of the text, or empty
  • line — the line of the error, from 1
  • column — the column of the error, from 1
  • offset — the byte offset of the error, from 0

Methods

to_string

to_string(): String

Returns the error as a compiler prints one: path:line:column: error: message, without the path: when there is no path and without line:column: when line is 0.

Returns

  • the rendered error

parse

parse( text: String, comments: bool = false, path: String = "", max_depth: ulong = 1000 ): Result<JsonValue, JsonError>

Parses JSON text into a JsonValue.

Parsing is strict RFC 8259: one value, surrounded only by spaces, tabs and line breaks. A byte-order mark at the start is skipped. The text must be valid UTF-8, and a string cannot hold a raw control character or a \u escape of an unpaired surrogate ("\uD800"). When an object repeats a key, the last value wins, in the place of the first.

With comments = true the text may be JSONC, as VS Code’s settings files are: // line comments, /* */ block comments, and a trailing comma after the last element or member. The comments are skipped.

Nesting comes from the text, so the parser keeps its own stack rather than recursing, and max_depth bounds it.

Parameters

  • text — the JSON text
  • comments — whether to accept comments and trailing commas
  • path — the name of the text (a file name, say), which the error’s to_string() puts first
  • max_depth — how deeply arrays and objects may nest

Returns

  • the value, or an error naming the first problem and its line and column

Example

import json;

var parsed = json.parse("{\"server\": {\"port\": 8080}}");
assert(parsed.value()["server"]["port"].as_int().value() == 8080, "a lookup");
var bad = json.parse("[1 2]", path = "list.json");
assert(bad.error().to_string() == "list.json:1:4: error: expected ',' or ']'", "where");

Documents with comments

JsonDocument: a JSONC document that keeps its comments, so a settings file can be read, changed and written back with them.

Source: runtime/json/document.pep

JsonDocument

struct JsonDocument

A JSON value with the comments of the JSONC text it was read from, as parse_document makes it.

Each comment belongs to a value, by the value’s path from the root $ ($.editor.tab_size, $.files[0]): the comments on the lines before a value, or on the line it ends (after its comma), stay with it, and those before a closing bracket stay at the end of their array or object. to_string() writes them back in those places, so text read and written unchanged keeps every comment, in the indented layout to_pretty_string uses.

Change root freely. A comment whose value is gone (a removed key, an array shortened past it) is not written; one whose path names another value afterwards (an element shifted into its index) goes with that value.

Example

import json;

var text = "{\n  // The editor.\n  \"tab_size\": 4, // spaces\n  \"wrap\": true\n}";
var document = json.parse_document(text).value();
document.root["wrap"] = json.JsonValue.Bool(false);
assert(
    document.to_string() == "{\n  // The editor.\n  \"tab_size\": 4, // spaces\n  \"wrap\": false\n}",
    "the comments stay"
);
assert(document.comments("$.tab_size")[0] == "// The editor.", "by path");

Fields

root

var root: JsonValue

The document’s value.

Constructor

JsonDocument()

construct(root: JsonValue = JsonValue.Null)

Creates a document holding root, with no comments.

Parameters

  • root — the document’s value

Methods

comments

comments(path: String): Array<String>

Returns the comments on the lines before the value at path, each with its // or /* */.

Parameters

  • path — the value’s path from the root: $, $.key, $[0], or $["a key"] for a key that is not an identifier

Returns

  • the comments, oldest first; empty when there are none

set_comments

set_comments(path: String, comments: Array<String>)

Replaces the comments on the lines before the value at path. Each must be a whole comment, // ... (one line) or /* ... */.

Parameters

  • path — the value’s path from the root, as for comments
  • comments — the comments to write before the value; empty removes them

Panics

  • if a comment does not start with // or /* (“a JSONC comment starts with ‘//’ or ‘/*’”)

to_string

to_string(indent: int = 2, ascii_only: bool = false): String

Returns the document as indented JSONC text with its comments, each element and member on its own line, as to_pretty_string writes it.

Parameters

  • indent — the number of spaces per level of nesting
  • ascii_only — whether to escape every non-ASCII character as \u

Returns

  • the text, without a final line break

Panics

  • if indent is negative (“JsonDocument.to_string needs a non-negative indent”)

parse_document

parse_document(text: String, path: String = "", max_depth: ulong = 1000): Result<JsonDocument, JsonError>

Parses JSONC text into a JsonDocument, keeping its comments.

The text is read as parse reads it with comments = true: // and /* */ comments, and a trailing comma after the last element or member. Use it to change a file people edit, such as a settings file, and write it back with its comments; use parse when the comments do not matter.

Parameters

  • text — the JSONC text
  • path — the name of the text (a file name, say), which the error’s to_string() puts first
  • max_depth — how deeply arrays and objects may nest

Returns

  • the document, or an error naming the first problem and its line and column

Struct conversion

Struct conversion: encode and decode turn Pepper values into JSON text and back, through reflection, with no per-type code to write.

Source: runtime/json/convert.pep

UnknownKeys

enum UnknownKeys

What decode does with an object key that names no property of the struct it is read into.

Cases

Ignore

Ignore

Skips the key and its value.

Reject

Reject

Fails with an error naming the key and its path.

to_json

to_json<T>(value: T): JsonValue

Converts value to a JsonValue.

T may be a number type, bool, String, an enum (a case that carries no values as its name, "Point"; one that carries values as {"Circle": {"radius": 2}}; [Tag] and [Untagged] choose other forms), Optional<U> (the value, or null), Array<U>, a map with String keys (HashMap, OrderedMap or Map) as an object, JsonValue itself, or a struct, whose public properties become an object’s members in declaration order. Element and property types follow the same rules, so structs nest.

A struct property takes its key from [Name("key")] when it has one, and is left out when marked [Ignore] (the attributes of the serialize module, which json exports). A property of a struct type that holds no object (a struct field never assigned) is written as null. An empty Array, even one never assigned, is written as [].

The conversion is generated while compiling, by a static block per type. A type it cannot convert, such as a Result or an io.File, is a compile error naming the property: 'encode' cannot convert property 'outcome' of type 'Result<int>' to JSON.

Type parameters

  • T — the type of the value

Parameters

  • value — the value to convert

Returns

  • the value as JSON

Example

import json;

struct Point {
    var x: int;
    var y: int;
}

main(): int {
    var p = Point();
    p.x = 1;
    p.y = 2;
    assert(json.to_json(p)["y"].as_int().value() == 2, "a member");
    return 0;
}

encode

encode<T>(value: T, indent: int = 0, ascii_only: bool = false): String

Writes value as JSON text, converting it as to_json does.

Type parameters

  • T — the type of the value

Parameters

  • value — the value to write
  • indent — spaces per nesting level; 0, the default, writes compact text on one line
  • ascii_only — whether to escape every character outside ASCII as \u

Returns

  • the JSON text

Example

import json;

struct ServerConfig {
    var host: String;
    var port: int;
    [Name("max-connections")]
    var max_connections: int;
    [Ignore]
    var started_at: long;
}

main(): int {
    var config = ServerConfig();
    config.host = "localhost";
    config.port = 8080;
    config.max_connections = 16;
    var text = json.encode(config);
    assert(text == "{\"host\":\"localhost\",\"port\":8080,\"max-connections\":16}", "compact");
    return 0;
}

from_json

from_json<T>(value: JsonValue, unknown_keys: UnknownKeys = UnknownKeys.Ignore): Result<T, JsonError>

Converts a JsonValue to a T, the reverse of to_json.

A struct is made with its constructor that takes no arguments, then each property found in the object is read in. A missing key leaves its property at its default, unless the property is marked [Required], which makes it an error; a property marked [Ignore] is never read. A key that names no property is skipped, or with unknown_keys = UnknownKeys.Reject is an error.

A number converts to an integer type when it has no fraction and fits the type, and to float or double always. null converts only to an Optional (as none) and to JsonValue.

An error names where in the document it is, as a path from the root $ in its value_path and at the start of its message, and has no line or column, since there is no text: $.server.port: expected a number, found a string.

Type parameters

  • T — the type to convert to

Parameters

  • value — the JSON value
  • unknown_keys — what to do with an object key that names no property

Returns

  • the converted value, or the first error

Example

import json;

struct Point {
    var x: int;
    var y: int;
}

main(): int {
    var parsed = json.parse("{\"x\": 1, \"y\": 2}");
    var p = json.from_json<Point>(parsed.value());
    assert(p.value().y == 2, "a property");
    return 0;
}

decode

decode<T>( text: String, unknown_keys: UnknownKeys = UnknownKeys.Ignore, comments: bool = false, path: String = "" ): Result<T, JsonError>

Parses JSON text and converts it to a T: parse, then from_json.

Type parameters

  • T — the type to convert to

Parameters

  • text — the JSON text
  • unknown_keys — what to do with an object key that names no property
  • comments — whether to accept JSONC: comments and trailing commas
  • path — the name of the text (a file name, say), which the error’s to_string() puts first

Returns

  • the value, or the first error: a syntax error, or a conversion error with its path in the document; either has its line and column

Example

import json;

struct ServerConfig {
    var host: String;
    [Required]
    var port: int;
    var tags: Array<String>;
    var timeout: int?;
}

main(): int {
    var config = json.decode<ServerConfig>("{\"host\": \"a\", \"port\": 80, \"tags\": [\"x\"]}");
    assert(config.value().port == 80, "a number");
    assert(config.value().tags[0] == "x", "an array");
    assert(config.value().timeout.is_empty(), "a missing optional");

    var bad = json.decode<ServerConfig>("{\"port\": \"80\"}", path = "server.json");
    assert(bad.error().to_string() == "server.json:1:10: error: $.port: expected a number, found a string", "a path");
    return 0;
}

The serialize module

Serialization attributes: how a struct’s properties are written to and read from text formats.

json reads these attributes when it converts a struct (json.encode, json.decode), and other formats will share them, so one set of attributes describes a type for every format. Write them above a property (or an enum or one of its cases: Tag, Untagged, Name); import serialize brings them in, and import json alone is enough too, since json exports them.

Example

import json;

struct ServerConfig {
    var host: String;
    [Name("max-connections")]
    var max_connections: int;
    [Required]
    var port: int;
    [Ignore]
    var started_at: long;
}

Source: runtime/serialize/module.pep

Name

[Attribute] struct Name

Writes a property, or an enum case, under value rather than under its own name.

Example

import json;

struct Limits {
    [Name("max-connections")]
    var max_connections: int;
}

Fields

value

const value: String

The key the property is written under, or the name the case is written as.

Ignore

[Attribute] struct Ignore

Leaves a property out: it is not written, and reading leaves it as it was.

Required

[Attribute] struct Required

Makes a missing key an error when reading. Without it, a missing key leaves the property at its default.

Tag

[Attribute] struct Tag

Writes an enum’s cases as objects that name their case under key, rather than as {"Case": {...}}.

Without content, a case’s values sit beside the tag: {"kind": "Circle", "radius": 2}. With content, they go in an object under that key: {"kind": "Circle", "data": {"radius": 2}}. A case that carries no values is {"kind": "Point"} either way. Write it above an enum.

Example

import json;

[Tag("kind")]
enum Shape {
    Circle(radius: int),
    Point,
}

Fields

key

const key: String

The key that holds the case’s name.

content

const content: String = ""

The key that holds the case’s values, or empty to put them beside the tag.

Untagged

[Attribute] struct Untagged

Writes an enum’s cases with no tag at all: a case that carries values as the object of its values ({"radius": 2}), and one that carries none as its name ("Point").

Reading tries each case in order and takes the first that reads without an error, so list a case whose values are a superset of another’s first. Write it above an enum.

Example

import json;

[Untagged]
enum Size {
    Fixed(px: int),
    Auto,
}

The toml module

TOML: parse a document into a TomlValue, look values up and change them, write tables back as documents, edit a document in place keeping its comments and layout, and convert structs to TOML and back.

Bring it in with import toml. Parsing follows TOML 1.0 and checks all of its rules; errors carry a line and column, like a compiler’s. Tables keep their keys in the order they were written.

Example

import toml;

main(): int {
    var parsed = toml.parse("[package]\nname = \"demo\"\nversion = \"0.1.0\"\n");
    if (parsed.is_err()) {
        print(parsed.error().to_string());
        return 1;
    }
    var root = parsed.value();
    assert(root.lookup("package.name").value().as_string().value() == "demo", "a string");
    var package = root["package"];
    package["edition"] = toml.TomlValue.Integer(2026);
    print(root.to_document());
    return 0;
}

Source: runtime/toml/module.pep

TomlValue

TomlValue: a TOML value of any kind, and TomlDateTime, TOML’s dates and times.

Source: runtime/toml/value.pep

TomlDateTime

struct TomlDateTime

A TOML date, time, or date and time: an offset date-time (1979-05-27T07:32:00Z), a local date-time (1979-05-27T07:32:00), a local date (1979-05-27) or a local time (07:32:00.5).

has_date, has_time and has_offset say which of the four it is; the fields of a part it does not have are 0. to_string() writes it in the RFC 3339 form TOML uses, with as many fraction digits as it was read with.

Example

import toml;

var when = toml.TomlDateTime.offset(1979, 5, 27, 7, 32, 0, -420);
assert(when.to_string() == "1979-05-27T07:32:00-07:00", "an offset");
assert(toml.TomlDateTime.date(2024, 2, 29).to_string() == "2024-02-29", "a date");

Fields

year

var year: int

The year, 0 to 9999; 0 without a date.

month

var month: int

The month, 1 to 12; 0 without a date.

day

var day: int

The day of the month, from 1; 0 without a date.

hour

var hour: int

The hour, 0 to 23.

minute

var minute: int

The minute, 0 to 59.

second

var second: int

The second, 0 to 59.

nanosecond

var nanosecond: int

The fraction of the second, in nanoseconds.

fraction_digits

var fraction_digits: int

How many digits of the fraction to_string() writes, 0 to 9; 0 writes none.

offset_minutes

var offset_minutes: int

The offset from UTC in minutes (-07:00 is -420); 0 for Z.

has_date

var has_date: bool

Whether it has a date.

has_time

var has_time: bool

Whether it has a time of day.

has_offset

var has_offset: bool

Whether it has an offset from UTC, which only a date and time has.

Constructor

TomlDateTime()

construct()

Creates a local date-time at midnight on 0000-00-00, to fill in.

Static methods

date

static date(year: int, month: int, day: int): TomlDateTime

Returns a local date, such as 1979-05-27.

Parameters

  • year — the year
  • month — the month, from 1
  • day — the day of the month, from 1

Returns

  • the date

time

static time(hour: int, minute: int, second: int, nanosecond: int = 0): TomlDateTime

Returns a local time of day, such as 07:32:00.

Parameters

  • hour — the hour
  • minute — the minute
  • second — the second
  • nanosecond — the fraction of the second, in nanoseconds; written with as few digits as it needs

Returns

  • the time

local

static local( year: int, month: int, day: int, hour: int, minute: int, second: int, nanosecond: int = 0 ): TomlDateTime

Returns a local date-time, with no offset: 1979-05-27T07:32:00.

Parameters

  • year — the year
  • month — the month, from 1
  • day — the day of the month, from 1
  • hour — the hour
  • minute — the minute
  • second — the second
  • nanosecond — the fraction of the second, in nanoseconds

Returns

  • the date-time

offset

static offset( year: int, month: int, day: int, hour: int, minute: int, second: int, offset_minutes: int, nanosecond: int = 0 ): TomlDateTime

Returns a date-time at an offset from UTC: 1979-05-27T07:32:00Z.

Parameters

  • year — the year
  • month — the month, from 1
  • day — the day of the month, from 1
  • hour — the hour
  • minute — the minute
  • second — the second
  • offset_minutes — the offset from UTC in minutes; 0 is written Z
  • nanosecond — the fraction of the second, in nanoseconds

Returns

  • the date-time

Methods

to_string

to_string(): String

Returns the value as TOML writes it: 1979-05-27T07:32:00.5-07:00, 1979-05-27, 07:32:00.

Returns

  • the text

TomlValue

enum TomlValue

A TOML value: a string, an integer, a float, a boolean, a date-time, an array or a table. toml.parse makes a table from a document, and to_document writes one back.

Look members up with value["key"] and elements with value[index], which panic when there is none; try_get and lookup give none instead, and lookup follows a dotted key ("package.name"). Then read the value with an as_ method, which gives none when the value is of another kind, or match on its case.

A Table keeps its keys in the order they were written. An Array or Table case holds its array or map by reference, so changing them through value[key] = ... or append changes every holder of the value.

Example

import toml;

var package = toml.TomlValue.table();
package["name"] = toml.TomlValue.String("demo");
package["authors"] = toml.TomlValue.array([toml.TomlValue.String("Ada")]);
assert(package.to_string() == "{ name = \"demo\", authors = [\"Ada\"] }", "inline");
assert(package["name"].as_string().value() == "demo", "a lookup");

Cases

String

String(value: String)

A string.

Parameters

  • value — the text

Integer

Integer(value: long)

A 64-bit integer.

Parameters

  • value — the integer

Float

Float(value: double)

A 64-bit float, including inf and nan.

Parameters

  • value — the number

Boolean

Boolean(value: bool)

true or false.

Parameters

  • value — the boolean

DateTime

DateTime(value: TomlDateTime)

A date, a time, or both.

Parameters

  • value — the date-time

Array

Array(items: Array<TomlValue>)

An array, holding its elements in order.

Parameters

  • items — the elements

Table

Table(members: OrderedMap<String, TomlValue>)

A table, holding its members in the order they were written.

Parameters

  • members — the members, by key

Static methods

table

static table(): TomlValue

Returns a new, empty table.

Returns

  • a Table with no members

array

static array(items: Array<TomlValue> = Array<TomlValue>()): TomlValue

Returns an array holding items, which it keeps by reference rather than copying.

Parameters

  • items — the elements; an empty array when not given

Returns

  • an Array of items

Methods

operator []

[](key: String): TomlValue

Reads value[key]: the member named key of a table.

Parameters

  • key — the member’s key

Returns

  • the member

Panics

  • if this is not a table or has no member named key (“no key ‘k’ in the TOML table”); use try_get to check

operator []

[](key: String, member: TomlValue): TomlValue

Writes value[key] = member: sets the member named key of a table. A new key goes at the end; an existing key keeps its place.

Parameters

  • key — the member’s key
  • member — the value to store

Returns

  • member, the value of the assignment expression

Panics

  • if this is not a table (“TomlValue[key] = value needs a table”)

operator []

[](index: ulong): TomlValue

Reads value[index]: an element of an array.

Parameters

  • index — the element’s position, from 0

Returns

  • the element

Panics

  • if this is not an array (“TomlValue[index] needs an array”)
  • if index is not less than the array’s count (as Array’s indexer does)

operator []

[](index: ulong, element: TomlValue): TomlValue

Writes value[index] = element: replaces an element of an array.

Parameters

  • index — the element’s position, from 0
  • element — the value to store

Returns

  • element, the value of the assignment expression

Panics

  • if this is not an array (“TomlValue[index] = value needs an array”)
  • if index is not less than the array’s count

try_get

try_get(key: String): TomlValue?

Returns the member named key, when this is a table that has one.

Parameters

  • key — the member’s key

Returns

  • the member, or none

lookup

lookup(key: String): TomlValue?

Returns the value at a dotted key below this table, read as TOML reads a key: package.name, dependencies."my.crate".version.

Parameters

  • key — the dotted key

Returns

  • the value, or none when a part is missing, is not a table, or key is not a valid TOML key

Example

import toml;

var root = toml.parse("[package]\nname = \"demo\"").value();
assert(root.lookup("package.name").value().as_string().value() == "demo", "found");
assert(root.lookup("package.version").is_empty(), "missing");

get_path

get_path(keys: Array<String>): TomlValue?

Returns the value at the path of keys keys below this table.

Parameters

  • keys — the keys, outermost first; empty gives this value

Returns

  • the value, or none when a part is missing or is not a table

append

append(element: TomlValue)

Appends element to the end of an array.

Parameters

  • element — the value to append

Panics

  • if this is not an array (“TomlValue.append needs an array”)

contains

contains(key: String): bool

Returns whether this is a table with a member named key.

Parameters

  • key — the key to look for

Returns

  • true when the member exists

remove

remove(key: String): bool

Removes the member named key from a table.

Parameters

  • key — the key to remove

Returns

  • true when this is a table and the member was there

type_name

type_name(): String

Returns what kind of value this is, as errors name it: string, integer, float, boolean, date-time, array or table.

Returns

  • the kind’s name

as_string

as_string(): String?

Returns the string, when this is one.

Returns

  • the text of a String, or none

as_int

as_int(): long?

Returns the integer, when this is one.

Returns

  • the value of an Integer, or none

as_float

as_float(): double?

Returns the number as a double, when this is a Float or an Integer.

Returns

  • the number, or none

as_bool

as_bool(): bool?

Returns the boolean, when this is one.

Returns

  • the value of a Boolean, or none

as_date_time

as_date_time(): TomlDateTime?

Returns the date-time, when this is one.

Returns

  • the value of a DateTime, or none

as_array

as_array(): Array<TomlValue>?

Returns the elements, when this is an array. The array is the one this value holds, not a copy.

Returns

  • the elements of an Array, or none

as_table

as_table(): OrderedMap<String, TomlValue>?

Returns the members, when this is a table. The map is the one this value holds, not a copy.

Returns

  • the members of a Table, or none

to_string

to_string(): String

Returns the value as TOML writes it after a key’s =, on one line: "text", 42, 1.5, [1, 2], { name = "demo" }.

Returns

  • the TOML text

to_document

to_document(): String

Returns a table as a TOML document: its plain members as key = value lines, then each table member under a [header] and each array of tables as [[header]] sections, nested tables after their parent’s members.

Returns

  • the document text, ending with a line break unless it is empty

Panics

  • if this is not a table (“TomlValue.to_document needs a table”)

Example

import toml;

var root = toml.TomlValue.table();
root["title"] = toml.TomlValue.String("demo");
var server = toml.TomlValue.table();
server["port"] = toml.TomlValue.Integer(8080);
root["server"] = server;
assert(root.to_document() == "title = \"demo\"\n\n[server]\nport = 8080\n", "a document");

Parsing

parse: reading a TOML document into a TomlValue, and TomlError, what it reports when the text is not TOML.

Source: runtime/toml/parse.pep

TomlError

struct TomlError

What parse reports when the text is not TOML, and what decode reports when a value does not fit its property: what is wrong, and the line and column where it is.

to_string() renders it like a compiler error: pep.toml:4:12: error: expected '='.

Fields

message

var message: String

What is wrong, worded like a compiler error (expected '=').

path

var path: String

The path given to parse, which names the text in to_string(); empty when none was given.

line

var line: ulong

The line of the error, from 1; 0 for an error with no text to point into, such as one from from_toml.

column

var column: ulong

The column of the error, from 1, counting bytes as clang does; 0 when line is.

offset

var offset: ulong

The byte offset of the error in the text, from 0.

value_path

var value_path: String

Where in the document the value an error is about is, as a path from the root $ ($.package.version, $.bin[0].name); empty for an error in the text’s syntax. decode and from_toml set it, and start message with it.

Constructor

TomlError()

construct(message: String = "", path: String = "", line: ulong = 1, column: ulong = 1, offset: ulong = 0)

Creates an error.

Parameters

  • message — what is wrong
  • path — the name of the text, or empty
  • line — the line of the error, from 1
  • column — the column of the error, from 1
  • offset — the byte offset of the error, from 0

Methods

to_string

to_string(): String

Returns the error as a compiler prints one: path:line:column: error: message, without the path: when there is no path and without line:column: when line is 0.

Returns

  • the rendered error

parse

parse(text: String, path: String = "", max_depth: ulong = 1000): Result<TomlValue, TomlError>

Parses a TOML 1.0 document into a TomlValue, a Table.

Every rule of TOML 1.0 is checked: a key or table defined twice, a table extended after it was closed (an inline table, or one written with dotted keys in another section), strings, numbers and dates as the specification spells them. The text must be valid UTF-8; a byte-order mark at the start is skipped. Tables keep their keys in the order they were written. A line break in a multi-line string reads as \n, also when the text has \r\n.

Arrays and inline tables nest as deeply as the text says, so the parser keeps its own stack rather than recursing, and max_depth bounds it.

Parameters

  • text — the TOML text
  • path — the name of the text (a file name, say), which the error’s to_string() puts first
  • max_depth — how deeply arrays and inline tables may nest

Returns

  • the document’s table, or an error naming the first problem and its line and column

Example

import toml;

var parsed = toml.parse("[server]\nport = 8080\n");
assert(parsed.value()["server"]["port"].as_int().value() == 8080, "a lookup");
var bad = toml.parse("name \"demo\"", path = "pep.toml");
assert(bad.error().to_string() == "pep.toml:1:6: error: expected '=' after the key", "where");

Writing

Writing TOML: values on one line, and tables as documents.

Source: runtime/toml/write.pep

Documents with comments

TomlDocument: a TOML document changed in place, keeping its comments, blank lines and layout, as pepper package add changes pep.toml.

Source: runtime/toml/document.pep

TomlDocument

struct TomlDocument

A TOML document that keeps the text it was read from, so a file people edit can be changed and written back without reformatting it.

set and remove change only the text they must: a changed value is rewritten where it stands, a new key goes after the last key of its table, a new table goes at the end, and a removed key or table takes its lines with it, along with the comment lines right above them. Every other byte, comments included, stays as it was. Each edit parses the result again, so root() and get always see the current text.

Keys are given as arrays, outermost first (["dependencies", "json5"]), so a key holding a dot needs no quoting. An edit cannot reach inside an array of tables ([[bin]]); set or remove the whole array instead.

Example

import toml;

var text = "[package]\nname = \"demo\"  # the name\n\n[dependencies]\njson5 = \"1.0\"\n";
var document = toml.parse_document(text).value();
document.set(["package", "version"], toml.TomlValue.String("0.2.0")).value();
document.set(["dependencies", "yaml"], toml.TomlValue.String("0.3")).value();
document.remove(["dependencies", "json5"]).value();
assert(
    document.to_string()
        == "[package]\nname = \"demo\"  # the name\nversion = \"0.2.0\"\n\n[dependencies]\nyaml = \"0.3\"\n",
    "only the edits change"
);

Constructor

TomlDocument()

construct()

Creates an empty document.

Methods

root

root(): TomlValue

Returns the document’s table, as parse reads the current text. Changing it does not change the document; use set and remove.

Returns

  • the root table

get

get(keys: Array<String>): TomlValue?

Returns the value at keys.

Parameters

  • keys — the keys, outermost first

Returns

  • the value, or none when a part is missing or is not a table

lookup

lookup(key: String): TomlValue?

Returns the value at a dotted key, read as TOML reads a key: package.name, dependencies."my.package".

Parameters

  • key — the dotted key

Returns

  • the value, or none when it is missing or key is not a valid TOML key

set

set(keys: Array<String>, value: TomlValue, inline: bool = false): Result<void, TomlError>

Sets the value at keys, adding the key, and any tables above it, when it is missing.

A key that is there has its value rewritten in place, on one line; a key inside an inline table rewrites that inline table. A new key goes after the last key of the table it belongs to, indented like it, or under a new [header] at the end when that table has no section to put it in. A new table value (or array of tables) is written as its own [header] section at the end, unless inline is true, which writes it { ... } on the key’s line instead, as cargo add writes a dependency.

Parameters

  • keys — the keys, outermost first
  • value — the value to store
  • inline — whether a new table value is written as an inline table rather than a section

Returns

  • nothing, or an error when a part of keys holds a value that is not a table, or lies inside an array of tables

Panics

  • if keys is empty (“TomlDocument.set needs a key”)

remove

remove(keys: Array<String>): Result<bool, TomlError>

Removes the key or table at keys, with its lines and the comment lines right above them. Removing a table removes its sections, the tables below it, and any dotted keys that define it.

Parameters

  • keys — the keys, outermost first

Returns

  • whether something was removed, or an error when a part of keys lies inside an array of tables

Panics

  • if keys is empty (“TomlDocument.remove needs a key”)

to_string

to_string(): String

Returns the document’s text, with every edit made.

Returns

  • the TOML text

parse_document

parse_document(text: String, path: String = ""): Result<TomlDocument, TomlError>

Parses a TOML document into a TomlDocument, to change it in place.

The text is read as parse reads it. Use it to change a file people edit, such as pep.toml, and write it back with its comments and layout; use parse when only the values matter.

Parameters

  • text — the TOML text
  • path — the name of the text (a file name, say), which an error’s to_string() puts first

Returns

  • the document, or an error naming the first problem and its line and column

Struct conversion

Struct conversion: encode and decode turn Pepper values into TOML documents and back, through reflection, with no per-type code to write. They read the same serialize attributes json does, so one set of attributes describes a type for both formats.

Source: runtime/toml/convert.pep

UnknownKeys

enum UnknownKeys

What decode does with a table key that names no property of the struct it is read into.

Cases

Ignore

Ignore

Skips the key and its value.

Reject

Reject

Fails with an error naming the key and its path.

to_toml

to_toml<T>(value: T): TomlValue?

Converts value to a TomlValue.

T may be a number type, bool, String, TomlDateTime, an enum (a case that carries no values as its name, "Point"; one that carries values as { Circle = { radius = 2 } }), Optional<U>, Array<U>, a map with String keys (HashMap, OrderedMap or Map) as a table, TomlValue itself, or a struct, whose public properties become a table’s members in declaration order. Element and property types follow the same rules, so structs nest.

TOML has no null, so what JSON writes as null is left out: an empty Optional, a String or struct property that holds no object, and such an element of an array or map. A ulong past the largest TOML integer, 2^63 - 1, is written as a float.

A struct property takes its key from [Name("key")] when it has one, and is left out when marked [Ignore] (the attributes of the serialize module, which toml shares with json).

The conversion is generated while compiling, by a static block per type. A type it cannot convert, such as a Result, is a compile error naming the property: 'encode' cannot convert property 'outcome' of type 'Result<int>' to TOML.

Type parameters

  • T — the type of the value

Parameters

  • value — the value to convert

Returns

  • the value as TOML, or none when there is nothing to write

Example

import toml;

struct Point {
    var x: int;
    var y: int;
}

main(): int {
    var p = Point();
    p.x = 1;
    p.y = 2;
    assert(toml.to_toml(p).value()["y"].as_int().value() == 2, "a member");
    return 0;
}

encode

encode<T>(value: T): String

Writes value, a struct or a map, as a TOML document, converting it as to_toml does.

Type parameters

  • T — the type of the value

Parameters

  • value — the value to write

Returns

  • the document text

Panics

  • if value does not convert to a table, as a number or an array does (“toml.encode needs a value that converts to a table”)

Example

import toml;

struct Package {
    var name: String;
    var version: String;
    var authors: Array<String>;
}

struct Manifest {
    var package: Package;
    [Name("build-script")]
    var build_script: String?;
}

main(): int {
    var manifest = Manifest();
    manifest.package = Package();
    manifest.package.name = "demo";
    manifest.package.version = "0.1.0";
    manifest.package.authors = ["Ada"];
    var text = toml.encode(manifest);
    assert(text == "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nauthors = [\"Ada\"]\n", "a document");
    return 0;
}

from_toml

from_toml<T>(value: TomlValue, unknown_keys: UnknownKeys = UnknownKeys.Ignore): Result<T, TomlError>

Converts a TomlValue to a T, the reverse of to_toml.

A struct is made with its constructor that takes no arguments, then each property found in the table is read in. A missing key leaves its property at its default (an empty Optional, for one), unless the property is marked [Required], which makes it an error; a property marked [Ignore] is never read. A key that names no property is skipped, or with unknown_keys = UnknownKeys.Reject is an error.

An integer converts to an integer type it fits, and to float or double; a float converts only to float or double.

An error names where in the document it is, as a path from the root $ in its value_path and at the start of its message, and has no line or column, since there is no text: $.package.version: expected a string, found an integer.

Type parameters

  • T — the type to convert to

Parameters

  • value — the TOML value
  • unknown_keys — what to do with a key that names no property

Returns

  • the converted value, or the first error

decode

decode<T>( text: String, unknown_keys: UnknownKeys = UnknownKeys.Ignore, path: String = "" ): Result<T, TomlError>

Parses a TOML document and converts it to a T: parse, then from_toml.

Type parameters

  • T — the type to convert to

Parameters

  • text — the TOML text
  • unknown_keys — what to do with a key that names no property
  • path — the name of the text (a file name, say), which the error’s to_string() puts first

Returns

  • the value, or the first error: a syntax error, or a conversion error with its path in the document; either has its line and column when the value it is about is written in the text

Example

import toml;

struct Package {
    [Required]
    var name: String;
    var version: String;
    var authors: Array<String>;
    var edition: int?;
}

struct Manifest {
    [Required]
    var package: Package;
}

main(): int {
    var text = "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nauthors = [\"Ada\"]\n";
    var manifest = toml.decode<Manifest>(text);
    assert(manifest.value().package.name == "demo", "a string");
    assert(manifest.value().package.authors[0] == "Ada", "an array");
    assert(manifest.value().package.edition.is_empty(), "a missing optional");

    var bad = toml.decode<Manifest>("[package]\nname = 7\n", path = "pep.toml");
    assert(
        bad.error().to_string() == "pep.toml:2:8: error: $.package.name: expected a string, found an integer",
        "where"
    );
    return 0;
}

The compress module

Compression: DEFLATE (RFC 1951) with its zlib and gzip framings, in one call or as streams, and the CRC-32 and Adler-32 checksums.

Bring it in with import compress. It is written in Pepper, so it needs no zlib, unless the program is built with -D USE_ZLIB, which makes it use zlib with the same API. Corrupt data is an io.IoError whose is_invalid_input() holds.

Example

import compress;

main(): int {
    var data = "to be or not to be, that is the question".bytes();
    var packed = compress.Gzip.compress(data);
    assert(compress.Gzip.decompress(packed).value() == data, "round trip");
    return 0;
}

Source: runtime/compress/module.pep

Formats and streams

Format, the one-call Deflate, Zlib and Gzip functions, and the streaming DecompressReader and CompressWriter.

Source: runtime/compress/format.pep

Format

enum Format

The framing around DEFLATE-compressed data.

Example

import compress;

var packed = compress.Gzip.compress("hi".bytes());
var reader = compress.DecompressReader(io.ByteReader(packed), compress.Format.Gzip);

Cases

Raw

Raw

Raw DEFLATE (RFC 1951) with no header or checksum, as zip archives and HTTP’s Content-Encoding: deflate from some servers carry it.

Zlib

Zlib

zlib (RFC 1950): a two-byte header, DEFLATE, then an Adler-32 checksum. PNG images and HTTP’s deflate encoding use it.

Gzip

Gzip

gzip (RFC 1952): a header, DEFLATE, then a CRC-32 and the length. .gz files and HTTP’s gzip encoding use it. Reading accepts several members one after another, as cat a.gz b.gz makes.

Deflate

struct Deflate

Raw DEFLATE (RFC 1951) data, compressed or decompressed in one call.

Example

import compress;

var data = "abcabcabcabc".bytes();
var packed = compress.Deflate.compress(data);
assert(compress.Deflate.decompress(packed).value() == data, "round trip");

Static methods

compress

static compress(data: u8[], level: int = 6): u8[]

Compresses data into raw DEFLATE data.

Parameters

  • data — the bytes to compress
  • level — from 0 (store without compressing) through 9 (smallest output, slowest); 6 balances the two, as zlib’s default does

Returns

  • the compressed bytes

Panics

  • if level is not 0 through 9 (“compression level must be 0 through 9”)

decompress

static decompress(data: u8[]): io.IoResult<u8[]>

Decompresses raw DEFLATE data. Bytes after the end of the compressed data are ignored.

Parameters

  • data — the compressed bytes

Returns

  • the decompressed bytes; an is_invalid_input() error when data is not valid DEFLATE data or ends too soon

Zlib

struct Zlib

zlib (RFC 1950) data, compressed or decompressed in one call.

Example

import compress;

var packed = compress.Zlib.compress("hello".bytes());
assert(packed[0] == 0x78, "the zlib header");
assert(compress.Zlib.decompress(packed).value() == "hello".bytes(), "round trip");

Static methods

compress

static compress(data: u8[], level: int = 6): u8[]

Compresses data into a zlib stream.

Parameters

  • data — the bytes to compress
  • level — from 0 (store without compressing) through 9 (smallest output, slowest); 6 balances the two

Returns

  • the compressed bytes

Panics

  • if level is not 0 through 9 (“compression level must be 0 through 9”)

compress

static compress(data: u8[], dictionary: u8[], level: int = 6): u8[]

Compresses data into a zlib stream that starts from a preset dictionary: bytes the data is likely to repeat, such as the common keys of small JSON messages, which the stream can then refer back to without carrying them. The header names the dictionary by its Adler-32, and decompressing needs the same dictionary. Only its last 32 KiB are used.

Parameters

  • data — the bytes to compress
  • dictionary — the preset dictionary
  • level — from 0 (store without compressing) through 9 (smallest output, slowest); 6 balances the two

Returns

  • the compressed bytes

Panics

  • if level is not 0 through 9 (“compression level must be 0 through 9”)

Example

import compress;

var dictionary = "{\"name\": \"\", \"email\": \"\"}".bytes();
var data = "{\"name\": \"Ada\", \"email\": \"ada@example.com\"}".bytes();
var packed = compress.Zlib.compress(data, dictionary);
assert(compress.Zlib.decompress(packed, dictionary).value() == data, "round trip");

decompress

static decompress(data: u8[]): io.IoResult<u8[]>

Decompresses a zlib stream and checks its Adler-32 checksum. Bytes after the stream are ignored. A stream made with a preset dictionary needs the overload that takes one.

Parameters

  • data — the compressed bytes

Returns

  • the decompressed bytes; an is_invalid_input() error when data is not a valid zlib stream, ends too soon, fails its checksum, or needs a preset dictionary

decompress

static decompress(data: u8[], dictionary: u8[]): io.IoResult<u8[]>

Decompresses a zlib stream that may need a preset dictionary, and checks its Adler-32 checksum. A stream that does not ask for a dictionary decompresses as without one.

Parameters

  • data — the compressed bytes
  • dictionary — the preset dictionary the stream was compressed with

Returns

  • the decompressed bytes; an is_invalid_input() error when data is not a valid zlib stream, ends too soon, fails its checksum, or names a dictionary whose Adler-32 is not that of dictionary

Gzip

struct Gzip

gzip (RFC 1952) data, as in .gz files, compressed or decompressed in one call.

Example

import compress;
import io;

var packed = compress.Gzip.compress("hello".bytes());
io.File.write_bytes(io.Path("hello.txt.gz"), packed);
assert(compress.Gzip.decompress(packed).value() == "hello".bytes(), "round trip");

Static methods

compress

static compress(data: u8[], level: int = 6): u8[]

Compresses data into one gzip member with no file name and no modification time.

Parameters

  • data — the bytes to compress
  • level — from 0 (store without compressing) through 9 (smallest output, slowest); 6 balances the two

Returns

  • the compressed bytes

Panics

  • if level is not 0 through 9 (“compression level must be 0 through 9”)

decompress

static decompress(data: u8[]): io.IoResult<u8[]>

Decompresses gzip data, checking each member’s CRC-32 and length. Several members one after another decompress to their contents joined. The header’s file name, comment and extra field are skipped.

Parameters

  • data — the compressed bytes

Returns

  • the decompressed bytes; an is_invalid_input() error when data is not valid gzip data, ends too soon, fails a check, or has bytes after the last member

DecompressReader

struct DecompressReader

A Reader that decompresses another stream as it is read, such as a .gz file or an HTTP response body, without holding all of it in memory.

It reads its source in chunks of up to 64 KiB and keeps at most about 160 KiB of output, however large the data. Corrupt or truncated data is an is_invalid_input() error from read, after the bytes decoded before the problem; an error from the source is passed on.

Example

import compress;
import io;

var file = io.File.open(io.Path("notes.txt.gz")).value();
var reader = compress.DecompressReader(file, compress.Format.Gzip);
for (var line in reader.lines()) {
    print(line.value());
}

Constructors

DecompressReader()

construct(source: io.Reader, format: Format)

Makes a reader of the decompressed contents of source.

Parameters

  • source — the stream of compressed bytes
  • format — the framing of the compressed data

DecompressReader()

construct(source: io.Reader, format: Format, dictionary: u8[])

Makes a reader of the decompressed contents of source, which was compressed with a preset dictionary (see Zlib.compress). A zlib stream uses it when its header asks for it; raw DEFLATE data always starts from it.

Parameters

  • source — the stream of compressed bytes
  • format — Format.Zlib or Format.Raw
  • dictionary — the preset dictionary

Panics

  • if format is Format.Gzip (“gzip data has no preset dictionary”)

Methods

read

read(buffer: u8[], max: ulong): io.IoResult<ulong>

Decompresses up to max more bytes and appends them to buffer.

Parameters

  • buffer — the array the bytes are appended to
  • max — the most bytes to read

Returns

  • the number of bytes appended, 0 at the end of the compressed data; an error when the data is invalid or the source fails

read_all

read_all(): io.IoResult<u8[]>

Decompresses the rest of the stream.

Returns

  • the decompressed bytes not read yet; an error as for read

CompressWriter

struct CompressWriter

A Writer that compresses what is written to it and writes the result to another stream, such as a .gz file.

Compressed bytes reach the sink in pieces as blocks fill; finish() writes the rest and the format’s trailer, and must be called once at the end, since dropping the writer does not. It does not close the sink.

Example

import compress;
import io;

var file = io.File.create(io.Path("log.txt.gz")).value();
var writer = compress.CompressWriter(file, compress.Format.Gzip);
writer.print_line("first line");
writer.print_line("second line");
writer.finish();
file.close();

Constructors

CompressWriter()

construct(sink: io.Writer, format: Format, level: int = 6)

Makes a writer that compresses into sink.

Parameters

  • sink — the stream the compressed bytes are written to
  • format — the framing to write
  • level — from 0 (store without compressing) through 9 (smallest output, slowest); 6 balances the two

Panics

  • if level is not 0 through 9 (“compression level must be 0 through 9”)

CompressWriter()

construct(sink: io.Writer, format: Format, dictionary: u8[], level: int = 6)

Makes a writer that compresses into sink, starting from a preset dictionary (see Zlib.compress). A zlib header names the dictionary by its Adler-32; raw DEFLATE data carries no sign of it.

Parameters

  • sink — the stream the compressed bytes are written to
  • format — Format.Zlib or Format.Raw
  • dictionary — the preset dictionary; only its last 32 KiB are used
  • level — from 0 (store without compressing) through 9 (smallest output, slowest); 6 balances the two

Panics

  • if level is not 0 through 9 (“compression level must be 0 through 9”), or if format is Format.Gzip (“gzip data has no preset dictionary”)

Methods

write

write(data: u8[]): io.IoResult<ulong>

Compresses all of data.

Parameters

  • data — the bytes to compress

Returns

  • data.count; an error when writing to the sink fails

Panics

  • if finish() was called (“CompressWriter used after finish()”)

write_all

write_all(data: u8[]): io.IoResult<void>

Compresses all of data.

Parameters

  • data — the bytes to compress

Returns

  • an Ok result; an error when writing to the sink fails

Panics

  • if finish() was called (“CompressWriter used after finish()”)

finish

finish(): io.IoResult<void>

Compresses what is still buffered, writes the format’s trailer, and ends the stream. Later calls do nothing.

Returns

  • an Ok result; an error when writing to the sink fails

Checksums

Crc32 and Adler32: the checksums gzip and zlib streams carry, for checking data as it is read or written.

Source: runtime/compress/checksum.pep

Crc32

struct Crc32

The CRC-32 checksum (IEEE 802.3, the polynomial gzip, zip and PNG use), computed over data given in one or more pieces.

Example

import compress;

var crc = compress.Crc32();
crc.update("hello ".bytes());
crc.update("world".bytes());
assert(crc.value == compress.Crc32.of("hello world".bytes()), "same in pieces");
assert(compress.Crc32.of("123456789".bytes()) == 0xCBF43926, "the check value");

Properties

value

var value: uint { get; }

The checksum of everything given to update so far.

Constructor

Crc32()

construct()

Makes a checksum of no data yet (value is 0).

Static methods

of

static of(data: u8[]): uint

Returns the CRC-32 of data.

Parameters

  • data — the bytes to check

Returns

  • the checksum

Methods

update

update(data: u8[])

Adds data to the checksum.

Parameters

  • data — the next bytes of the data

reset

reset()

Starts over, as if no data had been given.

Adler32

struct Adler32

The Adler-32 checksum (RFC 1950, the one zlib streams carry), computed over data given in one or more pieces.

Example

import compress;

assert(compress.Adler32.of("Wikipedia".bytes()) == 0x11E60398, "the example");

Properties

value

var value: uint { get; }

The checksum of everything given to update so far.

Constructor

Adler32()

construct()

Makes a checksum of no data yet (value is 1).

Static methods

of

static of(data: u8[]): uint

Returns the Adler-32 of data.

Parameters

  • data — the bytes to check

Returns

  • the checksum

Methods

update

update(data: u8[])

Adds data to the checksum.

Parameters

  • data — the next bytes of the data

reset

reset()

Starts over, as if no data had been given.

The archive module

Archives: tar files, read entry by entry, extracted safely into a directory, or written.

Bring it in with import archive. Combine it with compress for .tar.gz: wrap the file in a compress.DecompressReader to read one, or in a compress.CompressWriter to write one. A damaged archive is an io.IoError whose is_invalid_input() holds.

Example

import archive;
import io;

main(): int {
    var out = io.ByteWriter();
    var writer = archive.TarWriter(out);
    writer.append_file("hello.txt", "hi\n".bytes());
    writer.finish();

    var reader = archive.TarReader(io.ByteReader(out.bytes));
    var entry = reader.next_entry().value().value();
    assert(entry.path == "hello.txt" && entry.size == 3, "the header");
    assert(reader.read_all().value() == "hi\n".bytes(), "the contents");
    return 0;
}

Source: runtime/archive/module.pep

Tar archives

TarReader and TarWriter: tar archives (POSIX ustar, with the pax and GNU extensions for long names), read entry by entry from a stream, extracted safely into a directory, or written.

Source: runtime/archive/tar.pep

EntryKind

enum EntryKind

What a tar entry is.

Cases

File

File

A regular file, whose contents follow the entry’s header.

Directory

Directory

A directory.

Symlink

A symbolic link to TarEntry.link_target.

HardLink

A hard link to the earlier entry named TarEntry.link_target.

Other

Other

Anything else: a device, a FIFO, or a type this reader does not know.

TarEntry

struct TarEntry

One entry of a tar archive: its header, without its contents.

Example

import archive;

var entry = archive.TarEntry("bin/run.sh", archive.EntryKind.File);
entry.mode = 493;   # octal 755: rwxr-xr-x

Fields

path

var path: String

The path in the archive, with / between components, as stored (a directory’s may end with /).

kind

var kind: EntryKind

What the entry is.

size

var size: ulong

The length of the contents in bytes; 0 for anything but a file.

mode

var mode: uint

The permission bits, such as 420 (octal 644, rw-r--r--).

modified

var modified: long

The modification time, in seconds since 1970-01-01 UTC.

var link_target: String

The target of a symlink or hard link; empty otherwise.

Constructor

TarEntry()

construct(path: String = "", kind: EntryKind = EntryKind.File)

Makes an entry with the given path and kind, mode 420 (octal 644) for a file and 493 (octal 755) otherwise, size 0 and modification time 0.

Parameters

  • path — the path in the archive
  • kind — what the entry is

TarReader

struct TarReader

Reads a tar archive from a stream, one entry at a time: next_entry() moves to the next entry and returns its header, then read (it is an io.Reader) reads that entry’s contents. Contents not read are skipped.

It reads POSIX ustar archives and the pax (x) and GNU (L, K) extensions for long paths, link targets and sizes, which is what GNU tar, bsdtar and Git (git archive, and the .tar.gz snapshots GitHub, GitLab and Codeberg serve) write. Global pax headers (g) are skipped. Wrap the source in a compress.DecompressReader to read a .tar.gz.

Example

import archive;
import compress;
import io;

var file = io.File.open(io.Path("release.tar.gz")).value();
var tar = archive.TarReader(compress.DecompressReader(file, compress.Format.Gzip));
while (true) {
    var entry = tar.next_entry().value();
    if (entry.is_empty()) {
        break;
    }
    print(entry.value().path);
}

Constructor

TarReader()

construct(source: io.Reader)

Makes a reader of the archive in source, before its first entry.

Parameters

  • source — the stream of the archive

Methods

next_entry

next_entry(): io.IoResult<TarEntry?>

Moves to the next entry, skipping what is left of the current one’s contents, and returns its header.

The archive ends at two zero blocks, as tar writes, or at the end of the stream between entries.

Returns

  • the next entry, or none at the end of the archive; an is_invalid_input() error for a damaged or truncated archive, or the source’s error

read

read(buffer: u8[], max: ulong): io.IoResult<ulong>

Reads up to max more bytes of the current entry’s contents and appends them to buffer.

Parameters

  • buffer — the array the bytes are appended to
  • max — the most bytes to read

Returns

  • the number of bytes appended, 0 at the end of the entry’s contents (or before the first next_entry()); an error when the archive is truncated or the source fails

read_all

read_all(): io.IoResult<u8[]>

Reads the rest of the current entry’s contents.

Returns

  • the bytes not read yet; an error as for read

extract

extract(target: io.Path, strip_components: ulong = 0): io.IoResult<void>

Extracts the rest of the archive into the directory target, creating it and any directories the entries need.

Each entry’s path is checked first, so that no archive can write outside target: an absolute path, a .. component, a \ or a : (which Windows would read as a separator or a drive) is an is_invalid_input() error. Files are written (replacing a file already there) and directories created; a hard link becomes a copy of the file it names, which must be an earlier entry. Devices and FIFOs are skipped.

Each file and directory gets the entry’s modification time and, on Linux and macOS, its permission bits less the process umask, as tar gives them by default, so a script keeps its executable bit; the setuid, setgid and sticky bits are never applied. A directory’s mode and time are set once everything inside it is written, so a read-only directory can still be filled. A directory that already exists keeps its own. On Windows and WASI permission bits are not applied.

Symlinks are made after every other entry, so no file is ever written through one, and only when they stay inside target: a link whose target is absolute or climbs out of target with .., or that would be made inside another symlink, is skipped. Windows, where making a symlink needs Developer Mode or an administrator, skips them all.

Parameters

  • target — the directory to extract into
  • strip_components — how many leading path components to drop from each entry, as tar’s --strip-components does: 1 drops the name-version/ directory that GitHub’s snapshots put everything in. An entry with no components left is skipped.

Returns

  • an Ok result once every entry is extracted; an is_invalid_input() error for an unsafe path or a damaged archive, or the error of a file that could not be written

Example

import archive;
import compress;
import io;

var file = io.File.open(io.Path("pepper-main.tar.gz")).value();
var tar = archive.TarReader(compress.DecompressReader(file, compress.Format.Gzip));
tar.extract(io.Path(".pep/deps/pepper"), strip_components = 1).value();

TarWriter

struct TarWriter

Writes a tar archive to a stream: each append writes one entry’s header and contents, and finish() writes the end-of-archive marker.

It writes POSIX ustar headers, and a pax header before an entry whose path or link target does not fit one (a path over 100 bytes that cannot be split at a / into 155 and 100), so every path is kept whole. Owner ids are 0 and owner names empty. Wrap the sink in a compress.CompressWriter to write a .tar.gz.

Example

import archive;
import io;

var out = io.ByteWriter();
var tar = archive.TarWriter(out);
tar.append_directory("docs");
tar.append_file("docs/readme.txt", "hello\n".bytes());
tar.finish();
assert(out.bytes.count == 4 * 512 + 2 * 512, "headers, contents, end marker");

Constructor

TarWriter()

construct(sink: io.Writer)

Makes a writer of a new archive into sink.

Parameters

  • sink — the stream the archive is written to

Methods

append_file

append_file(path: String, data: u8[], mode: uint = 420): io.IoResult<void>

Appends a regular file holding data.

Parameters

  • path — the file’s path in the archive, with / between components
  • data — the file’s contents
  • mode — the permission bits; 420 is octal 644 (rw-r--r--)

Returns

  • an Ok result; an error when writing to the sink fails

Panics

  • if finish() was called (“TarWriter used after finish()”)

append_directory

append_directory(path: String, mode: uint = 493): io.IoResult<void>

Appends a directory.

Parameters

  • path — the directory’s path in the archive; a final / is added when missing
  • mode — the permission bits; 493 is octal 755 (rwxr-xr-x)

Returns

  • an Ok result; an error when writing to the sink fails

Panics

  • if finish() was called (“TarWriter used after finish()”)
append_symlink(path: String, target: String): io.IoResult<void>

Appends a symbolic link.

Parameters

  • path — the link’s path in the archive
  • target — the path the link points to

Returns

  • an Ok result; an error when writing to the sink fails

Panics

  • if finish() was called (“TarWriter used after finish()”)

append

append(entry: TarEntry, data: u8[]): io.IoResult<void>

Appends an entry with the given header and contents. The size written is data.count for a file and 0 for anything else, whatever entry.size holds.

Parameters

  • entry — the entry’s header
  • data — the contents of a file entry; ignored for other kinds

Returns

  • an Ok result; an error when writing to the sink fails

Panics

  • if finish() was called (“TarWriter used after finish()”)

append_stream

append_stream(entry: TarEntry, source: io.Reader): io.IoResult<void>

Appends a file entry whose contents are read from source: exactly entry.size bytes, so a large file need not be in memory.

Parameters

  • entry — the entry’s header; its kind must be File
  • source — the stream holding the contents

Returns

  • an Ok result; an error when reading or writing fails, or an is_invalid_input() error when source ends before entry.size bytes (the archive is then incomplete)

Panics

  • if finish() was called (“TarWriter used after finish()”)
  • if entry.kind is not File (“append_stream needs a file entry”)

Example

var file = io.File.open(io.Path("big.bin")).value();
var entry = archive.TarEntry("big.bin");
entry.size = file.metadata().value().size;
tar.append_stream(entry, file);

finish

finish(): io.IoResult<void>

Writes the end-of-archive marker (two zero blocks). Later calls do nothing. It does not close the sink.

Returns

  • an Ok result; an error when writing to the sink fails

Entry paths

safe_components: checking an entry’s path before extracting it, so an archive cannot write outside the directory it is extracted into.

Source: runtime/archive/path.pep

safe_components

safe_components(path: String): String[]?

Splits the path of an archive entry into its components, or gives none when extracting it could write outside the target directory (“zip slip”): an absolute path, a .. component, a \ or a : (which Windows would read as a separator or a drive letter). Empty and . components are left out, so a//./b/ gives a and b.

TarReader.extract checks every entry with it, and so does the zip package; a reader of another archive format should too.

Parameters

  • path — the entry’s path as stored, with / between components

Returns

  • the components to join onto the target directory, or none for an unsafe path

Example

import archive;

assert(archive.safe_components("docs/./readme.md").value().count == 2, "safe");
assert(archive.safe_components("../etc/passwd").is_empty(), "climbs out");
assert(archive.safe_components("C:/Windows").is_empty(), "a drive");

The digest module

Cryptographic hashes: SHA-256, SHA-1 and BLAKE3, and HMAC.

Bring it in with import digest. Each hash has an incremental hasher (update any number of times, then finish) and a one-shot function, and both take bytes (u8[]) or a String (its UTF-8 bytes). A digest is a u8[]; to_hex writes it as text.

  • SHA-256: Sha256, sha256
  • SHA-1: Sha1, sha1, for formats that need it, such as git object ids
  • BLAKE3: Blake3, blake3, with keyed hashing, key derivation and output of any length
  • HMAC: Hmac, hmac_sha256, hmac_sha1, and equal_constant_time to check a MAC
  • Hexadecimal: to_hex and from_hex

The implementations are portable Pepper with no link dependency, unless the program is built with -D USE_LIBCRYPTO: then SHA-256 and SHA-1 call OpenSSL’s libcrypto (CNG on Windows), which uses the CPU’s SHA instructions. They are not hardened against timing side channels, which matters only when hashing secrets.

For the hash tables of HashMap and HashSet, use hash<T> instead: it is much faster, but neither cryptographic nor stable between versions.

Example

import digest;

main(): int {
    var id = digest.to_hex(digest.sha256("hello"));
    assert(id == "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", "SHA-256");
    return 0;
}

Source: runtime/digest/module.pep

SHA-256

SHA-256 (FIPS 180-4): 32-byte digests, as used to check downloads and sign data.

Source: runtime/digest/sha256.pep

Sha256

struct Sha256

An incremental SHA-256 hasher: feed it bytes with update, any number of times and in pieces of any size, then read the 32-byte digest with finish.

The digest of the input is the same however it was split between update calls. finish resets the hasher, so it can hash a new input.

Example

import digest;

var hasher = digest.Sha256();
hasher.update("hello ");
hasher.update("world");
assert(hasher.finish_hex() == digest.to_hex(digest.sha256("hello world")), "same digest");

Constructor

Sha256()

construct()

Creates a hasher in the initial state, with no input.

Methods

reset

reset()

Discards the input given so far, returning the hasher to its initial state.

update

update(bytes: u8[])

Hashes bytes as the next part of the input.

Parameters

  • bytes — the bytes to add

update

update(text: String)

Hashes the UTF-8 bytes of text (without a terminating NUL) as the next part of the input.

Parameters

  • text — the text to add

finish

finish(): u8[]

Returns the 32-byte digest of the input given since the hasher was created or last reset, then resets it.

Returns

  • the digest, most significant byte first

finish_hex

finish_hex(): String

Returns the digest as finish does, written as 64 lowercase hexadecimal digits.

Returns

  • the digest in hexadecimal

sha256

sha256(bytes: u8[]): u8[]

Returns the SHA-256 digest of bytes.

Parameters

  • bytes — the input

Returns

  • the 32-byte digest

Example

import digest;

var empty = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
assert(digest.to_hex(digest.sha256(Array<u8>())) == empty, "SHA-256 of nothing");

sha256

sha256(text: String): u8[]

Returns the SHA-256 digest of the UTF-8 bytes of text.

Parameters

  • text — the input

Returns

  • the 32-byte digest

Example

import digest;

var abc = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
assert(digest.to_hex(digest.sha256("abc")) == abc, "FIPS 180-4 example");

SHA-1

SHA-1 (FIPS 180-4): 20-byte digests, as git uses for object ids. SHA-1 is broken for collision resistance; use it to read existing formats, and SHA-256 or BLAKE3 for anything new.

Source: runtime/digest/sha1.pep

Sha1

struct Sha1

An incremental SHA-1 hasher: feed it bytes with update, any number of times and in pieces of any size, then read the 20-byte digest with finish.

SHA-1 is not collision resistant: someone can make two inputs with the same digest. Use it only where a format requires it, such as git object ids. finish resets the hasher, so it can hash a new input.

Example

import digest;

# The id git gives an empty file ("blob 0" and a NUL byte).
u8[] nul = [0];
var hasher = digest.Sha1();
hasher.update("blob 0");
hasher.update(nul);
assert(hasher.finish_hex() == "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391", "git blob id");

Constructor

Sha1()

construct()

Creates a hasher in the initial state, with no input.

Methods

reset

reset()

Discards the input given so far, returning the hasher to its initial state.

update

update(bytes: u8[])

Hashes bytes as the next part of the input.

Parameters

  • bytes — the bytes to add

update

update(text: String)

Hashes the UTF-8 bytes of text (without a terminating NUL) as the next part of the input.

Parameters

  • text — the text to add

finish

finish(): u8[]

Returns the 20-byte digest of the input given since the hasher was created or last reset, then resets it.

Returns

  • the digest, most significant byte first

finish_hex

finish_hex(): String

Returns the digest as finish does, written as 40 lowercase hexadecimal digits.

Returns

  • the digest in hexadecimal

sha1

sha1(bytes: u8[]): u8[]

Returns the SHA-1 digest of bytes.

Parameters

  • bytes — the input

Returns

  • the 20-byte digest

sha1

sha1(text: String): u8[]

Returns the SHA-1 digest of the UTF-8 bytes of text.

Parameters

  • text — the input

Returns

  • the 20-byte digest

Example

import digest;

assert(digest.to_hex(digest.sha1("abc")) == "a9993e364706816aba3e25717850c26c9cd0d89d", "FIPS 180-4 example");

BLAKE3

BLAKE3: 32-byte digests, faster than SHA-256 in hardware without SHA instructions, and the hash the compiler’s build cache keys objects with. Also a keyed hash (a MAC), key derivation, and output of any length.

Source: runtime/digest/blake3.pep

Blake3

struct Blake3

An incremental BLAKE3 hasher: feed it bytes with update, any number of times and in pieces of any size, then read the 32-byte digest with finish.

Blake3() is the default hash mode; Blake3.keyed and Blake3.derive_key make hashers for the other two modes, and finish(length) reads output of any length in each.

This is a portable implementation, one block at a time; it does not use SIMD or threads, so it runs well below the speed of the official C and Rust implementations. The digests are the same. finish resets the hasher, so it can hash a new input.

Example

import digest;

var hasher = digest.Blake3();
hasher.update("hello ");
hasher.update("world");
assert(hasher.finish_hex() == digest.to_hex(digest.blake3("hello world")), "same digest");

Constructor

Blake3()

construct()

Creates a hasher in the initial state, with no input.

Static methods

keyed

static keyed(key: u8[]): Blake3

Returns a hasher for BLAKE3’s keyed mode, a MAC: only someone with key can compute or check the digest of an input.

Parameters

  • key — the 32-byte secret key

Returns

  • a hasher with no input, keyed with key

Panics

  • if key is not 32 bytes

Example

import digest;

var key = digest.blake3("a key made some other way");
var mac = digest.Blake3.keyed(key);
mac.update("message");
assert(mac.finish().count == 32, "a 32-byte MAC");

derive_key

static derive_key(context: String): Blake3

Returns a hasher for BLAKE3’s key derivation mode: the digest of its input (the key material, such as a shared secret or a password hash) is a key for the purpose that context names.

context should be a hard-coded string, unique to the application and the purpose, such as "example.com 2026-10-16 session tokens v1"; different contexts give unrelated keys from the same material. Read the key with finish, or finish(length) for one of another length.

Parameters

  • context — the application and purpose the key is for

Returns

  • a hasher with no input, for keys in context

Example

import digest;

var hasher = digest.Blake3.derive_key("example.com 2026-10-16 session tokens v1");
hasher.update("the shared secret");
var key = hasher.finish();

Methods

reset

reset()

Discards the input given so far, returning the hasher to its initial state.

update

update(bytes: u8[])

Hashes bytes as the next part of the input.

Parameters

  • bytes — the bytes to add

update_range

update_range(bytes: u8[], start: ulong, count: ulong)

Hashes count bytes of bytes, from index start, as the next part of the input; those past the end of bytes are left out.

Parameters

  • bytes — the bytes
  • start — the index of the first byte to add
  • count — how many bytes to add

update

update(text: String)

Hashes the UTF-8 bytes of text (without a terminating NUL) as the next part of the input.

Parameters

  • text — the text to add

finish

finish(): u8[]

Returns the 32-byte digest of the input given since the hasher was created or last reset, then resets it.

Returns

  • the digest

finish

finish(length: ulong): u8[]

Returns length bytes of output for the input given since the hasher was created or last reset, then resets it: BLAKE3’s extended output. The first 32 bytes are the digest finish() returns, and a shorter output is a prefix of a longer one.

Parameters

  • length — how many bytes to return

Returns

  • the output

Example

import digest;

var long_output = digest.Blake3();
long_output.update("abc");
var bytes = long_output.finish(100);
assert(digest.to_hex(bytes).starts_with(digest.to_hex(digest.blake3("abc"))), "a prefix");

finish_hex

finish_hex(): String

Returns the digest as finish does, written as 64 lowercase hexadecimal digits.

Returns

  • the digest in hexadecimal

blake3

blake3(bytes: u8[]): u8[]

Returns the BLAKE3 digest of bytes.

Parameters

  • bytes — the input

Returns

  • the 32-byte digest

Example

import digest;

var empty = "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262";
assert(digest.to_hex(digest.blake3(Array<u8>())) == empty, "BLAKE3 of nothing");

blake3

blake3(text: String): u8[]

Returns the BLAKE3 digest of the UTF-8 bytes of text.

Parameters

  • text — the input

Returns

  • the 32-byte digest

HMAC

HMAC (RFC 2104): a message authentication code from a hash and a secret key, as webhook signatures, JWTs (HS256) and S3-style request signing use, and a constant-time comparison to check one.

Source: runtime/digest/hmac.pep

Hmac

struct Hmac<T>

An incremental HMAC hasher over the hash T: feed it the message with update, any number of times and in pieces of any size, then read the MAC with finish.

Only someone with the key can compute the MAC of a message, so a receiver that shares the key can check that the message is authentic. Compare a MAC you receive with equal_constant_time, not ==. finish resets the hasher to the same key, so it can sign a new message.

Type parameters

  • T — the hash: Sha256 or Sha1, or another hasher with a 64-byte block, a constructor taking no arguments, and update and finish methods

Example

import digest;

var mac = digest.Hmac<digest.Sha256>("key");
mac.update("The quick brown fox ");
mac.update("jumps over the lazy dog");
var expected = "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8";
assert(mac.finish_hex() == expected, "HMAC-SHA-256");

Constructors

Hmac()

construct(key: u8[])

Creates a hasher for MACs under key, with no input.

A key longer than 64 bytes is hashed with T first, as RFC 2104 says; one as long as T’s digest is the usual choice.

Parameters

  • key — the secret key

Hmac()

construct(key: String)

Creates a hasher for MACs under the UTF-8 bytes of key, with no input.

Parameters

  • key — the secret key

Methods

reset

reset()

Discards the input given so far, returning the hasher to its initial state under the same key.

update

update(bytes: u8[])

Adds bytes as the next part of the message.

Parameters

  • bytes — the bytes to add

update

update(text: String)

Adds the UTF-8 bytes of text (without a terminating NUL) as the next part of the message.

Parameters

  • text — the text to add

finish

finish(): u8[]

Returns the MAC of the message given since the hasher was created or last reset, then resets it.

Returns

  • the MAC, as long as T’s digest

finish_hex

finish_hex(): String

Returns the MAC as finish does, written as lowercase hexadecimal digits.

Returns

  • the MAC in hexadecimal

hmac_sha256

hmac_sha256(key: u8[], message: u8[]): u8[]

Returns the HMAC-SHA-256 of message under key.

Parameters

  • key — the secret key
  • message — the message to sign

Returns

  • the 32-byte MAC

hmac_sha256

hmac_sha256(key: String, message: String): u8[]

Returns the HMAC-SHA-256 of the UTF-8 bytes of message under the UTF-8 bytes of key.

Parameters

  • key — the secret key
  • message — the message to sign

Returns

  • the 32-byte MAC

Example

import digest;

var mac = digest.hmac_sha256("key", "The quick brown fox jumps over the lazy dog");
var expected = "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8";
assert(digest.to_hex(mac) == expected, "HMAC-SHA-256");

hmac_sha1

hmac_sha1(key: u8[], message: u8[]): u8[]

Returns the HMAC-SHA-1 of message under key, for formats that require it. HMAC-SHA-1 is still secure as a MAC, though SHA-1 alone is not.

Parameters

  • key — the secret key
  • message — the message to sign

Returns

  • the 20-byte MAC

hmac_sha1

hmac_sha1(key: String, message: String): u8[]

Returns the HMAC-SHA-1 of the UTF-8 bytes of message under the UTF-8 bytes of key.

Parameters

  • key — the secret key
  • message — the message to sign

Returns

  • the 20-byte MAC

equal_constant_time

equal_constant_time(a: u8[], b: u8[]): bool

Returns whether a and b hold the same bytes, taking the same time wherever they first differ.

Check a MAC with this rather than ==, which stops at the first difference: timing it would let an attacker find a valid MAC a byte at a time. The lengths are not secret, so arrays of different lengths return false at once.

Parameters

  • a — the first bytes, such as the MAC computed
  • b — the second bytes, such as the MAC received

Returns

  • true if a and b have the same length and bytes

Example

import digest;

var mac = digest.hmac_sha256("key", "message");
assert(digest.equal_constant_time(mac, digest.hmac_sha256("key", "message")), "a valid MAC");

Hexadecimal

Hexadecimal text for digests, and back.

Source: runtime/digest/hex.pep

to_hex

to_hex(bytes: u8[]): String

Returns bytes as lowercase hexadecimal text, two digits per byte, the way checksums and git object ids are usually written.

Parameters

  • bytes — the bytes to write, such as a digest from finish

Returns

  • a String of 2 * bytes.count characters from 0-9a-f

Example

import digest;

assert(digest.to_hex([0, 15, 255]) == "000fff", "two digits per byte");

from_hex

from_hex(text: String): Result<u8[]>

Returns the bytes that hexadecimal text stands for, two digits per byte, the reverse of to_hex. Upper- and lowercase digits are both accepted.

Parameters

  • text — hexadecimal text, such as a checksum

Returns

  • the bytes, or an error when text has an odd length or a character that is not a hexadecimal digit

Example

import digest;

var bytes = digest.from_hex("000fFF").value();
assert(bytes.count == 3 && bytes[2] == 255, "two digits per byte");
assert(digest.from_hex("abc").is_err(), "an odd length");

The random module

Random numbers: bytes from the operating system’s secure source, and Generator, a fast seedable generator for everything else.

random is a module: bring it in with import random (and write random.next_int(1..>6)) or import names directly with import [Generator, shuffle] from random.

  • Entropy: bytes, fill and secure_ulong, from the operating system, for keys, tokens and identifiers
  • Generators: Generator, seeded from entropy or from a fixed seed, and next_int, next_double, next_bool, choose and shuffle on this thread’s own generator

Example

import random;

main(): int {
    var die = random.next_int(1..>6);            # 1 to 6
    var names = ["ada", "grace", "edsger"];
    random.shuffle(names);
    var key = random.bytes(32);                   # 32 secure bytes
    var replay = random.Generator(42);            # the same numbers every run
    print(replay.next_double());
    return 0;
}

Source: runtime/random/module.pep

Entropy

Secure random bytes from the operating system: getentropy on Linux, Android, macOS, iOS and WASI, and BCryptGenRandom on Windows. They are unpredictable, so they suit keys, tokens and identifiers, but each call asks the kernel; a Generator is much faster for everything else.

Source: runtime/random/entropy.pep

Generators

Generator, a fast pseudo-random generator, and functions that use this thread’s own generator, so code that only needs a random number need not pass one around.

The algorithm is xoshiro256** (Blackman and Vigna): 256 bits of state, a period of 2^256 - 1, and statistically strong output. It is not secure: anyone who sees enough output can predict the rest, so keys and tokens come from random.bytes instead.

Source: runtime/random/generator.pep

The base64 module

Base64 (RFC 4648): bytes written as text, for HTTP basic authentication, data URIs, JSON Web Tokens, PEM files and WebSocket handshakes.

Bring it in with import base64. encode writes the standard alphabet (A-Z a-z 0-9 + /) with = padding; pass url_safe for the URL and file name alphabet (- and _ in place of + and /), and padded = false to leave the padding off, as JSON Web Tokens do. decode takes text with or without padding, and rejects anything else that is not base64, including whitespace.

Example

import base64;

main(): int {
    var text = base64.encode("user:secret".bytes());
    assert(text == "dXNlcjpzZWNyZXQ=", "standard, padded");
    var bytes = base64.decode(text).value();
    assert(bytes.count == 11 && bytes[0] == 'u', "round trip");
    assert(base64.encode([251, 255], true, false) == "-_8", "URL-safe, unpadded");
    return 0;
}

Source: runtime/base64/module.pep

encode

encode(bytes: u8[], url_safe: bool = false, padded: bool = true): String

Returns bytes as base64 text: four characters for each three bytes.

Parameters

  • bytes — the bytes to write
  • url_safe — true for the URL and file name alphabet, with - and _ in place of + and /
  • padded — false to leave off the = that pads the text to a multiple of four characters

Returns

  • the base64 text, empty when bytes is

Example

assert(base64.encode("hi".bytes()) == "aGk=", "padded");
assert(base64.encode("hi".bytes(), false, false) == "aGk", "unpadded");

decode

decode(text: String, url_safe: bool = false): Result<u8[]>

Returns the bytes that base64 text stands for.

The padding is optional, but when present it must complete the last group of four. The decoding is strict, so each text has one meaning: a character outside the alphabet (whitespace included), a length no encoding gives, or unused bits that are not zero is an error.

Parameters

  • text — base64 text, such as aGk= or aGk
  • url_safe — true for the URL and file name alphabet, with - and _ in place of + and /

Returns

  • the decoded bytes, or an error naming what is not base64

Example

var bytes = base64.decode("aGk").value();
assert(bytes.count == 2 && bytes[1] == 'i', "unpadded");
assert(base64.decode("a b=").is_err(), "a space");

The uuid module

UUIDs (RFC 9562): 128-bit identifiers, written as 0190163d-8694-739b-aea5-966c26f8ad91.

Bring it in with import uuid (or import [Uuid] from uuid). Uuid.v4() makes a random one; Uuid.v7() puts the time first, so ids made later sort later, which keeps database indexes compact. Both draw their random bits from the operating system’s secure source (random.bytes).

Example

import uuid;

main(): int {
    var id = uuid.Uuid.v4();
    var text = id.to_string();                       # such as 9b2c3c1e-...
    assert(uuid.Uuid.parse(text).value() == id, "round trip");
    assert(id.version() == 4, "random");
    return 0;
}

Source: runtime/uuid/module.pep

Uuid

[Value] struct Uuid

A UUID, as a value struct of two 64-bit halves: == compares them and hash hashes them, so a Uuid works as a map key.

Example

var id = Uuid.parse("0190163D-8694-739B-AEA5-966C26F8AD91").value();
assert(id.version() == 7, "the version digit");
assert(id.to_string() == "0190163d-8694-739b-aea5-966c26f8ad91", "lowercase");

Fields

high

var high: ulong

The first 8 bytes, the first byte in the top bits.

low

var low: ulong

The last 8 bytes, the first of them in the top bits.

Constructor

Uuid()

construct(high: ulong, low: ulong)

Makes the UUID with these halves; Uuid(0, 0) is the nil UUID.

Parameters

  • high — the first 8 bytes, the first byte in the top bits
  • low — the last 8 bytes, the first of them in the top bits

Static methods

v4

static v4(): Uuid

Returns a new random UUID (version 4): 122 random bits.

Returns

  • the UUID

Panics

  • if the operating system cannot give random bytes, which only a broken system does

v7

static v7(): Uuid

Returns a new time-ordered UUID (version 7): the wall clock’s Unix time in milliseconds in the first 48 bits, then 74 random bits. Ids made in different milliseconds sort by time, both as values and as text; ids made in the same millisecond are in no particular order.

Returns

  • the UUID

Panics

  • if the operating system cannot give random bytes, which only a broken system does

parse

static parse(text: String): Result<Uuid>

Returns the UUID that text writes: 32 hexadecimal digits, either case, in groups of 8, 4, 4, 4 and 12 separated by -.

Parameters

  • text — the UUID as text, such as 0190163d-8694-739b-aea5-966c26f8ad91

Returns

  • the UUID, or an error when text is not in that form

Methods

version

version(): int

Returns the version: 4 for v4, 7 for v7, 0 for the nil UUID.

Returns

  • the version digit, from 0 to 15

bytes

bytes(): u8[]

Returns the UUID’s 16 bytes, high first, as databases and binary formats store it.

Returns

  • 16 bytes

to_string

to_string(): String

Returns the UUID as 32 lowercase hexadecimal digits in groups of 8, 4, 4, 4 and 12, such as 0190163d-8694-739b-aea5-966c26f8ad91.

Returns

  • the UUID as text, 36 characters

The cli module

Command-line parsing: declare a program’s options and arguments, then parse them, with the help, version and error messages generated from the declarations.

Bring it in with import cli. The raw arguments stay where they are, in main(args: String[]) and process.arguments().

  • Parser: Parser, and the handles its declarations return: Flag, Argument, ArgumentList; and closest, which picks the name a misspelled one most likely meant
  • Completions: Shell, the shells Parser.completions writes a completion script for
  • Options: Option and Positional, the attributes that shape what Parser.add_options declares from a struct’s fields

Example

import cli;

main(args: String[]): int {
    var parser = cli.Parser("greet", "Greets someone");
    var loud = parser.flag("loud", "l", "shout the greeting");
    var name = parser.positional("name", "who to greet", required = false, default_value = "world");
    if (!parser.parse(args)) {
        return 2;
    }
    var greeting = $"hello, {name.value()}";
    print(loud.is_set ? greeting.upper() : greeting);
    return 0;
}

Source: runtime/cli/module.pep

Parser

Parser and the handles its declarations return: Flag, Argument and ArgumentList.

Source: runtime/cli/parser.pep

Parser

struct Parser

Declares a program’s command-line interface and parses its arguments.

Declare each option and positional argument with flag, option, multiple, positional and positionals; each returns a handle that reads its value once parse has run. parse also writes the help (--help), the version (--version) and clang-style errors, all from the declarations.

Options have a long name (--output), a one-letter short name (-o), or both. A value follows as --output a.out, --output=a.out, -o a.out or -oa.out; short flags combine (-vq is -v -q), and -- ends the options, so every argument after it is positional. A value is taken as it is, even when it starts with -. An option declared with an implicit_value takes a value only when it is attached (--emit-ast=json, -O3), and the implicit one when it is not. When an option that takes one value is given twice, the last value wins.

Subcommands (git add, pep-package update) are parsers of their own, made with command. Options before the command’s name belong to this parser, and everything after it to the command’s.

Example

import cli;

main(args: String[]): int {
    var parser = cli.Parser("pepper", "Compiles a Pepper program", version = "0.2.1");
    var output = parser.option("output", "o", "the executable to write", default_value = "a.out");
    var imports = parser.multiple("import-path", "I", "a directory to search for modules");
    var verbose = parser.flag("verbose", "v", "say what is happening");
    var source = parser.positional("source", "the program's entry file");
    if (!parser.parse(args)) {
        return 2;
    }
    print($"{source.value()} -> {output.value()}");
    return 0;
}

Properties

is_selected

var is_selected: bool { get; }

Whether the last parse reached this parser: whether this command was given. The top-level parser is selected whenever its arguments were parsed.

Constructor

Parser()

construct(name: String, about: String = "", version: String = "")

Makes a parser for the program name.

Parameters

  • name — the program’s name, as the help and errors show it
  • about — a sentence on what the program does, the help’s first line
  • version — the program’s version; when not empty, --version and -V print name version

Methods

flag

flag(name: String, short_name: String = "", help: String = "", global: bool = false): Flag

Declares an option that takes no value, such as --verbose.

Parameters

  • name — the long name, without --, or empty for an option with only a short name (-I)
  • short_name — the one-letter short name, without -, or empty for none
  • help — what the option does, for the help
  • global — whether the flag may also be given after the name of a command, at any depth (pkg add -v x as well as pkg -v add x)

Returns

  • the handle that tells whether the flag was given

Panics

  • if name or short_name is invalid or already declared, here, as a global option of a parser this one is a command of, or (for a global flag) in one of its commands

option

option( name: String, short_name: String = "", help: String = "", value_name: String = "", default_value: String = "", required: bool = false, global: bool = false, implicit_value: String = "" ): Argument<String>

Declares an option that takes one String value, such as --output a.out. option<T> declares one whose value is a number, a path or an enum case.

Parameters

  • name — the long name, without --, or empty for an option with only a short name (-I)
  • short_name — the one-letter short name, without -, or empty for none
  • help — what the option sets, for the help
  • value_name — what the help calls the value; empty for the long name in capitals (OUTPUT)
  • default_value — the value when the option is not given; empty for none
  • required — whether leaving the option out is an error
  • global — whether the option may also be given after the name of a command, at any depth
  • implicit_value — the value when the option is given without one; when not empty, a value must be attached (--name=value, -xvalue) and the next argument is never taken as one

Returns

  • the handle that reads the value

Panics

  • if name or short_name is invalid or already declared

option

option<T>( name: String, short_name: String = "", help: String = "", value_name: String = "", default_value: String = "", required: bool = false, global: bool = false, implicit_value: String = "" ): Argument<T>

Declares an option that takes one value of type T, such as --jobs 4 for option<int>. A value that does not read as a T fails the parse: invalid value 'x' for '--jobs': expected an integer.

T may be String, an integer type, float, double, io.Path, or an enum whose cases carry no values; a case is written in kebab case (DebugInfo is debug-info), and the help lists them.

Type parameters

  • T — the type of the value

Parameters

  • name — the long name, without --, or empty for an option with only a short name (-I)
  • short_name — the one-letter short name, without -, or empty for none
  • help — what the option sets, for the help
  • value_name — what the help calls the value; empty for the long name in capitals (JOBS)
  • default_value — the value when the option is not given, written as on the command line ("4"); empty for none
  • required — whether leaving the option out is an error
  • global — whether the option may also be given after the name of a command, at any depth
  • implicit_value — the value when the option is given without one, written as on the command line (-O alone as -O2); when not empty, a value must be attached (--name=value, -xvalue) and the next argument is never taken as one

Returns

  • the handle that reads the value

Panics

  • if name or short_name is invalid or already declared, or if default_value or implicit_value does not read as a T

Example

var jobs = parser.option<int>("jobs", "j", "how many at once", default_value = "4");

multiple

multiple( name: String, short_name: String = "", help: String = "", value_name: String = "", required: bool = false, global: bool = false ): ArgumentList<String>

Declares an option that may be given any number of times, each with a String value, such as -I src -I lib. multiple<T> declares one whose values are numbers, paths or enum cases.

Parameters

  • name — the long name, without --, or empty for an option with only a short name (-I)
  • short_name — the one-letter short name, without -, or empty for none
  • help — what the option adds, for the help
  • value_name — what the help calls the value; empty for the long name in capitals (IMPORT_PATH)
  • required — whether leaving the option out is an error
  • global — whether the option may also be given after the name of a command, at any depth

Returns

  • the handle that reads the values, in the order given

Panics

  • if name or short_name is invalid or already declared

multiple

multiple<T>( name: String, short_name: String = "", help: String = "", value_name: String = "", required: bool = false, global: bool = false ): ArgumentList<T>

Declares an option that may be given any number of times, each with a value of type T, such as -I src -I lib for multiple<io.Path>. T is one of the types option<T> takes.

Type parameters

  • T — the type of each value

Parameters

  • name — the long name, without --, or empty for an option with only a short name (-I)
  • short_name — the one-letter short name, without -, or empty for none
  • help — what the option adds, for the help
  • value_name — what the help calls the value; empty for the long name in capitals (IMPORT_PATH)
  • required — whether leaving the option out is an error
  • global — whether the option may also be given after the name of a command, at any depth

Returns

  • the handle that reads the values, in the order given

Panics

  • if name or short_name is invalid or already declared

positional

positional(name: String, help: String = "", required: bool = true, default_value: String = ""): Argument<String>

Declares the next positional argument, with a String value. Positional arguments are filled in the order they are declared. positional<T> declares one whose value is a number, a path or an enum case.

Parameters

  • name — what the help and errors call it
  • help — what it is, for the help
  • required — whether leaving it out is an error
  • default_value — the value when it is left out; empty for none

Returns

  • the handle that reads the value

Panics

  • if name is empty or already declared, if a required argument follows an optional one, if it follows positionals, or if the parser has commands

positional

positional<T>(name: String, help: String = "", required: bool = true, default_value: String = ""): Argument<T>

Declares the next positional argument, with a value of type T. T is one of the types option<T> takes.

Type parameters

  • T — the type of the value

Parameters

  • name — what the help and errors call it
  • help — what it is, for the help
  • required — whether leaving it out is an error
  • default_value — the value when it is left out, written as on the command line; empty for none

Returns

  • the handle that reads the value

Panics

  • if name is empty or already declared, if a required argument follows an optional one, if it follows positionals, if the parser has commands, or if default_value does not read as a T

positionals

positionals(name: String, help: String = "", required: bool = false): ArgumentList<String>

Declares a positional argument that takes every remaining value, such as a list of files. It must be the last positional argument. positionals<T> declares one whose values are numbers, paths or enum cases.

Parameters

  • name — what the help and errors call it
  • help — what the values are, for the help
  • required — whether at least one value must be given

Returns

  • the handle that reads the values, in the order given

Panics

  • if name is empty or already declared, if it follows positionals, or if the parser has commands

positionals

positionals<T>(name: String, help: String = "", required: bool = false): ArgumentList<T>

Declares a positional argument that takes every remaining value, each of type T, such as a list of files for positionals<io.Path>. It must be the last positional argument. T is one of the types option<T> takes.

Type parameters

  • T — the type of each value

Parameters

  • name — what the help and errors call it
  • help — what the values are, for the help
  • required — whether at least one value must be given

Returns

  • the handle that reads the values, in the order given

Panics

  • if name is empty or already declared, if it follows positionals, or if the parser has commands

command

command(name: String, about: String = ""): Parser

Declares a subcommand, such as add in pep-package add NAME, and returns its parser, on which to declare its own options and arguments.

A parser with commands requires one: the first argument that is not an option names it, and the arguments after it go to its parser. After parse, is_selected on the command’s parser tells whether it was the one given.

Parameters

  • name — the command’s name
  • about — what the command does, for the help

Returns

  • the command’s parser

Panics

  • if name is empty, starts with - or is already declared, or if this parser has positional arguments

add_options

add_options<T>(options: T)

Declares an option or positional argument for each public field of options, and has every successful parse store the values in those fields.

A field’s type picks what it declares: bool is a flag, String an option with one value, String? one that may be left out, and String[] one that may be given many times. An option is named after its field, with each _ written - (import_path is --import-path); [cli.Option] on a field gives it a short name, help, another name, or makes it required. [cli.Positional] makes a field the next positional argument instead.

A String field that holds a value when this is called keeps it as the default; an empty one has no default, so leaving the option out leaves it empty, or is an error for a positional argument. Every parse sets every field: a flag to whether it was given, a list to the values given.

The declarations are the same as the builder methods make, so the help and errors read the same, and the two styles mix on one parser.

Type parameters

  • T — the options struct

Parameters

  • options — the struct whose fields to declare and fill in

Example

import cli;

struct Options {
    [cli.Option("o", "the executable to write")]
    var output: String = "a.out";
    [cli.Option("v", "say what is happening")]
    var verbose: bool;
    [cli.Positional("the program's entry file")]
    var source: String;
}

main(args: String[]): int {
    var options = Options();
    var parser = cli.Parser("pepper", "Compiles a Pepper program");
    parser.add_options(options);
    if (!parser.parse(args)) {
        return 2;
    }
    print($"{options.source} -> {options.output}");
    return 0;
}

usage

usage(): String

Returns the one-line usage, such as usage: pepper [OPTIONS] <source>.

Returns

  • the usage line, without a line break

help

help(): String

Returns the help that --help prints: what the program does, the usage, and a line for each argument, option and command.

Returns

  • the help text, ending in a line break

completions

completions(shell: Shell): String

Returns a script that completes this program’s command line in shell: its options, commands and enum choices, and file names where a value is a String or an io.Path. A program usually prints it from a command of its own (pkg completions bash), for the user to install where Shell’s cases say.

Call it on the program’s parser, after the declarations.

Parameters

  • shell — the shell the script is for

Returns

  • the script, ending in a line break

Example

var parser = cli.Parser("pkg");
var shell = parser.option<cli.Shell>("completions", help = "print a completion script");
if (!parser.parse(args)) {
    return 2;
}
if (shell.is_set) {
    print(parser.completions(shell.value()));
}

try_parse

try_parse(args: String[]): Result<String?>

Parses args and fills in every handle, or reports the problem.

Writes nothing. Use it to handle errors, help and the version yourself; parse does it the usual way.

Parameters

  • args — the arguments as main receives them: the program’s name, then its arguments

Returns

  • some text when --help or --version was given (the program should print it and exit with 0), none when the arguments were parsed, or an error when they are wrong, with a clang-style message such as missing value for '-o'

parse

parse(args: String[]): bool

Parses args and fills in every handle.

For --help or --version it prints the text to standard output and ends the program with status 0. When the arguments are wrong it writes the error, the usage and a hint to standard error, such as

pepper: error: missing value for '-o'
usage: pepper [OPTIONS] <source>
Run 'pepper --help' for more information.

and returns false; exit with status 2, as the example does.

Parameters

  • args — the arguments as main receives them: the program’s name, then its arguments

Returns

  • true when the arguments were parsed, false after an error

usage_error

usage_error(message: String): int

Writes a usage error found after parse, such as two options that cannot be used together, as parse writes one: the error, this parser’s usage and a hint, to standard error.

Parameters

  • message — what is wrong, clang-style ('--test' cannot be used with '--lib')

Returns

  • 2, the status a program exits with after a usage error

Example

if (test.is_set and library.is_set) {
    return parser.usage_error("'--test' cannot be used with '--lib'");
}

closest

closest(name: String, candidates: Array<String>): String?

Returns the candidate closest to a misspelled name, for a “did you mean” suggestion: the one the fewest single-byte insertions, deletions and substitutions turn name into, when that is at most 2. Of equally close candidates the first wins.

Parameters

  • name — the name that matched nothing
  • candidates — the names it could have meant

Returns

  • the closest candidate, or none when none is within 2 edits

Example

var names = Array<String>();
names.append("build");
names.append("fmt");
var best = cli.closest("buidl", names);   # some("build")

Flag

struct Flag

An option that takes no value, from Parser.flag.

Properties

is_set

var is_set: bool { get; }

Whether the flag was given at least once.

occurrences

var occurrences: int { get; }

How many times the flag was given, so -vvv can mean more than -v.

Constructor

Flag()

construct(argument: _CliArgument)

Wraps a declaration. Parser.flag makes flags; this constructor is for the runtime.

Parameters

  • argument — the declaration

Argument

struct Argument<T>

An option or positional argument that takes one value, from Parser.option or Parser.positional.

Type parameters

  • T — the type of the value: String, or what option<T> or positional<T> declared

Properties

is_set

var is_set: bool { get; }

Whether a value was given on the command line; false when the argument was left out, even when it has a default.

Constructor

Argument()

construct(argument: _CliArgument)

Wraps a declaration. Parser.option and Parser.positional make arguments; this constructor is for the runtime.

Parameters

  • argument — the declaration

Methods

value

value(): T

Returns the value given, or the default when it was left out.

Returns

  • the value

Panics

  • if the argument was left out and has no default; check is_set or use value_or for an optional argument

value_or

value_or(fallback: T): T

Returns the value given, or fallback when it was left out.

Parameters

  • fallback — the value to use when the argument was left out

Returns

  • the value given, else the declared default, else fallback

ArgumentList

struct ArgumentList<T>

An option that may be given many times, or a positional argument that takes every remaining value, from Parser.multiple or Parser.positionals.

Type parameters

  • T — the type of each value: String, or what multiple<T> or positionals<T> declared

Properties

is_set

var is_set: bool { get; }

Whether at least one value was given.

Constructor

ArgumentList()

construct(argument: _CliArgument)

Wraps a declaration. Parser.multiple and Parser.positionals make argument lists; this constructor is for the runtime.

Parameters

  • argument — the declaration

Methods

values

values(): Array<T>

Returns the values given, in command-line order.

Returns

  • a new array of the values, empty when none was given

Options

The attributes that shape the options Parser.add_options declares from a struct’s fields.

Source: runtime/cli/options.pep

Option

[Attribute] struct Option

Sets how a field of an options struct is declared as an option: its short name, its help, and whether it is required or global. A field without it is still an option, named after the field.

Example

import cli;

struct Options {
    [cli.Option("o", "the executable to write")]
    var output: String = "a.out";
    [cli.Option("v", "say what is happening")]
    var verbose: bool;
}

Fields

short_name

const short_name: String = ""

The one-letter short name, without -, or empty for none.

help

const help: String = ""

What the option does, for the help.

name

const name: String = ""

The long name, without --; empty for the field’s name with each _ written - (import_path is --import-path).

value_name

const value_name: String = ""

What the help calls the value; empty for the long name in capitals.

required

const required: bool = false

Whether leaving the option out is an error.

global

const global: bool = false

Whether the option may also be given after the name of a command, at any depth.

Positional

[Attribute] struct Positional

Declares a field of an options struct as the next positional argument instead of an option. A T field takes one value, required unless the field starts with a value (its default; an empty String or a null io.Path is none); a T? field takes an optional one; a T[] field takes every value that is left.

Example

import cli;

struct Options {
    [cli.Positional("the program's entry file")]
    var source: String;
}

Fields

help

const help: String = ""

What the argument is, for the help.

name

const name: String = ""

What the help and errors call it; empty for the field’s name.

required

const required: bool = false

Whether leaving it out is an error, even when the field has a default; for a T[] field, whether at least one value must be given. A T? field ignores it.

Completions

Shell completion scripts generated from a Parser: Shell names the shells, and Parser.completions writes the script.

Source: runtime/cli/completions.pep

Shell

enum Shell

A shell that Parser.completions writes a completion script for.

Cases

Bash

Bash

GNU Bash: source the script, or put it in ~/.local/share/bash-completion/completions/NAME.

Zsh

Zsh

Zsh: put the script in a directory on $fpath as _NAME.

Fish

Fish

fish: put the script in ~/.config/fish/completions/NAME.fish.

Powershell

Powershell

PowerShell (powershell on a command line): dot-source the script from your profile.

The http module

HTTP/1.1 and HTTP/2 clients and servers, over TCP or TLS.

Bring it in with import http. A Client sends a Request to an http:// or https:// URL and returns the Response; a Server accepts connections on every worker of async.run and answers each request with a handler function. Bodies are read into memory, or streamed through an io.Reader (Client.open, set_reader). Over TLS the client and server choose HTTP/2 through ALPN when both have it, with the same Request and Response types.

  • Messages: Headers, Request, and Response
  • Client: Client, Session and BodyReader
  • Server: Server and reason_phrase

Example

import async;
import http;
import io;

main(): int {
    async.run(() => {
        var server = http.Server.bind(io.SocketAddress.parse("127.0.0.1:0").value()).value();
        var port = server.local_address().value().port();
        async.spawn<void>(() => {
            server.serve((request) => {
                return http.Response(200).set_text("hello");
            });
        });
        var response = http.Client().get("http://127.0.0.1:" + to_string(port) + "/").value();
        assert(response.text() == "hello", "served");
    });
    return 0;
}

Source: runtime/http/module.pep

Requests and responses

HTTP messages: Headers, Request and Response, the values a Client sends and receives and a Server handler takes and returns.

Source: runtime/http/message.pep

Headers

struct Headers

HTTP header fields, by name. Names ignore ASCII case: they are stored lowercased, so Content-Type and content-type are the same field. A name may hold several values, kept in the order added. Different names have no defined order, as HTTP allows.

Example

var headers = http.Headers();
headers.add("Accept", "text/html");
headers["accept"] = "application/json";   # replaces it
assert(headers["ACCEPT"] == "application/json", "one value");

Properties

count

var count: ulong { get; }

The number of field names.

Constructor

Headers()

construct()

Creates an empty set of fields.

Methods

names

names(): String[]

Returns the field names, lowercased, in no defined order.

Returns

  • a new array with one entry per name

values

values(name: String): String[]

Returns every value of the field named name, in the order added.

Parameters

  • name — the field name, in any case

Returns

  • a new array of the values; empty when no field has that name

add

add(name: String, value: String)

Adds a value to the field named name, keeping any it has.

Parameters

  • name — the field name, in any case
  • value — the field value

set

set(name: String, value: String)

Replaces every value of the field named name with value.

Parameters

  • name — the field name, in any case
  • value — the field value

get

get(name: String): String?

Returns the first value of the field named name.

Parameters

  • name — the field name, in any case

Returns

  • the value, or none when no field has that name

has

has(name: String): bool

Reports whether a field is named name.

Parameters

  • name — the field name, in any case

Returns

  • true if a field has that name

remove

remove(name: String)

Removes the field named name and all its values.

Parameters

  • name — the field name, in any case

operator []

[](name: String): String

Reads headers[name]: the first value of the field named name, or an empty string when there is none. Use get to tell an empty value from a missing field.

Parameters

  • name — the field name, in any case

Returns

  • the first value, or ""

operator []

[](name: String, value: String): String

Writes headers[name] = value: replaces every value of the field, as set does.

Parameters

  • name — the field name, in any case
  • value — the field value

Returns

  • value, the value of the assignment expression

Request

struct Request

An HTTP request.

A Client sends one: build it with a method and an absolute URL, then add headers and a body. A Server handler receives one, with url set to the target from the request line (/path?query) and the body read in full.

Example

var request = http.Request("POST", "http://localhost:8080/items").set_text("name=pepper");
request.headers["Content-Type"] = "application/x-www-form-urlencoded";

Fields

method

var method: String

The method, such as GET or POST.

url

var url: String

For a client, the absolute URL (https://host/path?query); for a server handler, the request target (/path?query).

headers

var headers: Headers

The header fields. A client adds Host, Content-Length (or Transfer-Encoding) and Connection itself.

Constructor

Request()

construct(method: String = "GET", url: String = "/")

Creates a request with no headers and no body.

Parameters

  • method — the method, such as GET
  • url — the absolute URL to send it to

Methods

set_text

set_text(text: String): Request

Sets the body to the UTF-8 bytes of text.

Parameters

  • text — the body

Returns

  • this request

set_body

set_body(data: u8[]): Request

Sets the body to data, which may be any bytes (see body).

Parameters

  • data — the body’s bytes

Returns

  • this request

set_reader

set_reader(source: io.Reader, length: long = -1): Request

Sets the body to the bytes source gives, read as the request is sent rather than held in memory: a large file, or the output of a compress.CompressReader. With a length the client sends Content-Length; without one it sends the body chunked (Transfer-Encoding: chunked), which every HTTP/1.1 server accepts.

The source is read once, so a client cannot send the request again (to follow a redirect, for instance).

Parameters

  • source — the stream the body is read from, to its end
  • length — the number of bytes source gives, or -1 when it is not known

Returns

  • this request

Example

var file = io.File.open(io.Path("upload.bin")).value();
var request = http.Request("PUT", "http://localhost:8080/files/upload.bin").set_reader(file);

uri

uri(): Result<Uri>

Parses url as a URI reference: an absolute URL for a request a client sends, or the target (/items?page=2) for one a server received.

Returns

  • the parsed URL, or an error when url is not a valid URI reference (see Uri.parse)

path

path(): String

Returns the path of url, with its percent-escapes: / when the path is empty.

Returns

  • the path, such as /items, or an empty string when url is not a valid URI reference

query

query(): String

Returns the query of url: the text after ?, with its percent-escapes (see Uri.decode).

Returns

  • the query without the ?, or an empty string for none or when url is not a valid URI reference

body

body(): u8[]

Returns the body’s bytes, as they were sent or received: use it for a binary body. The array is the body itself, not a copy. It is empty when the body comes from set_reader.

Returns

  • the body; empty for none

text

text(): String

Returns the body as text, decoded as UTF-8. For a binary body, call body() instead: here each byte that is not valid UTF-8 becomes U+FFFD, the replacement character.

Returns

  • the body’s bytes decoded as a String

Response

struct Response

An HTTP response.

Client.send returns one with the body read in full; Client.open returns one whose body is read as it arrives, through reader(). A Server handler returns one: build it with a status, then add headers and a body, in memory (set_text, set_body) or streamed (set_reader). The server adds Content-Length (or Transfer-Encoding: chunked) itself.

Example

handle(request: http.Request): http.Response {
    var response = http.Response(200).set_text("hello");
    response.headers["Content-Type"] = "text/plain";
    return response;
}

Fields

status

var status: int

The status code, such as 200 or 404.

reason

var reason: String

The reason phrase, such as OK; the server sends the standard one for the status when this is empty.

headers

var headers: Headers

The header fields.

url

var url: String

For a response a Client returns, the URL it came from: the request’s, or after redirects the last one’s. Empty otherwise.

Constructor

Response()

construct(status: int = 200)

Creates a response with no headers and no body.

Parameters

  • status — the status code

Methods

set_text

set_text(text: String): Response

Sets the body to the UTF-8 bytes of text.

Parameters

  • text — the body

Returns

  • this response

set_body

set_body(data: u8[]): Response

Sets the body to data, which may be any bytes (see body).

Parameters

  • data — the body’s bytes

Returns

  • this response

set_reader

set_reader(source: io.Reader, length: long = -1): Response

Sets the body to the bytes source gives, which the server reads and sends as it goes rather than holding them in memory. With a length the server sends Content-Length; without one it sends the body chunked to an HTTP/1.1 client, and to an HTTP/1.0 client sends it unframed and closes the connection after it.

Parameters

  • source — the stream the body is read from, to its end
  • length — the number of bytes source gives, or -1 when it is not known

Returns

  • this response

Example

download(request: http.Request): http.Response {
    var file = io.File.open(io.Path("large.bin")).value();
    return http.Response(200).set_reader(file);
}

body

body(): u8[]

Returns the body’s bytes, as they were sent or received: use it for a binary body. The array is the body itself, not a copy. It is empty for a response from Client.open, whose body is read through reader(), and for one whose body comes from set_reader.

Returns

  • the body; empty for none

reader

reader(): io.Reader

Returns the body as a stream. For a response from Client.open it is the connection’s BodyReader, which reads the body as it arrives and ends where the body ends; read it once. For a response whose body comes from set_reader it is that source, and otherwise an io.ByteReader over body().

Returns

  • the stream of the body’s bytes

Example

var response = http.Client().open(http.Request("GET", url)).value();
var file = io.File.create(io.Path("download.bin")).value();
for (var chunk in response.reader().chunks(65536)) {
    file.write_all(chunk.value());
}

text

text(): String

Returns the body as text, decoded as UTF-8. For a binary body, call body() instead: here each byte that is not valid UTF-8 becomes U+FFFD, the replacement character.

Returns

  • the body’s bytes decoded as a String

is_success

is_success(): bool

Reports whether the status is 2xx.

Returns

  • true for a status from 200 to 299

Client

The HTTP client: Client sends a Request over HTTP or HTTPS and returns the Response.

Source: runtime/http/client.pep

MAX_RESPONSE_BODY

const MAX_RESPONSE_BODY: ulong = 67108864

The largest response body a Client reads: 64 MiB.

Client

[Shared] struct Client

Sends HTTP/1.1 and HTTP/2 requests and reads their responses.

Each request opens a new connection (plain TCP for http://, TLS for https://), sends the request with Connection: close, and reads the response: send reads the whole body into memory, and open returns once the header fields arrive, with the body to read as a stream. To send requests on connections kept open between them, use a Session (client.session()).

For https:// the client offers HTTP/2 and HTTP/1.1 through ALPN and speaks the one the server chooses; the request and response are the same either way. Over HTTP/2 the response’s reason is the standard phrase for its status. Plain http:// uses HTTP/1.1.

Redirects (301, 302, 303, 307 and 308 with a Location) are followed, up to max_redirects of them, as RFC 9110 describes: 303, and 301 or 302 after a POST, become a GET without the body; 307 and 308 repeat the method and body. A request whose body comes from Request.set_reader cannot be repeated, so its 307 or 308 is returned as it is. When a redirect leaves the origin (scheme, host and port), Authorization, Proxy-Authorization and Cookie are dropped. Make a client with max_redirects 0 to get every redirect back as it is.

A client is [Shared]: make one and use it from every thread and task. In a task, a request parks only the task while it waits.

Example

import http;

main(): int {
    var client = http.Client();
    var response = client.get("https://example.com/").value();
    print(to_string(response.status));   # 200
    print(response.text());
    return 0;
}

Fields

max_redirects

const max_redirects: int

The most redirects one request follows; 0 follows none.

Constructors

Client()

construct(max_redirects: int = 10)

Creates a client that trusts the system’s root certificates for https:// URLs. Where TLS is not available (Linux or macOS without OpenSSL 3), http:// URLs still work and https:// ones fail.

Parameters

  • max_redirects — the most redirects one request follows; 0 returns every redirect as it is

Client()

construct(connector: tls.TlsConnector, max_redirects: int = 10)

Creates a client whose https:// connections use connector, for instance one that trusts a private certificate authority. The client sets the connector’s ALPN protocols to h2 and http/1.1.

Parameters

  • connector — the TLS connector to open https:// connections with
  • max_redirects — the most redirects one request follows; 0 returns every redirect as it is

Methods

get

get(url: String): io.IoResult<Response>

Sends a GET request for url.

Parameters

  • url — an absolute http:// or https:// URL

Returns

  • the response; the errors of send

post

post(url: String, content_type: String, body: String): io.IoResult<Response>

Sends a POST request for url with body as a Content-Type of content_type.

Parameters

  • url — an absolute http:// or https:// URL
  • content_type — the body’s media type, such as application/json
  • body — the body

Returns

  • the response; the errors of send

send

send(request: Request): io.IoResult<Response>

Sends request and reads the response, body and all.

The client adds Host, Content-Length (when there is a body, or for POST, PUT and PATCH; Transfer-Encoding: chunked for a body from Request.set_reader of unknown length) and Connection: close, replacing any the request has.

Parameters

  • request — the request; its url must be absolute

Returns

  • the response after any redirects, whatever its status; an error with is_invalid_input() for a URL that is not http:// or https://, the errors of io.TcpStream.connect_host and tls.TlsConnector.connect, or an error when the response is not valid HTTP/1.x, its body is larger than MAX_RESPONSE_BODY, or there are more than max_redirects redirects

open

open(request: Request): io.IoResult<Response>

Sends request and returns the response as soon as its header fields arrive, with the body still to read: response.reader() returns it as a BodyReader, which reads it from the connection as it arrives, with no limit on its size. Use it for a large download, writing each chunk to a file or through a compress.DecompressReader.

The connection closes when the body has been read to its end, when the BodyReader is closed, or when the last reference to it goes. The request goes out, and redirects are followed, as for send.

Parameters

  • request — the request; its url must be absolute

Returns

  • the response with its body unread; the errors of send, except that the body may be any size. Errors reading the body come from the BodyReader.

Example

var response = client.open(http.Request("GET", "https://example.com/large.tar.gz")).value();
var archive = compress.DecompressReader(response.reader(), compress.Format.Gzip);
var file = io.File.create(io.Path("large.tar")).value();
for (var chunk in archive.chunks(65536)) {
    file.write_all(chunk.value());
}

session

session(): Session

Returns a new Session that sends requests with this client’s settings and keeps their connections open for the requests after them.

Returns

  • the session, with no connections yet

Session

struct Session

A run of requests that share connections: each connection stays open after its response (HTTP/1.1 keep-alive) and the next request to the same scheme, host and port uses it again, saving the TCP and TLS handshakes. Make one with client.session(); it sends requests and follows redirects with that client’s settings.

A connection goes back to the session once its response body has been read to its end (which send does), unless the server asked to close it. Up to max_idle_per_host connections wait for each scheme, host and port; one idle for longer than idle_timeout is closed instead of used. A request that finds its reused connection closed by the server is sent again on a new one, unless its body came from Request.set_reader.

Over HTTP/2 the session keeps one connection per origin, which every request to that origin shares at once on a stream of its own, so tasks started with spawn_local can send through one session concurrently.

A session is not [Shared]: use it from one thread or task. Its connections close when the last reference to it (and to the BodyReaders it returned) goes, or at close().

Example

var session = http.Client().session();
for (var page in 1..>3) {
    var response = session.get($"https://example.com/items?page={page}").value();
    print(response.text());
}
# One connection carried all three requests.

Fields

client

var client: Client

The client whose settings the session uses: its TLS connector and max_redirects.

Properties

idle_timeout

var idle_timeout: os.Duration { get; set; }

How long a connection may wait unused before the session closes it rather than send on it: 90 seconds unless changed. Servers close idle connections too, often sooner.

max_idle_per_host

var max_idle_per_host: ulong { get; set; }

The most idle connections kept for one scheme, host and port: 4 unless changed. 0 keeps none, so every request uses a new connection.

idle_count

var idle_count: ulong { get; }

The number of idle connections the session holds, for every host.

Constructor

Session()

construct(client: Client)

Creates a session with no connections yet; client.session() does the same.

Parameters

  • client — the client whose settings the session uses

Methods

get

get(url: String): io.IoResult<Response>

Sends a GET request for url, as Client.get does.

Parameters

  • url — an absolute http:// or https:// URL

Returns

  • the response; the errors of Client.send

post

post(url: String, content_type: String, body: String): io.IoResult<Response>

Sends a POST request for url with body as a Content-Type of content_type, as Client.post does.

Parameters

  • url — an absolute http:// or https:// URL
  • content_type — the body’s media type, such as application/json
  • body — the body

Returns

  • the response; the errors of Client.send

send

send(request: Request): io.IoResult<Response>

Sends request on an idle connection to its host if there is one, and reads the response, body and all, as Client.send does. The request keeps the connection open, so the client sends no Connection: close.

Parameters

  • request — the request; its url must be absolute

Returns

  • the response; the errors of Client.send

open

open(request: Request): io.IoResult<Response>

Sends request as send does and returns at the response’s header fields, as Client.open does. The connection goes back to the session when the body has been read to its end; closing the BodyReader early closes the connection.

Parameters

  • request — the request; its url must be absolute

Returns

  • the response with its body unread; the errors of Client.open

close

close()

Closes every idle connection. The session stays usable: later requests open new connections.

BodyReader

struct BodyReader

The body of a response from Client.open, read from the connection as it arrives. It is an io.Reader: read it with read, chunks or lines, or hand it to anything that takes a stream. It undoes the framing (a Content-Length, chunks, or the rest of the connection) and ends where the body ends, then closes the connection.

Properties

is_done

var is_done: bool { get; }

Reports whether the body has been read to its end, or the reader closed.

Constructor

BodyReader()

construct(reader: _HttpReader, mode: int, length: ulong, reusable: bool)

Makes a reader of a body framed by mode on reader’s connection. Client.open makes it; there is no other use.

Parameters

  • reader — the connection’s reader, positioned at the body
  • mode — how the body is framed
  • length — the body’s length, for a Content-Length body
  • reusable — whether the server keeps the connection open after the body

Methods

read

read(buffer: u8[], max: ulong): io.IoResult<ulong>

Waits until some of the body has arrived, then appends up to max bytes of it to buffer.

Parameters

  • buffer — the array the bytes are appended to
  • max — the most bytes to read

Returns

  • the number of bytes appended, 0 at the end of the body; an error when the connection fails, closes before the body ends, or the chunked framing is not valid

close

close()

Stops reading the body and closes the connection. Reading then returns 0, as at the end of the body. Closing a reader already done does nothing.

Server

The HTTP server: Server accepts HTTP/1.1 and HTTP/2 connections on every worker of async.run and answers each request with a handler function.

Source: runtime/http/server.pep

MAX_REQUEST_BODY

const MAX_REQUEST_BODY: ulong = 16777216

The largest request body a Server reads: 16 MiB. A larger one gets 413 Content Too Large.

Server

[Shared] struct Server

An HTTP/1.1 and HTTP/2 server.

Server.bind listens on an address (Server.bind_tls serves HTTPS); serve then accepts connections on every worker of the running async.run and serves each connection in a task of its own, calling the handler for every request. Connections are kept open between requests unless the client asks to close (or speaks HTTP/1.0).

The handler gets the Request with its body read in full and returns the Response; the server adds Content-Length (and Connection: close when it will close). A request the server cannot parse gets 400 Bad Request, one with header fields over 64 KiB 431 Request Header Fields Too Large, and one with a body over MAX_REQUEST_BODY 413 Content Too Large; the connection then closes.

HTTP/2 is served to a client that chooses it through ALPN (see bind_tls) or that starts a plain TCP connection with the HTTP/2 preface (prior knowledge): each stream is a request, answered by the same handler in a task of its own.

Example

import async;
import http;
import io;

hello(request: http.Request): http.Response {
    var response = http.Response(200).set_text("hello from " + request.path());
    response.headers["Content-Type"] = "text/plain";
    return response;
}

main(): int {
    async.run(() => {
        var server = http.Server.bind(io.SocketAddress.parse("0.0.0.0:8080").value()).value();
        server.serve(hello);
    });
    return 0;
}

Constructor

Server()

construct(listener: io.SharedTcpListener, secure: bool, acceptor: tls.TlsAcceptor)

Wraps a listening socket. Use Server.bind or Server.bind_tls.

Parameters

  • listener — the listening socket
  • secure — whether to serve HTTPS with acceptor
  • acceptor — the certificate for HTTPS; ignored for HTTP

Static methods

bind

static bind(address: io.SocketAddress): io.IoResult<Server>

Listens for HTTP connections on address.

Parameters

  • address — the local address (port 0 for any free port)

Returns

  • the server; the errors of io.TcpListener.bind

bind_tls

static bind_tls(address: io.SocketAddress, acceptor: tls.TlsAcceptor): io.IoResult<Server>

Listens for HTTPS connections on address, presenting acceptor’s certificate. It sets the acceptor’s ALPN protocols to h2 and http/1.1, so clients that offer HTTP/2 get it.

Parameters

  • address — the local address (port 0 for any free port)
  • acceptor — the certificate and key to serve with

Returns

  • the server; the errors of io.TcpListener.bind

Methods

local_address

local_address(): io.IoResult<io.SocketAddress>

Returns the address the server listens on, with the port the system chose for port 0.

Returns

  • the local address, or a platform error

serve

serve(handler: shared (Request) => Response)

Serves connections until the task is cancelled (when async.run ends): accepts on every worker and serves each connection in a task on the worker that accepted it.

Parameters

  • handler — answers one request; it runs in the connection’s task, so it may wait (on sockets, files, locks) without holding up other connections

Panics

  • outside async.run

reason_phrase

reason_phrase(status: int): String

Returns the standard reason phrase for an HTTP status code.

Parameters

  • status — the status code

Returns

  • the phrase, such as Not Found for 404; an empty string for a code without one

The tls module

Encrypted connections: TLS 1.2 and 1.3 over TCP, for clients and servers.

Bring it in with import tls. A TlsConnector opens client connections and checks each server’s certificate; a TlsAcceptor accepts connections with a certificate and key from PEM files; a TlsStream reads and writes like an io.TcpStream. In a task, a call that has to wait parks only the task.

On Linux and macOS the module uses OpenSSL 3, loaded when the first connector or acceptor is made; on Windows it uses SChannel.

  • Streams: TlsConnector, TlsAcceptor, and TlsStream

Example

import tls;

main(): int {
    var connector = tls.TlsConnector.create().value();
    var stream = connector.connect_host("localhost", 8443).value();
    stream.write_all("ping");
    var reply = Array<u8>();
    while (stream.read(reply, 4096).value() > 0) {
    }
    return 0;
}

Source: runtime/tls/module.pep

Connectors and streams

TLS connections: TlsConnector opens client connections, TlsAcceptor accepts server connections, and TlsStream is one encrypted connection over a TcpStream.

Source: runtime/tls/stream.pep

TlsConnector

[Shared] struct TlsConnector

Opens TLS client connections, checking each server’s certificate.

A connector trusts the system’s root certificates; add_root_certificate trusts more, such as a test certificate authority. Each connect runs the TLS handshake over a connected TcpStream and checks that the server’s certificate is valid and issued for the host name given. Only TLS 1.2 and 1.3 are offered.

A server that asks for a client certificate (mutual TLS) gets the one set with set_identity; without one, the connector presents none.

A connector is [Shared]: make one and use it from every thread and task. Configure it (add_root_certificate, set_identity) before the first connect.

See also

  • TlsStream

Example

import tls;

var connector = tls.TlsConnector.create().value();
var stream = connector.connect_host("localhost", 8443).value();
stream.write_all("ping");

Constructor

TlsConnector()

construct(context: __ptr<void>)

Wraps a platform context. Use TlsConnector.create.

Parameters

  • context — the platform’s client context, or null for none

Static methods

create

static create(): io.IoResult<TlsConnector>

Creates a connector that trusts the system’s root certificates.

Returns

  • the connector; an error when no TLS library is available (on Linux and macOS, OpenSSL 3: libssl.so.3, or Homebrew’s or MacPorts’ libssl.3.dylib)

Methods

add_root_certificate

add_root_certificate(path: io.Path): io.IoResult<void>

Also trusts the certificates in the PEM file at path, for servers whose certificates the system does not trust, such as a private or test certificate authority.

Parameters

  • path — a file of one or more PEM certificates

Returns

  • an Ok result; an error when the file cannot be read or holds no certificate

set_identity

set_identity(certificate_chain: io.Path, private_key: io.Path): io.IoResult<void>

Presents the certificate chain in certificate_chain, with the private key in private_key, to servers that ask for a client certificate (mutual TLS, as a TlsAcceptor does after require_client_certificate). A later call replaces the identity.

Parameters

  • certificate_chain — a PEM file: the client’s certificate first, then any intermediate certificates
  • private_key — a PEM file with the certificate’s private key (on Windows, PKCS #8: BEGIN PRIVATE KEY)

Returns

  • an Ok result; an error when a file cannot be read or the key does not match the certificate

Example

var connector = tls.TlsConnector.create().value();
connector.set_identity(io.Path("client.pem"), io.Path("client-key.pem")).value();
var stream = connector.connect_host("internal.example.com", 443).value();

set_protocols

set_protocols(protocols: String[]): io.IoResult<void>

Offers protocols to every server through ALPN (RFC 7301), most preferred first, such as ["h2", "http/1.1"]. After connect, TlsStream.protocol names the one the server chose, if it chose one. Call it before the first connect; a later call replaces the list.

Parameters

  • protocols — the protocol names, each 1 to 255 bytes

Returns

  • an Ok result; an error when the list is empty or a name is empty or longer than 255 bytes

Example

var connector = tls.TlsConnector.create().value();
connector.set_protocols(["h2", "http/1.1"]).value();
var stream = connector.connect_host("example.com", 443).value();
var http2 = stream.protocol() == Optional<String>.Value("h2");

connect

connect(stream: io.TcpStream, host: String): io.IoResult<TlsStream>

Runs the TLS handshake as the client over stream and checks the server’s certificate.

Parameters

  • stream — a connected TCP stream; the TlsStream keeps it, and nothing else should read or write it afterwards
  • host — the name the certificate must be for, also sent to the server as the requested name (SNI); an IP address is checked against the certificate’s addresses instead

Returns

  • the encrypted stream; an error when the handshake fails or the certificate is not trusted (“certificate verify failed: …”), or an io error from the socket

connect_host

connect_host(host: String, port: u16): io.IoResult<TlsStream>

Connects to host on port with TcpStream.connect_host, then runs the handshake as connect does.

Parameters

  • host — a host name or an IP address
  • port — the port, usually 443

Returns

  • the encrypted stream; the errors of TcpStream.connect_host and of connect

TlsAcceptor

[Shared] struct TlsAcceptor

Accepts TLS server connections with one certificate.

An acceptor presents a certificate chain and its private key, read from PEM files. Each accept runs the TLS handshake over a TcpStream that a TcpListener accepted. Clients are not asked for certificates unless require_client_certificate is called (mutual TLS).

An acceptor is [Shared], so the accept loops of every worker can share one.

See also

  • TlsStream

Example

import io;
import tls;

var acceptor = tls.TlsAcceptor.create(io.Path("cert.pem"), io.Path("key.pem")).value();
var listener = io.TcpListener.bind(io.SocketAddress.parse("0.0.0.0:8443").value()).value();
while (true) {
    var (tcp, peer) = listener.accept().value();
    var accepted = acceptor.accept(tcp);
    if (accepted.is_ok()) {
        accepted.value().write_all("hello over TLS");
    }
}

Constructor

TlsAcceptor()

construct(context: __ptr<void>)

Wraps a platform context. Use TlsAcceptor.create.

Parameters

  • context — the platform’s server context, or null for none

Static methods

create

static create(certificate_chain: io.Path, private_key: io.Path): io.IoResult<TlsAcceptor>

Creates an acceptor that presents the certificate chain in certificate_chain with the private key in private_key.

Parameters

  • certificate_chain — a PEM file: the server’s certificate first, then any intermediate certificates
  • private_key — a PEM file with the certificate’s private key (on Windows, PKCS #8: BEGIN PRIVATE KEY)

Returns

  • the acceptor; an error when a file cannot be read, the key does not match the certificate, or no TLS library is available (see TlsConnector.create)

Methods

require_client_certificate

require_client_certificate(roots: io.Path): io.IoResult<void>

Asks every client for a certificate and accepts only clients whose certificate is issued by one of the certificates in the PEM file at roots (directly or through intermediates the client sends) and is valid for client authentication. A handshake with a client that sends no certificate, or an untrusted one, fails. Call it before the first accept.

Parameters

  • roots — a file of one or more PEM certificates: the authorities that issue client certificates

Returns

  • an Ok result; an error when the file cannot be read or holds no certificate

Example

var acceptor = tls.TlsAcceptor.create(io.Path("cert.pem"), io.Path("key.pem")).value();
acceptor.require_client_certificate(io.Path("clients-ca.pem")).value();

set_protocols

set_protocols(protocols: String[]): io.IoResult<void>

Accepts protocols through ALPN (RFC 7301), most preferred first: the handshake chooses the first of them that the client offers, and TlsStream.protocol names it. A client that offers no protocols connects with none chosen; one that offers only protocols not in the list fails the handshake with the no_application_protocol alert (RFC 7301). Call it before the first accept.

Parameters

  • protocols — the protocol names, each 1 to 255 bytes

Returns

  • an Ok result; an error when the list is empty or a name is empty or longer than 255 bytes

accept

accept(stream: io.TcpStream): io.IoResult<TlsStream>

Runs the TLS handshake as the server over stream.

Parameters

  • stream — a connection a TcpListener accepted; the TlsStream keeps it, and nothing else should read or write it afterwards

Returns

  • the encrypted stream; an error when the handshake fails (including, after require_client_certificate, a client with no trusted certificate), or an io error from the socket

TlsStream

[NotCopyable("owns a TLS connection and cannot be copied")] struct TlsStream

An encrypted connection: TLS over a TcpStream.

Get one from TlsConnector.connect or TlsAcceptor.accept. Reading and writing work as on a TcpStream, on the decrypted bytes: read appends what has arrived and returns 0 once the peer has closed the connection, write may send part of the data, and read_exact and write_all loop until done. In a task, a call that has to wait parks only the task.

shutdown() tells the peer that nothing more is coming (a TLS close_notify). A peer that closes the TCP connection without one also ends the stream: read returns 0.

Closing: the destructor closes the connection when the last reference goes; close() closes it earlier. Neither sends close_notify; call shutdown() first for that.

See also

  • TlsConnector
  • TlsAcceptor

Example

var stream = connector.connect_host("localhost", 8443).value();
stream.write_all("ping");
var reply = Array<u8>();
while (stream.read(reply, 4096).value() > 0) {
}

Constructor

TlsStream()

construct(tcp: io.TcpStream, session: __ptr<void>)

Wraps a finished handshake. Use TlsConnector.connect or TlsAcceptor.accept.

Parameters

  • tcp — the TCP connection
  • session — the platform’s session, or null for a closed stream

Methods

is_open

is_open(): bool

Returns whether the stream is open.

Returns

  • false after close()

protocol

protocol(): String?

Returns the protocol the handshake chose through ALPN, from the lists TlsConnector.set_protocols and TlsAcceptor.set_protocols set.

Returns

  • the protocol name, such as h2; none when no protocol was chosen or the stream is closed

read

read(buffer: u8[], max: ulong): io.IoResult<ulong>

Waits until data arrives, then appends up to max decrypted bytes of it to buffer.

Parameters

  • buffer — the array the bytes are appended to
  • max — the most bytes to read; 0 reads nothing and returns 0

Returns

  • the number of bytes appended, 0 once the peer has closed the connection; an error when the stream is closed, a TLS error, or an io error from the socket

read_exact

read_exact(buffer: u8[], count: ulong): io.IoResult<void>

Reads until exactly count more bytes are appended to buffer.

Parameters

  • buffer — the array the bytes are appended to
  • count — the number of bytes to read

Returns

  • an Ok result once count bytes are appended; an error when the connection ends first, or an error as for read. The bytes read before an error stay in buffer.

write

write(data: u8[]): io.IoResult<ulong>

Encrypts and sends bytes from data. The send may be partial; use write_all to send everything.

Parameters

  • data — the bytes to send

Returns

  • the number of bytes sent; an error when the stream is closed, a TLS error, or an io error from the socket

write_all

write_all(data: u8[]): io.IoResult<void>

Sends all of data, repeating the send until every byte is sent.

Parameters

  • data — the bytes to send

Returns

  • an Ok result once every byte is sent; an error as for write

write_all

write_all(text: String): io.IoResult<void>

Sends all of the UTF-8 bytes of text, as write_all(u8[]) does. No newline or terminator is added.

Parameters

  • text — the text to send

Returns

  • an Ok result once every byte is sent; an error as for write

Example

stream.write_all("ping");

write_string

[Deprecated("use 'write_all' instead", "write_all")] write_string(text: String): io.IoResult<void>

Deprecated: use write_all instead. Sends all of the UTF-8 bytes of text, exactly like write_all(text).

Parameters

  • text — the text to send

Returns

  • an Ok result once every byte is sent; an error as for write_all

See also

  • write_all

shutdown

shutdown(): io.IoResult<void>

Tells the peer that nothing more is coming (sends a TLS close_notify). The peer’s reads return 0 after the data sent before.

Returns

  • an Ok result on success; an error when the stream is closed, or an io error from the socket

close

close(): io.IoResult<void>

Closes the connection now, without a close_notify (see shutdown). Closing a closed stream does nothing.

Returns

  • an Ok result on success or when already closed; the platform error when closing the socket fails

The testing module

The test program pep-build --test builds: a main that lists the entry file’s [Test] functions or runs one of them.

A test build loads testing into every file, as --import testing would, and adds to the entry file a main (marked [TestMain], which replaces the file’s own) that registers each [Test] function with a TestRunner and calls run. Test files do not import it themselves: the checks they call, such as expect_equal, are in the prelude, and the fixture TempFolder (a scratch folder removed when released) is here.

pep-test (pepper test) drives the program: it lists the tests with --list and runs each in a child process of its own with --run NAME (see the testing chapter).

Source: runtime/testing/module.pep

TestRunner

The main of a test program: TestRunner holds the tests of the files it was built from and answers pep-test’s --list and --run NAME.

Source: runtime/testing/runner.pep

keep_for_the_program

keep_for_the_program(body: () => void)

Runs body and lets what it leaves allocated stay without failing the test’s leak check: for what a program makes once, on first use, and keeps until it ends, such as a lazily made global (async.Lazy), when a test is the first to use it.

Call it at the start of the test, with the first use alone, so the rest of the test is still checked.

Parameters

  • body — the code that makes what the program keeps

Example

import tls;

[Test]
connects() {
    # The first connector loads the TLS library, which stays loaded.
    keep_for_the_program(() => {
        tls.TlsConnector.create();
    });
    # ... the checks, under the leak check as usual
}

TestMain

[Attribute] struct TestMain

Marks the main that pep-build --test generates: in a test build it replaces the entry file’s own main.

TestRunner

struct TestRunner

The tests of one test program, which its generated main registers and then runs with the program’s arguments.

The program answers two requests, both made by pep-test:

  • --list prints one line per test, its fields separated by tabs: the name, file:line, returns or panics (for [ShouldPanic]), and the text the panic must contain (empty for any panic or none).
  • --run NAME [--timeout SECONDS] runs the test NAME (or file::NAME, which tells apart tests of one name in two files) and exits with 0 when it returns, or with 1 when it left an allocation behind (or released one too many); a panic aborts the program. With a timeout above 0, a test still running after that many seconds ends the program with exit status 124 and a message on standard error. However the program ends (the test returns, panics, crashes or times out), every process the test started ends with it (a job object on Windows, a process group elsewhere), so none holds pep-test’s pipes to its output open.

Constructor

TestRunner()

construct(file: String)

Creates a runner for the tests of file.

Parameters

  • file — the entry file’s path, as pep-build was given it, or the folder of the files of a test program built from several

Methods

from_file

from_file(file: String)

Makes file the file of the tests registered after this call.

Parameters

  • file — the path of the file that declares them, as pep-build was given it

add

add(name: String, body: () => void, line: int)

Registers a test that passes when body returns.

Parameters

  • name — the test function’s name
  • body — the test function
  • line — the line that declares it

add_should_panic

add_should_panic(name: String, body: () => void, line: int, message: String)

Registers a [ShouldPanic] test, which passes only when body panics with a message containing message.

Parameters

  • name — the test function’s name
  • body — the test function
  • line — the line that declares it
  • message — text the panic’s message must contain; empty for any panic

run

run(args: String[]): int

Answers the request args makes (see the struct’s description).

Parameters

  • args — the program’s arguments, the program first

Returns

  • the program’s exit status: 0 when the request succeeded, 2 for a request it does not understand or a test it does not have

TempFolder

TempFolder, a test fixture: a fresh folder that is removed with everything in it when the fixture is released.

Source: runtime/testing/temp_folder.pep

TempFolder

struct TempFolder

A fresh, empty folder under the system’s temporary folder, for a test that writes files. The folder and everything in it are removed when the last reference to the fixture is released, so a test that holds it in a local cleans up when it returns.

Its name joins the name the test gives with the process id, so tests running at once, each in a process of its own, never share a folder. When a folder of that name is left from an earlier run (whose process had the same id) and cannot be removed, a number is added to the name, so the folder is always new and empty.

Example

[Test]
writes_a_file() {
    var folder = TempFolder("writes_a_file");
    var path = folder.path.join("notes.txt");
    io.File.write_text(path, "hello");
    expect_equal(io.File.read_to_string(path).value(), "hello");
}

Fields

path

const path: io.Path

The folder’s path.

Constructor

TempFolder()

construct(name: String)

Makes the folder, removing what a crashed run left at its path, or taking a numbered name when that cannot be removed.

Parameters

  • name — a name for the folder, such as the test’s

Panics

  • if the folder cannot be made

Building Pepper from source

Pepper is written in Pepper. The compiler, pep-build, is the Pepper program in tools/compiler (also called pepc), and every other tool is a Pepper program under tools/. Building them takes a Pepper compiler, so the first one comes from a published toolchain, the build of main (stage 0), the same way CI and the release build get theirs. The Rust compiler that once bootstrapped Pepper plays no part.

You need this to work on Pepper itself, or on a platform with no prebuilt binaries. To use Pepper, install it instead.

Setting up

Four steps, on every platform:

  1. The build of main. Every merge into main that changes the compiler, the runtime or the tools publishes it as a prerelease named x.y.z-dev, which pepper install list --available lists. Install it and make it the default, with the install script on a new machine or with pepper install:

    sh scripts/install.sh --version 0.4.0-dev    # the -dev version listed
    pepper install add 0.4.0-dev && pepper install use 0.4.0-dev
    

    A prerelease is picked only by its exact name, never by update or latest. pepper install add --force <version> refreshes it.

  2. Platform prerequisites, for linking:

    • Windows: Visual Studio 2022 or newer with the C++ workload (or the standalone Build Tools), which clang links through; PowerShell (pwsh 7.x is what the setup script is tested with; Windows PowerShell 5.1 also works); and 7-Zip, only for -DevTools, which extracts lldb from the llvm.org installer rather than running it.
    • macOS: the Xcode Command Line Tools (xcode-select --install), for the SDK, system headers and ld. The clang that drives the link comes from the LLVM prefix when it has one, which it does on macOS.
    • Linux (Debian/Ubuntu names): sudo apt install build-essential clang curl xz-utils. clang is the link driver, and on Linux nothing else provides one: llvm-20-dev installs no bin/clang into its prefix.
  3. LLVM 20. The compiler links LLVM through the pepper-llvm package, which binds LLVM 20, so building it needs an LLVM 20 development install. One command provisions it (every option is in Setting up LLVM):

    ./scripts/setup-llvm.sh --devtools            # macOS / Linux
    pwsh -File scripts/setup-llvm.ps1 -DevTools   # Windows
    
  4. The private packages. tools/compiler depends on justinboswell/pepper-llvm and tools/pep-lsp on justinboswell/pepper-lsp. The first build of each fetches its package with git into the tool’s .pep/deps, so your git needs read access to those repositories.

Where LLVM comes from

On macOS and Linux your package manager has an LLVM 20 and the script uses it:

platformwhere LLVM comes fromcost
Linuxapt install llvm-20-dev (plus libpolly-20-dev)4–8s, 400–750 MB
macOSbrew install llvm@2013–17s
Windowsthe official release archive896 MB download

Windows needs the archive because nothing else on the platform works: the llvm.org installer and the winget, scoop and chocolatey packages ship neither llvm-config nor the component libraries. The script falls back to the archive on Linux and macOS too, but only when the package manager has no LLVM 20.

That fallback is sound on Linux but usually unusable on macOS: the release tarballs are LTO builds, so their static libraries are LLVM bitcode, and Apple’s linker rejects bitcode produced by a newer LLVM than its own (could not parse bitcode object file … Unknown attribute kind). Homebrew’s llvm@20 is not LTO-built and works, so on macOS install it with brew and re-run the script. It warns about this when it takes the archive path there.

The script records the prefix it settled on in .pep/llvm-prefix (gitignored), which the test, release and CI scripts read (a git worktree reads its main checkout’s). Re-run it on a new machine rather than copying that file. With a valid install present it only rewrites the file, and ./scripts/setup-llvm.sh --check reports what it would use, changing nothing.

Only the Windows archive path is large: budget roughly 5 GB of disk for the tree plus its cached archive, and a few minutes for the first run. Everything lands outside the repository, under %LOCALAPPDATA%\pepper or ~/.local/share/pepper, except a package-manager prefix, which stays wherever apt or brew put it.

The debugger

--devtools sets up the lldb debugger, and is worth it: a compiler that generates and links native code fails in native ways, an access violation or SIGSEGV from inside LLVM or the code it generated, and lldb turns that into a symbolized stack. On Windows it installs lldb, lldb-dap and lldb-server into the LLVM prefix (an extra 335–434 MB), extracting them from the llvm.org installer with 7-Zip and placing the embeddable Python that liblldb.dll needs beside them. On macOS and Linux it checks for a usable lldb and says how to install one if there is none. PEPPER_SETUP_DEVTOOLS=1 turns it on for every run.

Build the compiler with -g for its own frames, then:

lldb --batch -o run -k "thread backtrace" -- target/bin/pep-build tests/hello.pep

On Windows the setup script leaves the PATH alone, so run "$env:LOCALAPPDATA\pepper\llvm\20.1.8\bin\lldb.exe" by its full path. Debugging covers debugging Pepper programs.

Building

Build the compiler from tools/compiler with the installed toolchain, against this checkout’s runtime, then every tool with the compiler you built. Each tool is a project: its tools/<tool>/pep.toml adds tools/ to the import search path, for the modules the tools share, and names the packages it depends on. On macOS and Windows the compiler’s link needs LLVM’s library folder (-L), and on Windows its LLVM-C.dll beside it:

export PEPPER_RUNTIME="$PWD/runtime"
llvm="$(head -1 .pep/llvm-prefix)"
mkdir -p target/bin
pepper build -O tools/compiler/main.pep -o target/bin/pep-build    # + -L "$llvm/lib" on macOS and Windows
# Windows: cp "$llvm/bin/LLVM-C.dll" target/bin/
for tool in pepper pep-fmt pep-test pep-package pep-profile pep-run pep-install pep-doc pep-lsp; do
  target/bin/pep-build -O "tools/$tool/main.pep" -o "target/bin/$tool"
done

(.exe names on Windows.) The release build (scripts/release-build.sh) does the same, then builds the compiler once more with itself, and ships that one. A cold build of the compiler takes several minutes; the build cache makes a rebuild after an edit take seconds.

Testing

The tests are Pepper [Test] functions, run by scripts/pepper-tests.sh as CI runs them; there is no cargo test.

scripts/pepper-tests.sh                      # with the installed build of main
scripts/pepper-tests.sh target/bin           # with the compiler you built
PEPPER_OPT_LEVEL=2 scripts/pepper-tests.sh   # every build the tests make at -O2
WASI_SDK_PATH=/opt/wasi-sdk WASMTIME=/opt/wasmtime/wasmtime scripts/pepper-tests.sh

The last also builds the docs examples and the corpus for wasm32-wasip1 and runs them under wasmtime, except those that need threads, tasks, sockets or processes; without a wasi-sdk those tests say they skipped and pass. Testing the runtime and the compiler says what runs and where new tests go. Before a pull request, also run what CI checks:

target/pepper-tests/bin/pep-doc check        # the reference and the tools' pages are current
target/pepper-tests/bin/pep-fmt --check runtime tools

AGENTS.md, at the repository’s root, holds the rules every change follows, for people and agents alike: the reference-counting model, how errors are reported, naming, formatting, and which docs a change must update. INTERNALS.md is the map of the compiler: where each stage lives and the patterns it follows. .agents/skills/ holds step-by-step workflows (build, test, finish a PR, test another platform), and its build and test skills carry the failure signatures this project has hit, such as LLVM discovery and link failures and the private packages’ fetch. Start there before diagnosing a failure from scratch.

The VS Code extension from a branch

The released extension comes from the vsix-v* GitHub release. To run the extension and language server from the branch checked out, before it merges:

scripts/install-vscode-extension.ps1          # build pep-lsp + vsix, install into VS Code
scripts/install-vscode-extension.ps1 -Watch   # follow the branch: pull and reinstall on change

The extension restarts its language server by itself when the installed server changes; a change to the extension’s own code needs Developer: Reload Window. When editing the server, setting pepper.lspPath to a pep-lsp you build skips the install (see The language server: pep-lsp). Packaging (vsce package, run by the script and the VSIX workflow) bundles tools/vscode-pepper/src and its dependencies into one file with esbuild (npm run bundle).

Platforms

  • Windows x64, Linux x64 and macOS arm64 build and pass the suite; CI runs it on all three.
  • Cross-compiling (--target): CI links from each of Linux x64, macOS arm64 and Windows x64 for the other two with lld and their system roots and runs the programs on their own OS. Other pairs emit their objects; their links are untested.
  • WebAssembly (--target wasm32-wasip1) and iOS and Android: see Platform selection and iOS and Android.
  • macOS x86_64 is untested. LLVM 20 publishes no macOS-X64 archive, so it needs Homebrew’s llvm@20.

To exercise another platform from the machine you are on, run the platform-test workflow (gh workflow run platform-test.yml -f platforms=linux); the platform-test skill in .agents/skills/ describes it.

Setting up LLVM: setup-llvm.sh

The compiler, pep-build (tools/compiler), links LLVM 20 through the pepper-llvm package, so building it from source needs an LLVM 20 development install. The setup scripts provision one:

./scripts/setup-llvm.sh --devtools            # Linux and macOS
pwsh -File scripts/setup-llvm.ps1 -DevTools   # Windows

They record the LLVM they settled on in .pep/llvm-prefix (gitignored), which scripts/pepper-tests.sh, the release scripts and the workflows read. Run them again on each new machine rather than copying that file. Building Pepper from source says what each platform needs first.

scripts/setup-llvm.sh [options]
pwsh -File scripts/setup-llvm.ps1 [-DevTools] [-Force]

What it does

  • provisions LLVM 20, the one LLVM major Pepper builds with, cheapest source first: an LLVM 20 that is already installed, else the platform’s package manager (apt llvm-20-dev or Homebrew llvm@20), else the LLVM release archive (cached, so a second run is free). Windows always uses the archive: no Windows package ships llvm-config and the component libraries;
  • writes the prefix, one line, to .pep/llvm-prefix in the checkout;
  • does nothing more when a valid install is already there.

Options

scripts/setup-llvm.sh takes these; --help prints them.

OptionWhat it does
--devtoolsAlso check for the lldb debugger, with instructions when it is missing
--no-devtoolsSkip the debugger, even when PEPPER_SETUP_DEVTOOLS is set
--no-packagesDo not use apt or Homebrew: use an LLVM already installed, or the release archive
--checkReport the LLVM it would use and what .pep/llvm-prefix records, changing nothing; the status is 0 only when they agree
-h, --helpPrint the options

Of --devtools and --no-devtools, the last one given wins. An unknown argument is an error that exits with status 2.

scripts/setup-llvm.ps1 takes -DevTools, which installs lldb, lldb-dap and lldb-server into the same prefix, and -Force, which downloads and extracts the archive again even when a valid install is there.

Environment variables

VariableWhat it does
PEPPER_SETUP_DEVTOOLS1 sets up the debugger on every run, as --devtools does, so it can be turned on once per machine

Building this documentation

This book lives in docs/ and is rendered with mdBook. The standard library reference (docs/src/reference/) is generated from the runtime’s doc comments by pep-doc, a Pepper tool in this repository (tools/pep-doc), which scripts/pepper-tests.sh builds beside pep-build.

Install mdBook once, from its releases (a single binary to put on the PATH), then:


pep-doc serve                                 # live preview while writing
pep-doc reference                             # regenerate the reference after changing runtime docs
pep-doc build                                 # render into target/docs-site/dev/
pep-doc build --version 0.2.0 --site <dir>

Run it as target/pepper-tests/bin/pep-doc after scripts/pepper-tests.sh, or as pepper doc.

pep-doc’s commands and options are described in The documentation builder: pep-doc.

Keeping the book current

Documentation changes land in the same PR as the code they describe. The checklist of what to update for each kind of change is in AGENTS.md, under “Keep the documentation current”.

Examples are tested

Every runnable example lives in docs/examples/: a single name.pep, or a folder name/ whose entry point is main.pep. Chapters pull them in with mdBook’s include syntax, so the text on the page is the file that was tested:

```pepper
{{#include ../../examples/structs.pep}}
```

tools/pep-doc/examples_test.pep, which scripts/pepper-tests.sh runs, builds and runs every example; each must return 0 from main and uses assert to check what it shows. PEPPER_DOCS_EXAMPLE=structs limits it to the examples whose name starts with structs. A sample of the examples is also built at -O2 -g, where LLVM checks the debug info. Short fragments that are not whole programs can stay inline in the chapter.

Versions

Documentation is versioned with the compiler’s semantic version (the one line of the root VERSION file). pep-doc build --version X.Y.Z renders the book into <site>/X.Y.Z/ and leaves every other version in the site alone; versions.json lists them for the version picker, and the site’s index.html opens the newest release. Builds from main publish as dev.

The Docs workflow (.github/workflows/docs.yml) publishes to the gh-pages branch: pushing to main updates dev, and pushing a tag vX.Y.Z adds the X.Y.Z docs. Published versions are never deleted.

The reference stays current

pep-doc check fails when docs/src/reference/ does not match the runtime, and CI runs it.

What the compiler provides rather than the runtime declares is still declared in the runtime, marked [__BuiltIn]: runtime/reflect.pep declares the reflect functions of static blocks and the values they return, and runtime/builtins.pep the raw pointer and weak reference types, __null, sizeof, unsafe_cast and the __ intrinsics. The compiler ignores such a declaration (its functions and methods end in ;, with no body), so it costs nothing and cannot clash with the built-in; the reference, the doc comment check and the language server read it like any other. A string argument names the built-in when it is not the declaration’s own name: [__BuiltIn("reflect.name")] titles the entry and its signature reflect.name, and hover and go-to-definition on a use of reflect.name lead to it. Only the runtime may use the attribute.

Each reference page’s title and place in the table of contents come from pages() in tools/pep-doc/reference.pep. A new runtime file with public declarations must be added there, under its module’s module.pep; until it is, reference and check fail and name the file.

check also fails on chapter Markdown that renders broken without an mdBook error. An include indented inside a list item is one: mdBook pastes the file’s lines at column 0, which ends the list and the code block, so the rest of the example shows as prose and its # comments as headings. Put an included example at column 0, after the list or in its own ### section. The runtime itself must meet the doc comment standard in AGENTS.md, which pep-doc check enforces.

Tool options stay documented

Every command-line option of every tool has its place in the book: docs/src/tools/<tool>.md for pep-build, pepper, pep-fmt, pep-lsp and pep-doc, and Setting up LLVM for scripts/setup-llvm.sh. pep-doc check takes each tool’s options from the tool itself (its --help text, from the tools that scripts/pepper-tests.sh builds beside pep-build) and fails, naming the tool, the option and the page, when a page does not mention one. It also checks pep-build’s option values, trace channels and environment variables, and pep-lsp’s initialization options and VS Code settings. A new option goes in the tool’s --help (or the usage text at the top of setup-llvm.sh) and on its page in the same PR.

Testing the runtime and the compiler

The tests are written in Pepper: [Test] functions (see Unit tests) that pepper test (pep-test) builds and runs. They cover the runtime and the Pepper tools, the compiler written in Pepper (tools/compiler) among them.

They are the whole suite: Pepper is written in Pepper, and so are its tests.

Where runtime tests live

Code under testIts tests
A module, runtime/<module>/runtime/<module>/test/<topic>_test.pep, which imports the module
A prelude file, runtime/<name>.pepruntime/test/<topic>_test.pep
A file’s private functions[Test] functions in that file itself
A Pepper tool, tools/<tool>/tools/<tool>/<topic>_test.pep, which imports the tool’s files (import "format";), or runs the built tool (see Tests that run the tools)

A test/ folder holds no module.pep, so no program that imports the module loads it. Its files are still files of the module, though: a test file that imports one of the module’s files (import "../atomic"; in runtime/async/test/) sees its unexported names. pep-test builds each folder’s tests into one program, so a helper (or test name) that two test files of one folder declare collides; mark it private in each (as runtime/async/test/ does). Prefer it to tests inside a source file, which only a test of private internals needs. The reference generator and the doc-comment check skip test/ folders, so a test file needs no ## comments.

Running them

scripts/pepper-tests.sh runs them all:

sh scripts/install.sh --version 0.4.0-dev        # the build of main; see below
scripts/pepper-tests.sh                          # every runtime and tool test
scripts/pepper-tests.sh -f parses                # by name, in each run
scripts/pepper-tests.sh target/bin               # with a pep-build you built

The tests build with the installed toolchain’s pep-build, the one pepper install which names. Every merge into main that changes the compiler, the runtime or the tools publishes the build of main as a prerelease named x.y.z-dev (pepper install list --available shows it), replacing the one before; CI and the workers install it with the install scripts rather than building the compiler, and so can you (run the install again to refresh it). The compiler’s own tests (tools/compiler/*_test.pep) build the compiler’s sources into their test programs, so they test a change to it. To run the whole suite with the changed compiler too, build it from tools/compiler (see Building Pepper from source) and pass its folder as the first argument.

The script builds the Pepper tools (pepper, pep-test, pep-doc and the others) from the checkout with that pep-build, against the checkout’s runtime, into target/pepper-tests/bin beside a link to the pep-build, and runs pepper test:

  • in runtime/, every runtime test;
  • in runtime/, the compress tests again with -D USE_ZLIB and the digest tests with -D USE_LIBCRYPTO, which link the system’s zlib and libcrypto (on Windows the zlib run needs PEPPER_TEST_ZLIB_DIR, a folder holding zlib.lib, and is skipped without it);
  • in tools/, the tools’ tests, each under its tool’s pep.toml.

The tools’ run, the longest, starts as soon as the tools its tests run (pepper, pep-test, pep-fmt, pep-profile, pep-package and pep-run) are built, and runs beside the runtime’s runs; its output is printed after theirs.

Once pepper is built, run any part of them by hand:

cd runtime
pepper test                       # every runtime test
pepper test json/test             # one module's
pepper test -f parses test        # by name

Tests run with runtime/ as the current folder, so a test reads data files kept beside it by a path from there (archive/test/gnu.tar.gz). The builds go to runtime/.pep/, which git ignores. A test that writes files uses a fresh folder under the system’s temporary folder (TempFolder in the testing module makes and removes one).

CI runs scripts/pepper-tests.sh with the installed build of main on Linux, macOS and Windows. A PR that changes the compiler (tools/compiler/) is tested with that compiler instead: CI builds it with the build of main first (scripts/ci-build-compiler.sh) and runs the suite with it.

Two Pepper tools depend on private repositories. The compiler written in Pepper, tools/compiler, depends on justinboswell/pepper-llvm, and the language server tools/pep-lsp depends on justinboswell/pepper-lsp. CI clones each over SSH with a read-only deploy key of that repository, whose private half is this repository’s secret PEPPER_LLVM_DEPLOY_KEY or PEPPER_LSP_DEPLOY_KEY.

scripts/ci-package-auth.sh writes the keys to temporary files. It gives each package a host alias in an SSH config that offers only that package’s key, since GitHub takes the first key offered and a deploy key opens only its own repository. It points git at the aliases through GIT_CONFIG_* and GIT_SSH_COMMAND variables of the test step only. Without a secret (in a fork, say) the step says so in a notice, and the tests of the tool that uses that package fail when they fetch it.

Tests that run the tools

Some tests run programs rather than call functions: pep-build on programs, and the Pepper tools on files and folders, checking their output and exit status as a user would see them.

TestWhat it runs
tools/compiler/corpus_test.peppep-build -I tests on each tests/<Name>/test.pep, then the program from the repository’s root, which must exit with 0. With WASI_SDK_PATH set and wasmtime on the PATH (or WASMTIME naming it), each again for wasm32-wasip1 under wasmtime; it says it skipped otherwise. A new tests/<Name>/test.pep joins the run.
tools/compiler/programs_test.peppep-build on the programs of tools/compiler/testdata/, each run with the arguments and environment it needs: cli’s parse and an add_options error, os’s environment and host, time’s zones (TZif files under TZDIR, TZ, the system’s database), and process’s arguments, exit, main’s arguments, children that are copies of the program, and exec.
tools/pep-doc/examples_test.peppep-build on every example of the book, then the example (see Building this documentation).
tools/pep-fmt/repository_test.pepformat_source in the test itself on every .pep file under runtime/, tools/, tests/ and docs/examples/: each formats, formatting it again changes nothing, the syntax tree stays the same, and the files of runtime/ and tools/ are already formatted (pep-fmt -w fixes one that is not).
tools/pep-fmt/cli_test.peppep-fmt on standard input and on files and folders (--check, -w), its syntax errors and usage errors.
tools/pepper/pepper_test.peppepper, copied beside stand-in tools built from tools/pepper/testdata/; and every tool’s --version against the version in each tools/*/main.pep (and pep-lsp’s tools/pep-lsp/arguments.pep).
tools/pep-test/pep_test_test.peppep-test on test files it writes, from tools/pep-test/testdata/ (named .pep.txt so that the suite’s own run skips them).
tools/pep-package/package_test.peppep-package and pep-build on projects whose dependencies are bare git repositories in a test folder, and archives on a loopback server, including builds of one project side by side, which fetch once under .pep/fetch.lock; skipped without git.
tools/pep-run/pep_run_test.peppep-run building and running the programs in tools/pep-run/testdata/ with a Pepper home of its own: arguments, exit status, working folder, the up-to-date rerun, compile errors and the options it refuses.
tools/pep-profile/profile_test.peppep-profile on the recordings in tools/pep-profile/testdata/, and on a real program when samply or a working perf is installed (skipped otherwise).

They find pep-build and the tools beside the test program or on the PATH (process.find_companion), where scripts/pepper-tests.sh puts the ones it builds from the checkout, so they test this checkout’s tools. To run one by hand, put that folder first on the PATH (after a run of the script, or with the tools built there as the script builds them):

export PATH="$PWD/target/pepper-tests/bin:$PATH"
cd tools && pepper test pep-fmt/cli_test.pep

The tool_testing module (tools/tool_testing/) holds what they share: tool(name) finds a tool, run and run_with_input run a process.Command and read its output with Windows’ line ends made \n, build_program builds a program with pep-build, and expect_exit, expect_contains, expect_starts_with and expect_ends_with show the program’s output when they fail. tool_testing.server’s serve_folder serves a folder over HTTP on loopback, for a test that downloads (pep-install, pep-package).

Leaks fail a test

Each test counts the runtime’s live allocations before and after it runs, and fails when they differ:

error: test 'parses_components' left 2 allocations behind

So a test needs no census of its own: every object it made must be released by the time it returns. What the program keeps for good, such as a global made on first use (async.Lazy), is made inside keep_for_the_program(() => { ... }) at the start of the test, which leaves it out of the count.