Releases: fizzexual/Sprout
v0.1.4 — faster tree-walker
Sprout v0.1.4 makes the tree-walker faster by cutting two allocations it was making for nothing — found by the benchmark suite. Both are pure internal speedups: identical semantics, no language change, the freeze holds.
Pay only for the scopes you use
- Repeat loops skip the per-turn scope when the body makes nothing.
repeatallocated a fresh environment every iteration to scope the body'smakes — even when there are none. Now a make-free body runs straight in the parent scope. Closures, nestedmakes, andstop/skipbehave exactly as before (an empty scope is invisible).loop: ~800 → ~165 ms (~5×);list_build: ~160 → ~90 ms. - Environments borrow variable names instead of copying them. Every definition
strdup'd its name — including a recursive call binding its parameters on every call. Those names are permanent AST text, so there's nothing to copy. Lighter GC pressure everywhere;fib: ~1960 → ~1790 ms.
Verified
The full suite + examples pass normally and under SPROUT_GC_STRESS=1 (collect on every statement), plus a new tests/loop_scope.sprout covering the edge cases the elision touches. AddressSanitizer + GC-stress green in CI on Linux.
This keeps Sprout a simple tree-walking interpreter — no second execution engine to maintain. A bytecode VM remains a deliberate, separate call, since that simplicity is a design goal.
Windows installer: SproutSetup.exe below.
v0.1.3 — string garbage collection
Sprout v0.1.3 closes the last big memory-model gap: heap strings are now garbage-collected too.
The conservative mark-sweep GC (v0.1.0) already reclaimed lists, maps, environments, and closures — but strings still leaked (a deliberate, safe first step). This was the planned next slice, and it's done: long-running programs now stay bounded for real.
How it works (no language change — the freeze holds; this is invisible)
- Every
Value's text is now a GC-owned copy:vstr()copies its argument into a newGC_STRand never takes ownership, so it can never free borrowed/AST memory;vstr_take()copies a malloc-owned buffer and frees the original. gc_push_valuemarks strings; the sweep frees an unmarkedGC_STRlike any leaf object.- Strings owned by map keys, environment names, and the module tables stay plain
mallocon purpose — they live where the conservative stack scan never reaches, so the GC must not touch them.
Validated
- Full suite + 12 examples pass normally and under
SPROUT_GC_STRESS=1(collect on every statement — any wrongly-freed live string corrupts a result). - A new string torture test (concat loops, strings as map keys + values, a closure capturing a string, heavy
upper/split/join/replacechurn) stays intact under stress. - 3,000,000 throwaway strings now peak at ~37 MB instead of leaking to hundreds.
- AddressSanitizer + GC-stress green in CI on Linux; an adversarial memory-safety review (ownership, root-completeness, error-unwinding) found nothing.
Also
- Corrected stale docs: the README and
gc-design.mdnow reflect a fully collected runtime, and the "no closures yet" note is fixed (closures shipped in v0.0.24).
Windows installer: SproutSetup.exe below.
v0.1.2 — hardened Docker playground
Sprout v0.1.2 makes the frozen language safe to host as an online playground that runs strangers' code — and ships the container to do it.
Sandbox hardening
use <module>is now blocked in sandbox mode. It loads and runs another file from disk, and being a statement (not a builtin) it slipped past the block list — untrusted code could have reached the filesystem through it. Now refused likeread/write/system/get. Normal module loading outside the sandbox is unchanged.- The sandbox self-test probe now covers 11 dangerous ops, run under
SPROUT_SANDBOX=1on every CI job.
A hardened Docker playground (playground/)
- Dockerfile — multi-stage (gcc build →
debian-slimruntime; no compiler, no curl), runs as non-root uid 10001, withSPROUT_SANDBOX=1baked into the image so the block holds even if the entrypoint is bypassed. - run.sh — runs one untrusted submission, sandboxed and resource-limited (wall-clock + CPU timeout, output cap, vmem/file/proc
ulimits). - README.md — the full hardened
docker run(network none, read-only rootfs, tmpfs,--cap-drop ALL, no-new-privileges, pids/mem/cpu caps), each layer explained, plus how to wire it into a backend. States plainly: the flag is necessary but not sufficient — the host still caps CPU/mem at the OS level. - A CI playground job builds the image and asserts safe code runs while file/shell/network are blocked.
Verified
An adversarial security review (3 lenses — container escape, runner robustness, sandbox completeness; 23 candidate findings) confirmed zero real issues for the actual threat model (untrusted code on stdin): every proposed "escape" required controlling the docker run command line, which the host owns. The review independently re-verified the sandbox, including the use fix.
All CI green: build & test on Linux/macOS/Windows, AddressSanitizer + GC-stress, and the new Docker playground image.
Windows installer: SproutSetup.exe below.
Sprout v0.1.1 — --sandbox (run untrusted code safely)
--sandbox — run untrusted code safely.
Hosting an online playground where strangers run Sprout means their programs run with your server's privileges. v0.1.1 adds a one-flag lockdown so a submission can't touch anything outside itself.
Pass --sandbox (anywhere on the command line) or set SPROUT_SANDBOX=1:
sprout --sandbox run untrusted.sprout
It turns off every builtin that reaches outside the program:
- filesystem —
read,write,append,exists - on-disk store —
remember,recall,forget - network (also blocks SSRF to internal/metadata endpoints) —
get,explore - shell — the whole
systemmodule (system.run)
Each blocked call is a clear, catchable error ('read' is turned off in sandbox mode…). Everything else — math, text, lists, maps, tasks, match, the pipe, comprehensions, the garbage collector — works exactly as normal.
The frozen language is unchanged; this only restricts the host-facing builtins, so it's a clean additive point release on the 0.1 line. CI now verifies (on every job, including AddressSanitizer) that all ten dangerous operations are blocked under the flag.
Important: the flag is necessary but not sufficient. It closes the language's outward APIs, but a host must still cap CPU time, memory, and output at the OS/container level — run each submission as a short-lived, unprivileged, resource-limited process. (The GC keeps memory bounded per program, not small.)
CI green on Linux, macOS, and Windows + ASan. Windows installer attached.
Sprout v0.1.0 — the freeze ❄️
Sprout v0.1.0 — the freeze ❄️
The first milestone meant to hold. After a long base-completion cycle — lambdas + closures, ranges + comprehensions, pattern matching, the pipe operator, multi-line literals, a real examples gallery, an O(1) map — the core now stops moving. Libraries and real programs can build on it without the ground shifting.
The headline: a garbage collector
Sprout's biggest known weakness — it leaked everything until the process exited — is gone.
A conservative mark-sweep GC now reclaims lists, maps, environments, and closures. A loop that used to leak gigabytes now runs in bounded memory; a REPL session or a long-running program stays healthy.
- Safe by design — conservative root scanning means the collector cannot free a live object, even at the cost of holding a little extra.
- Collects cycles — a self-referential map (
m["self"] = m) or a self-capturing closure is reclaimed correctly. - Handles any depth — marking uses an explicit worklist, not recursion, so even a 200,000-deep nested structure collects without trouble.
- Proven — the entire test suite + all 11 examples pass not just normally but under stress mode (collect on every statement, so any missing root would instantly crash). CI runs the whole suite under AddressSanitizer + stress together on every push.
Strings aren't collected yet (a deliberate, safe first step) — that's the next memory slice. See docs/gc-design.md.
Also in the freeze
- Benchmarks (
benchmarks/) — a reproducible suite that drove the v0.0.30 map hash index (O(n²) → O(1)) and now documents the GC's cost honestly (~1.5× on call-heavy code that now collects instead of leaking; near-zero elsewhere). - More real programs — the gallery grows to 11, including
calc(a recursive-descent calculator with operator precedence and parentheses),budget, andstats. - Honest docs — the README, roadmap, and design notes all updated to describe the collector.
CI green on Linux, macOS, and Windows, plus the ASan + stress job. Windows installer attached.
What's next (post-0.1, and it won't change the frozen core's meaning): string collection, a web kind, a package manager, tooling.
Sprout v0.0.30 — maps get a hash index (O(n^2) -> O(n))
Maps are now O(1) — the first benchmark-driven optimization.
The new benchmark suite (added this cycle) flagged maps as O(n²): every m[key] walked the keys, so building a large map got quadratically slow. Fixed.
SMap now keeps a hash index beside its ordered keys[]/vals[] arrays, so lookups are O(1) average — with identical semantics:
- insertion-order iteration preserved (
for each,keys(),values(), JSON,copy) remove-then-re-add still puts the key at the back- only lookups got faster
map_insert 20k: ~780 ms -> ~57 ms (14×)
map_insert 100k: ~18 s -> ~110 ms (now linear)
This is polish, not expansion — no new syntax, no behavior change, just a faster core data structure where the data said it mattered.
Part of a maturation push toward v0.1
- A small benchmark suite (
benchmarks/) with a documented baseline — so optimizations are driven by data and regressions get caught. - A garbage-collector design (
docs/gc-design.md) — the plan to solve the memory model safely (conservative mark-sweep; correctness over speed). - An AddressSanitizer CI job — proves the interpreter is memory-clean today, and is the safety net the GC will be built against.
Reviewed by an adversarial pass (which caught one latent invariant gap, now fixed); the Linux ASan job covers memory safety. CI green on Linux, macOS, Windows. Windows installer attached.
Sprout v0.0.29 — examples gallery + sort_by
A deliberate change of gear: proving the language, not growing it.
After five straight feature releases, a founder-style review gave the most important advice in the project: "You have enough language. Start proving it by building things. Every awkward pattern found building real projects is worth more than ten new language features."
So v0.0.29 adds no new syntax. It proves Sprout by building real programs with it — and fixes the one thing that genuinely got in the way.
🌱 An examples gallery — real, runnable programs
A new examples/ folder, each file self-contained and commented:
| Example | Builds |
|---|---|
fizzbuzz |
the classic |
leaderboard |
rank records by score |
wordcount |
most-common words in text |
units |
temperature / distance converter |
bank |
account ledger with overdraft errors |
roman |
number → Roman numerals |
rpn |
reverse-Polish calculator (3 4 + 5 *) |
todo |
a to-do list saved to disk |
All eight are now wired into CI, so they can't silently break.
The one earned fix: sort_by(list, task)
Building the leaderboard and word-counter immediately hit a wall — sort only handles a flat list of numbers/text, so "rank these records by a field" was impossible. sort_by orders a list by the value a task returns for each item (stable, in place; reverse() for descending):
make ranked = reverse(sort_by(players, task(p): p["score"]))
That's the whole code change. The other friction found (column padding, fixed-decimal formatting) was deliberately left out — it's workaround-able, and resisting it is the point.
Full suite + all examples green on Linux, macOS, and Windows. Windows installer attached.
Sprout v0.0.28 — multi-line literals
Multi-line lists, maps, and calls — write data the natural way.
Collections and call arguments can now span as many lines as you like, with an optional trailing comma, so each item gets its own line and reorders cleanly:
make people = [
{name: "Ada", age: 36},
{name: "Mo", age: 17},
]
make config = {
host: "localhost",
port: 8080,
tags: ["a", "b", "c"],
}
- Newlines and indentation are ignored inside
( ),[ ],{ }(Python-style implicit line joining), implemented at the lexer so it uniformly covers lists, maps, and call argument lists. - Trailing commas are allowed in lists, maps, calls — and (this release) in
matchdestructuring patterns (is [a, b,]:) andshow. - Text literals still stay on one line (join with
\n). A multi-step lambda inside a bracketed literal must use a one-line body — give it a name first for a multi-step block (Sprout tells you if you hit this).
Hardened by review
A 2-lens adversarial review (+ verification) stress-tested the load-bearing indentation change: it flagged 5 "critical" lexer bugs — all five rejected on verification (unmatched/unclosed/mismatched brackets all give clean, friendly errors; the depth counter is correctly reset and clamped). The 3 real findings — all trailing-comma consistency gaps — were fixed.
28-check test suite; CI green on Linux, macOS, and Windows. Windows installer attached.
Sprout v0.0.27 — the pipe operator
The pipe operator |> — data flows left to right.
The last of four beloved features. x |> f is just f(x), and x |> f(a) is f(x, a) — the left value threads in as the first argument. It's left-associative, so a pipeline reads top to bottom instead of inside-out:
nums |> filter(task(n): n % 2 == 0) |> map(task(n): n * 10) |> sum
# == sum(map(filter(nums, …even…), …×10…)) -> 120
- The right side is a task or a call: a name (
|> double), a call with more arguments (|> add(2)), or a module call (|> server.handle(req)). - Binds looser than arithmetic (
2 + 3 |> doubleisdouble(5)) and tighter than comparisons. - Desugars to an ordinary call at parse time, so it reuses every existing call path (arity checks, task lookup, module calls) — nothing new at runtime, and the left side is evaluated exactly once.
It pairs beautifully with the rest of the batch — lambdas, ranges, and comprehensions all pipe cleanly ((1 to 5) |> map(double) |> sum).
A 2-lens adversarial review (+ verification) found zero issues. 18-check test suite; CI green on Linux, macOS, and Windows. Windows installer attached.
This completes the four-feature batch 🎉
- v0.0.24 — Lambdas + closures
- v0.0.25 — Ranges + comprehensions
- v0.0.26 — Pattern matching
- v0.0.27 — Pipe operator
Sprout v0.0.26 — pattern matching
Pattern matching — clear multi-way dispatch, with destructuring.
match value: checks a value against is arms in order and runs the first that fits — comparing it or pulling a list/map apart — plus an optional otherwise.
match command:
is "start": show "go" # a value, compared with ==
is "stop": show "halt"
is [a, b]: show a + " & " + b # a 2-item list: binds a, b
is {name, age}: # a map with those keys: binds name, age
show name + " is " + age
otherwise: show "no idea"
Three kinds of pattern
- Value — any expression (
is 0,is "x",is yes,is nothing, evenis [1, 2]), matched with==. - List-destructure —
is [a, b]matches a list of exactly that length and binds each item to a name (is []matches the empty list). - Map-destructure —
is {name, age}matches a map that has those keys (a superset is fine) and binds each to a same-named variable (is {}matches only an empty map).
The rule of thumb: bare names in [ ]/{ } destructure; anything else ([1, 2], {a: 1}) is a value compared with ==. Bindings are local to their arm; give/stop/skip flow through arm bodies; if nothing matches and there's no otherwise, the match does nothing.
Hardened by review
A 3-lens adversarial review (+ verification, 28 agents) caught a real bug before release: is {}: was matching any non-empty map (and shadowing later arms) — now it matches only an empty map, symmetric with is []:. 24 over-flags correctly rejected.
33-check test suite; CI green on Linux, macOS, and Windows. Windows installer attached.
Next: the pipe operator — the last of the four.