Find & Replace — Zig source
Find and replace text with literal or regular-expression matching, global replace, case sensitivity, whole-word, and capture-group substitution. Live match counter.
This is the Zig implementation — the same logic the interactive tool runs, in a shareable, citable form.
//! Find & replace with literal or regex matching, $-substitution
//! ($1 backrefs, $&, $$), case sensitivity, whole-word, and global modes.
//!
//! Language: Zig 0.13 (standard library only)
//! Source: CosmoDev polyglot showcase port of the find-replace tool,
//! ported from src/lib/findReplace.ts (the canonical TypeScript
//! implementation).
//! License: display source — part of CosmoDev's polyglot tool pages.
//!
//! Zig's standard library has no regex engine, so this port carries a small
//! backtracking one — a find-replace tool's core IS a regex matcher. The
//! pattern compiles to a program of instructions and runs on an explicit
//! backtrack stack (an undo log restores capture slots on backtrack), which
//! reproduces JavaScript's leftmost + preference-order semantics rather
//! than POSIX leftmost-longest. Supported syntax (the common JS core):
//! literals, `.`, classes `[...]` with ranges and negation, escapes
//! `\d \D \w \W \s \S \b \B \n \t \r \f \v` plus identity escapes, groups
//! numbered by open paren, alternation `|`, quantifiers `* + ? {m} {m,}
//! {m,n}` with lazy `?` variants, and anchors `^ $` (line-aware under the
//! multiline flag); a malformed `{...}` is a literal `{`, as in JS.
//! Lookarounds and backreferences are rejected at compile time. Matching is
//! byte-oriented over the UTF-8 input with ASCII classes and ASCII case
//! folding. Worst case is exponential like any backtracking engine;
//! realistic find-replace patterns run linear in practice.
//!
//! Mirrors the live lib: a literal find string is escaped and matched
//! verbatim; an isRegex find is compiled as-is. `\b` wraps the pattern when
//! wholeWord is set. Case-insensitivity folds into byte comparison (the JS
//! i flag) and multiline changes ^/$ anchoring (the JS m flag, applied only
//! for regex finds). Invalid patterns return an error string instead of
//! erroring out, and an empty find is a no-op. Replacement $-substitution
//! (expandReplacement) matches JavaScript's String.replace exactly for the
//! realistic cases: `$$` -> `$`, `$&` -> whole match, `$1`..`$99` ->
//! capture group (literal "$<digits>" when out of range). JS's $` and $'
//! are unsupported.
const std = @import("std");
pub const Options = struct {
is_regex: bool = false,
case_sensitive: bool = true,
whole_word: bool = false,
global: bool = true,
multiline: bool = false,
};
pub const FindReplaceResult = struct {
result: []const u8,
matches: usize,
err: ?[]const u8 = null,
};
const PErr = error{ Syntax, OutOfMemory };
// ---- byte classifiers (ASCII, like the JS classes on ASCII input) ----
fn isDigit(c: u8) bool {
return c >= '0' and c <= '9';
}
fn isWordByte(c: u8) bool {
return (c >= 'a' and c <= 'z') or (c >= 'A' and c <= 'Z') or isDigit(c) or c == '_';
}
fn isSpace(c: u8) bool {
return c == ' ' or c == '\t' or c == '\r' or c == '\n' or c == 0x0B or c == 0x0C;
}
fn toLowerAscii(c: u8) u8 {
return if (c >= 'A' and c <= 'Z') c + 32 else c;
}
fn byteEq(a: u8, b: u8, icase: bool) bool {
if (a == b) return true;
if (!icase) return false;
return toLowerAscii(a) == toLowerAscii(b);
}
/// \b at pos: exactly one side is a word byte (or a bound).
fn atWordBoundary(input: []const u8, pos: usize) bool {
const before = pos > 0 and isWordByte(input[pos - 1]);
const after = pos < input.len and isWordByte(input[pos]);
return before != after;
}
// ---- compiled program ----
const Op = enum {
char, // one literal byte (case-folded when icase)
any, // any byte except '\n'
class, // one byte against a class
split, // try x first, backtrack to y
jmp,
save, // caps[x] = sp (logged for undo)
bol, // ^ : input start, or after '\n' when multiline
eol, // $ : input end, or before '\n' when multiline
wordb, // \b
nwordb, // \B
dig, ndig, wrd, nwrd, spc, nspc, // \d \D \w \W \s \S
match, // accept
};
const Inst = struct {
op: Op,
x: u32 = 0, // jmp/split target, or save slot
y: u32 = 0, // split alternate
ch: u8 = 0, // char operand
ci: u32 = 0, // class index
};
const EntryKind = enum { range, digit, not_digit, word, not_word, space, not_space };
const ClassEntry = struct {
kind: EntryKind,
lo: u8 = 0,
hi: u8 = 0,
};
const Class = struct {
negate: bool,
entries: []const ClassEntry,
};
fn classMatches(cl: Class, c: u8, icase: bool) bool {
var hit = false;
for (cl.entries) |e| {
const m = switch (e.kind) {
.range => (c >= e.lo and c <= e.hi) or
(icase and toLowerAscii(c) >= e.lo and toLowerAscii(c) <= e.hi),
.digit => isDigit(c),
.not_digit => !isDigit(c),
.word => isWordByte(c),
.not_word => !isWordByte(c),
.space => isSpace(c),
.not_space => !isSpace(c),
};
if (m) {
hit = true;
break;
}
}
return if (cl.negate) !hit else hit;
}
const Prog = struct {
insts: []const Inst,
classes: []const Class,
ngroups: u32, // explicit capture groups; slots = 2 * (n + 1)
multiline: bool,
icase: bool,
};
// ---- AST ----
const NodeKind = enum {
empty, char, any, class, shorth, group, alt, concat,
star, plus, quest, rangeq, bol, eol, wordb, nwordb,
};
const Node = struct {
kind: NodeKind,
ch: u8 = 0, // char operand, or shorthand set (0=digit 1=word 2=space)
neg: bool = false, // shorthand negated (\D \W \S)
class_idx: u32 = 0,
group_no: u32 = 0,
m: u32 = 0,
n: u32 = 0,
has_n: bool = false,
open: bool = false, // {m,}
lazy: bool = false,
child: ?*Node = null, // quantifier / group body
head: ?*Node = null, // first of a concat / alt sibling list
next: ?*Node = null, // next sibling
};
const Braces = struct { m: u32, n: u32 = 0, has_n: bool = false, open: bool = false };
const Parser = struct {
pat: []const u8,
i: usize = 0,
alloc: std.mem.Allocator,
prog: std.ArrayList(Inst),
classes: std.ArrayList(Class),
ngroups: u32 = 0,
err_msg: ?[]const u8 = null,
fn fail(p: *Parser, msg: []const u8) PErr {
if (p.err_msg == null) p.err_msg = msg;
return error.Syntax;
}
fn peek(p: *const Parser) u8 {
return if (p.i < p.pat.len) p.pat[p.i] else 0;
}
fn newNode(p: *Parser, kind: NodeKind) PErr!*Node {
const n = try p.alloc.create(Node);
n.* = .{ .kind = kind };
return n;
}
fn charNode(p: *Parser, ch: u8) PErr!*Node {
const n = try p.newNode(.char);
n.ch = ch;
return n;
}
fn shorthandNode(p: *Parser, set: u8, negated: bool) PErr!*Node {
const n = try p.newNode(.shorth);
n.ch = set;
n.neg = negated;
return n;
}
/// alt := concat ('|' concat)* — a single concat collapses to itself.
fn parseAlt(p: *Parser) PErr!*Node {
var head: ?*Node = null;
var tail: ?*Node = null;
while (true) {
const c = try p.parseConcat();
if (head == null) {
head = c;
} else {
tail.?.next = c;
}
tail = c;
if (p.peek() != '|') break;
p.i += 1;
}
if (head.?.next == null) return head.?;
const a = try p.newNode(.alt);
a.head = head;
return a;
}
fn parseConcat(p: *Parser) PErr!*Node {
var head: ?*Node = null;
var tail: ?*Node = null;
while (true) {
const c = p.peek();
if (c == 0 or c == '|' or c == ')') break;
const item = try p.parseRepeat();
if (head == null) {
head = item;
} else {
tail.?.next = item;
}
tail = item;
}
if (head == null) return try p.newNode(.empty);
if (head.?.next == null) return head.?;
const k = try p.newNode(.concat);
k.head = head;
return k;
}
fn parseRepeat(p: *Parser) PErr!*Node {
var atom = try p.parseAtom();
while (true) {
const q = p.peek();
if (q == '*' or q == '+' or q == '?') {
p.i += 1;
const node = try p.newNode(switch (q) {
'*' => .star,
'+' => .plus,
else => .quest,
});
if (p.peek() == '?') {
node.lazy = true;
p.i += 1;
}
node.child = atom;
atom = node;
// A second quantifier (a*+) is rejected, as JS does.
const q2 = p.peek();
if (q2 == '*' or q2 == '+' or q2 == '?') return p.fail("nested quantifier");
continue;
}
if (q == '{') {
const b = (try p.parseBraces()) orelse break; // literal '{', as in JS
const node = try p.newNode(.rangeq);
node.m = b.m;
node.n = b.n;
node.has_n = b.has_n;
node.open = b.open;
if (p.peek() == '?') {
node.lazy = true;
p.i += 1;
}
node.child = atom;
atom = node;
const q2 = p.peek();
if (q2 == '*' or q2 == '+' or q2 == '?') return p.fail("nested quantifier");
continue;
}
break;
}
return atom;
}
/// Try {m} / {m,} / {m,n}; null (position restored) when malformed.
fn parseBraces(p: *Parser) PErr!?Braces {
const save = p.i;
p.i += 1; // '{'
var m: u32 = 0;
var got = false;
while (isDigit(p.peek())) {
m = m * 10 + (p.peek() - '0');
p.i += 1;
got = true;
}
if (!got) {
p.i = save;
return null;
}
var b = Braces{ .m = m, .n = m };
if (p.peek() == ',') {
p.i += 1;
if (p.peek() == '}') {
p.i += 1;
b.open = true;
return b; // {m,}
}
var n: u32 = 0;
var gotn = false;
while (isDigit(p.peek())) {
n = n * 10 + (p.peek() - '0');
p.i += 1;
gotn = true;
}
if (!gotn or p.peek() != '}') {
p.i = save;
return null;
}
p.i += 1;
if (n < m) return p.fail("brace range out of order");
b.n = n;
b.has_n = true;
return b; // {m,n}
}
if (p.peek() != '}') {
p.i = save;
return null;
}
p.i += 1;
return b; // {m} exact
}
fn parseAtom(p: *Parser) PErr!*Node {
const c = p.peek();
switch (c) {
0, '|' => return p.fail("missing pattern"),
'(' => {
p.i += 1;
if (p.peek() == '?') return p.fail("group flags / lookarounds not supported");
p.ngroups += 1;
const g = p.ngroups;
const body = try p.parseAlt();
if (p.peek() != ')') return p.fail("missing ')'");
p.i += 1;
const n = try p.newNode(.group);
n.group_no = g;
n.child = body;
return n;
},
'[' => return p.parseClass(),
'.' => {
p.i += 1;
return try p.newNode(.any);
},
'^' => {
p.i += 1;
return try p.newNode(.bol);
},
'$' => {
p.i += 1;
return try p.newNode(.eol);
},
'\\' => {
p.i += 1;
const e = p.peek();
if (e == 0) return p.fail("trailing backslash");
p.i += 1;
return try p.escapeNode(e);
},
'*', '+', '?' => return p.fail("quantifier with nothing to repeat"),
else => {
p.i += 1;
return try p.charNode(c);
},
}
}
fn escapeNode(p: *Parser, e: u8) PErr!*Node {
return switch (e) {
'd' => try p.shorthandNode(0, false),
'D' => try p.shorthandNode(0, true),
'w' => try p.shorthandNode(1, false),
'W' => try p.shorthandNode(1, true),
's' => try p.shorthandNode(2, false),
'S' => try p.shorthandNode(2, true),
'b' => try p.newNode(.wordb),
'B' => try p.newNode(.nwordb),
'n' => try p.charNode('\n'),
't' => try p.charNode('\t'),
'r' => try p.charNode('\r'),
'f' => try p.charNode(0x0C),
'v' => try p.charNode(0x0B),
'0' => try p.charNode(0),
else => try p.charNode(e), // identity escape, as in JS
};
}
fn parseClass(p: *Parser) PErr!*Node {
p.i += 1; // '['
var negate = false;
if (p.peek() == '^') {
negate = true;
p.i += 1;
}
var entries = std.ArrayList(ClassEntry).init(p.alloc);
while (true) {
const c = p.peek();
if (c == 0) return p.fail("unterminated class");
if (c == ']') {
p.i += 1;
break;
}
// One element: escape / shorthand / literal byte, maybe a range.
var lo: u8 = undefined;
var shorthand: ?EntryKind = null;
if (c == '\\') {
p.i += 1;
const e = p.peek();
if (e == 0) return p.fail("trailing backslash in class");
p.i += 1;
switch (e) {
'd' => shorthand = .digit,
'D' => shorthand = .not_digit,
'w' => shorthand = .word,
'W' => shorthand = .not_word,
's' => shorthand = .space,
'S' => shorthand = .not_space,
'n' => lo = '\n',
't' => lo = '\t',
'r' => lo = '\r',
'f' => lo = 0x0C,
'v' => lo = 0x0B,
'0' => lo = 0,
else => lo = e,
}
} else {
lo = c;
p.i += 1;
}
if (shorthand) |k| {
try entries.append(.{ .kind = k });
continue;
}
// Range? '-' followed by something other than ']' or the end.
const can_range = p.i < p.pat.len and p.pat[p.i] == '-' and
p.i + 1 < p.pat.len and p.pat[p.i + 1] != ']' and p.pat[p.i + 1] != 0;
if (can_range) {
p.i += 1; // '-'
var hi: u8 = undefined;
const hc = p.peek();
if (hc == '\\') {
p.i += 1;
const e = p.peek();
if (e == 0) return p.fail("trailing backslash in class");
p.i += 1;
hi = switch (e) {
'n' => '\n',
't' => '\t',
'r' => '\r',
'f' => 0x0C,
'v' => 0x0B,
'0' => 0,
else => e,
};
} else {
hi = hc;
p.i += 1;
}
if (hi < lo) return p.fail("class range out of order");
try entries.append(.{ .kind = .range, .lo = lo, .hi = hi });
} else {
try entries.append(.{ .kind = .range, .lo = lo, .hi = lo });
}
}
const n = try p.newNode(.class);
n.class_idx = @intCast(p.classes.items.len);
try p.classes.append(.{ .negate = negate, .entries = entries.items });
return n;
}
};
fn setSplit(inst: *Inst, lazy: bool, first: u32, second: u32) void {
if (lazy) {
inst.x = second;
inst.y = first;
} else {
inst.x = first;
inst.y = second;
}
}
/// Flatten the AST into instruction list. Quantified groups re-emit the
/// same save slots on every iteration, so the last iteration wins (JS).
fn emitNode(p: *Parser, n: *Node) PErr!void {
switch (n.kind) {
.empty => {},
.char => try p.prog.append(.{ .op = .char, .ch = n.ch }),
.any => try p.prog.append(.{ .op = .any }),
.class => try p.prog.append(.{ .op = .class, .ci = n.class_idx }),
.shorth => try p.prog.append(.{ .op = switch (n.ch) {
0 => if (n.neg) .ndig else .dig,
1 => if (n.neg) .nwrd else .wrd,
2 => if (n.neg) .nspc else .spc,
else => unreachable,
} }),
.bol => try p.prog.append(.{ .op = .bol }),
.eol => try p.prog.append(.{ .op = .eol }),
.wordb => try p.prog.append(.{ .op = .wordb }),
.nwordb => try p.prog.append(.{ .op = .nwordb }),
.group => {
try p.prog.append(.{ .op = .save, .x = 2 * n.group_no });
try emitNode(p, n.child.?);
try p.prog.append(.{ .op = .save, .x = 2 * n.group_no + 1 });
},
.concat => {
var c = n.head;
while (c) |cc| : (c = cc.next) try emitNode(p, cc);
},
.alt => {
var splits = std.ArrayList(u32).init(p.alloc);
var jmps = std.ArrayList(u32).init(p.alloc);
var c = n.head.?;
while (c.next != null) {
const sp: u32 = @intCast(p.prog.items.len);
try p.prog.append(.{ .op = .split, .x = sp + 1, .y = 0 });
try splits.append(sp);
try emitNode(p, c);
const jp: u32 = @intCast(p.prog.items.len);
try p.prog.append(.{ .op = .jmp, .x = 0 });
try jmps.append(jp);
c = c.next.?;
}
try emitNode(p, c); // last alternative
const end: u32 = @intCast(p.prog.items.len);
for (splits.items, jmps.items) |sp, jp| {
p.prog.items[sp].y = jp + 1; // next alternative starts after the jmp
p.prog.items[jp].x = end;
}
},
.star => {
const sp: u32 = @intCast(p.prog.items.len);
try p.prog.append(.{ .op = .split, .x = 0, .y = 0 });
const body_start = p.prog.items.len;
try emitNode(p, n.child.?);
const body_len = p.prog.items.len - body_start;
if (body_len == 0) {
// Empty body would loop forever — a star of nothing is nothing.
p.prog.shrinkRetainingCapacity(sp);
return;
}
const after: u32 = @as(u32, @intCast(p.prog.items.len)) + 1;
try p.prog.append(.{ .op = .jmp, .x = sp });
setSplit(&p.prog.items[sp], n.lazy, sp + 1, after);
},
.plus => {
const body_start: u32 = @intCast(p.prog.items.len);
try emitNode(p, n.child.?);
if (p.prog.items.len == body_start) return; // nothing to repeat
const sp: u32 = @intCast(p.prog.items.len);
try p.prog.append(.{ .op = .split, .x = 0, .y = 0 });
setSplit(&p.prog.items[sp], n.lazy, body_start, sp + 1);
},
.quest => {
const sp: u32 = @intCast(p.prog.items.len);
try p.prog.append(.{ .op = .split, .x = 0, .y = 0 });
try emitNode(p, n.child.?);
const after: u32 = @intCast(p.prog.items.len);
setSplit(&p.prog.items[sp], n.lazy, sp + 1, after);
},
.rangeq => {
const child = n.child.?;
if (n.open) {
if (n.m == 0) {
const t = try p.newNode(.star);
t.lazy = n.lazy;
t.child = child;
try emitNode(p, t);
} else {
// {m,}: m copies, the last one loopable (plus).
var k: u32 = 0;
while (k < n.m) : (k += 1) try emitNode(p, child);
const t = try p.newNode(.plus);
t.lazy = n.lazy;
t.child = child;
try emitNode(p, t);
}
} else if (!n.has_n) {
var k: u32 = 0;
while (k < n.m) : (k += 1) try emitNode(p, child); // {m} exact
} else if (n.m == 0) {
var k: u32 = 0;
while (k < n.n) : (k += 1) { // {0,n}: n optionals
const t = try p.newNode(.quest);
t.lazy = n.lazy;
t.child = child;
try emitNode(p, t);
}
} else {
var k: u32 = 0;
while (k < n.m) : (k += 1) try emitNode(p, child);
var j: u32 = n.m;
while (j < n.n) : (j += 1) { // {m,n}: m copies then n-m optionals
const t = try p.newNode(.quest);
t.lazy = n.lazy;
t.child = child;
try emitNode(p, t);
}
}
},
}
}
const Compiled = union(enum) {
prog: Prog,
bad: []const u8, // syntax error message
};
/// Compile the pattern with the case and multiline modes baked in.
fn compile(alloc: std.mem.Allocator, pattern: []const u8, icase: bool, multiline: bool) error{OutOfMemory}!Compiled {
var p = Parser{
.pat = pattern,
.alloc = alloc,
.prog = std.ArrayList(Inst).init(alloc),
.classes = std.ArrayList(Class).init(alloc),
};
const root = p.parseAlt() catch |e| return switch (e) {
error.Syntax => Compiled{ .bad = p.err_msg orelse "invalid pattern" },
error.OutOfMemory => return error.OutOfMemory,
};
if (p.i != pattern.len) {
return Compiled{ .bad = "unbalanced ')'" };
}
emitNode(&p, root) catch |e| return switch (e) {
error.Syntax => Compiled{ .bad = p.err_msg orelse "invalid pattern" },
error.OutOfMemory => return error.OutOfMemory,
};
// Wrap the whole expression: save 0 ... save 1 ... match.
try p.prog.insert(0, .{ .op = .save, .x = 0 });
try p.prog.append(.{ .op = .save, .x = 1 });
try p.prog.append(.{ .op = .match });
return Compiled{ .prog = .{
.insts = p.prog.items,
.classes = p.classes.items,
.ngroups = p.ngroups,
.multiline = multiline,
.icase = icase,
} };
}
// ---- backtracking VM ----
const Thread = struct { pc: u32, sp: u32, undo_len: usize };
const UndoEntry = struct { slot: u32, old: ?u32 };
/// Anchored match at `start`. On success caps holds slot bounds:
/// caps[0]/caps[1] = whole match, caps[2g]/caps[2g+1] = group g (null when
/// the group did not participate). The undo log restores slots when a
/// thread backtracks past their saves.
fn matchAt(
prog: Prog,
input: []const u8,
start: usize,
caps: []?u32,
stack: *std.ArrayList(Thread),
undo: *std.ArrayList(UndoEntry),
) bool {
stack.clearRetainingCapacity();
undo.clearRetainingCapacity();
@memset(caps, null);
var pc: u32 = 0;
var sp: u32 = @intCast(start);
while (true) {
const inst = prog.insts[pc];
var alive = true;
switch (inst.op) {
.char => if (sp < input.len and byteEq(input[sp], inst.ch, prog.icase)) {
sp += 1;
pc += 1;
} else {
alive = false;
},
.any => if (sp < input.len and input[sp] != '\n') {
sp += 1;
pc += 1;
} else {
alive = false;
},
.class => if (sp < input.len and classMatches(prog.classes[inst.ci], input[sp], prog.icase)) {
sp += 1;
pc += 1;
} else {
alive = false;
},
.dig, .ndig, .wrd, .nwrd, .spc, .nspc => if (sp < input.len) {
const b = input[sp];
const hit = switch (inst.op) {
.dig => isDigit(b),
.ndig => !isDigit(b),
.wrd => isWordByte(b),
.nwrd => !isWordByte(b),
.spc => isSpace(b),
.nspc => !isSpace(b),
else => unreachable,
};
if (hit) {
sp += 1;
pc += 1;
} else {
alive = false;
}
} else {
alive = false;
},
.split => {
stack.append(.{ .pc = inst.y, .sp = sp, .undo_len = undo.items.len }) catch return false;
pc = inst.x;
},
.jmp => pc = inst.x,
.save => {
if (inst.x < caps.len) {
undo.append(.{ .slot = inst.x, .old = caps[inst.x] }) catch return false;
caps[inst.x] = sp;
}
pc += 1;
},
.bol => if (sp == 0 or (prog.multiline and input[sp - 1] == '\n')) {
pc += 1;
} else {
alive = false;
},
.eol => if (sp == input.len or (prog.multiline and input[sp] == '\n')) {
pc += 1;
} else {
alive = false;
},
.wordb => if (atWordBoundary(input, sp)) {
pc += 1;
} else {
alive = false;
},
.nwordb => if (!atWordBoundary(input, sp)) {
pc += 1;
} else {
alive = false;
},
.match => return true,
}
if (!alive) {
const f = stack.pop() orelse return false;
while (undo.items.len > f.undo_len) {
const e = undo.pop().?;
caps[e.slot] = e.old;
}
pc = f.pc;
sp = f.sp;
}
}
}
// ---- find & replace ----
/// Escape regex metacharacters so a literal find string matches verbatim.
fn escapePattern(alloc: std.mem.Allocator, s: []const u8) error{OutOfMemory}![]const u8 {
var out = std.ArrayList(u8).init(alloc);
for (s) |c| {
switch (c) {
'.', '*', '+', '?', '(', ')', '[', ']', '{', '}', '|', '^', '$', '\\' => try out.append('\\'),
else => {},
}
try out.append(c);
}
return out.items;
}
/// Group g's text from capture slots; "" when it did not participate.
fn groupText(caps: []const ?u32, input: []const u8, g: usize) []const u8 {
const so = caps[2 * g] orelse return "";
const eo = caps[2 * g + 1] orelse return "";
if (so > eo or eo > input.len) return "";
return input[so..eo];
}
/// Apply JS String.replace $-substitution for one match.
/// "$$" -> "$"; "$&" -> whole match; "$1".."$99" -> capture group N
/// (literal "$<digits>" when N is out of range, matching JS).
fn expandReplacement(alloc: std.mem.Allocator, tpl: []const u8, input: []const u8, caps: []const ?u32) error{OutOfMemory}![]const u8 {
const num_groups = caps.len / 2 - 1;
var out = std.ArrayList(u8).init(alloc);
var i: usize = 0;
while (i < tpl.len) {
const c = tpl[i];
if (c != '$') {
try out.append(c);
i += 1;
continue;
}
const nxt: u8 = if (i + 1 < tpl.len) tpl[i + 1] else 0;
if (nxt == '$') {
try out.append('$');
i += 2;
} else if (nxt == '&') {
try out.appendSlice(groupText(caps, input, 0));
i += 2;
} else if (isDigit(nxt)) {
const d1: usize = nxt - '0';
// Greedily try a second digit ($nn), matching JS.
if (i + 2 < tpl.len and isDigit(tpl[i + 2])) {
const d2 = d1 * 10 + (tpl[i + 2] - '0');
if (d2 >= 1 and d2 <= num_groups) {
try out.appendSlice(groupText(caps, input, d2));
i += 3;
continue;
}
}
if (d1 >= 1 and d1 <= num_groups) {
try out.appendSlice(groupText(caps, input, d1));
i += 2;
} else {
try out.append('$');
try out.append(nxt);
i += 2;
}
} else {
try out.append('$');
i += 1;
}
}
return out.items;
}
/// Replace occurrences of `find` with `replacement`. Never errors on
/// pattern syntax (that lands in `.err`); only allocation can fail. All
/// returned memory belongs to `alloc` (an arena in the demo below).
pub fn findReplace(
alloc: std.mem.Allocator,
input: []const u8,
find: []const u8,
replacement: []const u8,
o: Options,
) error{OutOfMemory}!FindReplaceResult {
if (find.len == 0) return .{ .result = input, .matches = 0 }; // empty find is a no-op
var pattern: []const u8 = find;
if (!o.is_regex) {
pattern = try escapePattern(alloc, find);
}
if (o.whole_word) {
var wrapped = std.ArrayList(u8).init(alloc);
try wrapped.ensureTotalCapacity(pattern.len + 4);
try wrapped.appendSlice("\\b");
try wrapped.appendSlice(pattern);
try wrapped.appendSlice("\\b");
pattern = wrapped.items;
}
// Multiline applies only to regex finds, mirroring the lib.
const compiled = try compile(alloc, pattern, !o.case_sensitive, o.is_regex and o.multiline);
const prog = switch (compiled) {
.prog => |pr| pr,
.bad => |msg| return .{ .result = input, .matches = 0, .err = msg },
};
const caps = try alloc.alloc(?u32, 2 * (prog.ngroups + 1));
var stack = std.ArrayList(Thread).init(alloc);
var undo = std.ArrayList(UndoEntry).init(alloc);
var out = std.ArrayList(u8).init(alloc);
var last: usize = 0;
var pos: usize = 0;
var matches: usize = 0;
while (pos <= input.len) {
// Leftmost match: anchored attempts from each position in turn.
var start = pos;
var found = false;
while (start <= input.len) : (start += 1) {
if (matchAt(prog, input, start, caps, &stack, &undo)) {
found = true;
break;
}
}
if (!found) break;
const so: usize = caps[0] orelse start;
const eo: usize = caps[1] orelse start;
try out.appendSlice(input[last..so]);
try out.appendSlice(try expandReplacement(alloc, replacement, input, caps));
matches += 1;
last = eo;
if (!o.global) break;
if (eo == so) { // empty match: copy one byte and step, like JS
if (eo < input.len) {
try out.append(input[eo]);
last = eo + 1;
}
pos = last;
} else {
pos = eo;
}
}
try out.appendSlice(input[last..]);
if (matches != 0 and !o.global) {
matches = 1 + prog.ngroups; // JS String.match length quirk
}
return .{ .result = out.items, .matches = matches };
}
pub fn main() !void {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const r = try findReplace(
arena.allocator(),
"Hello World world",
"world",
"Universe",
.{ .case_sensitive = false },
);
if (r.err) |e| {
std.debug.print("error: {s}\n", .{e});
} else {
std.debug.print("{s} ({d} matches)\n", .{ r.result, r.matches });
}
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →