Containers & Strings​

Arrays ([1, 2, 3]), maps ({"key": value}) and strings are values: assigning one copies it. Six rules govern the rest:

  • A method changes its receiver in place and returns nothing. xs.sort() sorts xs. The function of the same name in array::, map:: or string:: leaves its argument alone and returns a copy: array::sort(xs).
  • A typed container enforces its types. array<int> and map<string, int> convert a compatible value and reject the rest. auto infers the type from a uniform literal; var or array<var> holds anything.
  • Reading a missing map key raises. m[k] = v inserts; m.get(k) reads without raising.
  • Map keys are ordered and never coerce. Iteration and serialization walk keys in order; 1 and 1.0 are two keys.
  • A string is bytes. size(), s[i] and substr count bytes and s[i] is a char. A utf8_string counts characters.
  • Every value has one display text, which print, to_string, template strings and + onto a string all use. to_json writes the text from_json reads back.

The methods​

Every method name, by type: builtin methods. Every signature: the API reference.

Some names are exact synonyms, the same arguments, the same result and the same in-place behavior under either name: size = length, contains = has, remove = erase (every synonym).

Arrays​

jaiscript
auto nums = [4, 1, 3, 2];                  // an array<int>: auto infers it from the literal
nums.push(5);
print(nums.pop(), " ", nums.size(), " ", nums.front(), " ", nums.back(), " ", nums.index_of(3), " ", nums.contains(9));
nums.sort();                               // in place
auto evens = array::filter(nums, x => x % 2 == 0);       // a copy
print(nums, " ", evens, " ", array::slice(nums, 1, 3), " ", nums.join("-"));
nums.remove_if(x => x > 2);
print(nums, " ", nums.remove(0), " ", nums.remove(7), " ", nums);
var loose = [5, 2, 8];
try { var kept = loose.filter(x => x > 2); } catch (e) { print(e); }
output
5 4 4 2 2 false
[1, 2, 3, 4] [2, 4] [2, 3] 1-2-3-4
[2] true false [2]
filter changes loose in place and returns nothing; array::filter(loose, f) returns a filtered copy
MethodReturnsNotes
push(v), insert(i, v), clear()nothinginsert takes an index 0 to size() and moves the rest up; another index raises
pop()the element it tookon an empty array raises Cannot pop from empty array, as front, back, first and last raise
remove(i)whether there was oneerases the element at index i; out of range it returns false and changes nothing
remove_if(pred)how many it erasederases every element the predicate accepts
contains(v), index_of(v)bool; the index or -1compare with the language's ==: [1, 2].contains(2.0) is true, and a class's operator== is used
sort(), sort(f)nothingthe language's order, through a class's operator< or operator<=>; float NaNs last. f(x, y) answers whether x goes first.
join(sep)a stringeach element's display text

In place or a copy​

In place, returns nothingReturns a copy
xs.filter(f)array::filter(xs, f)
xs.sort(), xs.sort(f)array::sort(xs), array::sort(xs, f)
m.filter(f)map::filter(m, f)
s.trim()string::trim(s)

Every pair: in place or a copy.

  • Using the result of an in-place method is an error: auto ys = xs.filter(f); gives filter changes xs in place and returns nothing; array::filter(xs, f) returns a filtered copy, at load when the checker knows the receiver's type, else when the call runs, before it changes anything. A chain is the same use: for xs.filter(f).map(g) write array::map(array::filter(xs, f), g).
  • Dropping the result of a copying function is an error: each is [[nodiscard]], so array::sort(xs); as a statement fails the load ([[nodiscard]]).
  • Every other mutator returns nothing too (push, clear, insert, assign, reserve, a string's trim_left, pad_center, replace_first, erase and the rest of its in-place list, a handle's reset), except pop, remove, erase and remove_if on containers. A class's own method of one of these names returns what it declares.
  • slice, filter and concat keep the receiver's element type in both forms. xs.map(f) keeps it and converts each result as a store into xs would; array::map(xs, f) returns an array<T> when f declares its return type T, and an array<var> otherwise.
  • xs.slice(start) runs to the end, and array::slice(xs) copies the whole array. A copy copies value elements and shares handles.

Sizing​

An array sizes as a C++ std::vector does.

