# Culebra Standard Library
This document is the **API reference** for Culebra's built-in
library: the namespace objects (`Math`, `IO`, `Sys`, `FS`, `Time`,
`Args`, `Random`, `String`) that group the runtime utilities.
Everything described here is available without any `import`
statement.
For an introductory tour and usage idioms see
[`handbook.md` §14](handbook.md#14-standard-library).
Language-level built-ins — `to_long`, `to_float`, `to_string`,
`type_of`, `range`, `iota`, `repeat` — are specified in
[§19 of the language spec](language.md). The matcher family
(`assert_true` / `assert_eq` / `assert_throws` / etc.) is documented
in [§13 below](#13-matchers). Methods on built-in types (`String`,
`Array`, `Object`) are specified in
[§18 of the language spec](language.md).
`inspect`, `print` and `println` are additionally bound as globals
aliased to `IO.inspect` / `IO.print` / `IO.println` (see
[§22 of the language spec](language.md)). They are unconditional, so an
embedded program sees them exactly as a script does.
Conventions used below:
* Types follow the annotations described in [§14 of the language
spec](language.md). `Any` denotes any value.
* Throws clauses describe runtime errors of the form
`type error at L:C.` etc. See [§15 of the language spec](language.md).
* Namespaces are **closed**: reading a member a namespace doesn't have
raises `AttributeError: namespace 'IO' has no member 'read_all'` at the
access site, rather than yielding `nil`. This turns a typo or a use of a
removed API into an immediate, catchable error instead of a confusing
failure at a later call. (Plain dicts keep the permissive rule — a missing
key reads as `nil`.) The dict builtins `keys`/`values`/`has`/`get`/`size`
still work on a namespace. `Path` is the one exception: it is a class, not
a namespace, so a missing property reads as `nil` the way it does on any
other class.
## Index
1. [`Math`](#1-math) — numeric utilities, constants, integer sequences
2. [`IO`](#2-io) — output, stdin (file I/O lives under `FS`)
3. [`FS`](#3-fs) — path manipulation, file/dir queries and mutations
4. [`File`](#4-file) — stateful handle for streaming read/write/seek
5. [`Time`](#5-time) — `Instant` / `Duration` classes, ISO 8601, calendar arithmetic, nanosecond precision
6. [`Random`](#6-random) — seedable PRNG (uniform, gauss, shuffle, choice, weighted_choice)
7. [`Sys`](#7-sys) — argv, exit, env, executable; `GC` heap introspection lives here too
8. [`Tensor`](#8-tensor) — N-dimensional numeric tensor with a BLAS-backed lazy graph
9. [`JSON`](#9-json) — stringify / parse round-trip
10. [`Args`](#10-args) — declarative CLI argument parser (positional / option / subcommand / `--help`)
11. [`Proc`](#11-proc) — run external commands synchronously, capture stdout/stderr/exit
12. [`Isolate`](#12-isolate) — run a closure on another thread (own heap), copy values across the boundary; `Channel`, `Parallel`, `Signal` (route Ctrl+C to a channel), `SharedBuffer` (zero-copy shared fixed-layout data), and `Shared` (immutable values shared by reference) live here too
13. [Matchers](#13-matchers) — `assert_true` / `assert_eq` / `assert_throws` / `assert_close` family
14. [`Regex`](#14-regex) — linear-time, grapheme-aware regular expressions
15. [`Http`](#15-http) — synchronous HTTP/HTTPS client (get/post/put/delete/head/request), server (routes, static files, WebSocket), and Server-Sent Events
16. [`Encoding`](#16-encoding) — text codecs by scheme (`Encoding.html`, `Encoding.base64`, `Encoding.hex`, `Encoding.url`, `Encoding.utf16`)
17. [`Compress`](#17-compress) — gzip / deflate (de)compression for data and files, and packing files into a ZIP archive
18. [`Hash`](#18-hash) — SHA-256/SHA-1/SHA-512/MD5 digests and HMAC (hex output)
19. [`CSV`](#19-csv) — parse / stringify RFC 4180-ish comma-separated values
20. [`Env`](#20-env) — parse / load dotenv-style `.env` files
21. [`UUID`](#21-uuid) — generate v4 (random) and v7 (time-ordered) UUIDs
22. [`Term`](#22-term) — terminal color, cursor control, size, and key/mouse input for TUIs
23. [`Log`](#23-log) — leveled, structured logging to stderr (text / JSON, child loggers)
24. [`TOML`](#24-toml) — parse / stringify TOML configuration
25. [`XML`](#25-xml): parse / search (an XPath subset) / stringify XML; a lazy event stream for large documents
26. [`SQLite`](#26-sqlite) — embedded SQL database (query / execute / prepared statements / transactions)
27. [`Canvas`](#27-canvas) — immediate-mode 2D framebuffer for games (shapes, sprites, offscreen targets, text, keys/mouse/gamepad, window controls)
28. [`Scene`](#28-scene) — retained-mode 3D renderer for procedural geometry
29. [`Net`](#29-net) — raw TCP / UDP sockets and name resolution (the layer under `Http`)
30. [`Desktop` / `Webview`](#30-desktop--webview) — native WebView desktop app: local HTTP server + window, one call
31. [`Vector2`](#31-vector2) — minimal 2D float vector for graphics/game code (also stands in for a "Point")
32. [`Vector3`](#32-vector3) — the 3D counterpart of `Vector2`
33. [`Deque`](#33-deque) — double-ended queue, O(1) amortized push/pop at either end
34. [`PriorityQueue`](#34-priorityqueue) — binary min-heap, O(log n) push/pop
35. [`PEG`](#35-peg) — PEG parser generator: write a grammar, get a syntax tree
36. [`CodeGen`](#36-codegen) — build a small language's IR by hand and run it
37. [`StateMachine`](#37-statemachine) — hierarchical state machine, with a text DSL
38. [`FST`](#38-fst) — compiled read-only dictionary: prefix, predictive and fuzzy search
39. [`Search`](#39-search) — full-text index over your own documents, ranked
40. [`Audio`](#40-audio) — sound for any program, with or without a window: WASM-4 tones, samples, streamed music, synthesised PCM, the microphone
41. [`Dir`](#41-dir) — a read-only set of files on disk, in memory, baked into the binary or in a ZIP archive, read one way (`Dir.disk` / `Dir.memory` / `Dir.embedded` / `Dir.zip`, and the `Dir` trait)
42. [Design notes](#42-design-notes)
43. [Not included (yet)](#43-not-included-yet)
**Where to find what**
| Need… | Look at |
|---|---|
| Constants (π, e, inf, nan) | [§1 Math constants](#math-pi) |
| Scalar arithmetic (abs, min, max, log, exp, sqrt, floor, ceil, round) | [§1 Math](#1-math) |
| Trigonometry (sin, cos, tan, asin, acos, atan, atan2; radians) | [§1 Math](#1-math) |
| Print to stdout | `IO.inspect` (newline + quoting) / `IO.println` (newline, raw) / `IO.print` (raw, no newline) |
| Read back what a call printed | [§2 IO](#2-io) — `IO.capture(fn)` |
| Read a whole file | `FS.read` (throws on failure) |
| Stream a file (lines / chunks / seek) | [§4 File](#4-file) — `File.open` / `File.with` |
| Path manipulation (join, basename, dirname, stem, extension) | [§3 FS](#3-fs); fluent `Path` wrapper: [§3 `Path`](#path--the-fluent-wrapper) |
| Stat / walk / glob / copy / rename / symlink / chmod / chown | [§3 FS](#3-fs) |
| Watch a directory for changes | [§3 FS](#3-fs) — `FS.watch` |
| Directory listing / create / remove | `FS.list_dir`, `FS.mkdir`, `FS.remove` |
| Read a ZIP archive, the assets baked into the binary, or files held in memory, one way | [§41 Dir](#41-dir) — `Dir.zip(path)` / `Dir.embedded("assets")` / `Dir.memory({...})` |
| `Instant` / `Duration`, ISO 8601, calendar arithmetic | [§5 Time](#5-time) |
| Wrap an index that can go negative into `0..n` | [§1 Math](#1-math) — `Math.wrap(i, n)` (`%` truncates, so it stays negative) |
| Random numbers | `Random.int`, `.uniform`, `.gauss`, `.shuffle`, `.choice`, `.weighted_choice` |
| CLI argument parsing | [§10 Args](#10-args) |
| Process info | `Sys.argv`, `Sys.exit`, `Sys.env`, `Sys.set_env`, `Sys.getcwd`, `Sys.chdir`, `Sys.executable`, `Sys.script` |
| Save data that outlives the process | [§7 Sys](#7-sys) — `Sys.data_dir("myapp")` (the platform's per-user data directory) |
| Run an external command | [§11 Proc](#11-proc) — `Proc.run(["git", "status"])` |
| Call an HTTP/HTTPS API | [§15 Http](#15-http) — `Http.get("https://api.example/x")` |
| Serve HTTP — routes, static files, WebSocket | [§15 `Http.server()`](#httpserver---object) — `Http.server().get("/", h).listen(8080)` |
| Speak a raw TCP / UDP protocol, resolve a hostname | [§29 Net](#29-net) — `Net.connect(host, port)` / `Net.listen(port)` / `Net.udp()` / `Net.resolve(host)` |
| Escape / unescape HTML entities | [§16 Encoding](#16-encoding) — `Encoding.html.unescape("a & b")` |
| Encode / decode base64, hex, url | [§16 Encoding](#16-encoding) — `Encoding.base64.encode(s)` |
| gzip / gunzip data or files | [§17 Compress](#17-compress) — `Compress.gzip(s)` / `Compress.gunzip(z)` |
| Pack files into a ZIP archive | [§17 Compress](#17-compress) — `Compress.zip(files)` |
| Compress without the gzip envelope | [§17 Compress](#17-compress) — `Compress.deflate(s, level: 9)` (decode with `Compress.gunzip`) |
| Hash / checksum / HMAC | [§18 Hash](#18-hash) — `Hash.sha256(s)` / `Hash.hmac_sha256(key, s)` |
| Parse / write CSV | [§19 CSV](#19-csv) — `CSV.parse(text)` / `CSV.stringify(rows)` |
| Parse / write TOML | [§24 TOML](#24-toml) — `TOML.parse(text)` / `TOML.stringify(obj)` |
| Parse / search / write XML | [§25 XML](#25-xml): `XML.parse(text)` / `root.find_all(".//item")` / `el.stringify()` |
| Query an embedded SQL database | [§26 SQLite](#26-sqlite) — `SQLite.open(path)` → `db.query(sql, params)` / `db.execute(...)` |
| Load a `.env` config file | [§20 Env](#20-env) — `Env.load(".env")` / `Env.parse(text)` |
| Generate a UUID | [§21 UUID](#21-uuid) — `UUID.v4()` / `UUID.v7()` |
| Leveled / structured logging | [§23 Log](#23-log) — `Log.info("msg", {k: v})` / `Log.with({req: id})` |
| Run work on another thread (CPU parallelism) | [§12 Isolate](#12-isolate) — `Isolate.spawn(\|\| fib(40))` |
| Share fixed-layout data across threads/processes (zero copy) | [§12 SharedBuffer](#sharedbuffer--zero-copy-shared-fixed-layout-data) — `SharedBuffer.new(n, FloatPair)` / `.file` / `.shared` |
| Share variable-length read-only data across threads (no copy) | [§12 Shared](#shared--immutable-values-shared-by-reference) — `Shared.new(value)` |
| Handle Ctrl+C / SIGINT gracefully | [§12 Signal](#signal--signalnotify--signalreset) — `Signal.notify(tx)` / `Signal.reset()` |
| Desktop GUI (native WebView + local server) | [§30 Desktop](#30-desktop--webview) — `Desktop.run({title, assets, routes})` |
| Play a tone, a sound effect or music; synthesise audio | [§40 Audio](#40-audio) — `Audio.tone(440, 10)` / `Audio.Sound.new(bytes).play()` / `Audio.Music.new(bytes)` / `Audio.PCM.new(44100, 1, 1024)` / `Audio.Capture.new(44100)` |
| Heap introspection / leak checks | [§7 GC](#gc--heap-introspection) — `GC.stat()` → `{live_objects, rc_objects, heap_bytes}` |
| 2D/3D vector math (dot, length, normalize, distance) | [§31 `Vector2`](#31-vector2) / [§32 `Vector3`](#32-vector3) |
| FIFO queue, sliding window, front+back stack | [§33 `Deque`](#33-deque) — `Deque.new()` — `push_back`/`pop_front` |
| Priority scheduling, event simulation, shortest-path search | [§34 `PriorityQueue`](#34-priorityqueue) — `PriorityQueue.new()` — `push`/`pop` |
| Parse a language / config format of your own | [§35 PEG](#35-peg) — write a PEG grammar, `PEG.parser(grammar)`, `enable_ast()` and `parse(text)` → a tree `match` takes apart |
| Model a workflow, protocol or UI mode as states and events | [§37 `StateMachine`](#37-statemachine) — `StateMachine.parse(text)`, or a description `Object` |
| Autocomplete / prefix lookup over a large fixed word list | [§38 FST](#38-fst) — `FST.Set.new(FST.compile_set(words))` → `.predictive_search("hel")` |
| Spelling correction, fuzzy lookup | [§38 FST](#38-fst) — `.edit_distance_search(word, 1)` / `.suggest(word)` |
| Search many documents by keyword, ranked | [§39 Search](#39-search) — `Search.Index.new()` → `.add(key, text)` → `.search("quick -dog")` |
| String / Array / Object methods | [language spec §18](language.md) |
| Integer sequences (`range`, `iota`) | [language spec §19](language.md) |
| Fill an `Array` with `n` copies of a value | [language spec §19](language.md) — `repeat(n, value)` |
| Conversion (`to_long`, `to_float`, `to_string`, `type_of`) | [language spec §19](language.md) |
---
## 1. `Math`
Numeric utilities. Integer-only routines (`pow`, `sign`, `wrap`)
preserve `Long` input; `clamp` and the Float-domain routines (`log`,
`exp`, `sqrt`, …) accept either `Long` or `Float` and return the shape
documented below. See [§4](language.md#4-types) and
[§7](language.md#7-expressions) of the language spec for how `Long`
and `Float` interact.
Sub-groups in this section: **constants** (`Math.pi`, `Math.e`,
`Math.inf`, `Math.nan`) — **scalar ops** (`abs`, `min`, `max`,
`log`, `exp`, `sqrt`, `floor`, `ceil`, `round`, `pow`, `sign`,
`clamp`, `wrap`) — **trigonometry** (`sin`, `cos`, `tan`, `asin`, `acos`,
`atan`, `atan2`, in radians). Integer-sequence factories `range` / `iota`
are language-core globals — see [§19](language.md#19-core-built-in-functions).
### Constants
`Math.pi`, `Math.e`, `Math.inf`, `Math.nan` are `Float` properties.
Both backends evaluate these as compile-time constants under `--jit`.
#### `Math.pi`
`π` ≈ `3.141592653589793`.
#### `Math.e`
Euler's number, ≈ `2.718281828459045`.
#### `Math.inf`
Positive infinity (`Math.inf > 1e308 == true`). Negate with `-Math.inf`.
#### `Math.nan`
Quiet NaN. Note `Math.nan == Math.nan` is `false` per IEEE-754.
```culebra
inspect(Math.pi) # => 3.141592653589793
inspect(Math.e) # => 2.718281828459045
inspect(Math.inf > 1e308) # => true
inspect(Math.nan == Math.nan) # => false
```
### Scalar operations
### `Math.abs(x: Long|Float) -> Long|Float`
Absolute value. Returns `Long` for `Long` input, `Float` for `Float`
input.
```culebra
inspect(Math.abs(-7)) # => 7
inspect(Math.abs(-7.5)) # => 7.5
```
### `Math.min(a, b, ...) -> Long|Float`, `Math.max(a, b, ...) -> Long|Float`
Smallest / largest of two or more numeric arguments. Returns `Long`
when every argument is `Long`; any `Float` argument promotes the
result to `Float`. At least two arguments are required; fewer — or
any non-numeric argument — raises `type error`.
```culebra
inspect(Math.min(3, 1, 4, 1, 5)) # => 1
inspect(Math.max(1.5, 2, 0.5)) # => 2.0
```
### `Math.log(x: Long|Float) -> Float`
Natural logarithm of `x`. `Math.log(0)` is `-inf`; `Math.log` of a
negative value is `nan`. The return type is always `Float` even when
the result is mathematically an integer.
### `Math.exp(x: Long|Float) -> Float`
`e` raised to `x`.
### `Math.sqrt(x: Long|Float) -> Float`
Principal square root. `Math.sqrt(-1.0)` is `nan`.
### `Math.sin(x) -> Float`, `Math.cos(x) -> Float`, `Math.tan(x) -> Float`
Trigonometric functions; `x` is in **radians** (`Long` or `Float`).
```culebra
inspect(Math.sin(Math.pi / 2)) # => 1.0
inspect(Math.cos(0)) # => 1.0
```
### `Math.asin(x) -> Float`, `Math.acos(x) -> Float`, `Math.atan(x) -> Float`, `Math.atan2(y, x) -> Float`
Inverse trigonometric functions, returning radians. `asin` / `acos`
expect `x` in `[-1, 1]` (else `nan`). `Math.atan2(y, x)` is the
quadrant-aware arctangent of `y / x`.
```culebra
inspect(Math.atan2(1.0, 1.0)) # => 0.7853981633974483
# (that is pi/4)
```
### `Math.floor(x: Long|Float) -> Long`, `Math.ceil(x: Long|Float) -> Long`, `Math.round(x: Long|Float) -> Long`
Round a numeric value to an integer. `Long` input is returned
unchanged. `Math.floor` rounds toward `-∞`, `Math.ceil` toward `+∞`,
and `Math.round` uses **banker's rounding** (round half to even).
A Float whose result has no Long — NaN, an infinity, past ±2⁶³ —
raises `ValueError`.
```culebra
inspect(Math.floor(-1.5)) # => -2
inspect(Math.ceil(-1.5)) # => -1
# A tie rounds to the even neighbour, so 2.5 and 3.5 both land on even:
inspect(Math.round(2.5)) # => 2
inspect(Math.round(3.5)) # => 4
```
### `Math.f32(x: Long|Float) -> Float`
Round to the nearest `float` (IEEE binary32) and return it as a `Float`,
for reproducing a computation written in single precision — a game's
random-number generator, a file format that stores 32-bit values —
without leaving `Float`. A value just past `float`'s largest finite value
rounds back to it, further out becomes `±inf`, and `nan` stays `nan`.
```culebra
inspect(Math.f32(0.1)) # => 0.10000000149011612
inspect(Math.f32(16777217)) # => 1.6777216e+07
```
### `Math.pow(base: Long, exp: Long) -> Long`
Integer exponentiation. `base ** exp`, computed by repeated squaring.
`Math.pow(x, 0)` is `1` for every `x` (including `0`).
**Throws**: `type error at L:C.` if `exp < 0`.
Kept for back-compat; **prefer the `**` operator** which also handles
`Float` and negative exponents (see language spec §7).
```culebra
inspect(Math.pow(2, 10)) # => 1024
inspect(Math.pow(7, 0)) # => 1
inspect(Math.pow(-3, 3)) # => -27
```
### `Math.sign(x: Long) -> Long`
Returns `-1` for negative, `0` for zero, `1` for positive.
```culebra
inspect(Math.sign(-5)) # => -1
inspect(Math.sign(0)) # => 0
inspect(Math.sign(42)) # => 1
```
### `Math.clamp(x: Long|Float, lo: Long|Float, hi: Long|Float) -> Long|Float`
Clamp `x` to the inclusive range `[lo, hi]`. No error is raised when
`lo > hi`; the result in that case is `hi`. Returns `Long` when `x`,
`lo` and `hi` are all `Long`; `Float` if any of the three is `Float`
(matching `Math.min`/`Math.max`'s promotion rule).
```culebra
inspect(Math.clamp(5, 0, 10)) # => 5
inspect(Math.clamp(-5, 0, 10)) # => 0
inspect(Math.clamp(15, 0, 10)) # => 10
inspect(Math.clamp(0.5, 0.0, 1.0)) # => 0.5
inspect(Math.clamp(-3.0, 0.0, 10.0)) # => 0.0
```
### `Math.wrap(x: Long, n: Long) -> Long`
Wrap `x` into a span of `n` — the **floored** remainder, which `%` does
not give: `%` truncates, so its result carries the sign of `x`
([language spec §7](language.md#arithmetic)). `Math.wrap`'s result carries
the sign of `n`, so a positive `n` always lands it in `[0, n)`. That is
what a circular index wants: the element before index 0 is the last one,
not a negative subscript.
```culebra
inspect(Math.wrap(3, 320)) # => 3
inspect(Math.wrap(-3, 320)) # => 317
inspect(-3 % 320) # => -3
inspect(Math.wrap(320, 320)) # => 0
```
The two agree wherever `x` is non-negative, so `Math.wrap` is only worth
reaching for when `x` can go below zero — a scroll offset, a wrapped
tile coordinate, an angle stepped backwards:
```culebra
let frames = ['a', 'b', 'c']
let prev = fn (i) {
frames[Math.wrap(i - 1, frames.size())]
}
inspect(prev(0)) # => 'c'
```
A negative `n` mirrors the whole thing — the result lands in `(n, 0]` —
and `n` of `0` raises `divide by 0 error`, exactly as `x % 0` does.
---
## 2. `IO`
Output and standard input. File reading/writing lives under `FS`
(`FS.read` / `FS.write` / `FS.exists`).
### `IO.inspect(x: Any) -> Nil`
Print `x` followed by a newline to standard output. Reference types
are formatted the same way as `Array`/`Object`'s default `to_string`,
and strings are printed **with surrounding single quotes**.
```culebra
IO.inspect('hi') # → 'hi'
IO.inspect(42) # → 42
IO.inspect([1, 'a']) # → [1, 'a']
```
### `IO.print(x: Any) -> Nil`
Write `x` to standard output **without a trailing newline**, using
`to_string` formatting (strings are **unquoted**). Useful for
building a single line of output from several writes.
```culebra
IO.print('Hello, ')
IO.print('world!')
IO.println('') # → Hello, world!
```
### `IO.println(x: Any = '') -> Nil`
Print `x` followed by a newline to standard output, using `to_string`
formatting (strings are **unquoted**) — the raw-display twin of
`inspect`, and the newline-appending twin of `print`. `x` defaults to
`''`, so a bare `println()` just writes a blank line.
```culebra
IO.println('hi') # → hi
IO.println(42) # → 42
IO.println() # → (blank line)
```
### `IO.input() -> String`
Read a single line from standard input. The trailing newline is
stripped. Returns `''` (empty string) on end-of-file.
```culebra
# doctest: skip — reads stdin
println('name?')
name = IO.input()
println("Hello, {name}")
```
### `IO.stdin() -> reader`
Return a read-only handle over standard input, sharing the same reader shape
as a `File` handle (so source-generic code works over either):
| Method | Result |
| --- | --- |
| `.read()` | the rest of standard input to EOF (the portable replacement for `FS.read("/dev/stdin")`, which is POSIX-only); empty string on immediate EOF |
| `.read(n: Long)` | up to `n` bytes (fewer only at EOF) |
| `.lines()` | a lazy iterator over input lines, trailing newline stripped, stopping at EOF |
`.lines()` streams in constant memory — the idiom for Unix filters over large
or unbounded input (`tail -f \| script`). Reads are blocking and interruptible
(a single Ctrl+C breaks the wait). Standard input is single-consumer; the
methods share one underlying buffer, so `.read(n)` then `.lines()` continues
where the byte read left off.
```culebra
# doctest: skip — reads stdin
# Filter: uppercase lines containing "error".
for line in IO.stdin().lines() {
if line.contains("error") {
IO.println(line.upper())
}
}
let src = if IO.stdin_is_terminal() {
read_clipboard()
} else {
IO.stdin().read()
}
```
### `IO.einspect(x: Any) -> Nil` / `IO.eprint(x: Any) -> Nil` / `IO.eprintln(x: Any) -> Nil`
Write to standard error — the twins of `inspect` / `print` / `println`.
`einspect` quotes strings and adds a newline (like `inspect`); `eprint`
writes the raw display form with no trailing newline (like `print`);
`eprintln` writes the raw display form with a trailing newline (like
`println`). Use for diagnostics that shouldn't mix into stdout.
```culebra
# doctest: skip
IO.einspect("warning: retrying") # → stderr
if !ok {
IO.eprint("error: {msg}\n")
}
IO.eprintln("done")
```
### `IO.stdin_is_terminal() -> Bool` / `IO.stdout_is_terminal() -> Bool` / `IO.stderr_is_terminal() -> Bool`
Whether the given standard stream is connected to a terminal (POSIX
`isatty`). Lets a script branch on interactivity: prompt vs. read a
pipe (stdin), colorize vs. emit plain output (stdout / stderr).
Each returns `false` when the stream is
redirected to a file or pipe.
```culebra
# doctest: skip
let src = if IO.stdin_is_terminal() {
read_clipboard()
} else {
FS.read("/dev/stdin")
}
if IO.stdout_is_terminal() {
println(colorize(msg))
} else {
println(msg)
}
```
### `IO.capture(f: Function) -> String`
Run `f` with stdout redirected into a buffer and answer what it wrote.
Whatever `f` returns is discarded; a value it prints is the point. This
is how a test asserts on output that a function produces rather than
returns:
```culebra
let out = IO.capture(fn () {
println('hello')
})
assert_eq(out, "hello\n")
```
The redirect is the calling thread's: another thread printing while
`f` runs still writes to stdout, and two threads capturing at once
each take only their own output. It is undone even if `f` throws —
the throw propagates. Captures nest: an inner one restores the outer
one's buffer, not the terminal. `IO.eprint` and friends write to
stderr and are not captured.
`IO` is the standard-stream and console namespace. File reading and
writing live under `FS` (`FS.read` / `FS.write` / `FS.exists`).
---
## 3. `FS`
Filesystem path manipulation and directory operations. Backed by
`std::filesystem`; mutating calls throw structured `IOError`
(`{kind: "IOError", message, line, col}`) on failure so an embedder
can attribute the error to its source location.
### Path manipulation
#### `FS.join(parts...: String) -> String`
Concatenate path components with the platform separator (`/` on POSIX, `\`
on Windows — see `FS.sep()` below). Zero arguments returns `""`. Trailing
separators in components are respected — the operator behaves like
`std::filesystem::path::operator/=`.
```culebra
inspect(FS.join('a', 'b', 'c.txt')) # => 'a/b/c.txt'
inspect(FS.join('/usr', 'local', 'bin')) # => '/usr/local/bin'
inspect(FS.join()) # => ''
```
The examples above show the POSIX separator; a program that compares a
joined path against a literal should build that literal with `FS.sep()`
rather than a hard-coded `/`, or compare path components instead of the
whole string.
#### `FS.sep() -> String`
The platform's path separator — the character `FS.join` and `Path`'s `/`
put between components: `/` on POSIX, `\` on Windows.
```culebra
inspect(FS.join('a', 'b') == "a{FS.sep()}b") # => true
```
#### `FS.basename(path: String) -> String`
Final path component (filename + extension). Trailing separator
yields `""`.
```culebra
inspect(FS.basename('a/b/c.txt')) # => 'c.txt'
inspect(FS.basename('/')) # => ''
```
#### `FS.dirname(path: String) -> String`
Parent path. `""` for paths with no parent (`'c.txt' -> ''`).
#### `FS.extension(path: String) -> String`
File extension *including the leading dot*, or `""` for paths with
none. Dotfiles (`.hidden`) are treated as having no extension —
matches `std::filesystem::path::extension`.
```culebra
inspect(FS.extension('a/b/c.txt')) # => '.txt'
inspect(FS.extension('.hidden')) # => ''
```
#### `FS.stem(path: String) -> String`
Basename without the trailing extension.
```culebra
inspect(FS.stem('a/b/c.txt')) # => 'c'
```
### Whole-file read / write
#### `FS.read(path: String) -> String`
Read the entire file at `path` into a `String` (open + read + close in
one call). Always binary: the result is a byte string that round-trips
arbitrary content. For incremental/streaming reads use a `File` handle.
Throws `IOError` if the file is missing, unreadable, or a directory.
```culebra
# doctest: skip
contents = FS.read('data.txt')
```
#### `FS.write(path: String, content: String) -> Nil`
Write `content` to `path`, creating or overwriting it. Binary, no
newline translation. Throws `IOError` if the parent directory is
missing or the path is not writable.
```culebra
# doctest: skip — writes files beside it
FS.write('out.txt', 'hello\n')
```
### Queries
#### `FS.exists(path: String) -> Bool`
Whether anything exists at `path`. Does not distinguish files,
directories or symlinks. An empty or invalid path returns `false`.
#### `FS.is_file(path: String) -> Bool`
True iff `path` is a regular file. Symlinks are followed.
#### `FS.is_dir(path: String) -> Bool`
True iff `path` is a directory. Symlinks are followed.
#### `FS.size(path: String) -> Long`
File size in bytes. Throws `IOError` if `path` is missing or not a
regular file.
### Directory mutations
#### `FS.list_dir(path: String) -> Array`
Direct children of `path` as bare filenames (no `.` / `..`, no
prefix). Order is filesystem-defined — sort explicitly if needed.
Throws `IOError` if `path` is not a directory.
```culebra
# doctest: skip
let names = FS.list_dir('/tmp/build')
assert_true(names.contains('out.o'))
```
#### `FS.mkdir(path: String) -> Nil`
Create a directory at `path`, including any missing parents
(`mkdir -p` semantics). No-op if the directory already exists.
Throws `IOError` if the path exists as a file or creation fails.
#### `FS.temp_dir() -> String`
The directory this machine keeps scratch files in: `$TMPDIR` and its
fallbacks on POSIX, `GetTempPath` on Windows. No trailing separator, so
`FS.join` off it never doubles one. Throws `IOError` if the platform
names a directory that isn't one.
```culebra
assert_true(FS.is_dir(FS.temp_dir()))
```
#### `FS.mkdtemp(prefix: String) -> String`
Create a new directory under `FS.temp_dir()` whose name begins with
`prefix`, and return its path. The directory is *created*, not just
named, so two programs running at once cannot be handed the same one —
including two seeded identically with `Random.seed`, since the suffix
comes from the OS rather than the shared engine. Removing it is the
caller's job.
`prefix` is a name, not a path: a parent that doesn't already exist is
an `IOError` rather than a silent `mkdir -p`.
```culebra
let dir = FS.mkdtemp('build-')
assert_true(FS.is_dir(dir))
assert_eq(FS.dirname(dir), FS.temp_dir())
FS.remove(dir, recursive: true)
```
#### `FS.remove(path: String, recursive: Bool = false) -> Nil`
Remove a file or directory. By default removes a file or *empty*
directory and throws `IOError` if the directory is non-empty. With
`recursive: true` it removes a directory tree (`rm -rf`). Throws
`IOError` if the path doesn't exist or isn't removable.
```culebra
# doctest: skip
FS.remove('/tmp/build/out.o')
FS.remove('/tmp/build', recursive: true)
```
#### `FS.rename(src: String, dst: String) -> Nil`
Rename / move `src` to `dst` (atomic within one filesystem). Throws
`IOError` on failure.
#### `FS.copy(src: String, dst: String, recursive: Bool = false) -> Nil`
Copy a file, overwriting `dst` if it exists. With `recursive: true`
copies a directory tree. Throws `IOError` on failure.
#### `FS.chmod(path: String, mode: Long) -> Nil`
Set the permission bits of `path` to `mode` — an integer, usually written
as an octal literal (`0o755`, `0o644`). `mode` is masked to the low 12 bits
(`rwx` for owner/group/other plus setuid/setgid/sticky) and replaces the
existing bits. Read the current bits back with `FS.stat(path).mode`. Throws
`IOError` if the path is missing or the permissions can't be changed.
Only the owner-write bit crosses platforms. Windows stores a single
read-only attribute rather than permission bits, so every file there reads
back as `0o666` or `0o444` and a mode does not round-trip: `FS.chmod(p,
0o755)` followed by `FS.stat(p).mode` answers `0o666`. Clearing owner-write
(`0o444`) and setting it again is the part that behaves the same
everywhere.
```culebra
# doctest: skip
FS.chmod('deploy.sh', 0o755) # make executable
inspect(FS.stat('deploy.sh').mode) # 493 (0o755)
```
#### `FS.chown(path: String, owner = nil, group = nil) -> Nil`
Change the owner and/or group of `path`. `owner` and `group` each accept a
name (`String`), a numeric id (`Long`), or `nil` to leave that one unchanged.
Read the current ids back with `FS.stat(path).uid` / `.gid`. Changing the
owner usually requires root; changing the group works for a group you belong
to. Throws `IOError` on a missing path, an unknown user/group name, or a
permission failure; a non-String/Long/Nil argument is a `TypeError`.
```culebra
# doctest: skip
FS.chown('app.log', group: 'staff') # set group by name, keep owner
FS.chown('data', 'deploy', 'deploy') # set both by name (root)
```
### Stat / metadata
#### `FS.stat(path: String) -> Object`
Return `{size, is_dir, is_file, is_symlink, mtime, mode, uid, gid}` for
`path`. `size` is bytes (0 for non-regular files); `mtime` is seconds since
the Unix epoch; `mode` is the permission bits as an integer (compare with
octal, e.g. `st.mode == 0o644`); `uid` / `gid` are the owner and group ids;
`is_symlink` reflects the link itself while the other fields follow it. Throws
`IOError` if the path doesn't exist.
Windows has no POSIX owner model, so `uid` and `gid` are **absent** there
rather than carrying a stand-in value — `st.uid` reads as `nil`, and
`st.has('uid')` is what asks whether this platform has them. `mode` exists
everywhere but means less on Windows, which keeps one read-only attribute
instead of permission bits: see `FS.chmod` below.
```culebra
# doctest: skip
let st = FS.stat('config.toml')
inspect(st.size)
```
### Recursive traversal
#### `FS.walk(path: String) -> Array`
Every path under `path`, recursive, depth-first. Each entry is a full
path. A directory the process may not read is listed but not entered.
Throws `IOError` if `path` isn't a directory or can't be read, or if
reading one under it fails for any other reason.
#### `FS.glob(pattern: String) -> Array`
Paths matching a glob `pattern`, sorted. Supports `*`, `?`, `[...]`
per segment and `**` for recursive descent; a character is a code
point, so `?` matches `é` whole. This is shell-style glob,
distinct from `Regex`.
```culebra
# doctest: skip — reads the working directory
let sources = FS.glob('src/**/*.cul')
```
### Watching for changes
#### `FS.watch(path: String, recursive: Bool = true, match: Array? = nil) -> WatchHandle`
Watch a directory for changes. Iterating the handle blocks until the
next change and yields `{path, kind}`, where `path` is absolute and
canonical (symlinks resolved, so a `/tmp` root reports under
`/private/tmp` on macOS) and `kind` is `'created'`, `'modified'` or
`'deleted'`. `match` keeps only the paths ending in one of the given
extensions, with or without the leading dot (`'cul'` and `'.cul'` are
the same filter); the check happens before an event is queued, so a
rejected change never reaches the queue. Throws `IOError` if `path`
isn't a directory, `TypeError` if `match` isn't an array of strings.
`recursive: false` narrows the watch to the directory's own entries: a
change *below* a subdirectory is not reported, though the subdirectory
entry itself may be — Windows reports its timestamp moving when a file
inside it is written, where inotify and FSEvents do not.
Backed by FSEvents on macOS, inotify on Linux and `ReadDirectoryChangesW`
on Windows.
```culebra
# doctest: skip — watches until interrupted
for e in FS.watch('src', match: ['.cul']) {
println("{e.kind} {e.path}")
}
```
Events queue from the moment the watch opens, so a change made before
the first pull is still delivered. The handle is iterable directly, and
`break` leaves it usable — a named handle can be iterated again:
```culebra
# doctest: skip — watches until interrupted
let w = FS.watch('src')
for e in w { break } # handle stays open
for e in w { break } # picks up where the first loop left off
w.close()
```
`close()` stops the watch; a handle that goes out of scope closes
itself. An anonymous `for e in FS.watch(p)` is therefore stopped when
the loop ends. A watch belongs to the thread that opened it and cannot
be sent to an isolate.
Three things are deliberately not promised. **Kinds are best effort at
the OS's granularity**: FSEvents coalesces several changes to one path
inside a short window into a single event, while inotify reports each
separately, so the same edit can surface as one event on one platform
and two on another. **A rename is not paired** — it arrives as a
`deleted` on the old path and a `created` on the new one. **Nothing is
deduplicated or debounced**, and the queue is unbounded: a consumer
that pulls slower than the tree changes grows memory rather than
dropping events.
An iteration that is simply waiting can only be ended by `break`, by
Ctrl+C, or by cancelling the isolate it runs in — there is no timeout
or non-blocking pull.
### Path resolution
#### `FS.abspath(path: String) -> String`
Absolute, normalized form of `path` (relative to the current
directory). Does not resolve symlinks.
#### `FS.realpath(path: String) -> String`
Canonical path with symlinks resolved (`weakly_canonical`; missing
trailing components are kept).
#### `FS.normpath(path: String) -> String`
Lexically normalize (collapse `.` / `..` / duplicate separators)
without touching the filesystem.
#### `FS.is_abs(path: String) -> Bool`
Whether `path` is absolute.
### Symlinks
#### `FS.symlink(target: String, link: String) -> Nil`
Create a symbolic link at `link` pointing to `target`.
#### `FS.readlink(path: String) -> String`
Read a symlink's target. Throws `IOError` if `path` isn't a symlink.
#### `FS.is_symlink(path: String) -> Bool`
Whether `path` is a symbolic link (the link itself, not its target).
### `Path` — the fluent wrapper
`Path` is a thin, **immutable** wrapper over the `FS.*` helpers. `FS` is
the primitive layer (raw `String` paths in, `String`/values out); `Path`
is the sugar layer that carries the path around so you stop threading path
strings by hand. Every operation returns a fresh `Path` (or a `String` for
components) — a `Path` is never mutated in place.
Reach for `FS.*` in short scripts; reach for `Path` when a path is built
up in stages or passed through several operations, where the `/` operator
and property chains read more clearly:
```culebra
# doctest: skip
let root = Path.new(FS.dirname(Sys.script)).resolve()
for src in root.glob("*/content.src.js") {
let dst = src.parent / "content.js" # vs FS.join(FS.dirname(src), …)
dst.write(transform(src.read()))
IO.print("{src.parent.name}/content.js\n") # vs FS.basename(FS.dirname(src))
}
```
Construct with `Path.new(s)`, where `s` is a `String` or another `Path`.
Anywhere a method takes a path (`join`, `/`, `rename`, `==`) it likewise
accepts a `String` or a `Path`. String interpolation (`"{p}"`) and
`to_string(p)` yield the raw path string.
| Member | Returns | Delegates to |
|---|---|---|
| `p / other` / `p.join(other)` | `Path` | `FS.join` |
| `p.name` | `String` (final component) | `FS.basename` |
| `p.stem` | `String` (name without suffix) | `FS.stem` |
| `p.suffix` | `String` (extension incl. dot) | `FS.extension` |
| `p.parent` | `Path` | `FS.dirname` |
| `p.resolve()` | `Path` (absolute) | `FS.abspath` |
| `p.exists()` / `p.is_file()` / `p.is_dir()` | `Bool` | `FS.*` |
| `p.read()` / `p.write(content)` | `String` / `Nil` | `FS.read` / `FS.write` |
| `p.mkdir()` | `Nil` (creates parents) | `FS.mkdir` |
| `p.remove(recursive=false)` | `Nil` | `FS.remove` |
| `p.rename(dst)` | `Path` (the destination) | `FS.rename` |
| `p.list()` | `Array` (dir entries) | `FS.list_dir` |
| `p.glob(pattern)` | `Array` | `FS.glob` |
| `p.walk()` | `Array` (recursive) | `FS.walk` |
| `p.str()` | `String` (escape hatch) | — |
`name`, `stem`, `suffix`, and `parent` are **getters** (pure string
derivations), read without parentheses — `p.parent.name`, not
`p.parent().name()` (the call spelling still works). The filesystem
operations below stay methods because they do I/O and can throw.
Two `Path`s compare by their inner path string: `==` (also against a
`String`) and `<` / `<=` / `>` / `>=`, so a `Path` array sorts
(`paths.sorted()`) and works with `min` / `max` / `Set`. Ordering against a
non-path is a `TypeError` (no meaningful answer), whereas `==` against a
non-path is simply `false`.
The `FS.*` helpers and `File.open` / `File.with` accept a `Path` anywhere
they take a path — their path parameters are `String | Path` — so a `Path`
flows straight through without `.str()`:
```culebra
# doctest: skip
let cfg = Path.new("/etc") / "app.conf"
let text = FS.read(cfg) # FS.read(String | Path)
for line in File.open(cfg).lines() {
} # File.open(String | Path)
```
Only the *path-taking* stdlib functions opt in this way. `Path` and
`String` remain **distinct types** everywhere else: a plain `fn(x: String)`
does *not* accept a `Path`, so the type boundary stays meaningful — use
`p.str()` (or `"{p}"`) to hand a raw string to a `String`-only API.
---
## 4. `File`
A `File` is a **stateful handle** for streaming I/O — the complement
to `FS`'s one-shot whole-file operations (`FS.read` / `FS.write`).
Open one with `File.open` or the scoped `File.with`. All I/O is binary
(no text-mode newline translation); `String` is a byte string, so any
content round-trips.
The handle (`f` below) implements four method groups — **Reader** (`read` /
`lines` / `chunks`), **Writer** (`write` / `flush`), **Seekable**
(`seek` / `tell`), **Closeable** (`close`). Which are valid depends on
the open mode.
### Opening
#### `File.open(path: String, mode: String = "r") -> File`
Open `path`. `mode` is `"r"` (read), `"w"` (truncate + write), or
`"a"` (append). Throws `ValueError` for any other mode, `IOError` if
the file can't be opened.
#### `File.with(path: String, mode: String = "r", fn: Function) -> Any`
Open `path`, call `fn(handle)`, and close the handle on every exit
path (normal, `return`, or exception). Returns `fn`'s value. This is
the native equivalent of `open` + `defer { close }`, and the clearest
choice when the handle's lifetime fits one block.
```culebra
# doctest: skip
let head = File.with('big.log', 'r', fn (f) {
f.read(256)
})
```
### Resource safety — three ways to close
| Pattern | Use when |
|---|---|
| `File.with(p, m, fn (f) { … })` | the handle's lifetime fits one block |
| `let f = File.open(p, m); defer { f.close() }` | the handle outlives a block / mixes with other logic |
| `for line in File.open(p).lines() { … }` | streaming; the iterator closes on loop exit (incl. `break`) |
A handle never explicitly closed is closed by a GC backstop, but don't
rely on it — prefer one of the three patterns above.
### Reader methods
#### `f.read() -> String` / `f.read(n: Long) -> String`
Streaming read from the current position: `read()` returns the rest of
the file, `read(n)` at most `n` bytes (fewer at EOF). For a one-shot
whole-file read without a handle, use `FS.read(path)`.
#### `f.lines() -> Iterator`
Iterate lines, each with its trailing newline stripped (`\n`, `\r\n`,
and `\r` are all recognized). The iterator owns the handle and closes
it when the loop ends or breaks.
```culebra
# doctest: skip
for line in File.open('access.log').lines() {
if line.contains('ERROR') {
inspect(line)
}
}
```
#### `f.chunks(n: Long) -> Iterator`
Iterate fixed-size byte chunks of at most `n` bytes (the last may be
shorter). Same close-on-exit contract as `lines()`.
### Writer methods
#### `f.write(data: String) -> Nil`
Write `data` at the current position (raw bytes, no newline
translation). Throws `IOError` on a read-only handle.
#### `f.flush() -> Nil`
Flush buffered writes to the OS.
### Seekable methods
#### `f.seek(offset: Long, whence: String = "set") -> Nil`
Move the cursor. `whence` is `"set"` (from start), `"cur"` (relative),
or `"end"` (from end; use a negative `offset`).
#### `f.tell() -> Long`
Current byte offset.
### Closeable
#### `f.close() -> Nil`
Close the handle, flushing writes. Idempotent — closing twice is a
no-op. Operating on a closed handle throws `IOError`.
---
## 5. `Time`
Wall-clock + monotonic time, ISO 8601 round-trip, calendar
arithmetic. The module exposes two classes — `Instant` (a point in
time) and `Duration` (an interval) — backed internally by `i64`
nanoseconds since the Unix epoch (range ±292 years, full nanosecond
precision). A result past that range raises `ValueError` instead of
wrapping around.
Timezone handling is **UTC + local only** (named zones like
`Asia/Tokyo` are deferred). Methods accept a kw-only `utc:` flag;
`iso` defaults to UTC (`utc: true`) because Z-suffixed ISO 8601 is
the common interop wire form, others default to local.
### Acquisition
#### `Time.now() -> Instant`
Current wall-clock time. Subject to NTP / manual clock adjustments —
use `Time.monotonic` for measuring elapsed time.
#### `Time.monotonic() -> Float`
Seconds (with sub-second precision) elapsed since the first call /
process start. Strictly non-decreasing; immune to wall-clock
changes. The primary tool for benchmarks and timeouts.
```culebra
# doctest: skip
let t0 = Time.monotonic()
do_work()
inspect("elapsed: {Time.monotonic() - t0} s")
```
#### `Time.sleep(secs: Float) -> Nil`
Block the current thread for at least `secs` seconds. Negative or
zero is a no-op.
### `Instant` constructors
#### `Time.from_iso(s: String) -> Instant`
Parse an ISO 8601 timestamp. Accepted variants:
- `2026-05-20T15:30:00Z`
- `2026-05-20T15:30:00.123Z`
- `2026-05-20T15:30:00.000123456Z` (full nanosecond precision)
- `2026-05-20T15:30:00+09:00`
- `2026-05-20T15:30:00-0900`
- `2026-05-20` (date only — UTC midnight)
- `2026-05-20T15:30` (seconds omitted)
Throws `ValueError` on a malformed input, including a field outside its
calendar range (`2026-13-01`, `2025-02-29`, `T25:00`) — it is not carried
into the next month or day.
#### `Time.from_unix(secs: Long|Float) -> Instant`
From Unix epoch seconds. A Long is exact; a Float gives sub-second
precision, rounded to the nearest nanosecond.
#### `Time.from_parts(p: Object, utc: Bool = false) -> Instant`
Compose from a parts dict — the inverse of `Instant.parts`.
Recognised keys: `year`, `month`, `day`, `hour`, `minute`, `second`,
`nanosecond` (defaults: `year=1970`, `month=1`, `day=1`, others 0). Extra keys
are ignored. Each recognised key must be a Long (`TypeError`
otherwise) within its calendar range — `month` 1–12, `day` up to that
month's length, `hour` 0–23, `minute` and `second` 0–59, `nanosecond`
below 10⁹ — or it raises `ValueError`.
#### `Time.parse(s: String, fmt: String) -> Instant`
Strict strftime parse for non-ISO inputs. The format follows POSIX
`strptime`. Throws `ValueError` if `s` doesn't match `fmt`, if
characters are left over after `fmt` is used up, or if a field is
outside its calendar range (the ranges of `Time.from_parts` —
`2026-02-30` is refused, not carried into March). A field `fmt` does
not mention defaults as in `Time.from_parts` (`year=1970`, `month=1`,
`day=1`, others 0). Result is interpreted as local time.
```culebra
Time.parse("2026/05/20 15:30:00", "%Y/%m/%d %H:%M:%S")
```
### `Instant` methods
#### `t.iso(utc: true) -> String`
Format as ISO 8601 with full nanosecond precision (fractional
component omitted when zero). UTC by default (`...Z`); pass
`utc: false` for local time with `±HH:MM` offset.
#### `t.format(fmt: String, utc: false) -> String`
Format with a strftime format string. Local time by default.
```culebra
# doctest: skip
t.format("%Y-%m-%d %H:%M:%S") # local
t.format("%Y%m%d", utc: true) # 20260520
```
#### `t.parts(utc: false) -> Object`
Decompose into `{year, month, day, hour, minute, second,
nanosecond, weekday, dayofyear}`. `weekday` follows ISO 8601
(`0=Mon`, `6=Sun`); `dayofyear` is 1-based (`1..366`).
```culebra
let p = Time.now().parts()
if p.hour >= 9 && p.hour < 17 {
inspect("business hours")
}
```
#### `t.weekday(utc: false) -> Long`
Just the weekday component (0=Mon..6=Sun) — avoids the `parts()`
allocation when only the weekday is needed.
#### `t.add(years=0, months=0, days=0, hours=0, minutes=0, seconds=0, utc: false) -> Instant`
Calendar arithmetic. `years` / `months` use **clamp-to-end-of-month**
semantics: `2026-01-31 + 1 month → 2026-02-28`,
`2024-01-31 + 1 month → 2024-02-29` (leap year). Sub-day fields
compose as straightforward addition.
```culebra
let next_month = Time.now().add(months: 1)
let next_quarter = Time.now().add(months: 3)
let next_year = Time.now().add(years: 1)
```
#### `t.start_of(unit: String, utc: false) -> Instant`
Truncate to the start of a calendar unit. `unit` ∈ `"year"` /
`"month"` / `"day"` / `"hour"` / `"minute"`. Throws `ValueError`
on any other unit.
```culebra
# doctest: skip
let day_bucket = t.start_of("day")
let hour_bucket = t.start_of("hour")
```
#### `t.unix() -> Float`, `t.unix_nanos() -> Long`
Unix epoch as Float seconds (lossy past ~400ns near current epoch)
or Long nanoseconds (lossless).
### `Duration` constructors
```culebra
# doctest: skip
Time.seconds(n) # n seconds
Time.milliseconds(n)
Time.minutes(n)
Time.hours(n)
Time.days(n)
```
`n` may be Long or Float — a Long scales exactly, and fractional units
round to the nearest nanosecond.
### `Duration` methods
#### `d.seconds() / .milliseconds() / .minutes() / .hours() / .days() -> Float`
Express the duration in the requested unit (always Float so
fractional units round-trip).
#### `d.abs() -> Duration`
Absolute value of a (possibly negative) duration.
### Operator overloads
```culebra
# doctest: skip
let t = Time.now()
let one_hour = Time.hours(1)
t + one_hour # Instant + Duration → Instant
t - one_hour # Instant - Duration → Instant
t1 - t2 # Instant - Instant → Duration
Time.minutes(1) + Time.seconds(30) # → Duration (90s)
one_hour * 2 # → Duration (Long: exact)
one_hour / 2 # → Duration (Long: truncates)
-one_hour # → Duration
a < b, a <= b, a == b # natural ordering on both types
```
Both classes wrap a nanosecond count, but they are not interchangeable:
a combination with no meaning — `t1 + t2`, `t < one_hour`, or a bare
number on either side — raises a `TypeError` naming what it got instead
of quietly doing the arithmetic. Equality is the exception: `==` against
any other type is `false`, never an error.
---
## 6. `Random`
Random-number generation. The process has a single shared
Mersenne-Twister-64 engine, shared between the VM and JIT
backends; `Random.seed(n)` resets it and makes every subsequent draw
reproducible within one program execution. Without a `seed` call the
engine is initialised from `std::random_device`.
### `Random.seed(n: Long) -> Nil`
Reseed the shared PRNG. Same `n` → same sequence.
```culebra
Random.seed(42)
```
### `Random.int(lo: Long, hi: Long) -> Long`
Uniform integer in the half-open range `[lo, hi)`. Requires `hi > lo`,
otherwise `type error`.
```culebra
Random.seed(0)
inspect(Random.int(0, 10)) # 0..9
```
### `Random.uniform(lo: Float, hi: Float) -> Float`
Uniform real in the half-open range `[lo, hi)`. `Long` arguments are
accepted and promoted to `Float`.
### `Random.gauss(mu: Float, sigma: Float) -> Float`
A sample from a Gaussian distribution with the given mean and
standard deviation. `Long` arguments are promoted to `Float`.
```culebra
Random.gauss(0.0, 1.0) # standard normal
```
### `Random.shuffle(a: Array) -> Nil`
Fisher–Yates in-place permutation. Returns `nil`; the argument is
mutated.
### `Random.weighted_choice(pop: Array, weights: Array) -> Any`
Draw a single element from `pop` with probability proportional to the
matching `weights` entry. Weights must all be numeric and of the same
length as `pop`; empty or mismatched inputs raise `type error`.
Weights of `0` are never selected.
```culebra
Random.weighted_choice(['hit', 'miss'], [1, 9]) # ~10% 'hit'
```
### `Random.choice(pop: Array) -> Any`
Draw a single element from `pop` with uniform probability. An empty
`pop` raises `type error`.
```culebra
Random.seed(0)
Random.choice(['rock', 'paper', 'scissors'])
```
---
## 7. `Sys`
Process-level information.
### `Sys.argv -> Array`
Array of `String` arguments passed to the script on the command
line. The first non-flag argument is the script path; **everything
after it** is captured as `argv`.
culebra's own flags (`--jit`, `--debug`, …) must precede the script
path. Empty when the script is invoked with no trailing arguments or
when running in the REPL.
```culebra
# doctest: skip — prints values particular to one machine
# $ culebra run.cul hello world
inspect(Sys.argv) # ['hello', 'world']
# $ culebra --jit run.cul hello → ['hello'] (--jit is culebra's)
```
A standalone `--` is an optional escape hatch: it stops flag parsing,
so the next argument becomes the script even if it begins with a dash
(e.g. `culebra -- -weird.cul`). It is otherwise unnecessary.
A binary built by `culebra build` reports the arguments it was run
with, the same way and with the same skip of the program name. The
value is process-wide, so an [isolate](#12-isolate) or an HTTP handler
running on a worker thread reads the same array as the main thread.
### `Sys.exit(code: Long) -> Nil`
Terminate the process immediately with the given exit code. Does
not return; pending `defer` statements are *not* run.
```culebra
# doctest: skip
if error_occurred {
Sys.exit(1)
}
```
### `Sys.env(name: String, fallback = '') -> Any`
Return the value of the environment variable `name`. If it is not set,
return `fallback` unchanged — the default of `''` keeps the one-argument
form returning a `String`.
`fallback` is returned as-is whatever its type, so passing `nil` is what
separates an unset variable from one set to the empty string: the
one-argument form folds both to `''`.
```culebra
# doctest: skip — prints values particular to one machine
inspect(Sys.env('HOME')) # '/Users/alice'
inspect(Sys.env('NOT_A_VAR')) # ''
inspect(Sys.env('PORT', '8080')) # '8080' while PORT is unset
inspect(Sys.env('NOT_A_VAR', nil)) # nil — unset
inspect(Sys.env('SET_BUT_EMPTY', nil)) # '' — set, and empty
```
### `Sys.set_env(name: String, value: String) -> Nil`
Set the environment variable `name` to `value`, overwriting any existing
value. The change is visible to this process (via `Sys.env`) and to child
processes spawned afterwards (e.g. `Proc.run`). Raises `IOError` on failure
(for example, an invalid variable name).
```culebra
Sys.set_env('CULEBRA_MODE', 'fast')
inspect(Sys.env('CULEBRA_MODE')) # => 'fast'
```
### `Sys.getcwd() -> String`
Return the absolute path of the current working directory. Symlinks in the
path are resolved. Raises `IOError` if the directory cannot be determined
(for example, it was removed out from under the process).
```culebra
# doctest: skip — prints values particular to one machine
inspect(Sys.getcwd()) # '/Users/alice/project'
```
### `Sys.chdir(path: String) -> Nil`
Change the process working directory to `path`. Raises `IOError` if the path
does not exist or is not a directory.
```culebra
# doctest: skip — prints values particular to one machine
Sys.chdir('/tmp')
inspect(Sys.getcwd()) # '/tmp' (or its resolved path)
```
### `Sys.data_dir(app: String) -> String`
Return the per-user directory where `app` keeps data that outlives the process
— a save file, a high score, a settings blob. The convention is the platform's:
| platform | directory |
|---|---|
| macOS | `~/Library/Application Support/` |
| Windows | `%APPDATA%\` (falling back to `%USERPROFILE%\AppData\Roaming\`) |
| other | `$XDG_DATA_HOME/`, or `~/.local/share/` when it is unset |
The directory is **not created** — `FS.mkdir` makes the parents when the program
first writes. `app` must be a single path segment: an empty name, one holding a
`/`, `\` or `:`, and `.` / `..` all raise `ValueError`, so the result cannot
point outside the base directory (a Windows drive letter would otherwise
replace the base rather than extend it). Raises `IOError` when the platform's base directory
cannot be determined (no `HOME`, or no `APPDATA` and no `USERPROFILE`).
```culebra
# doctest: skip
let dir = Sys.data_dir('samegame')
FS.mkdir(dir)
FS.write(FS.join(dir, 'scores.json'), JSON.stringify(scores))
inspect(dir) # '/Users/alice/Library/Application Support/samegame'
```
### `Sys.executable -> String`
Absolute path to the running culebra binary. Use it to launch a worker copy of
culebra — e.g. `Proc.run([Sys.executable, "worker.cul"], ...)` — instead
of relying on `culebra` being on `PATH`. (In an AOT-built program it is the path
to that standalone binary.)
```culebra
# doctest: skip — prints values particular to one machine
inspect(Sys.executable) # '/usr/local/bin/culebra'
```
### `Sys.script -> String?`
Absolute path of the running script — the `__file__` analogue. Use it to resolve
files next to the script instead of relying on the current working directory:
`FS.join(FS.dirname(Sys.script), "data.txt")`. It is `nil` when there is no
source file at runtime — the REPL, a piped `stdin`, or an AOT-built binary (which
carries no `.cul`; use `Sys.executable` there).
```culebra
# doctest: skip — prints values particular to one machine
inspect(Sys.script) # '/Users/alice/project/build.cul'
```
### `Sys.time() -> Float`
Seconds elapsed on a **monotonic** clock, anchored at the first call in
the process. It never jumps backwards with a wall-clock adjustment, which
makes it the right tool for timing a block of code; use `Time.now()` (§5)
when you need an actual date. `Time.monotonic()` (§5) reads the same
monotonic clock; each function anchors its own origin at its first
call, so subtract two readings of the *same* function rather than
mixing the two.
```culebra
let t0 = Sys.time()
let sum = range(1000).reduce(0, |a, x| a + x)
inspect(Sys.time() - t0 >= 0.0) # => true
```
### `GC` — heap introspection
`GC.stat()` runs a full collection and returns an `Object` describing the
live heap right after it:
| key | type | meaning |
|---|---|---|
| `live_objects` | `Long` | number of reachable heap objects |
| `rc_objects` | `Long` | reachable *refcounted* objects (excludes traced-only Strings/StringViews) |
| `heap_bytes` | `Long` | bytes those objects occupy |
Because it collects first, the numbers report *reachable* state, not cycle
residue still awaiting sweep. The call itself allocates the result `Object`,
so back-to-back readings differ by a small constant — measure a delta around
the code under test rather than an absolute count.
The collection finds its roots from the reference counts (CPython's
`gc_refs`), not from a scan of the stack, so which of the program's
objects survive it — and which orphaned `drop`s it fires — is decided by
the reference counts alone: the same on every backend and every run.
`rc_objects` is therefore deterministic for a given program, and the
executor and `--jit` report the same figure. `live_objects` also counts
Strings, which are traced from a conservative stack scan and can straggle
by a few; the background collections between two calls are conservative
too, and cheaper.
```culebra
# doctest: skip
let base = GC.stat().live_objects
build_some_structure()
inspect(GC.stat().live_objects - base) # objects retained by the structure
```
This is the foundation for leak-regression tests: assert that a delta
stays bounded across many iterations. Memory is otherwise managed
automatically — see the language spec for the memory model and
deterministic `drop`.
`GC.refcount(value)` reports how many references a heap value is holding
open, or `nil` for one that carries no count — `Long`, `Float`, `Bool` and
`nil` are immediates, and a `String` is traced rather than counted. It
answers the question `GC.stat()` cannot: a value that is never collected —
anything reachable from a namespace, say — contributes no object growth
however many references pile up on it, so a reference that leaks against
such a value is invisible to a count of objects.
```culebra
let obj = {a: 1}
let base = GC.refcount(obj)
for _ in range(100) {
let alias = obj
}
inspect(GC.refcount(obj) - base) # => 0
```
Read it as a delta, never as an absolute: the call is holding a reference
of its own while it answers, and a value the runtime roots reads far above
what the program can see.
---
## 8. `Tensor`
N-dimensional numeric tensor. Builds a lazy computation graph and
launches BLAS / vDSP kernels through `Tensor.eval(...)` to materialize
values. Supported dtypes are `Float32` (default) and `Float64`.
Shapes can be supplied as variadic args or as an `[m, n]` Array.
`transpose`, `slice`, and `reshape` produce zero-copy views.
```culebra
let A = Tensor.from([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]) # [2, 3]
let B = Tensor.from([[1.0, 0.0], [0.0, 1.0], [1.0, 1.0]]) # [3, 2]
let C = A.dot(B) + 1.0 # lazy: builds the graph only
Tensor.eval(C) # BLAS GEMM runs here
inspect(C.shape()) # => [2, 2]
inspect(C.to_array()) # => [[5.0, 6.0], [11.0, 12.0]]
```
### Construction (namespace functions)
#### `Tensor.zeros(...) -> Tensor` / `Tensor.ones(...)` / `Tensor.randn(...)`
The shape is variadic (`Tensor.zeros(3, 4)`) or an Array
(`Tensor.zeros([3, 4])`). The dtype is a string tag placed as the
**first argument**, Julia-style. `"f32"` is the only dtype (float64
has no fast path on GPU backends):
```culebra
let a = Tensor.zeros(3, 4) # F32 default
let a32 = Tensor.zeros("f32", 3, 4) # explicit
let dims = [3, 4]
let b = Tensor.zeros(dims) # computed shape
let r = Tensor.randn(2, 3) # standard normal
```
#### `Tensor.from(arr: Array) -> Tensor`
Converts a nested Culebra Array into a Tensor. Accepts 1D
(`[1.0, 2.0]`) or 2D (`[[1.0, 2.0], [3.0, 4.0]]`); stored as F32:
```culebra
let v = Tensor.from([1.0, 2.0, 3.0, 4.0]) # [4]
let m = Tensor.from([[1.0, 2.0], [3.0, 4.0]]) # [2, 2]
```
#### `Tensor.concat(parts: Array, axis: Long = 0) -> Tensor`
Stacks tensors along `axis` (rows, by default) into one Tensor. Every
part must share the same dtype, rank, and every dim but `axis`; the
result's `axis` length is the sum of the parts'. Differentiable — the
gradient's own `axis` window routes back to each part.
```culebra
let a = Tensor.from([[1.0, 2.0], [3.0, 4.0]]) # [2, 2]
let b = Tensor.from([[5.0, 6.0]]) # [1, 2]
let c = Tensor.concat([a, b]) # [3, 2]
let k1 = Tensor.randn(2, 4, 8) # [B, T, D]
let k2 = Tensor.randn(2, 1, 8) # one new position
let kv = Tensor.concat([k1, k2], 1) # [2, 5, 8] — a KV-cache append
```
#### `Tensor.where(cond: Tensor, a, b) -> Tensor`
Elementwise select, broadcasting all three operands against each other the
same way a binop does: `cond != 0 ? a : b`. `a`/`b` may be scalars, lifted
against `cond`'s dtype. The building block for masking (attention masks,
padding masks). Differentiable — `da = g*cond`, `db = g*(1-cond)`; `cond`
itself gets no gradient, matching numpy/PyTorch's own `where()`.
```culebra
let mask = Tensor.from([[1.0, 0.0, 1.0]]) # [1, 3], broadcasts over rows
let scores = Tensor.randn(4, 3)
let masked = Tensor.where(mask, scores, -1.0e9) # padding-mask pattern
```
#### `Tensor.adam_step(p, m, v, g, lr, beta1, beta2, eps, step) -> Nil`
Adam's update for one parameter, in place: the moments `m` and `v` advance on
the gradient `g`, then `p` moves by the bias-corrected ratio. All four share
one shape and dtype; `p`, `m` and `v` are written through, so none of them may
be a view and all three must already be evaluated. `step` is 1-based — the
exponent the bias correction uses.
Written out, a step is `m = m*beta1 + g*(1-beta1)`, `v = v*beta2 +
g*g*(1-beta2)` and `p -= m/(1-beta1^step) * lr / (sqrt(v/(1-beta2^step)) +
eps)`: about fifteen operations per parameter, each allocating a buffer the
size of the parameter. This is one pass over the four, and one kernel launch
on CUDA.
```culebra
# doctest: skip
for i in range(params.size()) {
let p = params[i]
Tensor.adam_step(p, moments[i], velocities[i], p.grad(),
lr: 3.0e-4, beta1: 0.9, beta2: 0.95, eps: 1.0e-8,
step: t)
}
```
#### `Tensor.index_add(indices: Tensor, values: Tensor, target_shape: Array) -> Tensor`
`.index_select()`'s exact dual: scatter-adds `values` (shaped like some
`t.index_select(indices)`) into a fresh zero buffer of `target_shape`.
Repeated indices accumulate — this is the embedding-table gradient.
Differentiable w.r.t. `values` (`.index_select(indices)` on the incoming
gradient); `indices` gets no gradient.
```culebra
let idx = Tensor.from([1.0, 1.0, 0.0]) # row 1 hit twice: accumulates
let values = Tensor.from([[10.0, 20.0], [30.0, 40.0], [1.0, 2.0]])
let grad = Tensor.index_add(idx, values, [4, 2]) # [[1,2],[40,60],[0,0],[0,0]]
```
#### `Tensor.scatter_to_axis(indices: Tensor, values: Tensor, size: Long) -> Tensor`
One-hot scatter into a new trailing axis of length `size`: `out[..., k] =
values[...]` where `indices[...] == k`, else 0. `indices`/`values` must
share the same shape. The native primitive a pooling layer's own
hand-derived backward composes with `.fold()` to reconstruct a padded
input gradient — `.max(axis)`/`.argmax(axis)` pick one element out of a
window; this scatters a gradient back into that same window shape.
Differentiable w.r.t. `values`: each one reaches exactly one slot, so the
gradient picks that slot back out. `indices` gets no gradient.
```culebra
let idx = Tensor.from([2.0, 0.0, 1.0])
let values = Tensor.from([5.0, 7.0, 9.0])
let out = Tensor.scatter_to_axis(idx, values, 4)
# [[0,0,5,0], [7,0,0,0], [0,9,0,0]]
```
#### `Tensor.from_csv(path: String) -> Tensor`
Reads a CSV file directly into a contiguous Tensor. Always returns
**rank-2** — a single-column CSV becomes `[N, 1]` (the bias-vector
form). Skips the nested Array intermediary, which is 3-5× faster than
the `Tensor.from(load_2d(path))` pattern (measured on MNIST):
```culebra
# doctest: skip
let W1 = Tensor.from_csv("W1.csv") # [30, 784]
let b1 = Tensor.from_csv("b1.csv") # [30, 1]
let X = Tensor.from_csv("X.csv") # [N, 784]
```
#### `Tensor.eval(t1, t2, ...) -> Nil`
Takes a variable number of Tensor arguments and evaluates the
dependency graph in topological order. Shared subexpressions are
computed once. Call this **at least once per mini-batch boundary** in
a training loop — otherwise the graph accumulates and memory grows
unbounded.
```culebra
# doctest: skip
W2 -= d2.dot(a1.transpose()) * lr
b2 -= d2.sum(1).reshape([N_OUT, 1]) * lr
W1 -= d1.dot(xb.transpose()) * lr
b1 -= d1.sum(1).reshape([N_HID, 1]) * lr
Tensor.eval(W1, b1, W2, b2) # evaluate all four in one pass
```
### Activation functions
Instance methods on a Tensor. A user class may still define its own
`relu` / `sigmoid` / `softmax` (e.g. microGPT's `Value.relu()`) — method
lookup gives the class method priority over the builtin, so there is no
clash.
```culebra
# doctest: skip
let h = z.sigmoid() # 1/(1+exp(-z)) elementwise
let r = x.relu() # max(0, x)
let p = logits.softmax() # over the last axis, online-stable
let l = p.log() # natural log, elementwise
let t = z.tanh() # hyperbolic tangent
let s = z.sin() # elementwise sin/cos
let c = z.cos()
let m = z.clamp(-1.0, 1.0) # elementwise clip to [lo, hi]
```
### Tensor methods
Shape ops, linear algebra, and reductions use method syntax:
| Method | Returns | Description |
|---|---|---|
| `.shape() -> Array` | Array of Long | shape as an Array |
| `.dot(other: Tensor) -> Tensor` | lazy | matrix product; rank-1/2, or batched (rank >= 3, leading dims matching exactly) |
| `.linear_sigmoid(x, b) -> Tensor` | lazy | fused `sigmoid(self @ x + b)` |
| `.pow(exp) -> Tensor` | lazy | elementwise power; `exp` is Tensor or scalar |
| `.gt(other) / .lt(other) / .ge(other) / .le(other) / .eq(other) / .ne(other) -> Tensor` | lazy | elementwise comparison, `1.0` / `0.0` mask; `other` is Tensor or scalar |
| `.tanh() / .sin() / .cos() -> Tensor` | lazy | elementwise trig / hyperbolic tangent |
| `.clamp(lo, hi) -> Tensor` | lazy | elementwise clip to `[lo, hi]` |
| `.rope(pos: Long, base) -> Tensor` | lazy | rotary position embedding (half-split convention) over the last axis; `self` is `[H, D]` or `[H, T, D]`, row `r`'s position is `pos + (r % T)` |
| `.causal_attention(k, v) -> Tensor` | lazy | causal self-attention: `softmax(self·kᵀ / sqrt(D))·v` with row `t` attending rows `0..t`; `self`, `k`, `v` share one shape, `[H, T, D]` or `[T, D]` — see below |
| `.layer_norm(gamma, beta) -> Tensor` | lazy | layer normalization over the last axis: `(self - mean) / sqrt(var + 1e-5) · gamma + beta` per row; `gamma`, `beta` are `[D]` or `[1, D]` — see below |
| `.transpose() -> Tensor` | view | reverse all axes (matrix transpose for rank-2) |
| `.permute(axes: Array) -> Tensor` | view | general axis reorder; `axes[i]` names which of self's axes becomes result axis `i` |
| `.slice(start, end) -> Tensor` | view | take axis 0 in `[start, end)` |
| `.narrow(params: Array) -> Tensor` | view | `[axis, start, end]`; `.slice()` generalized to any axis |
| `.reshape(dims: Array) -> Tensor` | view | contiguous input only; new shape |
| `.unfold(params: Array) -> Tensor` | view | `[axis, win, step]`; sliding window along `axis`, adds a trailing size-`win` axis |
| `.pad(params: Array) -> Tensor` | new buffer | `[axis, before, after]`; zero-pad along `axis` |
| `.fold(params: Array) -> Tensor` | new buffer | `[axis, orig_size, step]`; `.unfold()`'s inverse — scatter-add every window overlap back onto `axis` |
| `.sum() -> Float` | scalar | sum of all elements (forces eval) |
| `.sum(axis: Long?, keepdims: Bool = false) -> Tensor` | lazy | reduce one axis; a nil axis reads as no axis, i.e. the scalar form. `keepdims: true` keeps the axis as size 1, so the result broadcasts back over the input: `x - x.mean(1, keepdims: true)` |
| `.mean() / .mean(axis, keepdims:)` | Float / Tensor | likewise |
| `.max() / .max(axis, keepdims:)` | Float / Tensor | likewise |
| `.argmax(axis: Long, keepdims: Bool = false) -> Tensor` | lazy | reduce one axis to indices stored as Float |
| `.index_select(indices: Tensor) -> Tensor` | lazy | row gather along axis 0: `out[i] = self[indices[i]]` — the embedding-table lookup |
| `.softmax_cross_entropy(targets: Tensor) -> Tensor` | lazy | fused softmax + cross-entropy over the rows of `[N, C]` logits; one loss per row (`[N]`) |
| `.to_array() -> Array` | eager | convert to a Culebra Array (forces eval) |
| `.item() -> Float` | eager | extract the lone element as a Float; throws unless the tensor holds exactly one element (any rank) |
`.item()` is the scalar exit point, complementing `.to_array()` (which is for
shaped data): use it to read a loss or any single-element result without
reshaping. `loss.item()` replaces `to_float(loss.to_array()[0])`.
#### `.softmax_cross_entropy(targets: Tensor) -> Tensor`
Softmax over the classes and cross-entropy against `targets`, fused into one
op. `self` is `[N, C]` logits; `targets` holds one class id per row (`[N]`,
Float-valued like `.index_select()`'s indices, whose element values are
likewise not range-checked). The result is one loss per row, so the caller
picks the reduction:
```culebra
# doctest: skip
let rows = logits.softmax_cross_entropy(targets)
let loss = rows.mean(0) # the plain batch mean
let sft = (rows * mask).sum(0) * (1.0 / kept) # scoring only some rows
```
Composing it by hand gives the same value by a longer route. Composed, the
forward builds a softmax, a one-hot and a clamped log over `[N, C]` before
summing each row to one loss, and `.backward()` walks all of that back.
Fused, the forward reduces each row to `logsumexp(self) - self[target]`
with nothing of `[N, C]` in between, and the VJP is the closed form
`softmax(self) - onehot` in one pass. On an RTX 3090 at `[512, 1000]`:
forward 47 µs against 91 µs, backward 59 µs against 203 µs.
The clamp the composed form needs is inside the op: a class whose
probability underflows to zero contributes `-log(1e-15)` rather than an
infinite loss. Like dezero's own fused `SoftmaxCrossEntropy`, the backward
takes the closed form and does not differentiate that clamp.
#### `.causal_attention(k, v) -> Tensor`
Causal self-attention in one op: for each head and each row `t`,
`softmax(self[t]·k[0..t]ᵀ / sqrt(D))·v[0..t]` — a row attends itself and
the rows before it, never the ones after. `self`, `k` and `v` share one
shape, `[H, T, D]` (heads, rows, head width) or `[T, D]` for a single
head. The scale is fixed at `1/sqrt(D)`; for any other factor, scale
`self` first — the product is bilinear, so that is the same op.
```culebra
# doctest: skip
let q = x.dot(Wq).reshape([T, H, D]).permute([1, 0, 2]) # [H, T, D]
let k = x.dot(Wk).reshape([T, H, D]).permute([1, 0, 2])
let v = x.dot(Wv).reshape([T, H, D]).permute([1, 0, 2])
let ctx = q.causal_attention(k, v) # [H, T, D]
```
Spelled out, the same product is a `.dot()`, a mask added to the `[T, T]`
scores, a `.softmax()` and another `.dot()`. Fused, on CUDA with `D` of
64 or 128 the forward is a single kernel that streams `k` and `v` past
each block of query rows and never writes the `[T, T]` scores out;
elsewhere it is a reference loop with the same result. The backward
rebuilds the probabilities from the unfused ops and applies the standard
attention pullback, so a fused forward trains like the composed one.
#### `.layer_norm(gamma, beta) -> Tensor`
Layer normalization over the last axis in one op: each row becomes
`(x - mean) / sqrt(var + 1e-5) · gamma + beta`, with `mean` and `var` (the
variance, divided by `D`) taken over that row. `gamma` and `beta` hold one
weight per element of the last axis, shaped `[D]` or `[1, D]`; `self` may
have any rank from 1 up. The epsilon is fixed at `1e-5`.
```culebra
# doctest: skip
let gamma = Tensor.ones(1, D)
let beta = Tensor.zeros(1, D)
let h = x.layer_norm(gamma, beta) # x: [T, D] or [B, T, D]
```
Spelled out, the same result is `.mean(last, keepdims: true)`, a
subtraction, a square, a second mean, `+ 1e-5`, `.pow(-0.5)`, two products
and a sum: nine ops, each writing a new buffer the size of `self`. Fused, it
is one pass per row on the CPU and one kernel on the GPU, and only the
result is written. The backward applies the closed-form pullback: on CUDA
three kernels recompute each row's statistics and write the three
gradients directly, elsewhere the normalized rows are rebuilt from the
unfused ops, and either way the gradients match the composed form's.
### Autograd (reverse-mode)
The Tensor primitive carries a native reverse-mode autodiff engine: the
forward graph doubles as the tape, and `.backward()` walks it in C++. No
script-level wrapper is needed — the same op that computes a value also
knows its vector-Jacobian product. The tape is recorded only for ops that
feed a `requires_grad` leaf; forward-only work (inference, or a training
loop that writes its own backward) records none, so it costs no more than
the underlying tensor library.
| Method | Returns | Description |
|---|---|---|
| `.requires_grad() -> Tensor` | self | mark a leaf to accumulate grad; chainable |
| `.backward() -> Nil` | — | seed `dL/dself = 1` and propagate to every leaf |
| `.grad() -> Tensor` | Tensor | the accumulated gradient (zeros until `backward`) |
| `.zero_grad() -> Nil` | — | clear the gradient before the next step |
| `.detach() -> Tensor` | Tensor | a materialized copy with no graph and no grad |
`requires_grad` propagates forward: any op with a grad-tracking input
produces a grad-tracking output. Differentiable ops include `+ - * /`,
`.pow()` (w.r.t. the base), `.dot()`, axis `.sum()` / `.mean()`,
`.relu()`, `.sigmoid()`, `.softmax()`, `.log()`, `.tanh()`, `.sin()`,
`.cos()`, `.clamp()`, `.transpose()`, `.permute()`, `.reshape()`,
`.slice()`, `.narrow()`, `.pad()`, `.unfold()`, `.fold()`, `.rope()`,
`.causal_attention()`, `.layer_norm()`, `.softmax_cross_entropy()`,
`Tensor.concat()`, `Tensor.where()`, `Tensor.scatter_to_axis()`, and
`.index_select()` / `Tensor.index_add()` (each other's own VJP). Gradients
un-broadcast automatically, so a bias added across a batch sums back to its
shape. `.permute()`'s own VJP is the inverse permutation, which is what
lets an attention head split (`[B, C, H, D]` to `[B, H, C, D]`) train:
`.transpose()` reverses *every* axis, so from rank 3 up it is a different
operation, not a shorthand for this one. The permutation rides in the node's
own scalar slot, four bits an axis, so `.backward()` through a `.permute()`
of rank 16 or more raises rather than storing one it has truncated.
`.rope()` needs nothing stored either: rotation by an angle has its own
transpose for an inverse, and negating the second half of the head
dimension before and after turns the forward rotation into that transpose
— so the backward re-runs the forward rather than keeping the cosines it
used. The im2col trio differentiates as two dual pairs: `.pad()`'s VJP is the
crop `.narrow()` performs and `.narrow()`'s is that pad, `.unfold()`'s is
`.fold()` and `.fold()`'s is `.unfold()`. Each stores only its axis and its
step; the window and the length to restore come off the shapes either side.
So a convolution written as pad -> unfold -> reshape -> `.dot()` trains
under `.backward()` with no hand-written backward pass anywhere — see
`examples/tensor/mnist_conv.cul`. Where windows overlap, `.fold()` sums the
copies, which is what makes it the transpose rather than the inverse.
`.gt()` / `.lt()` / `.ge()` / `.le()` / `.eq()` / `.ne()` (and `.max()` /
`.argmax()`) are never differentiable — `.backward()` through a comparison
always raises, the same as PyTorch/numpy: compose the 0/1 mask with `*`
instead (a ReLU-style backward gate is the concrete pattern). Writing a
Tanh/Sin/Cos/Clip backward by hand, composed from other native ops rather
than relying on this, stays valid too — both paths work, whichever a
caller's own autograd style needs.
```culebra
let w = Tensor.from([[2.0, 0.0], [0.0, 3.0]]).requires_grad()
let x = Tensor.from([[1.0], [1.0]]).requires_grad()
let y = w.dot(x) # [2, 1]
let loss = (y * y).sum(0).sum(0)
loss.backward()
Tensor.eval(w.grad(), x.grad())
let gw = w.grad().to_array() # dL/dw
```
A typical training step zeroes grads, runs the forward, calls
`.backward()`, reads `.grad()` for the optimizer update, then `.detach()`
on the new weights to start the next step from a clean leaf. The
`benchmarks/microgpt/microgpt_tensor.cul` transformer is a full worked
example (embeddings, attention with a KV cache, RMSNorm, MLP,
cross-entropy, Adam) built entirely on these methods.
`.backward()` reads gradients from the forward buffers, so it implies a
`Tensor.eval` of the loss. `.grad()` returns a Tensor like any other —
materialize it with `Tensor.eval` before `.to_array()`.
Gradients are kept at the leaves only. An intermediate's gradient is
released as soon as it has been passed on to that node's inputs, so its
`.grad()` reads zeros after `.backward()`, and a second `.backward()`
through the same graph adds to the leaves exactly what that pass
contributes.
`Tensor.no_grad(fn) -> Any` runs `fn` with grad tracking suppressed:
ops inside build no autograd graph (so no tape and no `requires_grad`
flow), and the call returns whatever `fn` returns. Use it for inference
or any forward you will not backprop through.
```culebra
# doctest: skip
let logits = Tensor.no_grad(fn () {
model_forward(x)
})
```
### Operator overloading
`+ - * /` are broadcasting elementwise (numpy / silarray rules).
Scalars mix automatically:
```culebra
let M = Tensor.ones(3, 4)
let v = Tensor.ones(4) # broadcasts to [3, 4]
let r = Tensor.ones(3, 1) # broadcasts to [3, 4]
Tensor.eval(M + v, M + r, M + 1.0, M * 2.0)
```
The `@` operator is not implemented (use `.dot()`).
### Compound assignment (`+=` `-=` `*=` `/=` `**=`) and in-place writes
Compound assignment writes back into the LHS Tensor's buffer (no
fresh Tensor is allocated) when **the LHS owns its buffer and the
shape matches the RHS broadcast result**. Views, unevaluated graph
nodes, and shape mismatches automatically fall back to the ordinary
path (a fresh Tensor).
```culebra
# doctest: skip
mut W = Tensor.randn('f32', 1024, 256)
let alias = W
W -= grad * lr # writes directly into W's buffer
Tensor.eval(alias) # alias.to_array() sees the updated values
```
The SGD-style update `W = W - grad * lr` allocates a W-sized buffer
every step. `-=` removes that allocation; the gap widens for larger
weights and longer loops (measured: 5000 steps on a 1024×256 f32
weight, plain `=` 5.5 s → `-=` 3.6 s).
Supported ops: `+= -= *= /= **=` (`%=` and `@=` are not — `%` has no
defined Tensor semantics, and `@` changes the output shape so
in-place is unsafe).
### dtype / shape constraints
- dtype is F32 only (float64 has no fast path on GPU backends; scalar
entry/exit points like `.item()` / `.to_array()` surface `Float`).
- `.dot()` supports batched matmul (rank >= 3): leading dims must match
between the two operands, and the matmul itself runs over each pair's
trailing two axes (GPU-dispatched per batch slice).
- `.reshape()` and `.softmax()` both accept non-contiguous input (a
`.transpose()` or `.permute()` result, say) — no manual materialize
step needed. `.reshape()` clones to a contiguous buffer first when the
source isn't already one; `.softmax()` reads strided input directly.
### Backends and device selection
Evaluation is delegated to the vendored `cpp-tensorlib` engine
which owns the lazy graph, kernel fusion,
and the device backends:
- **CPU** — vectorized kernels (AVX2 / NEON); Accelerate supplies the
BLAS-shaped kernels on macOS.
- **GPU** — Metal on macOS, CUDA on Linux / Windows.
The device is process-global (shared by VM, JIT, and AOT) and
switchable at runtime:
| Function | Effect |
| --- | --- |
| `Tensor.use_cpu() -> Nil` | evaluate every op on the CPU |
| `Tensor.use_gpu() -> Nil` | evaluate on the GPU backend |
| `Tensor.use_auto() -> Nil` | choose per op by problem size (the default) |
| `Tensor.gpu_available() -> Bool` | whether a GPU backend is compiled in and reachable |
| `Tensor.device() -> String` | the selection in effect: `'cpu'`, `'gpu'` or `'auto'` |
```culebra
inspect(type_of(Tensor.gpu_available())) # => 'Bool'
inspect(Tensor.device()) # => 'auto'
```
`device()` reports the selection, not where a given op ran — under
`'auto'` that is decided per op, and an op sent to a GPU that turned
out to be unreachable still lands on the CPU.
`use_auto` is the default because small tensors lose to kernel-launch
overhead: it keeps those on the CPU and sends only the ops big enough
to pay for the trip. Where that line falls for a matrix product is
measured on the machine itself, at the first evaluation under `auto`
(a few square products timed on both devices, a few milliseconds
once), so the split follows the CPU and GPU actually present rather
than a table from another box. Calling `use_cpu()` / `use_gpu()`
anywhere — including before the first tensor exists — pins every later
op to that device instead.
`use_gpu()` on a build with no reachable GPU falls back to the CPU
path rather than throwing, so a program stays portable; check
`gpu_available()` when the choice matters. Storage stays F32 on every
device (see the dtype constraints above).
Metal needs nothing at build time. CUDA is compiled in when `nvcc` is
found (`CULEBRA_TENSOR_CUDA=AUTO`, the default; `ON` turns a missing
`nvcc` into a configure error and `OFF` skips the backend). The CUDA
driver itself is loaded at run time, so a CUDA-enabled binary still
runs on a machine with no GPU — `gpu_available()` just reports
`false`.
### Profiling
| Function | Effect |
| --- | --- |
| `Tensor.profile(fn) -> Array` | run `fn` with the engine's profiler on; the rows it recorded |
| `Tensor.profile_scope(label, fn) -> Any` | run `fn` under a scope named `label`; `fn`'s result |
`Tensor.profile` returns what the profiler recorded while `fn` ran;
`fn`'s own result is dropped. During the session the engine opens a
scope named after each op it runs (`dot`, `sum_to`, `layer_norm`,
`adam_step`, …), and `.backward()` opens `backward` and one scope per
VJP named after the Culebra op (`Dot`, `LayerNorm`). Nested scopes
join into a path with `/`. Every kernel launch, host-device copy and
blocking wait a backend performs is attributed to the innermost open
scope.
`Tensor.profile_scope` adds a scope of your own — a training phase, a
layer — around `fn` and returns whatever `fn` returns. Outside a
session it just calls `fn`.
Each row is an Object:
| Field | Meaning |
| --- | --- |
| `kind` | `'scope'` for a scope's own row; `'launch'`, `'transfer'` or `'wait'` for work under it |
| `path` | the scope path (`'step/backward/Dot/dot'`) |
| `kernel` | a launch's kernel name; `'h2d'` / `'d2h'` for a transfer; `''` otherwise |
| `count` | scope entries, or launches / transfers / waits |
| `host_ms` | scope: inclusive wall time; transfer and wait: how long the host was blocked |
| `device_ms` | launch: summed kernel time on the device, where the backend can time it |
| `device_timed` | how many of the row's launches `device_ms` covers |
| `bytes` | transfer: bytes moved |
Rows come grouped by path, the paths in descending order of their
scope's inclusive time, each scope's own row first and its launches by
device time. `device_ms` is per launch on CUDA. Metal times a command
buffer, not a launch, so its launch rows have `device_timed` of 0 and
the batch time is not in the rows; WebGPU counts launches only. A row
with `device_timed` below `count` was not timed, which is not the same
as fast.
```culebra
let w = Tensor.from([[1.0, 2.0], [3.0, 4.0]]).requires_grad()
let rows = Tensor.profile(fn () {
Tensor.profile_scope('step', fn () {
w.dot(w).sum(0).sum(0).backward()
})
})
let paths = rows.map(|r| r.path)
inspect(paths.filter(|p| p.starts_with('step/backward/Dot')).size() > 0) # => true
```
Setting `TL_PROFILE=1` in the environment profiles a whole program
from its first evaluation and prints the same rows as a table to
stderr at exit — no code change needed.
---
## 9. `JSON`
Round-trip between Culebra values and JSON text. Both backends ship
the same surface.
### `JSON.stringify(v, indent=0, sort_keys=false, lines=false) -> String`
Serialize `v` to a JSON string.
* `indent > 0` pretty-prints with that many spaces per nesting level
(newline after each comma, `": "` separator). `indent <= 0` is the
compact form. An `indent` whose result is too large to allocate raises
`ValueError` (`JSON.stringify() result is too large`).
* `sort_keys=true` walks `Object` keys alphabetically instead of
insertion order — useful for deterministic diff / hash output.
* `lines=true` emits **JSON Lines**: each element of an `Array` /
`Tuple` / `Set` becomes its own compact line, terminated by `\n`.
An empty collection yields the empty string. Incompatible with
`indent > 0` and with non-list receivers (`TypeError` in either
case).
For backwards compatibility, a positional 2nd argument is still
accepted as `indent`: `JSON.stringify(v, 2)` is `JSON.stringify(v,
indent: 2)`.
Supported value types:
| Culebra | JSON |
|--------------------|---------------------------------|
| `Nil` | `null` |
| `Bool` | `true` / `false` |
| `Long`, `Float` | number (non-finite Float raises `ValueError`) |
| `String` | quoted string with `\n`, `\t`, `\r`, `\"`, `\\`, `\u00xx` escapes |
| `Array` | JSON array |
| `Tuple` | JSON array (same shape as `Array`) |
| `Set` | JSON array, members in insertion order |
| `Object` (String keys only) | JSON object, keys in insertion order |
`Function`, `Tensor`, and Objects carrying non-String keys are not
serializable — `stringify` throws `TypeError` for these.
### `JSON.parse(s, lines=false, number_mode='auto', jsonc=false) -> Any`
Parse a JSON string into a Culebra value.
* `number_mode='auto'` (default): integers (no decimal point or
exponent) read as `Long`; everything else as `Float`. An integer past
`Long`'s range reads as a `Float` too (JSON bounds no number); one past
`Float`'s range is a `ValueError`.
* `number_mode='float'` reads every number as `Float` — useful for
round-trip safety when the producer treats numbers uniformly.
* `lines=true` parses **JSON Lines**: split `s` on `\n`, parse each
non-empty line, return an `Array` of the per-line values.
* `jsonc=true` parses **JSONC**: tolerate `//` line comments, `/* */`
block comments, and trailing commas in objects and arrays — for
reading existing config files (`tsconfig.json`, VSCode `settings.json`)
without stripping them first. The default is strict JSON, which rejects
comments and trailing commas with a `ValueError`.
Malformed input raises `ValueError` and the structured Error Object
exposes the JSON-internal position via `e.line` / `e.col` (both
1-based, pointing at the offending character):
```culebra
let r = try {
JSON.parse('{"a": ,}')
nil
} catch e {
e
}
inspect(r.message) # => 'JSON.parse: expected value'
inspect("{r.line}:{r.col}") # => '1:7'
```
`\uXXXX` escapes decode to UTF-8. A character outside the BMP is written
the way JSON requires it, as a UTF-16 surrogate pair, and the two halves
combine into the one character; half a pair on its own names nothing and is
a `ValueError`. `stringify` writes control bytes as `\u00xx`, so its output
always reads back.
```culebra
# Go's encoding/json escapes `<`, `>` and `&` this way, so replies from a Go
# service arrive carrying them.
inspect(JSON.parse('"\u003cb\u003e"')) # => ''
inspect(JSON.parse('"\uD83D\uDE00"')) # => '😀'
```
Nesting is bounded: containers more than 1000 levels deep raise a
`ValueError` (`nesting too deep (limit 1000)`) instead of exhausting the
C stack. `stringify` applies the same bound to the value tree it walks.
Examples:
```culebra
let v = {name: 'alice', age: 30, tags: ['admin', 'staff']}
# The default is compact; `sort_keys` orders the keys alphabetically.
inspect(JSON.stringify(v)) # => '{"name":"alice","age":30,"tags":["admin","staff"]}'
inspect(JSON.stringify(v, sort_keys: true)) # => '{"age":30,"name":"alice","tags":["admin","staff"]}'
let back = JSON.parse(JSON.stringify(v))
inspect(back.name) # => 'alice'
let arr = JSON.parse("1\n2\n3\n", lines: true)
inspect(arr) # => [1, 2, 3]
let cfg = JSON.parse(
'{
// comments and trailing commas are allowed
"port": 8080,
}',
jsonc: true,
)
inspect(cfg.port) # => 8080
```
`indent` pretty-prints and `lines` emits JSON Lines; both produce
multi-line output:
```culebra
let v = {name: 'alice', age: 30, tags: ['admin', 'staff']}
inspect(JSON.stringify(v, indent: 2))
inspect(JSON.stringify([1, 2, 3], lines: true))
# => |
# '{
# "name": "alice",
# "age": 30,
# "tags": [
# "admin",
# "staff"
# ]
# }'
# '1
# 2
# 3
# '
```
JIT note: built-in `JSON.{stringify, parse}` route through the same
canonical call resolver every other namespace method uses, so every
call shape behaves identically to the VM: positional binding
(`JSON.stringify(v, 2)` sets `indent`, `JSON.parse(s, true)` sets
`lines`), keyword arguments, and both literal `**{...}` and dynamic
`**variable` splats. Used as a first-class value the binding is the
same (`let f = JSON.stringify; f(v, indent: 2)`).
---
## 10. `Args`
Declarative CLI argument parser. The spec is a culebra `Object`
listing positionals, options, and subcommands; `Args.parse` returns
an `Object` whose fields match the spec, prints help on `--help`,
and exits with status 2 on parse errors. For programmatic control,
`Args.try_parse` raises an `ArgParseError` instead, or throws an
`{kind: "ArgParseHelp", help}` value for `--help`.
### `Args.parse(argv: Array, spec: Object) -> Object`
Parse `argv` (typically `Sys.argv`) against `spec`. On `--help` or
`-h`, prints help to stdout and `Sys.exit(0)`. On any parse error,
prints `error: ` to stderr and `Sys.exit(2)`.
### `Args.try_parse(argv, spec) -> Object`
Same engine, but raises on error / help instead of exiting. Use
this from tests or when wrapping into a custom UX.
### `Args.help(spec: Object) -> String`
Produce the help text the parser would print on `--help`, without
parsing or exiting. Useful for embedding into a wider message.
### Spec format
Each argument is an `Object` with these fields:
| Field | Type | Default | Meaning |
|---|---|---|---|
| `name` | `String` | (required) | field name in the parsed result |
| `type` | `String` | `"String"` | `"String"` / `"Long"` / `"Float"` / `"Bool"` |
| `short` | `String` | (none) | short flag letter (e.g. `"v"` → `-v`). Marks it as an option. |
| `default` | `Any` | (none) | default value. Marks it as optional. |
| `doc` | `String` | `""` | help-text description |
| `repeated` | `Bool` | `false` | collect multiple values into an `Array` |
| `positional` | `Bool` | (inferred) | force positional (`true`) or option (`false`) |
Argument **type** of `Bool` means the option is a **flag** that
consumes no value (`--verbose` / `-v`). All other types consume the
next token (`--count 5` / `--count=5`).
An argument is **positional** unless it has `short` or `default`, and
**required** unless it has a `default`, is `repeated`, or is a `Bool`
flag. `positional` states which one it is where those two rules would
disagree: an optional positional needs a `default` to say what it is
worth when absent, which on its own would have made it an option.
```culebra
{name: "file", positional: true, default: "-"} # cat []
{name: "file", positional: true, default: nil} # the same, nil when absent
{name: "out", positional: false} # a required --out
```
Positionals are matched in spec order, but one that may be omitted
only takes a token when more are left than the required positionals
after it still need: `[{name: "files", repeated: true}, {name: "dest"}]`
parses `a b d` as `files == ["a", "b"]` and `dest == "d"`.
### Example
```culebra
# doctest: skip
let spec = {
name: "wc-lite",
doc: "count lines and words",
args: [
{name: "input", type: "String", doc: "input file"},
{
name: "lines",
short: "l",
type: "Bool",
default: false,
doc: "count lines",
},
{
name: "words",
short: "w",
type: "Bool",
default: false,
doc: "count words",
},
{name: "encoding", type: "String", default: "utf-8"},
],
}
let args = Args.parse(Sys.argv, spec)
inspect(args.input) # String
if args.lines {
inspect("lines: ...")
}
if args.words {
inspect("words: ...")
}
inspect("encoding: {args.encoding}")
```
```
$ ./wc-lite -l file.txt
lines: ...
encoding: utf-8
$ ./wc-lite --help
wc-lite - count lines and words
Usage: wc-lite [options]
Arguments:
input input file
Options:
-l, --lines count lines
-w, --words count words
--encoding
-h, --help show this help and exit
```
### Subcommands
`spec.subcommands` is an `Array', got ''
inspect("{r.line}:{r.col}") # => '2:6'
```
### `XML.events(text, keep_space=false) -> Iterator`
The same parser as `parse`, one event at a time, for a document too large to
hold as a tree or when only part of it is needed. Each event is an `Object`:
| `kind` | Other fields |
| --- | --- |
| `'start'` | `tag`, `ns`, `attrs` |
| `'end'` | `tag`, `ns` |
| `'text'` | `text` |
`` yields a `start` and an `end`. Text merges and whitespace is dropped
exactly as in `parse`; comments and processing instructions yield nothing.
Nothing is read at the call. Each step parses only as far as the next event,
so a malformed document raises when the iteration reaches the fault, after
every event before it, with the message starting `XML.events:`. Leaving a
`for` loop early with `break` closes the iterator and releases the parser;
the rest of the text is never read.
```culebra
let texts = XML.events('bootready')
.filter(|ev| ev.kind == 'text')
.map(|ev| ev.text)
.collect()
inspect(texts) # => ['boot', 'ready']
# The tail is not XML, but the loop stops before reaching it.
for ev in XML.events('boot<<<') {
inspect(ev) # => {kind: 'start', tag: 'log', ns: nil, attrs: {}}
break
}
```
### `el.find(path, namespaces={}) -> Object | Nil`, `XML.find(el, path, namespaces={}) -> Object | Nil`
### `el.find_all(path, namespaces={}) -> Array`, `XML.find_all(el, path, namespaces={}) -> Array`
### `el.find_iter(path, namespaces={}) -> Iterator`, `XML.find_iter(el, path, namespaces={}) -> Iterator`
Search the tree under `el` with `path`. `find_all` returns every match in
document order without duplicates, `find` the first of them (or `nil`), and
`find_iter` the same matches as `find_all` as an `Iterator`. The matches are
the elements themselves, not copies.
The path language is a subset of XPath: the abbreviated location paths of
XPath 1.0, plus two name forms from later versions (`*:tag` from XPath 2.0,
`Q{uri}tag` from XPath 3.0). Each form means what it means in XPath. A path is
relative to `el`; steps are separated by `/`.
| Step | Selects |
| --- | --- |
| `tag` | child elements named `tag` |
| `p:tag` | child elements named `tag` in the namespace bound to `p` |
| `Q{uri}tag` | child elements named `tag` in namespace `uri` (`Q{}tag`: in no namespace) |
| `*:tag` | child elements named `tag` in any namespace or none |
| `*`, `p:*`, `Q{uri}*` | every child element (in that namespace) |
| `.` | the element itself |
| `..` | the parent (nothing above `el`) |
| `a//b` | `b` elements anywhere below an `a`, at any depth (`.//b`: anywhere below `el`) |
| Predicate | Keeps an element that |
| --- | --- |
| `[@attr]` | has attribute `attr` |
| `[@attr='v']` | has `attr` equal to `v` (`"v"` works too) |
| `[p]` | has an element the relative path `p` selects from it (`[title]`, `[pitch/step]`, `[.//b]`, `[../note]`) |
| `[p='v']` | has an element `p` selects whose text is `v` (`[title='B']`) |
| `[p/@attr]`, `[p/@attr='v']` | has an element `p` selects with attribute `attr` (equal to `v`); `p//@attr` looks at those elements and every element below them |
| `[n]` | is the `n`th of the candidates (1-based) |
| `[last()]`, `[last()-n]` | is the last candidate, or `n` before it |
Predicates chain (`book[@lang='en'][2]`), each filtering what the one before
kept. As in XPath, a position counts among the children of one parent:
`book[1]` is the first `book` of every parent that has one, and `.//book[1]`
the first `book` child of `el` and of each element under it. Attribute names in
predicates are matched as written in the document (`[@xlink:href]`).
A path in a predicate starts from the candidate element and takes every step
the outer path takes, `.` and `..` included, with predicates of its own
(`note[pitch[step='C']]`); its `..` reaches up to `el` and no further. Its
names resolve as in the outer path. As in XPath, `[p='v']` holds when any one
of the elements `p` selects has text `v`. Predicates nest at most 1000 deep.
**Names and namespaces.** A prefix in a path is looked up in `namespaces:`
first, then in the `xmlns:p` declarations on `el` itself (not its ancestors,
which an element does not record). A prefix found in neither is a
`ValueError`. An unprefixed name matches only elements with no namespace, even
under a default `xmlns=`; to match those, map `""` in `namespaces:` to their
URI (XPath 2.0's default element namespace), or write `Q{uri}tag` or `*:tag`.
Everything else is rejected with a `ValueError` naming the construct and its
column: axes (`following-sibling::`), functions other than `last()`, `and`,
`or`, comparisons other than `=` or with anything but a literal, unions (`|`),
attribute steps (`a/@href`) other than at the end of a predicate's path, and
absolute paths (`/a`, `//a`, in a predicate too: "a path is relative to the
element"). A name in braces without the `Q` (`{uri}tag`, `{*}tag`) is not XPath
either; its error gives the XPath spelling (`Q{uri}tag`, `*:tag`).
```culebra
let lib = XML.parse('ABC')
let titles = |xs| xs.map(|b| b.text('title'))
inspect(titles(lib.find_all('.//book'))) # => ['A', 'B', 'C']
inspect(titles(lib.find_all(".//book[@lang='en']"))) # => ['A', 'C']
inspect(titles(lib.find_all('.//book[1]'))) # => ['A', 'C']
inspect(titles(lib.find_all('shelf/book[last()]'))) # => ['B', 'C']
inspect(lib.find_all(".//book[title='B']/..").size()) # => 1
inspect(lib.find_iter('.//title').count()) # => 3
lib.find('shelf/following-sibling::shelf') # !! unsupported path syntax 'following-sibling::' at column 7
inspect(lib.find_all("shelf[book/@lang='fr']").size()) # => 1
inspect(titles(lib.find_all(".//book[../book/title='B']"))) # => ['A', 'B']
inspect(lib.find_all("shelf[book[title='C']/@lang='en']").size()) # => 1
```
With namespaces:
```culebra
let feed = XML.parse('First')
inspect(feed.ns) # => 'urn:ex:feed'
inspect(feed.children[0].children[1].ns) # => 'urn:ex:media'
let ns = {f: 'urn:ex:feed'}
inspect(feed.text('f:entry/f:title', namespaces: ns)) # => 'First'
# `m` is not in `ns`; the root's own xmlns:m binds it.
inspect(feed.find('f:entry/m:image', namespaces: ns).attrs.href) # => 'a.png'
inspect(feed.find('entry')) # => nil
inspect(feed.find_all('entry', namespaces: {'': 'urn:ex:feed'}).size()) # => 1
inspect(feed.find_all('*:entry').size()) # => 1
```
### `el.text(path=".", namespaces={}) -> String | Nil`, `XML.text(el, path=".", namespaces={}) -> String | Nil`
The text of the first element `path` matches: its XPath string-value, every
text below it joined in document order. `nil` when nothing matches; `''` for
a match with no text. The whitespace `parse` dropped between elements is not
part of it; parse with `keep_space: true` to keep it.
```culebra
inspect(XML.parse('
a bold word
').text()) # => 'a bold word'
inspect(XML.parse('
').text('q')) # => ''
inspect(XML.parse('').text('q')) # => nil
```
### `el.stringify(indent=0, declaration=false) -> String`, `XML.stringify(el, indent=0, declaration=false) -> String`
Write an element as XML text: the `tag`, then `attrs` in order, then
`children`. `ns` is not read; namespace declarations come from `attrs`.
* `tag` must be a `String`. `attrs` and `children` may be left out (or `nil`);
when present `attrs` is an `Object` whose values are `String`, `Long`,
`Float` or `Bool`, and `children` an `Array` of elements and `String`s.
Anything else is a `TypeError`.
* The tag and each key of `attrs` must be names `parse` accepts (a qualified
name: name characters, at most one `:`, not at either end); another is a
`TypeError`. Text and attribute values that hold bytes which are not UTF-8,
or a character no XML document can contain (most control characters), are
a `ValueError`.
* An element with no children is written ``.
* Text escapes `&`, `<`, `>` and carriage return (`
`). Attribute values
are quoted with `"` and escape `&`, `<`, `"`, and tab, newline and carriage
return as character references, so each reads back unchanged.
* `indent > 0` puts each child element on its own indented line, but only in
an element whose children are all elements: an element holding any text
keeps its whole content on one line, so indenting never changes text.
* `declaration: true` starts the output with
`` and a newline.
* Elements nested more than 1000 deep raise `ValueError`, as in `parse`.
`XML.parse(XML.stringify(e))` equals `e` for any `e` that `parse` returned,
with or without `indent`, when both parses use the same `keep_space`.
```culebra
inspect(XML.stringify({tag: 'point', attrs: {x: 1, y: 2.5, note: 'a "b"'}})) # => ''
let page = XML.parse('Hi
#
```
---
## 26. `SQLite`
Embedded SQL database backed by [SQLite](https://sqlite.org) (the amalgamation
is vendored and compiled in — no system library is required). `SQLite.open`
returns a stateful **Database** handle; the high-level `execute` / `query` /
`transaction` methods cover everyday CRUD, and `prepare` returns a reusable
**Statement** handle for hot loops. Both handles close deterministically when
they leave scope, so an explicit `close` / `finalize` is optional.
| Function | Result |
| --- | --- |
| `SQLite.open(path: String) -> Database` | open (or create) a database; `":memory:"` for an in-memory one |
| `SQLite.version() -> String` | the linked SQLite library version, e.g. `"3.53.2"` |
### Database
| Method | Result |
| --- | --- |
| `db.execute(sql: String, params = nil) -> Long` | run one statement; returns rows affected |
| `db.query(sql: String, params = nil) -> Array` | run a query; each row is an `Object` keyed by column name |
| `db.prepare(sql: String) -> Statement` | compile a reusable statement |
| `db.transaction(fn: Function) -> Any` | `BEGIN`, run `fn`, `COMMIT`; any throw triggers `ROLLBACK` and re-raises |
| `db.close()` | close the connection (also runs automatically on scope exit) |
### Statement
| Method | Result |
| --- | --- |
| `stmt.run(params = nil) -> Long` | execute (INSERT/UPDATE/DELETE); returns rows affected |
| `stmt.query(params = nil) -> Array` | execute a query and collect the rows |
| `stmt.finalize()` | release the statement (also runs automatically on scope exit) |
### Parameters
`params` binds placeholders in the SQL. An **Array** binds positional `?`
placeholders left to right; an **Object** binds named `:name` (or `@name` /
`$name`) placeholders by key:
```culebra
# doctest: skip
db.execute("INSERT INTO users VALUES (?, ?)", [1, "Alice"])
db.query("SELECT * FROM users WHERE id = :id", {id: 1})
```
### Type mapping
Values are mapped by the column's runtime type (reads) or the culebra value's
type (writes):
| SQLite | Culebra |
| --- | --- |
| INTEGER | `Long` |
| REAL | `Float` |
| TEXT | `String` |
| BLOB | `String` (raw bytes) |
| NULL | `nil` |
On the write side a `Bool` binds as `0` / `1`; binding any other type (an
`Array`, `Object`, function, …) raises `TypeError`. SQL or constraint errors
raise `SQLiteError` carrying SQLite's own message:
```culebra
let db = SQLite.open(":memory:")
db.execute("CREATE TABLE users (id INTEGER, name TEXT)")
db.execute("INSERT INTO users VALUES (?, ?)", [1, "Alice"])
let rows = db.query("SELECT * FROM users")
inspect(rows[0]["name"]) # => 'Alice'
# a reusable prepared statement
let ins = db.prepare("INSERT INTO users VALUES (?, ?)")
for u in [[2, "Bob"], [3, "Carol"]] {
ins.run(u)
}
ins.finalize()
# all-or-nothing
db.transaction(fn () {
db.execute("UPDATE users SET name = 'Bob!' WHERE id = 2")
})
let r = try {
db.query("SELECT * FROM missing")
nil
} catch e {
e
}
inspect(r.kind) # => 'SQLiteError'
db.close()
```
A Database / Statement handle is tied to the thread (isolate) that created it —
it is not `Sendable` and cannot be passed across an `Isolate` / `Channel`
boundary. Transactions do not nest (use `SAVEPOINT` directly if you need that).
---
## 27. `Canvas`
An immediate-mode 2D framebuffer for little games and pixel graphics: draw a
frame, `present` it, poll input, repeat. Colors are packed RGBA `Long`s and
the buffer can be any size (a WASM-4-style 160×160 is typical). In the WASM
Playground a Canvas program runs in the **Canvas tab** — frames are shown on a
`