LEVIATHAN v0.1 · in development

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 byte

String methods

Grouped by role. The core group is native; the rest is the in-language toolkit built on top of it.

Core

MethodSignatureNotes
length()() -> intbyte length
charAt(int)(int) -> stringsingle-character string at a byte index
subStr(int start, int len)(int, int) -> stringbyte-range substring
indexOf(string)(string) -> intfirst occurrence, or -1
byteAt(int)(int) -> intraw 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()() -> stringstrips leading and trailing whitespace
contains(string), startsWith(string), endsWith(string)(string) -> bool
toString()() -> stringidentity
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

MethodSignatureNotes
at(int i)(int) -> chardecodes 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
MethodSignatureNotes
lastIndexOf(string)(string) -> int
indexOfFrom(string, int from)(string, int) -> inta distinct method, not an indexOf overload
count(string)(string) -> intnon-overlapping occurrences; "" counts as 0

Split & join

MethodSignatureNotes
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

MethodSignatureNotes
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

MethodSignatureNotes
isEmpty()() -> bool
isBlank()() -> booltrim().isEmpty()
equalsIgnoreCase(string)(string) -> bool
No 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.

Breaking change from 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 rejected

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

No arithmetic on 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'
MethodSignatureNotes
code()() -> intthe Unicode scalar value
toString()() -> stringUTF-8 encode to a one-character string
isDigit(), isAlpha(), isUpper(), isLower(), isSpace()() -> boolASCII ranges only — a non-ASCII char returns false in v1
toUpper(), toLower()() -> charASCII-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 byte

StringBuilder

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.

MemberSignatureNotes
add(string s)(string) -> StringBuilderappends; returns this, chainable
(<<)(string s)(string) -> StringBuilderoperator sugar for add(s)
length()() -> int
isEmpty()() -> bool
toString()() -> stringO(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.

No backtracking, no ReDoS

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 matchmatch 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.

GroupMemberNotes
constructRegex(string pattern), Regex(string, RegexOptions), Regex(string, string flags)pattern-as-code: a malformed pattern throws RegexException
constructRegex::FromProgram(Array<int> program)O(1) wrap of an already-compiled program — the comptime path
introspectpattern(), options(), groupCount(), groupNames() -> Array<string>declared (?<name>…) names, in declaration order
testisMatch(string), isMatch(string, int from)DFA fast path
extractfind(string) -> Match?, find(string, int from) -> Match?first match, or None
extractmatches(string) -> Array<Match>all non-overlapping matches
countcount(string) -> intDFA-only; never materializes a Match
replacereplace(string, string replacement[, int count])$-grammar below; replaces all by default
replacereplace(string, (Match) => string fn[, int count])evaluator form
splitsplit(string) -> Array<string>, split(string, int limit) -> Array<string>
batchisMatchAll(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.

MemberNotes
index, length, valuebyte 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) -> Groupout 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 dataregex::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.

Escape ${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
1Leftmost-first, greedy by default (Perl/C#/JS, not POSIX-longest) — falls out of the engine's Pike VM thread priority.
2Empty-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.
3split 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.
4replace replaces all by default; the count overloads bound it — this differs from JS's String.replace and matches C#.
5Byte-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.
6ignoreCase 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.