On this page

Language guide

Values, types, and methods

Shoal's runtime data model, access rules, equality, arithmetic, conversion boundaries, secrets, and the core collection method vocabulary.

Status
Current evaluator
For
Shoal script authors
On this page
  1. Runtime type map
  2. No general implicit conversion
  3. Conditions are typed
  4. Equality and identity
  5. Arithmetic is unit-aware
  6. Field access is strict
  7. Indexing
  8. Collection transforms
  9. String and regex methods
  10. Records and serialization
  11. Filesystem methods
  12. Interactive picking
  13. Secrets
  14. Resource values need lifecycle awareness

Shoal values carry runtime types through command calls and transformations. Rendering is presentation; a path shown as text is still a path, and a table shown in columns is still structured records.

Runtime type map🔗

TypePurposeTypical source
nullabsenceoptional lookup, missing optional field
booltyped conditioncomparison, .ok
int, floatnumbersliterals, parsers, arithmetic
strUnicode textliterals, command words, decoded output
pathfilesystem path, byte-preserving internallypwd, path(...), path-shaped command words
globpattern plus expansion contextglob(...), wildcard command word
regexcompiled regular expressionre"...", regex(...)
sizebyte count10mb, ls.size, stat.size
durationsigned nanoseconds250ms, outcome .dur
datetime, timecalendar instant / time of daynow, today, t"...", 10:30am
bytesarbitrary byte sequenceoutcome .stdout, file bytes
list<T>ordered valueslist literal, map, collect
recordordered named fieldsrecord literal, stat
tablerecord collection with tabular renderingls, CSV, adapters
rangeinteger interval1..10, 1..=10
stream<T>single-consumer asynchronous sequenceevery, watch, tail, channel events
errorcaught evaluation errorcatch err { ... }
outcome<T>command status, output, and metadataexternal and most builtin commands
task<T>background computationspawn { ... }, trailing &
closurefunction/lambda plus captured bindingsfn, =>
commandparsed command referencealias
secretredacted sensitive materialsecret.get("name")

Large captured stdout may be represented by a content-addressed, lazy bytes value. It behaves as bytes for type-facing operations while avoiding immediate materialization.

No general implicit conversion🔗

Expression operators do not freely turn strings into numbers or paths into strings. Convert at an explicit boundary:

"42".parse_int()
"3.5".parse_float()
path("./README.md")
value.str()
value.display()
value.json()

Command binding is the main controlled coercion site. A typed Shoal function or adapter can turn a command word into its declared int, float, path, glob, size, duration, time, datetime, bool, or collection type. Raw external programs receive argv strings.

fn wait_for(delay: duration) { sleep (delay) }
wait_for 250ms

For paths, .str() is a fallible UTF-8 conversion. .display() is intentionally lossy and suitable for diagnostics, not round-tripping. The path value itself preserves non-UTF-8 platform bytes where the host supports them.

Conditions are typed🔗

Only bool and outcome can be used as conditions. A successful outcome is true; a failed outcome is false.

if items.is_empty() { "empty" } else { "non-empty" }
if (^test -e ./Cargo.toml) { "found" } else { "missing" }

Zero, empty text, empty collections, and null are not silently truthy or falsey. This keeps absence, emptiness, and failure from collapsing into one control signal.

Equality and identity🔗

Most data values compare structurally. Mixed int/float equality promotes numerically, and a path can compare with a string by its display form. Tables compare as record lists as well as with equivalent list<record> values.

Streams and tasks compare by runtime identity. Outcomes and closures also use object identity rather than deep comparison. Content-addressed bytes compare cheaply by hash and length when both sides are ref-backed; materialize before comparing a ref-backed value with resident bytes.

Streams are single-consumer resources, so equality does not inspect their elements.

Arithmetic is unit-aware🔗

Regular numeric arithmetic supports integer/float promotion. Integer operations check overflow where applicable, and division by zero raises div_zero.

Unit values have meaningful combinations:

10mb + 512kb
10mb / 2
10mb / 2mb          # ratio
5s * 3
2h / 30m            # ratio
now + 15m
now - 1d
now - 30d.ago       # duration

Lists concatenate with lists; strings concatenate with strings. Shoal does not guess that a number should become text for +—interpolate or call .str().

Field access is strict🔗

Record fields use .name or a string index:

let user = { name: "Allie", role: "maintainer" }
user.name
user["role"]

A missing field raises field_missing. Optional navigation only handles a null receiver:

maybe_user?.profile?.name ?? "anonymous"

For a record, bare .keys means a field literally named keys. Call .keys() for the record method. This rule prevents method names from silently hiding real data fields.