CallEffect
array<T>(n)n value-initialized elements: 0, false, an empty string or container, a null handle, or a default-constructed object
array<T>(n, v), array<T> xs(n, v);n copies of v
array<int>{3}braces list elements: [3], as std::vector<int>{3} is
resize(n), resize(n, v)grows with those same elements or shrinks from the end. A grow that fails (a constructor that throws) leaves the array as it was.
assign(n, v)makes it n elements equal to v
fill(v)sets every element to v; the size stays
reserve(n)makes room for n elements; nothing a script can see changes

A size is a non-negative int (array size cannot be negative). An element that stays is assigned as xs[i] = v assigns, through its class's operator=; a new element is a copy.

jaiscript
auto grid = array<int>(4);
array<string> names(2, "?");
print(grid, " ", names, " ", array<int>{4});
grid.resize(6, 1);
print(grid);
grid.resize(2);
grid.fill(7);
print(grid, " ", array::resize(grid, 3));
output
[0, 0, 0, 0] ["?", "?"] [4]
[0, 0, 0, 0, 1, 1]
[7, 7] [7, 7, 0]

Callbacks: map, reduce, find and friends​

A callback is a function, a lambda or an arrow function. It is called with the element, or with the element and its index when it declares two parameters. A predicate accepts only by answering true.

jaiscript
auto scores = [72, 95, 40, 88];
print(array::map(scores, x => x + 5), " ", scores.reduce((sum, x) => sum + x, 0));
print(scores.find(x => x > 90), " ", scores.find_index(x => x < 50), " ", scores.some(x => x < 50), " ", scores.every(x => x > 30));
[7, 9].for_each((x, i) => print(i, ": ", x));
array<int> hp = [10, 20];
hp.for_each([](int& x) { x -= 5; });       // a T& parameter is the element itself
print(hp);
var xs = [1, 2, 3];
int seen = 0;
for (auto x : xs) { if (xs.size() < 5) { xs.push(x + 10); } seen++; }      // a range-for sees pushed elements
int called = 0;
xs.for_each((x) => { if (xs.size() < 8) { xs.push(0); } called++; });      // a helper does not
print(seen, " ", called, " ", xs.size());
output
[77, 100, 45, 93] 295
95 2 true true
0: 7
1: 9
[5, 15]
5 5 8
MethodReturns
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
find(f)a copy of the first element f accepts, or null
find_index(f)its index, or -1
some(f), every(f)whether any / every element is accepted, stopping at the first that decides. On an empty array some is false and every is true.
  • A callback 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 range-for walks the live array: it visits elements pushed during the loop.
  • A throw in a callback ends the call and propagates.

Maps​

jaiscript
auto stock = {"sword": 1, "potion": 3};                 // a map<string, int>
stock["gold"] = 250;                                    // inserts
print(stock, " ", stock.keys(), " ", stock.values(), " ", stock.to_array());
print(stock.contains("potion"), " ", stock.get("shield"), " ", stock.get("shield", 0), " ", stock.erase("sword"), " ", stock.erase("sword"));
try { print(stock["shield"]); } catch (e) { print("caught: ", e); }
try { stock["shield"] += 1; } catch (e) { print("caught: ", e); }
for (auto name : ["orc", "elf", "orc"]) { stock[name] = stock.get(name, 0) + 1; }      // counting
for (auto& [item, count] : stock) { count *= 2; }       // auto& makes count the entry's value
print(map::filter(stock, (k, v) => v > 10), " ", stock.remove_if((k, v) => v < 5), " ", stock);
var ids = {1: "a"};
try { print(ids[1.0]); } catch (e) { print("caught: ", e); }
output
{"gold": 250, "potion": 3, "sword": 1} ["gold", "potion", "sword"] [250, 3, 1] [["gold", 250], ["potion", 3], ["sword", 1]]
true null 0 true false
caught: Key "shield" not found in map
caught: Key "shield" not found in map
{"gold": 500} 2 {"gold": 500, "potion": 6}
caught: Key 1.0 not found in map
OperationBehavior
m[k] = vinserts or overwrites
m[k] readraises Key "k" not found in map for a missing key and never grows the map, typed or var
m[k] += 1, m[k]++, m[k] ??= v, var& r = m[k]read the entry first, so they need the key to be there, where C++'s operator[] would insert one. Count with m[k] = m.get(k, 0) + 1.
get(k), get(k, fallback)the value, or null / the fallback
contains(k)whether the key is there
erase(k)whether the key was there
filter(f), map::filter(m, f), remove_if(f)f takes (key, value). filter keeps the accepted entries in place, map::filter returns them as a new map, and remove_if erases them and returns the count.
keys(), values(), to_array()arrays in key order; to_array gives [key, value] pairs
  • Order. Range-for, keys(), values() and serialization walk keys in order on every platform, whatever the insertion order. Lookup is hashed.
  • Iterating. for (auto [k, v] : m) binds copies of the key and the value; with auto& the value is the entry itself (a store through it converts to V or raises), and the key is still a copy. for (auto kv : m) gives a pair, kv.first and kv.second. The loop walks key order live: a key inserted after the current one is visited, one inserted before it is not, and an erased entry is skipped.
  • Stored objects. get, like an array's front, back, first and last, returns a stored object, array or map as 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.

