On this page
- Lexical
- Comments
- Identifiers
- Keywords
- Strings
- Numbers
- Duration literals
- Size literals
- Operators
- Sigils
- Directives
- Attributes
- The other literals
- Statement layout
- In command argv
- Statements
- The statement rule
- Layout
- Blocks
- Sequencing with ;
- match arms and a trailing |>
- if / elif / else
- let
- for … in … do
- Declarations
- Commands
- How a line decides
- Argv
- Splices
- Pipes
- Streaming, capture, and the markers
- Dynamic heads
- Reifiers and chaining
- Signatures
- Types
- Scalars
- The unit-bearing scalars
- Uuid
- seq
- Tuples
- Records
- Unions
- Option
- Map
- Bytes and Secret
- Functions
- Constraints
- Patterns
- Variables and constructors: lowercase vs uppercase
- Literals
- Wildcard
- Tuple patterns
- Constructors
- Record patterns
- Guards
- The Regex pattern
- function
- Exhaustiveness
- Interpolation and rendering
- Holes
- Generic holes
- show
- Two values render as summaries
- Scopes
- tmp
- cd
- env
- Bare within and always
- always after any kind
- lock
- proc
- serve
- Boundaries: argv and env
- Args.load
- Subcommands
- Defaults
- Env.load
- What both refuse
- Adapters
- Which field types are allowed
- Documents in several shapes
- Keys that are data
- YAML
- TOML
- XML
- Aligned tables
- Editing YAML: Yaml.parse and yaml patch
- Which types serialize automatically
- Anonymous shapes at the boundary
- Modules and imports
- What a module is
- Import forms
- The signature is the export
- Qualified access, and what never leaks
- The graph
- Script-only
- Capabilities travel
- Loops and parallelism
- retry and poll
- Seq.pmap / Seq.piter
- Background processes
- Errors and diagnostics
- Check, then run
- Which error you see
- Raising
- fail and exit
- Log and log levels
- Warnings
- Modules
- Args
- Bytes
- Color
- Dir
- Duration
- Env
- File
- Float
- Frontier
- Graph
- Http
- Instant
- Json
- Log
- Map
- Net
- Option
- Path
- Plan
- Poll
- Proc
- Result
- Retry
- Secret
- Self
- Seq
- Server
- Size
- Str
- Table
- Term
- Tree
- Uuid
- Yaml
- Forms
Reference
Lexical
Comments, identifiers, keywords, string forms, numbers, unit literals, operators, sigils, directives, and attributes.
Comments
// runs to the end of the line. Full-line and trailing forms both
work:
let retries = 3 // trailing
// full-line
print $"{retries}"
A // needs a space before it or must start the line. Inside a word
it is just text, so http://a in a command line stays one word.
On a command line, an unquoted // standing alone as an argument
starts a comment. Quote it ("//") to pass the two characters to a
command.
// inside any kind of string is just text. Inside an interpolation
hole it is a parse error; write the comment outside the string:
print $"{1 // 2}" // a comment cannot live inside a hole
/// lines are doc comments. A doc comment attaches to the
declaration directly below it. A blank line in between detaches it,
but an attribute line ([<...>]) in between does not. The text
shows on hover and in completion. On an Args.load field, the first
line is also the field’s --help text.
Identifiers
An identifier starts with a letter or _ and continues with letters,
digits, _, or '.
Binding names start lowercase. Uppercase names are for types, modules, and constructors:
let Foo = 1 // binding names start lowercase
print $"{Foo}"
Keywords
A keyword can’t be used as a name — let let = 1 is refused with
'let' is a keyword.
| kind | keywords |
|---|---|
| shared with F# | do, elif, else, false, for, fun, function, if, in, let, match, module, of, then, true, type, when, with |
| weir’s own | always, from, import, plan, poll, pure, readonly, retry, to, try, until, within |
| reserved, unused | mutable, rec |
| reserved for a helpful error | def, return, while |
After within comes the kind of scope: cd, env, lock, proc, serve, tmp.
Data is read with from (json, jsonl, table, toml, xml, yaml) and written with to
(json, jsonl, yaml). These words are special only in those positions;
anywhere else they are ordinary names.
pure and readonly each start their own indented block. Code in a
pure block may have no effects at all; code in a readonly block may
read (files, the environment, the clock, HTTP GETs) but change nothing.
pure also marks a function: let pure f x = …. Neither goes after within.
The reserved words are deliberate. Two of them, rec and mutable,
are reserved with no meaning, so writing them out of F# habit gives
an error instead of quietly binding a name:
let rec f x = x // 'rec' is reserved; weir has no let rec
print "y"
Four more are reserved only so that the error can help. weir has no
such constructs, but if you type one out of habit from another
language, the error tells you what to write instead: while points
at retry/poll and seq iteration, return at a function’s last
expression, try at | complete and the within scopes, and def
at let:
while true do print "x" // 'while' is not a weir word — the error points at retry/poll
Strings
Strings come in five literal forms, each written on one line. For text that spans several lines, use a text block (below).
Ordinary — "..." with escapes \", \\, \n, \t:
print "a\tb"
A line break inside an ordinary string is a parse error:
let s = "a
b" // strings close before end of line
print s
Verbatim — @"...". Backslashes are literal, and "" stands for
one quote character:
print @"C:\temp\x"
Triple-quoted — """...""". No escapes at all; a bare " is
fine inside. The string closes at the first """.
Interpolated — $"...". A {expr} is a hole; {{ and }} are
literal braces:
let n = 2
print $"n={n} with {{literal braces}}"
Raw interpolated — $"""...""". No escapes, but holes work, so
one string can contain a backslash, a bare quote and a hole.
There is no $@"...":
print $@"x" // no verbatim-interpolated form; use $"""...""" instead
Text blocks — for text that spans several lines. A line ending in
<<< starts one: the indented lines below it are the text, and its
value is a seq<string> with one element per line.
- Everything inside is literal, including
$,{and quotes. - Indentation is kept relative to the first line. Blank lines inside the block are kept; blank lines at the end are dropped.
- Indentation is spaces only, inside blocks too: a tab in a line’s leading whitespace is an error. A tab is content only after the line’s first non-whitespace character.
$<<<puts values into the text with the same{expr}holes as$"…"strings;{{and}}are literal braces, and$stays as is.$$<<<uses$nameand${expr}instead and leaves braces and quotes alone, which suits JSON or config templates.$$is a literal$, and a$that does not start a name stays as is, so$1survives. Values are inserted exactly as they are, so a value containing"or a newline can break the output; build the text withto jsonwhen values aren’t trusted.- The marker goes at the end of a line (
let x = <<<) or alone on the line below. A marker with no indented block under it is an error. - To pipe the whole block, put a
|>line after it, at or left of the marker’s column (|> Seq.length). A|>indented deeper than the text is just text.
let n = 2
let block = $<<<
n={n} with {{literal braces}}
$HOME stays a byte
block |> Seq.iter print
Numbers
Integer literals are plain digits. A minus sign glued to the number or name after it, with a space (or
nothing, or another operator) before it, makes that value negative —
-5, 2 * -3, and f -1 passes −1 to f:
print $"{-5} {2 * -3}"
Spaced the same on both sides, it subtracts: x-1 and x - 1 both
mean x minus 1. (So x -1 is not subtraction — it passes −1 to x.)
Float literals have a decimal point (1.5). Nothing converts
implicitly: mixing int and float is a type error that suggests
Float.ofInt:
print $"{3 / 2.0}" // no implicit widening; use Float.ofInt 3
A float is always finite. An operation whose result would be NaN or
Infinity raises an error:
print $"{1.0 / 0.0}" // float division by zero raises
Integer overflow raises rather than wrapping.
Duration literals
Each literal has a single unit: 500ms, 30s, 2m, 1h.
print $"{90s + 45s}"
Compound literals do not exist — 1m30s is a parse error:
let d = 1m30s // one unit per literal; write 90s, or add 1m + 30s
print $"{d}"
show renders the compound form (90500ms shows as 1m30.5s), and
Duration.parse reads that form back.
Size literals
Only binary units have literals: 10MiB, 4KiB, 1GiB.
print $"{1KiB + 512B}"
The SI suffixes are not literals:
let s = 10MB // binary units only in literals; Size.parse reads SI text
print $"{s}"
Size.parse accepts SI suffixes in text it reads, as powers of ten.
Operators
Every infix operator, from lowest precedence to highest. All are left-associative:
| binds | operators | meaning |
|---|---|---|
| loosest | |> >> | pipe-apply; function composition (left to right; no <</<|) |
|| | boolean or | |
&& | boolean and | |
== <> < <= > >= | equality and comparison | |
+ - | additive | |
| tightest | * / % | multiplicative; % is integer remainder |
A minus sign directly before a value negates it, even after another
operator (2 * -3).
% is integer remainder. How it handles signs (truncated, unlike
Python’s floored %) and why floats are refused are covered in
Types.
The one precedence rule that tends to surprise: |> and >> share
the loosest level, so xs |> f >> g is (xs |> f) >> g. The error
message says so:
[1; 2] |> Seq.map show >> Seq.length // '>>' and '|>' share precedence; write xs |> (f >> g)
= is not equality. It binds a value in let and record fields, and
using it as a comparison is a check error that suggests ==:
if 1 = 1 then print "eq" // use '==' for equality
| is not in the table because it is the command pipe, not an
operator. Which one you use depends on the right-hand side:
git log | grep x feeds a program, git log |> Seq.head applies a
function, and using the wrong one is an error that names the right
one.
Sigils
These are all of weir’s sigils:
| sigil | where | meaning |
|---|---|---|
$name | command argv | splice a binding as exactly one argv word |
$@xs | command argv | splat a seq: N elements become N words |
$(...) | expression | capture a command chain’s output |
$e(...) | expression | the same, under the env overlay bound to e |
^cmd / ^$name | command name | always run an external program: one named literally, or by a $-spliced value |
_.field | expression | field-access shorthand (fun x -> x.field) |
_[i] | expression | index shorthand (fun x -> x[i]) |
_ | patterns | the wildcard |
weir has no ! for negation; use the word not:
if !true then print "x" // '!' is not negation — write 'not true'
Directives
A # line is an instruction to the tooling, not part of the
language. Some directives are read when a script is checked; others
run immediately, either from the REPL’s init file (read once at
startup) or typed at the REPL prompt. Each directive belongs to
exactly one of these three contexts:
| directive | purpose | context |
|---|---|---|
#sig | bind a command signature file | script file, read at check time |
#schema | the schema a yaml block is checked against (currently written as schema= on the marker line) | script file, read at check time |
#session | session settings: cwd, env, echo cap, log level | init.weir, read once at startup |
#alias | a command alias (#alias k = kubectl) | init.weir (also works at the prompt) |
#help | the directives, the modules, one member’s doc | REPL prompt, runs now |
#find | fuzzy help search (fzf + live preview; substring fallback) | REPL prompt, runs now |
#echo | how many elements of a lazy seq the REPL echoes | REPL prompt, runs now |
#infer | draft named types from a JSON/YAML sample | REPL prompt, runs now |
#save | distill the session to a runnable script | REPL prompt, runs now |
#history | show history (#history all, #history N last N); prints the file path | REPL prompt, runs now |
#quit | leave the REPL | REPL prompt, runs now |
The REPL manual covers the prompt set and the init file. Signatures and schemas cover the check-time ones.
Attributes
[<...>] attributes are read at check time and have no effect at
runtime. The set is fixed.
For command-line flags (Args.load):
[<Short "c">]— pin a one-character short flag ("h"is reserved for--help)[<NoShort>]— suppress the derived short[<Default lit>]— a literal default: string, int, float, bool, duration, or size
For data read and written with the adapters (Adapters):
[<Wire "key">]— the JSON/YAML key for a field, when that key is not a legal weir identifier- how a time, duration or size is encoded:
[<Iso8601>],[<EpochMs>]or[<EpochSec>]on anInstant;[<Millis>]or[<Seconds>]on aDuration;[<ByteCount>]on aSize [<Tag "field">]on a union, naming the field that says which case it is, and[<Other>]on the case that takes any unknown value[<Attr>]and[<Elem "Name">]for XML attributes and repeated child elements
And [<Positional>], in signature files only, where a foreign CLI’s
operands need describing.
A typo is a check error with a did-you-mean:
type Cli = {
[<Shrot "c">]
clean: bool
}
let c = Args.load Cli // unknown attribute 'Shrot'. Did you mean 'Short'?
print $"{c.clean}"
The other literals
true and false are keywords. () is the unit literal, the value
an effect returns.
There is no char type; a single character is just a one-character
string:
let c = 'a' // no char type; "a" is a one-character string
Duration and Size have literals; Instant deliberately does not.
You get a point in time from Instant.now (), Instant.parse or
Instant.parseWith.
Statement layout
A statement starts at column 0; the full layout and block rules are Statements.
In command argv
None of the above applies inside a command’s argv. 30s, 10MiB, a
glued //, and a {brace} are all ordinary words there; it is up to
the program to interpret its arguments.
Statements
The statement rule
There are two kinds of statement:
- a command line streams its output as the program produces it;
- every other statement must have type unit — bind its value
(
let x = …) or print it (expr |> print).
A value that is computed and then ignored is a check error, not silent output:
ls |> Seq.length // computes an int and discards it — bind it, or pipe it to print
Layout
A statement starts at column 0. Indented lines continue it, and the
next line at column 0 ends it. Indentation is spaces only — a tab in
a line’s leading whitespace is an error, inside text and yaml
blocks too. Blank lines and comment lines are
ignored for layout, so you can put gaps inside a block.
An indented let ends at the next line with the same indentation.
An open bracket keeps the statement going, but the closing bracket
must not be at column 0 while the statement is unfinished:
let xs = [
1
2
]
print $"{xs |> Seq.length}"
One layout form is triggered by how a line ends. A line ending in the
yaml marker or a heredoc marker (<<</$<<</$$<<<) opens a
block: the indented lines below are that literal’s content, not weir
statements, and the first less-indented line ends it. The heredoc
forms are covered in Lexical, the yaml
template in the guide and
Adapters. A marker with no indented block below it is
an error that names the marker.
let motd = $<<<
one line, {1 + 1} holes, $literal bytes
motd |> Seq.iter print
Blocks
Lines at the same indentation inside a block run in order. Each one
except the last must be unit, and the last expression is the block’s
value. A check before the result, such as if … then fail …, works
as you would expect:
type Target = { Name: string }
let target =
let stack = "web"
if stack == "" then fail "usage"
{ Name = stack }
print target.Name
Sequencing with ;
; puts several statements on one line. After an if or match,
everything following the ; still belongs to the body, as if it were
an indented block. Both statements below are in the then-branch, so
nothing prints:
if 1 > 2 then print "a" ; print "b"
print "after"
To run something after an if, put it on its own line (or wrap the
if in parentheses). In a command line, ; is a literal argument; it
does not chain commands, so put one command per line.
match arms and a trailing |>
What a |> on its own line after a match arm does depends on its
column. Lined up with the arm’s |, it ends the match and pipes the
whole match — no parentheses needed:
match 5 with
| 5 -> 50
| _ -> 0
|> print
That prints 50: the whole match is piped to print, not just the
last arm’s 0. Put the |> at or under the start of the arm’s
body and it continues that arm instead, whether inline or on its
own line:
match 5 with
| n -> n
|> print
Any column in between — left of the body but right of the | — is
an error. The error tells you the body’s column and suggests either
moving back to the | (to pipe the whole match) or under the body (to
continue the arm). weir does not guess which one you meant.
if / elif / else
if is an expression. else is optional only when the then-branch
is unit, and elif is short for else if. The condition can be a
command chain written directly: its arguments stop at then, and the
condition must still be a bool:
if git rev-parse HEAD | succeeds then print "in a repo"
let
let binds a value, defines a function (let f x y = …, curried),
or destructures (let host, port = target,
let { names = n } = row). The right-hand side can be a command
chain written directly, wherever a let appears:
let tree = git rev-parse HEAD | line
print tree[..6]
for … in … do
for runs a block for each element of a seq, for its effects. The
seq goes on the right of in, a pattern on the left. The body
streams output and raises errors as each iteration runs. It is the
same as Seq.iter; to transform values, use pipelines. The
comprehension form [for … -> …] builds a seq:
for greeting in ["hello"; "again"] do
print greeting
let squares = [for x in [1..5] -> x * x]
squares |> Seq.map show |> print
Declarations
type and module are statements too; import must come first in
the file. A file that starts with module is a module: it contains
only declarations, can be imported, and cannot be run. Directives
(#sig, #schema) go at the top of the file and are read at check
time.
Commands
How a line decides
The first word of a statement decides how the line is read. If it is
a name bound in scope (or a builtin), the line is an expression — an
ordinary function application. If it is an unbound bare word, weir
runs the external program of that name, resolved against PATH before
anything runs. Builtins take precedence over PATH, and ^ls forces
the real program. Inside a function body, its parameters also take
precedence over PATH:
let greet name = print $"hi {name}"
greet "io"
echo hi io
Argv
On a command line every word is passed as written: nothing expands, splits or concatenates. A spliced value becomes exactly one argv word. Pieces written next to each other are an error rather than being glued together:
let root = "build"
rm -rf $root/* // argv words do not concatenate — write $"{root}/*"
A command line continues onto indented lines, like every other
statement. There is no \ line continuation:
printf "%s %s\n" first
second
What command lines do not do:
- no backslash escapes — a
\word is an error that shows the replacement (\;is";", a literal backslash argument is"\\", and\lsis^ls) - no glob expansion —
Path.globis a function - no
$VARexpansion — splice weir bindings instead ~is the one exception, and only when typed directly: an unquoted word that is~or starts with~/means your home directory, resolved when the line runs (cat ~/.bashrc,~/bin/tool,cd ~). A quoted"~/x", an interpolation and a spliced value stay literal, because weir never re-reads data as syntax.~useris not expanded. In an expression, build the path withPath.home (), or withPath.configHome/Path.stateHome/Path.cacheHomefor the XDG directories:File.read $"{Path.home ()}/.bashrc". Each is a pureunit -> string. On Windows they use%APPDATA%/%LOCALAPPDATA%; on POSIX, the$XDG_*variables with the usual fallbacks such as~/.configcd dirchanges the session’s directory (relative to the current one;cdalone goes home) and returns the absolute path it moved to.pwdis the current directory as astring, read at the moment it is used- no
&&— write two statements, or chain them with| and/| or - no redirects —
>is passed as a literal argument, with a warning that suggestsFile.write
When you need bash behaviour, run bash: sh -c "the line". Inside
that quoted string, $w is a shell variable, not a weir binding — to
pass a weir value, interpolate it (sh -c $"echo {w}").
Splices
$name splices a binding, (expr) splices an expression, and $@xs
splats a seq, so N elements become N words:
let marker = "ref"
echo tagged $marker (40 + 2)
echo files: $@(["a.txt"; "b.txt"])
A splice can also read a record field path — $cli.tag,
$cli.out.dir, $@cli.files, or a dynamic head ^$cli.bin. It is
still one word (or one program, for a head). Only an identifier
extends the path, so $x. and $x/y are refused as glued words. This
means bash’s $f.bak reads a field; on a string it is an error that
suggests $"{f}.bak".
type Cli = { tag: string; bin: string }
let cli = { tag = "v1"; bin = "echo" }
^$cli.bin release $cli.tag
Typed values do not convert to argv implicitly. Splicing a Duration
is an error that suggests the explicit forms (Duration.toMillis d or
show d). A Secret does splice in the clear — passing it to a
command is what the type is for — but it is refused in interpolation
and at the serialization boundaries.
Pipes
The operator depends on what is on the right: | feeds a program,
|> applies a function. Using the wrong one is an error that names
the right one:
git ls-files | sort | head -1
git ls-files |> Seq.length |> show |> print
A value on the left of | becomes the program’s stdin:
["b"; "a"] | sort
Streaming, capture, and the markers
A command written as a statement on its own streams its output. With
a let in front, the output is captured instead, as a seq<string>
with one element per line, and nothing is streamed. A bare command is
an ordinary statement in any block and any match case, so most
command lines need no marker at all:
let files = git ls-files
print $"{files |> Seq.length} tracked"
if 2 > 1 then
git status --porcelain
print (($(git rev-parse HEAD) |> Seq.head)[..6])
$(...) captures a command chain where a bare command can’t go:
inside an expression such as an interpolation hole, a record or a
splice (as in the example above). A command line runs to the end of
the line — ; there is just an argv word — so each command needs a
line of its own.
Environment variables for a single command go before it, as in bash:
EDITOR=nano git commit. Each stage of a pipeline takes its own; after
the program name, CC=gcc is an ordinary argument. An env overlay
bound to e applies to a block of commands with within env e, to a
capture with $e(...), and combines with a prefix — where both set the
same name, the prefix wins. There is no ! negation; use the word
not. To choose between two known tools, branch on the whole command
line. When the program really is a runtime value, run it with a
dynamic head.
Dynamic heads
^$name runs the program named by a string value — the same
force-external ^, applied to a runtime string. The value is always
one program name and is never re-parsed: a value containing spaces is
one (odd) program name, the arguments after it splice as usual, and
nothing is globbed or word-split. Use it to dispatch to plugins or
callbacks without sh -c:
let tool = "printf"
^$tool dyn-head-ok
A string-typed capture works directly as a head (^$(… | line)). A
seq-typed value is refused, because one program has to be chosen and
weir never picks a line for you:
^$(git branch) status // which line is the program? bind and pick first
The program is looked up when the line runs, not at check time.
weir check reports no command-not-found for a dynamic head, since
there is nothing to look up yet. A missing program is a run-time error
that points at the line and names the value, and weir check --can
reports the head as not statically known (--strict treats it like
other sites it cannot see through). Reifiers, pipes, captures and env
overlays work exactly as with a literal head:
^$tool build | complete gives you the computed program’s exit.
Reifiers and chaining
A failing command raises an error when its output is read. A reifier —
a | stage after the command — turns the run into a value instead.
Where the command’s output goes follows from what you get back: it is
captured when the value contains it, streams to the terminal when you
only get the exit status, and is discarded by succeeds:
| form | output | result |
|---|---|---|
cmd | succeeds | silent | bool — exitCode == 0, exactly |
cmd | complete | captured | { exitCode; stdout; stderr } |
cmd | orFail "msg" | streams | unit; raises msg (exit N) on nonzero |
cmd | exitCode | streams | the code as int; never raises |
cmd | line | captured | the one trimmed stdout line, a string; raises on nonzero or 0-or-2+ lines |
cmd | text | captured | the whole stdout as one string, trailing blank lines dropped; raises on nonzero |
cmd | exec | the child’s | never returns — the command replaces the weir process |
let r = sh -c "echo out; exit 3" | complete
print $"exit {r.exitCode}, said {r.stdout |> Seq.head}"
A reifier can also follow a whole chain, a | b | c | line. The
chain’s exit code is that of the leftmost failing stage (where the
problem began), or 0. A complete record holds the last stage’s stdout
and every stage’s stderr, in stage order. When a later stage stops
reading early (git log | head -1), the earlier stage is told to stop,
and its exit does not count as a failure. Only exec needs a single
command, because a pipeline cannot replace the process (use
sh -c "a | b" | exec).
let newest = sh -c "printf 'b\na\n'" | sort | head -1 | line
print newest
exitCode is an error where its result would be captured or thrown
away, and the error explains what to do:
sh -c "exit 3" | exitCode // a bare statement discards the code — bind or match it
line, text and exec
The last three rows use the same | spelling but aren’t about the exit
code:
cmd | line— the single stdout line, trimmed, as astring. It raises on a nonzero exit, or if there are no lines or more than one:let sha = git rev-parse HEAD | line.cmd | text— all of stdout as onestring, with trailing blank lines dropped, like bash’s$(…). It raises on a nonzero exit:let notes = git log -1 --format=%B | text.cmd | exec— replaces the weir process with the command (execve; on Windows weir runs it, waits, and exits with its code). The command keeps weir’s pid, so a container entrypoint gets signals directly. It never returns, so it can stand alone as a statement. It can’t take piped input and isn’t allowed insideplan.
All three accept an env overlay ($e(cmd | line)), and line and
text accept piped input (xs | grep foo | line).
Chaining
cmd | and next runs next only if cmd succeeded, like bash’s &&;
if cmd fails, that failure is an error, so the script stops there.
cmd | or next runs next only if cmd failed, like ||; there the
failure picks the branch instead of raising. Both stream output and
return unit, and the right side is a whole command line, so a builtin
such as cd works there:
sh -c "exit 1" | or echo "fell back"
echo built | and echo linked
- One word per chain.
a | and b | and canda | or b | or care fine;a | and b | or cis an error. For “if a, then b, else c”, writeif a | succeeds then b else c(why mixing is refused: coming from bash). - The left of
| oris an external command or a pipeline, which fails with its leftmost failing code. A builtin can’t be there: it raises instead of returning an exit code. - No reifier at either end.
a | and b | completewould capture onlyb, anda | succeeds | and bhas no exit left to branch on; capture on a separate line instead.| execmay end a chain, since it hands over.
if sh -c "exit 1" | succeeds then echo "took the then" else echo "took the else"
Signatures
weir check looks up every command named literally in a script. A
declared signature (#sig tool, generated by weir add sig) extends
the check to the tool’s flags. The details are on the
tooling page.
Types
Scalars
int— 64-bit. Arithmetic overflow raises an error rather than wrapping around, while a range simply stops at the type’s limit, so every value it yields is correctfloat— always finite: an operation that would produceNaNorInfinityraises an error, and==on floats is a check error that suggestsFloat.nearstringboolunit— the value an effect returns, written()
Nothing converts implicitly: 3 / 2 is integer division, and mixing
int and float is a type error that suggests Float.ofInt:
print $"{3 / 2.0}" // no implicit widening; use Float.ofInt 3
% is integer remainder, truncated, so the sign follows the dividend
(-7 % 3 is -1, as in F# and .NET; Python’s floored % gives 2).
Dividing by zero raises an error, as with /. Floats are refused,
because weir floats are always finite and IEEE remainder can produce
NaN:
print $"{7 % 3} {-7 % 3}"
print $"{7.5 % 2.0}" // '%' is integer-only — Float.toInt one side
The unit-bearing scalars
Duration, Size, and Instant carry their unit in the value.
Durations and sizes have literals (Lexical);
instants come from Instant.now (), Instant.parse or Instant.parseWith. Arithmetic between values of
the same type works where it makes sense. Arithmetic across types
doesn’t exist, and the errors name the explicit conversions
(Duration.toMillis, Size.parse, Instant.epochMs).
Uuid
A 128-bit identifier with its own type, not just a string. Uuid.v7
is time-ordered and strictly increasing within the process, so v7 ids
sort by creation both as uuids and as their text; Uuid.v4 is random;
Uuid.v5 is name-based and deterministic. Uuid.parse reads the
hex-and-dash form, 32 bare hex digits, or a urn:uuid: prefix, in any
case, and raises on anything else; Uuid.tryParse returns an Option.
let id = Uuid.v7 ()
print $"{id} is version {Uuid.version id}"
print $"{Uuid.v5 Uuid.ns.url "https://example.com"}"
Uuids compare for equality and sort (Seq.sort, Seq.min), by their
big-endian bytes, which is the same order as their text. There is no
<, since identifiers are not quantities. In JSON and YAML they are
written as the lowercase string, and they splice into a command’s argv
as that same text.
seq
A seq is lazy. Pipelines compute only what they need, and ranges
are generated lazily, while [a; b; c] literals are ordinary eager
values. Iterating a bound pipeline a second time runs its effects
again, including external commands. Seq.freeze computes the values
once and keeps them, and is the usual fix. The checker warns when a
binding backed by a command is iterated in a second place without
being frozen (possible re-enumeration; this is only a warning, and
weir check still exits 0). Capturing a command gives a
seq<string>, one element per line.
A let bound to a command, a pipeline of commands, ls or
Path.glob holds a lazy value: nothing runs when it is bound, and
each read runs it again, in the directory and environment current at
that read; only a within cd/within env it was written inside is
kept, however late the read (let top = ls, then cd "src": top
lists src). These run at once instead: the reifiers
(| line, | text, | complete, | succeeds, | exitCode,
| orFail, | and/| or), Seq.freeze and any other function that
reads the whole sequence, and a $(…) or $@xs in a command
argument (argv is built immediately). The contexts
(guide):
- script: nothing runs until the value is used
- REPL: the echo of a lazy
letshowsname : type (command-backed — runs on each use; type name to run it)and never runs it - init file and modules: a command, a pipeline of commands or a value piped into a command is allowed; the eager forms and passing the command to a function are refused, since nothing may run at load
List items are separated by ; (or by line breaks). A comma builds a
tuple, so [1, 2] is a one-item list holding (1, 2); the checker
warns about that shape and suggests [1; 2], or [(1, 2)] if the
tuple was meant. A list of several pairs, ["a", 1; "b", 2], is fine.
xs[i] is Seq.item i xs and raises an error when out of range. The
bracket must directly follow the name, because f [0] is a function
call. xs[a..b] slices, inclusive and clamped to the available
range (an out-of-range or reversed range gives an empty result rather
than an error), and either end can be left open: xs[..b] / xs[a..].
A bounded slice stops reading a lazy source, so nats[0..4]
terminates. A slice has the type of what you slice: a seq gives a
seq, a string a string ("weir"[1..2] is "ei", counted in
UTF-16 chars), and Bytes gives Bytes. ^n counts from the end
(^n = length − n): xs[^1] is the last element, xs[..^2] all but
the last, xs[^2..] the last two, xs[^3..^1] uses the end for both
bounds. A ^ bound needs the length, so slicing a seq from the end
reads the whole sequence; slices that count from the start stay lazy.
There is no dotted xs.[i].
Tuples
(a, b) — two or more elements. A tuple type is just its element types; it needs no declaration. As soon as
the parts need names, declare a record instead. fst/snd work on
pairs only.
Records
Records are nominal and have a fixed set of fields: a record literal
must name every field, and with copies a record with some fields
changed but cannot add new ones:
type Score = { Name: string; Points: int }
let s = { Name = "a"; Points = 12 }
let s2 = { s with Points = 13 }
print $"{s2.Points}"
type P = { N: int }
let p = { N = 1 }
let q = { p with Extra = 2 } // record update cannot add fields
print $"{q}"
Two declared records with the same fields are different types.
Anonymous record types like {| ip: string |} are the exception: two
with the same fields are the same type, whatever the field order.
They are meant for reading an external shape once
(from json {| ip: string |}); for your own data, declare a record.
Fields can take attributes (Lexical), which
are read at check time and have no effect at runtime. A field whose
JSON/YAML key is not a legal identifier gives the key with
[<Wire "key">].
Unions
Each case can carry a payload; a payload of several values is a
tuple. You construct a value by writing the case name. For an
imported union, constructing a value needs the module name (X.Red),
but a pattern names the case bare (| Red ->):
type Verdict =
| Pass of int
| Fail
let v = Pass 12
match v with
| Pass n -> print $"{n}"
| Fail -> print "no"
Option
Some x / None represent a value that may be missing. The try
functions return them (Seq.tryFind, Str.tryToInt,
Bytes.tryFromBase64), and you handle them with match or the
Option module (defaultValue, map, orFail).
Map
Map<string, T> only allows string keys, matching JSON object keys.
The functions are ofPairs (the last duplicate key wins), get
(raises an error naming a missing key), tryGet, has, and
pairs/keys/values (sorted by key). There is no m[k] indexing,
and == is not defined:
let a = Map.ofPairs [("k", 1)]
print (show (a == a)) // '==' is not defined for Map<string, int>
Bytes and Secret
Bytes holds binary data. It is never converted to or from text
implicitly — you read and write it with functions like
File.readBytes and File.writeBytes — and anywhere it would be rendered as text the error
names the function to use instead
(the guide). It slices like a
sequence — b[1..3], b[..2], b[3..], inclusive and clamped,
giving Bytes — but has no single-element b[i]; use
Bytes.sub start len to take a range, including a single byte
(Bytes.sub i 1). Secret marks a
credential so it is never shown by accident: show masks it,
interpolation and serialization refuse it, Secret.reveal is the
only way to get the text out, and splicing it into argv passes it
as-is.
Functions
let f x y = … is curried, and you can partially apply any function.
Bindings are generic where possible: let id x = x works on any type,
and a function-typed parameter is inferred too, so
let apply f x = f x has type ('a -> 'b) -> 'a -> 'b and can be
called like any function:
let apply f x = f x
print (apply (fun n -> n + 1) 1)
The one place inference falls short: + on two values of unknown
type can’t be inferred (int or string?), so pin one side down, for
example with x + 0.
You can also write a function type — in a union payload, a record
field, or a generic argument: Custom of (string -> bool),
{ matches: string -> bool }. -> is right-associative and binds
more loosely than * and generics (int * string -> bool is
(int * string) -> bool); a function-typed argument needs
parentheses ((unit -> int) -> string). You can construct such a
value and call the function inside it, but ==, to json, to yaml
and show reject any type that contains a function anywhere, and the
error names the field. So whether a record can be serialized depends
on its fields: a record of plain scalars works everywhere.
Constraints
Equality, rendering and ordering each work through a built-in
constraint. Constraints are inferred, never written out, and you
cannot define your own (there are no user type classes). A helper
like let same x y = x == y works on any type that supports
equality, and using it with any other type is an error where it is
called. Functions and seqs can’t be compared, and for floats the
error suggests Float.near.
let same x y = x == y
print $"{same 1 1} {same "a" "b"}"
Patterns
Patterns appear in match arms (the | pattern -> … lines), let,
for, and function parameters. A parameter is a plain identifier,
(), a parenthesized tuple pattern (let dist (x, y) = …), or a
record pattern (without parentheses: let label { names = n } = n).
In these binding positions the pattern must always succeed; a pattern
that can fail (Some x, a literal) is rejected there — use match
or function instead.
type Crew = { names: string }
let label { names = n } = n
let swap (a, b) = (b, a)
print (label { names = "kestrel" })
print $"{swap (1, 2)}"
Variables and constructors: lowercase vs uppercase
A lowercase name in a pattern binds a variable. An uppercase name is a constructor. A mistyped constructor name would therefore bind a variable and silently match everything, so an arm that becomes unreachable because of it is an error with a did-you-mean:
type V =
| Pass
| Failing
match Pass with
| pass -> print "ok" // 'pass' binds — did you mean 'Pass'?
| Failing -> print "no"
Literals
Int, string, and bool literals match by equality:
let word =
match 2 with
| 0 -> "zero"
| 2 -> "two"
| _ -> "other"
print word
Literal arms alone never make a match complete; end with _ or a
variable:
let t =
match 1 with
| 0 -> "zero"
| 1 -> "one" // literals never complete a match: add a _ or var arm
print t
Wildcard
_ matches anything and binds nothing. _ also has two expression
shorthands — _.field and _[i] — which are lambdas, not
patterns.
Tuple patterns
(a, b) destructures a pair; the pattern must have as many elements
as the tuple. It works in let too:
let host, port = ("db", 5432)
print $"{host}:{port}"
fst and snd work on pairs only; a longer tuple is a type error:
print (show (fst (1, 2, 3))) // expected 'a2 * 'a3, got int * int * int
Constructors
A case name matches that case, and a nested pattern destructures its payload, including tuple payloads. Patterns can be nested to any depth:
type C = { names: string }
type W =
| Wrap of C
| Empty
let n =
match Wrap { names = "api" } with
| Wrap { names = n } -> n
| Empty -> "-"
print n
Record patterns
A record pattern can name any subset of fields; the rest are ignored. Field names keep their declared capitalization, while the names you bind are lowercase:
type Container = { State: string; Names: string }
let r =
match { State = "up"; Names = "api" } with
| { State = "up" } -> "running"
| _ -> "not running"
print r
A field may hold a literal, so one arm can both filter and destructure. Such a pattern can fail to match, so it never makes a match complete on its own:
type St = { state: string }
match { state = "up" } with
| { state = "up" } -> print "x" // a refutable record arm needs a catch-all below it
There is no punning — { names = n }, never { names }:
type Pn = { names: string }
let { names } = { names = "x" } // no punning: bind explicitly, { names = n }
print "unreachable"
There is also no {| |} pattern. Plain braces destructure a record
whether its type was declared or anonymous:
let f {| id = i |} = i // no {| |} patterns — the plain brace spelling destructures anonymous shapes too
print (f 1)
Guards
Alternatives share one arm: | "a" | "b" -> …, | Debug | Info -> ….
Each alternative behaves as its own arm with the same guard and body,
so a union’s alternatives count toward exhaustiveness. An alternative
cannot bind a name yet — | (n, 0) | (0, n) -> n is an error; write one
arm per alternative. An arm takes at most 64 alternatives; for a longer
list, test membership in a guard: | x when Seq.contains x values -> ….
type Level = Debug | Info | Warn | Error
let loud l =
match l with
| Debug | Info -> false
| Warn | Error -> true
print $"{loud Info} {loud Error}"
when adds a condition to any arm. An arm with a when guard never
counts toward exhaustiveness:
let tier =
match 42 with
| 0 -> "empty"
| n when n > 100 -> "huge"
| _ -> "ordinary"
print tier
The Regex pattern
Matches a regex and binds its captures in one arm. The regex must be
a raw string (@"..." or """..."""). It is compiled at check time,
so an invalid regex is a check error, and the tuple after it must
have exactly as many names as the regex has capture groups. Groups
are bound as strings:
match "cache=42" with
| Regex @"(\w+)=(\d+)" (key, count) -> print $"{key} -> {count}"
| _ -> print "unparsed"
function
function is a lambda that matches on its argument — short for
fun x -> match x with:
["cache=42"; "noise"]
|> Seq.choose (function
| Regex @"(\w+)=(\d+)" (k, v) -> Some $"{k}: {v}"
| _ -> None)
|> Seq.iter print
Exhaustiveness
A match that doesn’t cover every case is an error, not a warning. Likewise, an arm that can never be reached because a catch-all comes before it is an error. When matching on a union, listing every case makes the match complete; literal arms and record arms that can fail never do.
Interpolation and rendering
Holes
$"…{expr}…" inserts the text of expr into the string. A hole
renders anything show can, records and seqs included. Functions
cannot be rendered:
type Score = { Name: string; Points: int }
let s = { Name = "a"; Points = 12 }
print $"{s} and {[1; 2; 3]}"
print $"{fun x -> x}" // 'a1 -> 'a1 cannot be shown (functions never render)
{{ and }} are literal braces. A comment cannot go inside a hole.
The raw interpolated form $"""…""" has holes but no escapes, and
each line of a $<<< heredoc block follows the same hole rules, with
$ still an ordinary character. The $$<<< splice heredoc uses a
different marker: $name/${expr} substitute and braces stay
literal (Lexical).
Generic holes
A hole that uses a parameter makes the function generic, just as
show would: the function accepts any value a hole can render, and a
function that reads fields works on any record that has them.
let dash n = $"-{n}"
print (dash 5 + dash "x")
type Spec = { name: string; port: int }
let addr s = $"{s.name}:{s.port}"
print (addr { name = "a"; port = 1 })
The one exception is Secret: passing one to such a function is an
error at the call, just as it is in a hole written directly (a Secret
nested inside a record is rendered masked). A hole whose type cannot
be generalized, because the statement it is in does not bind a name,
defaults to string.
show
show x produces the same text as a hole, as a plain string. Use it
where a hole doesn’t fit: as a function value (Seq.map show), and
for a Secret, which show masks as *** while interpolation
refuses it.
Rendering is meant for reading, not for exchanging data: strings
inside rendered structures are quoted, and long seqs are truncated.
Use print for raw output and to json for data other programs
will read.
Two values render as summaries
A Secret always renders as ***. A Bytes value renders as its
size (<12 B>), never its content, since raw bytes can garble a
terminal. Where either is refused, the error names the function to
use instead (Secret.reveal; Bytes.toBase64 / File.writeBytes).
Scopes
within holds a resource for the length of a block and releases it
however the block exits: normal completion, an error, exit n,
SIGINT or SIGTERM. This works both in a terminal and detached: a
kill -INT/kill -TERM sent to a setsid or backgrounded weir
cleans up the same way, exiting with 130/143. The cleanup runs on the
script’s own thread, and no statement after the signal runs: a sleep
or a wait on a scoped process is cut short, and the block unwinds from
there. A reader closing weir’s stdout (weir s.weir | head -1) ends
the script the same way, exiting with 141 (bash’s SIGPIPE status) and
no message. A second signal during cleanup exits immediately, so
pressing Ctrl+C twice gets you out. There are two exceptions, both by design. kill -9 of weir
itself skips cleanup (only a lock is still released, by the kernel).
And a weir started in the background from an interactive shell
(weir … &/nohup, which keeps a controlling terminal) starts with
SIGINT ignored, following the usual job-control convention, so Ctrl+C
in the terminal does not reach it; send it SIGTERM, or kill -INT
its pid directly. The block is an ordinary block: its statements run
and the last expression is its value. It also works as a plain
statement.
| form | holds | on every exit |
|---|---|---|
within tmp d | a fresh directory | removes it |
within cd "path" | the working directory | restores it |
within env vars | an env overlay for child spawns | drops it |
within … always | nothing — a body plus cleanup | runs the always block |
within lock "path" | an advisory file lock | releases it — the kernel does, even on kill -9 |
within proc h = cmd | a background process | kills and reaps its tree |
within serve s = cfg handler | an HTTP listener | closes the socket — the port frees |
tmp
Creates a fresh directory and binds its path. It is fine for the block to remove the directory itself before it exits:
let digest = within tmp d
["payload"] |> File.write $"{d}/f.txt"
Str.sha256 (File.read $"{d}/f.txt" |> Str.join "-")
print digest[..11]
cd
Runs its block in the given directory and restores the previous one however the block exits. A missing directory is an error, raised before the block runs, that shows the absolute path.
env
Adds environment variables for every process started in the block.
Inside the block, Env.get, Env.vars and Env.load see the overlay
too, as pwd sees within cd; after it, they read the unchanged
process environment. Nested overlays combine, with inner values winning, and an
env given directly on a capture ($e(...)) wins over any enclosing
within env:
let vars = [Env.pair "GREETING" "scoped"]
within env vars
sh -c "echo child sees $GREETING"
print (Env.get "GREETING" |> Option.defaultValue "parent stays clean")
The variables can also be written right after within env, as you
would for one command (NAME=value cmd):
let stage = "prod"
within env STAGE=$stage REGION=eu-1
sh -c "echo $STAGE in $REGION"
A body of one command can go on the same line: within env A=1 make,
within cd "src" make.
Bare within and always
Holds no resource; the always block runs however the body exits.
When both the body and the cleanup fail, the body’s error is the one
raised, the cleanup’s failure is printed to stderr with a marker,
and cleanup of the enclosing scopes continues:
within tmp d
within lock $"{d}/demo.lock" timeout=10s
within
print "one holder at a time"
always
print "released either way"
always after any kind
Every kind of within can take a trailing always. It runs inside
the scope while the resource is still held, and the resource is
released afterwards. So the cleanup still sees the tmp directory,
runs in the cd directory with the env overlay, holds the lock,
and finds a proc or serve still running (they are stopped after
it). The bound name is still in scope:
within tmp d
["report"] |> File.write $"{d}/report.txt"
always
cp $"{d}/report.txt" report.txt
It behaves exactly like a bare within … always nested inside the
scope, so all the rules above apply.
lock
An advisory file lock. It waits for the lock by default; with
timeout=30s it raises an error if the time runs out. It works
across processes and across parallel pmap workers.
proc
Starts a background process and binds a handle to it. However the
block exits, the process and its children are killed and cleaned up.
Several scoped processes are stopped in reverse order of starting. A
child’s own exit status is a value (Proc.wait), not an error. The
guide covers the details: watch=, output
files and Proc.tail. A process that must outlive the script is a
daemon and belongs under systemd; weir has no nohup.
serve
An HTTP listener held for the block. within serve srv = { port = 8080; maxConcurrent = 4 } handler opens the socket on loopback, runs
handler — a plain synchronous HttpServerRequest -> HttpServerResponse function — once per request, and closes the socket
however the block exits (normally, by an error, SIGINT, SIGTERM), so
the port is freed. As with proc, the server lives exactly as long as
the block, and there is no stop function. Server.port,
Server.running and Server.streamErrors are all you can do with
the handle.
The listener answers on the same port for all the common loopback
names — 127.0.0.1, localhost and [::1] — so a client using any of
them reaches the handler ([::1] is skipped quietly on a host without
IPv6 loopback). It only listens on loopback and never binds all
interfaces, so it can’t be reached from other machines; put a reverse
proxy in front of it for a public address.
The handler routes on req.path with an ordinary match; there is
no routing framework:
let handler = fun req ->
match req.path with
| "/health" -> HttpServerResponse { status = 200; headers = []; body = Text "ok" }
| _ -> HttpServerResponse { status = 404; headers = []; body = Text "not found" }
The response body is an HttpBody, shared with the Http client —
NoBody, Text, Json, Form, or Stream of seq<string>. A Stream body
is written chunked and flushed per element (SSE-shaped: one event per
element, one data: line per line of it, so a newline cannot forge an
event),
so a lazily produced seq streams as it goes: the client sees the
first elements before the sequence is finished. If a Stream producer
raises partway through, the error is not raised out of the handler.
Instead it is recorded on the handle, and you read it with
Server.streamErrors srv (the response is aborted, so a careful
client can tell it was cut short). maxConcurrent limits how many
handlers run at once, as with Seq.pmapWith; extra requests wait in a
queue.
The config record is { port; maxConcurrent }, optionally with
bodyTimeout (a Duration, default 30s) that limits how long
reading the request body may take, and maxBody (a Size, default
16MiB) that limits its size. A client that sends its body too slowly
gets a 408 instead of tying up a handler; a body larger than maxBody
gets a 413 and is never buffered whole.
HttpServerRequest carries:
method— anHttpMethod. A valid method the union has no case for arrives asOther of string(match it with| Other v ->),QUERYarrives asQuery, and a malformed method gets a 400 before your handler runspathquery— the raw string without the leading?; split it withStr.trySplitOnceheaders— pairs, in the order received. The underlying listener keeps only the last value of a repeated header, so a proxiedX-Forwarded-Forchain shows only the last hopbody— the request text
Deliberately not supported in v1:
- TLS — put a reverse proxy in front
- a routing DSL — the
matchis the router - WebSockets
- request-body streaming
- HTTP/2
Boundaries: argv and env
Command-line arguments and environment variables load the same way: declare a record, load it once, and use the typed values from then on. Loading is strict and reports every problem at once, in a single error, before the script does anything.
Args.load
Field names become kebab-case flags (dryRun → --dry-run), plus a
first-letter short flag where that is unambiguous. A bool field is
true when the flag is present. string, int and the other scalars
are required, and Option makes a field optional. The first line of
the field’s /// comment is its --help text:
type Cli = {
[<Short "C">]
/// clean the target first
clean: bool
port: Option<int>
}
let cli = Args.load Cli
print $"{cli.clean} {cli.port}"
[<Short "c">] sets a short flag ("h" is reserved for --help),
and [<NoShort>] removes one. These problems are all reported
together:
- unknown flags (with a did-you-mean)
- unexpected arguments
- missing required flags
- unparseable values
--help prints the usage even when the rest of the command line is
invalid.
There are no positional arguments; pass operands as flags. To parse
arguments by hand, Args.flag and Args.value scan the raw
Self.args.
Subcommands
Declare subcommands as a union whose cases carry records. The first
argument picks the case, the rest are parsed as that case’s flags,
and the checker makes sure your match covers every case. Flags
shared by all subcommands go once on a containing record, and may
appear before or after the subcommand name:
type CloneArgs = { remote: string; force: bool }
type Cmd = Clone of CloneArgs | Status
match Args.load Cmd with // no argv here: "missing subcommand; one of: clone, status"
| Clone a -> print a.remote
| Status -> print "status"
Defaults
[<Default v>] supplies a value when the flag is absent; the field
stays non-Option, and --help shows the default. On a bool,
[<Default true>] adds a --no-x flag to turn it off.
[<Default false>] is rejected on a bool, since a missing flag is
already false. The attribute takes literals only; for a computed
default, keep the field an Option and fill it in with one line of
code.
Env.load
The same approach works for environment variables. Field names match
variable names exactly, with no case conversion (use
[<Wire "NAME">] for a name that is not a legal identifier).
[<Default>] fills in missing variables, and a Secret field is the
standard way to take in a token. Env bools are read from text
(FLAG=false), not from whether the variable is set.
When a variable has a fixed set of allowed values, declare it as a union of cases without payloads. Matching is case-insensitive (env values are usually uppercase), and an unknown value is an error that lists the allowed ones with a did-you-mean:
type Level =
| Debug
| Info
type LogCfg = { REF_BOUND_LEVEL: Level }
["REF_BOUND_LEVEL=debug"] |> File.write "ref-bound.env"
let e = Env.fromFile "ref-bound.env"
within env e
sh -c "echo level=$REF_BOUND_LEVEL"
print "declared sets beat stringly config"
Env.get "NAME" reads one variable as Option<string>.
Env.fromFile reads a subset of dotenv (KEY=VALUE, quotes, #
comments; no export and no $VAR expansion).
What both refuse
Both refuse Bytes fields, and the error names the conversion to
use. Both parse Instant fields as ISO 8601. A Secret passed as a
flag is visible in ps output; weir does not hide argv.
Adapters
from reads a data format into a type you declare; to writes one.
Three formats work in both directions (json, jsonl, yaml), and
three more are read-only (toml, xml, table). You always say which format
you mean: from json T reads one document however many lines it
spans, while from jsonl T reads one document per line (blank lines
are skipped) and yields seq<T>.
type Peer = { host: string; port: int }
let peer = ["{\"host\": \"a\", \"port\": 9000}"] |> from json Peer
print $"{peer.host}:{peer.port}"
let peers = ["{\"host\": \"a\", \"port\": 1}"; "{\"host\": \"b\", \"port\": 2}"] |> from jsonl Peer
print $"{peers |> Seq.length} peers"
Writing mirrors reading. to json writes one minified document: a
record becomes an object and a seq becomes an array (built in memory
all at once, since a single line cannot be streamed). to jsonl
writes NDJSON, one document per element, lazily. Strings escape only
what JSON requires (", \, control characters); <, &, ' and
non-ASCII text are written as-is, characters beyond the BMP as a
\uXXXX surrogate pair. Each format reads back what it writes:
to json |> from json T, to jsonl |> from jsonl T.
type P = { a: int }
{ a = 1 } |> to json |> Seq.iter print
[{ a = 1 }; { a = 2 }] |> to json |> Seq.iter print
[{ a = 1 }; { a = 2 }] |> to jsonl |> Seq.iter print
(prints {"a":1}, then [{"a":1},{"a":2}] — one array document —
then the two NDJSON lines.)
Which field types are allowed
A field is one of:
- a scalar (
int,float,string,bool) - an
Optionof one of these types - a record whose fields are all of these types
- a
seqof one of these types - a
Map<string, T>
The rule applies recursively. A self-referential record is a check
error that names the cycle. For a top-level JSON array, say so in the
type: from json seq<Peer>. Integer-looking JSON numbers are accepted
by float fields (JSON has only one number type).
A missing array is an error, not a silent []; use Option for
fields that may be absent. Unknown keys are ignored on read. A key
that is not a legal weir identifier is mapped with [<Wire "key">],
and two fields that map to the same key are an error at the
declaration.
Documents in several shapes
A tagged union reads documents whose shape depends on one
field. [<Tag "kind">] on the union names that field. Each case
carries a declared record, or nothing for a document that has only
the tag. The tag value defaults to the case name; [<Wire "v1">] on
a case overrides it. Tagged unions work in JSON and YAML, at the top
level or nested, so from jsonl KDoc reads mixed NDJSON and
from json seq<KDoc> reads a mixed array. Adding one [<Other>]
case (of string, or with no payload) makes the union accept unknown
tags: they land in that case instead of raising an error. A document
with no tag field is always an error, since that is malformed rather
than unknown. When writing, the tag is written first, and an
[<Other>] value cannot be written. Unions without a tag are not
supported; the error suggests [<Tag>].
Keys that are data
Map<string, T> reads an object keyed by IDs, either as a field or
as the whole document. Keys must be strings, entries are iterated in
key order, and for duplicate keys the last one wins. to json writes
the object back.
YAML
from yaml T accepts the same shapes as JSON. Quoting decides a
scalar’s type in both directions: rate: 1.5 is a number, "1.5" is
a string. An empty value and the plain words null, Null, NULL
and ~ are null (YAML 1.2’s core schema): None in an Option
field, an error in a required one; "null" is a string. A quoted
scalar may continue on more deeply indented lines,
as in kubectl’s long message: values:
- the closing quote ends it
- each line break folds to a single space
- an empty continuation line becomes a newline
- the folded value stays a string
Literal block scalars (key: | and key: |-) read as strings. An
indentation indicator such as |2 or |2-, which kubeconfigs
sometimes carry, sets the content’s indentation relative to the key,
so the first line may keep leading spaces. Folded scalars (>) and
|+ are not supported.
from yaml stream T reads a ----separated stream of documents,
each as T. To read a mixed bundle such as a Kubernetes apply file,
use from yaml stream KDoc with a tagged union: stream means “many
documents”, and the union picks the shape of each one. An empty
stream gives zero documents.
Writing mirrors reading exactly. to yaml writes one document: a
record becomes a mapping, a seq a sequence document, and a seq of
pairs one mapping. to yaml stream writes one document per element.
Each form reads back with the matching reader
(to yaml |> from yaml seq<T>,
to yaml stream |> from yaml stream T). A multiline string is
written as a block scalar. The yaml template literal (with checked
structure, splices as nodes, and schema=) is part of the language
and is covered in the guide;
vendoring schemas is on the tooling page.
TOML
from toml T reads one TOML document into T — a Cargo.toml, a
pyproject.toml, a tool’s config. It reads TOML 1.1, so every TOML 1.0
file too, and it is read-only: there is no to toml. The document is
a table, so T is a record (or a tagged union); seq, stream and
Map tops are refused. Inside, the field types follow YAML’s rules:
tables are records, arrays and arrays of tables ([[bin]]) are
seqs, a key that may be missing is an Option, and [<Wire "key">]
names a key that isn’t a weir name, such as requires-python.
- Integers in any TOML spelling (
1_000,0xff,0o7,0b1) read asint;infandnanparse, but reading one into afloatfield is an error, since weir floats are finite. - A date or time is read as its text. An offset date-time such as
2024-03-01T10:00:00Zreads into anInstantfield marked[<Iso8601>]. - Duplicate keys and tables defined twice are errors, reported with the line, as the TOML spec requires.
type Package = { name: string; version: string }
type Cargo = { package: Package; bin: seq<{| name: string |}> }
let cargo = <<<
[package]
name = "demo"
version = "0.3.1"
[[bin]]
name = "cli"
let c = cargo |> from toml Cargo
print $"{c.package.name} {c.package.version}: {c.bin |> Seq.map _.name |> Str.join ","}"
XML
from xml T reads one XML document into T. It is read-only and
supports a subset of XML. The document’s root element maps to the
top-level record. A field matches a child element by local name (a
default xmlns, like MSBuild’s, is stripped so field names stay
plain). [<Attr>] (or [<Attr "Include">]) reads an attribute.
[<Elem "ProjectReference">] names the repeated child element that a
seq< > field reads; by default that is the element type’s name for
seq<record> and the field name for seq<string>. A nested record
reads a child element recursively.
type Ref = { [<Attr>] Include: string }
type Pg = { IsPackable: Option<string> }
type Proj = { [<Elem "PropertyGroup">] groups: seq<Pg>
[<Elem "ProjectReference">] refs: seq<Ref> }
let proj =
[ "<Project>"
" <PropertyGroup><IsPackable>false</IsPackable></PropertyGroup>"
" <ProjectReference Include=\"../Core/Core.csproj\" />"
"</Project>" ]
|> from xml Proj
print $"{Seq.length proj.refs} refs, {Seq.length proj.groups} property groups"
Every XML leaf is text, so a field is string, Option<string> (a
present-or-absent element or attribute), a record, or a seq of a
string or record — nothing else. A numeric or boolean field is
declared string and converted (Str.toInt); the checker tells you
this rather than guessing at a convention XML does not define.
[<Attr>] fits only string/Option<string>, [<Elem>] only a
seq, and the top level is a single root element, so there is no
from xml seq<T> (repeated children are read with a seq< > field).
There is no to xml.
Aligned tables
from table T reads aligned column output, the kind kubectl and
docker print (one header row followed by aligned data rows), into
declared row records, yielding seq<T>. The first non-blank line is
the header. Columns are cut at the header positions, not at
whitespace, so a value containing spaces (Up 2 hours, a free-text
last column) stays intact. Headers are separated by two or more
spaces; a single space stays inside a header, so CONTAINER ID is one
column (both tools pad columns with three spaces).
type Pod = { name: string; status: string; restarts: int; node: Option<string> }
let pods =
[ "NAME STATUS RESTARTS NODE"
"web-1 Running 0 k3d-a"
"db-0 Pending 3 <none>" ]
|> from table Pod
pods |> Seq.iter (fun p -> print $"{p.name}: {p.status} ({show p.restarts} restarts)")
A field matches its header by name, ignoring case and anything that
is not a letter or digit: name reads NAME, podTemplateHash reads
POD-TEMPLATE-HASH. [<Wire "HEADER">] matches a header exactly as
written. Cells are trimmed and converted to the field’s type
(string, int, float, bool). An Option field reads an empty
cell, or a cell that is exactly <none> (as kubectl prints), as
None; an empty cell in a required field is an error that suggests
making it an Option. Extra columns are ignored (as with unknown JSON
keys), blank lines are skipped, and a table with only a header is an
empty seq (docker ps with nothing running). Errors say where: a
missing column is named along with the headers that were found, and a
cell of the wrong type is reported with its row and column. The
result is already a seq of rows, so you don’t write
seq/stream/Map around the type. There is no to table.
az … -o table (and other tabulate-style tools) draw a dashes rule
line under the header (------ ----------). from table skips that
separator when it is the first data row, so az output reads without a
garbage row and #infer types from the real rows. Tables with no
separator (kubectl, docker) are unchanged; only a row that is entirely
dashes and spaces is dropped, and only in first position.
#infer <src> from table as Pod (or sample |> Table.inferShape)
drafts the row record from a real sample. It infers each column’s
type, uses Option where a column has empty or <none> cells, and
notes that the value reads as seq<Pod>.
Editing YAML: Yaml.parse and yaml patch
from yaml T reads into a declared record and drops every field you
did not declare, so it is the wrong tool for read-modify-write. For
that, use the untyped pair, which keeps the whole document.
Yaml.parse reads one document into Yaml nodes (scalars get their
type from their text, exactly as in a yaml block), and Yaml.merge
applies a yaml patch block. The patch’s structure says where each
change goes — kustomize’s strategic-merge model, with no path
language. Maps are merged recursively, adding or updating keys. A
sequence item is appended if absent, or, with by=<key> on the
marker line, updated by that key (and inserted if no item matches). Scalars are replaced. The $- marker
removes things: in value position it removes the key it sits under,
and as - $- <content> it removes the matching item. The order of
changes does not matter and applying a patch twice gives the same
result, so with by= you never write an “update or insert” branch
yourself.
let doc = <<<
kind: Kustomization
images:
- name: app
newTag: v1
|> Yaml.parse
let p = yaml patch by=name
images:
- name: app
newTag: v2
doc |> Yaml.merge p |> to yaml |> Seq.iter print
A patch has type YamlPatch, not Yaml, and the type enforces the
rules. to yaml on a patch is a check error, because a patch is a set
of instructions rather than a document, so a $- removal marker can
never end up in a file. $- outside a yaml patch block is an error,
and patch cannot be combined with schema= (a patch is partial,
while schemas validate whole documents). Yaml.parse never produces a
removal marker; parsed text is always plain data. There is no function
that edits a file in place. You write the round-trip as a pipeline,
File.read f |> Yaml.parse |> Yaml.merge p |> to yaml |> File.write f,
so the change stays visible.
Which types serialize automatically
Types with one obvious encoding are serialized automatically. Types that could be encoded in more than one way are refused, and the error names the explicit conversion. JSON and YAML follow the same rule in both directions, with the same messages.
int,float,string,boolandUuidwork directly. AUuidis written as its lowercase string and read back with any formUuid.parseaccepts; a malformed string raises an error naming the field. A JSON-lines log keyed byUuid.v7stays sortable by id as text.Instant,DurationandSizeare refused unless you say how to encode them: an epoch number and a text form are both reasonable, and weir won’t guess which one the other side expects. Put a codec attribute on the field, or convert explicitly; the error message offers both.
| Type | Codecs | Written as |
|---|---|---|
Instant | [<Iso8601>], [<EpochMs>], [<EpochSec>] | ISO 8601 string; epoch ms; epoch seconds |
Duration | [<Millis>], [<Seconds>] | integer ms; integer seconds |
Size | [<ByteCount>] | integer bytes |
type Event = {
[<Iso8601>] at: Instant
[<Millis; Wire "elapsed_ms">] took: Duration
}
let line = [{ at = Instant.parse "2026-08-14T12:00:00Z"; took = 1500ms }] |> to jsonl |> Seq.head
print line
let back = [line] |> from jsonl Event |> Seq.head
print (show back.took)
One declaration covers both directions, so the writer and the reader
cannot disagree. A codec applies to a T, Option<T> or seq<T>
field, and it must fit the field’s type; this is checked where the
record is declared. [<EpochSec>] and [<Seconds>] raise an error on
a value with sub-second precision rather than silently dropping it.
Codecs affect only to/from json, jsonl and yaml; show and
== ignore them.
Bytesis refused; the error suggestsBytes.toBase64.Secretis always refused. Credentials are never serialized, in any encoding.
Anonymous shapes at the boundary
For a shape you read only once, you can write the type inline:
let n = ["{\"count\": 3, \"noise\": true}"] |> from json {| count: int |} |> _.count
print $"{n}"
Declared records are distinct types even when their fields match. Two anonymous shapes with the same fields are the same type, whatever the field order.
Modules and imports
What a module is
A file that starts with module (alone, or module Name) is a
module. It can be imported, it contains only declarations (type
and let; no commands and no bare expressions), and it cannot be run
on its own. A module’s let cannot run a command when the module is
imported (| line and the other reifiers, |> Seq.freeze, passing
the command to a function); wrap the command in a function, and it
runs when a script calls that function. A plain command value
(let log = git log -n 10, or a pipeline of commands) is allowed: it
is lazy, so importing runs nothing and each use runs it again. A module’s top-level let values are evaluated
when the importing program runs, never by weir check, which never
evaluates anything.
Import forms
import comes first in the file, before any declarations. How a
path resolves depends only on how it is written, never on which files
exist:
import "./lib/x.weir" as X— a file path, relative to the importing file’s directory (lib.weirand names without an extension work too), or an absolute path. If the importing file is a symlink, its real location counts: a script linked into~/binimports the files beside the originalimport "weir:name" as N— a vendored module: weir looks upward for a.weir/directory and loads.weir/modules/name.weir(tooling)asis optional — the alias defaults to the module’s declared name, or the capitalized filename
let modSrc = <<<
module RefMod
/// doubles
let twice : int -> int
let twice n = n * 2
let useSrc = <<<
import "./refmod.weir" as M
print $"{M.twice 21}"
modSrc |> File.write "refmod.weir"
useSrc |> File.write "use-refmod.weir"
weir use-refmod.weir
The signature is the export
A member is exported by declaring its signature —
let twice : int -> int, with no = — above its implementation.
Members without a signature are private to the module: their types
are inferred inside the module, importers can’t see them, and using
one from an importer is a check error that shows the signature to add
(with the inferred type). The implementation needs no type
annotations, because it is checked against the signature. A parameter
can therefore pattern-match on its declared union type, and a generic
signature ('a -> 'a) is enforced: an implementation that is less
general than its signature is an error. The signature must come before
the implementation, and a signature with no implementation is an
error. Put /// docs on the signature line, where the API is
documented. type declarations are always exported. Scripts can’t
use signatures; in a script, types are always inferred.
Qualified access, and what never leaks
Imported values are always qualified: X.helper. Imported types can
be used by their plain name — Ctx { field = v }, from json Ctx —
and a record can also be built with the qualified X.Ctx { field = v }.
An imported union’s cases are qualified when you construct a value
(X.Red) but named bare in a pattern (| Red ->), and a local
declaration always takes precedence over an imported name. A module exports only its own
declarations, not what it imported.
The graph
Imports are transitive. A module imported by two others is checked only once. An import cycle is a check error that shows the loop, and a module cannot import itself. Paths are resolved at check time, from the path as written; nothing is looked up at runtime, and a missing file is an error at the import that shows the resolved absolute path.
Script-only
import needs a file to resolve paths against, so it is not allowed
in -e or at the REPL prompt; the error explains why. The REPL’s
init file is a file, so it can import.
Capabilities travel
check --can follows imports transitively: a module’s commands,
file writes and network access appear in the importing script’s
report, located at the module’s own file:line.
Loops and parallelism
There is no while, and no unbounded loop in any form. Every loop
has a limit: retry (a number of attempts), poll (a time limit),
for (a seq), and the parallel fan-outs.
retry and poll
Both have the same form: key=value options after the keyword, a
block whose last statement is its value, and an optional until
section that binds that value and tests it. If the block’s value is a
bool, that is the success condition, and the whole form returns
unit:
retry attempts=3 delay=100ms
weir -e "print 1" | succeeds
To keep the successful attempt’s output, yield a value and bind it
in until:
let out = retry attempts=3 delay=100ms
let r = weir -e "print 42" | complete
r
until r
r.exitCode == 0
print (out.stdout |> Seq.head)
poll timeout=5m interval=10s has the same form but is limited by
time; it is the loop for waiting until something is ready.
watch=<proc handle> makes it fail immediately if the watched child
process dies. When attempts or time run out, the error states the
attempts made and the time elapsed. The options are an ordinary
record, so you can compute and share them:
let fast = { Retry.defaults with attempts = 3 }, then retry fast.
Seq.pmap / Seq.piter
These run a function over a seq in parallel. Results come back in input order, every item is processed, and if any fail, the error from the earliest item (in input order) is raised once all have finished:
[30; 10; 20] |> Seq.pmap (fun ms -> ms * 2) |> Seq.freeze |> Seq.map show |> print
Each worker gets its own copy of the session, so a cd or env change
inside one only affects that worker and is gone afterwards. At most
64 run at once. The limit protects system resources rather than
matching CPU count, since the work is usually I/O-bound.
Seq.pmapWith / Seq.piterWith set the limit explicitly, and a
value below 1 is an error:
[1] |> Seq.pmapWith 0 (fun x -> x) |> Seq.freeze // degree must be >= 1
There is no async/await, and there won’t be: concurrency in weir comes from processes and pipelines. A task that really needs async belongs in full F#.
Background processes
within proc h = cmd runs a background process for the length of a
block. The process and its children are killed and cleaned up
however the block exits. poll watch=h catches a process that
crashes on startup. The child’s output goes to files (Proc.tail
reads the last lines), and its exit status is a value you get from
Proc.wait rather than an error. The
guide walks through an example with a
running server, and the scopes page covers how scopes
work.
Errors and diagnostics
Check, then run
weir check reports all diagnostics at once, each with a location
and a code, without running anything. A missing command is a warning
at check time (exit 0, so you can still edit scripts for tools you
haven’t installed) and an error at run time:
["nosuchcmd --flag"] |> File.write "ref-missing.weir"
let c = weir check ref-missing.weir | complete
print $"check exits {c.exitCode}"
let r = weir ref-missing.weir | complete
print $"run exits {r.exitCode}"
weir check --json emits the same diagnostics as an array of
{file, line, col, endLine, endCol, severity, code, message} — for
editors, CI gates and agent loops.
Which error you see
When a line has more than one thing wrong, you see one error: the one
at the furthest point the parser reached. A later mistake cannot hide
an earlier one, and separate broken statements each report their own
error. The position is always a real spot in your file; a hint may
replace the message, but never moves the position. check,
check --json and the LSP all report the same error.
An error while the script runs is reported as file:line:col too, at
the statement that failed — inside an if, match, for, within
or retry body, a pipeline, or a lambda, not at the block’s first
line. A failed command points at the command itself, even when its
output is only read a few lines later. Other errors (fail, a builtin)
point at the start of the failing line. A failure inside a function
reports the line in the function, then where it was called from, one
line per call, innermost first:
lib.weir:7:5: error: command failed with exit code 9: sh -c exit 9
called from deploy.weir:4:1
Inline lambdas and for bodies add no called from line; they are
part of the statement they’re written in.
Raising
A failing command raises an error when its output is read; a reifier
turns the failure into a value instead
(commands). Builtins raise errors with a
location and a readable message (File.read: no such file: …). There
is no try/catch block. A resource that needs cleanup goes in a
within scope, which releases it however the block exits.
To turn a failure into a value, end an expression with |> try — the
expression twin of | complete:
e |> tryisOption<'T>:Some value, orNoneif evaluatingeraised.e |> try resultisResult<'T, string>:Ok value, orErrorwith the message.Resulthasmap,defaultValue,toOptionandisOk.- Everything to the left of
|> tryis captured (|>groups left to right), and the value is fully evaluated inside, sequences included, so a failure can’t surface aftertryreturns. An infinite sequence on the left never finishes. exitstill exits, and weir’s own internal errors are not caught. Effects that happened before the failure are not undone.tryis only valid after|>.
A script’s own union cases take precedence over the prelude’s: a
type Status = Ok | Error declared in the script keeps constructing
its own Ok, while a match on a try result value still sees
Result’s cases.
fail and exit
fail "reason" stops the script with a located error and exit 1.
exit n exits silently with a specific code, which is how you pass
on a child process’s failure:
let r = sh -c "exit 3" | complete
if r.exitCode <> 0 then print $"would exit {r.exitCode}"
Log and log levels
Log.trace/debug/info/warn write levelled diagnostics to
stderr; WEIR_LOG sets the level for one run (off silences them).
There is deliberately no Log.error, because an error message should
never be hidden by an environment variable. For a message that must
always appear, use printerr; to stop, use fail. Stdout is exactly
the same at every log level:
let e = Env.ofPairs [("WEIR_LOG", "off")]
within env e
weir -e "Log.info \"hidden\" ; printerr \"loud\" ; print 1"
Warnings
Warnings never stop a run; they point out a likely mistake and carry
on. Examples are a missing command at check time, > passed as a
literal argv word, and ; in a command line. Each warning says what
to use instead.
Modules
Args
Args.flag
Args.flag (name: string) : string -> bool
True when a --flag (or its short form) is present in argv.
Args.flag "verbose"
Args.value
Args.value (name: string) : Option<string>
The value of a --name option as an Option.
Args.value "name"
Bytes
Bytes.fromBase64
Bytes.fromBase64 (s: string) : Bytes
Decode standard base64 (padded or unpadded) to Bytes. Raises on malformed input. Use it for binary data; Str.fromBase64 decodes to text.
Bytes.fromBase64 "iVBORw0KGgo="
Bytes.fromHex
Bytes.fromHex (s: string) : Bytes
Decode hex (upper or lower case) to Bytes. Raises on odd-length or non-hex input. Handy for crypto values such as fingerprints, moduli and EC points, which are usually written in hex.
Bytes.fromHex "0a1B" |> Bytes.toHex
Bytes.hmacSha256
Bytes.hmacSha256 (key: Bytes) (msg: Bytes) : Bytes
Compute an HMAC-SHA256 of a message with a key (key first), returned as Bytes. To use a Secret as the key, pass Secret.reveal s |> Str.toUtf8; revealing a Secret is always explicit.
Bytes.hmacSha256 (Str.toUtf8 "key") (Str.toUtf8 "msg") |> Bytes.toHex
Bytes.length
Bytes.length (b: Bytes) : Size
The byte count as a Size.
Bytes.sha256
Bytes.sha256 (b: Bytes) : string
The SHA-256 digest of the bytes, as lowercase hex (same output as sha256sum).
Bytes.sub
Bytes.sub (start: int) (len: int) (b: Bytes) : Bytes
A slice of the bytes by start index and length. Works like Str.sub, including the error when the range is out of bounds.
Str.toUtf8 "abcd" |> Bytes.sub 1 2 |> Bytes.toHex
Bytes.toBase64
Bytes.toBase64 (b: Bytes) : string
Base64 of the bytes, on one line. Use it to turn bytes into text, since print, JSON and YAML do not accept raw bytes.
Bytes.toHex
Bytes.toHex (b: Bytes) : string
The bytes as lowercase hex on one line (the same format sha256 uses).
Str.toUtf8 "hi" |> Bytes.toHex
Bytes.tryFromBase64
Bytes.tryFromBase64 (s: string) : Option<Bytes>
Like fromBase64, but returns an Option: None for malformed base64.
Bytes.tryFromBase64 "!!!"
Color
Color.blue
Color.blue (s: string) : string
Color the text blue. No color when output is piped or NO_COLOR is set.
print (Color.blue "info")
Color.bold
Color.bold (s: string) : string
Make the text bold; combine with a color by nesting. No styling when output is piped or NO_COLOR is set.
print (Color.bold (Color.red "!"))
Color.cyan
Color.cyan (s: string) : string
Color the text cyan. No color when output is piped or NO_COLOR is set.
print (Color.cyan "x")
Color.dim
Color.dim (s: string) : string
Make the text dim. No styling when output is piped or NO_COLOR is set.
print (Color.dim "note")
Color.gray
Color.gray (s: string) : string
Color the text gray (bright black). No color when output is piped or NO_COLOR is set.
print (Color.gray "muted")
Color.green
Color.green (s: string) : string
Color the text green. No color when output is piped or NO_COLOR is set.
print (Color.green "ok")
Color.magenta
Color.magenta (s: string) : string
Color the text magenta. No color when output is piped or NO_COLOR is set.
print (Color.magenta "x")
Color.red
Color.red (s: string) : string
Color the text red. No color when output is piped or NO_COLOR is set.
print (Color.red "error")
Color.sgr
Color.sgr (code: string) (s: string) : string
Style the text with a raw terminal escape code, for 256-color (38;5;208) or truecolor (38;2;r;g;b). No styling when output is piped or NO_COLOR is set.
print (Color.sgr "38;5;208" "orange")
Color.underline
Color.underline (s: string) : string
Underline the text. No styling when output is piped or NO_COLOR is set.
print (Color.underline "link")
Color.yellow
Color.yellow (s: string) : string
Color the text yellow. No color when output is piped or NO_COLOR is set.
print (Color.yellow "warn")
Dir
Dir.copy
Dir.copy (src: string) (dst: string) : unit
Copy a directory and everything in it from src to dst. Raises if the destination exists (use Dir.deleteAll first to replace it) or lies inside the source. The copy is always recursive; symlinks are copied as links, never followed.
let d = Path.newTempDir ()
Dir.copy d $"{d}-c"
Dir.deleteAll d
Dir.deleteAll $"{d}-c"
Dir.create
Dir.create (path: string) : unit
Create a directory and any missing parents (like mkdir -p). Does nothing if it already exists, unlike copy and move, which raise on an existing destination.
Dir.create (Path.tempRoot ())
Dir.delete
Dir.delete (path: string) : unit
Delete an empty directory. Raises when it does not exist, or when it is not empty (the error points you to Dir.deleteAll).
Dir.delete (Path.newTempDir ())
Dir.deleteAll
Dir.deleteAll (path: string) : unit
Delete the directory and everything in it, recursively. There is no undo.
let d = Path.newTempDir ()
Dir.create $"{d}/tree-a/tree-b"
Dir.deleteAll d
Dir.exists
Dir.exists (path: string) : bool
True when the directory exists.
Dir.exists "."
Dir.list
Dir.list (path: string) : seq<string>
The full paths of the directory's entries, both files and directories, sorted by name and read immediately. Filter with Seq.where and File.exists/Dir.exists; for a recursive listing use Path.glob "**".
let d = Path.newTempDir ()
print $"{Dir.list d |> Seq.length}"
Dir.delete d
Dir.move
Dir.move (src: string) (dst: string) : unit
Move (rename) a directory from src to dst. Raises if the destination exists.
let d = Path.newTempDir ()
Dir.move d $"{d}-m"
Dir.delete $"{d}-m"
Dir.stat
Dir.stat (path: string) : seq<FileRow>
The directory's entries as FileRow values, the same rows ls shows, but for any directory (ls only lists the current one). Sorted by name and read immediately.
let d = Path.newTempDir ()
print $"{Dir.stat d |> Seq.length}"
Dir.delete d
Duration
Duration.average
Duration.average (xs: seq<Duration>) : Duration
The mean duration, truncated to whole milliseconds. Raises on an empty sequence.
[90s; 30s] |> Duration.average
Duration.h
Duration.h (n: int) : Duration
A duration of n hours.
Duration.h 2
Duration.m
Duration.m (n: int) : Duration
A duration of n minutes.
Duration.m 5
Duration.ms
Duration.ms (n: int) : Duration
A duration of n milliseconds. The function form of a literal like 500ms.
Duration.ms 500
Duration.parse
Duration.parse (text: string) : Duration
Parse a duration in the form show prints, such as 1h30m, 2.5s or 500ms. Raises on anything else, including values finer than a millisecond.
Duration.parse "1h30m"
Duration.s
Duration.s (n: int) : Duration
A duration of n seconds.
Duration.s 30
Duration.sleep
Duration.sleep (d: Duration) : unit
Pause for the duration. Zero returns immediately; a negative duration raises. The OS timer resolution (about 15ms on Windows, 1ms on Linux) means a short sleep may last longer than asked, never shorter. It is called Duration.sleep so that plain sleep still runs the system command.
Duration.sleep 10ms
Duration.sum
Duration.sum (xs: seq<Duration>) : Duration
Sum durations.
[90s; 30s] |> Duration.sum
Duration.toMillis
Duration.toMillis (d: Duration) : int
The total milliseconds as an int, for example for ratios or JSON fields.
Duration.toMillis 2m
Duration.toSeconds
Duration.toSeconds (d: Duration) : float
The total seconds as a float, without losing precision (2500ms is 2.5).
Duration.toSeconds 2500ms
Duration.tryParse
Duration.tryParse (text: string) : Option<Duration>
Like Duration.parse, but returns None instead of raising.
Duration.tryParse "not-a-duration"
Env
Env.fromFile
Env.fromFile (path: string) : seq<EnvVar>
Read .env lines (KEY=value) as EnvVar records.
Env.get
Env.get (name: string) : Option<string>
A process environment variable as an Option.
Env.get "PATH"
Env.ofPairs
Env.ofPairs (pairs: seq<string * string>) : seq<EnvVar>
Build EnvVar records from name/value tuples.
Env.ofPairs [("K", "V")] |> Seq.freeze
Env.pair
Env.pair (name: string) (value: string) : EnvVar
Build one EnvVar from a name and value.
Env.pair "K" "V"
Env.vars
Env.vars : seq<EnvVar>
Every environment variable as EnvVar records.
Env.vars |> Seq.freeze
File
File.append
File.append (path: string) (lines: seq<string>) : unit
Append a sequence of lines, safely even when other processes append to the same file. Each call is written as a single append (O_APPEND on Unix, append-only access on Windows), so its lines stay together and intact, and no lock blocks readers. If the system writes only part of the data (disk full, a size limit), the rest is retried and another writer's data may land in between. This holds on local filesystems, including bind mounts on the same host, but not on network filesystems (nfs, smb).
File.copy
File.copy (src: string) (dst: string) : unit
Copy a file from src to dst. The arguments are in the usual (src, dst) order rather than data-last, since neither one is the data. Raises, naming the path, if the destination exists; delete it first to overwrite.
let d = Path.newTempDir ()
["x"] |> File.write $"{d}/a.txt"
File.copy $"{d}/a.txt" $"{d}/b.txt"
Dir.deleteAll d
File.delete
File.delete (path: string) : unit
Delete a file. Raises, naming the path, when it does not exist. Copy and move never overwrite, so delete the destination first when you want to replace it.
let d = Path.newTempDir ()
["x"] |> File.write $"{d}/f.txt"
File.delete $"{d}/f.txt"
Dir.delete d
File.exists
File.exists (path: string) : bool
True when a path exists.
File.exists "README.md"
File.isExecutable
File.isExecutable (path: string) : bool
True when the owner execute bit is set (the bit an installer sets). Simpler than checking File.mode with Str.contains "x". Follows symlinks like the other File.* queries; a missing path raises. Windows has no execute bit, so there the answer is based on the extension (.exe/.bat/.cmd/.com).
File.isExecutable "."
File.mode
File.mode (path: string) : Option<string>
The path's permissions as text like rwxr-xr-x, wrapped in Some; None on Windows, which has no POSIX permissions. Like the other File.* queries it follows symlinks, so a broken link raises, naming the link. A typical use is checking for 0600 before File.readSecret: File.mode p == Some "rw-------".
File.mode "." |> Option.defaultValue "none"
File.move
File.move (src: string) (dst: string) : unit
Move (rename) a file from src to dst. Raises if the destination exists.
let d = Path.newTempDir ()
["x"] |> File.write $"{d}/a.txt"
File.move $"{d}/a.txt" $"{d}/b.txt"
Dir.deleteAll d
File.read
File.read (path: string) : seq<string>
Read a file's lines. The whole file is read immediately.
File.readBytes
File.readBytes (path: string) : Bytes
Read a file's raw bytes, exactly as stored, without decoding or splitting into lines (File.read does both and replaces invalid characters). The whole file is loaded into memory, so it is not meant for very large files.
File.readSecret
File.readSecret (path: string) : Secret
Read a file's whole content as a Secret, for example a secret mounted into a container. Trailing newlines are removed.
File.sha256
File.sha256 (path: string) : string
The SHA-256 digest of a file's bytes, as lowercase hex (same output as sha256sum). The file is streamed, never loaded into memory whole.
File.size
File.size (path: string) : Size
The file's size as a Size, which you can compare directly (File.size p > 10MiB); use Size.toBytes to get an int. Raises when the file does not exist (there is no trySize yet).
let d = Path.newTempDir ()
let f = $"{d}/a.txt"
["x"] |> File.write f
print $"{File.size f}"
Dir.deleteAll d
File.stat
File.stat (path: string) : FileRow
The FileRow for one path, the same row ls shows, so Path.glob ... |> Seq.map File.stat turns paths into rows. For a symlink it describes the link itself (kind Symlink, target Some), not what it points to. Raises when the path does not exist; note that a file found by glob may be deleted before stat reads it.
let d = Path.newTempDir ()
let f = $"{d}/a.txt"
["x"] |> File.write f
print (File.stat f).name
Dir.deleteAll d
File.write
File.write (path: string) (lines: seq<string>) : unit
Write a sequence of lines to a file, replacing its contents.
File.writeAtomic
File.writeAtomic (path: string) (lines: seq<string>) : unit
Write a sequence of lines by replacing the whole file at once: the content goes to a temp file in the same directory, is flushed to disk, and is then renamed over the target. Readers see either the complete old file or the complete new one, and a failed write leaves the original intact. For a symlink, the file it points to is replaced, not the link; the file's permissions and any existing BOM are kept. Because the file is a new inode, hard links are separated, open handles and tail -f keep seeing the old file, and a single-file bind mount into a container stops updating; use File.write when you need an in-place write. Inside a plan it records the same WriteFile op as File.write, and apply then does an in-place write.
File.writeBytes
File.writeBytes (path: string) (bytes: Bytes) : unit
Write raw bytes to a file exactly as given (File.write instead encodes lines and ends each with LF).
Float
Float.abs
Float.abs (f: float) : float
The absolute value.
Float.abs (0.0 - 1.5)
Float.average
Float.average (xs: seq<float>) : float
The mean of a float sequence. Raises on an empty sequence or a non-finite result.
[1.0; 2.0] |> Float.average
Float.near
Float.near (a: float) (b: float) (eps: float) : bool
True when a and b differ by at most eps. This is how to compare floats, since == does not accept them.
Float.near (0.1 + 0.2) 0.3 1e-9
Float.ofInt
Float.ofInt (n: int) : float
Convert an int to a float. weir never converts between them automatically.
Float.ofInt 3 / 2.0
Float.parse
Float.parse (text: string) : float
Parse a float in the form show prints. Raises on anything else, including NaN and Infinity, because weir floats are always finite.
Float.parse "1.5e-3"
Float.round
Float.round (f: float) : float
Round to the nearest whole number, with halves rounded away from zero (2.5 becomes 3.0).
Float.round 2.5
Float.sum
Float.sum (xs: seq<float>) : float
Add up a sequence of floats; raises if the sum is not finite. Seq.sum is for ints only; each numeric type has its own sum.
[1.5; 2.5] |> Float.sum
Float.toInt
Float.toInt (f: float) : int
The integer part, rounding toward zero. Raises when the value is outside the 64-bit int range.
Float.toInt 2.9
Float.tryParse
Float.tryParse (text: string) : Option<float>
Like Float.parse, but returns None instead of raising.
Float.tryParse "nope"
Frontier
Frontier.fold
Frontier.fold (keyOf: 'a -> string) (seed: 'b) (step: 'b -> 'a -> 'b * seq<'a>) (frontier: seq<'a>) : 'b
Fold over a graph that is discovered as you go. Nodes are taken from a first-in, first-out queue; a node whose key was already seen is skipped. The step returns the new accumulator and the node's children, which are added to the queue. It ends when the queue is empty. Cycles are safe, and after 100000 steps it raises instead of running forever. A node with key "" is never skipped as a duplicate.
[1] |> Frontier.fold (fun n -> show n) 0 (fun acc n -> (acc + n, if n < 3 then [n + 1] else []))
Graph
Graph.reach
Graph.reach (keyOf: 'a -> string) (neighbors: 'a -> seq<'a>) (start: 'a) : seq<'a>
Every node reachable from a start node, breadth-first, each listed once; nodes with the same key count as one, so cycles are safe. The neighbors function defines the graph, so nothing has to be built up front.
Graph.reach (fun n -> show n) (fun n -> if n < 3 then [n + 1] else []) 1
Http
Http.defaults
Http.defaults : HttpRequest
The default request: method = Get, empty url, NoAuth, no body, 30s timeout. Start from it with Http.send { Http.defaults with url = u }. Http.get url and the other method helpers return a request built from it.
Http.delete
Http.delete (url: string) : HttpRequest
Build a Delete request for the url.
Http.expect
Http.expect (request: HttpRequest) : seq<string>
Send a request and return the response body as lines. Raises on a non-2xx status; the error names the method, url and status and includes the start of the body. Use Http.get url |> Http.expect for a simple read; requests built with with work the same way. If you need to inspect an error response, use Http.send instead.
Http.get
Http.get (url: string) : HttpRequest
Build a GET request for the url; nothing is sent yet. Http.get u is the same as { Http.defaults with method = Get; url = u }. Send it with Http.send (returns the response, whatever the status) or Http.expect (returns the body, raises on failure). Set other fields with with: Http.send { Http.get u with auth = Bearer t }.
Http.get "http://x/y"
Http.head
Http.head (url: string) : HttpRequest
Build a Head request for the url.
Http.header
Http.header (name: string) (resp: HttpResponse) : Option<string>
The first value of a response header, or None when it is missing. Header names match case-insensitively, as HTTP requires, so ETag and etag are the same header. For headers that can repeat (Set-Cookie), use Http.headerAll.
(HttpResponse { status = 200; headers = [("ETag", "abc")]; body = [""] }) |> Http.header "etag"
Http.headerAll
Http.headerAll (name: string) (resp: HttpResponse) : seq<string>
Every value of a response header, in the order received, matching the name case-insensitively. Use it for headers that can repeat (Set-Cookie). Empty when the header is missing.
Http.options
Http.options (url: string) : HttpRequest
Build an Options request for the url.
Http.patch
Http.patch (url: string) : HttpRequest
Build a Patch request for the url.
Http.post
Http.post (url: string) : HttpRequest
Build a Post request for the url; add a body or auth with with.
Http.put
Http.put (url: string) : HttpRequest
Build a Put request for the url.
Http.query
Http.query (url: string) : HttpRequest
Build a QUERY request (RFC 10008) for the url. QUERY is idempotent by definition, so wrapping it in retry is safe. Few servers support it yet, so expect 405.
Http.send
Http.send (request: HttpRequest) : HttpResponse
Send a request and return the response. A non-2xx status such as 404 is returned, not raised; only network failures raise. A Json body sends to json output exactly as given. Auth values hold Secrets, and show masks them.
Http.withQuery
Http.withQuery (params: seq<string * string>) (base: string) : string
Add a query string to a url. The parameters come first and the url last, so it fits a pipe: url |> Http.withQuery [(k, v)]. Keys and values are percent-encoded, so a space or & cannot break the url. (Not to be confused with Http.query, which builds a QUERY request.)
"http://x/s" |> Http.withQuery [("q", "a b")]
Instant
Instant.epochMs
Instant.epochMs (t: Instant) : int
Milliseconds since the Unix epoch, as an int. Useful for JSON fields and other tools (like date +%s%3N).
Instant.parse "1970-01-01T00:00:01Z" |> Instant.epochMs
Instant.now
Instant.now () : Instant
The current instant (UTC).
Instant.now () > Instant.parse "2020-01-01"
Instant.ofEpochMs
Instant.ofEpochMs (ms: int) : Instant
The instant for a number of milliseconds since the Unix epoch.
Instant.ofEpochMs 0 |> show
Instant.parse
Instant.parse (text: string) : Instant
Parse an ISO 8601 timestamp with Z or a numeric offset, converted to UTC. A bare date means midnight UTC. Raises on anything else; use tryParse to get an Option instead.
Instant.parse "2026-08-14T12:00:00Z"
Instant.parseWith
Instant.parseWith (format: string) (text: string) : Instant
Parse a timestamp using a format string: %Y %m %d %e (1-2 digit day) %b (Jan..Dec) %H %M %S %f %z, with %% for a literal %; any other text must match exactly. Only the start of the text has to match, so trailing text such as the rest of a log line is ignored. Without %z the time is UTC. Raises on a mismatch, naming the position.
Instant.parseWith "%Y/%m/%d %H:%M:%S" "2026/08/14 09:15:00 GET /health"
Instant.tryParse
Instant.tryParse (text: string) : Option<Instant>
Some instant, or None when the text is not ISO 8601.
Instant.tryParse "not-a-time"
Instant.tryParseWith
Instant.tryParseWith (format: string) (text: string) : Option<Instant>
Some instant, or None when the text does not match. An unknown directive in the format still raises, because that is a bug in the format, not in the data.
Instant.tryParseWith "%Y-%m-%d" "no timestamp here"
Json
Json.inferShape
Json.inferShape (lines: seq<string>) : string
Generate draft type declarations from a JSON sample and return them as text; this is what #infer uses. The top record is named Root; nested records are named after their field, with array element types in the singular. Array elements are merged, and a key missing from some elements becomes an Option. An object used as a dictionary (values of one shape with mostly non-identifier keys, different keys in sibling objects, or an empty {}) becomes seq<string * _>. Notes are added as // lines. The draft only covers what the sample contains, so review and edit it. This runs on a sample at runtime; it does not affect type checking.
print (Json.inferShape ["{\"id\": 1, \"name\": \"x\"}"])
Like weir add schema, this turns outside data into a type declaration you paste into your script and own; it does not change how check or from json behave.
Log
Log.debug
Log.debug (message: string) : unit
Write a DEBUG line to stderr; hidden unless WEIR_LOG=debug (or trace). All Log functions write to stderr so stdout stays free for data; there is no option to log to stdout.
Log.debug "cache miss"
Log.debugWith
Log.debugWith (thunk: unit -> string) : unit
Like Log.debug, but takes a function that builds the message, called only when debug logging is on.
Log.debugWith (fun () -> "expensive detail")
Log.info
Log.info (message: string) : unit
Write an INFO line to stderr. Shown by default; set WEIR_LOG to a level (case-insensitive) to change what is shown, or to off to silence logging.
Log.info "deploy starting"
Log.infoWith
Log.infoWith (thunk: unit -> string) : unit
Like Log.info, but takes a function that builds the message, called only when info logging is on.
Log.infoWith (fun () -> "expensive detail")
Log.trace
Log.trace (message: string) : unit
Write a TRACE line to stderr. This is the most detailed level, hidden unless WEIR_LOG=trace.
Log.trace "entering the retry loop"
Log.traceWith
Log.traceWith (thunk: unit -> string) : unit
Like Log.trace, but takes a function that builds the message, called only when trace logging is on. Plain Log.trace always builds its message, so use this when that is expensive.
Log.traceWith (fun () -> "expensive detail")
Log.warn
Log.warn (message: string) : unit
Write a WARN line to stderr. This is the highest level, shown unless WEIR_LOG=off.
Log.warn "lockfile missing, regenerating"
Log.warnWith
Log.warnWith (thunk: unit -> string) : unit
Like Log.warn, but takes a function that builds the message, called only when warnings are shown.
Log.warnWith (fun () -> "expensive detail")
Map
Map.add
Map.add (key: string) (value: 'a) (m: Map<string, 'a>) : Map<string, 'a>
A new map with the entry added, replacing any existing value for the key; the original is unchanged.
Map.ofPairs [("a", 1)] |> Map.add "k" 5
Map.count
Map.count (m: Map<string, 'a>) : int
The number of entries.
Map.ofPairs [("aaa", 1)] |> Map.count
Map.get
Map.get (key: string) (m: Map<string, 'a>) : 'a
The value for a key. Raises, naming the key, when it is missing; use tryGet to get an Option instead.
Map.ofPairs [("aaa", 1)] |> Map.get "aaa"
Map.has
Map.has (key: string) (m: Map<string, 'a>) : bool
True when a key is present.
Map.ofPairs [("aaa", 1)] |> Map.has "aaa"
Map.keys
Map.keys (m: Map<string, 'a>) : seq<string>
The keys, sorted.
Map.ofPairs [("b", 2); ("a", 1)] |> Map.keys |> Seq.freeze
Map.ofPairs
Map.ofPairs (pairs: seq<string * 'a>) : Map<string, 'a>
Build a map from (key, value) pairs. If a key repeats, the last value wins, as when reading JSON.
Map.ofPairs [("a", 1); ("b", 2)]
Map.pairs
Map.pairs (m: Map<string, 'a>) : seq<string * 'a>
The entries as (key, value) pairs, key-sorted.
Map.ofPairs [("b", 2); ("a", 1)] |> Map.pairs |> Seq.freeze
Map.remove
Map.remove (key: string) (m: Map<string, 'a>) : Map<string, 'a>
A new map without the key (a missing key is not an error); the original is unchanged.
Map.ofPairs [("a", 1); ("k", 2)] |> Map.remove "k"
Map.tryGet
Map.tryGet (key: string) (m: Map<string, 'a>) : Option<'a>
Some value when the key is present, None when not.
Map.ofPairs [("aaa", 1)] |> Map.tryGet "zzz"
Map.values
Map.values (m: Map<string, 'a>) : seq<'a>
The values, in key-sorted order.
Map.ofPairs [("b", 2); ("a", 1)] |> Map.values |> Seq.freeze
Net
Net.portOpen
Net.portOpen (port: int) : bool
True when 127.0.0.1:<port> accepts a TCP connection within 250ms. Meant as a readiness check inside poll. Only local ports are supported for now.
Net.portOpen 1
Option
Option.bind
Option.bind (f: 'a -> Option<'b>) (opt: Option<'a>) : Option<'b>
Apply a function that itself returns an Option to the value inside Some, without nesting the result. Use it to chain lookups that may each fail.
Some 5 |> Option.bind (fun x -> Some (x + 1))
Option.defaultValue
Option.defaultValue (fallback: 'a) (opt: Option<'a>) : 'a
The Some value, or a fallback when None.
Option.defaultValue 0 (Some 5)
Option.defaultWith
Option.defaultWith (fallback: unit -> 'a) (opt: Option<'a>) : 'a
Like defaultValue, but the fallback is computed only when None.
Option.defaultWith (fun () -> 0) None
Option.flatten
Option.flatten (opt: Option<Option<'a>>) : Option<'a>
Collapse a nested Option<Option<T>> into Option<T> (Some (Some x) -> Some x; any None -> None).
Option.flatten (Some (Some 5))
Option.iter
Option.iter (f: 'a -> unit) (opt: Option<'a>) : unit
Run a side effect on the value inside Some; do nothing for None. Saves writing a match.
Some "x" |> Option.iter print
Option.map
Option.map (f: 'a -> 'b) (opt: Option<'a>) : Option<'b>
Apply a function to the value inside Some; None stays None.
Option.map (fun x -> x + 1) (Some 5)
Option.orElse
Option.orElse (fallback: Option<'a>) (opt: Option<'a>) : Option<'a>
The option itself when it is Some, otherwise the fallback option. The fallback comes first so it works in a pipe. The result is still an Option; use Option.defaultValue to unwrap. The fallback is evaluated even when the option is Some.
None |> Option.orElse (Some 1)
Option.orElseWith
Option.orElseWith (f: unit -> Option<'a>) (opt: Option<'a>) : Option<'a>
Like orElse, but the fallback is a function (unit -> Option) called only when the option is None. Use it when the fallback is expensive to compute.
None |> Option.orElseWith (fun () -> Some 1)
Path
Path.cacheHome
Path.cacheHome () : string
The base directory for user cache: $XDG_CACHE_HOME (or ~/.cache) on POSIX, %LOCALAPPDATA% on Windows.
Path.cacheHome ()
Path.combine
Path.combine (a: string) (b: string) : string
Join two path segments.
Path.combine "a" "b"
Path.configHome
Path.configHome () : string
The base directory for user config: $XDG_CONFIG_HOME (or ~/.config) on POSIX, %APPDATA% on Windows.
Path.configHome ()
Path.dir
Path.dir (path: string) : string
The directory part of a path.
Path.dir "a/b/c"
Path.extension
Path.extension (path: string) : string
The extension, including the dot.
Path.extension "a.txt"
Path.fileName
Path.fileName (path: string) : string
The final component of a path.
Path.fileName "a/b.txt"
Path.glob
Path.glob (pattern: string) : seq<string>
The paths matching a glob pattern, lazily. ** does not follow symlinks.
Path.glob "*.nope123" |> Seq.freeze
Path.home
Path.home () : string
The user's home directory: $HOME as set (even if that directory is missing), the OS lookup when it is unset; raises when there is none (an empty $HOME). Has no side effects. In a command line, a typed ~ means this path; in an expression, build paths with $"{Path.home ()}/.bashrc".
Path.home ()
Path.newTempDir
Path.newTempDir () : string
Create a new, uniquely named directory under the temp root and return its path (named the same way as within tmp). It is never removed automatically; deleting it is up to you or the OS. For a directory that is removed when a block exits (also on Ctrl+C or kill), use within tmp dir.
Path.newTempDir () |> Str.startsWith (Path.tempRoot ())
Path.normalize
Path.normalize (path: string) : string
Resolve '.' and '..' segments in the text of a path, without touching the filesystem, using the current directory, or following symlinks. combine leaves '..' in place and under rejects paths that leave the base; use normalize when going up out of a directory is intended. A relative path keeps its leading '..' segments; at the root of an absolute path, '..' is dropped.
Path.normalize "src/App/../Core/Core.csproj"
Path.stateHome
Path.stateHome () : string
The base directory for user state: $XDG_STATE_HOME (or ~/.local/state) on POSIX, %LOCALAPPDATA% on Windows. Where the REPL keeps its history.
Path.stateHome ()
Path.stem
Path.stem (path: string) : string
The file name without its extension.
Path.stem "a/b.txt"
Path.tempRoot
Path.tempRoot () : string
The system temp directory, in the platform's native form and without a trailing separator. Has no side effects.
Path.tempRoot ()
Path.under
Path.under (base: string) (name: string) : string
Join a name onto base, normalize, and raise if the result ends up outside base. The check is by whole path segments, so uploads-evil is not inside uploads. Use combine for paths you control and under for paths you do not (such as user input). It works on the text only and never touches the disk or follows symlinks. An absolute or Windows-style name is rejected on every platform.
Path.under (Path.tempRoot ()) "a/b"
Plan
Plan.apply
Plan.apply (plan: Plan) : unit
Carry out a plan's operations in the order they were recorded, using the normal functions. Stops at the first operation that fails; earlier ones stay done, since there is no rollback. Not allowed inside a plan block.
Plan.isEmpty
Plan.isEmpty (plan: Plan) : bool
True when the plan recorded no operations, for example because the script only read things.
Plan.ops
Plan.ops (plan: Plan) : seq<Op>
The operations recorded in a plan, in the order they were recorded, as a seq<Op>. Useful for tests and inspection (plan <block> == [WriteFile(p, c)]); Op values can be compared and shown, with Secrets masked.
Plan.preview
Plan.preview (plan: Plan) : seq<string>
A readable description of a plan's operations, one line each. Nothing has been changed yet; only Plan.apply makes the changes. Secrets are masked.
Poll
Poll.defaults
Poll.defaults : Poll
The default poll options: timeout = 1m, interval = 1s.
Proc
Proc.pid
Proc.pid (p: Proc) : int
The child's OS process id.
within proc p = <command> binds the handle; see #help within
Proc.running
Proc.running (p: Proc) : bool
True while the child has not exited.
poll watch=p checks this for you and gives better errors.
Proc.stop
Proc.stop (p: Proc) : unit
Kill the process and its children now and wait for them. Safe to call more than once; the block's exit then has nothing left to do.
Proc.tail
Proc.tail (p: Proc) : seq<string>
The child's last ~100 lines of output (stderr first). You can read it while the process is still running.
Errors from poll watch=… include this automatically.
Proc.wait
Proc.wait (p: Proc) : int
Wait until the child exits on its own and return its exit code. Use this to let a scoped process finish; otherwise it is killed when the block exits.
Result
Result.defaultValue
Result.defaultValue (fallback: 'a) (r: Result<'a, 'e>) : 'a
The value inside Ok, or the fallback for an Error.
File.read "n.txt" |> Seq.length |> try result |> Result.defaultValue 0
Result.isOk
Result.isOk (r: Result<'a, 'e>) : bool
True for Ok, false for an Error.
File.read "n.txt" |> try result |> Result.isOk
Result.map
Result.map (f: 'a -> 'b) (r: Result<'a, 'e>) : Result<'b, 'e>
Apply a function to the value inside Ok; an Error stays as it is.
File.read "n.txt" |> Seq.length |> try result |> Result.map (fun n -> n * 2)
Result.toOption
Result.toOption (r: Result<'a, 'e>) : Option<'a>
Some value for Ok, None for an Error (the message is dropped).
File.read "n.txt" |> try result |> Result.toOption
Retry
Retry.defaults
Retry.defaults : Retry
The default retry options: attempts = 5, delay = 1s, timeout = None. retry attempts=5 is short for retry { Retry.defaults with attempts = 5 }.
Secret
Secret.map
Secret.map (f: string -> string) (s: Secret) : Secret
Transform a secret's value while keeping it secret. Secret.map (fun t -> "Bearer " + t) gives a Secret, whereas revealing and then concatenating would give a plain string.
Secret.map (fun t -> "Bearer " + t) (Secret.of "x")
Secret.of
Secret.of (s: string) : Secret
Mark a computed string (a generated token, a derived key) as secret. show prints it as ***; only Secret.reveal gives back the plain value.
Secret.of "hunter2"
Secret.reveal
Secret.reveal (s: Secret) : string
The secret's plain value. This is the only way to get it out of a Secret, so each use (a header, a hash) is explicit and easy to find when auditing.
Secret.reveal (Secret.of "x")
Self
Self.args
Self.args : seq<string>
The command-line arguments of the script that was run. The same in every module.
Self.entryPath
Self.entryPath : string
The path of the script that was run. Unlike scriptPath, it is the same in every module.
Self.pid
Self.pid : int
This process's id.
Self.prompt
Self.prompt (message: string) : string
Write a message to stderr and read one line from stdin, for interactive input. The prompt goes to stderr, so piped stdout still carries only data. Raises at end of input instead of returning an empty answer. To read the rest of stdin as a stream, use Self.stdin — both read the same input, so a line a prompt took is not seen again.
let name = Self.prompt "your name?". In tests, pipe the answer in.
Self.scriptPath
Self.scriptPath : string
The path of the file that contains this code; in a module, the module's own path.
Self.stdin
Self.stdin : seq<string>
This process's standard input, as lazy lines. The same in every module.
Seq
Seq.append
Seq.append (xs: seq<'a>) (ys: seq<'a>) : seq<'a>
Concatenate two sequences, lazily.
Seq.append [1; 2] [3; 4] |> Seq.freeze
Seq.average
Seq.average (xs: seq<int>) : float
The mean of an int sequence, as a float. Raises on an empty sequence. For floats, sizes and durations use Float.average, Size.average or Duration.average.
[1; 2] |> Seq.average
Seq.choose
Seq.choose (f: 'a -> Option<'b>) (xs: seq<'a>) : seq<'b>
Apply a function that returns an Option and keep only the Some values, lazily.
[1; 2; 3] |> Seq.choose (fun x -> if x > 1 then Some x else None) |> Seq.freeze
Seq.chunkBySize
Seq.chunkBySize (n: int) (xs: seq<'a>) : seq<seq<'a>>
Split into consecutive batches of at most n elements, lazily. The last batch may be shorter.
[1; 2; 3; 4; 5] |> Seq.chunkBySize 2 |> Seq.map Seq.freeze |> Seq.freeze
Seq.collect
Seq.collect (f: 'a -> seq<'b>) (xs: seq<'a>) : seq<'b>
Map each element to a sequence and flatten the results into one, lazily (flatMap in other languages).
["a<b"; "c"] |> Seq.collect (Str.split "<") |> Seq.freeze
Seq.concat
Seq.concat (xss: seq<seq<'a>>) : seq<'a>
Flatten a sequence of sequences into one, lazily.
[[1; 2]; [3]] |> Seq.concat |> Seq.freeze
Seq.contains
Seq.contains (x: 'a) (xs: seq<'a>) : bool
True when an element is present.
Seq.contains 2 [1; 2; 3]
Seq.countBy
Seq.countBy (key: 'a -> 'b) (xs: seq<'a>) : seq<'b * int>
Count the elements per key as (key, count) pairs, in the order each key is first seen. The whole input is read when the first pair is requested.
["a"; "bb"; "c"] |> Seq.countBy Str.length |> Seq.freeze
Seq.distinct
Seq.distinct (xs: seq<'a>) : seq<'a>
Drop duplicate elements, keeping first order.
[1; 1; 2] |> Seq.distinct |> Seq.freeze
Seq.distinctBy
Seq.distinctBy (key: 'a -> 'b) (xs: seq<'a>) : seq<'a>
Keep only the first element for each key, lazily. Like distinct, but compares by a key function.
["a"; "bb"; "cc"] |> Seq.distinctBy Str.length |> Seq.freeze
Seq.equal
Seq.equal (xs: seq<'a>) (ys: seq<'a>) : bool
True when both sequences have the same length and equal elements in order. Elements must support equality, as for Seq.contains. It stops at the first mismatch without reading further. Prefer it to joining and comparing strings, which can give a wrong answer when elements contain the separator.
["a"; "b"] |> Seq.equal ["a"; "b"]
Seq.exactlyOne
Seq.exactlyOne (xs: seq<'a>) : 'a
The only element. Raises if there are none or more than one (unlike head, which quietly ignores extra elements). Use it for command output that should be exactly one line.
["one line"] |> Seq.exactlyOne |> print
Seq.except
Seq.except (excluded: seq<'a>) (xs: seq<'a>) : seq<'a>
The elements of the sequence that are not in the excluded values. The excluded values come first and are read in full on the first request; the sequence itself is read lazily.
[1; 2; 3; 4] |> Seq.except [2; 4] |> Seq.freeze
Seq.exists
Seq.exists (pred: 'a -> bool) (xs: seq<'a>) : bool
True when any element satisfies a predicate.
[1; 2; 3] |> Seq.exists (fun x -> x > 2)
Seq.find
Seq.find (pred: 'a -> bool) (xs: seq<'a>) : 'a
The first element a predicate accepts. Raises when none match; use tryFind to get an Option instead.
[1; 5; 3] |> Seq.find (fun x -> x > 2)
Seq.fold
Seq.fold (f: 'a -> 'b -> 'a) (init: 'a) (xs: seq<'b>) : 'a
Combine the elements from left to right, starting from an initial accumulator.
[1; 2; 3] |> Seq.fold (+) 0
Seq.forall
Seq.forall (pred: 'a -> bool) (xs: seq<'a>) : bool
True when every element satisfies a predicate.
[1; 2; 3] |> Seq.forall (fun x -> x > 0)
Seq.freeze
Seq.freeze (xs: seq<'a>) : seq<'a>
Evaluate a lazy sequence now and keep the results.
[1; 2; 3] |> Seq.map (fun x -> x + 1) |> Seq.freeze
Freeze a sequence you will read more than once, or to capture its contents now (such as a command's output or a directory listing) before something changes them.
Seq.groupBy
Seq.groupBy (key: 'a -> 'b) (xs: seq<'a>) : seq<'b * seq<'a>>
Group elements by a key into (key, items) pairs, as in F#.
[1; 2; 3] |> Seq.groupBy (fun x -> x) |> Seq.freeze
Seq.head
Seq.head (xs: seq<'a>) : 'a
The first element (raises on empty).
Seq.head [1; 2; 3]
Seq.indexed
Seq.indexed (xs: seq<'a>) : seq<int * 'a>
Pair every element with its zero-based index, lazily. For F#'s mapi/iteri, use indexed |> map/iter on the pairs.
["a"; "b"] |> Seq.indexed |> Seq.freeze
Seq.isEmpty
Seq.isEmpty (xs: seq<'a>) : bool
True when the sequence has no elements.
Seq.isEmpty [1; 2; 3]
Seq.item
Seq.item (i: int) (xs: seq<'a>) : 'a
The element at a zero-based index (raises out of range).
[1; 2; 3] |> Seq.item 0
Seq.iter
Seq.iter (f: 'a -> unit) (xs: seq<'a>) : unit
Run a unit-returning effect over each element.
[1; 2; 3] |> Seq.iter (fun x -> ())
Seq.last
Seq.last (xs: seq<'a>) : 'a
The last element. Raises 'last: empty sequence' when empty; Seq.tryLast returns None instead. It reads the whole sequence, so it never returns on an infinite one.
[1; 2; 3] |> Seq.last
Seq.length
Seq.length (xs: seq<'a>) : int
Count the elements (forces the sequence).
Seq.length [1; 2; 3]
Seq.map
Seq.map (f: 'a -> 'b) (xs: seq<'a>) : seq<'b>
Apply a function to every element, lazily.
[1; 2; 3] |> Seq.map (fun x -> x + 1) |> Seq.freeze
Seq.max
Seq.max (xs: seq<'a>) : 'a
The largest element, found in one pass without sorting. Elements must be comparable; raises on an empty sequence.
Seq.max [3; 1; 2]
Seq.maxBy
Seq.maxBy (key: 'a -> 'b) (xs: seq<'a>) : 'a
The element with the largest key. Keys must be comparable; raises on an empty sequence.
["a"; "ccc"] |> Seq.maxBy Str.length
Seq.min
Seq.min (xs: seq<'a>) : 'a
The smallest element, found in one pass without sorting. Elements must be comparable; raises on an empty sequence.
Seq.min [3; 1; 2]
Seq.minBy
Seq.minBy (key: 'a -> 'b) (xs: seq<'a>) : 'a
The element with the smallest key. Keys must be comparable; raises on an empty sequence.
["a"; "ccc"] |> Seq.minBy Str.length
Seq.pairwise
Seq.pairwise (xs: seq<'a>) : seq<'a * 'a>
Adjacent pairs: (e0,e1), (e1,e2), and so on.
[1; 2; 3] |> Seq.pairwise |> Seq.freeze
Seq.pfirst
Seq.pfirst (f: 'a -> 'b) (xs: seq<'a>) : 'b
Run the function on every element in parallel and return the first successful result. Processes started by the others are killed, along with their children. If every call fails, the error of the earliest element is raised; an empty sequence raises. Errors from the calls that did not win are discarded, so if only one element can ever succeed, the others' errors stay hidden and a misconfiguration can go unnoticed.
[3; 1; 2] |> Seq.pfirst (fun n -> n * 10)
A race, not a retry: for example the same download from several mirrors, where the first answer wins and the losers' processes are killed.
Seq.pfirstWith
Seq.pfirstWith (degree: int) (f: 'a -> 'b) (xs: seq<'a>) : 'b
Seq.pfirst with a maximum number of parallel calls.
[1; 2] |> Seq.pfirstWith 2 (fun n -> n)
Seq.pick
Seq.pick (f: 'a -> Option<'b>) (xs: seq<'a>) : 'b
Apply a function to each element and return the value of the first Some result. Raises when there is none; use tryPick to get an Option instead.
["a"; "12"] |> Seq.pick Str.tryToInt
Seq.piter
Seq.piter (f: 'a -> unit) (xs: seq<'a>) : unit
Run an effect over each element in parallel.
[1; 2; 3] |> Seq.piter (fun x -> ())
Each worker gets its own copy of the session: a cd inside a worker affects only that worker and is gone when the workers finish.
Seq.piterWith
Seq.piterWith (n: int) (f: 'a -> unit) (xs: seq<'a>) : unit
Seq.piter with an explicit worker count.
[1; 2; 3] |> Seq.piterWith 2 (fun x -> ())
Plain piter uses fewer workers when parallel calls are nested; the n you give here is always used as is.
Seq.pmap
Seq.pmap (f: 'a -> 'b) (xs: seq<'a>) : seq<'b>
Map in parallel across worker threads.
[1; 2; 3] |> Seq.pmap (fun x -> x + 1) |> Seq.freeze
Results keep the input order. It runs eagerly with up to 64 workers; if several items fail, the error from the earliest item is the one reported.
Seq.pmapWith
Seq.pmapWith (n: int) (f: 'a -> 'b) (xs: seq<'a>) : seq<'b>
Seq.pmap with an explicit number of workers. Use it when the work is rate-limited or uses a lot of memory.
[1; 2; 3] |> Seq.pmapWith 2 (fun x -> x + 1) |> Seq.freeze
Plain pmap uses fewer workers when parallel calls are nested; the n you give here is always used as is.
Seq.range
Seq.range (start: int) (step: int) (stop: int) : seq<int>
A lazy arithmetic range: start, step, stop.
Seq.range 1 1 5 |> Seq.freeze
Seq.reduce
Seq.reduce (f: 'a -> 'a -> 'a) (xs: seq<'a>) : 'a
Like fold without an initial value: the first element is the starting accumulator. Raises on an empty sequence; use fold when you have a starting value.
[1; 2; 3] |> Seq.reduce (+)
Seq.replicate
Seq.replicate (n: int) (x: 'a) : seq<'a>
A sequence of n copies of a value, lazily. Raises on a negative count.
Seq.replicate 3 "x" |> Seq.freeze
Seq.rev
Seq.rev (xs: seq<'a>) : seq<'a>
Reverse the sequence. The whole input is read when the first element is requested, so it cannot be used on an infinite sequence.
[1; 2; 3] |> Seq.rev |> Seq.freeze
Seq.scan
Seq.scan (f: 'a -> 'b -> 'a) (init: 'a) (xs: seq<'b>) : seq<'a>
Like fold, but returns every intermediate accumulator, starting with the initial value, lazily.
[1; 2; 3] |> Seq.scan (+) 0 |> Seq.freeze
Seq.skip
Seq.skip (n: int) (xs: seq<'a>) : seq<'a>
Drop the first n elements, keep the rest lazily.
[1; 2; 3] |> Seq.skip 1 |> Seq.freeze
Seq.skipWhile
Seq.skipWhile (pred: 'a -> bool) (xs: seq<'a>) : seq<'a>
Drop leading elements while the predicate holds, then return all the rest, lazily.
[1; 2; 9; 1] |> Seq.skipWhile (fun x -> x < 5) |> Seq.freeze
Seq.sort
Seq.sort (xs: seq<'a>) : seq<'a>
Sort the elements in ascending order (they must be comparable). The whole input is read when the first element is requested.
["pear"; "apple"] |> Seq.sort |> Seq.freeze
Seq.sortBy
Seq.sortBy (key: 'a -> 'b) (xs: seq<'a>) : seq<'a>
Order by a key projection.
[3; 1; 2] |> Seq.sortBy (fun x -> x) |> Seq.freeze
Seq.sortByDescending
Seq.sortByDescending (key: 'a -> 'b) (xs: seq<'a>) : seq<'a>
Order by a key projection, descending.
[1; 3; 2] |> Seq.sortByDescending (fun x -> x) |> Seq.freeze
Seq.sortDescending
Seq.sortDescending (xs: seq<'a>) : seq<'a>
Sort the elements in descending order (they must be comparable). The whole input is read when the first element is requested.
[1; 3; 2] |> Seq.sortDescending |> Seq.freeze
Seq.sum
Seq.sum (xs: seq<int>) : int
Add the elements of an int sequence.
Seq.sum [1; 2; 3]
Seq.take
Seq.take (n: int) (xs: seq<'a>) : seq<'a>
The first n elements, lazily; pairs with Seq.skip.
[1; 2; 3] |> Seq.take 2 |> Seq.freeze
Seq.takeWhile
Seq.takeWhile (pred: 'a -> bool) (xs: seq<'a>) : seq<'a>
The leading elements for which the predicate holds, lazily; stops at the first one that fails.
[1; 2; 9; 1] |> Seq.takeWhile (fun x -> x < 5) |> Seq.freeze
Seq.tryExactlyOne
Seq.tryExactlyOne (xs: seq<'a>) : Option<'a>
Like exactlyOne, but returns an Option: Some when there is exactly one element, None otherwise.
[42] |> Seq.tryExactlyOne
Seq.tryFind
Seq.tryFind (pred: 'a -> bool) (xs: seq<'a>) : Option<'a>
The first element a predicate accepts, as an Option.
[1; 2; 3] |> Seq.tryFind (fun x -> x > 1)
Seq.tryHead
Seq.tryHead (xs: seq<'a>) : Option<'a>
The first element as an Option, None when empty.
Seq.tryHead [1; 2; 3]
Seq.tryItem
Seq.tryItem (i: int) (xs: seq<'a>) : Option<'a>
The element at an index as an Option.
[1; 2; 3] |> Seq.tryItem 0
Seq.tryLast
Seq.tryLast (xs: seq<'a>) : Option<'a>
The last element as an Option, None when empty. Reads the whole sequence.
[] |> Seq.tryLast
Seq.tryPick
Seq.tryPick (f: 'a -> Option<'b>) (xs: seq<'a>) : Option<'b>
Apply a function to each element and return the first Some result, or None.
["a"; "12"] |> Seq.tryPick Str.tryToInt
Seq.tryReduce
Seq.tryReduce (f: 'a -> 'a -> 'a) (xs: seq<'a>) : Option<'a>
Like reduce, but returns an Option: Some result, or None for an empty sequence (where reduce raises).
[1; 2] |> Seq.where (fun n -> n > 9) |> Seq.tryReduce (+)
Seq.where
Seq.where (pred: 'a -> bool) (xs: seq<'a>) : seq<'a>
Keep the elements a predicate accepts, lazily.
[1; 2; 3] |> Seq.where (fun x -> x > 1) |> Seq.freeze
Seq.windowed
Seq.windowed (n: int) (xs: seq<'a>) : seq<seq<'a>>
Sliding windows of n consecutive elements, produced lazily. A source shorter than n gives an empty sequence (there are no partial windows). The windows share the same cached elements. Raises when n <= 0.
[1; 2; 3] |> Seq.windowed 2 |> Seq.map Seq.freeze |> Seq.freeze
Seq.zip
Seq.zip (xs: seq<'a>) (ys: seq<'b>) : seq<'a * 'b>
Pair two sequences element-wise, stopping at the shorter.
Seq.zip [1; 2] [3; 4] |> Seq.freeze
Server
Server.port
Server.port (srv: Server) : int
The port the listener bound.
within serve srv = { port = 8080; maxConcurrent = 4 } handler binds the handle; see #help within
Server.running
Server.running (srv: Server) : bool
True while the listener is still open (the scope has not exited).
Server.streamErrors
Server.streamErrors (srv: Server) : seq<string>
Error messages from Stream response bodies that raised partway through; the client received a truncated body for each. Empty until one fails. Streaming errors are reported here instead of being raised out of the handler.
When a Stream body raises, the client's response is cut off and the error is recorded here.
Size
Size.average
Size.average (xs: seq<Size>) : Size
The mean size, truncated to whole bytes. Raises on an empty sequence.
[1KiB; 3KiB] |> Size.average
Size.bytes
Size.bytes (n: int) : Size
A size of n bytes. The function form of a literal like 512B.
Size.bytes 512
Size.parse
Size.parse (text: string) : Size
Parse a size. Binary units are powers of 1024 (1.5MiB); SI units are powers of 10 (1MB is 10^6 bytes); B means bytes. Raises if the value is not a whole number of bytes.
Size.parse "1.5MiB"
Size.sum
Size.sum (xs: seq<Size>) : Size
Add up sizes; the total is a Size.
[1KiB; 512B] |> Size.sum
Size.toBytes
Size.toBytes (s: Size) : int
The exact number of bytes as an int. (show truncates sizes to one decimal place.)
Size.toBytes 2KiB
Size.tryParse
Size.tryParse (text: string) : Option<Size>
Like Size.parse, but returns None instead of raising.
Size.tryParse "nope"
Str
Str.contains
Str.contains (needle: string) (s: string) : bool
True when a substring is present.
"abc" |> Str.contains "b"
Str.endsWith
Str.endsWith (suffix: string) (s: string) : bool
True when the string ends with a suffix.
"abc" |> Str.endsWith "c"
Str.fields
Str.fields (s: string) : seq<string>
Split on runs of whitespace (the same whitespace trim removes) into fields. Never produces empty pieces; a blank or empty string gives an empty sequence.
"NAME READY 1/1" |> Str.fields |> Seq.freeze
Str.fromBase64
Str.fromBase64 (s: string) : string
Decode standard base64 (padded or unpadded) to text. Raises on invalid base64 or when the decoded bytes are not valid UTF-8, instead of silently inserting replacement characters.
Str.fromBase64 "Y2Fmw6k="
Str.fromUtf8
Str.fromUtf8 (b: Bytes) : string
Decode Bytes as UTF-8 text. Raises when the bytes are not valid UTF-8 or contain a NUL byte, so corrupt data is never decoded silently.
Str.isEmpty
Str.isEmpty (s: string) : bool
True for the empty string "" (whitespace is not empty; Str.trim first for that).
Str.isEmpty ""
Str.isMatch
Str.isMatch (pattern: string) (subject: string) : bool
True when a regex matches anywhere in the string.
Str.isMatch "[0-9]+" "x42"
Str.join
Str.join (sep: string) (xs: seq<string>) : string
Join a sequence of strings with a separator.
Str.join "," ["a"; "b"]
Str.length
Str.length (s: string) : int
The number of characters (Unicode code points).
Str.length "abc"
Str.nonEmpty
Str.nonEmpty (s: string) : bool
True for any string but "" — the point-free filter: lines |> Seq.where Str.nonEmpty.
["a"; ""] |> Seq.where Str.nonEmpty
Str.padLeft
Str.padLeft (width: int) (s: string) : string
Pad with spaces on the left to a total width, for right-aligned columns. A string already that long is unchanged; a negative width raises.
Str.padLeft 5 "42"
Str.padRight
Str.padRight (width: int) (s: string) : string
Pad with spaces on the right to a total width, for left-aligned columns (like printf "%-15s"). A string already that long is unchanged; a negative width raises.
Str.padRight 6 "name"
Str.replace
Str.replace (old: string) (new: string) (s: string) : string
Replace every occurrence of a substring.
Str.replace "a" "b" "aba"
Str.replicate
Str.replicate (n: int) (s: string) : string
The string repeated n times. 0 gives the empty string; a negative count raises, as in Seq.replicate.
Str.replicate 3 "ab"
Str.rmatch
Str.rmatch (pattern: string) (s: string) : Option<seq<string>>
The groups of the first regex match as Some sequence, or None when there is no match. Groups are positional: named groups (?<x>...) are rejected, because in weir you name captures when you bind them.
Str.rmatch "([0-9]+)" "x42"
Str.rmatchAll
Str.rmatchAll (pattern: string) (s: string) : seq<seq<string>>
Every regex match's groups, as a sequence of sequences.
Str.rmatchAll "[0-9]+" "a1b2" |> Seq.freeze
Str.rsplit
Str.rsplit (pattern: string) (s: string) : seq<string>
Split on every regex match. As with split, empty pieces are kept (adjacent matches, or a match at either end, produce ""). Capture groups do not add extra pieces.
Str.rsplit @"\s*,\s*" "a , b,c" |> Seq.freeze
Str.rsplitOnce
Str.rsplitOnce (sep: string) (s: string) : string * string
Split at the last occurrence of the separator into (before, after). Raises when the separator is missing; tryRsplitOnce returns an Option instead. Useful for host:port when the host may contain colons.
Str.rsplitOnce ":" "[::1]:8080" |> snd |> print
Str.sha256
Str.sha256 (s: string) : string
The SHA-256 digest of the string's UTF-8 bytes, as lowercase hex (same output as sha256sum). Only SHA-256 is available for now.
Str.sha256 "hello"
Str.split
Str.split (sep: string) (s: string) : seq<string>
Split on a separator into a sequence. Empty pieces are kept: adjacent separators, or a separator at either end, produce "".
Str.split "," "a,b,c" |> Seq.freeze
Str.splitOnce
Str.splitOnce (sep: string) (s: string) : string * string
Split at the first occurrence of the separator into (before, after); the after part is kept whole, including any later separators. Raises when the separator is missing; trySplitOnce returns an Option instead.
Str.splitOnce "=" "key=a=b" |> snd |> print
Str.startsWith
Str.startsWith (prefix: string) (s: string) : bool
True when the string starts with a prefix.
"abc" |> Str.startsWith "a"
Str.sub
Str.sub (start: int) (len: int) (s: string) : string
A substring by start index and length, both in characters (code points).
Str.sub 0 2 "abc"
Str.toBase64
Str.toBase64 (s: string) : string
Base64 of the string's UTF-8 bytes, on one line. It is never wrapped at 76 columns, so there is no need for base64 -w0.
Str.toBase64 "café"
Str.toInt
Str.toInt (s: string) : int
Parse an int (raises on a non-number).
Str.toInt "42"
Str.toLower
Str.toLower (s: string) : string
Lowercase (invariant culture).
Str.toLower "ABC"
Str.toUpper
Str.toUpper (s: string) : string
Uppercase (invariant culture).
Str.toUpper "abc"
Str.toUtf8
Str.toUtf8 (s: string) : Bytes
The string's UTF-8 bytes as a Bytes value.
Str.toUtf8 "café"
Str.trim
Str.trim (s: string) : string
Drop leading and trailing whitespace.
Str.trim " x "
Str.trimEnd
Str.trimEnd (s: string) : string
Drop trailing whitespace.
Str.trimEnd "x "
Str.trimStart
Str.trimStart (s: string) : string
Drop leading whitespace.
Str.trimStart " x"
Str.tryFromBase64
Str.tryFromBase64 (s: string) : Option<string>
Like fromBase64, but returns an Option: None both for malformed base64 and for valid base64 that does not decode to text.
Str.tryFromBase64 "!!!"
Str.tryFromUtf8
Str.tryFromUtf8 (b: Bytes) : Option<string>
Like fromUtf8, but returns an Option: None when the bytes are not text (including bytes with NUL).
Str.tryIndexOf
Str.tryIndexOf (needle: string) (s: string) : Option<int>
The index of a substring as an Option.
Str.tryIndexOf "b" "abc"
Str.tryRsplitOnce
Str.tryRsplitOnce (sep: string) (s: string) : Option<string * string>
Like rsplitOnce, but returns an Option: Some (before, after) at the last occurrence, None when the separator is missing.
match Str.tryRsplitOnce "@" "a@b@host" with | Some (u, h) -> print h | None -> print "no"
Str.trySplitOnce
Str.trySplitOnce (sep: string) (s: string) : Option<string * string>
Like splitOnce, but returns an Option: Some (before, after) at the first occurrence, None when the separator is missing. Handy for parsing KEY=VALUE.
match Str.trySplitOnce "=" "key=val" with | Some (k, v) -> print k | None -> print "no"
Str.tryToInt
Str.tryToInt (s: string) : Option<int>
Parse an int as an Option, None when it is not a number.
Str.tryToInt "42"
Table
Table.inferShape
Table.inferShape (lines: seq<string>) : string
Generate a draft row type declaration from a sample of column-aligned output and return it as text; this is what #infer … from table uses. Each column's type comes from its values: int if all are ints, otherwise float or bool if they all fit, otherwise string; a column with empty or <none> cells becomes an Option, with a note. Headers become field names, with [<Wire>] added when the original header cannot be recovered from the name. A note says the result is read as seq<Root>. Review and edit the draft. This runs on a sample at runtime; it does not affect type checking.
print (Table.inferShape ["NAME RESTARTS"; "web-1 0"])
Like weir add schema, this turns outside data into a type declaration you paste into your script and own; it does not change how check or from table behave.
Table.render
Table.render (rows: seq<'a>) : seq<string>
Format a seq of records of the same type as aligned table lines for print, with a bold header and the same layout the REPL uses. It is for display only: there is no to table, and a table cannot be read back reliably, so use to json or to yaml to exchange data. The table is fitted to the terminal width, and not limited when piped. A seq of non-records is an error that points at the location.
ls |> Table.render |> Seq.iter print
Term
Term.width
Term.width () : int
The terminal width in columns, read each time it is called, so resizes are picked up. Returns 80 instead of raising when there is no terminal (output piped or redirected). Use it to size output, like tput cols.
print (show (Term.width ()))
Tree
Tree.walk
Tree.walk (step: 'a -> seq<'a>) (root: 'a) : unit
Walk a tree for its side effects, parents before children. The step function does the work for a node and returns its children, so which children exist can depend on what the parent did. Nodes are not de-duplicated; for structures with cycles use Graph.reach or Frontier.fold. The 100000-step limit still applies.
1 |> Tree.walk (fun n -> if n < 3 then [n + 1] else [])
Uuid
Uuid.fromBytes
Uuid.fromBytes (b: Bytes) : Uuid
The uuid of 16 big-endian bytes. Raises on any other length.
Uuid.nil |> Uuid.toBytes |> Uuid.fromBytes
Uuid.instant
Uuid.instant (id: Uuid) : Option<Instant>
Some creation instant for a version 7 uuid (millisecond precision); None for every other version.
Uuid.parse "017f22e2-79b0-7cc3-98c4-dc0c0c07398f" |> Uuid.instant
Uuid.max
Uuid.max : Uuid
The all-ones uuid. It sorts after every other uuid.
show Uuid.max
Uuid.nil
Uuid.nil : Uuid
The all-zero uuid, used to mean "no uuid".
show Uuid.nil
Uuid.ns
Uuid.ns : UuidNamespaces
The RFC 9562 namespaces for v5: dns, url, oid, x500. Any uuid works as a namespace of your own.
Uuid.ns.url
Uuid.parse
Uuid.parse (text: string) : Uuid
Parse a uuid: 8-4-4-4-12 hex digits or 32 digits without dashes, in any case, with an optional urn:uuid: prefix. Every version and variant is accepted. Raises on anything else; use tryParse to get an Option instead.
Uuid.parse "urn:uuid:F81D4FAE-7DEC-11D0-A765-00A0C91E6BF6"
Uuid.toBytes
Uuid.toBytes (id: Uuid) : Bytes
The 16 bytes in big-endian (network) order, the same order as the text form.
Uuid.nil |> Uuid.toBytes |> Bytes.toBase64
Uuid.toString
Uuid.toString (id: Uuid) : string
The standard text form: lowercase 8-4-4-4-12. This is what show, JSON and command arguments use.
Uuid.toString Uuid.nil
Uuid.tryParse
Uuid.tryParse (text: string) : Option<Uuid>
Some uuid, or None when the text is not one (including braces and other platform-specific forms).
Uuid.tryParse "{f81d4fae-7dec-11d0-a765-00a0c91e6bf6}"
Uuid.v4
Uuid.v4 () : Uuid
A random uuid (version 4), from the system's cryptographic generator.
Uuid.v4 () |> Uuid.version
Uuid.v5
Uuid.v5 (ns: Uuid) (name: string) : Uuid
The name-based uuid (version 5): SHA-1 over the namespace's bytes and the name's UTF-8 bytes. The same name in the same namespace always gives the same uuid.
Uuid.v5 Uuid.ns.dns "www.example.com"
Uuid.v7
Uuid.v7 () : Uuid
A time-ordered uuid (version 7): epoch milliseconds, then a counter and random bits. Values are strictly increasing within this process, so they sort by creation time both as uuids and as text.
Uuid.v7 () |> Uuid.instant
Uuid.v7At
Uuid.v7At (t: Instant) : Uuid
A version 7 uuid for the given instant, truncated to milliseconds. Use it for records that carry their own timestamp. If the instant is between the last generated uuid and now, it sorts correctly with v7 uuids; an earlier or future instant gets random bits and no ordering guarantee. Raises for instants before the Unix epoch.
Uuid.v7At (Instant.parse "2022-02-22T19:22:22Z") |> Uuid.instant
Uuid.version
Uuid.version (id: Uuid) : int
The version field: 4, 5, 7 and so on (0 for nil, 15 for max). Meaningful for the RFC 9562 variant.
Uuid.parse "017f22e2-79b0-7cc3-98c4-dc0c0c07398f" |> Uuid.version
Yaml
Yaml.inferShape
Yaml.inferShape (lines: seq<string>) : string
Generate draft type declarations from a YAML sample and return them as text; this is what #infer uses. The top record is named Root and nested records are named automatically. Array elements are merged, and a key missing from some elements becomes an Option. An object used as a dictionary (values of one shape with mostly non-identifier keys, different keys in sibling objects, or an empty {}) becomes seq<string * _>. Notes are added as // lines. The draft only covers what the sample contains, so review and edit it. This runs on a sample at runtime; it does not affect type checking.
let sample = <<<
name: web
port: 8080
print (Yaml.inferShape sample)
Like weir add schema, this turns outside data into a type declaration you paste into your script and own; it does not change how check or from yaml behave.
Yaml.merge
Yaml.merge (patch: YamlPatch) (doc: Yaml) : Yaml
Apply a yaml patch block to a document. The patch's structure says where each change goes: mappings are merged recursively; sequence items are appended if missing, or updated/removed by the key given with by=<key>; scalars are replaced; $- removes a key or a matching item. The order of entries does not matter and applying a patch twice changes nothing more. Write the result with to yaml.
Define the patch with let p = yaml patch by=name and indented patch lines, then run File.read f |> Yaml.parse |> Yaml.merge p |> to yaml |> File.write f.
Yaml.parse
Yaml.parse (lines: seq<string>) : Yaml
Parse one YAML document (the same strict subset) into Yaml nodes without a declared type. Every key is kept, including ones from yaml T would drop. Scalar types follow the same rules as in a yaml block: unquoted true/3/1.5 become YBool/YInt/YFloat, quoted or block scalars become YStr, and empty values become YNull.
["replicas: 3"] |> Yaml.parse
Forms
Completed
A finished command: exitCode, stdout, stderr. You get one from | complete.
EnvVar
A name/value environment pair. From Env.vars / pair / ofPairs / fromFile.
FileRow
A directory entry, as returned by ls. Fields: name; kind (Regular, Directory or Symlink); target (Some link target for a symlink, None otherwise; no File.* function gives you this); bytes (0 B for a directory); modified (the last-write time as an Instant, which does not change after you read it; tables show it as relative time, show prints ISO); hidden; and path. ls lists files and subdirectories sorted by name, case-sensitively with uppercase first, regardless of locale. Use name for matching and display and path when calling File.* functions. Less common details are separate functions rather than fields, such as File.mode for permissions.
cd
cd (path: string) : string
Change the session's directory and return the previous one, so you can go back later (let prev = cd "/tmp"). A bare name uses the value of that binding (cd target). cd with no argument, or a typed cd ~, goes to the home directory; a string argument is used literally. To change directory for a block only, use within cd.
cd "."
complete
Run the command (or pipeline) and capture everything as a Completed record: its exit code, stdout and stderr. A nonzero exit does not raise.
Use it when you need the exit code and the output together: let r = git push | complete, then read r.exitCode, r.stdout, r.stderr.
exec
Replace weir's process with the command (execve), so it never returns. The program keeps weir's process id, which means a container entrypoint receives signals directly. It cannot take piped stdin.
Like fail and exit, it ends the script, so it fits in any branch, whatever type the other branches have.
exit
exit (code: int) : 'a
Exit the process with a status code.
exitCode
Run the command with its output streaming to the terminal and return its exit code as an int. It never raises.
Bind or match the result, as in let rc = make test | exitCode. Using it as a bare statement is an error, because the code would be thrown away.
fail
fail (message: string) : 'a
Stop with a message and exit code 1.
Use exit n to exit with a code and no message. fail never returns (its type is string -> 'a), so it can stand in any branch, for example opposite a branch that returns a value.
freeze
freeze (xs: seq<'a>) : seq<'a>
Evaluate a lazy sequence now and keep the results. Same as Seq.freeze.
[1; 2; 3] |> freeze
from json
Parse one JSON document, which may span several lines (so a pretty-printed HTTP body can be piped straight in), into a declared record. Fields can be int, string, bool, float, or an Option of one of those; a missing key or null becomes None.
Use it as a pipe stage: resp.body |> from json Config.
from jsonl
Parse JSON lines (NDJSON, one document per line, as to jsonl writes) into declared records.
Use it as a pipe stage: xs |> from jsonl Config.
from table
Parse column-aligned tool output (a header row followed by aligned rows) into declared records, giving a seq<T>. Columns are cut at the header positions, not at every space, so a value with spaces (Up 2 hours) stays whole; headers are separated by 2 or more spaces (CONTAINER ID is one column). Fields match headers by their letters and digits, ignoring case (podTemplateHash matches POD-TEMPLATE-HASH); [<Wire "HEADER">] matches a header exactly. Cells are trimmed and converted to the field type (string, int, float, bool); for an Option field, an empty or <none> cell becomes None. Extra columns and blank lines are ignored, and errors report the line and column. Reading only; there is no to table.
Use it as a pipe stage on a tool's table output: … |> from table Pod.
from toml
Parse one TOML document (TOML 1.1, so every 1.0 file too) into a declared record, the same way from yaml does: nested records for tables, seqs for arrays and arrays of tables, Option for a key that may be missing, [<Wire "key">] for a key that is not a weir name (current-context, requires-python). Duplicate keys and redefined tables are errors, with the line. A date or time is read as its text; an offset date-time can be read into an Instant field with [<Iso8601>]. Reading only; there is no to toml.
Use it as a pipe stage: File.read "Cargo.toml" |> from toml Cargo.
from xml
Parse one XML document (a .csproj, .slnx or any XML) into a declared record. The root element is the record, and each field matches a child element by local name (a default xmlns is ignored). Use [<Attr>] to read an attribute, [<Elem "X">] for a repeated child, and a nested record for a child element. All values are text: fields are string, Option<string>, a record, or a seq of these, so declare a number as string and convert it with Str.toInt. Reading only; there is no to xml.
Use it as a pipe stage: File.read "App.csproj" |> from xml Proj.
from yaml
Parse YAML into declared records. Supports a strict subset: block mappings and sequences, scalars, # comments, and multiple documents separated by ---. Fields can be nested records, seqs, seq<string * _> for mappings, and Option (a missing key or null becomes None). Anchors, tags and flow style are rejected.
Use it as a pipe stage: lines |> from yaml Deployment gives a seq<Deployment>, one per YAML document.
id
id (x: 'a) : 'a
The identity function: returns its argument unchanged. Useful where a function is expected, as in Seq.choose id over a seq of Options. Unlike not or fst, id can still be used as a variable name; a binding named id simply shadows it.
[Some 1; None; Some 3] |> Seq.choose id
let pure
Mark a binding as pure: let pure f x = … asserts that its whole body has no side effects, and weir check reports an error if it does. It works the same as wrapping the body in a pure block; hover shows the binding with a (pure) badge.
let pure double n = n * 2
See #help pure for the block form and the list of effects.
line
Run a one-value command and return its single line of output as a trimmed string — for tools like git rev-parse HEAD or az … -o tsv. Raises on a nonzero exit, or when the output is not exactly one line.
For several lines use | text (one string), or bind the command itself (a seq<string>, one element per line).
not
not (b: bool) : bool
Boolean negation.
not true
orFail
Run the command with its output streaming to the terminal; if it exits nonzero, raise with your message and the exit code.
Use it to stop the script when a command fails: make test | orFail "tests failed".
poll
Like retry, but bounded by time instead of attempts: poll timeout=5m interval=10s followed by a body that checks readiness. With watch=<proc>, poll fails immediately if that scoped process dies (the error includes its last output), and a timeout error reports the process's state.
poll timeout=1s interval=1ms true
To wait for a server to come up, start it with within proc srv = …, then poll timeout=10s watch=srv with Net.portOpen <port> as the body.
print (value: 'a) : unit
Write a value and a trailing newline to stdout. Takes a string, int, float, bool, or seq<string> (one line per element); for anything else, render through an interpolation hole ($"{row}") or show.
print "hi"
printerr
printerr (value: 'a) : unit
Write a value and a newline to stderr. Use it for diagnostics and keep stdout for data. Accepts the same types as print: string, int, float, bool, or seq<string>.
printerr "oops"
pure
Assert that an indented block has no side effects: no filesystem, commands, network, environment, console or clock. If the block can reach one, weir check reports an error naming it. This is opt-in; code outside a pure block is not restricted.
pure 1 + 1
To mark a whole function, use let pure. Calls the checker cannot see into (a function parameter, a member of an import) count as impure, so the check may reject code that is actually pure, but it never accepts code that is not.
retry
Re-run an indented body until it succeeds: retry attempts=5 delay=30s followed by the body. If the body returns a bool, true means success. If it returns another value, add until r and a predicate block; retry then returns the value that passed. Running out of attempts raises. An error raised inside the body is not retried, it propagates; to retry a failing command, turn its result into data with | succeeds or | complete.
retry attempts=2 delay=1ms true
The options are a record, so you can also write retry { Retry.defaults with attempts = 3 }.
show
show (value: 'a) : string
Turn any value into its text form, the same text string interpolation produces. Use it where interpolation does not fit, such as Seq.map show. A Secret cannot be interpolated, but show renders it masked. It works on every value; functions show as an opaque placeholder.
[1; 2; 3] |> Seq.map show |> Seq.freeze
succeeds
Run the command and return true if it exited 0. Its output is discarded.
Use it in a condition: if git diff --quiet | succeeds then ….
text
Run the command and return its whole output as one string: lines joined with newlines, trailing blank lines dropped (like bash's $(…)). Raises on a nonzero exit.
To keep the exit code instead of raising, use | complete.
to json
Write one value as a single-line JSON document. A record becomes an object and a seq becomes an array (the whole seq is read first). A None field is left out, so from json reads it back as None.
Use it as a pipe stage: payload |> to json.
to jsonl
Write a sequence as JSON lines (NDJSON, one document per element, as from jsonl reads), lazily.
Use it as a pipe stage: xs |> to jsonl.
to yaml
Write one value as a YAML document. A record becomes a mapping, a seq a sequence, and a seq of pairs a mapping. to yaml stream writes one document per element, separated by ---. A None field is left out; strings that YAML could misread as another type (no, 007, 1e5) are quoted.
Use it as a pipe stage: deployment |> to yaml.
try
Turn a failure into a value: <expr> |> try gives Some value, or None if evaluating the expression raised (a failed command, a parse error, a fail, a missing file). <expr> |> try result gives Ok value or Error message instead. Everything to the left of |> try is captured, and its value is fully evaluated inside, so a lazy sequence cannot fail later. exit still exits, and effects that happened before the failure are not undone.
File.read "Cargo.toml" |> from toml {| package: {| version: string |} |} |> try
Commands have their own form: <command> | complete gives the exit code and output as a record.
within
Open a resource for an indented block. It is released however the block exits, normally or by an error. The kinds: tmp — a fresh directory, removed when the block exits; cd — the working directory for the block, restored after; env — environment variables set for the commands the block runs; proc — a background process, stopped together with anything it started when the block exits; serve — an HTTP server that runs until the block exits; its port is freed however the block ends; lock — an advisory file lock, held for the block and released however it ends, even on kill -9.
tmp |
a fresh directory, removed when the block exits |
cd |
the working directory for the block, restored after |
env |
environment variables set for the commands the block runs |
proc |
a background process, stopped together with anything it started when the block exits |
serve |
an HTTP server that runs until the block exits; its port is freed however the block ends |
lock |
an advisory file lock, held for the block and released however it ends, even on kill -9 |
each kind's laws and examples: Scopes
within tmp d
print d
within proc srv = <command> binds a Proc handle; the process and its children are killed and reaped when the block exits.