Standard Library
Strings, Chars & Regex
A byte-clean string with a native core and an in-language
toolkit on top, a one-scalar char for when you need a
single Unicode character, a mutable StringBuilder for
linear-time accumulation, and a regex engine that is linear-time by
construction — no backtracking, no ReDoS.
The string type
A Leviathan string is byte-clean: an
embedded NUL survives rather than terminating the string, and every
byte you put in comes back out. It is built from a small
native core — the primitives the runtime implements
directly — plus an in-language toolkit layered over
that core (split/join, transform, and query helpers written in
Leviathan itself, not the runtime). From the caller's side the
distinction is invisible; it only matters for which methods run as
raw native calls versus prelude code.
Relational operators < > <= >= are
lexicographic — a byte-wise comparison of the
character data, not identity or length. "" orders
first, and equal strings compare equal.
bool a = ("" < "a"); // true — empty string orders first
bool b = ("apple" < "banana"); // true — byte-wise
string s = "caf\u{e9}"; // byte-clean: any scalar, any byteString methods
Grouped by role. The core group is native; the rest is the in-language toolkit built on top of it.
Core
| Method | Signature | Notes |
|---|---|---|
length() | () -> int | byte length |
charAt(int) | (int) -> string | single-character string at a byte index |
subStr(int start, int len) | (int, int) -> string | byte-range substring |
indexOf(string) | (string) -> int | first occurrence, or -1 |
byteAt(int) | (int) -> int | raw byte value 0..255; out of range throws RuntimeException (same wording as Array.at's OOB — a byte position is either in the string or it's a bug) |
toUpper(), toLower() | () -> string | |
trim() | () -> string | strips leading and trailing whitespace |
contains(string), startsWith(string), endsWith(string) | (string) -> bool | |
toString() | () -> string | identity |
toInt(), toFloat() | () -> int? / () -> float? | strict, optional-returning — see Parsing |
The reverse of byteAt, std::byteToString(int b),
is a std free function rather than a static method on
string — the language has no static keyword
yet, so there's no plumbing for a string::fromByte(int)
static-side native to land on. It throws the same way for b
outside 0..255.
Char access
| Method | Signature | Notes |
|---|---|---|
at(int i) | (int) -> char | decodes the scalar starting at byte offset i — O(1), pairs with the byte-counted length(); a mid-sequence byte offset throws RuntimeException ("not a scalar boundary") |
chars() | () -> Array<char> | full UTF-8 decode; invalid bytes decode to U+FFFD, never a throw — malformed input is data, not a programming error |
Search
| Method | Signature | Notes |
|---|---|---|
lastIndexOf(string) | (string) -> int | |
indexOfFrom(string, int from) | (string, int) -> int | a distinct method, not an indexOf overload |
count(string) | (string) -> int | non-overlapping occurrences; "" counts as 0 |
Split & join
| Method | Signature | Notes |
|---|---|---|
split(string sep) | (string) -> Array<string> | empty sep splits into an array of 1-char strings; keeps empty segments |
splitLines() | () -> Array<string> | splits on \n, trims one trailing \r per line — the sanctioned way to strip CRLF |
Transform
| Method | Signature | Notes |
|---|---|---|
replace(string from, string to) | (string, string) -> string | |
padStart(int, string), padEnd(int, string) | (int, string) -> string | |
repeat(int) | (int) -> string | <= 0 returns "" |
trimStart(), trimEnd() | () -> string | |
removePrefix(string), removeSuffix(string) | (string) -> string |
Queries
| Method | Signature | Notes |
|---|---|---|
isEmpty() | () -> bool | |
isBlank() | () -> bool | trim().isEmpty() |
equalsIgnoreCase(string) | (string) -> bool |
string.reverse()
A byte reverse is wrong for UTF-8 content, so it's deliberately not offered as a method. Use the scalar-correct idiom instead:
string reversed = s.chars().reverse().joinToString("");Parsing: toInt / toFloat
toInt() and toFloat() are
strict, optional-returning parses. toInt()
accepts an optional leading - and digits only — no
surrounding whitespace, no leading + — and requires the
full string to be consumed; anything else, including
numeric overflow, produces None rather than a guess.
toFloat() is the same shape over strtod,
additionally rejecting non-finite results: the text "inf"
or "nan" parses to None, not an infinite or
NaN value.
atoll-style parsing
An earlier version of toInt() silently turned garbage
input into 0, C's atoll style. That
behavior is gone — garbage now returns None, so a
silently-wrong 0 can no longer hide a bad parse.
Narrow the optional before use, the same as any T?:
int? p = "42".toInt();
int n = p ?? 0;
int? bad = "42abc".toInt(); // None — trailing garbage, not a partial parse
if (bad != None) { /* unreachable here */ }
float? f = "3.14".toFloat();
float? nanText = "nan".toFloat(); // None — non-finite text is rejectedchar: one Unicode scalar
char holds exactly one Unicode scalar
(0..0x10FFFF, surrogates excluded), unboxed,
defaulting to '\0' on a bare declaration. Literals are
target-typed — see also
Types & Generics for the full
typing rule. Comparisons (== != < <= > >=) are
by scalar value.
char
There is deliberately no +/- on
char, avoiding C's integer-promotion pitfalls. Go
through code() for anything numeric:
char c = 'a';
int next = c.code() + 1; // 98
char d = std::charFromCode(next); // 'b'| Method | Signature | Notes |
|---|---|---|
code() | () -> int | the Unicode scalar value |
toString() | () -> string | UTF-8 encode to a one-character string |
isDigit(), isAlpha(), isUpper(), isLower(), isSpace() | () -> bool | ASCII ranges only — a non-ASCII char returns false in v1 |
toUpper(), toLower() | () -> char | ASCII-only; a non-ASCII char returns itself unchanged |
std::charFromCode(int) -> char is the
factory: a native free function, since class static sides don't exist
yet. An out-of-range or surrogate code point throws
RuntimeException.
On the string side, at(i) and
chars() (see Char access
above) are how you move between bytes and scalars.
char c = 'a'; // char — the context expects one
var s = 'a'; // stays string — no char context here
string word = "caf\u{e9}";
Array<char> scalars = word.chars(); // one entry per Unicode scalar, not per byteStringBuilder
Repeated string concatenation with + re-copies on every
step. StringBuilder is the accumulator that avoids that:
unlike the pure, immutable Array/Map, it is
a mutable class — reference semantics, where mutation
across call sites is the entire point.
| Member | Signature | Notes |
|---|---|---|
add(string s) | (string) -> StringBuilder | appends; returns this, chainable |
(<<)(string s) | (string) -> StringBuilder | operator sugar for add(s) |
length() | () -> int | |
isEmpty() | () -> bool | |
toString() | () -> string | O(total) — joins the accumulated parts in one pass |
StringBuilder sb = StringBuilder();
sb.add("Hello, ").add("world").add("!");
string s = sb.toString(); // "Hello, world!"
Verified linear, not quadratic, on the emit-C++,
LLVM, and frozen-ELF backends — 100k .add() calls run in
under 0.1s on each.
(<<) doesn't lower on emit-C++ for user classes
--run, --ir, and LLVM all support the
<< operator sugar on user-defined classes like
StringBuilder, but the emit-C++ backend does not yet
lower it there. Use .add(...) when targeting
emit-C++.
Regular expressions
The regex engine compiles byte-oriented patterns to a flat
Thompson NFA, run with an ordered Pike VM for
leftmost-first captures, backed by a byte-equivalence-class
lazy DFA for captureless boolean queries (fast paths
for isMatch/count). Literal,
required-prefix, anchored, single-first-byte, and minimum-length
prefilters are selected at compile time.
There is no backtracking, so matching stays linear in input length times compiled-program size regardless of pattern shape — a user-supplied pattern cannot create a catastrophic-backtracking (ReDoS) path. The cost is expressiveness: backreferences and lookaround are intentionally unsupported.
Supported syntax: literals and escapes, ., ASCII classes
and \d/\w/\s, alternation,
capturing/non-capturing/named groups, greedy and lazy quantifiers,
anchors, multiline mode, word boundaries, ASCII ignore-case, and
dot-all. Offsets and lengths throughout the API are
bytes, not scalars.
The entire engine is implemented in Leviathan prelude code and adds
no natives. Most code should reach it only through the
Regex/Match/Group surface below
and namespace regex — not the lower-level
regex::compileProgram/programIsMatch/programFind/programCount
boundary, which is the stable target the public library is itself
built on (useful for advanced or comptime use).
Regex & Match
A C#-shaped public API in front of the engine core: a compiled
Regex object, plus Match/Group
value types and namespace regex conveniences. All five
public types — Regex, Match,
Group, RegexOptions, RegexException
— are top-level, not namespace-nested. Method names are camelCase
(isMatch, not Matches); the first-match
method is find, not match — match
is the pattern-dispatch keyword, and one word must not mean two
things.
Regex userRe = Regex("^[a-z0-9_]{3,16}$");
bool ok = userRe.isMatch(name);
Regex kvRe = Regex("(?<key>\\w+)=(?<val>[^;]*)");
Match? m = kvRe.find(line);
if (m != None) { console.writeln(m.group("key")?.value ?? ""); }Regex
Reference semantics — share freely, it wraps a compiled
Array<int> program.
| Group | Member | Notes |
|---|---|---|
| construct | Regex(string pattern), Regex(string, RegexOptions), Regex(string, string flags) | pattern-as-code: a malformed pattern throws RegexException |
| construct | Regex::FromProgram(Array<int> program) | O(1) wrap of an already-compiled program — the comptime path |
| introspect | pattern(), options(), groupCount(), groupNames() -> Array<string> | declared (?<name>…) names, in declaration order |
| test | isMatch(string), isMatch(string, int from) | DFA fast path |
| extract | find(string) -> Match?, find(string, int from) -> Match? | first match, or None |
| extract | matches(string) -> Array<Match> | all non-overlapping matches |
| count | count(string) -> int | DFA-only; never materializes a Match |
| replace | replace(string, string replacement[, int count]) | $-grammar below; replaces all by default |
| replace | replace(string, (Match) => string fn[, int count]) | evaluator form |
| split | split(string) -> Array<string>, split(string, int limit) -> Array<string> | |
| batch | isMatchAll(Array<string>) -> Array<bool>, countAll(Array<string>) -> Array<int> | one warm engine across a column (DBMS shape) |
Match & Group
Match is a struct — a value, returned only
on success. There is no success field: absence of a
match is None (find() -> Match?) — there
is already a word for "no match," and it isn't a flag on a hollow
object.
| Member | Notes |
|---|---|
index, length, value | byte offset/length and text of the whole match |
groups: Array<Group> | groups[0] is the whole match (C# convention), then 1..n |
group(int i) -> Group | out of range throws RuntimeException, like Array.at |
group(string name) -> Group? | None if no such declared name |
Group is a struct: matched: bool,
index: int (-1 if !matched),
length: int (0 if !matched),
value: string ("" if !matched).
An unparticipating group inside a successful match —
an untaken alternation arm — is a real, reachable state:
matched stays false rather than the whole
Match going absent.
RegexOptions & RegexException
RegexOptions is a struct, built with
named-arg construction: ignoreCase (ASCII-only fold in
v1), multiline (^/$ also match
at \n boundaries), dotAll (.
matches \n too — the honest rename of C#'s confusing
Singleline).
Regex a = Regex(p, RegexOptions(multiline: true));
Regex b = Regex(p, "im"); // string-flags shorthand: i / m / s, any order
Regex c = Regex(p, "s");
RegexException : Exception carries message
plus int offset — a best-effort byte position in the
pattern, parsed from the underlying compile error;
-1 when a message carries no offset. It's thrown by
every pattern-as-code path (constructors, and the
namespace regex convenience functions below) and is
catchable by contract as any IException.
namespace regex & replacement grammar
The None-vs-throw split, one rule: pattern
as data — regex::compile(pattern[, options|flags]) -> Regex?
— returns None on malformed input, never a throw (for
patterns sourced from config or user input). Pattern as
code — a literal at the call site, via constructors or the
convenience functions below — throws
RegexException: a malformed literal is a programmer
error and should fail loud.
namespace regex conveniences use compile-per-call
semantics, backed by a 16-entry LRU pattern cache (correctness only
depends on the cache being present, not on its eviction
order): compile, isMatch, find,
matches, replace, split,
count each have (s, pattern),
(s, pattern, RegexOptions), and
(s, pattern, string flags) forms (replace
additionally takes an evaluator lambda in place of a replacement
string). Plus escape(string) -> string (quotes every
metacharacter: \.^$|()[]{}*+?) and the engine's own
compileProgram/programIsMatch/…
boundary for advanced or comptime use.
Replacement-string grammar (replace,
parsed once per call before scanning): $0…$99
(group by number — greedy two-digit read, the longest valid group
number wins), ${name} (named group), $$
(literal $); a bare $ before anything else
or at the end is a literal $. An unmatched group
substitutes "". A reference to a nonexistent group
throws RegexException — replacement
strings are code, and silent passthrough (the C#/JS behavior) is
exactly the silent-distant footgun the language avoids elsewhere.
${name} in replacement templates
Leviathan's own string literals treat "...${expr}..."
as interpolation. A replacement template containing a named-group
reference must escape it — "\${name}" — to deliver
the literal three characters ${name} to the regex
engine, rather than having the host language try to interpolate
a variable named name before the string even reaches
replace.
Regex semantics
Pinned by the test corpus (tests/corpus/regex/):
| # | Rule |
|---|---|
| 1 | Leftmost-first, greedy by default (Perl/C#/JS, not POSIX-longest) — falls out of the engine's Pike VM thread priority. |
| 2 | Empty-match advance: a zero-length match at position i is reported, then the scan resumes at i+1 — this is what prevents the classic infinite loop in matches/replace/split. |
| 3 | split includes captured-group text in the output when the separator pattern captures (C# behavior, kept for porting fidelity). split(s, limit) returns at most limit pieces, with the remainder unsplit in the last piece. |
| 4 | replace replaces all by default; the count overloads bound it — this differs from JS's String.replace and matches C#. |
| 5 | Byte-oriented v1: offsets/lengths are bytes, and . matches one byte — a multi-byte UTF-8 scalar is not one .. Use string.chars() for the scalar view; codepoint classes are a deferred follow-up. |
| 6 | ignoreCase is ASCII-only in v1 — a compile-time fold in the engine. |
A constant pattern used with comptime compiles at
build time — a malformed constant pattern is then a
build error reported at the pattern's source line, not a runtime
surprise:
comptime Array<int> EMAIL_P = regex::compileProgram("^[\\w.+-]+@[\\w-]+\\.[\\w.]+$", "");
Regex emailRe = Regex::FromProgram(EMAIL_P); // O(1) wrap, no parse at runtime
Array<bool> hits = emailRe.isMatchAll(column); // batch: one warm engine per column
Deferred (named so they aren't re-litigated ad hoc):
raw string literals as a first-class pattern source (a general lexer
feature, see Raw strings),
a (~) match operator, regex patterns in match
arms, a lazy Seq<Match> adapter, Unicode
classes/folding, escape's inverse (unescape),
IgnorePatternWhitespace, scalar-indexed offsets, and
columnar/Block scanning fused with where.