Key types​

A map<K, V> checks its key type on every store, and keys never coerce: map<string, int> rejects m[5] (Cannot use 'int' as a key of type 'string', at load when the checker knows the key's type). A var map takes any value as a key, and still never merges kinds: {1: "a"}[1.0] raises Key 1.0 not found in map although 1 == 1.0.

DeclarationKeys and values
map<string, int> m;an empty map of that type, ready for m[k] = v
map<var, V>any key
map<auto, V>, map<K, auto>, auto m = {"a": 1}the auto side is inferred from a uniform literal, then enforced. A mixed literal raises Map 'auto' requires homogeneous keys (or values); an empty literal leaves the side dynamic.

Keys follow one total order, the order a map iterates and serializes in (every key kind):

KeyIdentity and order
different kindsnever one key: 1 and 1.0 are two
a char and its one-character stringone key, ordered as the string
an array, map or handleby the identity of the object it holds, never its contents
a class value with operator< or operator<=>ordered by it; two keys are one when neither is less
  • Class keys iterate grouped by class name, ahead of handles.
  • An operator that throws 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 order stale, as in a C++ map: erase it and insert the changed value.
  • In a parallel_for body, a map that holds class values cannot be captured, and a map the body builds raises at its first class key whose class orders or hashes (Parallel Loops).
jaiscript
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); }
map<var, int> mixed = {};
mixed[5] = 1;
mixed["five"] = 5;
mixed['a'] = 2;
mixed["a"] = 3;                            // a char and its one-character string are one key
print(mixed);
output
1,5 a
2,1 B
{5: 1, 'a': 3, "five": 5}

Typed containers​

array<T> and map<K, V> enforce their types on every push and store:

jaiscript
array<int> nums = [1, 2, 3];
nums.push(true);                     // converts to the element type
var bad = "nope";                    // a var: the checker cannot see its type
try { nums.push(bad); } catch (e) { print("caught: ", e); }
array<var> copy = nums;              // the explicit step to the dynamic form: a copy
copy.push("s");
array<float> widened = nums;         // typed to typed converts element by element
auto guessed = [1, 2];               // auto infers array<int>
var loose = [1, 2];                  // var: dynamic, whatever the literal held
loose.push("s");
array<array<int>> grid = [[1, 2], [3, 4]];
print(nums, " ", copy, " ", widened, " ", loose, " ", grid[1][0]);
array<uint8> pixels = [0, 128, 255];
pixels[1] += 100;
try { pixels[2] += 1; } catch (e) { print("caught: ", e); }
output
caught: Cannot push 'string' to array<int>
[1, 2, 3, 1] [1, 2, 3, 1, "s"] [1.0, 2.0, 3.0, 1.0] [1, 2, "s"] 3
caught: Cannot store 256 in array<uint8> (0..255)
RuleDetail
conversions into an element or a map valueint into float (a float into an int element needs cast<int>(x)), and bool ↔ int (true is 1; an int is false only when zero). float ↔ bool is rejected, and a number into a string needs to_string(v) or cast<string>(v).
rejectionan error naming the container (Cannot push 'string' to array<int>), at load for a literal, a typed push argument or a typed subscript store, and at run time (catchable) for a var. A rejected push leaves the array as it was. [[warn_mode]] downgrades the load error.
nullonly shared_ptr<T> and weak_ptr<T> elements admit null. A shared-handle element also takes a live instance of T or a subclass.
inferenceauto infers array<T> or map<K, V> from a uniform literal and enforces it; a mixed literal raises Array 'auto' requires homogeneous elements - type mismatch at index 1. A literal with no declared type under var is dynamic.
a typed array in a varstays typed: after var v = ints;, v.push("s") raises, while v = "hello" is fine
typed to typedarray<float> f = ints; converts element by element, by value; array<var> v = ints; is the dynamic copy
class elementsarray<Base> holds a Base or any subclass, and each element keeps its own class and fields (cast<Archer&>(squad[1]).range). array<Derived> converts to array<Base> by value. An unrelated class raises Cannot push 'Rock' to array<Unit>.
declared without an initializerempty, as every typed declaration takes its type's default (Type Ladder)

