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
- Lexical Structure, Declarations, Types, and Operators
- Strings
- Control flow
- Functions, Lambdas, Closures, and Pipelines
- Arrays, Maps, and the Collection Toolkit
- Errors as Values
- Regular expressions
- External Commands & Concurrency
- In-language concurrency
- Files and Paths
- JSON
- CSV
- Date and time
- Hashing, encoding, and randomness
- HTTP client
- One-liner mode
- Modules:
use - Testing:
drang test - Formatting:
drang fmt - Quick reference: builtins
- Not Yet: Known Gaps and Surprises
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:
- Perl's soul, not its warts. First-class regex, terse one-liners, a single
$sigil on every variable, and string interpolation, but without scalar/list context, typeglobs,bless, or the punctuation-variable zoo. One sigil covers all data:$xwhether it holds a number, string, array, or hash. (Names carry their kind:$for data,.for your own functions, bare for builtins; see Functions.) - Effortless parallelism. Real multi-core execution with no GIL, made safe by subtraction: top-level bindings are frozen constants and there are no mutable globals, so data-parallel combinators like
pmaprun lock-free. - First-class errors. Failures are ordinary values you can inspect (
is_err,err_msg,err_code) or propagate with a trailing?. There is no$@global and no exceptions-by-default; a dropped failure is a deliberate choice, not an accident. - Complete via Go. The standard library is a curated binding over Go's (strings, files,
os/exec, regex (RE2)) not a from-scratch reimplementation.
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.
| Flag | Effect |
|---|---|
--run | Run the program (the default; rarely written explicitly). |
--ast | Print the parsed AST instead of running. |
--tokens | Print the token stream instead of running. |
--version, -V | Print the version and exit. |
--help, -h | Print 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
| Type | Example literal / how you get one | ||
|---|---|---|---|
nil | the absent/empty value (e.g. a missing map key); no nil literal keyword | ||
bool | true, false | ||
int | 42 (64-bit signed) | ||
float | 3.5 (64-bit) | ||
string | "hello" | ||
error | from fail("...") and fallible builtins | ||
array | [1, 2, 3] | ||
map | {"a": 1, "b": 2} (insertion-ordered) | ||
range | 1..5 (inclusive) | ||
function | a lambda ` | $x | $x * 2, or fn .name declared and referenced as .name` |
regex | re("[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:
- No ternary
?:: useif/else. - No exponent
**: there is no power operator. - No bitwise operators (
&,|,^,<<,>>). - No increment/decrement
++/--: use+= 1/-= 1. - No Perl regex operators (
=~,s///) or$1..$ncapture variables: drang usesqr//literals with thematch/gsub/matches/find_allbuiltins and pipelines; named captures come back as a map. This is a deliberate choice (keeping the clean three-sigil model), not a missing feature.
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.
q{...}: raw. No interpolation, no escape processing at all.qq{...}: interpolated, exactly like"...".qw{...}: whitespace-split word list, producing an array.
$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:
<<ENDand<<"END": interpolate, likeqq/"...".<<'END': raw, likeq.<<~END: strips the common leading indentation of the body (the terminator may be indented too).
$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
| Builtin | Signature | Notes |
|---|---|---|
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]}
- align:
<left,>right,^center. Numbers default to right, everything else to left. - fill: any character placed before an align char (
{:*^10}centers with*); defaults to a space. - sign:
+shows a sign on positives too; a space reserves a column for it. #: alternate form (0x/0o/0bprefixes for thex/o/btypes).0: sign-aware zero-padding to the width.- width /
.precision: minimum field width, and decimal places (floats) or max length (strings). - type:
dint ·b/o/x/Xbinary/octal/hex ·f/e/g(andF/E/G) float ·sstring ·%percent (×100 with a%suffix).
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:
$name: data, variables and constants alike ($count,$pi)..name: a user-defined function, declaredfn .name(), called.name(), and passed as a value as.name.- a bare
name: a builtin or standard-library function, the language's own verbs (say,map,split, …).
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:
| Helper | Meaning |
|---|---|
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:
- A wrong argument count is a programmer error: a hard abort that
?///cannot intercept. It stops the program with a source location. - A wrong type or bad value is a runtime condition: a catchable Err value you can recover.
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.
| Builtin | Returns |
|---|---|
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 tocmd /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:
expr?, propagate: ifexpris anErr, abort the program with that message.expr // fallback, recover: substitutefallbackwhenexpris anErr.- let the
Errflow as an ordinary value.
$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
read_file(path)→ the whole file as a string, orErrif unreadable.write_file(path, content, {append: true}?)→ writescontent(any value, rendered
like say) to path, creating or truncating it; with {append: true} it appends instead. Returns the path, or Err.
tempfile(prefix?)/tempdir(prefix?)→ create a uniquely-named empty file / directory
in the system temp dir and return its path; remove it with rm when done.
lines(text)→ splits a string into an array of lines. It is not a file
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
exists(p)→ bool, true if the path exists.is_dir(p)→ bool, true only ifpexists and is a directory.mkdir(p)→ createspand any missing parents (mkdir -p); returnsp.glob(pattern)→ sorted array of matching paths; **no match is an empty
array, not an error. Supports *, ?, [...], and a recursive `` segment that spans directories.
rename(src, dst)→ moves/renames; returnsdst.copy(src, dst)→ copies a file, or recursively copies a directory tree;
returns dst.
rm(p)→ removes a file or directory tree, recursively and idempotently (no
error if absent). It is named rm because delete is the map-key remover.
size(p)→ file size in bytes as an int, orErrif the path is missing.
$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.
| Builtin | Input | Result |
|---|---|---|
dirname(p) | .../notes.txt | ... (the directory) |
basename(p) | .../notes.txt | notes.txt |
ext(p) | .../notes.txt | .txt (leading dot) |
stem(p) | .../notes.txt | notes (basename minus ext) |
abspath(p) | foo/bar.txt | absolute path against the CWD (numeric absolute value is abs) |
slash(p) | C:\a\b | C:/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.
mtime(p)→ modification time as a Unix-seconds int, orErrif missing.newer(a, b)→ bool: isastrictly newer thanb? Both must exist (a
missing operand is an Err).
stale(target, sources)→ bool: doestargetneed rebuilding? True if
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):
| Option | Where | Meaning |
|---|---|---|
sep | both | field delimiter, one character (default ,; e.g. "\t" for TSV) |
header | both | read: first row is column names → records; write: include a header row (default true) |
lenient | both | relax strictness (ragged rows, duplicate / divergent keys) |
comment | read | skip lines whose first character is this |
trim | read | drop leading whitespace in each field |
lazy_quotes | read | tolerate stray quotes in malformed input |
crlf | write | end 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".
now(): the current time, as epoch seconds (a float).sleep($secs): pause for$secsseconds (a float; fractional is fine).strftime($epoch, $fmt, {utc: true}?): format an epoch as a string, using%-codes; local time by default, or UTC with{utc: true}.parse_time($str, $fmt, {utc: true}?): parse a string (same%-codes) back to an epoch, or anErr; interprets the string as local time, or UTC with{utc: true}.date_parts($epoch, {utc: true}?): a map of components:year month day hour minute second weekday yearday(weekdayis 0–6, Sunday = 0); local or, with{utc: true}, UTC.
$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):
| Variable | Meaning |
|---|---|
$_ | the current line, with its trailing newline (and \r) stripped |
$nr | the 1-based line number, counting across every input file |
$file | the current input filename ("<stdin>" when reading stdin) |
$f | with -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):
- Separate statements on one line with
;. A block's closing}also ends a
statement, so BEGIN{ ... } stmt needs no ;, but stmt; END{ ... } does.
- Use
:=/=in the per-line body, not::=; re-declaring a constant on every
line is an error.
-pends each line with\n(CRLF input is normalized to LF, and a missing final
newline is added).
- Runtime errors in stream mode report the message but not a source position.
- In-place file editing (
-i) is not yet supported.
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
- Load once. A module is evaluated once per process and cached by canonical path,
so a diamond (a uses b and c, both use d) loads d exactly once.
- Cycles error. If imports form a cycle, the import fails with `import cycle
through …` rather than looping.
- Flat-merge is not transitive. If module
bdoesuse "./d", importingbdoes
not re-export d's names: you get b's own exports only.
- Collisions error. Merging a name already defined in the current scope (or
defining one a use already merged) is an error, not a silent overwrite.
exit/diepropagate. If a module callsexit()ordie()while loading, it
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
- A bare
use("./util")with parentheses, as a statement (result discarded) loads
the module but imports nothing, almost always a mistake. Write use "./util" to merge, or $u := use("./util") to capture.
- Exported values are deeply immutable. A module's exports (the record and every
array/map within) are frozen, so mutating one fails loudly rather than affecting other importers; exports are safe to share across the import cache.
- Every top-level
.foois exported; there is no module-private helper yet.
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.dr → report.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).
| flag | effect |
|---|---|
-w, --write | rewrite each changed file in place, atomically |
-c, --check | list unformatted files to stderr; exit non-zero (for CI) |
-l, --list | list files that would change to stdout |
-d, --diff | print a diff of the changes |
--fix | also 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
- Indentation is tabs, one per nesting level; braced blocks are always multi-line.
- Spacing is normalized: one space around binary, assignment, and
|>operators;
tight .. ranges and prefix -/!; , between elements; key: value in maps.
- Blank lines are collapsed, with one kept around each top-level function.
- Parentheses are reduced to the minimum precedence requires:
(1 + 2) * 3keeps its
parens, 1 + (2 * 3) loses them.
- Long lines wrap at ~100 columns: a
|>pipeline breaks at every stage; a long
call, array, or map puts one element per line.
- Your surface is kept faithful. String quoting and the
qq/q/heredoc forms,
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
| Builtin | Signature | Description |
|---|---|---|
say | say(x...) | Print all arguments space-separated, then a newline; returns nil. |
warn | warn(x...) | Like say, but to stderr: for diagnostics that shouldn't pollute stdout. |
fail | fail(msg?) | Make an Err value with message msg (default "failed") and code 1. |
is_err | is_err(x) | True if x is an Err value. |
err_code | err_code(x) | The Err's code; 0 for a non-Err (so it reads as an exit code). |
err_msg | err_msg(x) | The Err's message; "" for a non-Err. |
exit | exit(code?) | End the program with code (default 0, clamped 0–255), unwinding past functions, loops, ?, and //. |
die | die(x...) | Print the message to stderr and exit with code 1: the fatal-error convention for a tool. |
Conversions
| Builtin | Signature | Description |
|---|---|---|
int | int(x) | Convert int/float/string to an int; unparseable → Err. |
str | str(x) | Render any value as its display string (numbers, bools, nil, collections, errors). |
float | float(x) | Convert int/float/string to a float; unparseable → Err. |
bool | bool(x) | Coerce by truthiness (nil/false/0/0.0/""/empty container → false). |
type | type(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.
| Builtin | Signature | Description |
|---|---|---|
abs | abs(n) | Numeric absolute value (the path builtin is abspath). |
sum | sum(arr) / sum(a, ...) | Add numbers (array or variadic); empty → 0; overflow → Err. |
min | min(arr) / min(a, ...) | Smallest value; empty → Err. |
max | max(arr) / max(a, ...) | Largest value; empty → Err. |
floor | floor(n) | Round down to an int; NaN/Inf/out-of-range → Err. |
ceil | ceil(n) | Round up to an int. |
round | round(n) | Round to the nearest int (half away from zero). |
sqrt | sqrt(n) | Square root (float); negative → Err. |
pow | pow(base, exp) | base^exp; int when both are ints and exp ≥ 0 (overflow → Err), else float. |
log | log(x, base?) | Natural log, or log base base; non-positive x or bad base → Err. |
div | div(a, b) | Truncating integer division (toward zero, matching %); divide-by-zero → Err. |
Strings
| Builtin | Signature | Description |
|---|---|---|
split | split(s, sep?) | Split s; no sep splits on whitespace runs, "" splits into runes. |
replace | replace(s, old, new) | Replace every literal old with new. |
trim | trim(s, cutset?) | Trim whitespace, or the given cutset characters, from both ends. |
upper | upper(s) | Uppercase. |
lower | lower(s) | Lowercase. |
starts_with | starts_with(s, prefix) | True if s begins with prefix. |
ends_with | ends_with(s, suffix) | True if s ends with suffix. |
format | format(tmpl, x...) | Fill each {} in tmpl with the next arg ({{/}} are literal); arity-checked → Err. |
lines | lines(s) | Split into lines (CRLF-normalized), dropping one trailing newline; "" → []. |
repeat | repeat(s, n) | Concatenate n copies of s; negative or oversized n → Err. |
chars | chars(s) | Array of single-rune strings. |
index_of | index_of(s, needle) | Rune index of the first needle in s, or -1; empty needle → 0. |
len | len(x) | Rune count of a string (also entry count of an array/map/range). |
contains | contains(s, needle) | Substring test for a string (also membership for an array). |
JSON
| Builtin | Signature | Description |
|---|---|---|
from_json | from_json(s) | Parse JSON into drang values (object→map, array→array, number→int/float); malformed input → Err. |
to_json | to_json(v, indent?) | Render a value as JSON; indent (int spaces or whitespace string) pretty-prints, else compact. Non-encodable values → Err. |
from_csv | from_csv(s, opts?) | Parse RFC 4180 CSV into rows (arrays, or records with {header: true}); strict by default. Malformed input → Err. |
to_csv | to_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
| Builtin | Signature | Description | |
|---|---|---|---|
len | len(arr) | Element count (also string runes, map entries, range length). | |
push | push(arr, x...) | Append values in place; returns the same array. | |
pop | pop(arr) | Remove and return the last element; empty → Err. | |
take | take(arr, n) | New array of the first n elements (clamped). | |
drop | drop(arr, n) | New array with the first n elements removed (clamped). | |
uniq | uniq(arr) | Distinct elements (structural ==), in order. | |
contains | contains(arr, x) | True if x is in arr by structural == (also string substring). | |
map | map(arr, fn) | Apply fn to each element → new array; fail-loud on first Err. | |
filter | filter(arr, fn) | Keep elements where fn is truthy. | |
reject | reject(arr, fn) | Drop elements where fn is truthy. | |
each | each(arr, fn) | Run fn for side effects; returns the original array (for `\ | >`). |
find | find(arr, fn) | First element where fn is truthy, else undef (composes with //). | |
any | any(arr, fn) | True if fn is truthy for any element (false over empty). | |
all | all(arr, fn) | True if fn is truthy for every element (true over empty). | |
count | count(arr, fn) | How many elements satisfy fn. | |
reduce | reduce(arr, init, fn) | Fold left with fn(acc, el) (or fn(acc, el, i)) starting at init. | |
flat_map | flat_map(arr, fn) | Map then flatten one level (array results spliced, scalars appended). | |
pmap | pmap(arr, fn) | Parallel map over a CPU-bounded worker pool; result in input order. | |
sort | sort(arr, cmp?) | New array sorted ascending (stable); optional comparator fn(a,b)→int. | |
sort_by | sort_by(arr, keyFn) | New array sorted by keyFn(el) (key computed once per element). | |
min_by | min_by(arr, keyFn) | Element with the smallest keyFn(el); empty → undef. | |
max_by | max_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
| Builtin | Signature | Description |
|---|---|---|
keys | keys(m) | Fresh array of keys, in insertion order. |
values | values(m) | Fresh array of values, in insertion order. |
pairs | pairs(m) | Array of [key, value] arrays, in insertion order. |
has | has(m, key) | True if m contains key. |
delete | delete(m, key) | Remove key in place; returns the same map. |
len | len(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(...)).
| Builtin | Signature | Description |
|---|---|---|
re | re(pattern) | Compile a string pattern into a reusable regex value; bad pattern → Err. |
matches | matches(s, pattern) | True if pattern matches anywhere in s. |
match | match(s, pattern) | First match as [full, group1, ...], or undef if no match. |
find_all | find_all(s, pattern) | Array of every (full) match, in order. |
gsub | gsub(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.
| Builtin | Signature | Description |
|---|---|---|
join | join(seg, ...) | Join path segments (OS-native). Also join(arr, sep?) to render+join an array. |
dirname | dirname(p) | Directory portion of a path. |
basename | basename(p) | Final path element. |
ext | ext(p) | Extension including the dot (.txt), or "". |
stem | stem(p) | Basename without its extension. |
abspath | abspath(p) | Absolute path against the CWD; failure → Err. (Numeric absolute value is abs.) |
slash | slash(p) | Convert separators to forward slashes. |
is_abs | is_abs(p) | True if p is an absolute path. |
clean | clean(p) | Lexically simplify a path (resolve ./..). |
rel | rel(base, p) | Relative path from base to p; uncomparable → Err. |
within | within(base, p) | True if p is inside (or equal to) base. |
path_list_sep | path_list_sep() | OS PATH-list separator (; Windows / : Unix). |
exists | exists(p) | True if the path exists. |
is_dir | is_dir(p) | True if the path exists and is a directory. |
glob | glob(pattern) | Sorted matches (supports **); no match is [], bad pattern → Err. |
read_dir | read_dir(p) | List a dir as [{name, path, is_dir}] (sorted by name); missing → Err. |
mkdir | mkdir(p) | Create the directory tree (like mkdir -p); returns p, failure → Err. |
mtime | mtime(p) | Modification time as a Unix timestamp; missing → Err. |
newer | newer(a, b) | True if a is newer than b; a missing path → Err. |
stale | stale(target, sources) | True if target is missing or older than any source; missing source → Err. |
read_file | read_file(p) | Read the whole file as a string; failure → Err. |
write_file | write_file(p, content, {append}?) | Write (or append) content to p; returns p, failure → Err. |
tempfile | tempfile(prefix?) | Create a unique empty temp file; returns its path (remove with rm). |
tempdir | tempdir(prefix?) | Create a unique temp directory; returns its path (remove with rm). |
rename | rename(src, dst) | Rename/move; returns dst, failure → Err. |
rm | rm(p) | Remove a file or tree, recursively and idempotently; returns p. |
copy | copy(src, dst) | Copy a file or directory tree; returns dst, failure → Err. |
size | size(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.
| Builtin | Signature | Description |
|---|---|---|
run | run(cmd, args..., opts?) | Run with inherited stdio; true on success, non-zero exit → Err (code = exit). |
capture | capture(cmd, args..., opts?) | Run and return trimmed stdout; failure → Err (stderr folded in). |
capture_all | capture_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). |
pipe | pipe([cmd,args..], ..., opts?) | Stream a pipeline of [cmd, args...] stages; returns last stage's trimmed stdout. |
start | start(cmd, args..., opts?) | Launch detached (no wait); returns a process handle, can't-start → Err (127). |
pid | pid(proc) | PID of a started process. |
kill | kill(proc) | Terminate a started process (and its tree); returns true. |
await | await(t) | Block for a task's result or a process's exit status (deep-copied out). |
chan | chan(n?) | Make a channel, unbuffered or with buffer size n. |
send | send(c, v) | Send a copy of v (blocking); send on a closed channel → Err. |
recv | recv(c) | Block for the next value; closed-and-drained yields undef. |
recv_ok | recv_ok(c) | Like recv but returns [value, ok] (ok=false when closed). |
close | close(c) | Close a channel (safe from any goroutine); returns nil. |
drain | drain(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.
| Builtin | Signature | Description |
|---|---|---|
http | http(method, url, opts?) | Request with any method; the primitive. |
http_get | http_get(url, opts?) | GET. |
http_post | http_post(url, body, opts?) | POST a string body (use http(..., {json: x}) for JSON). |
System
| Builtin | Signature | Description |
|---|---|---|
sys_gc | sys_gc(mode) | Tune the GC (off/lean/normal/relaxed, or a GOGC int); returns the previous percent. |
cwd | cwd() | Current working directory as a native path. |
env | env(name, default?) | Process env var (case-insensitive on Windows); default or nil if unset. |
os | os() | Operating system name (windows/darwin/linux/…). |
arch | arch() | CPU architecture (amd64/arm64/…). |
home | home() | Current user's home directory; failure → Err. |
parse_args | parse_args(argv, value_opts?) | Parse an argv array into a flat map: --flag→true, --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).
| Builtin | Signature | Description |
|---|---|---|
now | now() | Current time as epoch seconds (float). |
sleep | sleep(secs) | Pause for secs seconds (fractional ok). |
strftime | strftime(epoch, fmt, {utc}?) | Format an epoch via %-codes (local, or UTC). |
parse_time | parse_time(str, fmt, {utc}?) | Parse a string back to an epoch, or Err. |
date_parts | date_parts(epoch, {utc}?) | Map of year month day hour minute second weekday yearday. |
Hashing & encoding
| Builtin | Signature | Description |
|---|---|---|
sha256 / sha1 / md5 | sha256(s) | Hex digest of a string. |
to_base64 / from_base64 | to_base64(s) | Standard base64 encode / decode (decode → Err on bad input). |
to_hex / from_hex | to_hex(s) | Hex encode / decode (decode → Err on bad input). |
url_encode / url_decode | url_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.
| Builtin | Signature | Description |
|---|---|---|
rand | rand() | A float in [0, 1). |
rand_int | rand_int(n) / rand_int(lo, hi) | A random int in [0, n), or in [lo, hi). |
shuffle | shuffle(arr) | A new array, randomly permuted. |
sample | sample(arr) | A random element; empty array → Err. |
uuid | uuid() | 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
- No integer-division operator.
/is always float division. Use thediv()builtin (truncating, like%), orint():
say(10 / 4) # 2.5
say(div(10, 4)) # 2
say(int(10 / 4)) # 2
- No `
exponent operator**:2 ** 8is a parse error (unexpected STAR`). - No ternary:
1 > 0 ? 1 : 2does not parse.ifis a statement, not an expression, so there is no inline conditional. Useand/orshort-circuit value-returning logic ($cond and $a or $b) as the workaround. - No bitwise operators:
&,|(as bitwise),<<,>>all fail to parse (&lexes asILLEGAL;<<is read as a heredoc start).|is the lambda delimiter, not bitwise-or. - No
++/--:$x++is a parse error. Use compound assignment:$x += 1.
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:
- Structs.
struct Foo { ... }is a parse error. Use maps as records in the meantime:$s := {reqs: 0, by_ip: {}}. - Named arguments (
f(port: 9090)) are not supported. Arguments are positional (default parameters are supported; see Named functions). Variadic parameters ($a...) are deliberately out of scope: pass an array instead.- No automatic stringy coercion."5" + 3is an error, not8. Convert explicitly withint():
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
intis 64-bit;+/-/*overflow is an error, not a silent wrap (and there is no auto-promotion to float). It fails loudly, like division by zero:
say(9223372036854775807 + 1)
# drang: integer overflow: 9223372036854775807 + 1
format()uses{}/{:spec}placeholders, not%-style verbs. Width, precision, alignment, sign, and base live in the spec ({:>8},{:.2f},{:08x}, see Format specs), not in%d/%sverbs. A%-style template has no placeholders, so it is a (catchable) arity error:
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".)