Releases: cuffscript/cuffscript
Release list
v3.2.0
Description
-
New: several libraries in one
usestatement.use DLC:math, DLC:string, DLC:convertloads all three; oneuseper line still works and gives the same result. Every item in the list needs its ownDLC:prefix (use DLC:math, stringis a syntax error). A dangling comma and a forgotten comma (use DLC:math DLC:string) each get a specific message instead of a confusingunexpected token ':'. An unknown library in the middle of a list (E5-004) is pointed at directly.SPEC.mdstill requires a module-load command to fit on one line, so a list cannot be continued onto the next line.UseStmtnow holds a list of{name, loc}for the DLC form. -
New: typed function parameters.
set func add_num(number n1, number n2) do:. Each parameter isnameortype name, withnumber,str,boolean,list,mapormatch. Typed and untyped parameters can be mixed. Passing a value of another type is a runtime error,ParameterTypeMismatch(E4-030), reported at the call site. Forasyncfunctions the check runs before the call is queued, so the error points at the call.emptyis accepted by every type, the same rule asset. A type keyword counts as a type only when a name follows it, so(number)and(number, x)remain valid untyped parameters. Functions with no typed parameter skip the check entirely. -
New: an interactive CuffScript shell,
cuffsh. Implementedcuffshto allow users to execute scripts in a REPL environment. Added command-line options for script execution, timeout settings, and network configurations. Created a fuzz testing script to identify potential crashes and runtime errors in the shell. Developed a harness for testing terminal interactions, including escape sequences and cursor movements. Implemented comprehensive test cases for multi-line editing, error handling, and input/output behavior, and added a shell script to run the test suite with dependencies. -
Changed: error codes print as
E<category>-<number>.E4005is nowE4-005,E2001isE2-001, and so on. The enum values in C++ are unchanged; only the printed tag is split, byerrorCodeTag(). The dash keeps a tag unambiguous if a category ever needs two digits (E10-001). Capacity within a category is still 999 numbers. 158 test/example files (.expected_codefiles andnote: E####headers) and all current docs were converted. Entries in this changelog from earlier releases keep the old format, as a record of what was printed then. -
Fixed: errors inside an f-string
{...}pointed at the wrong source line. The expression is tokenized separately, so its positions started over at line 1, column 1, and the source snippet added in the previous release then displayed line 1 of the file for any error inside an f-string. Locations for tokens in the fragment, and for any error thrown while tokenizing or parsing it, are now pinned to the f-string literal, so the snippet shows the line the f-string is on. The caret sits at the start of the literal, not at the exact column inside the braces. Two cases added totests/unit/error_snippet_test.cpp. -
Fixed:
tests/run.ps1did not work on Windows PowerShell 5.1. It usedProcessStartInfo.ArgumentList, which does not exist on .NET Framework, so every test failed with a null-method error. Rewritten, and these were fixed on the way: output is decoded as UTF-8 (the OEM code page garbled Korean output and failed every diff);\r\nis normalized before comparing; stdin is closed soinput()sees EOF instead of waiting for the timeout; g++ runs through the same helper rather than& g++ 2>, since under$ErrorActionPreference='Stop'PowerShell 5.1 turns any compiler warning on stderr into a terminating error, and the build timeout is 600 s instead of 10 s; unit tests link with-lws2_32and an 8 MiB stack on Windows, asMakefile.windoes; the binary iscuffc.exeon Windows and./cuffcelsewhere, so it also runs under PowerShell 7 on Linux/macOS. Not executed on a real Windows machine (none available here). -
Release binary is now stripped.
-sis added toMakefileandMakefile.win: 1.18 MB → 670 KB with no change in speed. It is skipped on macOS, where Apple's linker ignores the flag with a warning. -
Performance: measured, nothing else worth changing. Against the original engine:
fib(30), a 3M-iteration loop, 1M user-function calls, string/list/map work, f-strings and a 66k-line script are all at parity (differences inside run-to-run noise). The only profiler available wasgprof, and its profile was flat (evalExpr,evalBinaryOp, scope lookup). Tried and not adopted: profile-guided optimization (0–4%, inside noise, needs a two-step build),-O2instead of-O3(same), staticlibstdc++(startup 2.2 → 1.7 ms but a 2.4× larger binary), and dropping-fstack-protector/_FORTIFY_SOURCE(about 0–8%, not worth weakening a sandboxing interpreter). A micro-suite (200k-element sort,list_unique,splitinto 150k parts,replace/find/countover 850 KB, a 50k-object JSON round trip, a 50k-key map) found no quadratic behavior. -
Comments in
engine/cut from 1,534 lines to 92. Of 657 comment blocks, about 90 short ones remain: lifetime and ordering invariants, platform quirks (windef.hmacros, Emscripten's shadow stack,SIGPIPE,pthread_get_stackaddr_np), stack-safety limits, the reason behind non-obvious checks, and the publicCuffEngine::Optionsfields. Longer explanations are indocs/IMPLEMENTATION_NOTES.md. Checked mechanically: a string/char-literal-aware scanner found the comments; the preprocessed output ofmain.cppis identical before and after once whitespace is ignored; every string and char literal inengine/is byte-identical; build and suite pass unchanged.main.cpp,wasm/bindings.cppandtests/were not touched. -
Docs.
SPEC.md: typed parameters (§9), comma-separateduse(§13), new §14 on the error-code format and categories.README.md/README_ko.md: thepureparagraph still said the restriction covers only the function's own body, which was wrong since the call-graph hardening in the previous release, and is now corrected; typed parameters and theuselist are documented; the error example shows the caret and the new tag format; the directory tree now listsdlc/andnet/, which were missing.docs/IMPLEMENTATION_NOTES.md: new §35–39, plus the tag format in §20 and the f-string fix in §29.docs/EXTENDING.md: new §12–14 (parameter syntax,usesyntax, comment policy).SECURITY.md,tests/README.mdandexamples/README.mdupdated;examples/08_dlc_libraries.cuffshows bothuseforms. -
Tests: 8 new checks (
typed_parameters,use_multiple_dlcs, and the error casesparameter_type_mismatch,parameter_type_mismatch_async,use_multiple_unknown_dlc,use_multiple_missing_prefix,use_multiple_missing_comma,use_multiple_dangling_comma), and the f-string cases above. The suite is 124 checks, up from 116. -
Verification. Clean build, 124/124 passing. Also run clean: AddressSanitizer + UBSan over the whole suite, with a leak check on the new-feature scripts; libstdc++ debug mode (
_GLIBCXX_DEBUG);-pedantic -Wall -Wextra -Werror; each header underengine/dlc,engine/interpreter,engine/parserandengine/commoncompiled standalone. The one known LeakSanitizer finding (the intentionally circular lists invalue_semantics.cuff) is the same pre-existing one. Not done: nothing here was compiled or run on Windows or macOS, since no such toolchain exists in this environment. The new engine code is standard C++17 with no platform-specific API.run.ps1is reviewed by hand only.
v3.1.0
Description
-
Removed: closures / nested function values. The nested-function-as-closure feature added in the previous release has been reverted. Implementing capture the only way that didn't require rewriting
Environment's stack-allocated lifetime model — by value, once, at definition time — meant a closure over a scalar never accumulates state across separate calls (the textbook "counter closure" returning 1, 2, 3, ... on successive calls did not work; every call returned the same first value), while a closure over a list/map behaved as expected (shared by reference, like an ordinary argument). That asymmetry was judged too confusing for this language's intended audience to justify keeping a half-correct version of the feature.set funcinside another function's body is once again a hard error (NestedFunctionNotSupported,E4018), exactly as before closures were introduced.ValueType::Function/Closure(Value.h),Environment::collectCapturable(),callClosure/findClosure(Interpreter.h), and theset func NAME to EXPRclosure-typed-variable syntax are all gone. No other feature from the same release (pure-function hardening, thechange x to globalfix, reserved words as identifiers, the caret marker,DLC:filesystem, the DLC naming convention, or theloop matchremoval) is affected. -
Async: two real bugs fixed, two behaviors locked in with tests — no architectural change. Two error-hint messages quoted a keyword that doesn't exist in this language:
'set async function NAME(...) do:'and'set returnable function'both saidfunctionwhere the actual declaration keyword isfunc(throwAwaitOnNonAsync,throwReturnInVoidFunction). Fixed. Separately, two behaviors that were already correct but had no regression test now do: a queued task that itself queues another queued task (AqueuesBqueuesC) still drains in the order queued (tests/cases/async_self_queue_chain.cuff), and an error thrown by a queued task still stops the drain the same way an error mid-script stops synchronous execution — tasks queued after the failing one don't run (tests/errors/async_error_stops_queue.cuff). The cooperative, non-concurrent scheduling model itself (documented in the previous release) is unchanged. -
engine/interpreter/NativeFunctions.hsplit intoengine/dlc/. The single 1,748-line file holding every library's implementation is now one file per library —MathDLC.h,StringDLC.h,TimeDLC.h,RandomDLC.h,ListDLC.h,MapDLC.h,ConvertDLC.h,NetworkDLC.h,FilesystemDLC.h,JsonDLC.h— plusDLCCommon.hfor what several of them share (theexpectArgCount/expectNumber/expectStr/expectList/expectMapargument-checking helpers, and thetextutilUTF-8 namespace).NativeFunctions.his now ~100 lines: the always-on core builtins (print/input/type_of) andregisterDLC(), the dispatcher everyuse DLC:namecalls into. Every new file was verified to compile standalone (one#include+ an emptymain()each) rather than silently depending on include order. Pure reorganization — no declaration changed namespace, name, or behavior; full suite passes unchanged before and after. -
New:
tests/run.ps1. A PowerShell equivalent oftests/run.shfor running the suite on Windows without WSL or Git Bash — same section headers, same pass/fail summary line, same exit-code convention. Not executed against a real Windows/PowerShell environment during development (none available); reviewed by hand instead, same caveat as the platform notes in the previous release. -
Tests: 2 new cases (
async_self_queue_chain, and the error caseasync_error_stops_queue); 4 removed (closures,closure_pure_leak,closure_call_non_function,nested_async_not_supported— all specific to the now-reverted closure feature, including the one-offNestedAsyncFunctionNotSupportederror code, which reverts back to the originalNestedFunctionNotSupportedunder the same number,E4018). Net: 116 checks (previous release's 119, minus 5 closure-only checks — one of the four removed files had covered more than one assertion — plus 2 new async ones). -
Verification. Full suite rebuilt clean and passes (116/116) after both the revert and the file split; re-checked under AddressSanitizer + UBSan with the same clean result as the previous release (the one pre-existing, documented exception — intentional circular-structure tests triggering LeakSanitizer — is unchanged and unaffected by anything in this release).
use DLC:math/use DLC:string/use DLC:converttogether, in one script, was specifically re-tested on a report that it might not work — it does, both before and after this release's changes, so nothing needed fixing there.
v3.0.0
Description
-
Pure-function hardening: the restriction now propagates through the call graph. Previously
pureonly checked a function's own body, soset pure func f() do: g() endwheregtouches a global was allowed —fnever touched a global directly.checkPureCallAllowed()now runs at every call site and rejects a pure caller invoking a non-pure user-defined function or closure, with a new error,PureFunctionImpureCall(E4029). Native/DLC functions remain exempt (they can't reach a CuffScript global through this route at all). Behavior change:tests/cases/pure_functions.cuff's old "pure calling non-pure is fine" example no longer holds; it now lives intests/errors/pure_func_call_impure.cuffas anE4029case. -
Fixed: a real bug in
change x to global.Environment::resolve()checked whether a name was bridged to global before checking for an actual local at each scope level. Once bridged, any later local redeclaration of the same name in that call — most visibly aloop repeat x to 1 ~ 3reusing a bridgedxas its own loop counter — became permanently invisible: every read silently returned the stale global instead of the loop's own value, with no error. Fixed by checking local-first everywhere, matching the "innermost declaration wins" rule the rest of the language already follows. Same bug also affected thepureglobal-access check, which could previously misreport a shadowed local as forbidden global access. -
Reserved words can now be used as identifiers.
add,count,find,split,replace,match,in,by,not,globalcan now be used as variable, function, parameter, and loop-variable names, and read back in expressions.match/find/replace/split/count(which have their own expression syntax) use one token of lookahead to tell "the construct" from "just a name" apart, without breaking the ability to call or index something with one of those names. -
New: caret (
^) marker in error messages. Errors now show the source line with a^under the exact column (codepoint-aligned, so multibyte UTF-8 text earlier on the line — Korean, emoji, ... — doesn't throw off the alignment). -
New:
DLC:filesystem. Real local file access:file_exist,file_size,file_read,file_readlines,file_write,file_add,file_remove. Every path is confined to the exact same sandbox rootuse ... frommodule imports already use — an absolute path or a../-escape is rejected with a new error,FilesystemAccessDenied(E5008), before touching the filesystem. Symlinks pointing outside the root are also rejected (verified by hand). New host controls, mirroringDLC:network's existing shape:CuffEngine::Options::filesystemEnabled/--no-filesystem. SeeSECURITY.md. -
DLC function naming convention:
library_verb. Every DLC function name now carries its library as a prefix —sqrt→math_sqrt,upper→str_upper,sort→list_sort,get/post→network_get/network_post, and so on — so a script can tell which library a call came from without cross-referencing everyuseline, and two libraries can no longer silently shadow each other's bare names in the shared native-function table. Exceptions:length/contains/index_of(intentionally polymorphic across str/list/map) andto_json/from_json/to_number/to_str/to_boolean(already self-describing). This is a breaking rename — every example, test, and doc in this repository was updated; external scripts need the same mechanical rename per function (full mapping indocs/IMPLEMENTATION_NOTES.md§9/§31). -
loop matchremoved. It was implemented identically toloop while(same condition-recheck-every-iteration code path, same parsing) — a complete, confusing duplicate with no functional difference and no use anywhere in this repository.loop while [condition] do: ... endcovers the same case.LoopStmt::LoopKindnow has justRepeat/While. -
New: nested functions / closures.
set funcinside another function's body no longer errors — it creates a closure: a first-class value that can be assigned to a variable (set func NAME to EXPR, a new type-keyword use), passed as an argument, returned, and called by name, including recursively. Capture is by value, once, at definition time — not shared upvalues: a captured number/str/boolean is independent from that point on (a closure does not accumulate state across separate calls — the classic "counter closure" pattern does not work here), while a captured list/map is still shared by reference, exactly like an ordinary function argument. A closure created inside apurefunction is automatically forced pure regardless of its ownpurekeyword, closing the same escape hatch the call-graph hardening above closes for named functions. Top-level function declarations are unaffected (still not values, still the fast named registry). Nestedasyncfunctions are explicitly rejected for now with a clear error (NestedAsyncFunctionNotSupported, reusing error codeE4018) rather than silently running wrong or dropping their capture. Calling something that isn't a function now says so specifically ('x' is a number, not a function) instead of a generic "undefined function". -
Async concurrency: reasoned decision not to add OS threads. Evaluated a full design (GIL-style lock + worker-thread pool, releasing the lock only around a queued task's blocking network I/O, with per-call-stack state made thread-local so concurrently-running tasks can't corrupt each other's bookkeeping) and chose not to ship it. Genuine concurrent execution needs either OS threads or turning the evaluator into a resumable state machine; the rewrite is out of scope, and the threading approach is a permanent complexity and correctness tax on every future change to the interpreter, for a feature (overlapping network I/O) most scripts will never exercise. No code changed for this item —
f()on anasyncfunction still queues it for after the top-level script's synchronous code finishes (strict FIFO order, self-queued tasks included);await f()still runs it immediately and synchronously. This is cooperative scheduling, not concurrency, and is now documented as such. -
Added error codes:
E4029(PureFunctionImpureCall),E5008(FilesystemAccessDenied). Reused (repurposed, previously unused after the closures change):E4018, nowNestedAsyncFunctionNotSupportedinstead of the old blanketNestedFunctionNotSupported. -
Fixed: a flaky test-runner bug, unrelated to the engine.
tests/run.sh's error-code check piped a captured error message intogrep -qunderbash -o pipefail;grep -qexits at its first match, which can make the upstreamechodie ofSIGPIPE— pipefail then reports the whole pipeline as failed even though the message matched. Measured at roughly 1 false failure in 1,500 runs on a ~450-byte message, worse on longer ones. Switched to a here-string (grep -q "..." <<<"$actual"), which has no pipe to race; confirmed at 0 failures in 6,000 stress-runs of the old failure pattern. -
Tests: 4 new script cases (
closures,dlc_filesystem,global_bridge_shadowing,reserved_words_as_identifiers), 7 new error cases, and a new C++ unit test file (error_snippet_test.cpp, 3 checks for the caret marker). The suite now has 119 checks (was 106). -
Verification. Full suite passes clean under AddressSanitizer + UBSan, with one caveat:
tests/cases/value_semantics.cuffintentionally builds circular list/map structures (add a to a), andshared_ptrreference cycles never reach a zero refcount, so LeakSanitizer correctly flags them — this is a pre-existing architectural tradeoff (predates this change; the same construct leaked before it too), not a new bug, and every other script in the suite is leak-clean including all new closure and filesystem code (closure self-recursion in particular was specifically checked, since it could easily have introduced its own reference cycle — it doesn't). Also run clean:-pedantic, and libstdc++'s debug-iterator mode (_GLIBCXX_DEBUG). One real, if minor, regression was caught and fixed during ASan verification: identifier-token handling inlined into the parser's hot recursive-descent path enlarged that function's stack frame enough to measurably reduce how deeply((((...))))-style nesting could go before the existing stack-depth guard trips (462 vs. 483 levels under ASan's larger frames) — moved into a dedicatednoinlinehelper, which recovered it (506 vs. 483, so no worse than before, if anything slightly better from the refactor). Windows: not cross-compiled this round (nomingw-w64toolchain available in this environment); the new code is all standard C++17 +<filesystem>(already an existing, working dependency in this codebase's module-loader), introduces no new platform-specific APIs, and follows the same patterns as the existing, already-Windows-tested code — reviewed by hand rather than compiled. macOS: not cross-compiled or reviewed against real Windows/macOS toolchains either — same caveat as prior releases' entries for this project.
v2.0.0
Description
-
Nesting depth ceiling raised to 512 (from 256, introduced in v1.6.0). Scoped specifically to source-level nesting — parentheses, list/map literals,
if/loop/or_elseblocks — not the string/collection/size limits from the previous release, which are unchanged. Raising the counter alone would have been unsafe on a small stack: measured empirically, 512 levels of nested parentheses can need over 1 MiB of native stack, more than Windows' 1 MiB default thread stack, since the parser recurses on the real C++ stack. The parser now also carries a stack-pointer safety net (primed fresh at the start of every parse, including a module's own parse) mirroring the interpreter's existing one, so it fails cleanly with the sameE2008instead of crashing on a small stack, on any platform.availableStackBytes()(the real-stack-size query this relies on) is now implemented on Windows and macOS as well as Linux, not just Linux — a general robustness improvement, not just a v1.7.0 side effect. -
New:
constant list— a read-only, Python-tuple-style list.set constant list NAME to [...]freezes a list: adding, removing, or index-assigning into it is a runtime error (ConstantReassignment), and — unlike a naive implementation — this can't be bypassed by assigning it to another variable or passing it into a function, since the immutability lives on the list value itself, not on one variable's name. Declaring one always makes an independent copy first, soset constant list A to existing_listfreezes onlyA, neverexisting_list. Matches Python tuples exactly in one respect worth knowing: it's shallow — a plain list or map found inside a frozen one is still fully mutable through its own reference. No new value type was added;listgained one internal flag.constantcontinues to require ALL-CAPS names for every type, as before;constant mapwas not added. -
Keyword:
function→func. A straight rename, not an alias —set function x() do:no longer parses; it'sset func x() do:now. Every script in this repository (examples, tests, docs) was updated; existing external scripts need the same one-word find-and-replace. -
New:
purefunctions — no global-scope access.set pure func f() do: ... end(freely combinable withreturnable/asyncin any order). Apurefunction's body cannot read or write a top-level global variable — including via thechange x to globalbridge — and fails immediately with a new error,PureFunctionGlobalAccess(E4027), that names the variable and reminds you removingpureis the fix. The restriction is on that function's own body only: apurefunction calling a non-pureone is fine, and the callee's own purity governs its own body, the same per-call scopingreturnableandasyncalready had. -
New:
DLC:network— real HTTP (not a stub anymore).get(url)andpost(url, body[, content_type]), returning{"status", "ok", "body"}. A from-scratch, dependency-free HTTP/1.1 client (POSIX sockets on Linux/macOS, Winsock2 on Windows) handling chunked encoding,Content-Lengthframing, and redirects. Plain HTTP only —https://fails with a clear error rather than silently talking plaintext to an HTTPS port. Connection/DNS/timeout failures raiseNetworkRequestFailed(E4028), catchable withor_elselike any other runtime error. Security-relevant defaults, please readSECURITY.md's new "DLC:network" section before deploying: network access is on by default (unlike the module sandbox); every request is checked against loopback/private/link-local addresses (including169.254.169.254, the common cloud-metadata address) and blocked unless explicitly widened. New:CuffEngine::Options::networkEnabled/--no-network(turnDLC:networkoff entirely — do this before hosting untrusted scripts) andallowPrivateNetworkTargets/--allow-private-network(widen the address check). Response size, connect timeout, total timeout, and redirect count are all capped (engine/common/Limits.h). -
Windows build fix (found via this release's new mingw-w64 cross-compile check — see Verification below).
<windows.h>#definesTRUE,FALSE, andINas plain macros, which was silently corruptingTokenType::TRUE/FALSE/INwherever the stack-introspection header was included first, breaking the Windows build outright. Fixed withWIN32_LEAN_AND_MEAN/NOMINMAXand targeted#undefs. This means the Windows build had not actually been verified against a real toolchain before now. -
Performance. Two changes, kept because both are measured improvements with no downside: dispatch in the interpreter's hot paths uses
std::get_ifinstead ofstd::get(skips exception-handling machinery the kind-check already makes unnecessary), and function calls now recycle their argument vector and the callee's local-variable storage across calls instead of allocating fresh each time. Net effect is modest — recursive/call-heavy code is about 7% faster; pure arithmetic loops (no allocations to begin with) are unchanged. In the interest of not overclaiming: two more aggressive ideas (non-atomic reference counting; broader allocation pooling) were built and benchmarked, then not kept, because on this toolchain (glibc 2.39) both turned out to duplicate optimizations glibc already does transparently (single-threadedshared_ptris already non-atomic in practice; the allocator's per-thread cache already does the pooling). A tree-walking evaluator's per-node cost was already close to its practical floor after v1.6.0; going further would mean the bytecode VM this project is deliberately deferring, not a tuning pass. -
Added error codes:
E4027(PureFunctionGlobalAccess),E4028(NetworkRequestFailed). -
Tests: 12 new checks added directly to
tests/unit/limits_test.cpp(the raised depth ceiling, both at and near the new ceiling), 2 new script cases (pure_functions,constant_list_tuple) and 10 new error cases (constant-list mutation via direct name/index/alias/argument,pureread/write violations, andDLC:network's SSRF/HTTPS/malformed-URL/disabled-network error paths).tests/run.shgained support for a per-test.argsfile (extra CLI flags), used by the network-disabled test. The suite now has 106 checks (was 94).DLC:network's success paths (GET, POST, redirects, chunked decoding, JSON round-trip) were verified manually against a local test server, including under AddressSanitizer + UBSan, but aren't part of the automated suite, which shouldn't depend on live network access. -
Verification. Full suite + a battery of hostile/stress scripts run clean under AddressSanitizer + UBSan (176 files, 0 findings), including the new socket-handling code. Windows: this release added a real
x86_64-w64-mingw32-g++cross-compile check (previously the Windows build was never actually compiled), which is how the macro-collision bug above was caught; the cross-compile is clean, but running the resulting binary could not be verified in this environment (no working Windows/Wine runtime available) — compilation only. macOS: not cross-compiled (no toolchain available here); the new platform-specific code follows the same POSIX APIs already used on Linux, with the two known Linux/macOS differences (SO_NOSIGPIPEvsMSG_NOSIGNAL, andpthread_get_stackaddr_np/pthread_get_stacksize_npvspthread_getattr_np) handled explicitly, but this is unverified by actual compilation or execution.
v1.6.0
Description
-
Fixed: deeply nested input could crash the process. Nested parentheses, lists, maps, unary chains (
----1,!!!!true),if/loop/or_elseblocks,awaitchains, index chains and f-string expressions recursed on the native stack with no bound, so a hostile (or generated) script killed the process with a segfault. Parser recursion is now counted across every sub-parser, including the nested parser used for f-string expressions, and fails with the newE2008beyond 256 levels. Left-associative chains that the parser builds iteratively (1+1+1+...,a[1][1]...) are bounded by an AST height computed as each node is built (10,000). Source over 16 MiB is rejected withE2009. A number literal too large for a double is now a cleanE1003instead ofInternal error: stod. -
Fixed: runtime stack exhaustion. The 1,000-call limit did not protect functions whose bodies nest blocks: 40 nested
ifs inside a function recursing 990 deep segfaulted. The evaluator now compares the real stack pointer against a budget derived from the actual stack size and raises the catchableE4017(the same code as the call-depth limit) before it can overflow. Regex matching honors a hard floor below that budget and fails withE3103instead of overflowing on small stacks. Programs that ran before still run: a 990-deep recursion with six nested blocks per call and 10M-element lists work as before. -
Fixed: circular and very deep values crashed.
add a to afollowed byprint(a),a is borto_json(a)recursed forever, and destroying a deeply nested list recursed once per level. Nested lists/maps are now destroyed iteratively;iscompares iteratively (any depth, and circular structures of the same shape compare equal); printing marks cycles as[...]/{...}and stops at depth 1,000;to_jsonreportsE4025instead of overflowing. -
Regex hardening. Group nesting is capped at 64 (
E3011), quantifier counts at 100,000 (E3004; a huge count used to escape asInternal error: stoi), patterns at 64 KiB, and the compile cache at 512 entries. Every find/replace/split/count now has an overall time allowance (5 s plus 2 s per MiB of input) on top of the existing per-attempt limits,replacecannot build a result beyond the string limit, andfind ... gno longer materializes capture groups for every match. -
Size limits. Strings are capped at 128 MiB and lists/maps at 32M items (
E4026), checked where they can grow (+, f-strings,join,repeat_str, padding,range,flatten,merge, regex results, the async task queue). Running out of memory anyway is reported asE6003instead of terminating the process. All limits are inengine/common/Limits.h. -
New opt-in execution budget.
--max-steps <n>(loop iterations + function calls) and--timeout <ms>, also available asCuffEngine::Options. Off by default. They raiseE6001/E6002, a new 6000 "Resource" range thator_elsedeliberately cannot catch. -
Module sandbox (behavior change).
use name from pathmay only load files inside the script's directory, or inside--root <dir>/Options::rootDir. Absolute paths and paths that resolve outside the root (.., or a symlink pointing out) fail withE5006; oversized or too deeply nested imports fail withE5007. Error messages show the path as written instead of the host's absolute path. A failed import no longer marks the module as loaded, and a module's AST now outlives a failure in its body, so functions it registered can no longer dangle. Scripts that import from a parent directory need--root. -
New built-ins.
type_of(always available).DLC:math:mod(floored),clamp,sign,trunc,log,log2,log10,exp,sin,cos,tan,asin,acos,atan,atan2,pi,e, andround(x, digits).DLC:string:trim_start,trim_end,index_of,repeat_str,pad_left,pad_right,char_code,from_char_code.DLC:list:sum,average,flatten,range.DLC:random:random_seed,choice,shuffle. NewDLC:map:keys,values,has_key,entries,merge(maps previously had no way to enumerate their keys).length,containsandindex_ofwork on strings, lists and maps.min/maxaccept a single list. -
Hardened built-ins (behavior changes).
sort,min,maxandclampreject NaN (sorting a list containing NaN was undefined behavior).powreports domain errors and results too large to represent (previouslyInfinity/NaN);log,asin,acosandmodreject out-of-domain input.to_numberaccepts only plain decimal text:"nan","inf", hex floats and trailing garbage are nowE4025. Whole-number arguments and indices are range-checked to +/-2^53 (an index like1e30used to be undefined behavior;random_intused a 32-bitlongunder WebAssembly).uniqueis O(n) for numbers and strings.upper/lowermap Latin, Greek and Cyrillic letters, not just ASCII.awaitnow resolves a name the same way a plain call does (user function first). -
Performance. Strings are now immutable and shared: reading a variable or passing a string never copies its text, literals are built once at parse time, and ASCII-ness, codepoint count and a codepoint cursor are cached. Reading a 1 MB string variable 20,000 times: 19.7 s → 6 ms. A 20,000-character
s[i]loop: 0.55 s → 12 ms. Declaring 20,000 globals: 3.8 s → ~0.1 s (the scope index is now maintained incrementally instead of rebuilt). Native and user functions are looked up by interned id, and cold error paths are out of line, which shrinks the stack used per call by about a quarter. Tokens are moved rather than copied between pipeline stages. Raw arithmetic speed (fib(27), tight loops) is unchanged; the bytecode VM remains deferred (see section 21 ofdocs/IMPLEMENTATION_NOTES.md). -
Added error codes:
E1003now covers oversized number literals; newE2008,E2009,E3011,E4026,E5006,E5007,E6001,E6002,E6003.main.cppno longer syncs iostreams with stdio. -
Tests: new
tests/unit/limits_test.cpp(45 checks: hostile nesting, cycles, size and step/time limits, module sandbox, and large legitimate inputs that must keep working), 6 new script cases (builtins_*,strings_utf8_access,value_semantics) and 21 new error cases. The suite now has 94 checks (was 66). Also run clean under AddressSanitizer + UBSan. -
Docs:
docs/IMPLEMENTATION_NOTES.mdsections 22 (limits and recursion safety), 23 (module sandbox) and 24 (built-ins and performance);SECURITY.mdgained a "Running untrusted scripts" section; READMEs list the new CLI options andDLC:map.Makefile.winnow links with an 8 MiB stack.
v1.5.0
Description
-
Operators are now enums instead of strings.
BinaryOp/UnaryOpstored the operator as astd::stringand the interpreter
dispatched through anif (op == "is") ... else if (op == "+")chain. Profiling showed this cost 16.2 million string comparisons on a 635k-call benchmark — about 25 per call, the largest single cost in the engine. Now aswitchoverBinOp/UnOpassigned at parse time.fib(27): 0.241s → 0.149s. -
Variable and function names are interned to integer IDs (
engine/common/NameInterner.h), so scope lookups compareuint32_ts
over a contiguous vector instead of strings. A 400-global lookup benchmark went 0.059s → 0.027s. Names are still stored alongside for error messages. -
Interpreter scope-storage optimization. Profiling showed parameter binding dominated call cost (~60ns per parameter, 72% of a
3-argument call) because each one was anunordered_mapinsert: a hash plus a node allocation. Scopes are small in practice, soEnvironmentnow stores variables in a contiguous vector with linear lookup — one allocation per scope instead of one per variable — falling back to a lazily-built index once a scope exceeds 16 entries, which keeps large global scopes fast. Measured: 0-argument call overhead 72ns → 32ns, 3-argument 252ns → 164ns. -
Decided against the larger slot-resolution refactor (pre-resolving every variable to an array index at parse time) and a bytecode VM
for now. The measurements above captured most of the available win for a fraction of the risk, and the engine is now in the same performance range as CPython on call-heavy code. Seedocs/IMPLEMENTATION_NOTES.mdsection 21 for details. -
Net effect of optimizations this release:
fib(27)0.32s → 0.140s (~56% faster), a 2M-iteration loop 0.287s → 0.173s, 3-argument call
overhead 252ns → ~145ns. -
Added
DLC:json(to_json,from_json). Object/array/string/number/true/false/null map onto map/list/str/number/boolean/empty.
to_json(value, indent)pretty-prints. The parser is strict per RFC 8259 — trailing commas, single quotes, unquoted keys, leading zeros,NaN/Infinity, and trailing content are rejected — and decodes\uXXXXescapes including surrogate pairs. Seedocs/IMPLEMENTATION_NOTES.mdsection 19. -
Error-code audit and normalization.
ArgumentError(E4010ArgumentCountMismatch) was being used both for wrong argument
counts and for bad argument values (sqrt(-1),to_number("abc"), malformed JSON). AddedValueError/InvalidArgumentValue(E4025) and rerouted the six value-problem sites to it. Also normalized message capitalization across every throw site: all messages now start lowercase, since they're always printed after a...at line N, column M:prefix. -
The regex matcher is now codepoint-based instead of byte-based.
[any]matches one character rather than one byte
("안녕하세요" is "[any]5"is now true, previously false); literal non-ASCII characters in a pattern compile to a single multi-byte literal node; negated sets ([!num]) match non-ASCII codepoints;search()only starts attempts on codepoint boundaries and[one:...]alternatives must end on one.[edge]treats non-ASCII letters as word characters, per REGEX.md section 17.[let]/[str]/[word]/[num]/[hex]stay ASCII-only per spec. Seedocs/IMPLEMENTATION_NOTES.mdsection 17. -
Fractional indices and range bounds are now a runtime error (
FractionalIndex, E4024) instead of being silently rounded. Applies
tolist[i],str[i], slices, andloop repeatbounds. Whole-valued doubles (2.0,6 / 2) still work. -
Moved
Utf8.hfromengine/interpreter/toengine/common/,
since the regex engine now uses it too. -
Added
tests/unit/for standalone C++ unit tests, and moved the regex engine's own test suite there (78 cases, including new Unicode ones).
tests/run.shnow builds and runs it first. -
Expanded integration tests: added
tests/cases/json.cuff(plus 7 error cases),tests/cases/regex_unicode.cuff,
tests/cases/whole_number_indices.cuff, and 3 fractional-index error cases. All 66 script tests and 78 regex unit tests pass unchanged.
v1.4.0
Description
- Added an automated regression test suite (
tests/,bash tests/run.sh):tests/cases/
(exact output diff),tests/errors/(must fail with a specific error code), plus the
existingexamples//examples/error_cases/are now checked the same way instead of only
by hand. Wired into CI (.github/workflows/build-and-test.yaml). - Fixed a real crash (SIGSEGV) in the regex matcher: a pattern with deep linear recursion
(e.g.[any]+against a ~20k+ character string) overflowed the real C++ stack before the
matcher's own recursion-depth guard could throw its safety exception — the guard's limit
(20,000) was measured, empirically, to be higher than where the actual crash occurs on an
8MB stack (~19,500). Lowered the default depth limit to 3,000 (a large margin below the
observed crash point) and verified no crash from 100 to 1,000,000 characters. Found by the
new test suite. - Found and documented (not yet fixed) that names using certain keywords —
add,count,
find,split,replace,match,in,by,not,global, etc. — can't be used as
variable or function names anywhere (set number add to 5fails to parse). Same root cause
as theDLC:listimport-parsing bug fixed earlier, but spread across every place an
identifier is declared. Seedocs/IMPLEMENTATION_NOTES.mdsection 16. - Fixed
examples/06_error_recovery.cuff, which incorrectly demonstratedor_else"catching"
afindthat simply returnsemptyon no match (not an exception) — added the correct
is emptycheck alongside a genuine or_else-recoverable example (out-of-range index).
v1.3.0
Description
-
String indexing/slicing/
length()are now UTF-8 codepoint-based, not byte-based.
Previously"안녕하세요"[1]sliced into the middle of a multi-byte UTF-8 sequence and
produced corrupted output — any non-ASCII string indexing was broken. Fixed via a small
UTF-8 boundary scanner (engine/interpreter/Utf8.h);contains/starts_with/ends_with/
+were already byte-safe (UTF-8 is self-synchronizing) and needed no change. The regex
engine remains byte-oriented by design/scope — seedocs/IMPLEMENTATION_NOTES.mdsections
14-15 for the exact boundaries and reasoning. -
to_number/to_str/to_booleanare now core builtins — nouse DLC:convertneeded.
use DLC:convertstill works (harmlessly re-registers the same functions). -
Fixed the Makefile using Windows-only batch syntax (
@if exist ... del) forclean/wasm,
which failed with a shell syntax error on Linux/macOS (and in CI). Restored portable
rm -f/mkdir -p. Also fixedwasm-cleandeleting the wholenpm/dist/directory,
including the hand-writtencuffscript.d.ts, instead of just the compiled outputs. -
Restored
docs/EXTENDING.md, which the changelog referenced but was missing from the repo.
v1.2.1
v1.2.0
Description
-
Performance: replaced the exception-based
return/stopcontrol-flow implementation
with an explicitExecOutcomevalue threaded throughexecStatement/execBlock/execIf/
execLoop(engine/interpreter/Signals.h). C++ exceptions are reserved for genuine
CuffErrorconditions again. Measured effect: afib(27)recursion benchmark (~635k
function calls) went from 2.75s to 0.26s (~10.7x). This also fixed two real bugs the
exception-based version had:stopused inside a function with no loop of its own used to
leak through the function-call boundary and terminate whatever loop was active in the
caller; andreturn/stopused at the top level (outside any function/loop) used to
crash the whole process with an uncaught exception. Both are now clean, catchable
CuffRuntimeErrors (StopOutsideLoop,ReturnOutsideFunction). -
Async: calling an
asyncfunction withoutawaitno longer runs it immediately —
it's queued and runs once the entire top-level script's synchronous code has finished,
in the order it was queued (FIFO).awaitis unchanged (still runs immediately and
returns the value). This is cooperative, single-threaded deferral — not real concurrency —
chosen for safety (the interpreter's shared state isn't thread-safe). See
docs/IMPLEMENTATION_NOTES.mdsection 1 andexamples/10_async_ordering.cuff. -
DLC libraries: added
DLC:list(sort,reverse,join,unique— all
non-mutating) andDLC:convert(to_number,to_str,to_boolean). -
Fixed a parser bug uncovered while adding
DLC:list:use DLC:<name>/use <name> from ...only accepted anIDENTIFIERtoken for the name, so any name colliding with a
reserved word (likelist) failed to parse at all.ImportParsernow accepts any
word-shaped token (identifier or keyword) as a name. -
Minor interpreter micro-optimizations:
Environmentpre-sizes its variable table for a
function call's known parameter count, and arguments are moved rather than copied into
parameter bindings. -
Added
examples/10_async_ordering.cuffand expandedexamples/08_dlc_libraries.cuffto
cover the new libraries.