The declared element type is the storage. An array<int> or array<float> is a raw buffer of int64s or doubles, and every other element type but var (array<math::vec3> included) is packed the same way, so a typed loop and a C++ binding read it without boxing (performance notes).

Byte arrays. array<uint8> (0 to 255) and array<int8> (-128 to 127) hold one byte per element, and an element reads as an int.

  • Every store checks the range: Cannot store 256 in array<uint8> (0..255), from =, a compound assignment, ++, push, a reference or a conversion.
  • A bool stores 0 or 1. A float and a char need cast<int>.
  • array<int> x = bytes; and array<uint8> b = ints; convert by value; the second checks every element.
  • uint8 and int8 name array elements only: uint8 x = 5; is a parse error.

Printing values​

print, to_string, a template splice, format's {}, join, + onto a string, a string parameter or variable, and a throw's message all use a value's display text:

ValueDisplay text
a stringits bytes. Inside a container a string or utf8_string is quoted with " and a char with ', with \n, \r, \t, \\ and the quote escaped and other control bytes as \xNN.
a number, bool, null, a vector2.0, true, null, (1.0, 2.0)
an array, a map[1, 2, 3]; {"x": 1, "y": [2]} in key order
an instanceits string to_string() (its own or a base's); otherwise its class name and fields in declaration order, a base's first: P{x: 1, name: "hi"}
a shared_ptr or weak_ptrthe object it points to; an expired weak_ptr is null. An object met again while it is being shown prints <cycle P>; one held twice side by side prints twice.
a function, a coroutine handle<function>, <coroutine>
a host object with no bound to_stringits type name: <Sprite>

A value prints in full however large or deep it is. A to_string() that throws is the error of the call that asked for the text.

jaiscript
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], " ", {"name": "orc\n"});
print(Q(), " ", [Tag(), Tag()]);
shared_ptr<Link> a = new Link();
a.next = a;
print(a, " ", "tags: " + [Tag()]);
output
[1, 2.5, "two", 'c', true, null] {"name": "orc\n"}
Q{x: 1, name: "hi", hits: [2, 3]} [<tag>, <tag>]
Link{id: 0, next: <cycle Link>} tags: [<tag>]

JSON​

to_json(x) writes any value as compact JSON, and to_json(x, n) indents it n spaces per level. from_json(text) reads it back; a malformed text raises a catchable error. The same data always writes the same text, since map keys come out in order.

jaiscript
var save = {"name": "orc", "hp": [10, 20], "pos": {"x": 1.5}};
print(to_json(save));
print(to_json({"hp": [10]}, 2));
map<int, string> slots = {1: "sword"};
var back = from_json(to_json(slots));                 // int keys survive the round trip
print(to_json(slots), " ", back[1], " ", to_json('A'), " ", to_json(1.0 / 0.0));
class Hero { string name = "Vex"; int hp = 30; }
Hero h;
h.hp = 12;
Hero copy = from_json(to_json(h));
print(to_json(h), " -> ", copy.name, " ", copy.hp);
try { from_json("{bad"); } catch (e) { print("caught: ", e); }
output
{"hp":[10,20],"name":"orc","pos":{"x":1.5}}
{
  "hp": [
    10
  ]
}
{"$map":[[1,"sword"]]} sword {"$char":65} {"$float":"inf"}
{"$type":"Hero","$val":{"name":"Vex","hp":12}} -> Vex 12
caught: Expected '"' in JSON

