ECMAScript String API implementation in Zig
A Zig library implementing the operations of ECMAScript 262's String.prototype โ full capability coverage of the spec's string operations, expressed in Zig's own idioms rather than a syntactic mimicry of JavaScript's dynamic object model (see Zig Idioms vs JavaScript Syntax below for what that distinction means and why it matters). Designed to be the foundation for JavaScript/ECMAScript runtime engines written in Zig.
- Spec Coverage: Provide a Zig-idiomatic equivalent for every
String.prototypeoperation ECMAScript 262 defines - UTF-16 Indexing: Use UTF-16 code units for indexing (like JavaScript)
- Performance: Efficient implementation leveraging Zig's strengths
- Runtime Ready: Built to be integrated into ECMAScript runtime engines
Every method below is a real, independently-tested Zig function โ this is NOT a percentage of "spec compliance" (that would mean matching ECMA-262's exact edge-case behavior, verified against a real conformance suite like Test262; see Known Gaps for what's honestly missing on that front). It's a coverage count of named operations.
charAt(index)- Get character at indexat(index)- Get character with negative indexing supportcharCodeAt(index)- Get UTF-16 code unit valuecodePointAt(index)- Get Unicode code point
indexOf(searchString, position?)- Find first occurrencelastIndexOf(searchString, position?)- Find last occurrenceincludes(searchString, position?)- Check if contains substringstartsWith(searchString, position?)- Check if starts with substringendsWith(searchString, length?)- Check if ends with substring
slice(start, end?)- Extract substring with negative indicessubstring(start, end?)- Extract substring (swaps if start > end)concat(...strings)- Concatenate stringsrepeat(count)- Repeat string N times
padStart(targetLength, padString?)- Pad from startpadEnd(targetLength, padString?)- Pad from end
trim()- Remove whitespace from both endstrimStart() / trimLeft()- Remove whitespace from starttrimEnd() / trimRight()- Remove whitespace from end
split(separator?, limit?)- Split string into array by a literal separator
toLowerCase()- Convert to lowercasetoUpperCase()- Convert to uppercasetoLocaleLowerCase(locale?)- Locale-aware lowercase*toLocaleUpperCase(locale?)- Locale-aware uppercase*
toStringAlloc()- Get string valuevalueOfAlloc()- Get primitive valuelocaleCompare(that, locales?, options?)- Compare strings*normalize(form?)- Unicode normalization (NFC/NFD/NFKC/NFKD)**
* Basic implementation without full locale support (ICU integration planned)
** Supports common Latin characters (ร-รฟ range) with proper decomposition/composition
ZString.fromCharCode(code_units)- Build a string from UTF-16 code units (combines surrogate pairs)ZString.fromCodePoint(code_points)- Build a string from full Unicode code points
Both live directly on ZString (constructors, same as init/initOwned/
fromOwned), not in methods/. Both take a slice instead of JS's
variadic ...args (Zig has no variadic parameters โ see
Zig Idioms vs JavaScript Syntax), and
both error on an unpaired surrogate rather than silently building a
string z-string's UTF-8 storage can't actually represent (see
WELL_FORMED_STRINGS.md for why) โ stricter
than real JS, which allows it.
searchRegex(pattern)- Search with regexmatchRegex(pattern)- Match with regexmatchAllRegex(pattern)- Match all with regexreplaceRegex(pattern, replaceValue)- Replace with regex support*replaceAllRegex(pattern, replaceValue)- Replace all with regex support*splitRegex(pattern, limit?)- Split by a regex separator (see Zig Idioms vs JavaScript Syntax for why this isn't just anothersplit()overload โ Zig has no function overloading)
All six carry a *Regex suffix rather than reusing the plain ECMAScript
names (search, match, matchAll, replace, replaceAll) โ see
Zig Idioms vs JavaScript Syntax for why
z-string doesn't overload the non-regex methods for this.
* replaceRegex/replaceAllRegex always compile pattern as a regex,
even when you intend a literal substring โ if your search string contains
regex metacharacters (., +, *, etc.) they'll be interpreted as regex
syntax instead of matched literally. Escape them yourself if that matters,
or see Known Gaps.
z-string is a native Zig library โ see Quick Start below.
z-string depends on zregex for regex functionality, pinned as a git dependency in build.zig.zon:
.dependencies = .{
.zregex = .{
.url = "git+https://github.com/carlos-sweb/z-regex.git#<commit>",
.hash = "zregex-...",
},
},zig build fetches it automatically on first run (into a local zig-pkg/
directory, per Zig 0.16's package layout) โ no manual cloning or sibling
checkout required. To move to a different zregex commit:
zig fetch --save git+https://github.com/carlos-sweb/z-regex.gitThis rewrites the .zregex entry in build.zig.zon with the new commit's
URL and hash.
git clone https://github.com/carlos-sweb/z-string.git
cd z-string
zig build test # fetches zregex automaticallyOnce published, you'll be able to add to your build.zig.zon:
.{
.name = .my_project, // Zig 0.16 requires an enum literal, not a string
.version = "0.1.0",
.fingerprint = 0x..., // Zig 0.16 requires this; `zig build` will generate it
.dependencies = .{
.zstring = .{
.url = "https://github.com/carlos-sweb/z-string/archive/refs/tags/v0.2.0.tar.gz",
.hash = "1220...", // Use zig fetch to get hash
},
},
}Add to your build.zig:
const zstring = b.dependency("zstring", .{
.target = target,
.optimize = optimize,
});
exe.root_module.addImport("zstring", zstring.module("zstring"));git clone https://github.com/carlos-sweb/z-string.git
cd z-string
zig build testz-string follows Zig's error handling philosophy. All operations that can fail return error unions:
// โ
Proper error handling
const upper = try str.toUpperCase(allocator);
defer allocator.free(upper);
// โ
Handle specific errors
const result = str.toUpperCase(allocator) catch |err| {
std.log.err("Failed: {}", .{err});
return err;
};
// โ
Check optional returns
const char = try str.at(allocator, 0);
if (char) |c| {
defer allocator.free(c);
// Use c...
}๐ See ERROR_HANDLING.md for comprehensive error handling guide.
const std = @import("std");
const zstring = @import("zstring");
pub fn main() !void {
var gpa = std.heap.DebugAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Create a ZString
const str = zstring.ZString.init("Hello, World!");
// Character access
const char = try str.charAt(allocator, 0);
defer allocator.free(char);
std.debug.print("First char: {s}\n", .{char}); // "H"
// Search
const pos = str.indexOf("World", null);
std.debug.print("Position: {}\n", .{pos}); // 7
// Transform
const upper = try str.toUpperCase(allocator);
defer allocator.free(upper);
std.debug.print("Upper: {s}\n", .{upper}); // "HELLO, WORLD!"
// Split (literal separator)
const parts = try str.split(allocator, ", ", null);
defer zstring.ZString.freeSplitResult(allocator, parts);
std.debug.print("Parts: {s}, {s}\n", .{parts[0], parts[1]}); // "Hello", "World!"
// Split (regex separator) -- a sibling function, not an overload of
// split() -- see "Zig Idioms vs JavaScript Syntax" below.
const digits = zstring.ZString.init("a1b2c3");
const pieces = try digits.splitRegex(allocator, "[0-9]+", null);
defer zstring.ZString.freeSplitResult(allocator, pieces);
std.debug.print("Pieces: {s}, {s}, {s}\n", .{pieces[0], pieces[1], pieces[2]}); // "a", "b", "c"
}z-string depends on nothing but zregex (and only for the methods that
actually need real regex matching). It does NOT depend on z-array,
z-value, or anything else in the wider z-* family โ that's
deliberate, not an oversight. split()/splitRegex() already return
everything you need ([][]u8, a plain Zig slice) to build a richer
container yourself, on the consumer side, with zero changes to this
library:
const std = @import("std");
const zstring = @import("zstring");
const zarray = @import("zarray");
pub fn main() !void {
var gpa = std.heap.DebugAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const str = zstring.ZString.init("hola, a, todos");
const words = try zarray.ZArray([]const u8).fromSlice(
allocator,
try str.split(allocator, ", ", null),
);
// words is now a real ZArray([]const u8) -- push/pop/map/filter/etc.
// all available, on top of what split() already gave you.
}Why z-string doesn't just depend on z-array and return ZArray
directly: it would only save the one fromSlice copy above, at the
cost of a real dependency edge pointed the wrong way for what's likely
to come next. ZArray's own join()/toString() want to turn their
elements back into text -- which is exactly z-string's job. Depend
z-string -> z-array today and there's a real chance of wanting
z-array -> z-string (or -> z-value) tomorrow for that, and now
you're negotiating a cycle instead of composing two libraries. Keeping
the dependency arrow one-directional (or absent, as here) means either
side can be used completely on its own -- you reach for both only when
YOUR code wants both, and z-string never has to care that z-array
exists.
z-string targets functional equivalence with String.prototype, not
syntactic mimicry โ some of what reads as a single, flexible JavaScript
method is deliberately split across several Zig functions, because Zig
doesn't support what JavaScript relies on to make that flexibility work:
- No function overloading.
"a,b".split(",")and"a1b".split(/\d/)are the same JS method dispatching on the argument's runtime type. Zig can't do that โ there's no way to have two functions namedsplitdistinguished only by parameter type. So a literal separator usessplit(), and a regex separator uses the sibling functionsplitRegex(). Same pattern elsewhere in this library:toUpperCase()vstoLocaleUpperCase(), and the regex-backed methods (searchRegex()/matchRegex()/matchAllRegex()/replaceRegex()/replaceAllRegex()) are all named with an explicitRegexsuffix rather than overloadingsearch/match/replacefrom the spec. - No dynamic
[]indexing on custom types. JS'snew String("abc")[0]relies on the language auto-exposing indexed properties on an object. Zig has no operator overloading for that. The equivalent capability isstr.at(allocator, 0)โ same result, explicit call instead of bracket syntax.
If you're coming from JS and expect one method name per spec operation, this is the one thing to unlearn: the library grows by adding a new, clearly-named function, not by overloading an existing one.
JavaScript uses UTF-16 code units for string indexing. z-string maintains this behavior for spec compliance:
const str = zstring.ZString.init("๐"); // Emoji (surrogate pair)
std.debug.print("Length: {}\n", .{str.length()}); // 2 (UTF-16 code units)Methods that return new strings require explicit memory management:
const upper = try str.toUpperCase(allocator);
defer allocator.free(upper); // Caller owns the memoryAccumulating several results before combining them (e.g. building up parts
of a string) uses Zig 0.16's unmanaged std.ArrayList โ initialize with
.empty, and pass the allocator explicitly to append/deinit (there is
no .init(allocator) shorthand anymore):
var parts: std.ArrayList([]const u8) = .empty;
defer {
for (parts.items) |part| allocator.free(part);
parts.deinit(allocator);
}
try parts.append(allocator, try str1.toUpperCase(allocator));
try parts.append(allocator, try str2.toUpperCase(allocator));See examples/error_handling.zig (safeStringBuilder, run via
zig build example-errors) for this in context.
// Borrowed (no allocation)
const borrowed = zstring.ZString.init("hello");
// Owned (allocated, must call deinit)
var owned = try zstring.ZString.initOwned(allocator, "hello");
defer owned.deinit();See the examples/ directory for complete examples:
character_access.zig- Character access methodssearch_methods.zig- Search and indexOf methodstransform_methods.zig- Slice, substring, concat, repeatpadding_trimming_methods.zig- Padding and trimmingsplit_method.zig- String splittingerror_handling.zig- Error handling patterns (try/catch, errdefer, ArrayList-based string building)
Run examples:
zig build example # Character access
zig build example-search # Search methods
zig build example-transform # Transform methods
zig build example-padding-trimming
zig build example-split
zig build example-errors # Error handling (recommended!)# Run all tests
zig build test
# Run benchmarks
zig build benchTest Coverage:
- 372+ tests across all implemented methods
- ECMAScript spec compliance tests
- Unicode and emoji handling tests
- Unicode normalization tests (NFC/NFD/NFKC/NFKD)
- Edge case coverage
z-string/
โโโ src/
โ โโโ zstring.zig # Public Zig API entry point
โ โโโ core/
โ โ โโโ utf16.zig # UTF-8 โ UTF-16 conversion
โ โ โโโ string.zig # ZString struct
โ โโโ methods/ # Method implementations (grouped by category)
โ โโโ access.zig # charAt, at, charCodeAt, codePointAt
โ โโโ search.zig # indexOf, lastIndexOf, includes, etc.
โ โโโ transform.zig # slice, substring, concat, repeat
โ โโโ padding.zig # padStart, padEnd
โ โโโ trimming.zig # trim, trimStart, trimEnd
โ โโโ split.zig # split
โ โโโ case.zig # toLowerCase, toUpperCase
โ โโโ regex.zig # search, match, matchAll, replace, replaceAll, splitRegex
โ โโโ unicode_normalize.zig # NFC/NFD/NFKC/NFKD normalization
โ โโโ utility.zig # toString, valueOf, localeCompare, normalize
โโโ tests/
โ โโโ spec/ # ECMAScript spec compliance tests
โ โโโ benchmarks/ # Performance benchmarks
โโโ examples/ # Usage examples
- Character access methods
- Search methods (literal)
- Transform methods
- Padding and trimming
- Split (literal)
- Case conversion
- Utility methods
- Unicode normalization (NFC/NFD/NFKC/NFKD)
- Integrate zregex as dependency
- Implement search() with regex
- Implement match() and matchAll()
- Implement replace() and replaceAll() with regex
- Implement splitRegex() (regex-separator split, sibling of split())
- Comprehensive test coverage for regex methods
See PHASE3_ANALYSIS.md for a complexity breakdown of these three โ they are NOT the same size (locale-aware case mapping is bounded and cheap; full UCD normalization and real ICU collation are each a project of their own).
- Full locale support (ICU integration)
- Extended Unicode normalization (full UCD coverage beyond Latin-1)
- Locale-aware case mapping (Turkish ฤฐ/i, etc.)
- Static factories:
ZString.fromCharCode,ZString.fromCodePoint -
String.raw(needs a tagged-template-literal caller, out of scope for a pure string-ops library on its own) -
toWellFormed()(the fix-up counterpart to the existingisWellFormed()check) -
replace/replaceAlltreating a plain (non-regex) search string as a literal match instead of always compiling it as a pattern
Honest list of ECMA-262 String.prototype capabilities with no
equivalent in z-string yet (see Zig Idioms vs JavaScript Syntax
for why "capability" and "same method name" are different questions):
toWellFormed()/ realisWellFormed()โ deeper than a missing method; the current[]const u8(standard UTF-8) storage can't even represent the lone-surrogate case these two are supposed to detect. Full writeup, including what a real fix requires, in WELL_FORMED_STRINGS.md.String.rawโ no equivalent; it's inherently tied to a tagged-template-literal call site (the raw, unescaped source text of the template), which doesn't have a meaningful counterpart in a standalone string-operations library.replace()/replaceAll()literal-search correctness โ both always compile their search argument as a regex pattern. A search string containing regex metacharacters (.,+,*,?, ...) will be interpreted as regex syntax rather than matched literally โ e.g. searching for the literal text"3.14"also matches"3X14"for any characterX, because.means "any character" once compiled.
This list is a byproduct of an actual capability audit against ECMA-262 ยง22.1.3, not a percentage estimate โ if you find something else missing, open an issue and it'll get added here.
Contributions are welcome! This project is actively maintained.
git clone https://github.com/carlos-sweb/z-string.git
cd z-string
zig build test- Follow ECMAScript 262 specification exactly
- Maintain UTF-16 indexing compatibility
- Include comprehensive tests for all changes
- Document public APIs with examples
MIT License - see LICENSE file for details.
- zregex - Zig regex engine for ECMAScript compatibility; z-string's only dependency, used for all
*Regexmethods - Zig Standard Library - UTF-8/UTF-16 utilities
Current Version: 0.5.0
Coverage: every String.prototype operation from ECMA-262 ยง22.1.3 has
a Zig-idiomatic equivalent in this library, EXCEPT the items listed under
Known Gaps above (String.raw, toWellFormed(), and the
replace/replaceAll literal-search caveat). "Coverage" here means
"a real function exists for it" โ it is deliberately NOT a spec-compliance
percentage. This library doesn't run against Test262 (ECMAScript's actual
conformance suite) on its own; the only end-to-end conformance numbers
that exist are measured downstream, through a full JS engine that
consumes z-string, and reflect that engine's plumbing as much as this
library's own correctness.
โ Project Status: ACTIVE
Dependency Architecture:
- z-string depends on zregex (one-way dependency)
- No circular dependencies
- Clean separation of concerns
- ECMAScript 262 specification
- Zig community
- All contributors
Note: For questions or discussions about the project architecture, please open an issue.