gsh - the GodspeedOS shell language
Status: COMPLETE. The whole language is built, and it lives entirely on the stack (no heap anywhere in the interpreter), with a fixed, readable bound on every buffer and a LOUD refusal on every overflow: a 10 MB script is read through a bounded minifier, and when its code exceeds
SCRIPT_MAX(7112 bytes) it is refused loudly with none of it run, never OOMs. (2026-10-25: it used to truncate at ~7 KiB and run the part that fitted, whichosdev test big-script(6/6) pinned; the refusal replaced that in914b4329,backlog/fixed/80H10-H21, and the suite has pinned the refusal since 2026-10-25: refused loudly, none of it run, no panic, 5/5.) Scripts use the.gshextension (GodspeedOS shell;.gsis reserved for a future general-purpose Godspeed language). Built on therun/run_linesinterpreter and the command Result model (executereturnsOk/Err). Not POSIX - see CLAUDE.md Appendix B.3 / D.
What is built
-
Tier 1 (§3, §4, §6, §8) -
let/let mut+ reassignment;$-expansion ($name,"...") and params ($arg1..$arg9,$args,$argcount,$self);fail;if/else if/elsewith comparisons (== != < > <= >=),in,!, andresultas a comparable value;switchwith multiple values per arm, a_default, andswitch result. Pinned byosdev test files(a greet-shape param+if+in+switch script runs end to end, both paths). -
Arithmetic (§3) - checked integer
+ - * / %,( )grouping, precedence. -
Command capture (§3) -
$( )captures a bare producer ($(date),$(read /f),$(greet)). A pipeline capture ($(greet | count)) is refused loudly, because its nested pipe buffers would overflow the bounded 256 KiB user stack (§26.6); stage it through a file instead -greet | count | write /t.txtthenlet n = $(read /t.txt), the materialize-then-capture idiom.
Corrected 2026-09-26. Nine worked examples in this document captured a pipeline directly (
let n = $(greet | count)), which is the thing this very bullet says is refused. They are staged now. The bound is real - a nested pipe inside a capture is what the bounded stack cannot afford - so an example that hides it fails on first use.
-
Functions (§7) -
fn name params { … }called like a command; named params; scoped locals plus immutable-global access;return; and bounded recursion via explicit call frames (no native recursion, §9). Function-valued conditions (if fn { }, §4) and function output-capture ($(fn …), §3) are included. -
Libraries -
import <path>(all functions) andfrom <path> import <name> [as <alias>] …(selective, aliased), resolved at LOAD time: each library is minified and its requested functions appended to the buffer so the pre-scan indexes them. Explicit paths, flat namespace, loud on a name collision (asresolves it). -
Loops (§5) -
for <var> in <words | range N | range A B | $args | (producer)> { … }, unboundedloop { … }with a 100k-iteration backstop, andbreak/continue. A mutable loop counter lives in a fixed slot (overwritten in place, so a long loop grows no arena) and each pass resets the body's locals, so aletinside is fresh. The byte-line stream form (for line in (producer)) is built. -
defer(§5) -defer <command>runs cleanup when the current scope exits (a function'sreturn, or the whole script), LIFO, even onfail. Each defer records only an (offset, len, scope-depth) into the resident script. -
Record aggregators (§5) -
countis dual (rows of a record stream, lines of a byte stream);sum/min/max/avg <col>reduce a numeric column, loud on a non-numeric or missing column, never a silent 0. -
Console input (§8) -
input "prompt"andinput secret "prompt": invisible entry plus an echo-taint guard rail (sealedreserved).
The output-capture cluster (for line in (producer), if myfn, $(fn)) closed the set, so Tier 2
is complete. Capture behaviour is pinned by osdev test files plus the baked osdev test script.
Deferred follow-ons, collected as future work under §11: the record-row form
for row in (pipeline); sealed enforcement plus a secret consumer; and input pipe-else-prompt.
Contents
- Spirit
- Lexical
- Variables, parameters, arithmetic
- Conditionals
- Loops
- Switch
- Functions
- Builtins
- Bounds
- Out of scope
- Tiers and effort
- Worked example
- Example programs - setup.gsh · greet.gsh · report.gsh · check.gsh · retry.gsh
1. Spirit
- Simple, explicit surface. Braces for blocks, newline-separated statements, no trailing
;, no parentheses around conditions, a tiny keyword set. - Conditions are command results.
Okis true,Erris false. The thing that makesifcheap is that we already produce aResultfor every command. - Errors are checked, not hidden. No
set -e-style invisible mode - you inspectresultright where the error can happen. See §4. - Explicit and loud (the Godspeed part). Referencing an undefined
$xis an error, not a silent empty string. Every limit is fixed; overflow is a loud error, never silent truncation or a runaway loop. - Bounded on purpose. Fixed storage, no heap (§9). The ceiling is a feature: it forces small,
composable, readable scripts and keeps gsh from becoming the cryptic, magic-laden sprawl POSIX
shells drift into. A script that needs more than the ceiling is a program - use the Godspeed
language (
.gs), not the shell. - Not bash. No
fi/esac/done, no word-splitting surprises, no$?quirks.
2. Lexical
- A newline ends a statement. You don't write a trailing
;. Use;only to put two statements on one line - never as a line terminator (a trailing one is just ignored). #to end of line is a comment; blank lines are ignored.- Tokens: bare words (no spaces, literal),
"double quoted"(spaces ok,$varexpands),'single quoted'(literal, no expansion). {and}are structural - they delimit blocks (if/else, and laterfor/loop/fn/switch). To use a brace literally in a value or command argument (e.g. a JSON literal), quote it:write /f '[{"n":1}]'. Inside'...'/"..."a brace is literal, never a block.- Multi-line content uses a triple-quoted block - for writing real files. It expands
$varslike"…". It is for a command argument (bounded byMAX_FILE_BYTES); storing one in aletvar that exceeds the value-size bound (§9) is a loud error, not a silent truncation.
echo one # a statement; the newline ends it
mkdir /x; cd /x # two statements on one line, joined by ;
echo "spaces kept, $name expands"
echo 'literal $name - no expansion'
write /work/config """
mode = normal
cores = 4
owner = $name
"""
3. Variables, parameters, arithmetic
Command position vs value position (the one disambiguation rule). The interpreter is always in one of two places, and that decides how bare text reads:
- Command position - a statement, or right after
if/for … in. Bare text is a command; no marker needed. - Value position - the right of
let x =, or inside"…". Bare text is a literal. To run a command here and use its output, promote it with$( … ).
The sigil rule that ties it together: $ means "the value of." $name = the value of a
variable; $(cmd) = the value of running a command. (This is why $(…) is the capture form and a
bare (…) is not - one obvious way.)
let name = "Matthew"
let n = 3
let lit = greet # value position, no $( ): the literal string "greet"
echo $name # Matthew
echo "hi $name, n=$n" # hi Matthew, n=3
Capture - promote a command into a value with $( ):
greet | count | write /t.txt # stage it - a capture cannot hold a pipeline
let count = $(read /t.txt) # 3
let when = $(date) # the date stamp
status | where state=Running | count | write /t.txt
let live = $(read /t.txt)
echo "running: $live, at $when"
fn make_greeting name { echo "Hello, $name!" }
let msg = $(make_greeting Bankole) # capture a FUNCTION's output: "Hello, Bankole!"
echo $msg
Immutable by default. let x = … is an immutable binding - reassigning it is a loud error.
Opt into mutation with let mut x = …, and reassign a mutable binding with bare x = … (no let):
let ROOT = /work # immutable
echo $ROOT/logs
# ROOT = /tmp # error: cannot reassign an immutable binding
let mut i = 0 # mutable
i = $i + 1 # reassign - no 'let'
There is no const: the default let is the constant. Mutation is explicit and visible at the
declaration - and that let / let mut line is exactly the scope boundary functions key off (§7).
Script parameters - run greet.gsh Matthew core (params are immutable, like a function's):
echo "name=$arg1 role=$arg2" # name=Matthew role=core
echo "got $argcount args: $args" # got 2 args: Matthew core
echo "script: $self" # script: greet.gsh
let x = <value>declares an immutable binding;let mut x = <value>a mutable one;x = …reassigns a mutable binding (loud error on an immutable or undeclared name).- A value is a literal word, a
"string", a capture$( … ), or an arithmetic expression (below). $xexpands; undefined$xis a loud error (never a silent empty string).$arg1 $arg2 …positional,$argcountcount,$argsall,$selfscript name.$homeis the session's home,/users/default(docs/filesystem-layout.md6), so a script can writecd $home/notes. Reserved like the others:let home = ...is refused.
Arithmetic. Integer expressions, evaluated wherever a value is expected - the right of a
let/let mut/reassignment, and the operands of a comparison. Operators are + - * / %, with bare
( ) for grouping and the usual precedence (* / % before + -). Operands are integer literals or
$vars that expand to integers.
let mut i = 0
i = $i + 1 # 1 - replaces the old $(add $i 1)
let area = $w * $h
let half = $n / 2 # integer division - truncates
let odd = $n % 2
let cost = ( $base + $tax ) * $qty # bare ( ) groups; $( ) is still capture
if $i + 1 > $max {
echo "next step would overflow the window"
}
- Signed 64-bit, checked. Divide-by-zero and overflow are loud errors, never a wrap or a silent zero; a non-integer operand is a loud error too.
- Space-separated operators, so text is never mistaken for math:
$dir/subis a path,$n / 2is division. String building stays interpolation ("$a$b","$dir/sub"), never+. - Arithmetic is value-position only. To use a result as a command argument, assign it first
(
let d = $n + $n→echo $d) - compute it, then use it.
4. Conditionals
A condition is either a comparison (its first token starts with $, ", or a digit), a
command (true iff it returns Ok; pipelines are commands), or a function call (if myfn args { … }
- the function is run and you branch on its result:
Ok-> then,Err-> else).
if read /sc/a.txt {
echo "exists"
} else if dir /sc {
echo "dir but no file"
} else {
echo "nothing"
}
if $role == "core" {
echo "the core service"
}
if $n >= 3 {
echo "enough"
}
if !read /sc/secret { # ! negates a command condition
echo "absent - good"
}
A function as a condition - branch on what it returns, not what it prints:
fn writable f { … } # a function whose last statement is Ok/Err-valued
if writable /sc/log {
echo "can write"
} else {
echo "read-only"
}
if !writable /sc/rom { # ! negates it too
echo "confirmed read-only"
}
Membership - the condition form of a one-arm switch:
if $role in core worker courier {
echo "known role"
}
if <cond> { … } else if <cond> { … } else { … }- braces, no parens, nofi.- Comparison ops:
== != < > <= >=(numeric if both sides parse as integers, else string). Either side may be an arithmetic expression -if $i + 1 > $max { … }(§3). $x in a b c- true if$xequals one of the listed words.
Failure model - explicit (no hidden set -e). A failed statement is tallied (the run summary
already does this) and execution continues; you handle errors where they happen by checking
result right where it can fail:
mkdir /sc
if result == Err {
fail "could not create /sc"
}
read /sc/cfg
if result == FileNotFound { # the SPECIFIC failure kind - flat, no Err( ) wrapper
write /sc/cfg "defaults"
}
if result != Ok { # "anything failed" - the negation form
echo "something went wrong"
}
To branch on several specific kinds, switch the result (§6) instead of chaining ifs:
read /work/config
switch result {
Ok { echo "loaded" }
FileNotFound { write /work/config "defaults" }
Denied { fail "no permission to read config" }
_ { fail "unexpected: config unreadable" }
}
resultis a first-class value: after every statement it holds that statement's outcome. It is theResultthe shell already threads between commands - soresult == Erris justprev.is_err(), nearly free to implement.- Compare
resultwith==/!=against one of:Ok,Err(any failure), or a specific kind -FileNotFound,Denied,AssertFailed,Unknown(the setassert fails-withuses). - Flat, no wrapper. It is
result == FileNotFound, neverresult == Err(FileNotFound)- the kind names are self-evidently errors, andErralone already means "any failure". One spelling per check. fail <msg>prints<msg>loudly and ends the script withErr.return <cond>ends a function (§7) with a result.- Optional one-liner sugar, addable later (desugars to the
resultcheck):a and b(runbiffawasOk),a or b(runbiffawasErr). Not required.
5. Loops
Iterate a stream - byte lines, or record rows with $row.<col>:
for line in (greet) { # the LINES of a byte stream
echo "> $line"
}
for row in (status | where state=Running) { # the ROWS of a record stream
echo "$row.name on core $row.core"
}
Built: the byte-line form
for line in (producer)- a bare producer, iterated line by line. The record-row form (for row in (pipeline) { $row.col }) is a design sketch, still deferred (see §11): it needs a capturable pipeline and$row.<col>field access.
Iterate params or a literal word list:
for arg in $args {
echo "arg: $arg"
}
for svc in events fs block-driver {
echo "checking $svc"
}
Counting loops with range:
for i in range 3 { # 0 1 2
echo $i
}
for i in range 2 6 { # 2 3 4 5
echo $i
}
loop repeats until you break - the one unbounded loop:
let mut i = 0
loop {
i = $i + 1
if $i == 3 { continue }
if $i > 5 { break }
echo $i # 1 2 4 5
}
A loop that should stop on a condition just breaks on it:
loop {
if read /work/ready { break }
let waited = $waited + 1
}
for x in ( … )iterates lines of a byte stream or rows of a record stream; record rows expose columns as$x.<col>(the payoff of typed pipes - nothing in POSIX has it).- Not
$( )here.for … inis command position - the live stream goes in directly (the parens are optional readability).$( )would flatten the command to text and lose$row.col;for … inkeeps the stream so it can iterate rows. ($( )= "the text";for … in= "the stream".) for x in a b citerates literal words;for x in $argsiterates the script's params.for i in range N/range A Bcounts.loop { … }repeats untilbreak. There is nowhile- a conditional loop isloop { if !cond { break } … }, which keeps the exit explicit and visible.- Two loops, one job each - and the keyword tells you if it terminates.
foris bounded: it walks something finite.loopis the unbounded one: it runs untilbreak, with the hard iteration cap (default 100k) as a loud backstop, never a silent hang. break/continue.
6. Switch
switch $role {
core { echo "the core" }
worker courier { echo "a helper" } # multiple values per arm
_ { echo "unknown" } # _ = default
}
switch $arg1 {
start { echo "starting" }
stop { echo "stopping" }
status { echo "ok" }
_ { fail "unknown command: $arg1" }
}
No fallthrough; _ is the default; multiple values per arm. The matched value is any value -
including result, which is the clean way to branch on several error kinds at once (§4).
7. Functions
fn greet name {
echo "hello $name"
}
fn ensure_dir path {
if !dir $path {
mkdir $path
}
}
greet Matthew # a function is just a command
ensure_dir /sc
Output is the value ($( )), result is the control (used by if):
fn full name surname {
echo "$name $surname"
}
fn double n {
let d = $n + $n # arithmetic is value-position; assign, then output
echo $d
}
let who = $(full Matthew Levi) # capture the output
echo $who # Matthew Levi
echo $(double 5) # 10
A function is just a command - that's what keeps it coherent with the rest of the language:
fn <name> <param…> { … }- named positional params, bound as$namein the body ($arg1 … $args $argcountalso available).- Output is the value, result is the control.
$(f …)captures what the function printed; itsOk/Errworks in conditions like any command.return <cond>ends early with that result; falling off the end returns the last statement's result (so a helper ending in anassertreturns the assert's verdict). No separate "return value" concept. - Scope = params + locals + immutable globals (mirrors constitution invariant 9). A function
sees its parameters, its own locals, and the script's immutable (
let) globals - but not mutable (let mut) globals. Invariant 9 is "no unowned global mutable state; immutable globals are fine," applied one layer up: immutable config (let ROOT = /work) is ambient-readable everywhere (no spooky action - it can't change); a value the script mutates must be passed in, so a function's mutable inputs are explicit, exactly like a service's capabilities (§3.1). Thelet/let mutdistinction is this scope boundary. Assignments inside are local and vanish on return. - Defined anywhere. A one-pass pre-scan over the (resident) script indexes every
fnblock, so a call can precede its definition - put helpers at the bottom and the main logic at the top if you like. (This is why the script is loaded whole, not streamed - a deliberate trade for ergonomics over arbitrarily-huge scripts; §9.) - Bounded, not natively recursive. Calls use the interpreter's explicit call-frame stack, not native Rust recursion - so the native stack stays flat regardless of gsh call depth. Recursion is allowed; depth is capped (loud error on overflow).
8. Builtins
Each returns a Result, so they compose with if.
let/let mut- declare an immutable / mutable variable (reassign a mutable one withx = …).result- the previous statement's outcome, as a value (compare== Ok/== Err/== <Variant>).fail <msg>- print loudly and end the script withErr;return <cond>ends a function (§7).defer <command>- run a command when the current scope (function, or the whole script) exits, including onfail; deferreds run LIFO.Ok/Errand the variant names (FileNotFound,Denied,AssertFailed,Unknown).range N/range A B- the counting iterator forfor.break,continue.
Arithmetic is inline (+ - * / %, value position) - see §3, not a builtin.
defer - cleanup that always runs:
mkdir /tmp/build
defer delete /tmp/build recursive # runs no matter how we leave
write /tmp/build/out done
Record aggregators (typed-pipe stages). Because our pipes carry a typed Table, a pipeline can
reduce - something byte pipes can't. count is dual (rows of a record stream, lines of a byte
stream); the column reducers are record-only:
roster | count | write /t.txt # row count (stage, then capture)
let rows = $(read /t.txt)
dir /work | count | write /t.txt # entries in a directory
let files = $(read /t.txt)
status | sum mem | write /t.txt # sum a numeric column
let used = $(read /t.txt)
dir /work | max size | write /t.txt # largest file
let big = $(read /t.txt)
status | avg queue | write /t.txt # average a column
let avgq = $(read /t.txt)
count- rows (record stream) or lines (byte stream).sum <col>min <col>max <col>avg <col>- reduce a numeric column; a non-numeric or missing column is a loud error, never a silent 0.
Console input (input / secret). A script reads a line from the user with input, a producer
captured like any other with $( ):
let name = $(input "Your name: ") # prompt to the console, capture the reply
echo "hi, $name"
input "prompt"- print the prompt, read one line from the console, emit it (captured, or piped).input secret "prompt"- the same, but keystrokes are invisible (nothing echoes as you type, likesudo). The captured value is tainted (below).input secret sealed "prompt"- reserved, not built (the maximum-lockdown escalation; below).
The value is captured, not written into a variable by side effect, so it follows gsh's one binding
model (let x = $(…)) - there is no bash-style read var reaching in to set a variable.
Secret taint (a guard rail, honestly labelled). A value from input secret is marked, and the
mark does exactly one thing: it may not be printed to the console. Expanding it into an echo is
refused loudly and masked as [secret]; everything else - writing it to a file, assigning it,
passing it to a command - is allowed, and the taint rides along on assignment (let y = $pw
gives y the same mark, so it cannot be laundered to the console in one hop):
let pw = $(input secret "Password: ")
echo $pw # refused, loud: "refusing to echo secret 'pw'" -> [secret]
write /vault $pw # allowed (your disk, your call)
let y = $pw # allowed; y is also secret, so `echo $y` is refused too
This is a guard rail against the accidental echo, not a vault - and honest about it: because
write and assign are allowed, a determined author can always move the value back out (write, then
read it back), so airtight non-leakage is impossible here by construction. We catch the common
accident loudly and claim nothing more - no general taint tracking; propagation stops at assignment.
sealed (reserved). input secret sealed will additionally forbid write and assignment, so
the value can only ever be consumed through a designated secret-aware door (a future login/auth
command, or a secret-eq compare). Until such a consumer exists a sealed value would be a black box
you cannot use, so the keyword is reserved and not yet built (§26.2) - the escalation is on
record, to be built the day a real crown-jewel consumer justifies it.
Interactive only. input blocks for a real person, so an automated suite cannot use it (it would
hang). It is tested by injecting keystrokes over the console - the way the harness already injects
commands - not by a bypass.
assert and the whole existing command set are unchanged, and a script's own result is still Ok
iff every top-level statement was Ok (the PASS/FAIL summary keeps working).
9. Bounds
Bounded by design, and the bounds are the point. gsh is no_std, no heap - fixed storage,
loud on overflow. Every limit below is a deliberate ceiling, generous for real shell scripts and
tunable; hitting one is a clear error, never a silent truncation or a runaway. (Values:)
| Thing | Bound (loud on overflow) |
|---|---|
| variables | fixed count × bounded value size |
| block nesting | fixed |
| loop iterations | hard cap (no runaway; configurable per loop later) |
| function call depth | fixed frames (each = its params + locals) |
| deferred actions | fixed per scope |
| script size | embedded: rodata; on-disk file: SCRIPT_MAX (7112 B of minified code) |
This is a feature, not a limitation. A finite ceiling forces good script hygiene - small,
composable, readable scripts - and rules out the cryptic, sprawling, magic-laden scripts the POSIX
world drifts into. If a script needs more than the ceiling, it isn't a shell script anymore - it's
a program, and that's the job of the general-purpose Godspeed language (.gs), not gsh.
Choosing the right tool is the discipline; the loud overflow is what tells you you've crossed the
line. (No silent stack→heap fallback, ever - §26.7.)
Execution model: the script is loaded whole (resident in a fixed buffer - small file or
embedded rodata), pre-scanned once for fn definitions (a tiny name→offset index, so functions
are callable before they're defined, §7), then executed top-to-bottom. Control flow uses an
explicit control stack (a flat array of frames), not native Rust recursion - loops and blocks
re-seek within the resident buffer, conditions are a command result or a comparison. No native
recursion - the native stack stays flat regardless of gsh nesting or call depth, the same
discipline behind every #[inline(never)] in the shell today.
Streaming huge scripts (read-at-offset from a multi-block file, buffering only the active block, the way bash reads a file descriptor) is deliberately not done - it would mean abandoning whole-script residency, which is what makes "defined anywhere" cheap. That trade-off lands on the side of small- script ergonomics; the streaming path belongs to Godspeed lang + a v2 fs.
10. Out of scope
For now:
- Capability delegation in script syntax (pipes-as-caps,
spawnwith explicit grants) is Appendix D.3 territory and is not built. (2026-10-25: the service-initiated spawn it waited on exists - the shell asks the supervisor to spawn a stage with named send peers - but no script syntax names a grant.) - A string toolkit (length, slice, split) - interpolation covers building strings; richer string ops wait. (Integer arithmetic is in - §3.)
- Cross-file include /
source, and record values held in variables (hold a whole table in alet) - bigger storage stories; iterate withforfor now. (2026-10-09: function libraries are the exception -import <path>andfrom <path> import <name>are built, see "Libraries" under What is built. A generalsourceof a script's statements is still not.) - A heap, and huge / streamed scripts. gsh stays
no_std/fixed-storage (§9). Heap-backed values, multi-block-file streaming, and 10K-line scripts are explicitly not gsh's job - by the time a script wants them it's a program, and that's the Godspeed language (.gs). The.gsh/.gssplit is this boundary. The loud overflow is the signal you've crossed it.
These are the genuinely balloon-prone parts, or they belong to the general-purpose language. (Functions and integer arithmetic are not here - both are Tier 2.)
11. Tiers and effort
- Tier 1 (~2-4 days):
let/let mut+$-expansion + params,if/else if/else, comparisons +in,result/fail,switch. - Tier 2 (~6-8 days):
$( )capture, multi-line"""…""", inline integer arithmetic (+ - * / %, precedence,( ), checked),for(lines / rows / words /range),loop,break/continue, functions (fn, pre-scan index, call-frame stack,return),defer, and the record aggregators (count/sum/min/max/avg). - Tier 3 (resist): a string toolkit (length/slice/split), cross-file include /
source, record-valued variables.
Deferred follow-ons (future ergonomics)
These are intended for gsh (unlike §10, which is genuinely out of scope), but not built yet - the language is already complete enough to work in fully without them. They are recorded here so the intent is not lost; each is pulled into existence when a real script wants it (§26.2), not before.
The output-capture cluster - COMPLETE. Three features that let a script use what a sub-computation produces. Each was deferred while it looked like it needed a nested ~64 KiB capture buffer on the stack; each turned out to need something smaller once actually built - the constitution's own lesson (§26.2): pull a feature into existence, then size it to the real need, not the feared one (§26.6.1 - change the representation, do not reach for more stack or a heap).
-
$(fn …)- BUILT. Capture a function's output into a value:let g = $(make_greeting Bankole). The function runs via the Call machinery under aCaptureCallframe, with its body output routed to a bounded 512 B buffer (it lives in the executor frame for the whole run, so it stays small to coexist with the pipe path - a captured value is a name, a number, a short line); the trimmed buffer becomes the value. One capture at a time (a nested$(fn)is refused loudly). No heap- scratch space, filled then dropped. (
$( )also still captures a bare producer:$(read /f).)
- scratch space, filled then dropped. (
-
Function-valued conditions - BUILT.
if myfn args { … } [else { … }]andif !myfn { … }branch on the function's RESULT directly (Ok -> then, Err -> else), instead of calling it as a statement then checkingresult. The function runs via the executor's Call machinery under anIfCallframe; a comparisonelse ifafter a function-if still works. This one needs no capture buffer - it branches on the result, not the output. -
Stream loops - BUILT for byte-line producers.
for line in (producer) { … }captures the producer's output and iterates its lines:for line in (read /f) { echo "> $line" }. The producer must be a bare producer (read,date,tree, a producer service, …); a pipeline(a | b)is not captured (bounded stack) - stage it to a file first. Bounded to a 16 KiB capture; loud if larger. The record-row form (for row in (pipeline) { $row.col }) is still deferred: it needs a capturable pipeline and$row.<col>field access.For the record-row case, the workaround stands: materialize-then-pipe - write the pipeline to a file, then
for line in (read <file>) { … }.
Secret handling - input secret today is a guard rail that only blocks console echo (not a
vault - once a value may be written to a file it can be read back). Deferred until a real consumer
exists to justify more (§26.2):
sealedenforcement -input secret sealedwould additionally refuse write + assign of the secret, not just echo. A reserved keyword today, currently a no-op.- A secret consumer - e.g.
auth/secret-eq: something that compares a secret without exposing it. This is the use that would makesealedworth enforcing.
Automation input - input pipe-else-prompt: input reads from a connected pipe if one is
present, else prompts - so the same script can run interactively or be fed non-interactively.
12. Worked example
# a helper: its mutable inputs are explicit params (immutable globals it could read)
fn ensure_dir path {
if !dir $path {
mkdir $path
if result == Err {
fail "could not create $path"
}
}
}
# provision once, then report - re-runnable
ensure_dir /sc
for row in (roster | where seat > 0) {
echo "$row.name is a $row.role at seat $row.seat"
}
greet | count | write /t.txt
let lines = $(read /t.txt)
echo "greet emitted $lines"
13. Example programs
Complete scripts - the kind the language is for, using only Tier 1-2 features. The bar for each milestone is "these run."
13.1 setup.gsh
Provision a workspace; fail loudly if it can't.
fn ensure_dir path {
if !dir $path {
mkdir $path
if result == Err {
fail "cannot create $path"
}
echo "created $path"
}
}
ensure_dir /work
ensure_dir /work/logs
if !read /work/config {
write /work/config """
mode = normal
cores = 4
"""
echo "seeded default config"
}
echo "workspace ready"
13.2 greet.gsh
Parameters, usage check, and switch.
# run greet.gsh <name> <role>
if $argcount < 2 {
fail "usage: run greet.gsh <name> <role>"
}
let name = $arg1
let role = $arg2
if $role in core worker courier {
switch $role {
core { echo "$name runs the core" }
worker courier { echo "$name is a $role" }
}
} else {
fail "$name has an unknown role: $role"
}
13.3 report.gsh
Iterate and aggregate a record stream.
echo "running services by core:"
for row in (status | where state=Running) {
echo " $row.name core=$row.core queue=$row.queue"
}
status | where state=Running | count | write /t.txt # row count (record-aware)
let running = $(read /t.txt)
status | sum mem | write /t.txt # reduce a column
let memuse = $(read /t.txt)
echo "$running running, $memuse KiB in use"
13.4 check.gsh
Test helper (functions + assert) with defer cleanup.
let DIR = /check # immutable
mkdir $DIR
defer delete $DIR recursive # cleaned up however we leave - even on fail
# a helper returns its last statement's result - here, the assert's verdict
fn roundtrip path text {
write $path $text
read $path | assert contains $text
}
roundtrip $DIR/a hello
roundtrip $DIR/b "two words"
# negative case: the file must NOT exist after delete
delete $DIR/a
if read $DIR/a {
fail "delete did not remove $DIR/a" # defer still runs DIR cleanup
}
echo "all checks passed"
13.5 retry.gsh
loop, break, and a bounded counter.
let mut tries = 0
loop {
if read /work/ready {
echo "ready after $tries retries"
break
}
if $tries >= 3 {
fail "never became ready"
}
tries = $tries + 1
}