A value JSON cannot spell is written as a tagged object ($map, $char, $float, $type, $id, $ref): JSON encoding.

  • from_json rebuilds an instance when its class exists, and a typed declaration takes it directly (Hero h = from_json(text);). It 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).
  • Handles that shared one object share it again after loading, through cycles too. An id the text never defined raises Invalid shared_ptr ID: 4.
  • to_binary and from_binary round-trip the same values and identities in a binary form.
  • No depth limit applies: a value nests as deep as memory allows, in JSON, binary and a copy alike.
  • JSON and binary archives for C++ objects are in Serialization.

Strings​

The reading methods return information, the in-place methods change the string and return nothing, and the string:: functions return a changed copy. auto upper = name.to_upper(); is an error (to_upper changes name in place and returns nothing; string::to_upper(name) returns an uppercased copy), as is the chain t.trim().to_upper().

jaiscript
auto s = "The Quick Fox";
print(s.length(), " ", s.find("Quick"), " ", s.find("Slow"), " ", s.count("o"), " ", s.substr(4, 5), " ", s.at(-1));
print(s.contains("Fox"), " ", s.starts_with("The"), " ", s.ends_with("Fox"), " ", s.partition(" "), " ", "b".compare("a"));
print("42".to_int() + 1, " ", "2.5".to_float(), " ", "abc".to_int(), " ", "abc".to_int() ?? -1);
print("  stats 35\t25 ".split(), " ", "a,,b".split(","), " ", "a,b,c".split(",", 1), " ", "ab".split(""), " ", "a\r\n\nb\n".lines());
var t = "  hello world  ";
t.trim();
t.to_upper();
t.replace("WORLD", "JAI");
var id = "7";
id.pad_left(3, '0');
print(t, " ", string::to_lower(t), " ", id, " ", string::repeat("-", 5), " ", string::join(["a", "b"], "+"));
var built = "a";
built += 'b';                               // a char appends as its character
built = built + "c" + [1];                  // an object or container joins its display text
try { built += 5; } catch (e) { print("caught: ", e); }
print(built);
output
13 4 -1 1 Quick x
true true true ["The", " ", "Quick Fox"] 1
43 2.5 null -1
["stats", "35", "25"] ["a", "", "b"] ["a", "b,c"] ["a", "b"] ["a", "", "b"]
HELLO JAI hello jai 007 ----- a+b
caught: Cannot apply '+=' to string and int
abc[1]
MethodBehavior
find(text)the byte position, or -1
substr(start, len)len bytes from start
at(i)the char s[i] reads; a negative i counts from the end
to_int(), to_float()the number, or null when the text is not one. A typed slot refuses the null (int n = "abc".to_int(); raises Cannot initialize 'n' (declared int) with null), so supply a default: s.to_int() ?? 0.
split()splits on whitespace (space, tab, newline, carriage return, vertical tab, form feed) and drops empty pieces; split(string::whitespace) spells it out
split(sep), split(sep, max)cuts at each exact separator and keeps empty pieces; max caps the number of splits and the rest stays in the last piece
split("")characters: bytes for a string, characters for a utf8_string
lines()\n and \r\n both end a line, blank lines stay, and a trailing newline adds no empty last element
partition(sep)[head, sep, tail]; [s, "", ""] when sep is absent
a.compare(b)-1, 0 or 1
is_digit, is_alpha, is_alnum, is_space, is_upper, is_lowertrue when the string is non-empty and every byte is in the ASCII class. Case mapping is ASCII too.
replace(from, to)every occurrence, left to right without overlap ("aaaa" with "aa" → "b" is "bb"); replace_first and replace_last replace one
pad_left(n, fill), pad_right, pad_centerpad to n with spaces or a fill; a fill of several characters tiles from its start ("ab".pad_left(7, "xyz") is "xyzxyab"), and an empty one raises pad_left() padding character cannot be empty
repeat(n)a negative count raises string::repeat count cannot be negative
  • The copying functions are listed by name (the pads take an optional fill: string::pad_left(s, n, [fill])); string::join(parts, [sep]) joins an array of strings.
  • The namespace is open: namespace string { function shout(string s) -> string { return string::to_upper(s) + "!"; } } adds string::shout.
  • + joins text only: strings, chars, and the display text of an object or container. A number, bool or null beside a string is an error (Operators).
  • += 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, so a loop that builds a page costs the bytes it appends (the cost model). A copy taken along the way keeps the text it had.

Bytes and chars: s[i], \xNN, cast<char>​