Other types allow selected zero-argument methods as field-like access, particularly inside implicit lambdas. Paths expose .name, .stem, .ext, .parent, .read, .read_bytes, .lines, .exists, .is_dir, .is_file, .size, and .modified; methods requiring arguments remain calls.

glob("src/**/*.rs").map(.name)
path("README.md").read
path("src").join("main.rs")

Indexing🔗

ReceiverIndexBehavior
listintegerelement; negative counts from end
strintegercharacter; negative counts from end
recordstringnamed field
tableinteger indexing is intentionally unsupported
[10, 20, 30][-1]
"shoal"[0]
record[dynamic_key]
table.first()

Out-of-range access raises index_range. An outcome forwards unknown field and method access to structured .out, but indexing does not forward; write result.out[0] explicitly when the payload is indexable.

Collection transforms🔗

Lists, tables, ranges, globs, and streams share much of a collection vocabulary. The eager methods below operate on finite collections; stream variants preserve incremental behavior where documented.

items.map(x => transform(x))
items.where(.enabled)
items.reduce(0, (acc, x) => acc + x)
items.flat_map(.children)
items.each(x => echo (x))

Core selection and shape methods include:

FamilyMethods
Size/statelen, count, is_empty, first, last
Transformmap, flat_map, flatten, enumerate, chunks, zip
Selectwhere/filter, find, any, all, skip, take
Order/set-likesort, sort_by, reverse, uniq
Aggregatereduce/fold, sum, min, max
Group/combinegroup, group_by, join
Runtime bridgestream, collect, tee, tap/also

Aggregates take zero arguments. Project first:

rows.map(.size).sum()

Do not write rows.sum(.size).

tap/also run a side action while preserving the receiver, which is useful for diagnostics inside a chain.

String and regex methods🔗

Common string operations include:

text.lines()
text.words()
text.chars()
text.trim()
text.upper()
text.lower()
text.split(",")
text.starts_with("v")
text.ends_with(".rs")
text.contains("shoal")
text.replace("old", "new")
text.matches(re"[0-9]+")
text.match(re"^name=(.*)$")

Use .parse_int() and .parse_float() for numeric conversion. Regex values retain their source in .pattern-style representations and compile at construction, so invalid patterns fail early.

Records and serialization🔗

Record methods include keys(), values(), items(), get(key), set(key, value), and merge(other).

let base = { host: "localhost", port: 8080 }
base.merge({ port: 9090 })
base.get("missing")            # null

Use namespace encoders when a specific wire format matters:

json.stringify(value, pretty: true)
yaml.stringify(value)
toml.stringify(value)
csv.stringify(rows)

.json() is a convenient generic JSON representation, but process stdin uses the more specific feed serialization contract.

Filesystem methods🔗

Paths are values with filesystem-aware accessors:

let p = path("./notes.txt")
p.exists
p.parent
p.read
p.read_bytes
p.lines
p.abs()
"replacement".save(p)
"more".append(p)

Most file content reads and mutations go through the evaluator’s host filesystem port, which supports embedding and testing. The boundary is not complete today: some path classification, canonicalization/navigation, watcher checks, and value/stream .save paths call the platform filesystem directly. Do not treat the port as a complete sandbox boundary. Mutation participates in journaling/undo only where the host installs those facilities and the operation has a recorded inverse.

Interactive picking🔗

Finite values can open the terminal picker:

(ls .).pick(prompt: "file> ")
(ls .).pick(multi: true, prompt: "files> ")

pick needs a terminal. It raises an argument error in non-interactive hosts, and canceling it yields an error rather than inventing a selection.

Secrets🔗

A secret is a distinct value whose normal rendering and interpolation expose only its redacted name. Secret material cannot be converted to argv or fed to generic stdin. JSON-like serialization produces a redacted tag rather than the stored bytes. It is intended for explicit, policy-aware injection at approved boundaries such as an HTTP header.

let token = secret.get("deploy_token")
token                         # redacted representation

This type reduces accidental disclosure; it does not make an untrusted process safe once the process is authorized to receive the secret. Kernel principals and leash policy remain the authorization boundary.

Resource values need lifecycle awareness🔗

Streams and tasks are not ordinary replayable collections:

  • a stream is single-consumer and may be unbounded;
  • a task continues until completion or cancellation;
  • a large content-addressed value may load bytes lazily;
  • an outcome retains process metadata and possibly captured storage refs.

Use explicit sinks (collect, each, render, save) and task methods (await, cancel) so ownership stays visible. Streams and channels and Outcomes and errors cover those protocols.

Type to search every guide navigate open esc close
Diagram