lib/strings+test: port replace per Hare

ref/hare/strings/replace.ha:46-66. Two-pass byte scan: pass 1 counts
non-overlapping needle hits via bytes.hasprefix, pass 2 allocs the
result []u8 at exact size and copies chunks + replacement. Single
nomem propagation site at the alloc — ww's append builtin aborts
on OOM (#11), so the per-chunk append(...)? form Hare uses is not
available; the exact-size single alloc is equivalent in spec.

total==0 returns {nil,0} to dodge rt_alloc(0) per #47.

Empty needle is intentionally ungated and loops forever — that's
Hare's behavior at ref/hare/strings/replace.ha:31 (i += len(needle)
is 0; hasprefix("") always matches). Hare-faithful divergence,
documented at the site.

multireplace deferred to #49 — its (str, str) variadic gather hits
#39 in variadic-param position. Filed and blocked accordingly.
This commit is contained in:
2026-05-20 02:46:22 +09:00
parent f8770d1502
commit cd44cc1893
5 changed files with 336 additions and 0 deletions

View File

@@ -906,6 +906,62 @@ export fn lpad(s: str, p: rune, maxlen: i32) str = {
return r;
};
// replace — fresh allocation of `s` with every non-overlapping
// occurrence of `needle` replaced by `target`. Caller releases with
// `os.free(r.ptr, r.len: u64)`. ref/hare/strings/replace.ha:8 (#4).
//
// Hare delegates to [[multireplace]] with a single pair; ww has no
// `(str, str)` variadic shape today (#39), so this is a standalone
// two-pass implementation: pass 1 counts matches to size the result,
// pass 2 copies chunks and `target` into a single fresh buffer.
// Single nomem path (the `alloc([], total)?`) preserves Hare's
// signature without a per-write `append(...)?` (ww's append builtin
// aborts on OOM, #11). Empty `needle` would hasprefix-match every
// position with a zero stride — same infinite loop Hare exhibits at
// ref/hare/strings/replace.ha:31; not gated.
export fn replace(s: str, needle: str, target: str) (str | nomem) = {
let sb: []u8 = toutf8(s);
let nb: []u8 = toutf8(needle);
let tb: []u8 = toutf8(target);
let count: i32 = 0;
let i: i32 = 0;
for (i < sb.len) {
if (bytes.hasprefix(sb[i:sb.len], nb)) {
count += 1;
i += nb.len;
} else {
i += 1;
};
};
let total: i32 = sb.len + count * (tb.len - nb.len);
if (total == 0) {
let r: str;
r.ptr = nil;
r.len = 0;
return r;
};
let res: []u8 = alloc([], total)?;
let off: i32 = 0;
i = 0;
for (i < sb.len) {
if (bytes.hasprefix(sb[i:sb.len], nb)) {
let j: i32 = 0;
for (j < tb.len) {
res.ptr[off + j] = tb.ptr[j];
j += 1;
};
off += tb.len;
i += nb.len;
} else {
res.ptr[off] = sb.ptr[i];
off += 1;
i += 1;
};
};
res.len = total;
return fromutf8_unsafe(res);
};
// rpad — right-pad `s` with `p` rune until the result reaches `maxlen`
// bytes. Symmetric with [[lpad]]. ref/hare/strings/pad.ha:39.
export fn rpad(s: str, p: rune, maxlen: i32) str = {

View File

@@ -1664,6 +1664,117 @@ fn expect_str(toks: []str, i: i32, want: str) void = {
os.free(r6.ptr: *void, r6.len: u64);
};
// ---- replace ----------------------------------------------------------
// ref/hare/strings/replace.ha:46 (#4). Hare rows 1-5 (target found
// once / multiple times / shorter / longer / multibyte) plus ww rows
// (no-match returns fresh copy, empty-replacement, result-shrinks-to-
// empty). Per-row `signalled` 1810+i narrows the failing row.
@test fn replace_cases() void = {
// Hare row 1: target found once.
signalled = 1810;
match (strings.replace("Hello world!", "world", "there")) {
case let s: str => {
if (!streq(s, "Hello there!")) { fail(); };
os.free(s.ptr: *void, s.len: u64);
};
case nomem => { fail(); };
};
// Hare row 2: needle found four times (replacement same length).
signalled = 1811;
match (strings.replace("I like dogs, dogs, birds, dogs", "dogs", "cats")) {
case let s: str => {
if (!streq(s, "I like cats, cats, birds, cats")) { fail(); };
os.free(s.ptr: *void, s.len: u64);
};
case nomem => { fail(); };
};
// Hare row 3: replacement shorter than needle, non-overlapping
// matches (Hare advances by needle.len, not 1).
signalled = 1812;
match (strings.replace("aaaaaa", "aa", "a")) {
case let s: str => {
if (!streq(s, "aaa")) { fail(); };
os.free(s.ptr: *void, s.len: u64);
};
case nomem => { fail(); };
};
// Hare row 4: replacement longer than needle.
signalled = 1813;
match (strings.replace("aaa", "a", "aa")) {
case let s: str => {
if (!streq(s, "aaaaaa")) { fail(); };
os.free(s.ptr: *void, s.len: u64);
};
case nomem => { fail(); };
};
// Hare row 5: multibyte UTF-8 (3-byte runes).
signalled = 1814;
match (strings.replace("こんにちは", "にち", "ばん")) {
case let s: str => {
if (!streq(s, "こんばんは")) { fail(); };
os.free(s.ptr: *void, s.len: u64);
};
case nomem => { fail(); };
};
// ww row 1: needle not found — fresh copy (distinct from
// borrowed input ptr).
signalled = 1815;
let src: str = "hello world";
match (strings.replace(src, "xyz", "abc")) {
case let s: str => {
if (!streq(s, "hello world")) { fail(); };
if (s.ptr == src.ptr) { fail(); };
os.free(s.ptr: *void, s.len: u64);
};
case nomem => { fail(); };
};
// ww row 2: empty replacement — needle is removed.
signalled = 1816;
match (strings.replace("foo-bar-baz", "-", "")) {
case let s: str => {
if (!streq(s, "foobarbaz")) { fail(); };
os.free(s.ptr: *void, s.len: u64);
};
case nomem => { fail(); };
};
// ww row 3: result shrinks to empty — total==0 nil-alloc path.
signalled = 1817;
match (strings.replace("aaaa", "aa", "")) {
case let s: str => {
if (!streq(s, "")) { fail(); };
if (s.len != 0) { fail(); };
};
case nomem => { fail(); };
};
// ww row 4: multibyte single-rune replace — every "に" → "X".
signalled = 1818;
match (strings.replace("こんにちは", "に", "X")) {
case let s: str => {
if (!streq(s, "こんXちは")) { fail(); };
os.free(s.ptr: *void, s.len: u64);
};
case nomem => { fail(); };
};
// ww row 5: empty input — returns empty.
signalled = 1819;
match (strings.replace("", "x", "y")) {
case let s: str => {
if (s.len != 0) { fail(); };
};
case nomem => { fail(); };
};
};
export fn main() i32 = {
signalled = 1; dup_cases();
signalled = 42; dupall_cases();
@@ -1708,5 +1819,6 @@ export fn main() i32 = {
signalled = 37; split_cases();
signalled = 38; lpad_cases();
signalled = 39; rpad_cases();
signalled = 44; replace_cases();
return 0;
};

View File

@@ -2743,6 +2743,62 @@ export fn lpad(s: str, p: rune, maxlen: i32) str = {
return r;
};
// replace — fresh allocation of `s` with every non-overlapping
// occurrence of `needle` replaced by `target`. Caller releases with
// `os.free(r.ptr, r.len: u64)`. ref/hare/strings/replace.ha:8 (#4).
//
// Hare delegates to [[multireplace]] with a single pair; ww has no
// `(str, str)` variadic shape today (#39), so this is a standalone
// two-pass implementation: pass 1 counts matches to size the result,
// pass 2 copies chunks and `target` into a single fresh buffer.
// Single nomem path (the `alloc([], total)?`) preserves Hare's
// signature without a per-write `append(...)?` (ww's append builtin
// aborts on OOM, #11). Empty `needle` would hasprefix-match every
// position with a zero stride — same infinite loop Hare exhibits at
// ref/hare/strings/replace.ha:31; not gated.
export fn replace(s: str, needle: str, target: str) (str | nomem) = {
let sb: []u8 = toutf8(s);
let nb: []u8 = toutf8(needle);
let tb: []u8 = toutf8(target);
let count: i32 = 0;
let i: i32 = 0;
for (i < sb.len) {
if (bytes.hasprefix(sb[i:sb.len], nb)) {
count += 1;
i += nb.len;
} else {
i += 1;
};
};
let total: i32 = sb.len + count * (tb.len - nb.len);
if (total == 0) {
let r: str;
r.ptr = nil;
r.len = 0;
return r;
};
let res: []u8 = alloc([], total)?;
let off: i32 = 0;
i = 0;
for (i < sb.len) {
if (bytes.hasprefix(sb[i:sb.len], nb)) {
let j: i32 = 0;
for (j < tb.len) {
res.ptr[off + j] = tb.ptr[j];
j += 1;
};
off += tb.len;
i += nb.len;
} else {
res.ptr[off] = sb.ptr[i];
off += 1;
i += 1;
};
};
res.len = total;
return fromutf8_unsafe(res);
};
// rpad — right-pad `s` with `p` rune until the result reaches `maxlen`
// bytes. Symmetric with [[lpad]]. ref/hare/strings/pad.ha:39.
export fn rpad(s: str, p: rune, maxlen: i32) str = {

View File

@@ -2743,6 +2743,62 @@ export fn lpad(s: str, p: rune, maxlen: i32) str = {
return r;
};
// replace — fresh allocation of `s` with every non-overlapping
// occurrence of `needle` replaced by `target`. Caller releases with
// `os.free(r.ptr, r.len: u64)`. ref/hare/strings/replace.ha:8 (#4).
//
// Hare delegates to [[multireplace]] with a single pair; ww has no
// `(str, str)` variadic shape today (#39), so this is a standalone
// two-pass implementation: pass 1 counts matches to size the result,
// pass 2 copies chunks and `target` into a single fresh buffer.
// Single nomem path (the `alloc([], total)?`) preserves Hare's
// signature without a per-write `append(...)?` (ww's append builtin
// aborts on OOM, #11). Empty `needle` would hasprefix-match every
// position with a zero stride — same infinite loop Hare exhibits at
// ref/hare/strings/replace.ha:31; not gated.
export fn replace(s: str, needle: str, target: str) (str | nomem) = {
let sb: []u8 = toutf8(s);
let nb: []u8 = toutf8(needle);
let tb: []u8 = toutf8(target);
let count: i32 = 0;
let i: i32 = 0;
for (i < sb.len) {
if (bytes.hasprefix(sb[i:sb.len], nb)) {
count += 1;
i += nb.len;
} else {
i += 1;
};
};
let total: i32 = sb.len + count * (tb.len - nb.len);
if (total == 0) {
let r: str;
r.ptr = nil;
r.len = 0;
return r;
};
let res: []u8 = alloc([], total)?;
let off: i32 = 0;
i = 0;
for (i < sb.len) {
if (bytes.hasprefix(sb[i:sb.len], nb)) {
let j: i32 = 0;
for (j < tb.len) {
res.ptr[off + j] = tb.ptr[j];
j += 1;
};
off += tb.len;
i += nb.len;
} else {
res.ptr[off] = sb.ptr[i];
off += 1;
i += 1;
};
};
res.len = total;
return fromutf8_unsafe(res);
};
// rpad — right-pad `s` with `p` rune until the result reaches `maxlen`
// bytes. Symmetric with [[lpad]]. ref/hare/strings/pad.ha:39.
export fn rpad(s: str, p: rune, maxlen: i32) str = {

View File

@@ -2634,6 +2634,62 @@ export fn lpad(s: str, p: rune, maxlen: i32) str = {
return r;
};
// replace — fresh allocation of `s` with every non-overlapping
// occurrence of `needle` replaced by `target`. Caller releases with
// `os.free(r.ptr, r.len: u64)`. ref/hare/strings/replace.ha:8 (#4).
//
// Hare delegates to [[multireplace]] with a single pair; ww has no
// `(str, str)` variadic shape today (#39), so this is a standalone
// two-pass implementation: pass 1 counts matches to size the result,
// pass 2 copies chunks and `target` into a single fresh buffer.
// Single nomem path (the `alloc([], total)?`) preserves Hare's
// signature without a per-write `append(...)?` (ww's append builtin
// aborts on OOM, #11). Empty `needle` would hasprefix-match every
// position with a zero stride — same infinite loop Hare exhibits at
// ref/hare/strings/replace.ha:31; not gated.
export fn replace(s: str, needle: str, target: str) (str | nomem) = {
let sb: []u8 = toutf8(s);
let nb: []u8 = toutf8(needle);
let tb: []u8 = toutf8(target);
let count: i32 = 0;
let i: i32 = 0;
for (i < sb.len) {
if (bytes.hasprefix(sb[i:sb.len], nb)) {
count += 1;
i += nb.len;
} else {
i += 1;
};
};
let total: i32 = sb.len + count * (tb.len - nb.len);
if (total == 0) {
let r: str;
r.ptr = nil;
r.len = 0;
return r;
};
let res: []u8 = alloc([], total)?;
let off: i32 = 0;
i = 0;
for (i < sb.len) {
if (bytes.hasprefix(sb[i:sb.len], nb)) {
let j: i32 = 0;
for (j < tb.len) {
res.ptr[off + j] = tb.ptr[j];
j += 1;
};
off += tb.len;
i += nb.len;
} else {
res.ptr[off] = sb.ptr[i];
off += 1;
i += 1;
};
};
res.len = total;
return fromutf8_unsafe(res);
};
// rpad — right-pad `s` with `p` rune until the result reaches `maxlen`
// bytes. Symmetric with [[lpad]]. ref/hare/strings/pad.ha:39.
export fn rpad(s: str, p: rune, maxlen: i32) str = {