s[i] reads a char. In arithmetic, bitwise operators and comparisons with numbers a char is an integer 0–255, always unsigned ("\xC3"[0] + 0 is 195), the unary -c, +c and ~c included: -'a' is -97. Beside a string it is text (Operators). The byte-work idioms:

jaiscript
var s = "Jai\x21";                             // \xNN: exactly two hex digits
print(s, " ", s[0], " ", type_of(s[0]), " ", s[0] + 0, " ", "7"[0] - '0', " ", s[3] == 0x21);
print((s[0] + 0) | ((s[1] + 0) << 8));         // a little-endian u16 from two bytes
var out = "";
out += to_char(0x4A);                          // int -> char, range-checked 0..255
for (auto c : "ab") { out += c; }              // range-for binds each char by value
print(out);
string name = "jai";
name[0] = 'J';                                 // a char replaces byte 0
name[2] = "i!!";                               // a string replaces byte 2 with its whole text
name[1] += "h";                                // name[1] = name[1] + "h"
print(name);
var five = 5;
try { name[0] = five; } catch (e) { print("caught: ", e); }
output
Jai! J char 74 7 true
24906
Jab
Jahi!!
caught: Cannot assign int to a string character (it takes a char or a string)
  • A subscript is a store target. s[i] = c replaces byte i, and s[i] = text replaces it with the whole text, so the string grows or shrinks. A compound store reads the char, applies the operator and writes the result back. Anything but a char or text raises Cannot assign int to a string character (it takes a char or a string), a load error when the checker sees the types. A copy of the string taken before the write keeps its bytes.
  • An index past the end raises String index 9 out of bounds for string of size 5, read or write.
  • for (auto c : s) binds each char by value; auto& raises, since a char read from a string is not a variable.
  • Chars and numbers convert only through cast: cast<int>(c) and cast<float>(c) give the code, cast<string>(c) the one-character string, and cast<char>(n) or cast<char>("a") a char (Cannot cast 'string' to 'char' for a longer string). to_char(n) is the function form.

Binary data​

A string holds any byte 0–255, NULs included, and read_file / write_file move bytes unchanged, so a file's bytes are a string.

CallResult
s.to_bytes(), s.to_bytes(offset), s.to_bytes(offset, count)an array<uint8> (on a utf8_string, its UTF-8 bytes). The range is checked: to_bytes(1, 3) out of bounds for string of size 3.
string::from_bytes(bytes)a string from an array<uint8> or array<char>, or from an array<int> or array<var> whose every value is 0–255 (else string::from_bytes expects bytes (0 to 255): element 3 is 256)
read_file(path)the file's bytes; an empty file is "". A missing or unreadable file raises Cannot read file 'saves/world.dat': not found (or not a file, permission denied). Ask first with file_exists(path) (Where files are).
write_file(path, text)writes the bytes exactly: embedded NULs, \r\n untouched
jaiscript
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(r.text(4), ", lump count ", r.int32());
r.seek(r.int32());                           // the directory
var e = r.unpack(ENTRY);                     // map<string, var>: name -> value
print(e["name"], " at ", 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, " ", wad.to_bytes(0, 4), " ", string::from_bytes([80, 87, 65, 68]));
try { r.int32(); } catch (err) { print("caught: ", err); }
try { w.uint8(256); } catch (err) { print("caught: ", err); }
output
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)

binary::reader. s.reader() or binary::reader(s) reads the string from a cursor at 0, little-endian. Every read advances the cursor. Every call: binary::reader.

  • Reading past the end raises Cannot read int32 at position 10: 4 bytes wanted, 2 remain and leaves the cursor where it was.
  • text() never scans past the end of the data or past the host's engine::binary_scan_limit (1 MiB unless the host sets it; a script cannot change it). With no NUL 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 ... no NUL terminator within 1048576 bytes (the engine's binary scan limit; raise it from C++ with engine->binary_scan_limit).
  • A reader is a value: auto r2 = r; copies the cursor, and a binary::reader& parameter shares it. It reads the string as it was when the reader was made.

binary::writer. binary::writer w; mirrors the reader, and each call returns the writer. Every call: binary::writer.

