drang

drang: Language Manual

A small, Perl-inspired, parallel scripting language for text processing, system glue, and orchestration, implemented in Go.

Covers drang 0.4.

Every code example in this manual was executed against the interpreter; the shown output is real.

Contents

Introduction

drang is a small, Perl-inspired scripting language for text processing and system glue (the niche awk, sed, and Perl one-liners have always owned) implemented in Go. Its tagline is "reads like Ruby, thinks like Perl, runs like Go." It is a personal daily-driver: the language you reach for to wrangle text, shell out to other programs, and orchestrate small jobs.

Four ideas define it:

Under the hood drang runs on a tree-walking interpreter alongside a register bytecode VM kept byte-for-byte in lockstep with it, but that is an implementation detail. The language behaves identically either way.

Running programs

drang reads a program from one of four places.

A file (.dr extension):

drang app.dr

Inline, with -e:

drang -e 'say("hello, world")'
hello, world

Piped stdin: when stdin is not a terminal, drang runs it as the program, so cat foo.dr | drang works:

echo 'say("from stdin")' | drang
from stdin

The REPL: run drang with no program on an interactive terminal (this is also what double-clicking the executable does), or force it with drang --repl. State persists across submissions:

drang> $x := 21
21
drang> $x * 2
42
drang> exit

Finally, a standalone executable: drang build app.dr -o app compiles a script into a single self-contained binary (the drang runtime with your program embedded) that needs no separate interpreter. Running it executes the embedded program, with arguments exposed as $ARGV. The build validates that the script parses and refuses to overwrite the source or the running interpreter; Windows and Linux are supported natively, and on macOS it best-effort ad-hoc re-signs the result.

Flags

Leading flags are consumed up to the first non-flag token (the program); everything after the program becomes script arguments.

FlagEffect
--runRun the program (the default; rarely written explicitly).
--astPrint the parsed AST instead of running.
--tokensPrint the token stream instead of running.
--version, -VPrint the version and exit.
--help, -hPrint usage and exit.

--tokens and --ast are debugging windows onto the front end:

drang --ast -e 'say(1+2)'
# ast of <-e>
(call say (+ 1 2))

Script arguments and the environment

Arguments after the program are exposed as the array $ARGV; the process environment is the hash $ENV.

drang -e 'say($ARGV[0], $ARGV[1])' foo bar
foo bar
drang -e 'say($ENV["FOO"])'    # with FOO=bar in the environment
bar

For real command-line tools, parse_args turns $ARGV into a flat map: --flag becomes true, --key=val (or --key val when key is named in the optional second argument) becomes a string, and the leftover positionals collect under "_":

drang -e '$o := parse_args($ARGV, ["out"]); say($o.out); say($o["_"])' --out=build x.dr y.dr
build
[x.dr, y.dr]

A taste

Variables are declared with := (a lexical) or ::= (a frozen top-level constant); plain = reassigns. Builtins are called with parentheses, strings interpolate bare $var (or ${ expr } for anything complex), and data nests transparently with . and []:

$d := {users: [{name: "ada"}, {name: "alan"}]}
say($d.users[1].name)
say("count: ${len($d.users)}")
alan
count: 2

Subroutines use fn and carry a leading-dot sigil (fn .name, called .name, more on the three name sigils later), are first-class values, and pair with the higher-order combinators (map, filter, reduce, …) using |args| body lambdas. Loops are for-in over ranges, with postfix modifiers for one-liners:

$xs := [1, 2, 3, 4]
say(map(filter($xs, |$x| $x % 2 == 0), |$x| $x * $x))
for $n in 1..5 { say($n) if $n % 2 == 1 }
[4, 16]
1
3
5

And the headline trick: counting words across files in parallel, propagating any read failure with ?, with no locks and no threads to manage:

fn .wc($path) { len(split(trim(read_file($path)?), " ")) }
$files ::= ["a.txt", "b.txt"]
$counts := pmap($files, .wc)
say("total:", reduce($counts, 0, |$a, $b| $a + $b))
total: 5

Lexical Structure, Declarations, Types, and Operators

This section covers the surface syntax: how a program is broken into statements, how you bind names, the value types, what counts as true, and the operator set. Variables always carry a $ sigil.

Comments

A # begins a comment that runs to the end of the line. There is no block-comment form.

# a full-line comment
$x := 10   # a trailing comment

Statement termination

