Skip to content

Releases: fizzexual/Sprout

v0.1.4 — faster tree-walker

16 Jun 12:33

Choose a tag to compare

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. repeat allocated a fresh environment every iteration to scope the body's makes — even when there are none. Now a make-free body runs straight in the parent scope. Closures, nested makes, and stop/skip behave 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

15 Jun 20:26

Choose a tag to compare

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 new GC_STR and never takes ownership, so it can never free borrowed/AST memory; vstr_take() copies a malloc-owned buffer and frees the original.
  • gc_push_value marks strings; the sweep frees an unmarked GC_STR like any leaf object.
  • Strings owned by map keys, environment names, and the module tables stay plain malloc on 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/replace churn) 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.md now 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

15 Jun 18:35

Choose a tag to compare

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 like read/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=1 on every CI job.

A hardened Docker playground (playground/)

  • Dockerfile — multi-stage (gcc build → debian-slim runtime; no compiler, no curl), runs as non-root uid 10001, with SPROUT_SANDBOX=1 baked 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)

15 Jun 17:34

Choose a tag to compare

--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:

  • filesystemread, write, append, exists
  • on-disk storeremember, recall, forget
  • network (also blocks SSRF to internal/metadata endpoints) — get, explore
  • shell — the whole system module (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 ❄️

13 Jun 11:20

Choose a tag to compare

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, and stats.
  • 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))

12 Jun 21:10

Choose a tag to compare

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

12 Jun 20:23

Choose a tag to compare

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

12 Jun 19:41

Choose a tag to compare

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 match destructuring patterns (is [a, b,]:) and show.
  • 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

12 Jun 19:09

Choose a tag to compare

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 |> double is double(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

12 Jun 18:53

Choose a tag to compare

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, even is [1, 2]), matched with ==.
  • List-destructureis [a, b] matches a list of exactly that length and binds each item to a name (is [] matches the empty list).
  • Map-destructureis {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.