Layouts. binary::layout(name, width, ...) builds a reusable layout, validated once (binary::layout fields total more than 9223372036854775807 bytes). The widths are binary::int8, int16, int32, int64, uint8, uint16, uint32, float32, float64, binary::text(n) and binary::bytes(n): values that describe an encoding, not types. binary::bytes(n) unpacks to an array<uint8> and packs exactly n bytes. Field order comes from the arguments, never from a map. The checker reports at load a wrong signature, an odd name/width pairing, a non-width where a width belongs, and a read(Class, layout) whose class and layout do not match. As a table: layouts and widths.

UTF-8 text​

A string counts bytes. Its UTF-8 methods read characters without changing that, and a utf8_string holds the same bytes with every position a character:

jaiscript
string s = "héllo 🙂";
print(s.size(), " bytes, ", s.utf8_length(), " characters, valid: ", s.is_valid_utf8());
print(s.utf8_chars(), " ", "é".code(), " ", string::from_code(233));
utf8_string name = "Zoë 🙂";
print(name.size(), " ", name[2], " ", name.byte_size(), " ", name.substr(0, 3), " ", type_of(name[2]));
for (auto c : name) { print(c, skip_newline); }
string raw = name;                  // the same bytes, counted as bytes
print(" ", raw.size(), " ", name == raw);
utf8_string u = "abc";
u[1] = "日";
print(u, " ", u.size(), " ", u.byte_size());
output
11 bytes, 7 characters, valid: true
["h", "é", "l", "l", "o", " ", "🙂"] 233 é
5 ë 9 Zoë utf8_string
Zoë 🙂 9 true
a日c 3 5
On a stringResult
utf8_length()the number of characters
utf8_chars()each character as a string
is_valid_utf8(), string::is_valid_utf8(s)whether every byte belongs to a valid sequence
code()the code point of a string of exactly one character
string::from_code(n)the character; raises for a surrogate or a value past U+10FFFF
to_utf8(), utf8_string(x), a utf8_string declarationa utf8_string

utf8_string: text by character

  • On a utf8_string, size(), length(), u[i], at, substr, find, insert, erase, the pads, reverse, split("") and range-for count and return characters. A character is a one-character utf8_string; byte_size() counts bytes.
  • A byte that starts no valid sequence is one character of its own, everywhere, and keeps its byte: "a\xFFb" is three characters. No text is ever lost.
  • A string variable or a C++ std::string parameter takes a utf8_string as its bytes, and the two compare and order by bytes: u == s holds for the same text.
  • + with a utf8_string on either side makes one, and a method or string:: function that returns text returns the receiver's kind.
  • u[i] = text replaces character i whatever its byte width, and u[i] += text appends to it.
  • Positions are cheap: ASCII text indexes like bytes, a loop over u[i] steps a character at a time, and random reads index a long text once.

Digests, HMACs and password hashes are crypto:: functions (Random Numbers & Crypto).

Template strings & format specs​