A newline ends a statement whenever the line could end there: that is, when the previous token is something that can finish an expression (a literal, a $var, an identifier, a closing ) } ], or a ?). Inside ( or [, newlines are insignificant, so long calls and pipelines wrap freely; inside { blocks and at top level they terminate. A ; also separates statements, letting you put several on one line:

$a := 1; $b := 2; say($a + $b)
3

Declarations and assignment

Introduce a name with := (mutable) or ::= (constant). Once declared, a plain = reassigns it.

$count := 0      # mutable binding
$count = $count + 1
$pi ::= 3.14     # constant binding

In practice every binding is a $-name:

$count := 0
$count = $count + 1
$pi ::= 3.14
say($count, $pi)
1 3.14

Reassigning a constant is an error:

$k ::= 1
$k = 2
drang: cannot assign to constant $k
  at <-e>:2:6
    $k = 2
         ^

A constant is deeply immutable, not just an unrebindable name: if you bind a container to a constant, its contents are frozen too. Mutating one (by index/field assignment, push, pop, or delete) is an error.

$TABLE ::= {"a": 1, "b": 2}
$TABLE["c"] = 3            # error: cannot modify a frozen map
$NAMES ::= ["ana", "bo"]
push($NAMES, "cy")        # error value: cannot push to a frozen array

This is what makes a constant safe to share read-only across pmap/spawn workers without copying or locking: a worker reads it freely, and an accidental write fails loudly instead of racing. (Mutable := containers are not frozen; sharing a mutable container into parallel callbacks and writing it is still a data race you must avoid: collect each callback's return value instead.) Freezing follows the object: binding an existing mutable container to a constant ($C ::= $existing) freezes that object, so $existing becomes read-only too. Bind a fresh literal, or a copy, if you need the original to stay mutable.

Value types at a glance

TypeExample literal / how you get one
nilthe absent/empty value (e.g. a missing map key); no nil literal keyword
booltrue, false
int42 (64-bit signed)
float3.5 (64-bit)
string"hello"
errorfrom fail("...") and fallible builtins
array[1, 2, 3]
map{"a": 1, "b": 2} (insertion-ordered)
range1..5 (inclusive)
functiona lambda `$x$x * 2, or fn .name declared and referenced as .name`
regexre("[0-9]+")

(The concurrency section adds channel, task, and process handles.) nil is a real runtime value but has no source literal: writing nil yields undefined: nil. You obtain it from, e.g., an absent map key:

$m := {}
say($m["absent"])
nil
say(42)
say(3.5)
say([1, 2, 3])
say({"a": 1, "b": 2})
say(1..3)
42
3.5
[1, 2, 3]
{a: 1, b: 2}
1..3

Truthiness

Falsy: nil, false, 0, 0.0, "", and empty containers ([], {}, and an empty range). Everything else is truthy, including non-empty containers, functions, and error values.

fn .t($v) { if $v { say("truthy") } else { say("falsy") } }
$m := {}
.t($m["missing"]); .t(false); .t(0); .t(0.0); .t(""); .t([]); .t({})
.t(true); .t(1); .t(3.14); .t("x"); .t([1]); .t({"a": 1})
falsy
falsy
falsy
falsy
falsy
falsy
falsy
truthy
truthy
truthy
truthy
truthy
truthy

An error is truthy, so an if on it takes the true branch. Use is_err to test for errors rather than truthiness:

$e := fail("boom")
if $e { say("err is truthy") }
say(is_err($e))
err is truthy
true

Operators

Arithmetic + - * / %. With two ints, + - * % stay int. The big gotcha: / between two ints yields a float. There is no integer-division operator. For a truncated integer quotient, wrap with int(...).

say(7 + 2, 7 - 2, 7 * 2, 7 % 2)   # int in, int out
say(7 / 2)                        # / always produces a float
say(int(7 / 2))                   # truncate back to int
9 5 14 1
3.5
3

% requires integer operands. Division or modulo by zero is a runtime error (division by zero / modulo by zero). Arithmetic operators do not coerce strings: "a" + "b" errors; use ~ to concatenate.

String concat ~:

say("foo" ~ "bar" ~ "!")
foobar!

Comparisons == != < <= > >= (numbers compare numerically, strings lexicographically) and the spaceship <=>, which returns -1, 0, or 1:

say(1 < 2, 2 <= 2, "a" < "b")
say(1 <=> 2, 2 <=> 2, 3 <=> 2)
true true true
-1 0 1

Logical and / or / not (and ! as a prefix synonym for not). and/or short-circuit: the right side is not evaluated when the left already decides the result:

fn .boom() { say("boom ran"); true }
say(false and .boom())
say(true or .boom())
say(!true, not false)
false
true
false true

(boom ran never prints, both calls are short-circuited.)

Compound assignment += -= *= /=. Note that /= follows /'s float rule:

$n := 10
$n += 5    # 15
$n -= 2    # 13
$n *= 3    # 39
$n /= 2    # -> float
say($n)
19.5

Ranges lo..hi are inclusive of both ends:

say(len(1..5))
5

What is not in the language

These are deliberate omissions. Each is a parse error, not a missing feature you can polyfill with syntax:

say(2 ** 3)
# parse errors in <-e>
line 1: unexpected STAR "*"
line 1: expected end of statement, got INT "3"
$x := 1
$x++
# parse errors in <-e>
line 2: unexpected PLUS "+"

Strings

drang strings are UTF-8 text. The most common form is a double-quoted literal, which both processes escapes and interpolates $ expressions. Several quote operators and heredocs give you raw, interpolated, and word-list variants.

String literals and the lenient escape policy

Inside "...", exactly five escapes are decoded: \n, \t, \r, \\, and \". Any other backslash escape is left intact: the backslash and the following character are kept verbatim. This "lenient" policy is deliberate: it makes regex classes and Windows paths far less painful, since you don't have to double every backslash.

say("a\tb\nc")
say("\d+")
a	b
c
\d+

The unknown escape \d survives as \d, ready to hand to a regex builtin.

Watch out for the one trap this creates with Windows paths: \n, \t, and \r are still real escapes, so a path segment that begins with n, t, or r gets mangled:

say("C:\dir\new")
C:\dir
ew

Here \d stayed literal but \new became \ + a newline + ew. For paths use a raw quote operator (q{...}, below) or build the path with join.

Interpolation

A $name splices a variable's value; ${expr} splices any expression. Escape a literal dollar with \$.

$x := 42
say("x is $x")
say("sum=${$x + 4}")
say("\$x stays literal, $x splices")
x is 42
sum=46
$x stays literal, 42 splices

${...} can hold arithmetic, calls, and indexing:

$a := [10, 20, 30]
say("second is ${$a[1]}")
second is 20

One limitation: a ${...} body cannot itself contain a double-quoted string while inside a "..." literal. The nested " confuses brace matching and you get an unterminated ${...} parse error. Reach for qq{...} (a different delimiter) when the interpolated expression needs a string literal:

say(qq{up is ${upper("hi")}})
up is HI

Quote operators

Three quote operators avoid escaping gymnastics. The delimiter follows the operator with no space; allowed delimiters are ( [ { / |. The paired ones ((), [], {}) nest; / and | simply run to the next matching delimiter.

$x := 9
say(q{no $x interp, \n stays literal})
say(qq{x=$x and a \t tab})
say(qw{red green blue})
no $x interp, \n stays literal
x=9 and a 	 tab
[red, green, blue]

q{...} is the clean way to write a Windows path or a regex with backslashes:

say(q(C:\Users\new\tmp))
C:\Users\new\tmp

Nesting and alternate delimiters:

say(q{outer {inner} done})
say(qq|x is ${3 + 4}|)
outer {inner} done
x is 7

qw{...} is a real array: splits on runs of whitespace and works with the usual array tools:

$w := qw{one  two   three}
say(len($w))
say($w[1])
say(join(qw{a b c}, "+"))
3
two
a+b+c

Note: the quote body is taken literally: there is no backslash escaping of the delimiter itself, so pick a delimiter the content avoids (or a nesting paired one).

Heredocs

A heredoc starts with <<TAG and runs on the following lines until a line equal to TAG. The opener must be the last thing on its line. Forms:

$name := "world"
$msg := <<END
Hello, $name!
Sum is ${2 + 3}.
END
say($msg)

$raw := <<'END'
Literal $name and \n here.
END
say($raw)
Hello, world!
Sum is 5.

Literal $name and \n here.

(A non-empty body keeps a trailing newline, which is why a blank line follows each block above.) The dedenting form <<~END removes the smallest shared indent; extra indentation is preserved relative to it:

$body := <<~END
    line one
      line two (extra indent)
    line three
    END
say($body)
line one
  line two (extra indent)
line three

Indexing and slicing

Strings index and slice by rune (not byte), so Unicode is handled correctly. s[i] returns the i-th character as a one-character string; a negative index counts from the end; an out-of-range index is a catchable error. s[lo..hi] is a substring over an inclusive rune range (negatives count from the end, bounds clamp, an empty/reversed range yields ""):

say("hello"[0])        # h
say("hello"[-1])       # o
say("héllo"[1])        # é      (rune-aware)
say("hello"[1..3])     # ell    (inclusive)
say("héllo"[0..1])     # hé
say("hi"[9] // "?")    # ?      (out of range -> catchable error)

Indexing is by Unicode code point (rune), not grapheme cluster: a character built from several code points, a combining mark (e+◌́), a flag, or a ZWJ emoji, spans more than one index. Indexing reads only: s[i] = … is not assignment; build a new string instead.

String builtins

BuiltinSignatureNotes
upper / lower(s)ASCII/Unicode case fold
trim(s, cutset?)trims whitespace, or the given cutset of chars
split(s, sep?)no sep → split on whitespace runs; "" → split into runes; else split on sep
join(array, sep?)renders each element and joins with sep (default "")
replace(s, old, new)replaces all occurrences
contains(s, needle)substring test (also works on arrays)
starts_with / ends_with(s, prefix/suffix)boolean
repeat(s, n)n copies; n must be an int
chars(s)array of single-rune strings
lines(s)CRLF-normalized; drops one trailing newline; ""[]
format(template, args...){} placeholders; counts must match
say(upper("Hi"))
say("[" ~ trim("  hi  ") ~ "]")
say(trim("xxhix", "x"))
say(split("a b  c"))
say(split("a,b,c", ","))
say(join(["a", "b", "c"], "-"))
say(replace("a.b.c", ".", "-"))
say(contains("hello", "ell"))
say(starts_with("foobar", "foo"))
say(repeat("ab", 3))
say(chars("héy"))
say(lines("a\nb\nc\n"))
HI
[hi]
hi
[a, b, c]
[a, b, c]
a-b-c
true
true
ababab
[h, é, y]
[a, b, c]

Note join is polymorphic: join(array, sep) is the string join shown above, but join called on plain string arguments instead joins them as path components, see the filesystem section.

format and its placeholders

format substitutes each {} placeholder with the next argument. Use {{ and }} for literal braces.

say(format("{} + {} = {}", 2, 3, 5))
say(format("set {{x}} to {}", 9))
2 + 3 = 5
set {x} to 9

Format specs: {:spec}

A placeholder may carry a format spec after a colon ({:spec}) to control width, alignment, precision, sign, and number base. The grammar is a Python/Rust-inspired subset:

{:[[fill]align][sign][#][0][width][.precision][type]}
say(format("{:.2f}", 3.14159))      # fixed decimals
say(format("[{:>8}]", "hi"))        # right-align in a field of 8
say(format("[{:*^9}]", "hi"))       # center, '*' fill
say(format("{:08.2f}", -3.1))       # sign-aware zero pad
say(format("{:#x}", 255))           # hex with 0x
say(format("{:+d}", 42))            # forced sign
say(format("{:.1%}", 0.1234))       # percent
3.14
[      hi]
[***hi****]
-0003.10
0xff
+42
12.3%

A spec that doesn't fit the value is a catchable error: {:d} on a string, or an unknown type, returns an Err (recover with //, propagate with ?).

The number of {} placeholders must equal the number of arguments. Otherwise format returns an error value rather than silently dropping or emitting literal braces. This deliberately catches the printf habit (%s has no {}):

say(format("{} and {}", 1))
say(format("%s", 5))
error: format: template has 2 placeholder(s) but got 1 argument(s)
error: format: template has 0 placeholder(s) but got 1 argument(s)

The result is a regular error value (the program does not crash); it propagates through ? like any other drang error, see the error-handling section.


Control flow

Control flow in drang is built from statements, not expressions. if, while, and for produce no value as an expression, so you cannot bind one to a variable or use it inline:

$x := if 1 { 2 } else { 3 }
# parse errors in <-e>
line 1: unexpected IF "if"
line 1: expected end of statement, got INT "1"

Use a plain assignment inside the branches instead. (A function still returns the value of its last statement, so an if/else in tail position does hand its taken branch out, see Implicit and explicit return. The restriction is only on using if inline, mid-expression.)

if / else

if cond { ... } runs its block when the condition is truthy. An optional else block, or an else if chain, handles the rest. The condition is bare (no parentheses) and the braces are mandatory.

$g := 75
if $g >= 90 { say("A") } else if $g >= 70 { say("B") } else { say("C") }
B

The else may sit on the same line as the closing } or on the next line.

unless

unless exists only as a postfix modifier (see below). There is no block unless form. Writing unless cond { ... } is a parse error; use if !cond { ... } for a negated block.

while and until

while cond { ... } loops while the condition stays truthy:

$i := 0
while $i < 3 { say($i); $i += 1 }
0
1
2

Like unless, until has no block form. It is postfix-only. For a negated block loop use while !cond { ... }.

for-in

for $x in iter { ... } iterates a collection. With one loop variable you get each element; with two (for $a, $b in iter) the first is an index/key and the second the value. The iterable is snapshotted before the loop, so mutating it in the body does not disturb the iteration.

Over an array: one variable is the element, two are index + element:

for $i, $x in ["a", "b"] { say($i ~ ":" ~ $x) }
0:a
1:b

(~ is the string-concat operator; + does not concatenate.)

Over a map: one variable iterates values, two iterate key then value:

for $v in {"a": 1, "b": 2} { say($v) }
1
2
for $k, $v in {"a": 1, "b": 2} { say($k ~ "=" ~ $v) }
a=1
b=2

Over an integer range lo..hi, inclusive of both ends; two variables give index + value:

for $n in 1..4 { say($n) }
1
2
3
4

A descending range such as 5..1 yields no iterations.

Over a string: character by character (by rune, so multibyte characters stay intact):

for $c in "héy" { say($c) }
h
é
y

break and next

break exits the innermost enclosing loop; next skips to its next iteration. They bind to the innermost loop only.

for $n in 1..5 {
  if $n == 3 { next }
  if $n == 5 { break }
  say($n)
}
1
2
4

These are checked at parse time: break or next outside any loop is a parse error, not a runtime one.

break
# parse errors in <-e>
line 1: 'break' outside a loop

Crucially, the loop nesting count resets at every function and lambda boundary. So break/next inside a lambda or fn (even one that is itself nested inside a loop) cannot escape to the outer loop, and is likewise a parse error:

for $n in 1..3 {
  each([10, 20], |$x| { break })
}
# parse errors in <-e>
line 2: 'break' outside a loop

Postfix modifiers

Any simple statement may take a single trailing modifier: if, unless, while, until, or for. This is the only form unless and until come in.

$x := 5
say("yes") if $x > 3
say("ok") unless 0
yes
ok

while / until re-run the statement until the condition flips:

$i := 0
$i += 1 while $i < 3
say($i)
3
$i := 0
$i += 1 until $i >= 3
say($i)
3

Postfix for iterates a collection, binding each element to the implicit variable $_:

say($_) for [10, 20, 30]
10
20
30

Functions, Lambdas, Closures, and Pipelines

Three name kinds, three sigils

drang carries a name's kind in a sigil at every use, so you always know what a name refers to:

The leading . is the user-namespace sigil. Read .foo as "foo, a member of the implicit user namespace." It is the same . as field access: .foo is a member of the implicit user namespace, just as $m.foo is a member of the map $m. Because your functions live in that . namespace, they can never collide with builtins or the stdlib: your .split and the builtin split coexist, so adding a new builtin can never break your code.

Named functions: fn .name

Define a named function with fn, a dotted name, a parameter list of sigil variables, and a brace body. Call it through the same dot:

fn .add($a, $b) { $a + $b }

fn .greet($name) {
  return "hi " ~ $name
}

say(.add(2, 3))
say(.greet("sam"))
5
hi sam

(~ is the string-concatenation operator; say prints a line.) A bare fn name (no dot) is an error: user functions must be fn .name.

Default parameters

A parameter may have a default value, $name = expr, making it optional. Defaulted parameters must come after the required ones. A default is evaluated at call time, only when its argument is omitted (so there is no shared-mutable-default surprise), and it may reference an earlier parameter:

fn .serve($app, $port = 8080, $host = "localhost") {
  "{}://{}:{}" |> format($app, $host, $port)
}
say(.serve("web"))                  # web://localhost:8080
say(.serve("web", 9090))            # web://localhost:9090

fn .range_end($start, $end = $start + 10) { $end }
say(.range_end(5))                  # 15

Calling with too few or too many arguments is a catchable error that names the accepted range (e.g. .serve expects 1 to 3 arguments, got 4). The same $name = expr syntax works in lambda parameters. (Arguments are positional. There are no named/keyword arguments, and no variadic $a... parameter; pass an array for a variable number of values.)

Implicit and explicit return

A function returns the value of its last statement, no return needed. When that last statement is an if/else (or any block), the value of the taken branch falls straight out:

fn .classify($n) {
  if $n < 0 { "negative" }
  else { "non-negative" }
}

say(.classify(-3))
say(.classify(7))
negative
non-negative

Use explicit return for early exits. There is also a postfix return … if form. (Note .abs here is your function; the builtin abs is untouched in the . namespace and the two never clash:)

fn .abs($n) {
  return -$n if $n < 0
  $n
}

say(.abs(-4))
say(.abs(9))
4
9

Lambdas: |$a, $b| …

An anonymous function is written with pipe-delimited parameters followed by either a single expression or a { … } block (the block also returns its last expression). Zero parameters is ||. A lambda has no name of its own; bind it to a $ variable and it is plain data, called through that $ name ($sq(5)). The . sigil is only for functions declared with fn .name:

$sq := |$x| $x * $x
say($sq(5))

$f := |$a, $b| { $z := $a + $b; $z * 2 }
say($f(3, 4))

$hi := || "hello"
say($hi())
25
14
hello

The body parses at the lowest precedence, so a lambda absorbs operators and |> but stops at ,, ), ], or a newline. Since a lambda is always the last argument to a higher-order function, its body runs cleanly to the closing ). (|| is the zero-param lambda; there is no || boolean operator, use the or keyword.)

Closures

Both named functions and lambdas are closures: they capture the variables of the scope where they are defined.

fn .make_adder($n) {
  |$x| $x + $n
}

$add10  := .make_adder(10)
$add100 := .make_adder(100)
say($add10(5))
say($add100(5))
15
105

Crucially, each iteration of a for loop captures its own binding of the loop variable: closures made in different iterations do not share one mutable slot:

$fns := []
for $i in [1, 2, 3] {
  push($fns, || $i)
}
for $f in $fns {
  say($f())
}
1
2
3

(If iterations shared a single $i, this would print 3 three times.)

The pipeline operator |>

x |> f(args) desugars to f(x, args): the left side is threaded in as the first argument. Chains read left-to-right, which is the natural reading order for glue code:

fn .double($x) { $x * 2 }
fn .add($a, $b) { $a + $b }

say(5 |> .double())          # .double(5)
say(5 |> .add(10))           # .add(5, 10)
say(3 |> .double() |> .add(1))   # .add(.double(3), 1)
10
15
7

|> is lexed greedily as a single two-character token, so it never collides with the lambda |.

To spread a pipeline across lines, put |> at the end of each line (a trailing |> continues the statement; a leading |> on a fresh line is read as a new statement and fails). Inside ( or [, newlines are suppressed, so a leading |> is also fine when the whole chain is parenthesized:

$words := ["apple", "fig", "banana", "kiwi"]

$result := $words |>
  filter(|$w| len($w) > 3) |>
  map(|$w| upper($w)) |>
  reduce("", |$acc, $w| $acc ~ $w ~ " ")
say($result)
APPLE BANANA KIWI 

Higher-order functions (brief)

map, filter, reject, reduce, and friends are built in and array-first, precisely so they compose under |>. (Full coverage of the toolkit lives in the Collections section.)

$xs := [1, 2, 3, 4, 5]
say($xs |> map(|$x| $x * $x))
say($xs |> filter(|$x| $x % 2 == 0))
say($xs |> reduce(0, |$acc, $x| $acc + $x))
[1, 4, 9, 16, 25]
[2, 4]
15

Callbacks are arity-flexible: a one-parameter lambda receives the element; a two-parameter lambda also receives the index (and reduce's lambda is (acc, el) or (acc, el, index)).

Functions and builtins are first-class values

Both a named user function and a builtin can be passed point-free: a bare name in value position is a function value:

fn .shout($s) { upper($s) }
say(["a", "b"] |> map(.shout))                       # [A, B]

say(["/a/b/foo.txt", "/c/d/bar.txt"] |> map(basename))   # [foo.txt, bar.txt]
say(["x", "yy", "zzz"] |> map(len))                  # [1, 2, 3]
say([3, 1, 2] |> reduce(0, max))                     # 3

$f := upper
say($f("hi"))                                        # HI
say(type(len))                                       # function

A user binding of the same name still shadows the builtin ($len := 99 makes $len the number 99, exactly as it shadows the builtin in a call). You only need a lambda when you want to reshape the call: reorder arguments, supply extra ones, or pass the index: map($xs, |$x, $i| format("{}:{}", $i, $x)).


Arrays, Maps, and the Collection Toolkit

drang has two built-in container types: ordered arrays ([..]) and insertion-ordered maps ({k: v}). Both work directly with the same higher-order toolkit (map, filter, sort, ...), which is the workhorse for text and glue scripts.

Arrays

An array literal is a comma-separated list in square brackets. Elements may be any value and may be mixed:

say([10, 20, 30])
say([1, "two", [3, 4]])
[10, 20, 30]
[1, two, [3, 4]]

Indexing is zero-based with arr[i]. Negative indices count from the end (-1 is the last element):

say([10, 20, 30][1])     # 20
say([10, 20, 30][-1])    # 30
say([10, 20, 30][-2])    # 20

Out-of-bounds access is a catchable error value, not a crash, and the same applies to a negative index that reaches before the start:

say([1, 2][5])           # error: index 5 out of range (len 2)
say([10, 20, 30][-4])    # error: index -4 out of range (len 3)

Slicing uses a range index, arr[lo..hi], and returns a new array. The range is inclusive (like every drang range), so arr[1..3] includes index 3. Negative bounds count from the end, out-of-range bounds clamp, and an empty or reversed range yields [], so a slice never errors:

say([10, 20, 30, 40, 50][1..3])    # [20, 30, 40]   (inclusive)
say([10, 20, 30, 40, 50][-2..-1])  # [40, 50]
say([10, 20, 30][1..99])           # [20, 30]        (clamped)
say([10, 20, 30][2..0])            # []              (reversed)

len returns the element count (and works on maps, ranges, and strings too):

say(len([1, 2, 3]))      # 3

push and pop mutate the array in place. push appends one or more values and returns the same array; pop removes and returns the last element, erroring on an empty array:

$a := [1, 2]
push($a, 3, 4)
say($a)                  # [1, 2, 3, 4]

$a := [1, 2, 3]
say(pop($a))             # 3
say($a)                  # [1, 2]

say(pop([]))             # error: pop from empty array

take / drop / uniq return new arrays and never mutate. take(arr, n) keeps the first n; drop(arr, n) skips the first n; both clamp n to the array's length. uniq removes duplicates (by structural equality), preserving first-seen order:

say(take([1, 2, 3, 4, 5], 2))    # [1, 2]
say(take([1, 2], 9))             # [1, 2]   (clamped)
say(drop([1, 2, 3, 4, 5], 2))    # [3, 4, 5]
say(uniq([1, 1, 2, 3, 3, 3, 1])) # [1, 2, 3]

Maps

A map literal is {key: value, ...}. Keys may be barewords (treated as strings) or any scalar expression; iteration follows insertion order:

$m := {name: "ada", age: 36}
say($m)                  # {name: ada, age: 36}
say({z: 1, a: 2, m: 3})  # {z: 1, a: 2, m: 3}   (order preserved, not sorted)

Access a value with dot syntax $m.field (field name as a string key) or bracket syntax $m[key] (any key expression). A missing key reads as nil, not an error:

$m := {name: "ada"}
say($m.name)             # ada
say($m["name"])          # ada
say($m["missing"])       # nil
say($m.zzz)              # nil

Assign into a map (creating or updating the key) with $m[key] = value:

$m := {}
$m["x"] = 9
say($m)                  # {x: 9}

Inspection and mutation builtins:

$m := {a: 1, b: 2}
say(has($m, "a"), has($m, "z"))   # true false
say(keys($m))                     # [a, b]
say(values($m))                   # [1, 2]
say(pairs($m))                    # [[a, 1], [b, 2]]
delete($m, "a")
say($m)                           # {b: 2}

keys, values, and pairs all return fresh arrays in insertion order, which makes iteration straightforward:

$m := {a: 1, b: 2}
for $p in pairs($m) {
  say(format("{} = {}", $p[0], $p[1]))
}
a = 1
b = 2

Only scalar keys are hashable. Integers and strings are fine; an array (or other container) used as a key is a catchable error:

$m := {1: "one", 2: "two"}
say($m[1])               # one

$m := {a: 1}
say($m[[1, 2]])          # error: unhashable map key: array

The higher-order toolkit

These functions operate on arrays and take a callback written as a closure |$x| ... (or |$x, $i| to also receive the element's index). They compose cleanly with the pipe operator |>, where xs |> f(args) calls f(xs, args).

map: transform each element into a new array:

say([1, 2, 3] |> map(|$x| $x * $x))      # [1, 4, 9]

filter / reject: keep / drop elements matching a predicate:

say([1, 2, 3, 4, 5, 6] |> filter(|$x| $x % 2 == 0))   # [2, 4, 6]
say([1, 2, 3, 4, 5, 6] |> reject(|$x| $x % 2 == 0))   # [1, 3, 5]

find: the first matching element, or nil if none match:

say([3, 8, 5, 12, 2] |> find(|$x| $x > 10))   # 12
say([1, 2] |> find(|$x| $x > 10))             # nil

any / all / count: predicate aggregates:

say([1, 2, 3] |> any(|$x| $x > 2))            # true
say([2, 4, 6] |> all(|$x| $x % 2 == 0))       # true
say([1, 2, 3, 4, 5] |> count(|$x| $x % 2 == 1))  # 3

flat_map: map then concatenate the resulting arrays one level deep:

say([1, 2, 3] |> flat_map(|$x| [$x, $x * 10]))   # [1, 10, 2, 20, 3, 30]

reduce(arr, init, fn): fold left with an explicit initial accumulator (note the 3-argument call form, not a pipe target's natural shape):

say(reduce([1, 2, 3, 4], 0, |$acc, $x| $acc + $x))   # 10

The ordering family

sort returns a new array in natural ascending order (numbers numerically, strings lexicographically):

say(sort([3, 1, 2, 10, 5]))                  # [1, 2, 3, 5, 10]
say(sort(["banana", "apple", "cherry"]))     # [apple, banana, cherry]

For a custom order, pass a comparator |$a, $b| ... that returns a negative number, 0, or a positive number. The <=> (spaceship) operator computes exactly that three-way comparison, so it pairs naturally with sort:

say(1 <=> 2, 2 <=> 2, 3 <=> 2)               # -1 0 1
say(sort([3, 1, 2], |$a, $b| $b <=> $a))     # [3, 2, 1]   (descending)

sort_by, min_by, and max_by take a key function instead of a comparator and order by the computed key (sort_by computes each key once). min_by/max_by return the extreme element, or nil for an empty array:

say(sort_by(["ccc", "a", "bb"], |$s| len($s)))   # [a, bb, ccc]
say(min_by(["ccc", "a", "bb"], |$s| len($s)))    # a
say(max_by(["ccc", "a", "bb"], |$s| len($s)))    # ccc
say(min_by([], |$x| $x))                         # nil

Because every collection function returns a value (a new array, or nil), they chain end to end:

say([5, 3, 8, 1, 9, 2] |> filter(|$x| $x > 2) |> sort() |> take(3))   # [3, 5, 8]

Prelude: collection helpers written in drang

A handful of everyday helpers are part of the standard library but written in drang itself (an embedded prelude) rather than in Go. They are pure compositions of the builtins above, available unqualified like any builtin:

HelperMeaning
flatten(xss)concatenate one level of nesting: [[1, 2], [3]][1, 2, 3]
sum_by(xs, f)sum of f over each element
tally(xs)count occurrences → a map {value: count}
count_by(xs, f)like tally, but keyed by f(x)
chunk(xs, n)split into n-sized pieces (n < 1 is an error)
zip(a, b)pair two arrays element-wise, truncating to the shorter
group_by(xs, f)bucket elements by f(x){key: [elems]}
partition(xs, pred)split into [matching, non-matching]
uniq_by(xs, f)keep the first element per distinct f(x) (order preserved)
enumerate(xs)pair each element with its index: ["a", "b"][[0, "a"], [1, "b"]]
mean(xs)arithmetic mean (float); empty list → catchable Err
median(xs)middle of the sorted list (mean of the two middle if even); empty → Err
intersect(a, b)elements in both, deduped (hashable elements; a's order)
union(a, b)all distinct elements from both (a's order first)
difference(a, b)elements in a not in b, deduped
pad(s, width)left-justify by padding the right with spaces (use format's {:>n} for right-justify)
capitalize(s)first character upper, the rest lower
reverse(s)reverse a string by characters (rune-correct)
dedent(s)strip the common leading indentation from every line
clamp(x, lo, hi)constrain x to the range [lo, hi]
sign(x)-1, 0, or 1
get_in(data, path)follow a path of keys/indices into nested maps/arrays; nil if any step is missing
deep_merge(a, b)recursively merge two maps into a new map (b wins; nested maps merged)
retry(n, delay_ms, f)call f() up to n times, returning the first non-error result (waiting delay_ms between)

Writing part of the stdlib in drang keeps the Go core small and pressure-tests the language; the rule for what goes in Go vs drang is recorded in DESIGN.


Errors as Values

In drang an error is not an exception. It is an ordinary value with a tag, like an int or a string. A fallible operation returns either its normal result or an Err value carrying a message and an integer code. Nothing unwinds on its own; you decide what to do with the Err: inspect it, recover from it, or propagate it.

Three pieces make up the model: the ? postfix operator (propagate), the // operator (recover with a fallback), and the inspectors is_err / err_code / err_msg. You create errors with fail.

Inspecting errors

is_err(x) reports whether x is an Err value. err_code(x) and err_msg(x) pull out its code and message. On a non-error they return the neutral values 0 and "", so err_code(run(cmd)) reads naturally as "the exit code, 0 on success."

$r := fail("boom")
say(is_err($r), err_code($r), err_msg($r))
say(err_code(42), err_msg(42) == "")
true 1 boom
0 true

An Err value prints through say as error: <msg>:

say(int("x"))
error: cannot parse "x" as int

Creating errors: fail

fail(msg) builds an Err with the given message and code 1. Called with no argument it uses the message "failed".

$r := fail()
say(is_err($r), err_msg($r), err_code($r))
true failed 1

Note: fail only honors a message: it does not take a second code argument, and the Err code is always 1. Custom, non-1 codes come from operations that carry one naturally, most importantly subprocess builtins, which fold the child's exit status into the Err code (see below).

Recovering with //

risky() // fallback evaluates risky(); if the result is an Err value or nil, it evaluates and returns fallback instead. Otherwise the original value passes through. This is the workhorse for "try, but have a default."

say(int("100") // 0, int("oops") // 0)
100 0

// triggers only on Err or nil; other falsy values (0, "", false) are real results and pass straight through:

say(0 // 99, "" // "x", false // "y")
0  false

Propagating with ?

The ? postfix operator is the early-exit half of the model. expr? evaluates expr; if it is an Err, ? propagates that error out of the enclosing function. If it is not an Err, the value flows through unchanged. This lets you write the happy path without per-call checks:

fn .parse($s) {
  $n := int($s)?      # bail out of .parse() if $s isn't an int
  return $n * 2
}
say(.parse("21"))
42

The key rule: ? propagates only to the nearest call boundary. When the propagated error reaches the point where the function was called, it turns back into an ordinary Err value. It does not keep unwinding. So a caller can simply recover it:

fn .parse($s) {
  $n := int($s)?
  return $n * 2
}
$r := .parse("xx")
say(is_err($r), err_msg($r))
say("still running")
true cannot parse "xx" as int
still running

At the top level there is no enclosing function, so a ? that fires there aborts the whole program. The process exits with the Err's code (clamped to 1..255), printing drang: <msg> to stderr:

fail("nope")?
say("unreached")
drang: nope
exit status 1

This top-level behavior is what makes ? useful for scripts: propagate failures up to main, and the program exits with a meaningful status automatically.

The builtin convention: arg-count aborts, bad values are catchable

Builtins distinguish two kinds of wrongness:

say(is_err(int([1, 2])))   # wrong type -> catchable Err
true
say(int(1, 2) // 99)       # wrong arg count -> hard abort, // can't save it
drang: int expects 1 argument, got 2
  at <-e>:1:5
    say(int(1, 2) // 99)
        ^

So int("x") // 0 is safe and idiomatic, but int() // 0 (or int(1,2) // 0) is a bug that will rightly crash.

Recovering a failed command

Subprocess builtins follow the same value-result convention, and they are where non-1 codes appear. run(...) returns true on success or a catchable Err carrying the child's exit code (127 if the command could not be started). capture(...) returns the child's trimmed stdout, or an Err on failure.

$r := run("cmd", "/c", "exit 3")
say(is_err($r), err_code($r))
true 3

Recover a missing or failing command with //:

say(run("definitely-not-a-real-cmd") // "could not run")
say(capture("cmd", "/c", "exit 1") // "default")
could not run
default

Because the Err carries the real exit code, you can branch on it, e.g. treating grep's exit 1 ("no match") differently from a genuine error:

$r := capture("cmd", "/c", "exit 1")
if is_err($r) {
  if err_code($r) == 1 { say("no match") } else { say("error:", err_msg($r)) }
} else {
  say("found:", $r)
}
no match

And ? plumbs a command's exit code straight through to the process when it propagates to the top level:

run("cmd", "/c", "exit 3")?
drang: cmd exited with code 3
exit status 3

Putting it together

A guard that returns an Err, propagated or recovered by the caller's choice:

fn .checked_div($a, $b) {
  if $b == 0 { return fail("divide by zero") }
  return $a / $b
}
say(.checked_div(10, 2) // "n/a")
say(.checked_div(10, 0) // "n/a")
5
n/a

The shape to internalize: fail and failing builtins make Err values; ? moves them up to the call boundary (aborting at the top level with the right exit code); // absorbs them with a default; and is_err/err_code/err_msg read them when you need to branch.


Regular expressions

drang's regex engine is Go's RE2: matching is linear-time with no catastrophic backtracking, but in exchange the pattern syntax has no backreferences (\1 inside a pattern) and no lookaround. Patterns come in two forms: a qr// literal that the lexer turns into a compiled, first-class regex value, or a plain string that the regex builtins compile on demand. Compiled regexes are immutable, cached, and safe to share across pmap workers.

qr// literals

A qr literal compiles a pattern at lex time into a reusable regex value:

say(qr/\d+/)
qr/\d+/

The body is taken literally: backslashes are passed straight through to RE2, so you do not double them the way you would in a "..." string.

Flags follow the closing delimiter: i (case-insensitive), m (multi-line ^/$), s (dotall, . matches newline), U (ungreedy, swaps greedy/lazy). They are baked into the pattern as Go inline flags, which is visible when you print the value:

say(qr/foo/i)
say(qr/foo/ims)
qr/(?i)foo/
qr/(?ims)foo/
say(matches("a\nb", qr/a.b/s))   # dotall on: . spans the newline
say(matches("a\nb", qr/a.b/))    # off: . won't cross \n
say(match("<a><b>", qr/<.+>/U))  # ungreedy: stops at first >
true
false
[<a>]

An unknown flag letter is a parse error, caught before the program runs:

say(qr/foo/x)
# parse errors in <-e>
line 1: unexpected ILLEGAL "invalid regex flag after qr//"
line 1: expected end of statement, got IDENT "x"

Delimiters. Besides /, you may open a qr literal with |, (, [, or {. Same-char delimiters (/, |) run to the next occurrence; paired delimiters ((...), [...], {...}) nest, which lets the pattern contain unbalanced copies of the delimiter char. Pick a delimiter your pattern avoids:

say(matches("a/b", qr|/|))          # pattern contains a slash → use | delimiter
say(match("ab", qr((a)(b))))        # ( ) nest around the groups
say(find_all("aaa", qr{a{1}}))      # { } nest around the quantifier
true
[ab, a, b]
[a, a, a]

re(pattern): compile a dynamic pattern

qr// is a literal; when the pattern is built at runtime (e.g. interpolated), use re() to compile a string into a reusable regex value. An already-compiled regex passes straight through:

$p := "\d+"
$rx := re($p)
say(matches("a9", $rx))
say($rx)
say(re(qr/x/i))   # regex in → same regex out
true
qr/\d+/
qr/(?i)x/

The matching builtins

Every regex builtin takes the pattern as either a string or a compiled regex value. They are interchangeable. Using a qr// value (or one from re()) reuses the compiled object instead of recompiling.

BuiltinReturns
matches(s, p)bool: does p match anywhere in s
match(s, p)[full, group1, group2, ...], or nil if no match
find_all(s, p)array of every (full) match, in order
gsub(s, p, repl)s with every match replaced by repl
say(matches("Hello World", qr/world/i))
say(match("2026-06-26", qr/(\d{4})-(\d{2})-(\d{2})/))
say(match("nope", qr/\d+/))
say(find_all("a1 b22 c333", qr/\d+/))
true
[2026-06-26, 2026, 06, 26]
nil
[1, 22, 333]

String and qr// pattern arguments are equivalent, but note the string form needs the backslash that the literal form does not:

say(find_all("a1b2", "\d"))
say(find_all("a1b2", qr/\d/))
[1, 2]
[1, 2]

gsub and backreferences in the replacement

In gsub, the replacement string uses Go's $1 / ${name} substitution (this is replacement-side substitution, not a pattern backreference, RE2 has none of those):

say(gsub("2026-06-26", qr/(\d{4})-(\d{2})-(\d{2})/, "$3/$2/$1"))
26/06/2026

For ${name} references, name the groups with RE2's (?P<name>...) syntax. Beware: a "..." double-quoted replacement is interpolated by drang first, so use a non-interpolating literal (q{...}) to keep the ${...} intact for gsub:

say(gsub("john smith", qr/(?P<first>\w+) (?P<last>\w+)/, q{${last}, ${first}}))
smith, john

Bad patterns are catchable errors

A malformed string pattern is not a crash: it surfaces as a first-class Err value the program can inspect with is_err:

$e := matches("x", "(")
say(is_err($e))
true

The same Err flows out of re(), and uncaught it prints the RE2 diagnostic:

say(re("("))
error: bad regex "(": error parsing regexp: missing closing ): `(`

Because the engine is RE2, a backreference inside the pattern is simply not valid syntax and produces such an Err:

say(re("(a)\1"))
error: bad regex "(a)\\1": error parsing regexp: invalid escape sequence: `\1`

External Commands & Concurrency

drang is a glue language, so running other programs and doing work in parallel are first-class. External commands go through os/exec directly, with no shell involved and arguments are passed verbatim (no word-splitting, no glob expansion). Failures are values: a failed command returns a catchable Err carrying the child's exit code, which you propagate with ?, recover with //, or inspect with is_err/err_code/err_msg.

All examples below were run on Windows, so they shell out to cmd /c, findstr, ping, etc. The drang surface is identical on any platform; only the command names differ. Examples deliberately use short, non-destructive commands.

run: execute and stream stdio

run(cmd, args..., {opts}?) runs a command with the child's stdin/stdout/stderr wired straight through to drang's. It returns true on success (so it composes with if and //) or an Err on failure.

$ok := run("cmd", "/c", "exit 0")
say("success returns true: $ok")
$bad := run("cmd", "/c", "exit 5")
say("failure is_err: ${is_err($bad)}  code: ${err_code($bad)}")
success returns true: true
failure is_err: true  code: 5

Array arguments are flattened one level, so you can build an argv list and splat it: run("git", ["log", "--oneline"]).

capture: collect stdout

capture(...) buffers the child's stdout and returns it as a trimmed string on success, or an Err (with the child's stderr folded into the message) on failure.

$ver := capture("cmd", "/c", "ver")
say($ver)
$where := capture("where", "cmd")
say("where cmd -> $where")
Microsoft Windows [Version 10.0.26200.6899]
where cmd -> C:\Windows\System32\cmd.exe

pipe: a pipeline, no shell

pipe([cmd, args...], [cmd, args...], ..., {opts}?) wires each stage's stdout to the next stage's stdin through real OS pipes (streamed, not buffered between stages). Each stage is an array. It returns the last stage's trimmed stdout. This is native os/exec wiring. There is still no shell.

$out := pipe(["cmd", "/c", "echo apple& echo banana& echo cherry"],
             ["findstr", "an"])
say("pipe -> $out")
pipe -> banana

(For genuine shell features: globbing, &&, redirection, invoke cmd /c "..." yourself as a single stage.)

Options: {cwd, env, stdin, timeout}

A trailing map sets per-command options on run, capture, pipe, and each_line. env is overlaid onto the inherited environment (matched case-insensitively, per Windows); timeout is in milliseconds and 0 means no limit.

$dir := capture("cmd", "/c", "cd", {cwd: "C:\\Windows"})
say("cwd -> $dir")
$e := capture("cmd", "/c", "echo", "%GREETING%", {env: {GREETING: "hi there"}})
say("env -> $e")
$s := capture("findstr", "world", {stdin: "hello\nworld\nfoo\n"})
say("stdin -> $s")
cwd -> C:\Windows
env -> hi there
stdin -> world

There is no global cd; per-command {cwd} is the only way to change the working directory (a process-wide chdir would race across goroutines).

Error codes: 124 (timeout) and 127 (cannot start)

Two exit codes are synthesized, matching GNU timeout/shell conventions. On timeout the whole process tree is killed (not just the direct child, so a cmd /c <spawner> whose grandchild holds the pipe can't keep the call blocked):

$r := run("cmd", "/c", "ping -n 5 127.0.0.1 >NUL", {timeout: 300})
say("is_err: ${is_err($r)}  code: ${err_code($r)}")
is_err: true  code: 124

When a command cannot be started (not found, not executable), the code is 127:

$r := run("no_such_program_xyz")
say("code: ${err_code($r)}  msg: ${err_msg($r)}")
code: 127  msg: no_such_program_xyz: exec: "no_such_program_xyz": executable file not found in %PATH%

pipe follows bash's pipeline semantics: 127 if a stage can't start, 124 on timeout, otherwise the last stage's exit code.

each_line: stream output line by line

each_line(cmd, args..., {opts}?, |$line| { ... }) invokes the callback for each line of stdout as it arrives (not buffered), ideal for build logs or tails. It returns true on success or an Err (exit code / 124 timeout) once the command finishes.

$n := 0
each_line("cmd", "/c", "echo one& echo two& echo three", |$line| {
  $n = $n + 1
  say("[$n] $line")
})
say("total lines: $n")
[1] one
[2] two
[3] three
total lines: 3

start: a detached process handle

start(cmd, args...) launches a child without waiting (the equivalent of cmd &), with stdio detached, and returns a process handle. Three builtins act on it: pid(p) reads the PID, await(p) blocks for its exit status (true, or an Err with the code), and kill(p) terminates the whole tree.

$p := start("cmd", "/c", "exit 3")
say("pid > 0: ${pid($p) > 0}")
$status := await($p)
say("await -> is_err: ${is_err($status)}  code: ${err_code($status)}")
pid > 0: true
await -> is_err: true  code: 3

kill works on a still-running process; its pending await then yields an error:

$p := start("cmd", "/c", "ping -n 30 127.0.0.1 >NUL")
kill($p)
say("after kill, is_err: ${is_err(await($p))}")
after kill, is_err: true

In-language concurrency

drang has real multi-core parallelism with no GIL: goroutine-backed, made safe by subtraction: top-level bindings are frozen constants, scoping is lexical-only, strings are immutable, and there is no shared mutable global state. With almost nothing shared, parallel execution needs no locks.

spawn / await: tasks

spawn(fn, args...) runs a drang function on its own goroutine (args are deep-copied in, copy-on-send) and returns a Task. await(task) blocks for the result. (await accepts a Task or a process handle from start: one "await any async handle".)

fn .work($n) { $n * 2 }
$tasks := [1, 2, 3, 4] |> map(|$n| spawn(.work, $n))
$results := $tasks |> map(|$t| await($t))
say("fan-out: $results")
fan-out: [2, 4, 6, 8]

An error inside a spawned task (returned, ?-propagated, or panicked) is captured and surfaced by await, so await($t)? propagates and await($t) // x recovers:

fn .boom() { fail("worker failed") }
$res := await(spawn(.boom))
say("is_err: ${is_err($res)}  msg: ${err_msg($res)}")
is_err: true  msg: worker failed

Channels: chan / send / recv / recv_ok / close / drain

chan() makes an unbuffered channel; chan(n) a buffered one. A channel is the one intentionally shared value type. send blocks until received (and copies the value, copy-on-send); recv blocks for the next value (and yields undef once the channel is closed and drained); recv_ok returns [value, ok]; close is idempotent; drain collects every remaining value into an array, blocking until the channel is closed.

$c := chan(3)
fn .produce($ch) {
  for $i in 1..3 { send($ch, $i * 10) }
  close($ch)
}
$t := spawn(.produce, $c)
$all := drain($c)
await($t)
say("drained: $all")
drained: [10, 20, 30]
$c := chan()
fn .worker($ch) {
  send($ch, "first")
  send($ch, "second")
  close($ch)
}
$t := spawn(.worker, $c)
say("recv: ${recv($c)}")
$pair := recv_ok($c)
say("recv_ok: $pair")
say("after close, undef: ${not recv($c)}")
await($t)
recv: first
recv_ok: [second, true]
after close, undef: true

send on a closed channel is a catchable Err, never a crash.

pmap: parallel map across CPU cores

pmap(arr, fn) is the high-level workhorse: the same contract as map (array-first so $xs |> pmap(f) composes; element + optional index callback; results in input order; fail-loud on the first Err), but fanned across a bounded NumCPU worker pool for true parallelism.

$squares := [1, 2, 3, 4, 5] |> pmap(|$x| $x * $x)
say("pmap squares: $squares")
pmap squares: [1, 4, 9, 16, 25]

The win is real, not cooperative. Four ping -n 3 calls (each ~2s of wall time), map vs pmap, measured end-to-end:

serial (map):    8.14s
parallel (pmap): 2.05s

The purity contract. A pmap callback must be pure: it may read frozen top-level constants and its own parameters, but it must not mutate shared captured state. Each element is deep-copied to its worker, so mutating the element only affects that worker's private copy:

$rows := [[1], [2], [3]]
$out := pmap($rows, |$row| {
  push($row, 99)   # mutates the worker's COPY
  len($row)
})
say("callback saw lengths: $out")
say("original rows unchanged: $rows")
callback saw lengths: [2, 2, 2]
original rows unchanged: [[1], [2], [3]]

The language deliberately offers no shared accumulator to reduce into, so the canonical racy form is largely unwriteable. Mutating a captured mutable lexical container from a parallel callback is documented-undefined. Keep callbacks pure.

Like map, pmap is fail-loud: the first Err a callback produces becomes the whole result and stops further work.

$r := pmap([1, 2, 3], |$x| {
  if $x == 2 { fail("boom on 2") } else { $x }
})
say("is_err: ${is_err($r)}  msg: ${err_msg($r)}")
is_err: true  msg: boom on 2

Parallel subprocesses are just pmap over commands: each call gets its own {timeout}/cwd/env and runs lock-free:

$versions := ["git", "go", "node"] |> pmap(|$tool| capture($tool, "--version") // "(missing)")

Files and Paths

drang treats paths as plain strings and leans on Go's os/filepath underneath. The builtins fall into four groups: file I/O (read_file, write_file, lines), filesystem ops (exists, is_dir, mkdir, glob, rename, rm, copy, size), pure path transforms (dirname, basename, ext, stem, abs, slash), and freshness gates for build scripts (mtime, newer, stale).

A guided tour: everything below was run end to end. It builds a scratch directory under the system temp, writes a file, reads it back, globs it, and cleans up:

# A scratch dir under the system temp, cleaned up at the end.
$dir := join($ENV["TEMP"], "drang_fs_tour")
rm($dir)              # idempotent: no error if absent
mkdir($dir)          # mkdir -p semantics

$f := join($dir, "notes.txt")
write_file($f, "alpha\nbeta\ngamma\n")

say("exists : " ~ exists($f))
say("size   : " ~ size($f))
say("lines  : " ~ len(lines(read_file($f))))

for $m in glob(join($dir, "*.txt")) {
  say("glob   : " ~ basename($m))
}

rm($dir)             # tidy up — nothing left behind
say("gone   : " ~ !exists($dir))
exists : true
size   : 17
lines  : 3
glob   : notes.txt
gone   : true

Two conventions show up throughout: join(...) assembles path segments OS-correctly, and ~ concatenates strings. Use := to declare a variable.

Error model

Fallible filesystem builtins do not throw on failure. They return a catchable Err value (exit code 1). You handle it three ways:

$txt := read_file("does_not_exist_xyz.txt") // "DEFAULT"
say("recovered: " ~ $txt)            # recovered: DEFAULT

The ? form aborts with the underlying OS error and a non-zero exit:

read_file("nope_missing.txt")?
say("unreached")
drang: read_file nope_missing.txt: open nope_missing.txt: The system cannot find the file specified.

exists and is_dir are the exception: they always return a plain bool, so they drop straight into if/unless without recovery plumbing.

File I/O: read_file, write_file, lines

like say) to path, creating or truncating it; with {append: true} it appends instead. Returns the path, or Err.

in the system temp dir and return its path; remove it with rm when done.

reader. Pair it with read_file: lines(read_file(path)).

lines normalizes CRLF to LF and drops a single trailing newline, so "a\nb\n" yields two elements and "" yields an empty array:

say(len(lines("")))        # 0
say(len(lines("a\nb\n")))  # 2   (trailing newline dropped)
say(len(lines("a\nb")))    # 2

write_file accepts non-strings, rendering them the way say would: write_file(f, 42) stores the text 42.

Filesystem ops

array, not an error. Supports *, ?, [...], and a recursive `` segment that spans directories.

returns dst.

error if absent). It is named rm because delete is the map-key remover.

$dir := join($ENV["TEMP"], "drang_fs_demo2")
rm($dir)
mkdir($dir)

$src := join($dir, "src.txt")
write_file($src, "hello")

copy($src, join($dir, "copy.txt"))
rename(join($dir, "copy.txt"), join($dir, "renamed.txt"))

say("orig  : " ~ exists($src))                       # orig  : true
say("copy  : " ~ exists(join($dir, "copy.txt")))     # copy  : false  (renamed away)
say("moved : " ~ exists(join($dir, "renamed.txt")))  # moved : true
rm($dir)

glob with ** walks subdirectories (results are sorted; the walk root itself is never yielded):

$all := glob(join($dir, "**", "*.go"))
for $m in $all { say(slash($m)) }
C:/Users/anafa/AppData/Local/Temp/drang_fs_demo4/sub/deep.go
C:/Users/anafa/AppData/Local/Temp/drang_fs_demo4/top.go

Pure path helpers

These are string transforms. They never touch the disk and never return an Err (a non-string argument is a hard error). On Windows they use the native separator unless noted.

BuiltinInputResult
dirname(p).../notes.txt... (the directory)
basename(p).../notes.txtnotes.txt
ext(p).../notes.txt.txt (leading dot)
stem(p).../notes.txtnotes (basename minus ext)
abspath(p)foo/bar.txtabsolute path against the CWD (numeric absolute value is abs)
slash(p)C:\a\bC:/a/b (forward slashes)
$f := "C:/Users/anafa/AppData/Local/Temp/drang_fs_demo/notes.txt"
say(dirname($f))   # C:\Users\anafa\AppData\Local\Temp\drang_fs_demo
say(basename($f))  # notes.txt
say(ext($f))       # .txt
say(stem($f))      # notes
say(slash($f))     # C:/Users/anafa/AppData/Local/Temp/drang_fs_demo/notes.txt

Note dirname returns the path with the platform separator; reach for slash when you want stable forward-slash output (e.g. for logging or comparison).

Freshness helpers for build scripts

These power the classic "rebuild only if stale" pattern.

missing operand is an Err).

target is missing or older than any source. sources may be a single path or an array of paths. A missing source is a real Err.

$dir := join($ENV["TEMP"], "drang_fresh")
mkdir($dir)
$src := join($dir, "main.c")
$obj := join($dir, "main.o")
write_file($src, "int main(){}")

say(stale($obj, $src))   # true  — target missing, build it

After building the object and later editing the source, stale flips back to true and newer agrees:

say(stale($obj, $src))    # true   — source edited after obj built
say(newer($src, $obj))    # true
$obj := "build/app.o"
$srcs := glob("src/**/*.c")
if stale($obj, $srcs) {
  say("rebuilding " ~ basename($obj))
  # ... run the compiler ...
}

Timestamp granularity caveat: mtime resolves to whole seconds, so two files written in the same instant compare equal. newer returns false in both directions for them. Freshness checks are reliable across a real time gap (an actual edit between builds), not for files created back-to-back in one script run.


JSON

from_json parses a JSON document into drang values; to_json renders them back. Objects become drang's insertion-ordered maps (so key order round-trips), arrays become arrays, and numbers become int when integral or float otherwise.

$cfg := from_json("{\"name\": \"zmal\", \"tags\": [\"build\", \"test\"]}")
say($cfg.name)
say($cfg.tags |> len)
zmal
2

Build a value and serialize it. A second argument to to_json switches on indentation: an int is that many spaces; without it the output is compact:

$out := {}
$out["ok"] = true
$out["items"] = [1, 2]
say(to_json($out))
say(to_json($out, 2))
{"ok":true,"items":[1,2]}
{
  "ok": true,
  "items": [
    1,
    2
  ]
}

Malformed input is a catchable error value, not an abort; recover it with // or inspect it with is_err:

say(is_err(from_json("{ broken")))
say(from_json("nope") // "fallback")
true
fallback

CSV

from_csv parses RFC 4180 CSV into rows; to_csv renders rows back. Both are built on a battle-tested parser, so the awkward parts are handled: fields containing commas, quotes, or newlines, and the doubled-quote escape (""). PLACEHOLDER (int($row.age)); there is no type inference.

By default rows are arrays of strings. With {header: true} the first row names the columns and every later row becomes a record keyed by those names:

from_csv("a,b\n1,2")                       # [["a", "b"], ["1", "2"]]
$rows := from_csv("name,age\nalice,30\nbob,25", {header: true})
say($rows[0].name)                         # alice
say(to_csv($rows))                         # name,age / alice,30 / bob,25 (header auto-written)

to_csv accepts either shape: an array of arrays writes plain rows; an array of records writes a header (from the first record's keys) plus one row per record, with values pulled by key (so a record's key order need not match). Scalars stringify (nil is an empty cell); a non-scalar cell is an error.

Both are strict by default, to catch malformed data loudly: ragged rows (a differing field count), duplicate header names, and records whose keys differ from the header are errors. Pass {lenient: true} to relax all three (pad/truncate, keep the last duplicate column, drop unknown keys).

Options (an optional trailing map):

OptionWhereMeaning
sepbothfield delimiter, one character (default ,; e.g. "\t" for TSV)
headerbothread: first row is column names → records; write: include a header row (default true)
lenientbothrelax strictness (ragged rows, duplicate / divergent keys)
commentreadskip lines whose first character is this
trimreaddrop leading whitespace in each field
lazy_quotesreadtolerate stray quotes in malformed input
crlfwriteend lines with \r\n (strict RFC) instead of the default \n

As with JSON, option misuse (a bad type, a multi-character or invalid sep, an unknown option key) aborts; malformed CSV and unencodable rows are catchable Err values:

say(is_err(from_csv("a,b\n1,2,3")))                          # true — ragged row (strict)
$rows := from_csv(read_file("data.csv"), {header: true}) // []   # [] on a read error

A leading UTF-8 BOM (which Excel writes) is stripped automatically. Two inherited quirks worth knowing: a \r\n inside a quoted field reads back as \n, and blank lines are skipped, so a row that is a single empty field won't survive a round trip. And because one-liner -n/-p is line-based, it can't safely stream a CSV with quoted newlines, so run from_csv on the whole text instead.


Date and time

A point in time is just a number: seconds since the Unix epoch, with sub-second precision (the Perl model). So time arithmetic and comparison use ordinary number operators: $t + 3600 is an hour later, $a < $b is "before".

$t := parse_time("2026-06-27 13:45:09", "%Y-%m-%d %H:%M:%S")
say(strftime($t, "%A, %b %e %Y at %H:%M"))   # Saturday, Jun 27 2026 at 13:45
say(strftime($t + 86400, "%Y-%m-%d"))         # 2026-06-28  (one day later)
say(date_parts($t).weekday)                   # 6

The %-codes are the usual strftime set: %Y %y %m %d %e %H %I %M %S %p %A %a %B %b %j %w %z %Z %% (plus %n / %t). An unknown code is left literal by strftime; parse_time rejects a code it can't parse. Times are local by default; pass {utc: true} to strftime/parse_time/date_parts to work in UTC (important for cross-machine logs and timestamps).


Hashing, encoding, and randomness

Thin bindings over Go's standard library.

Hashing: sha256, sha1, md5 take a string and return its lowercase hex digest:

say(sha256("abc"))   # ba7816bf...f20015ad
say(md5("abc"))      # 900150983cd24fb0d6963f7d28e17f72

Encoding: to_base64/from_base64, to_hex/from_hex, and url_encode/url_decode convert to and from a string; the from_* / *_decode side returns a catchable Err on malformed input:

say(to_base64("hi"))                    # aGk=
say(from_base64("aGk="))                # hi
say(to_hex("AB"))                       # 4142
say(url_encode("a b&c=d"))              # a+b%26c%3Dd
say(from_base64("!!!") // "bad input")  # bad input

Randomness: rand() is a float in [0, 1); rand_int(n) is an int in [0, n) and rand_int(lo, hi) in [lo, hi); shuffle(arr) returns a new shuffled array (the input is untouched); sample(arr) returns a random element; uuid() returns a random v4 UUID:

say(rand_int(6) + 1)            # a die roll, 1–6
say(shuffle([1, 2, 3, 4]))     # e.g. [3, 1, 4, 2]
say(sample(["a", "b", "c"]))   # e.g. b
say(uuid())                    # e.g. 5b1f9d2c-...-4e7a-...

rand/rand_int/shuffle/sample use a fast auto-seeded generator (fine for jitter, sampling, and test data, not for secrets); uuid draws from the cryptographic generator.


HTTP client

A small, robust HTTP client over Go's net/http. The whole surface is http plus get and post sugar; PUT/PATCH/DELETE go through http(method, url, ...). Higher-level patterns (retry, cookies, auth, pagination) are written in drang, not configured in the builtin.

$r := http_get("https://example.com")
say($r.status, $r.ok, len($r.body))                       # 200 true <n>  (n = body bytes)

$r := http("POST", "https://api.example.com/items", {json: {name: "ada"}})?
$r := http_post("https://example.com/form", "a=1&b=2")    # a string body

A response is a map: {status, ok (200–299), body, headers (lowercased keys), url (final, after redirects)}. opts (a trailing map, like run's) accepts: headers (a {name: value} map), body (string), json (any value, serialized and sent as application/json; body and json together is an error), timeout (ms; 0 = unlimited), redirects (cap; 0 = don't follow, returning the 3xx), max_body (bytes; 0 = unlimited), and insecure (skip TLS verification).

Robust by default (the defaults Go's bare client lacks): a 30-second timeout, follow up to 10 redirects (dropping Authorization on a cross-host hop), TLS verification on, a 32 MiB response-body cap (exceeding it is an error, never a silent truncation), transparent gzip, and one shared connection-pooled transport (safe to fan out under pmap).

The error model mirrors capture/capture_all: a completed exchange is data, a failure to complete is an Err. A 4xx/5xx is a normal answer: you get {status: 404, ok: false, …}, never an error. Only a transport failure (DNS, connection refused, timeout (carries err_code 124, like a subprocess), TLS failure, a bad URL, or a body over max_body) is a catchable Err. So ? means "I couldn't reach the server" (which should bubble), and // masks only that:

$r := http_get($url)
if is_err($r) {
  if err_code($r) == 124 { say("timed out") } else { say("unreachable") }
} else if $r.status == 404 { say("not found") }           # an answer, not an error
  else if $r.ok           { say(from_json($r.body).name) }

$health := http_get($url) // {status: 0, ok: false}       # // masks ONLY a transport failure
$statuses := $urls |> pmap(|$u| http_get($u, {timeout: 2000}) // {status: 0}) |> map(|$r| $r.status)

Task dispatch

dispatch(tasks) turns a script into a subcommand-style CLI, a tiny make/just in your program. It takes a map of {name: function}, looks up the task named by $ARGV[0], runs it, and exits the process with a resolved code (it does not return).

fn .build($args) { say("building " ~ to_json($args)) }
fn .clean()      { say("cleaning") }

dispatch({build: .build, clean: .clean})
$ drang tasks.dr                 # no task, or `list` / `-l` / `--list`: print the task names
tasks:
  build
  clean
$ drang tasks.dr build a b       # runs .build(["a", "b"]) — the remaining argv as a string array
building ["a","b"]
$ drang tasks.dr nope            # unknown task: list to stderr, exit 2
drang: unknown task "nope"
tasks:
  build
  clean

A task function takes either 0 parameters (it ignores the arguments) or 1 parameter (it receives the post-name argv as a string array); more is an error. The exit code follows the task: success → 0; a returned/propagated Err → its code (clamped to 1..255); an exit(n)/die → that code; an unknown task → 2.


One-liner mode

-n and -p turn drang into a stream processor in the awk/perl/sed tradition: the program runs once per input line. -n just loops; -p also prints the topic variable after each line (the filter/sed mode). Short flags combine: -ne, -pe, -ane, and a trailing e takes the program source as its argument, like a plain -e.

drang -pe '$_ = upper($_)' < notes.txt               # filter: uppercase each line
drang -ne 'if matches($_, "ERROR") { say($_) }' log  # grep-like (matches(s, pat))
drang -ape '$_ = $f[0]' data.tsv                      # print the first column

Per-line variables (all in the $ data namespace):

VariableMeaning
$_the current line, with its trailing newline (and \r) stripped
$nrthe 1-based line number, counting across every input file
$filethe current input filename ("<stdin>" when reading stdin)
$fwith -a, the line split on whitespace into a 0-indexed array

Input comes from the files named after the program, or from stdin when none are given; - in the file list also means stdin. The filenames are exposed as $ARGV.

BEGIN { ... } and END { ... } blocks run once, before and after the loop, for setup, headers, accumulators, and totals. The per-line body runs in a persistent scope, so a variable declared in BEGIN survives every line:

drang -ane 'BEGIN{ $sum := 0 } $sum = $sum + int($f[0]); END{ say($sum) }' nums.txt

(BEGIN/END are contextual keywords, recognized only as a statement-leading BEGIN { / END {, so they stay ordinary identifiers everywhere else.)

Notes and limits (v1):

statement, so BEGIN{ ... } stmt needs no ;, but stmt; END{ ... } does.

line is an error.

newline is added).


Modules: use

A program can be split across files. Any .dr file is a module; its top-level named functions (fn .foo) and constants ($CONST ::= …) are its exports. Nothing else is exported; a mutable top-level variable in a module is an error at import time, so exports are always functions and constants.

There is one keyword, use, and whether you capture its result chooses the mode.

Flat merge: use "./util"

Used as a statement, use merges the module's exports into the current scope, as if pasted: its .foo functions join your .-space, its $CONSTs join your $-space.

# util.dr
fn .shout($s) { upper($s) ~ "!" }
$GREETING ::= "hi"
# main.dr
use "./util"
say(.shout("hey"))   # HEY!
say($GREETING)       # hi

Isolated: $u := use("./util")

Used as a call whose result you bind, use returns the module's export record and merges nothing into your namespaces. Reach the exports through the binding. This is the aliased-import form, bind to any $name; there is no as keyword.

$u := use("./util")
say($u.shout("hey"))   # HEY!
say($u.GREETING)       # hi

Paths

Paths are strings (so a path with a space just works), and the .dr extension is optional. A relative path resolves against the importing file's directory, so a module's own use "./sibling" is relative to that module, not to whoever imported it. For entry points with no source file (-e, stdin, the REPL, and a drang build standalone) relative paths resolve against the current working directory.

Loading rules

so a diamond (a uses b and c, both use d) loads d exactly once.

through …` rather than looping.

not re-export d's names: you get b's own exports only.

defining one a use already merged) is an error, not a silent overwrite.

terminates the program, even through the captured $u := use(...) form (it is not downgraded to a catchable error).

Errors

A failed import via the captured form is a catchable error value, so you can fall back:

$cfg := use("./optional") // {}      # missing/broken module → use a default

A failed flat-merge statement aborts the program with the import error.

Notes and limits

the module but imports nothing, almost always a mistake. Write use "./util" to merge, or $u := use("./util") to capture.

array/map within) are frozen, so mutating one fails loudly rather than affecting other importers; exports are safe to share across the import cache.


Testing: drang test

Tests are written as example statements: assertions that double as verified documentation. There are three forms:

example .add(2, 3) == 5       # equality
example is_valid("ok")        # truthy
example .parse("bad") fails   # expects an error (an Err value or a runtime abort)

Run them with the test subcommand:

drang test mathutil.dr

drang test runs each file (so its functions and top-level setup execute), then evaluates every example as an assertion. It prints a block for each failure: the location, the example, and expected-vs-got, followed by a per-file N passed, M failed line, and exits non-zero if anything failed:

  FAIL mathutil.dr:8  (example (call .add 2 3) == 6)
        expected 6, got 5
mathutil.dr: 4 passed, 1 failed

An example examines all of a file's definitions regardless of order (the file runs first, then the assertions), so an example may appear above the function it tests, handy for example-first documentation. In a normal run (drang file.dr / -e), example statements are a no-op: they never execute, so they cost nothing and can't interfere with the program. (A richer test "name" { … } block form with an assert builtin is a possible future addition; v1 is example-only.)

Golden-output tests

Where example checks a value, a golden test snapshots a script's whole stdout against a saved fixture, ideal for scripts that produce text (reports, CLI tools). Put a .golden file next to the script (report.drreport.golden); drang test then runs the script, captures its stdout, and diffs it against the golden:

$ drang test report.dr
report.dr: 2 passed, 0 failed        # the golden + any example assertions

On a mismatch it prints a compact diff (common lines trimmed, -expected / +actual) and exits non-zero. To create or re-bless a golden from the current output, use --update (-u):

$ drang test --update report.dr      # writes report.golden from captured stdout

A file can carry both: its example assertions and a .golden snapshot are checked together (the pass/fail counts combine). A script with no sibling .golden is just an example test, and its stdout passes through to the terminal as usual.

A golden test assumes the script's output is deterministic and complete when the top level finishes: await any spawned tasks before the end (a detached task's output may not make it into the capture), and avoid output whose line order depends on pmap scheduling.


Formatting: drang fmt

drang fmt reformats drang source into one canonical style. Like gofmt, it is opinionated and has no configuration knobs. It preserves comments and never changes what a program does. Only how it reads.

$ drang fmt script.dr            # print formatted source to stdout
$ drang fmt -w script.dr         # rewrite the file in place
$ drang fmt -w src/              # rewrite every *.dr under a directory
$ drang fmt --check src/         # exit non-zero if anything is unformatted (CI gate)
$ cat script.dr | drang fmt      # filter stdin to stdout

With no paths it reads stdin and writes to stdout; directory paths are searched for *.dr files (skipping .git and dot-directories).

flageffect
-w, --writerewrite each changed file in place, atomically
-c, --checklist unformatted files to stderr; exit non-zero (for CI)
-l, --listlist files that would change to stdout
-d, --diffprint a diff of the changes
--fixalso apply migration rewrites (see below)

Exit status is 0 when everything is already formatted, 1 when a file would change (under --check/-l/-d) or a file errors, 2 for a usage error.

What it does

tight .. ranges and prefix -/!; , between elements; key: value in maps.

parens, 1 + (2 * 3) loses them.

call, array, or map puts one element per line.

qw{…} word lists, regex literals, numeric spelling, |> pipelines, and postfix modifiers (say(x) if c) are reprinted as written, not rewritten into an equivalent.

Comments are preserved and re-attached by position (leading, same-line trailing, and floating). drang fmt verifies its own output before writing: if reformatting would drop a comment or produce source that no longer parses, it aborts that file untouched (and exits non-zero) rather than risk corrupting your code. Formatting is idempotent: running it on already-formatted source is a no-op.

--fix: migrations

drang has no version pragma. Instead, a language revision that renames or reshapes a construct ships a mechanical source rewrite, applied by drang fmt --fix. Today there are no such rewrites, so --fix behaves exactly like fmt; when a future revision needs one, drang fmt --fix -w src/ will migrate a whole codebase in a single pass.


Quick reference: builtins

Every builtin in drang, grouped by area. Signatures use ? for an optional argument and ... for variadic. Builtins follow one error convention: a wrong argument count aborts the program (an uncatchable Go error), while a wrong argument type or a runtime failure becomes a first-class Err value you can recover with // or propagate with ?. "→ Err" below means the failure mode is a catchable Err value.

The list is derived from the builtins map in internal/eval/eval.go and the higher-order forms in internal/eval/hof.go. (spawn, each_line, and dispatch are evaluator special forms, not map builtins, and are documented elsewhere; pmap, sort, and the *_by forms are higher-order and appear under Collections.)

Output & errors

BuiltinSignatureDescription
saysay(x...)Print all arguments space-separated, then a newline; returns nil.
warnwarn(x...)Like say, but to stderr: for diagnostics that shouldn't pollute stdout.
failfail(msg?)Make an Err value with message msg (default "failed") and code 1.
is_erris_err(x)True if x is an Err value.
err_codeerr_code(x)The Err's code; 0 for a non-Err (so it reads as an exit code).
err_msgerr_msg(x)The Err's message; "" for a non-Err.
exitexit(code?)End the program with code (default 0, clamped 0–255), unwinding past functions, loops, ?, and //.
diedie(x...)Print the message to stderr and exit with code 1: the fatal-error convention for a tool.

Conversions

BuiltinSignatureDescription
intint(x)Convert int/float/string to an int; unparseable → Err.
strstr(x)Render any value as its display string (numbers, bools, nil, collections, errors).
floatfloat(x)Convert int/float/string to a float; unparseable → Err.
boolbool(x)Coerce by truthiness (nil/false/0/0.0/""/empty container → false).
typetype(x)Type name: int float string bool nil array map range function error.

Numeric

Minimal daily-driver math (not a math/trig kitchen sink: no sin/cos, no bignum). abs/sum/min/max preserve int vs float; floor/ceil/round/div return an int; sqrt/log return a float; pow returns an int when it can. A bad operand (non-number, sqrt of a negative, log of a non-positive, divide-by-zero, overflow) is a catchable Err.

BuiltinSignatureDescription
absabs(n)Numeric absolute value (the path builtin is abspath).
sumsum(arr) / sum(a, ...)Add numbers (array or variadic); empty → 0; overflow → Err.
minmin(arr) / min(a, ...)Smallest value; empty → Err.
maxmax(arr) / max(a, ...)Largest value; empty → Err.
floorfloor(n)Round down to an int; NaN/Inf/out-of-range → Err.
ceilceil(n)Round up to an int.
roundround(n)Round to the nearest int (half away from zero).
sqrtsqrt(n)Square root (float); negative → Err.
powpow(base, exp)base^exp; int when both are ints and exp ≥ 0 (overflow → Err), else float.
loglog(x, base?)Natural log, or log base base; non-positive x or bad base → Err.
divdiv(a, b)Truncating integer division (toward zero, matching %); divide-by-zero → Err.

Strings

BuiltinSignatureDescription
splitsplit(s, sep?)Split s; no sep splits on whitespace runs, "" splits into runes.
replacereplace(s, old, new)Replace every literal old with new.
trimtrim(s, cutset?)Trim whitespace, or the given cutset characters, from both ends.
upperupper(s)Uppercase.
lowerlower(s)Lowercase.
starts_withstarts_with(s, prefix)True if s begins with prefix.
ends_withends_with(s, suffix)True if s ends with suffix.
formatformat(tmpl, x...)Fill each {} in tmpl with the next arg ({{/}} are literal); arity-checked → Err.
lineslines(s)Split into lines (CRLF-normalized), dropping one trailing newline; ""[].
repeatrepeat(s, n)Concatenate n copies of s; negative or oversized n → Err.
charschars(s)Array of single-rune strings.
index_ofindex_of(s, needle)Rune index of the first needle in s, or -1; empty needle → 0.
lenlen(x)Rune count of a string (also entry count of an array/map/range).
containscontains(s, needle)Substring test for a string (also membership for an array).

JSON

BuiltinSignatureDescription
from_jsonfrom_json(s)Parse JSON into drang values (object→map, array→array, number→int/float); malformed input → Err.
to_jsonto_json(v, indent?)Render a value as JSON; indent (int spaces or whitespace string) pretty-prints, else compact. Non-encodable values → Err.
from_csvfrom_csv(s, opts?)Parse RFC 4180 CSV into rows (arrays, or records with {header: true}); strict by default. Malformed input → Err.
to_csvto_csv(rows, opts?)Render rows (arrays or records) as CSV; minimal quoting, \n lines ({crlf: true} for \r\n). Bad rows → Err.

Collections & higher-order

BuiltinSignatureDescription
lenlen(arr)Element count (also string runes, map entries, range length).
pushpush(arr, x...)Append values in place; returns the same array.
poppop(arr)Remove and return the last element; empty → Err.
taketake(arr, n)New array of the first n elements (clamped).
dropdrop(arr, n)New array with the first n elements removed (clamped).
uniquniq(arr)Distinct elements (structural ==), in order.
containscontains(arr, x)True if x is in arr by structural == (also string substring).
mapmap(arr, fn)Apply fn to each element → new array; fail-loud on first Err.
filterfilter(arr, fn)Keep elements where fn is truthy.
rejectreject(arr, fn)Drop elements where fn is truthy.
eacheach(arr, fn)Run fn for side effects; returns the original array (for `\>`).
findfind(arr, fn)First element where fn is truthy, else undef (composes with //).
anyany(arr, fn)True if fn is truthy for any element (false over empty).
allall(arr, fn)True if fn is truthy for every element (true over empty).
countcount(arr, fn)How many elements satisfy fn.
reducereduce(arr, init, fn)Fold left with fn(acc, el) (or fn(acc, el, i)) starting at init.
flat_mapflat_map(arr, fn)Map then flatten one level (array results spliced, scalars appended).
pmappmap(arr, fn)Parallel map over a CPU-bounded worker pool; result in input order.
sortsort(arr, cmp?)New array sorted ascending (stable); optional comparator fn(a,b)→int.
sort_bysort_by(arr, keyFn)New array sorted by keyFn(el) (key computed once per element).
min_bymin_by(arr, keyFn)Element with the smallest keyFn(el); empty → undef.
max_bymax_by(arr, keyFn)Element with the largest keyFn(el); empty → undef.

Callbacks take one parameter, or two to also receive the 0-based index (reduce takes 2 or 3: acc, el, optional i).

Maps

BuiltinSignatureDescription
keyskeys(m)Fresh array of keys, in insertion order.
valuesvalues(m)Fresh array of values, in insertion order.
pairspairs(m)Array of [key, value] arrays, in insertion order.
hashas(m, key)True if m contains key.
deletedelete(m, key)Remove key in place; returns the same map.
lenlen(m)Entry count.

Regex

Patterns use Go's RE2 syntax. A pattern argument may be a string (compiled and cached) or a compiled regex value (a qr/.../ literal or re(...)).

BuiltinSignatureDescription
rere(pattern)Compile a string pattern into a reusable regex value; bad pattern → Err.
matchesmatches(s, pattern)True if pattern matches anywhere in s.
matchmatch(s, pattern)First match as [full, group1, ...], or undef if no match.
find_allfind_all(s, pattern)Array of every (full) match, in order.
gsubgsub(s, pattern, repl)Replace every match with repl ($1/${name} backrefs).

Filesystem & paths

Path helpers are pure string transforms (never an Err); stat guards always return a bool; the rest signal real I/O failures as Err.

BuiltinSignatureDescription
joinjoin(seg, ...)Join path segments (OS-native). Also join(arr, sep?) to render+join an array.
dirnamedirname(p)Directory portion of a path.
basenamebasename(p)Final path element.
extext(p)Extension including the dot (.txt), or "".
stemstem(p)Basename without its extension.
abspathabspath(p)Absolute path against the CWD; failure → Err. (Numeric absolute value is abs.)
slashslash(p)Convert separators to forward slashes.
is_absis_abs(p)True if p is an absolute path.
cleanclean(p)Lexically simplify a path (resolve ./..).
relrel(base, p)Relative path from base to p; uncomparable → Err.
withinwithin(base, p)True if p is inside (or equal to) base.
path_list_seppath_list_sep()OS PATH-list separator (; Windows / : Unix).
existsexists(p)True if the path exists.
is_diris_dir(p)True if the path exists and is a directory.
globglob(pattern)Sorted matches (supports **); no match is [], bad pattern → Err.
read_dirread_dir(p)List a dir as [{name, path, is_dir}] (sorted by name); missing → Err.
mkdirmkdir(p)Create the directory tree (like mkdir -p); returns p, failure → Err.
mtimemtime(p)Modification time as a Unix timestamp; missing → Err.
newernewer(a, b)True if a is newer than b; a missing path → Err.
stalestale(target, sources)True if target is missing or older than any source; missing source → Err.
read_fileread_file(p)Read the whole file as a string; failure → Err.
write_filewrite_file(p, content, {append}?)Write (or append) content to p; returns p, failure → Err.
tempfiletempfile(prefix?)Create a unique empty temp file; returns its path (remove with rm).
tempdirtempdir(prefix?)Create a unique temp directory; returns its path (remove with rm).
renamerename(src, dst)Rename/move; returns dst, failure → Err.
rmrm(p)Remove a file or tree, recursively and idempotently; returns p.
copycopy(src, dst)Copy a file or directory tree; returns dst, failure → Err.
sizesize(p)File size in bytes; missing → Err.

Process & concurrency

Process builtins take command words (arrays splice, scalars stringify) and an optional trailing options map {cwd, env, stdin, timeout, arg0} (timeout in ms; arg0 presents a different argv[0] than the launched executable). No shell is involved; args are passed verbatim. Channels and tasks are shared reference types; values are deep-copied on send and on await.

BuiltinSignatureDescription
runrun(cmd, args..., opts?)Run with inherited stdio; true on success, non-zero exit → Err (code = exit).
capturecapture(cmd, args..., opts?)Run and return trimmed stdout; failure → Err (stderr folded in).
capture_allcapture_all(cmd, args..., opts?)Run and return {out, err, code, ok}; non-zero exit is data, not an Err (124 timeout / 127 can't-start).
pipepipe([cmd,args..], ..., opts?)Stream a pipeline of [cmd, args...] stages; returns last stage's trimmed stdout.
startstart(cmd, args..., opts?)Launch detached (no wait); returns a process handle, can't-start → Err (127).
pidpid(proc)PID of a started process.
killkill(proc)Terminate a started process (and its tree); returns true.
awaitawait(t)Block for a task's result or a process's exit status (deep-copied out).
chanchan(n?)Make a channel, unbuffered or with buffer size n.
sendsend(c, v)Send a copy of v (blocking); send on a closed channel → Err.
recvrecv(c)Block for the next value; closed-and-drained yields undef.
recv_okrecv_ok(c)Like recv but returns [value, ok] (ok=false when closed).
closeclose(c)Close a channel (safe from any goroutine); returns nil.
draindrain(c)Collect all remaining values into an array, blocking until closed.

HTTP

Transport failure → catchable Err (a timeout carries err_code 124); an HTTP status (incl. 4xx/5xx) is data in the returned {status, ok, body, headers, url} map. opts: headers, body, json, timeout (ms; 0=∞), redirects (0=don't follow), max_body (0=∞), insecure. Defaults: 30s timeout, follow ≤10 redirects, TLS on, 32 MiB body cap.

BuiltinSignatureDescription
httphttp(method, url, opts?)Request with any method; the primitive.
http_gethttp_get(url, opts?)GET.
http_posthttp_post(url, body, opts?)POST a string body (use http(..., {json: x}) for JSON).

System

BuiltinSignatureDescription
sys_gcsys_gc(mode)Tune the GC (off/lean/normal/relaxed, or a GOGC int); returns the previous percent.
cwdcwd()Current working directory as a native path.
envenv(name, default?)Process env var (case-insensitive on Windows); default or nil if unset.
osos()Operating system name (windows/darwin/linux/…).
archarch()CPU architecture (amd64/arm64/…).
homehome()Current user's home directory; failure → Err.
parse_argsparse_args(argv, value_opts?)Parse an argv array into a flat map: --flagtrue, --key=val/--key val (if key is in value_opts)→string, positionals under "_".

Date & time

A point in time is epoch seconds (a float); see the Date-and-time chapter. strftime/parse_time/date_parts take an optional trailing {utc: true} map (local time otherwise).

BuiltinSignatureDescription
nownow()Current time as epoch seconds (float).
sleepsleep(secs)Pause for secs seconds (fractional ok).
strftimestrftime(epoch, fmt, {utc}?)Format an epoch via %-codes (local, or UTC).
parse_timeparse_time(str, fmt, {utc}?)Parse a string back to an epoch, or Err.
date_partsdate_parts(epoch, {utc}?)Map of year month day hour minute second weekday yearday.

Hashing & encoding

BuiltinSignatureDescription
sha256 / sha1 / md5sha256(s)Hex digest of a string.
to_base64 / from_base64to_base64(s)Standard base64 encode / decode (decode → Err on bad input).
to_hex / from_hexto_hex(s)Hex encode / decode (decode → Err on bad input).
url_encode / url_decodeurl_encode(s)Percent-encode / decode (decode → Err on bad input).

Randomness

rand/rand_int/shuffle/sample use a fast auto-seeded generator (not for secrets); uuid uses the cryptographic generator.

BuiltinSignatureDescription
randrand()A float in [0, 1).
rand_intrand_int(n) / rand_int(lo, hi)A random int in [0, n), or in [lo, hi).
shuffleshuffle(arr)A new array, randomly permuted.
samplesample(arr)A random element; empty array → Err.
uuiduuid()A random (v4) UUID string.

Not Yet: Known Gaps and Surprises

drang is a personal daily-driver under active construction, not a finished language. This section is the honest inventory of what is missing or behaves unexpectedly, so you don't waste time reaching for something that isn't there. Everything below was confirmed against the binary.

Whole capability areas with no builtins

The math family is daily-driver-sized, not a scientific library: abs/sum/min/max/floor/ceil/round/sqrt/pow/log/div are present, but trigonometry is not. Calling one is an unknown function error:

drang -e 'say(sin(0))'
# drang: unknown function sin

Trig (sin, cos, tan, …) is planned as a thin binding over Go's math, not yet landed; for a one-off, orchestrate an external tool. Everything else you might reach for is here now: HTTP (http_get/http_post/http, see HTTP client), date/time, hashing, encodings, and randomness. (The bare name fetch is not a builtin. Use http_get.)

Missing operators

  say(10 / 4)        # 2.5
  say(div(10, 4))    # 2
  say(int(10 / 4))   # 2

Designed but not yet built

Several features are specified in DESIGN.md but do not work in the binary yet. Don't reach for them:

  say("5" + 3)        # error: cannot use string and int with '+'
  say(int("5") + 3)   # 8

(Modules are shipped, see Modules: use, as is the one-liner BEGIN/END block; both were once listed here as missing.)

Behaviors that may surprise you

  say(9223372036854775807 + 1)
  # drang: integer overflow: 9223372036854775807 + 1
  say(format("{:>3}: {:.2f}", "pi", 3.14159))   # " pi: 3.14"
  say(format("%d", 5))                          # error: format: template has 0 placeholder(s) but got 1 argument(s)

There is no sprintf (unknown function); format is the only string-formatting builtin.

Also absent (from DESIGN.md, not yet built)

sh() shell escape, char ranges ('a'..'z'), and the cross-machine/distribution growth paths. These are tracked in DESIGN.md and ROADMAP.md as deferred or planned, not shipped. (First-class builtin values, map($xs, basename), now do work; see "Functions and builtins are first-class values".)