From c1ab28f547c32d9caf622754c8e989cf07abfdd6 Mon Sep 17 00:00:00 2001 From: indaco Date: Thu, 7 May 2026 20:16:39 +0200 Subject: [PATCH 1/3] perf(text-replace): share the optimised byte-replace across DSL and macho The macho patcher's memchr-backed replaceAll, originally rewritten after mem.eqlBytes dominated the warm-ffmpeg profile, now also drives DSL inreplace and DSL gsub. Three hand-rolled byte-eql loops collapse into one shared module, the slow paths in mt migrate post-install rewrites disappear, and the release binary shrinks by ~16 KiB from the dedupe. --- src/core/dsl/builtins/inreplace.zig | 43 +---------- src/core/dsl/builtins/string.zig | 94 ++++++++++++----------- src/lib.zig | 1 + src/macho/patcher.zig | 59 +-------------- src/text_replace.zig | 113 ++++++++++++++++++++++++++++ 5 files changed, 169 insertions(+), 141 deletions(-) create mode 100644 src/text_replace.zig diff --git a/src/core/dsl/builtins/inreplace.zig b/src/core/dsl/builtins/inreplace.zig index 8764ad5..3914de8 100644 --- a/src/core/dsl/builtins/inreplace.zig +++ b/src/core/dsl/builtins/inreplace.zig @@ -7,6 +7,7 @@ const values = @import("../values.zig"); const sandbox = @import("../sandbox.zig"); const pathname = @import("pathname.zig"); const output = @import("../../../ui/output.zig"); +const text_replace = @import("../../../text_replace.zig"); const Value = values.Value; const BuiltinError = pathname.BuiltinError; @@ -43,7 +44,7 @@ pub fn inreplace(ctx: ExecCtx, _: ?Value, args: []const Value) BuiltinError!Valu // Replace all occurrences if (from.len == 0) return Value{ .nil = {} }; - const new_content = replaceAll(ctx.allocator, content, from, to) catch { + const new_content = text_replace.replaceAll(ctx.allocator, content, from, to) catch { return Value{ .nil = {} }; }; @@ -65,46 +66,6 @@ pub fn inreplace(ctx: ExecCtx, _: ?Value, args: []const Value) BuiltinError!Valu return Value{ .nil = {} }; } -/// Replace all occurrences of `needle` with `replacement` in `haystack`. -fn replaceAll(allocator: std.mem.Allocator, haystack: []const u8, needle: []const u8, replacement: []const u8) ![]const u8 { - // Count occurrences - var count: usize = 0; - var pos: usize = 0; - while (pos <= haystack.len -| needle.len) { - if (std.mem.startsWith(u8, haystack[pos..], needle)) { - count += 1; - pos += needle.len; - } else { - pos += 1; - } - } - - if (count == 0) return try allocator.dupe(u8, haystack); - - const new_len = haystack.len - (count * needle.len) + (count * replacement.len); - const buf = try allocator.alloc(u8, new_len); - - var src: usize = 0; - var dst: usize = 0; - while (src <= haystack.len -| needle.len) { - if (std.mem.startsWith(u8, haystack[src..], needle)) { - @memcpy(buf[dst..][0..replacement.len], replacement); - dst += replacement.len; - src += needle.len; - } else { - buf[dst] = haystack[src]; - dst += 1; - src += 1; - } - } - // Copy remaining tail - if (src < haystack.len) { - @memcpy(buf[dst..][0 .. haystack.len - src], haystack[src..]); - } - - return buf; -} - /// Write content atomically: write to temp file then rename over original. /// Returns one of `AtomicWriteError` to identify the failing step; the /// underlying error name is written to `underlying_name_out` so the caller diff --git a/src/core/dsl/builtins/string.zig b/src/core/dsl/builtins/string.zig index c6d535d..65bad8f 100644 --- a/src/core/dsl/builtins/string.zig +++ b/src/core/dsl/builtins/string.zig @@ -4,6 +4,7 @@ const std = @import("std"); const values = @import("../values.zig"); const pathname = @import("pathname.zig"); +const text_replace = @import("../../../text_replace.zig"); const Value = values.Value; const BuiltinError = pathname.BuiltinError; @@ -23,48 +24,6 @@ fn receiverStr(allocator: std.mem.Allocator, receiver: ?Value) BuiltinError![]co }; } -/// Replace all occurrences of `needle` with `replacement` in `haystack`. -fn replaceAll(allocator: std.mem.Allocator, haystack: []const u8, needle: []const u8, replacement: []const u8) BuiltinError![]const u8 { - if (needle.len == 0) return allocator.dupe(u8, haystack) catch return BuiltinError.OutOfMemory; - - // Count occurrences to compute result size. - var count: usize = 0; - var pos: usize = 0; - while (pos <= haystack.len -| needle.len) { - if (std.mem.startsWith(u8, haystack[pos..], needle)) { - count += 1; - pos += needle.len; - } else { - pos += 1; - } - } - - if (count == 0) return allocator.dupe(u8, haystack) catch return BuiltinError.OutOfMemory; - - const new_len = haystack.len - (count * needle.len) + (count * replacement.len); - const buf = allocator.alloc(u8, new_len) catch return BuiltinError.OutOfMemory; - - var src: usize = 0; - var dst: usize = 0; - while (src <= haystack.len -| needle.len) { - if (std.mem.startsWith(u8, haystack[src..], needle)) { - @memcpy(buf[dst..][0..replacement.len], replacement); - dst += replacement.len; - src += needle.len; - } else { - buf[dst] = haystack[src]; - dst += 1; - src += 1; - } - } - // Copy remaining tail bytes (less than needle.len). - if (src < haystack.len) { - @memcpy(buf[dst..][0 .. haystack.len - src], haystack[src..]); - } - - return buf; -} - /// Replace only the first occurrence of `needle` with `replacement`. fn replaceFirst(allocator: std.mem.Allocator, haystack: []const u8, needle: []const u8, replacement: []const u8) BuiltinError![]const u8 { if (needle.len == 0) return allocator.dupe(u8, haystack) catch return BuiltinError.OutOfMemory; @@ -91,7 +50,10 @@ pub fn gsub(ctx: ExecCtx, receiver: ?Value, args: []const Value) BuiltinError!Va if (args.len < 2) return Value{ .string = s }; const pattern = try args[0].asString(ctx.allocator); const replacement = try args[1].asString(ctx.allocator); - return Value{ .string = try replaceAll(ctx.allocator, s, pattern, replacement) }; + return Value{ + .string = text_replace.replaceAll(ctx.allocator, s, pattern, replacement) catch + return BuiltinError.OutOfMemory, + }; } /// gsub!(pattern, replacement) — in-place gsub (same as gsub for immutable strings) @@ -321,3 +283,49 @@ fn parseLeadingInt(s: []const u8) i64 { } return if (saw_digit) n else 0; } + +// --------------------------------------------------------------------------- +// Inline tests +// --------------------------------------------------------------------------- + +fn testCtx(allocator: std.mem.Allocator) ExecCtx { + return .{ + .allocator = allocator, + // The string builtins only touch `allocator`; remaining fields are + // structurally required by ExecCtx but never read on these paths. + .io = undefined, + .environ = undefined, + .cellar_path = "", + .malt_prefix = "", + }; +} + +test "gsub rewrites every occurrence and routes through the shared replacer" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const ctx = testCtx(arena.allocator()); + + const args = [_]Value{ .{ .string = "AAA" }, .{ .string = "X" } }; + const out = try gsub(ctx, .{ .string = "headAAAmidAAAtail" }, &args); + try std.testing.expectEqualStrings("headXmidXtail", out.string); +} + +test "gsub with no match returns the receiver bytes unchanged" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const ctx = testCtx(arena.allocator()); + + const args = [_]Value{ .{ .string = "absent" }, .{ .string = "Z" } }; + const out = try gsub(ctx, .{ .string = "no needles here" }, &args); + try std.testing.expectEqualStrings("no needles here", out.string); +} + +test "gsub with empty replacement deletes every match" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const ctx = testCtx(arena.allocator()); + + const args = [_]Value{ .{ .string = "X-" }, .{ .string = "" } }; + const out = try gsub(ctx, .{ .string = "remove-X-and-X-everything" }, &args); + try std.testing.expectEqualStrings("remove-and-everything", out.string); +} diff --git a/src/lib.zig b/src/lib.zig index b18cf0e..2d1e64a 100644 --- a/src/lib.zig +++ b/src/lib.zig @@ -82,6 +82,7 @@ pub const perms = @import("core/perms.zig"); pub const sandbox_macos = @import("core/sandbox/macos.zig"); pub const main_mod = @import("main.zig"); pub const app_ctx = @import("app_ctx.zig"); +pub const text_replace = @import("text_replace.zig"); test { @import("std").testing.refAllDecls(@This()); diff --git a/src/macho/patcher.zig b/src/macho/patcher.zig index ab4fd04..1f2c872 100644 --- a/src/macho/patcher.zig +++ b/src/macho/patcher.zig @@ -3,6 +3,7 @@ const std = @import("std"); const parser = @import("parser.zig"); +const text_replace = @import("../text_replace.zig"); pub const PatchError = error{ PathTooLong, @@ -403,60 +404,4 @@ fn hasPrefix(path: []const u8, prefix: []const u8) bool { return std.mem.eql(u8, path[0..prefix.len], prefix); } -/// Replace all occurrences of `needle` with `replacement` in `haystack`. -/// Returns the original slice (same pointer) if there were no matches, or -/// a caller-owned allocation with the substitution applied. Uses -/// `std.mem.findPos` which is memchr-based and significantly faster -/// than a naive byte-by-byte `mem.eql` loop for small needles — the old -/// implementation showed up as ~60 samples on `mem.eqlBytes` in the -/// warm-ffmpeg profile. -fn replaceAll(allocator: std.mem.Allocator, haystack: []const u8, needle: []const u8, replacement: []const u8) ![]const u8 { - if (needle.len == 0) return haystack; - - // Fast path: no matches at all → return the original slice unchanged. - const first = std.mem.indexOf(u8, haystack, needle) orelse return haystack; - - // Count the remaining matches so we can preallocate exactly. - // (indexOfPos is O(n) per call; the total work is one linear pass.) - var match_count: usize = 1; - var probe = first + needle.len; - while (std.mem.findPos(u8, haystack, probe, needle)) |p| { - match_count += 1; - probe = p + needle.len; - } - - const rep_len = replacement.len; - const ndl_len = needle.len; - const new_len = haystack.len + match_count * rep_len - match_count * ndl_len; - const buf = try allocator.alloc(u8, new_len); - errdefer allocator.free(buf); - - // Second pass: copy segments between matches and write the replacement - // at each match position. Uses indexOfPos for the fast scan. - var src: usize = 0; - var dst: usize = 0; - var match = first; - while (true) { - // Copy the segment leading up to `match`. - const segment_len = match - src; - if (segment_len > 0) { - @memcpy(buf[dst .. dst + segment_len], haystack[src..match]); - dst += segment_len; - } - // Emit replacement. - @memcpy(buf[dst .. dst + rep_len], replacement); - dst += rep_len; - src = match + ndl_len; - - match = std.mem.findPos(u8, haystack, src, needle) orelse break; - } - - // Tail: everything after the last match. - if (src < haystack.len) { - @memcpy(buf[dst .. dst + (haystack.len - src)], haystack[src..]); - dst += haystack.len - src; - } - - std.debug.assert(dst == new_len); - return buf; -} +const replaceAll = text_replace.replaceAll; diff --git a/src/text_replace.zig b/src/text_replace.zig new file mode 100644 index 0000000..288203b --- /dev/null +++ b/src/text_replace.zig @@ -0,0 +1,113 @@ +//! malt - shared byte-string replace. +//! +//! `mt migrate` rewrites and Mach-O text patching both walk large bodies of +//! `.la` / pkgconfig / dylib bytes; the hot path is dominated by the inner +//! match loop. `std.mem.indexOf` / `std.mem.findPos` go through memchr, +//! which beats a hand-rolled `mem.eqlBytes` byte scan by an order of +//! magnitude on small needles. + +const std = @import("std"); + +/// Replace every occurrence of `needle` in `haystack` with `replacement`. +/// Returns the original slice (same pointer) when `needle` is empty or has +/// no match - callers must compare pointers before freeing if they own +/// `haystack`. Otherwise the result is a caller-owned allocation. +pub fn replaceAll( + allocator: std.mem.Allocator, + haystack: []const u8, + needle: []const u8, + replacement: []const u8, +) ![]const u8 { + if (needle.len == 0) return haystack; + + // Fast path: no matches at all -> hand back the original slice. + const first = std.mem.indexOf(u8, haystack, needle) orelse return haystack; + + // Count remaining matches in a single linear pass so the output + // allocation is sized exactly. + var match_count: usize = 1; + var probe = first + needle.len; + while (std.mem.findPos(u8, haystack, probe, needle)) |p| { + match_count += 1; + probe = p + needle.len; + } + + const rep_len = replacement.len; + const ndl_len = needle.len; + const new_len = haystack.len + match_count * rep_len - match_count * ndl_len; + const buf = try allocator.alloc(u8, new_len); + errdefer allocator.free(buf); + + var src: usize = 0; + var dst: usize = 0; + var match = first; + while (true) { + const segment_len = match - src; + if (segment_len > 0) { + @memcpy(buf[dst .. dst + segment_len], haystack[src..match]); + dst += segment_len; + } + @memcpy(buf[dst .. dst + rep_len], replacement); + dst += rep_len; + src = match + ndl_len; + + match = std.mem.findPos(u8, haystack, src, needle) orelse break; + } + + if (src < haystack.len) { + @memcpy(buf[dst .. dst + (haystack.len - src)], haystack[src..]); + dst += haystack.len - src; + } + + std.debug.assert(dst == new_len); + return buf; +} + +test "replaceAll on multi-match expanding replacement rewrites every occurrence" { + const haystack = "aXbXc"; + const out = try replaceAll(std.testing.allocator, haystack, "X", "YYY"); + defer if (out.ptr != haystack.ptr) std.testing.allocator.free(out); + try std.testing.expectEqualStrings("aYYYbYYYc", out); +} + +test "replaceAll on contracting replacement keeps tail bytes intact" { + const haystack = "header /OLD/path/to/lib /OLD/other tail"; + const out = try replaceAll(std.testing.allocator, haystack, "/OLD", "/N"); + defer if (out.ptr != haystack.ptr) std.testing.allocator.free(out); + try std.testing.expectEqualStrings("header /N/path/to/lib /N/other tail", out); +} + +test "replaceAll with no match returns the input slice unchanged" { + const haystack: []const u8 = "no needles here"; + const out = try replaceAll(std.testing.allocator, haystack, "absent", "present"); + try std.testing.expectEqual(haystack.ptr, out.ptr); + try std.testing.expectEqual(haystack.len, out.len); +} + +test "replaceAll with empty needle returns the input slice unchanged" { + const haystack: []const u8 = "abc"; + const out = try replaceAll(std.testing.allocator, haystack, "", "Z"); + try std.testing.expectEqual(haystack.ptr, out.ptr); +} + +test "replaceAll handles overlapping-prefix needles without skipping matches" { + // "aaa" with needle "aa" should yield exactly two non-overlapping matches. + const haystack = "aaaa"; + const out = try replaceAll(std.testing.allocator, haystack, "aa", "B"); + defer if (out.ptr != haystack.ptr) std.testing.allocator.free(out); + try std.testing.expectEqualStrings("BB", out); +} + +test "replaceAll deletes matches when replacement is empty" { + const haystack = "remove-X-and-X-everything"; + const out = try replaceAll(std.testing.allocator, haystack, "X-", ""); + defer if (out.ptr != haystack.ptr) std.testing.allocator.free(out); + try std.testing.expectEqualStrings("remove-and-everything", out); +} + +test "replaceAll on adjacent matches walks past each one cleanly" { + const haystack = "AAAA"; + const out = try replaceAll(std.testing.allocator, haystack, "A", "ZZ"); + defer if (out.ptr != haystack.ptr) std.testing.allocator.free(out); + try std.testing.expectEqualStrings("ZZZZZZZZ", out); +} From 7705054b070f1ff279daa6a19d2695b0d424ada6 Mon Sep 17 00:00:00 2001 From: indaco Date: Thu, 7 May 2026 20:22:06 +0200 Subject: [PATCH 2/3] test(text-replace): pin single-match, empty-haystack, and recursion-safety paths Tighten replaceAll's branch coverage: the prior set hit every multi-match shape but skipped the count-equals-one short-circuit, the empty-input guard, and the equal-length-replacement arithmetic. Also pin that an expansion whose replacement contains the needle does not re-scan its own output - a regression here would silently double-expand macho paths. --- src/text_replace.zig | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/text_replace.zig b/src/text_replace.zig index 288203b..dadc9a9 100644 --- a/src/text_replace.zig +++ b/src/text_replace.zig @@ -111,3 +111,34 @@ test "replaceAll on adjacent matches walks past each one cleanly" { defer if (out.ptr != haystack.ptr) std.testing.allocator.free(out); try std.testing.expectEqualStrings("ZZZZZZZZ", out); } + +test "replaceAll on a single match copies prefix, replacement, and tail" { + const haystack = "before/OLD/after"; + const out = try replaceAll(std.testing.allocator, haystack, "/OLD/", "/N/"); + defer if (out.ptr != haystack.ptr) std.testing.allocator.free(out); + try std.testing.expectEqualStrings("before/N/after", out); +} + +test "replaceAll on an empty haystack hands the empty slice back" { + const haystack: []const u8 = ""; + const out = try replaceAll(std.testing.allocator, haystack, "x", "y"); + try std.testing.expectEqual(@as(usize, 0), out.len); +} + +test "replaceAll does not re-scan replacement text for further matches" { + // "a"->"aa" must produce one expansion per original "a", not run away + // by re-matching the expansion itself. + const haystack = "aXa"; + const out = try replaceAll(std.testing.allocator, haystack, "a", "aa"); + defer if (out.ptr != haystack.ptr) std.testing.allocator.free(out); + try std.testing.expectEqualStrings("aaXaa", out); +} + +test "replaceAll preserves length when replacement is the same size as needle" { + // Representative shape of a macho path swap: "/A/" -> "/B/". + const haystack = "lib/A/x.dylib /A/y.dylib"; + const out = try replaceAll(std.testing.allocator, haystack, "/A/", "/B/"); + defer if (out.ptr != haystack.ptr) std.testing.allocator.free(out); + try std.testing.expectEqual(haystack.len, out.len); + try std.testing.expectEqualStrings("lib/B/x.dylib /B/y.dylib", out); +} From d6ae96e1ae49ea72ca291192b8ee012a149e4005 Mon Sep 17 00:00:00 2001 From: indaco Date: Thu, 7 May 2026 20:26:05 +0200 Subject: [PATCH 3/3] test(text-replace): add integration tests for OOM, scale, and pkgconfig shape Cover what the inline tests can't reach: the result-allocation OOM path, the no-alloc fast paths under a failing allocator, a 1000-match haystack that drives findPos across memchr boundaries, and a synthetic pkgconfig body shaped like the .pc / .la rewrites mt migrate runs through the DSL. --- build.zig | 1 + tests/text_replace_test.zig | 109 ++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 tests/text_replace_test.zig diff --git a/build.zig b/build.zig index 7c14eef..800888f 100644 --- a/build.zig +++ b/build.zig @@ -210,6 +210,7 @@ pub fn build(b: *std.Build) void { "tests/list_cli_test.zig", "tests/tap_cli_test.zig", "tests/uses_cli_test.zig", + "tests/text_replace_test.zig", }; const test_step = b.step("test", "Run all unit tests"); diff --git a/tests/text_replace_test.zig b/tests/text_replace_test.zig new file mode 100644 index 0000000..9b21350 --- /dev/null +++ b/tests/text_replace_test.zig @@ -0,0 +1,109 @@ +//! malt - text_replace integration tests. +//! +//! Inline tests in src/text_replace.zig pin the small-input branches. +//! These cases exercise the function past what fits inside the source +//! file: allocator-failure semantics, large multi-match haystacks, and +//! realistic .la / pkgconfig payloads of the kind mt migrate rewrites. + +const std = @import("std"); +const testing = std.testing; +const malt = @import("malt"); +const text_replace = malt.text_replace; + +test "replaceAll surfaces OOM when the result allocation fails" { + // One alloc happens on the match path: the output buffer. fail_index=0 + // trips it. The function must surface OutOfMemory rather than panic or + // hand back a partial slice. + var failing = std.testing.FailingAllocator.init(testing.allocator, .{ .fail_index = 0 }); + try testing.expectError( + error.OutOfMemory, + text_replace.replaceAll(failing.allocator(), "abcXdef", "X", "YYYY"), + ); +} + +test "replaceAll on a no-match input never touches the allocator" { + // The fast path returns the input slice; even an allocator that fails + // every request must not see a call. + var failing = std.testing.FailingAllocator.init(testing.allocator, .{ .fail_index = 0 }); + const haystack: []const u8 = "no needles in this hay"; + const out = try text_replace.replaceAll(failing.allocator(), haystack, "absent", "present"); + try testing.expectEqual(haystack.ptr, out.ptr); + try testing.expectEqual(@as(usize, 0), failing.allocations); +} + +test "replaceAll on an empty-needle input never touches the allocator" { + var failing = std.testing.FailingAllocator.init(testing.allocator, .{ .fail_index = 0 }); + const haystack: []const u8 = "abc"; + const out = try text_replace.replaceAll(failing.allocator(), haystack, "", "Z"); + try testing.expectEqual(haystack.ptr, out.ptr); + try testing.expectEqual(@as(usize, 0), failing.allocations); +} + +test "replaceAll on a 1000-match haystack produces the expected expansion" { + // Synthesise "XX...X" where is a fixed 7-byte block, so + // the matches are spaced widely enough to exercise findPos crossing + // memchr boundaries on every iteration. + const sep = "_______"; + const matches: usize = 1000; + + var hay = std.ArrayList(u8).empty; + defer hay.deinit(testing.allocator); + for (0..matches) |i| { + try hay.appendSlice(testing.allocator, "X"); + if (i + 1 < matches) try hay.appendSlice(testing.allocator, sep); + } + + const out = try text_replace.replaceAll(testing.allocator, hay.items, "X", "YY"); + defer if (out.ptr != hay.items.ptr) testing.allocator.free(out); + + // Byte-exact length check: 2*matches replacement bytes + (matches-1)*sep. + try testing.expectEqual(matches * 2 + (matches - 1) * sep.len, out.len); + // Each match site holds "YY"; each separator is preserved verbatim. + try testing.expect(std.mem.startsWith(u8, out, "YY")); + try testing.expect(std.mem.endsWith(u8, out, "YY")); + try testing.expectEqual(@as(usize, matches), std.mem.count(u8, out, "YY")); + try testing.expectEqual(matches - 1, std.mem.count(u8, out, sep)); +} + +test "replaceAll rewrites every prefix= line in a synthetic pkgconfig body" { + // Representative of the .pc / .la rewrites mt migrate drives through + // inreplace: many short fixed-prefix matches scattered across a body + // that also contains tokens which must not be touched. + const body = + "prefix=/old/opt/foo\n" ++ + "exec_prefix=${prefix}\n" ++ + "libdir=/old/opt/foo/lib\n" ++ + "includedir=/old/opt/foo/include\n" ++ + "Name: foo\n" ++ + "Description: refers to /old/opt/foo at runtime\n" ++ + "Cflags: -I/old/opt/foo/include\n" ++ + "Libs: -L/old/opt/foo/lib -lfoo\n"; + + const expected = + "prefix=/new/cellar/foo\n" ++ + "exec_prefix=${prefix}\n" ++ + "libdir=/new/cellar/foo/lib\n" ++ + "includedir=/new/cellar/foo/include\n" ++ + "Name: foo\n" ++ + "Description: refers to /new/cellar/foo at runtime\n" ++ + "Cflags: -I/new/cellar/foo/include\n" ++ + "Libs: -L/new/cellar/foo/lib -lfoo\n"; + + const out = try text_replace.replaceAll(testing.allocator, body, "/old/opt/foo", "/new/cellar/foo"); + defer if (out.ptr != body.ptr) testing.allocator.free(out); + try testing.expectEqualStrings(expected, out); +} + +test "replaceAll preserves byte order across an alternating match/non-match pattern" { + // Stress the segment-copy path: many small interleaved matches and + // non-match runs of mixed length, so each iteration of the copy loop + // hits a non-zero segment_len and a different tail size. + const haystack = + "aabbbcddddeffffff"; + const expected = + "aa[r]bbb[r]c[r]dddd[r]e[r]ffffff"; + + const out = try text_replace.replaceAll(testing.allocator, haystack, "", "[r]"); + defer if (out.ptr != haystack.ptr) testing.allocator.free(out); + try testing.expectEqualStrings(expected, out); +}