A backtick string splices any expression with ${...}, as its display text. It takes the escapes of a double-quoted string plus \` and \$, and a line ending inside the backticks is one \n. A splice, and a {} placeholder of print or format, can end in a format spec, ${expr:spec}:

[[fill]align][+| ][0][width][,|_][.precision][d|x|X|b|f|e|g]

jaiscript
var name = "Vex";
var hp = 73.5;
var gold = 1234;
print(`${name:<8}|${gold:8}|${gold:*>8}|${gold:^7}|`);
print(`${hp:.1f} ${hp:>8.2f} ${gold:05} ${-42:05} ${gold:+} ${gold:x} ${gold:X} ${5:b}`);
print(`${gold * 1000:,} ${hp * 1000:,.2f} ${gold:_b} ${1234:010,}`);
var crit = true;
print(`${crit ? "CRIT" : "hit"} for ${(crit ? 150 : 100):>5}|`);
try { print(`${name:.2f}`); } catch (e) { print("caught: ", e); }
print(format("{:.2f} vs {0:>8}", 3.14159));
output
Vex     |    1234|****1234| 1234  |
73.5    73.50 01234 -0042 +1234 4d2 4D2 101
1,234,000 73,500.00 100_1101_0010 00,001,234
CRIT for   150|
caught: format spec ':.2f' does not apply to string value
3.14 vs  3.14159

Each part: format specs.

  • A spec that does not fit the value raises when it runs: format spec ':.2f' does not apply to string value.
  • A malformed spec (${x:q}, ${x:#x}, ${n:,x}) in a template string is a load error, 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.
  • The spec starts at a top-level single : that closes no ternary, so ${a ? b : c} stays an expression and ${m["k"]:6} works. Parenthesize a ternary to give it a spec.
  • :: is scope resolution, never a spec: ${::hp} reads the global hp, and ${::hp:5} pads it.
  • format_value(value, "spec") is the builtin behind a splice, on every engine.

Regular expressions​

regex(pattern) and regex(pattern, flags) make a regex value. Write the pattern as a raw string, R"(\d+)", so backslashes stay backslashes. Matching is linear in the text: no pattern backtracks, so a hostile pattern or text cannot stall a search.

jaiscript
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(), ": ", m[1], " / ", m["month"], " (", 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}"), " ", date.search("no dates") == null);
print(regex(R"(\d+)").replace("a1b22c333", (x) => "<" + to_string(x.text().size()) + ">"));
print(regex(R"(\s*,\s*)").split("red , green,blue"), " ", regex("(,)").split("a,b"), " ", regex(",").split("a,b,c", 1));
print(regex(R"(^[a-z_]\w*$)", "i").matches("Player_2"), " ", regex::escape("1+1=2?"));
print(regex("x*").replace("abc", "-"), " ", regex(R"((\w+) (\w+))").replace("ada lovelace", "$2, $1 ($$5)"));
string backref = R"((\w+)\1)";
try { regex(backref); } catch (e) { print("caught: ", e); }
output
2026-10-21 at 4..14: 2026 / 10 (4 slots)
21 at 0
02 at 11
on 21.10.2026 true
a<1>b<2>c<3>
["red", "green", "blue"] ["a", ",", "b"] ["a", "b,c"]
true 1\+1=2\?
-a-b-c- lovelace, ada ($5)
caught: regex: the backreference '\1' at position 5 is not supported: matching runs in linear time, without backtracking
CallAnswers
r.search(text)the leftmost match, a regex::match, or null
r.matches(text)whether the whole text matches
r.find_all(text)every match, left to right, as an array of regex::match
r.replace(text, with)the text with every match replaced by a replacement text, or by what a function returns for the match
r.split(text)the pieces between matches

Every call, and what a regex::match answers: regex calls.

A match is a value: it holds its own texts. to_json writes a regex as its pattern and flags, and from_json and from_binary compile it again; a regex::match is not saved state.

The syntax​

Every construct: regex syntax. The flags i, m, s and x: regex flags.

Another flag 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.

Constructs that need backtracking are errors that name the construct (regex: the backreference '\1' at position 5 is not supported: matching runs in linear time, without backtracking): backreferences (\1, \k<name>), lookahead and lookbehind ((?=, (?!, (?<=, (?<!), atomic groups ((?>), possessive quantifiers (*+) and recursion ((?R)). A pattern whose compiled form grows past the engine's limit ((a{1000}){1000}) raises too.

Replacements, empty matches and text kinds​

A replacement text inserts a group as $1, ${name} or $0 (the whole match) and a dollar sign as $$: replacement text.

  • A group that took no part inserts nothing. A group the pattern lacks, or a $ before anything else, raises: regex: the replacement names group 2 at position 0, but the pattern has 1 group.
  • A function replacement is called once per match, in order.
  • Empty matches follow ECMAScript: after one, the next search starts one character on. So regex("x*").replace("abc", "-") is -a-b-c-, and split splits nothing at a piece's start or at the end of the text.

A string matches by byte, a utf8_string by character.

  • On a string, . and a class match one byte, positions count bytes, and a non-ASCII character in the pattern matches its UTF-8 bytes. A class that holds a multi-byte character raises on a string (match a utf8_string, or write the bytes as \xNN).
  • On a utf8_string, . 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 byte that starts no valid sequence is matched by nothing and counts as one character in a position.

A constant pattern compiles once, at load, when the pattern and flags are string literals (a raw string, a + of literals, or a const string the file declares from literals); an invalid one is a load error with its position (regex: missing ')' to close the group at position 0). A pattern built at run time compiles when the call runs and raises the same texts, catchably; the engine keeps recent patterns compiled. Regex calls run inside a parallel_for body, except a function replacement (Parallel Loops).

Parallel loops​

parallel_for, parallel_transform and parallel_reduce run a loop over an array on every core, with the serial loop's results: Parallel Loops.