Containers & Strings
Value-semantic arrays, ordered maps, a string type with a deep method set, and byte-level string reads for binary work. Array literals use [...]; map literals use {key: value, ...}. Builtin methods for arrays, maps, strings, and pointers live in one registry shared by both backends, so the lists below cannot drift between them. They are enumerated directly from that registry.
print(x) shows a container's contents (Printing values); to_json(x) writes the archive text from_json reads back.
The builtin method registry
Array methods (31): size push pop empty clear front back index_of has contains first last length slice filter sort reverse remove erase remove_if join map reduce for_each find find_index some every concat append insert (erase is remove, append is concat)
Map methods (14): size empty clear contains erase keys values has get length remove remove_if filter to_array
String methods (56): length size byte_size empty at front back substr find rfind find_first_of find_last_of find_first_not_of find_last_not_of contains starts_with ends_with count compare to_int to_float split explode lines partition is_digit is_alpha is_alnum is_space is_upper is_lower utf8_chars utf8_length is_valid_utf8 code to_utf8 to_bytes reader, and the in-place mutators append to_lower to_upper trim trim_left trim_right pad_left pad_right pad_center replace replace_first replace_last insert erase remove_prefix remove_suffix reverse repeat
weak_ptr methods (4): lock expired reset same_as shared_ptr methods (5): reset use_count cpp_ref_count unique same_as
Arrays
auto nums = [4, 1, 3, 2];
print("size={} front={} back={}", nums.size(), nums.front(), nums.back());
nums.push(5);
print("after push(5): {}", nums);
auto popped = nums.pop();
print("pop() -> {}", popped);
nums.sort(); // sorts IN PLACE
print("sorted: {}", nums);
nums.reverse(); // reverses IN PLACE
print("reversed: {}", nums);
print("index_of(3) = {}, contains(9) = {}", nums.index_of(3), nums.contains(9));
print("slice(1, 3) = {} (a new array)", array::slice(nums, 1, 3));
auto evens = array::filter(nums, [](x) { return x % 2 == 0; }); // filter returns new
print("evens: {}, original intact: {}", evens, nums);
nums.remove_if([](x) { return x > 2; }); // remove_if mutates
print("after remove_if(>2): {}", nums);
["a", "b", "c"].join("-");size=4 front=4 back=2
after push(5): [4, 1, 3, 2, 5]
pop() -> 5
sorted: [1, 2, 3, 4]
reversed: [4, 3, 2, 1]
index_of(3) = 1, contains(9) = false
slice(1, 3) = [3, 2] (a new array)
evens: [4, 2], original intact: [4, 3, 2, 1]
after remove_if(>2): [2, 1]
== result: a-b-cSee the complete API signatures for arguments, defaults, results and mutation effects. array::slice(values) copies the whole array; values.slice(start) retains the range in place and defaults the exclusive end to size(). erase(index) aliases remove(index), and both map removal spellings report whether a key existed.
Transforms called on an array mutate it: append concat filter map slice sort reverse. The matching array:: functions return new arrays. push pop clear erase remove remove_if insert also mutate; observers such as size front back find contains read without changing it. remove(i) erases the element at index i and returns true, or returns false and changes nothing when i is out of range; remove_if(pred) erases every element the predicate accepts. pop, front, back, first and last on an empty array raise a catchable error (Cannot pop from empty array). contains, has and index_of compare with the language's ==, so [1, 2].contains(2.0) is true and an array of class instances finds an element its operator== accepts; sort() uses the language order, including a class's operator< or its operator<=> fallback. An explicit predicate returns bool, and default float sorting groups NaNs last. A slice keeps the receiver's element type: a slice of an array<int> is an array<int>.
One thing to notice about the first line: auto nums = [4, 1, 3, 2] is an array<int>. auto infers the element type from a uniform literal and then enforces it, so nums.push("x") would raise. When you want a mixed bag, say so: var or array<var>. The details are under Typed containers.
values.filter(f) retains accepted elements in place; array::filter(values, f) creates a result with the same element constraints. The latter copies selected value objects and shares explicit handles. A reference callback still targets the live input element. values.map(f) retains the receiver's element constraints and converts callback results through its usual store rule; array::map(values, f) can produce another declared callback result type. In-place slice/filter move retained values without cloning them, and append leaves existing receiver elements in place.
Callbacks: map, reduce, find and friends
The callback helpers take a function, a lambda or an arrow function (Functions) and call it with the element, or with the element and its index when the callback declares two parameters. A predicate counts only when it answers true, as filter's does:
auto scores = [72, 95, 40, 88];
print(array::map(scores, (x) => x + 5));
print(scores.reduce((sum, x) => sum + x, 0), " ", scores.find(x => x > 90), " ", scores.find_index(x => x < 50));
print(scores.some(x => x < 50), " ", scores.every(x => x > 30));
scores.for_each((x, i) => print(i, ": ", x));
auto more = array::concat(scores, [60]);
more.insert(0, 100);
print(more);
array<int> hp = [10, 20];
hp.for_each([](int& x) { x -= 5; }); // a T& element parameter is the element itself
print(hp);[77, 100, 45, 93]
295 95 2
true true
0: 72
1: 95
2: 40
3: 88
[100, 72, 95, 40, 88, 60]
[5, 15]| method | returns |
|---|---|
array::map(values, f) | a new array of f(x): an array<T> when f declares its return type T ([](int x) -> int { ... }, int twice(int x)), an array<var> otherwise |
reduce(f, init) | f(accumulator, x) folded left to right from init; reduce(f) starts from the first element and raises on an empty array |
for_each(f) | nothing; calls f on each element in order |
find(f) | a copy of the first element f answers true for, or null |
find_index(f) | its index, or -1 |
some(f) / every(f) | whether any / every element answers true; they stop at the first element that decides (some of an empty array is false, every is true) |
array::concat(values, other) | a new array: this one's elements, then copies of other's, with this array's element type |
insert(i, v) | nothing; puts v at index i (0 to size()) and moves the rest up; another index raises |
A callback element parameter declared T& names the element in the array, so a write through it changes the array, as std::for_each does in C++; a by-value parameter gets a copy. The helpers walk the elements the array has when they start: a callback that grows the array does not see the new elements, and one that shrinks it ends the walk early. A throw in a callback ends the call and propagates.
Maps
auto inventory = {"sword": 1, "potion": 3};
inventory["gold"] = 250;
print("size = {}", inventory.size());
print("has(potion) = {}, has(shield) = {}", inventory.has("potion"), inventory.has("shield"));
print("keys: {}", inventory.keys());
print("values: {}", inventory.values());
inventory.erase("sword");
print("after erase: {}", inventory);
print("get with default: {}", inventory.get("shield", 0));
auto rich = map::filter(inventory, [](k, v) { return v > 10; });
print("filter(v > 10): {}", rich);
print("missing key read: {}", inventory["nothing"] == null);
to_json(inventory.to_array());size = 3
has(potion) = true, has(shield) = false
keys: ["gold", "potion", "sword"]
values: [250, 3, 1]
after erase: {"gold": 250, "potion": 3}
get with default: 0
filter(v > 10): {"gold": 250}
missing key read: true
== result: [["gold",250],["potion",3]]Notes: on a map, m.filter(f) keeps the entries f accepts in place and returns the map, as an array's dot filter, map, slice, concat and append return the array (its sort() and reverse() return null); map::filter(m, f) returns them as a new map and leaves m alone; remove_if(f) erases in place and returns the count. Writing m[key] = v inserts; reading a missing key returns null and does NOT insert. get(key) returns null for a missing key; get(key, default) supplies an explicit fallback. get, like an array's front, back, first and last, hands back a stored object, array or map the way a C++ reference return does: var x = m.get(k) is a copy, while var& x = m.get(k) and m.get(k).method() reach the stored value. Keys are ordered (a var map takes any script value as a key; the total ordering of <=> guarantees it), so iteration order is deterministic: range-for, keys(), values() and serialization walk keys in order on every platform, whatever order they were inserted in. Lookups are hashed, and the order is produced only when something iterates; a loop that inserts a key ahead of its position visits it, and one that erases entries skips them. Note that auto inventory = {…} inferred map<string, int> from its literal, exactly as auto does for arrays, so inventory[5] would raise; see Key types. Map filter's predicate takes (key, value).
A range-for names an entry's key and value with a structured binding, C++17's for (auto [k, v] : m). The key is a copy (map keys are read-only); the value is the iteration's copy, and with auto& (or var&) it is the entry itself. Over a map<K, V> that entry is a V&: a store through it converts to V or raises, as m[k] = v does. The loop walks key order live: a key the body inserts after the current one is visited later in the same loop, one inserted before it is not. A for (auto kv : m) pair is a value like any other, so var p = kv holds copies of both halves:
auto stock = {"arrow": 20, "potion": 3};
for (auto [item, count] : stock) { print(item, " x", count); }
for (auto& [item, count] : stock) { count *= 2; } // auto& makes count the entry's value
print(stock);arrow x20
potion x3
{"arrow": 40, "potion": 6}Typed containers
array<T> and map<K,V> enforce their element types. A push or a store converts when the value is compatible (int to float, say) and rejects otherwise, with an error that names the container (the sample below).
Elements and map values convert between bool and int: false becomes 0, true becomes 1, and an int becomes false only when it is zero. Int/float conversion also applies. Int-to-string requires an explicit to_string(value) or cast<string>(value); float/bool conversion stays rejected. Map keys never coerce.
shared_ptr<T> and weak_ptr<T> elements admit null. A shared-handle element also takes a live instance of T or a subclass and checks the class of a shared handle. Class values, primitive values and nested containers remain non-nullable.
array<int> nums = [1, 2, 3];
nums.push(4.9); // converts to the element type
print("pushed 4.9 -> {}", nums);
var bad = "nope"; // a var-sourced value: the checker cannot see it
try {
nums.push(bad); // checked at run time
} catch (e) {
print("caught: {}", e);
}
map<string, int> ages = {"ana": 31};
ages["bo"] = 44;
array<array<int>> grid = [[1, 2], [3, 4]];
print("grid[1][0] + ages[bo] = {}", grid[1][0] + ages["bo"]);pushed 4.9 -> [1, 2, 3, 4]
caught: Cannot push 'string' to array<int>
grid[1][0] + ages[bo] = 47The checker checks known literal elements and keys, nested literals, typed push arguments and typed subscript stores. A subscript on a declared array<T> or map<K,V> carries its element type into later typed assignments. A var-sourced value is checked at run time and its error is catchable, as above. Bounds, missing keys and computed numeric range failures remain runtime checks. [[warn_mode]] is the explicit way to downgrade a static diagnostic.
Representation follows the declared type
The declared element type is not just a check; it is the storage. array<int> and array<float> are raw int64 and double buffers, always; bool, char, string and class element types get packed buffers of their own. array<var>, and a literal without a spelled element type, is the one form that holds a vector of dynamic values. Moving between the two is an explicit assignment, and a typed array keeps its constraint when a push is rejected. Copying it into array<var> explicitly creates a dynamic copy. A var holding a copy keeps the typed array (var v = ints; v.push("s") raises like ints.push("s")) while the variable itself stays a var (v = "hello" is fine). The math types are element types of the same kind: array<math::vec3> stores its vectors' components in one buffer (Math).
array<int> nums = [1, 2, 3]; // a raw int buffer
array<var> copy = nums; // the explicit step to the dynamic form: a copy
copy.push(4);
print("copy: {} nums: {}", copy, nums);
array<float> widened = nums; // typed to typed converts element by element
print("widened: {}", widened);
auto guessed = [1, 2, 3]; // auto infers array<int> from a uniform literal
try { guessed.push("s"); } catch (e) { print("caught: {}", e); }
var loose = [1, 2, 3]; // var: dynamic, whatever the literal held
loose.push("s");
print("loose: {}", loose);copy: [1, 2, 3, 4] nums: [1, 2, 3]
widened: [1.0, 2.0, 3.0]
caught: Cannot push 'string' to array<int>
loose: [1, 2, 3, "s"]Three spellings, three intents: array<int> when you know, auto when the literal says it for you (a mixed literal under auto raises Array 'auto' requires homogeneous elements - type mismatch at index 1), and var or array<var> when the elements genuinely vary. This is the contract behind the "typed arrays" bullet in the performance notes: a typed loop reads a raw buffer, and a C++ binding can take the buffer without boxing.
Byte arrays. array<uint8> (0 to 255) and array<int8> (-128 to 127) hold one byte per element. An element reads as an int, and every store runs the range: Cannot store 256 in array<uint8> (0..255), whether it comes from =, a compound assignment, ++, push, a reference or a conversion. A float element converts like it does into an int first (7.9 stores 7), and a bool stores 0 or 1; a char needs cast<int>. array<int> x = bytes; and array<uint8> b = ints; convert by value (the second checks every element), and array<var> v = bytes; is the dynamic copy. uint8 and int8 name array elements only: a single byte is an int, so uint8 x = 5; is a parse error that says so.
array<uint8> pixels = [0, 128, 255]; // three bytes
pixels[1] += 100;
array<int> wide = pixels; // by value: a raw int buffer
int v = pixels[2] + 1;
try { pixels[2] = v; } catch (e) { print("caught: {}", e); }
print("pixels: {} wide: {}", pixels, wide);caught: Cannot store 256 in array<uint8> (0..255)
pixels: [0, 228, 255] wide: [0, 228, 255]Class-typed arrays admit subclasses
array<Base> holds a Base or anything derived from it, and each slot keeps its own class, so a derived element keeps its extra fields inside the base-typed array. array<Derived> converts to array<Base> by value. An unrelated class raises:
class Unit { int hp = 10; }
class Archer : Unit { int range = 6; }
class Rock { int mass = 3; }
array<Unit> squad = [Unit(), Archer()]; // a subclass in a Base slot keeps its class
squad.push(Archer());
print("{} units, squad[2] is {}", squad.size(), reflect::type_name(squad[2]));
print("Archer fields survive: range = {}", cast<Archer&>(squad[1]).range);
array<Archer> archers = [Archer(), Archer()];
array<Unit> promoted = archers; // array<Derived> -> array<Base>, by value
print("promoted: {}", promoted.size());
var rock = Rock(); // a var hides the class from the checker: the push raises
try { squad.push(rock); } catch (e) { print("caught: {}", e); }3 units, squad[2] is Archer
Archer fields survive: range = 6
promoted: 2
caught: Cannot push 'Rock' to array<Unit>(type_of and reflect::type_name both name the class an element actually holds: Chapter 12.)
Key types
A map checks its declared key type on every direct store, and keys never coerce: map<string, int> rejects m[5] rather than turning 5 into "5". map<var, V> takes any key. map<auto, V> infers the key type from a uniform literal, map<K, auto> the value type, and plain auto infers both sides (auto m = {"a": 1} is a map<string, int>); the inferred type is then enforced like a spelled one. A mixed literal raises Map 'auto' requires homogeneous keys (or values), and an empty literal leaves the auto side dynamic: map<auto, int> m = {}; takes keys of any type. A typed map declared without an initializer is an empty map of its declared type, ready for m[k] = v.
map<string, int> ages = {"ana": 31};
try { ages[5] = 1; } catch (e) { print("caught: {}", e); } // keys never coerce
map<var, int> anything = {}; // any key
anything[5] = 1;
anything["five"] = 5;
print("map<var,int>: {}", anything);
map<auto, int> inferred = {5: 1}; // auto key: int, from the literal
try { inferred["k"] = 2; } catch (e) { print("caught: {}", e); }
map<string, int> empty; // no initializer: an empty map of its declared type
print("empty.size(): {}", empty.size());
empty["x"] = 1;
print("after assigning a literal: {}", empty);caught: Cannot use 'int' as a key of type 'string'
map<var,int>: {5: 1, "five": 5}
caught: Cannot use 'string' as a key of type 'int'
empty.size(): 0
after assigning a literal: {"x": 1}Keys follow one total order, the order a map iterates and serializes in, and lookup hashes alike any two keys that order calls equivalent, so a lookup finds exactly the entry iteration shows. Numbers order by value, with 0.0 and -0.0 one key and every NaN one key, found again and ordered after every number (a vector's float components alike); keys of different kinds never merge (1 and 1.0 are two keys); an array, map or handle used as a key orders and hashes by the identity of the object it holds, never by its contents, so two equal array literals are two keys while the same array is one.
A class value used as a key (an instance or a bound C++ object held by value, not a handle) keys by its class, as std::map and std::unordered_map do. A class that declares operator< or operator<=> orders its keys: two keys are one when neither is less, and the map iterates them in that order. A class with neither but a hash() returning an int keys by hash() and operator== (by identity when it declares no ==), and iterates them by hash() value. A class with none of these keys by identity, as an array does, and the first such key warns: map keys of class 'C' compare by identity: declare operator<=> (or operator<) or hash() to key its values by value (the checker gives the same warning where it sees the key's class). Class keys iterate grouped by class name, ahead of handles. An operator that throws or fails inside a lookup or insert raises from that statement and leaves the map unchanged. Keep a key unchanged while it is in the map: a key changed through another name leaves the map's order stale, as a key mutated in place breaks a C++ map, so erase it and insert the changed value. In a parallel_for body, a map that holds class values cannot be captured (it is not value-semantic), and a map the body builds itself raises the parallel operator verdict at its first class key whose class orders or hashes.
class Tile { int x = 0; int y = 0; Tile(int a, int b) { x = a; y = b; } int operator<=>(Tile o) { return x != o.x ? x - o.x : y - o.y; } }
var seen = {};
seen[Tile(2, 1)] = "b";
seen[Tile(1, 5)] = "a";
seen[Tile(2, 1)] = "B"; // the same key: neither is less
for (auto& [t, v] : seen) { print("{},{} {}", t.x, t.y, v); }
print("size {} has {}", seen.size(), seen.contains(Tile(1, 5)));1,5 a
2,1 B
size 2 has trueEvery typed declaration without an initializer takes its type's default: containers start empty, numbers at zero, strings empty, and a class type default-constructs (the Type Ladder has the table).
A compound assignment on a missing key starts from the value type's default, as C++'s operator[] does: m[k] += 1 on a map<string, int> counts from 0, += on a map<string, string> appends to "", and ++ works the same way. A plain read still inserts nothing, and a var-valued map has no default to start from, so there the compound raises:
map<string, int> hits = {};
for (auto name : ["orc", "elf", "orc"]) { hits[name] += 1; } // a missing key counts from 0
hits["troll"]++;
map<string, string> log = {};
log["turn1"] += "moved"; // appends to ""
print("{} {}", hits, log);
var loose = {};
try { loose["q"] += 1; } catch (e) { print("caught: {}", e); }{"elf": 1, "orc": 2, "troll": 1} {"turn1": "moved"}
caught: Cannot apply '+=' to null and intPrinting values
Every value has one display text, and print, to_string, string + x, a template splice ${x}, format’s {}, join, a string parameter or variable and a throw’s message all use it:
- A string at the top level is its bytes. Inside a container a string or
utf8_stringis written as a literal in double quotes and acharin single quotes, with\n,\r,\t,\\and the quote escaped and other control bytes as\xNN. - Numbers,
bool,nulland vectors print as they do alone:2.0,true,(1.0, 2.0). - An array lists its elements,
[1, 2, 3]; a map its entries in key order,{"x": 1, "y": [2]}. - An instance whose class or a base declares
string to_string()shows that text. Any other instance shows its class name and its fields in declaration order, a base’s fields first:P{x: 1, name: "hi"}. - A
shared_ptrorweak_ptrshows the object it points to, an expiredweak_ptrnull. An object met again while it is still being shown prints<cycle P>; one object held twice side by side prints twice. - A function prints
<function>, a coroutine handle<coroutine>, a host object whose type binds noto_stringits type name,<Sprite>.
A value prints in full however large or deeply nested it is. A container or an instance given to print or format first is never read as a format string, and a to_string() that throws or fails is the error of the call that asked for the text.
class P { int x = 1; string name = "hi"; }
class Q : P { array<int> hits = [2, 3]; }
class Tag { string to_string() { return "<tag>"; } }
class Link { int id = 0; shared_ptr<Link> next; }
print([1, 2.5, "two", 'c', true, null]);
print({"pos": [1, 2], "name": "orc\n"});
print(Q(), " ", [Tag(), Tag()]);
shared_ptr<Link> a = new Link();
a.next = a;
print(a);
P shared = P();
var twice = [shared, shared];
var stock = {"arrow": 20};
print("stock: " + stock, " ", `${twice}`, " ", format("{:>8}|", [7]));
print(to_json(stock));[1, 2.5, "two", 'c', true, null]
{"name": "orc\n", "pos": [1, 2]}
Q{x: 1, name: "hi", hits: [2, 3]} [<tag>, <tag>]
Link{id: 0, next: <cycle Link>}
stock: {"arrow": 20} [P{x: 1, name: "hi"}, P{x: 1, name: "hi"}] [7]|
{"arrow":20}JSON
to_json(x) writes any value as compact JSON text and to_json(x, n) pretty-prints it with n spaces per level (0 is compact). from_json(text) reads it back: objects become maps, arrays become arrays, numbers keep int or float, and a malformed text raises a catchable error. A class instance writes its class name under $type and its fields under $val; from_json rebuilds the instance when the class exists, and a typed declaration takes it directly (Hero h = from_json(text);). Rebuilding runs the class's zero-argument constructor whatever its access, restores every saved field in declaration order without notifying, then runs post_load once the whole load is done (Loading saved objects). The values JSON has no spelling for round-trip through tagged objects: a map whose keys are not strings is {"$map": [[key, value], ...]}, a char is {"$char": 65} and a float no JSON number spells is {"$float": "inf"}, "-inf" or "nan" (a vector component too), so each comes back as what it was. A shared_ptr or weak_ptr handle writes its object once, at its first handle, as {"$id": 1, "$type": "Hero", "$val": {...}} ($ref in place of $id for a weak one) and every later handle as the id alone, an expired weak handle as null, so handles that shared one object share it again after from_json (and after to_binary / from_binary, which keeps the same identities), through cycles too (a handle back into an object still being written is that object); an id whose object the text never wrote raises Invalid shared_ptr ID: 4. No depth limit applies: a value nests as deep as memory allows, through to_json, from_json, the binary pair and a copy alike. Floats are written with every digit they need to read back exactly.
var save = {"name": "orc", "hp": [10, 20], "pos": {"x": 1.5}};
print(to_json(save));
print(to_json(save, 2));
map<int, string> slots = {1: "sword", 2: "rope"};
var back = from_json(to_json(slots)); // int keys survive the round trip
print("{} {} {}", to_json(slots), back[1], type_of(from_json(to_json(cast<char>(65)))));
class Hero { string name = "Vex"; int hp = 30; }
Hero h;
h.hp = 12;
string text = to_json(h);
Hero copy = from_json(text);
print("{} -> {} {}", text, copy.name, copy.hp);
try { from_json("{bad"); } catch (e) { print("caught: {}", e); }{"hp":[10,20],"name":"orc","pos":{"x":1.5}}
{
"hp": [
10,
20
],
"name": "orc",
"pos": {
"x": 1.5
}
}
{"$map":[[1,"sword"],[2,"rope"]]} sword char
{"$type":"Hero","$val":{"name":"Vex","hp":12}} -> Vex 12
caught: Expected '"' in JSONMap keys come out in key order (maps are ordered), so the same data always writes the same text. The C++ side (JSON and binary archives for host objects) is in Serialization.
Strings: observers vs mutators
This is the part that surprises people: JaiScript string methods split into two camps, and the mutating camp modifies the receiver in place and returns self for chaining, deliberately:
// OBSERVERS return information and leave the string alone:
auto s = "The Quick Fox";
print("length={} find(Quick)={} count(o)={}", s.length(), s.find("Quick"), s.count("o"));
print("contains/starts/ends: {} {} {}", s.contains("Fox"), s.starts_with("The"), s.ends_with("Fox"));
print("substr(4, 5) = {} (s untouched: {})", s.substr(4, 5), s);
print("split: {}", s.split(" "));
print("\"42\".to_int() + 1 = {}", "42".to_int() + 1);
// MUTATORS change the string IN PLACE and return self (chainable):
var t = " hello world ";
t.trim().to_upper().replace("WORLD", "JAI");
print("t is now: [{}]", t);
var padded = "7";
padded.pad_left(3, '0');
print("padded: {}", padded);
var r = "stressed";
r.reverse();
r;length=13 find(Quick)=4 count(o)=1
contains/starts/ends: true true true
substr(4, 5) = Quick (s untouched: The Quick Fox)
split: ["The", "Quick", "Fox"]
"42".to_int() + 1 = 43
t is now: [HELLO JAI]
padded: 007
== result: dessertsSo auto upper = name.to_upper(); also uppercases name itself. If you need to keep the original, copy first (auto upper = name; upper.to_upper();) or build a new string with +. find returns -1 when absent; substr(start, len); split() with no argument splits on whitespace and drops empty pieces, as Python, Ruby, Go, JavaScript and Java do (" stats 35\t25 ".split() → ["stats","35","25"]; whitespace is space, tab, newline, carriage return, vertical tab and form feed, and split(string::whitespace) spells the same split out); split(sep) cuts at each exact separator and keeps the empty pieces ("a,,b".split(",") → ["a","","b"]); split("") splits into characters (bytes for a string, characters for a utf8_string); an optional second argument caps the number of splits, the rest staying in the last piece. explode is a synonym for every form. lines() splits text into lines: \n and \r\n both end a line, interior blank lines stay (as ""), and a final line needs no terminator, so a trailing newline adds no empty last element ("a\r\n\nb\n".lines() → ["a","","b"]).
The non-mutating twins live in string::. string::trim(s), string::replace(s, from, to), string::repeat(s, n), string::pad_left(s, n, [fill]), string::pad_right(s, n, [fill]), string::to_lower(s) and string::to_upper(s) return a new string and leave the argument alone, the spelling for expressions: print(string::trim(line)). A negative count raises string::repeat count cannot be negative in both spellings, s.repeat(-1) and string::repeat(s, -1). string::join(parts, [sep]) joins an array of strings. The namespace is yours to extend: namespace string { function shout(string s) -> string { return string::to_upper(s) + "!"; } } adds string::shout beside the built-in entries.
The everyday helpers: s.replace(from, to) replaces every occurrence of from, left to right and without overlap ("aaaa" with "aa" → "b" is "bb"), and replace_first and replace_last replace one; s.split(sep, max) stops after max splits (the last element keeps the rest); s.lines() splits on \n and \r\n with no empty last element; s.partition(sep) is [head, sep, tail] ([s, "", ""] when sep is absent); a.compare(b) is -1, 0 or 1; is_digit, is_alpha, is_alnum, is_space, is_upper and is_lower are true when the string is non-empty and every byte is in the ASCII class; pad_left(n, fill), pad_right and pad_center tile a fill of several characters from its start on each side, as JavaScript's padStart does ("ab".pad_left(7, "xyz") is "xyzxyab"), and an empty fill raises pad_left() padding character cannot be empty.
Bytes and chars: s[i], \xNN, cast<char>
s[i] reads a char, and chars promote to integers (0–255, always unsigned) in arithmetic, bitwise, and comparison contexts, so a single byte is plain arithmetic (whole records read through binary data). s.at(i) is the alternative when you want a one-character string instead of a char. Range-for walks a string the same way: for (auto c : s) binds each char by value, in order (auto& raises, since a char is a read, not a cell). Going the other way: \xNN escapes (exactly two hex digits) put literal bytes in source, and cast<char>(n) mints a char from an int, range-checked to 0–255:
var s = "Jai\x21"; // \xNN: exactly two hex digits
print("s = {}", s);
print("s[0] = {} ({})", s[0], type_of(s[0]));
print("byte value: {}", s[0] + 0); // char + int promotes: 0..255, unsigned
print("as a cast: {}", cast<int>(s[0])); // the same code point, spelled explicitly
print("digit: {}", "7"[0] - '0'); // the parser idiom
print("sentinel: {}", s[3] == 0x21); // byte comparisons just work
var word = (s[0] + 0) | ((s[1] + 0) << 8); // little-endian u16 from two bytes
print("le u16: {}", word);
var out = "";
out += cast<char>(0x4A); // int -> char, range-checked 0..255
out += to_char(0x21); // the stdlib spelling of the same rule
out += '!'; // string += char appends as text
print("built: {}", out);
try { cast<char>(256); } catch (e) { print("caught: {}", e); }
var name = "jai";
name[0] = 'J'; // a char replaces byte 0
name[2] = "i!!"; // a string replaces byte 2 with its text
print("written: {}", name);s = Jai!
s[0] = J (char)
byte value: 74
as a cast: 74
digit: 7
sentinel: true
le u16: 24906
built: J!!
caught: Cannot cast 'int' to 'char'
written: Jai!!A subscript is a store target too. s[i] = c replaces byte i, and s[i] = text replaces byte i with the whole text, so the string grows or shrinks: after string s = "hello"; s[2] = "ll"; it holds "helllo". A compound store reads the char, applies the operator and writes the result back, so s[2] += "ll" (like s[2] = s[2] + "ll") turns "hello" into "hellllo". The stored value is a char or text; anything else raises Cannot assign int to a string character (it takes a char or a string), a load error when the checker sees the types. An index past the end raises the read's String index 9 out of bounds for string of size 5. The string stays a value: a copy taken before the write keeps its bytes. And the promotion never crosses into the string domain: 'a' == "a" is false, char + string concatenates, and string += int is an error (stringify explicitly if you mean it). High bytes stay unsigned: "\xC3"[0] + 0 is 195, never -61.
The char door is cast<T>, and only cast<T>. The assignment table has no char row: char c = 65 and int n = 'A' are both load errors under strict checking (see Types That Fail Early). To cross deliberately, spell it: cast<int>(c) and cast<float>(c) give the code point, cast<string>(c) the one-character string, cast<char>(n) or cast<char>("a") mint one. Out of range raises the catchable Cannot cast 'int' to 'char' (a longer string, Cannot cast 'string' to 'char'). to_char(n) is the stdlib function form of the same range-checked rule. Chapter 4 covers promotion in full.
What this doesn't do (and what to use instead): no unary -c/~c on chars (write 0 - c / c ^ 0xFF). Binary payloads live in ordinary strings, read and built by the binary library below.
Binary data
A string is binary-safe: it holds any byte 0–255, NULs included, and read_file / write_file move bytes unchanged, so a file's bytes are a string and a string's bytes are a file. utf8_string is for text. Whole-buffer conversions are one native call each:
s.to_bytes(),s.to_bytes(offset),s.to_bytes(offset, count)→ anarray<uint8>(one byte per element; each reads as an int 0–255, andarray<int> b = s.to_bytes();takes them into an int buffer). The range is checked:to_bytes(1, 3) out of bounds for string of size 3. On autf8_stringit gives the UTF-8 bytes.string::from_bytes(bytes)builds a string from anarray<uint8>or anarray<char>(one copy), or from anarray<int>orarray<var>whose every value is 0–255 (elsestring::from_bytes expects bytes (0 to 255): element 3 is 256).
Reading: binary::reader. s.reader() (or binary::reader(s)) walks the string with a cursor from 0, little-endian until big_endian(); little_endian() switches back, and both return the reader so auto r = s.reader().big_endian(); reads naturally. Every read advances the cursor and names its width:
int8()int16()int32()int64()sign-extend;uint8()uint16()uint32()are 0 to 4294967295. There is nouint64(): a script int is an int64, so a value past 263 has no int to land in;int64()reads the same eight bytes as a signed value.float32()widens to the script float,float64()keeps every bit (a NaN's payload,-0.0).text(n)reads ann-byte text field, the Cchar[n]convention: the cursor moves exactlyn, and the text is the bytes before the first NUL (a lump name"E1M1\0\0\0\0"is"E1M1", a full"TEXTURE1"with no NUL is all eight; bytes after a terminator are not part of the text even when a tool left something there, which trimming trailing NULs would keep).bytes(n)is exactlynraw bytes, NULs and all, as anarray<uint8>.text()reads a C string: the bytes before the next NUL, and the cursor moves past the NUL. Its scan never passes the end of the data or the host'sengine::binary_scan_limit(1 MiB unless the host sets it; scripts cannot change it), so no script runs an unbounded scan. When no NUL is in reach it raises and leaves the cursor:Cannot read null-terminated text at position 5: no NUL terminator in the remaining 3 bytes, or at the limit... no NUL terminator within 1048576 bytes (the engine's binary scan limit; raise it from C++ with engine->binary_scan_limit).seek(pos)andskip(n)move the cursor and return the reader;pos(),size(),remaining()andat_end()ask.
Reading past the end raises the catchable Cannot read int32 at position 10: 4 bytes wanted, 2 remain and leaves the cursor where it was. A reader is a value: auto r2 = r; copies the cursor, a by-value parameter gets its own copy, a binary::reader& parameter shares it. It reads a snapshot of the string: strings are shared copy-on-write, so making a reader copies nothing, and a later change to the string leaves the reader's bytes as they were.
Writing: binary::writer. binary::writer w; mirrors the reader: w.int8(v) through w.int64(v), w.uint8(v) through w.uint32(v), w.float32(v), w.float64(v), w.text(s) (the text, then a NUL), w.text(s, n) (an n-byte text field: the text, then NULs to n; a text of exactly n bytes fills the field with no terminator, the way WAD lump names are stored), w.bytes(b) (raw bytes as they are: a string's, or an array of 0–255), big_endian() / little_endian(). Each returns the writer for chaining. A value that does not fit raises and writes nothing: Cannot write 256 as uint8 (0 to 255), Cannot write text: the text is 9 bytes, longer than text(8) (a text is never cut), Cannot write text: the text holds a NUL at byte 3 (text is NUL-ended, so raw data goes through w.bytes). w.pos() and w.seek(pos) go back to patch an earlier field in place (a length or an offset known only at the end); w.size() is the byte count and w.to_string() the bytes, ready for write_file(path, w.to_string()).
Layouts. binary::int8, binary::int16, binary::int32, binary::int64, binary::uint8, binary::uint16, binary::uint32, binary::float32, binary::float64, binary::text(n) and binary::bytes(n) are width tokens: values that describe an encoding, not language types (a script int is always an int64; array<uint8> and array<int8> are the byte arrays above). binary::text(n) is the n-byte text field of r.text(n) and w.text(s, n); binary::bytes(n) is exactly n raw bytes, an array<uint8> when unpacked, and packs a string or byte array of exactly n bytes. A layout names them in order:
binary::layout(name, width, ...)builds a reusable layout, validated once (widths that total more than an int holds raisebinary::layout fields total more than 9223372036854775807 bytes);r.unpack(layout)reads its fields in order into amap<string, var>, andr.unpack(name, width, ...)reads an inline one.w.pack(layout, map)writes the map's values in the layout's order; a missing name or a value that does not fit raises before anything is written.r.read(Class, layout)makes a default-constructed instance and fills its fields in declaration order, base class fields first; the field count and each field's kind (int, float, string for text, array for bytes) must match the widths.
The order always comes from the arguments, never from a map: maps iterate in key order. The strict checker knows the library: the types, every method's signature and result, an odd name/width pairing or a non-width where a width belongs, and a read(Class, layout) whose class and layout it can see, all reported at load.
var ENTRY = binary::layout("pos", binary::int32, "size", binary::int32, "name", binary::text(8));
binary::writer w; // a tiny WAD: header, one lump, a directory
w.bytes("PWAD").int32(1).int32(0); // id, lump count, directory offset (patched below)
int lump = w.pos();
w.bytes("hello");
int dir = w.pos();
w.pack(ENTRY, {"pos": lump, "size": 5, "name": "GREET"});
w.seek(8).int32(dir); // back to the header to patch the offset
string wad = w.to_string();
var r = wad.reader();
print("{}, lump count {}", r.text(4), r.int32());
r.seek(r.int32()); // the directory
var e = r.unpack(ENTRY); // map<string, var>: name -> value
print("{} at {}: {}", e["name"], e["pos"], wad.substr(e["pos"], e["size"]));
class Entry { int pos; int size; string name; }
Entry first = wad.reader().seek(dir).read(Entry, ENTRY);
print("{} {} {}", first.name, first.pos, first.size);
print(wad.to_bytes(0, 4));
print(string::from_bytes([80, 87, 65, 68]));
try { r.int32(); } catch (err) { print("caught: {}", err); }
try { w.uint8(256); } catch (err) { print("caught: {}", err); }PWAD, lump count 1
GREET at 12: hello
GREET 12 5
[80, 87, 65, 68]
PWAD
caught: Cannot read int32 at position 33: 4 bytes wanted, 0 remain
caught: Cannot write 256 as uint8 (0 to 255)Files. write_file(path, string::from_bytes(b)) and read_file(path).to_bytes() round-trip any bytes exactly: every value 0–255, embedded NULs, \r\n untouched. read_file raises a catchable error for a file that is missing or cannot be read (Cannot read file 'saves/world.dat': not found, ...: not a file, ...: permission denied); an existing empty file reads as "". Ask first when absence is expected: file_exists(path) ? read_file(path) : "" (Where files are).
UTF-8 text
A string is bytes, as in C++: size(), s[i] and substr count bytes, which is O(1) and what binary formats and renderers want. Text in UTF-8 has its own methods, named so they never blur with char (a byte):
string s = "héllo 🙂";
print("{} bytes, {} characters", s.size(), s.utf8_length());
for (string ch : s.utf8_chars()) { print("[{}]", ch); } // each UTF-8 character, as a string
print("{} {}", "é".code(), string::from_code(233)); // Python ord / chr
print(s.is_valid_utf8());11 bytes, 7 characters
[h]
[é]
[l]
[l]
[o]
[ ]
[🙂]
233 é
trueutf8_chars() never loses text: a byte that starts no valid sequence comes back as its own one-byte element, and is_valid_utf8() (or string::is_valid_utf8(s)) checks first. code() needs a string of exactly one UTF-8 character; string::from_code(n) raises for a surrogate or a value past U+10FFFF.
utf8_string: text by character
utf8_string holds the same bytes as a string and has the same library, but every position is a character: size(), length(), u[i], at, substr, find, insert, erase, the pads, reverse, split("") and range-for count and return characters. A character comes back as a one-character utf8_string; byte_size() still counts bytes. A byte that starts no valid sequence is one character of its own, in utf8_length(), utf8_chars() and every utf8_string position alike, and keeps its byte (a terminal draws it as U+FFFD): "a\xFFb" is three characters.
utf8_string name = "Zoë 🙂";
print(name.size()); // 5: characters
print(name[2]); // ë
for (auto c : name) { print(c); } // Z, o, ë, space, 🙂
print(name.byte_size()); // 9: the bytes underneath
string raw = name; // the same bytes, counted as bytes
print(name == raw); // true5
ë
Z
o
ë
🙂
9
trueMake one by declaring the type, with utf8_string(x), or with s.to_utf8(); a string slot takes a utf8_string back as its bytes, and so does a C++ std::string parameter. It compares and orders by bytes, so u == s holds for the same text, but as a map key it keeps its own kind (like 1 and 1.0). A + with a utf8_string on either side makes a utf8_string, and every method or string:: function that returns text returns the receiver's kind. u[i] = text replaces character i whatever its byte width (u[1] = "日" in "abc" gives "a日c", three characters in five bytes), u[i] += text appends to it, and a range-for takes characters by value. Case mapping and the character classes stay ASCII, as on a string.
Character positions are cheap in practice: an all-ASCII text indexes like bytes, the character count is remembered, the last two positions are remembered so a loop over u[i] steps one character at a time, and a long text read at random positions builds a table of every 64th character once. These caches are invisible to scripts: every write clears them, so no read ever sees a stale count or position, and parallel bodies read them without writing them.
Digests, HMACs and password hashes are crypto:: functions (Random Numbers & Crypto).
Strings and +
Concatenation with + returns a new string and is the safe non-mutating way to build text: s = s + "!". Mixed string + x appends the display text of x (Printing values). Ordinary code is the string builder: s += x, s = s + x and s = s + a + b append to the string's own buffer, on a local, a field, an element, a map value or through a reference alike, so a loop that builds a page costs the bytes it appends, never a copy of what it built so far (the cost model). A copy taken along the way keeps the text it had.
Template strings & format specs
Backtick strings interpolate any expression with ${...} (the lexer desugars them to plain string concatenation). A splice can end in a format spec, ${expr:spec}, the most useful part of std::format: [[fill]align][+| ][0][width][,|_][.precision][d|x|X|b|f|e|g] (a sign and zero padding for numbers, ${id:05} is 00042, ${-42:05} is -0042, ${d:+} marks non-negatives; , or _ groups a number's integer digits in thousands, so ${1234567:,} is 1,234,567 and ${total:,.2f} is a money amount, and _ groups x, X and b digits in fours). This kills the to_string-then-pad dance in HUD code:
var name = "Vex";
var hp = 73.5;
var gold = 1234;
print(`${name:<8}|${gold:8}|`); // strings left-align, numbers right-align
print(`${hp:.1f} ${gold:*>8} ${gold:^8}|`); // fixed float; fill+align; centered
print(`${gold:x} ${gold:X} ${5:b}`); // hex / HEX / binary (ints only)
print(`HP ${hp:>6.1f}/${100.0:<6.1f} G${gold:>7} ${name:<12}|`);
print(`${gold * 1000:,} ${hp * 1000:,.2f} ${gold:_b}`); // thousands, a money amount, nibbles
var crit = true;
print(`${crit ? "CRIT" : "hit"} for ${(crit ? 150 : 100):>5}|`); // ternaries still work
try {
print(`${name:.2f}`); // spec/type mismatch: catchable run-time error
} catch (e) {
print("caught: {}", e);
}
print(format("{:.2f} vs {0:>8}", 3.14159)); // same specs in format()/print placeholdersVex | 1234|
73.5 ****1234 1234 |
4d2 4D2 101
HP 73.5/100.0 G 1234 Vex |
1,234,000 73,500.00 100_1101_0010
CRIT for 150|
caught: format spec ':.2f' does not apply to string value
3.14 vs 3.14159The rules: .N means fixed (bare or with f; :f alone is 6 decimals), ^ centering puts the odd fill char on the right, zero padding under a grouping groups its zeros too (${1234:010,} is 00,001,234), and a malformed spec (${x:q}, ${x:#x}, ${n:,x}) is rejected at lex time with Unsupported format spec ':q' in template string; in a format() or print placeholder it raises Unsupported format spec ':q' in format string when the call runs, never printing the braces. The spec starts at a top-level : that closes no ternary ?, so ${a ? b : c} stays an expression and ${m["k"]:6} / map-literal colons just work; parenthesize a ternary to give it a spec. Splices desugar to the engine-core builtin format_value(value, "spec"), available on every engine, one shared implementation, so both backends produce byte-identical output and error text.
Regular expressions
regex(pattern) and regex(pattern, flags) make a regex, a value that searches text. Write a pattern as a raw string, R"(\d+)" (Hello & Basics), so its backslashes stay backslashes; in a plain string every one doubles ("\\d+"). Matching runs in linear time: no pattern backtracks, so no pattern and text can stall a search, and a hostile pattern from a request is as safe to run as a known one.
regex date = regex(R"((?<year>\d{4})-(?<month>\d\d)-(?<day>\d\d))");
var m = date.search("due 2026-10-21, paid 2026-11-02");
print(m.text(), " at ", m.start(), "..", m.end());
print(m[1], " / ", m["month"], " / ", m["day"], " (", m.size(), " slots)");
for (var each : date.find_all("2026-10-21 2026-11-02")) { print(each["day"], " at ", each.start()); }
print(date.replace("on 2026-10-21", "${day}.${month}.${year}"));
print(regex(R"(\d+)").replace("a1b22c333", (x) => "<" + to_string(x.text().size()) + ">"));
print(to_json(regex(R"(\s*,\s*)").split("red , green,blue , gold")));
print(regex(R"(^[a-z_]\w*$)", "i").matches("Player_2"), " ", regex(R"(^[a-z_]\w*$)").matches("2fast"));
print(date.search("no dates here") == null);2026-10-21 at 4..14
2026 / 10 / 21 (4 slots)
21 at 0
02 at 11
on 21.10.2026
a<1>b<2>c<3>
["red","green","blue","gold"]
true false
true
| call | answers |
|---|---|
r.search(text), r.search(text, start) | the leftmost match at or after start (a position as find counts them; the text before it still counts for ^ and \b), a regex::match, or null |
r.matches(text) | whether the whole text matches (any way through the pattern: regex("a|ab").matches("ab") is true) |
r.find_all(text) | every match, left to right, as an array of regex::match |
r.replace(text, with), r.replace_first(text, with) | the text with every match (or the first) replaced by a replacement text or by what a function returns for the match |
r.split(text), r.split(text, max_splits) | the pieces between matches; each split's capture groups follow its piece; a count keeps the rest in the last piece |
r.pattern, r.flags | the pattern and the flags it was made with (read-only) |
regex::escape(text) | a pattern that matches the text literally |
A regex::match is the match and its groups: m.text() and m[0] are the whole match, m[i] group i and m["name"] a named group, null for a group that took no part; m.start(g) and m.end(g) are its positions (group 0 when g is left out; -1 for a group that took no part), and m.size() counts the whole match and every group. A group in a loop keeps the text of the loop's last pass. A match is a value: it holds its own texts, and the text it came from can change after.
The syntax
| construct | matches |
|---|---|
a, \., \t \n \r \f \v \0 | the character; a backslash before punctuation or a space takes it literally, and before a letter that names nothing it is an error |
\xNN, \uXXXX, \u{X} | a byte on a string and U+00NN on a utf8_string; a code point (its UTF-8 bytes on a string) |
. | any character but a newline (with flag s, any) |
[abc], [a-z], [^...], [], [^] | one character of the set, or not of it; - first or last is literal, [] matches nothing and [^] anything |
\d \w \s, \D \W \S | an ASCII digit, word character (letters, digits, _) or whitespace (space, tab, newline, carriage return, vertical tab, form feed), or anything else; inside a class too |
^, $ | the start or end of the text (with flag m, of a line: \n ends one); $ does not match before a final newline |
\A, \z | the start or end of the text, whatever the flags |
\b, \B | a word boundary, or a position that is not one |
(...), (?<name>...), (?:...) | a capture group (numbered from 1 by its opening parenthesis), a named one (also numbered), a group that captures nothing |
x|y | the first alternative that leads to a match (an empty alternative matches empty) |
* + ?, {n} {n,} {n,m} | repetition, as many as possible; a count runs to 1000, and a brace that starts no count is a literal brace |
*? +? ??, {n,m}? | repetition, as few as possible |
(?i), (?-i), (?i:...) | turns flags on or off to the end of the enclosing group, or inside the group alone (any of i m s x) |
The flags string holds any of i (letters match either case: ASCII letters, as to_upper maps them), m (^ and $ at line breaks), s (. takes a newline) and x (spaces and # comments outside a class are ignored); another letter, or one twice, raises. Among the matches that start leftmost, the one an alternation's order and each repetition's greed reach first wins, as in ECMAScript, Perl and PCRE; a loop pass that matches empty ends the loop.
Replacements and empty matches
In a replacement text $1 to $99 are groups (the longest number the pattern has: $10 with one group is $1 then 0), $0 the whole match, ${n} and ${name} a group by number or name, and $$ a dollar sign; a group that took no part is empty, and a reference to a group the pattern lacks, or a $ before anything else, raises. A function replacement takes the regex::match and returns the text, once per match, in order. Empty matches follow ECMAScript: after an empty match the next search starts one character on, so find_all, replace and split see a match between every character a pattern can match empty there, and split splits nothing at a piece's start or at the end of the text.
string out = "";
for (var m : regex("a*").find_all("baaab")) { out += "[" + m.text() + "]@" + to_string(m.start()) + " "; }
print(out);
print(regex("x*").replace("abc", "-"));
print(to_json(regex("").split("abc")), " ", to_json(regex("(,)").split("a,b")), " ", to_json(regex(",").split("a,b,c,d", 2)));
print(regex(R"((\w+) (\w+))").replace("ada lovelace", "$2, $1 ($$5, $0)"));
try { regex("(a)").replace("a", "$2"); } catch (e) { print("caught: ", e); }[]@0 [aaa]@1 []@4 []@5
-a-b-c-
["a","b","c"] ["a",",","b"] ["a","b","c,d"]
lovelace, ada ($5, ada lovelace)
caught: regex: the replacement names group 2 at position 0, but the pattern has 1 group
Strings match by byte, utf8_strings by character
A pattern is UTF-8 text. On a string a character is a byte: . and a class match one byte, positions count bytes, and a non-ASCII character in the pattern matches its UTF-8 bytes. On a utf8_string a character is a code point: . and classes take whole characters, positions count characters, an empty match moves on by a whole character, and the texts come back as utf8_string. A class that holds a multi-byte character would hold part of a character on a string, so using it on one raises (match the utf8_string, or write the bytes with \xNN). A byte of a utf8_string that starts no valid sequence is matched by nothing, and counts as one character in a position.
string bytes = "café 🙂";
utf8_string text = bytes;
print(regex("^.{4}").search(bytes).text().size(), " bytes, ", regex("^.{4}").search(text).text(), " as characters");
var m = regex("é.(.)").search(text);
print(m.start(), "..", m.end(), " ", type_of(m[1]), " ", m[1]);
print(regex("é").search(bytes).start(), " ", regex("é").search(text).start());
try { regex("[à-ÿ]").search(bytes); } catch (e) { print("caught: ", e); }
print(regex("[à-ÿ]").search(text).start());4 bytes, café as characters
3..6 utf8_string 🙂
3 3
caught: regex: the class at position 0 holds 'à', a character of more than one byte, but a string matches by bytes (match a utf8_string, or write its bytes as \xNN)
3
A constant pattern compiles once
When a regex(...) call's pattern and flags are string literals (a raw string, or a + of literals), the pattern compiles once, at load, on both backends: a regex(R"(\d+)") in a loop costs no compile per pass, only the new value. An invalid one is the load's error, at the argument, with its position in the pattern (error: regex: missing ')' to close the group at position 0); a pattern named by a const string this unit declares from literals is checked the same way. A pattern built at run time compiles when the call runs and raises the same texts, catchably; the engine keeps its recent patterns compiled, so building the same one again is cheap.
Every regex call runs on any worker of a parallel_for body, on a regex the body makes (a constant one compiles once for every worker) or one it captures from outside the loop; a function replacement is refused there as every function value is (Parallel Loops).
Linear time, and what it leaves out
A search runs a DFA built as the text is read, with the Pike VM over the match alone when groups are wanted; both read each byte a bounded number of times, and each keeps its own memory within a fixed budget, so a search takes time in proportion to the text times the pattern and never more. Backreferences (\1, \k<name>), lookahead and lookbehind ((?=, (?!, (?<=, (?<!), atomic groups ((?>), possessive quantifiers (*+) and recursion ((?R)) need backtracking, so a pattern that uses one is an error that names it. Patterns whose program grows past the engine's limit (a deeply nested count, (a{1000}){1000}) raise too.
string pattern = R"((\w+)\1)";
try { regex(pattern); } catch (e) { print("caught: ", e); }
string open = "(a|b";
try { regex(open); } catch (e) { print("caught: ", e); }
string many = "";
for (int i = 0; i < 30000; ++i) { many += "a"; }
print(regex("(a+)+$").search(many + "!") == null, " ", regex("^(a|aa)*$").matches(many));
print(regex::escape("1+1=2? [yes]"), " ", regex("^" + regex::escape("1+1=2?") + "$").matches("1+1=2?"));
print(to_json(regex(R"(\d+)", "i")));
regex back = from_json(to_json(regex(R"(\d+)", "i")));
print(back.pattern, " ", back.flags, " ", back.search("x42").text());caught: regex: the backreference '\1' at position 5 is not supported: matching runs in linear time, without backtracking
caught: regex: missing ')' to close the group at position 0
true true
1\+1=2\?\ \[yes\] true
{"$type":"regex","$val":{"pattern":"\\d+","flags":"i"}}
\d+ i 42
to_json writes a regex as its pattern and flags, and from_json and from_binary compile it again. A regex::match is a result rather than saved state: save its texts and positions instead.
Parallel loops
parallel_for, parallel_transform and parallel_reduce run a loop over an array on every core, with results identical to the serial loop at every worker count. Parallel Loops covers them: what a parallel body may do, the zip form, captured reads, index writes, constructing objects, C++ calls and reductions.