From 8a6f4090d3c1c176e4ee92016964941c052563ac Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:42:27 -0800 Subject: [PATCH 1/2] chore(quest): claim quest/m3/ts-string-add.md Quest-Claim: e3a4eec625b7d82b From b899c680e9bfa7a72f8f820790249a42c3dd565f Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:04:46 -0800 Subject: [PATCH 2/2] feat(ts): translate String + &str and Cow's + as += does Co-Authored-By: Claude Opus 5.5 --- quest/m3/README.md | 2 +- quest/m3/ts-reassigned-pop.md | 30 +++++++++++ quest/m3/ts-string-add.md | 13 ----- ts/README.md | 3 +- ts/fixtures/lang/expected/cows.ts | 55 ++++++++++--------- ts/fixtures/lang/expected/strs.ts | 90 ++++++++++++++++++------------- ts/fixtures/lang/src/cows.rs | 6 +++ ts/fixtures/lang/src/strs.rs | 12 ++++- ts/src/lower/library.rs | 5 ++ ts/src/lower/mir/call.rs | 6 ++- 10 files changed, 141 insertions(+), 81 deletions(-) create mode 100644 quest/m3/ts-reassigned-pop.md delete mode 100644 quest/m3/ts-string-add.md diff --git a/quest/m3/README.md b/quest/m3/README.md index d166fcf22..3d3e9af79 100644 --- a/quest/m3/README.md +++ b/quest/m3/README.md @@ -37,7 +37,7 @@ generic std gap is planned here even when moq never calls it. - [A vector's exact capacity as it shrinks](/quest/m3/ts-vec-exact-capacity.md) - a deque made from a `Vec` splits its slices where std's does - [A B-tree's searches call Ord as std's do](/quest/m3/ts-btree-search-order.md) - a B-tree's lookups and inserts call a key's own `Ord` in std's order and number - [Rc and Arc of a slice](/quest/m3/ts-shared-slices.md) - `Rc<[T]>` and `Arc<[T]>` from a `Vec`, a slice, or an iterator translate -- [Adding to a String](/quest/m3/ts-string-add.md) - `String + &str` translates as `+=` does +- [Popping a String that is reassigned elsewhere](/quest/m3/ts-reassigned-pop.md) - `pop`, `remove`, and `split_off` of a local some other path assigns whole translate - [The rest of a vector's methods](/quest/m3/ts-vec-rest.md) - `append`, `resize`, `split_off`, `try_reserve`, and `Vec::from(VecDeque)` translate - [A reversed zip's per-pair cost](/quest/m3/ts-zip-rev-overhead.md) - a reversed by-index zip costs per pair what a forward one does, or the gap is explained - [Whole writes into a lazy cell or thread-local key](/quest/m3/ts-lazy-writes.md) - a whole write through a `&mut` to a `LazyCell`, `LazyLock`, or `LocalKey` keeps its state diff --git a/quest/m3/ts-reassigned-pop.md b/quest/m3/ts-reassigned-pop.md new file mode 100644 index 000000000..74a7f0ec3 --- /dev/null +++ b/quest/m3/ts-reassigned-pop.md @@ -0,0 +1,30 @@ +# [S] Popping a String that is reassigned elsewhere + +## Goal + +[rust2-ts](/ts/README.md) translates `String::pop`, `remove`, and `split_off` on a local +that another path assigns whole, proven by a differential test against native Rust. + +## Plan + +Found on 2026-11-02 testing [`String + &str`](https://github.com/moq-dev/rust2/pull/479): +with one arm `s = String::from("x")`, another arm's `s.pop()` reads `s` but writes the +result through `_3.v`, an `rt.Place` it never declares, so `tsc` fails. `truncate` and +`insert` in the same spot declare the place and pass. + +```rust +pub fn local(op: u8) -> Option { + let mut s = String::new(); + match op { + 1 => return s.pop(), + _ => s = String::from("x"), + } + None +} +``` + +The read and the write of the borrowed place disagree on whether the `&mut` is an alias of +`s` or its `rt.Place`; look for every edit that reads through `borrowed_place` and writes +through `store`. + +Public API: the translator's output. diff --git a/quest/m3/ts-string-add.md b/quest/m3/ts-string-add.md deleted file mode 100644 index 8e6bc28d5..000000000 --- a/quest/m3/ts-string-add.md +++ /dev/null @@ -1,13 +0,0 @@ -# [XS] Adding to a String - -## Goal - -[rust2-ts](/ts/README.md) translates `String + &str`, which moves the `String` and pushes -onto it, as `+=` already does, proven by a differential test against native Rust. - -## Plan - -Found on 2026-11-02 writing a scratch test for `&mut dyn Debug`: `a + &b` with `a: String` -is an "unsupported call to `>::add`", while `+=` maps. - -Public API: the translator's output. diff --git a/ts/README.md b/ts/README.md index ac0ff7939..2b86f2ad1 100644 --- a/ts/README.md +++ b/ts/README.md @@ -315,7 +315,8 @@ one `&s[a..b]` or `&s[a..=b]` takes, which reads the string up to it and panics does inside a char, where `get` gives `None`. A JS string can't change in place, so `String`'s edits, `push_str`, `truncate`, `insert`, `retain`, `replace_range`, and the rest, assign the edited one to the local or field the `&mut` borrows, taking and checking byte offsets as std does, and `retain` then -drops its closure. A `&mut str` is +drops its closure. `+`, of a `String` or a `Cow`, is JS's `+` of the moved text, as +`+=` is. A `&mut str` is the `rt.Place` of its `String` wherever that is, and a part of one, from `&mut s[a..b]`, `get_mut`, or `split_at_mut`, is `rt.strPart(p, a, b)`, a place of its own that writes the whole string anew, checking its offsets as `&s[a..b]` does. `as_bytes_mut`, diff --git a/ts/fixtures/lang/expected/cows.ts b/ts/fixtures/lang/expected/cows.ts index 5659f6c47..356431420 100644 --- a/ts/fixtures/lang/expected/cows.ts +++ b/ts/fixtures/lang/expected/cows.ts @@ -64,27 +64,32 @@ export function cows(kind: rt.u8, data: rt.ByteSlice, out: rt.ByteVec): void { fmt_put(out, _124); } fmt_put(out, c2); + let joined: string = cow("", owned) + first + cow(s, !owned); + const _145: string = cow("", !owned); + joined = joined + _145; + joined = joined + "+"; + fmt_put(out, joined); if ((kind & 64) === 0) { r = rt.Ok(cow(first, owned)); } else { r = rt.Err(kind); } - const _138: rt.Result = exclaimed(r); - if (_138.ok) { - fmt_put(out, _138.value); + const _156: rt.Result = exclaimed(r); + if (_156.ok) { + fmt_put(out, _156.value); } else { - rt.byteVecPush(out, _138.error); + rt.byteVecPush(out, _156.error); } } else if (_14 === 2) { const a: string = cow(s, owned); const b: string = cow(first, !owned); const iter2: rt.Iter = rt.iterSlice([a === first, a === first, a === s, first === a, s === a, a === b, a !== b, rt.cmpStr(a, b) < 0, rt.cmpStr(a, b) >= 0, rt.cmpStr(a, b) === 1]); while (true) { - const _187: boolean | rt.Done = iter2.next(); - if (_187 === rt.DONE) { + const _205: boolean | rt.Done = iter2.next(); + if (_205 === rt.DONE) { break; } - rt.byteVecPush(out, _187 ? 1 : 0); + rt.byteVecPush(out, _205 ? 1 : 0); } rt.byteVecPush(out, rt.ordMax(a, b, rt.cmpLt(rt.cmpStr)) === b ? 1 : 0); } else if (_14 === 3) { @@ -97,45 +102,45 @@ export function cows(kind: rt.u8, data: rt.ByteSlice, out: rt.ByteVec): void { rt.byteVecPush(out, cows_Named.cmp$cmp_Ord(n, m2) > 0 ? 1 : 0); rt.byteVecPush(out, cows_Named.partialCmp$cmp_PartialOrd(n, m2) === -1 ? 1 : 0); fmt_put(out, cows_Named.intoOwned(n).name); - const _252: cows_Named.Named = cows_Named.default$Default(); - const _254: cows_Named.Named = cows_Named.new("", kind); - const args4: rt.Fmt[] = [(f: rt.Formatter) => cows_Named.fmt$fmt_Debug(_252, f), (f: rt.Formatter) => cows_Named.fmt$fmt_Debug(_254, f)]; + const _270: cows_Named.Named = cows_Named.default$Default(); + const _272: cows_Named.Named = cows_Named.new("", kind); + const args4: rt.Fmt[] = [(f: rt.Formatter) => cows_Named.fmt$fmt_Debug(_270, f), (f: rt.Formatter) => cows_Named.fmt$fmt_Debug(_272, f)]; fmt_put(out, rt.fmtFormat([args4[0], " ", args4[1]])); } else if (_14 === 4) { const counts: rt.BTreeMap = rt.btreeNew(rt.cmpStr) as rt.BTreeMap; const iter3: rt.Iter<[rt.usize, string]> = rt.iterEnumerate(rt.strSplit(s, "/")); while (true) { - const _268: [rt.usize, string] | rt.Done = iter3.next(); - if (_268 === rt.DONE) { + const _286: [rt.usize, string] | rt.Done = iter3.next(); + if (_286 === rt.DONE) { break; } - const _276: string = cow(_268[1], rt.usizeRem(_268[0], 2) === 1 === owned); - const _273: rt.Place = rt.entryOrInsertMut(rt.entry(counts, _276), 0); - _273.v = rt.u8Add(_273.v, 1); + const _294: string = cow(_286[1], rt.usizeRem(_286[0], 2) === 1 === owned); + const _291: rt.Place = rt.entryOrInsertMut(rt.entry(counts, _294), 0); + _291.v = rt.u8Add(_291.v, 1); } const iter4: rt.Iter<[string, rt.u8]> = rt.btreeIter(counts); while (true) { - const _285: [string, rt.u8] | rt.Done = iter4.next(); - if (_285 === rt.DONE) { + const _303: [string, rt.u8] | rt.Done = iter4.next(); + if (_303 === rt.DONE) { break; } - fmt_put(out, _285[0]); - rt.byteVecPush(out, _285[1]); + fmt_put(out, _303[0]); + rt.byteVecPush(out, _303[1]); } rt.byteVecPush(out, option_unwrapOr$0r2bmpr(rt.btreeGet(counts, first), 255)); const names: rt.HashSet = rt.hashNew(rt.hashing(cows_Named.hash$Hash_hash_DefaultHasher as (a0: cows_Named.Named, a1: rt.Hasher) => undefined, cows_Named.eq$cmp_PartialEq)) as rt.HashSet; const iter5: rt.Iter = rt.strSplit(s, "/"); while (true) { - const _305: string | rt.Done = iter5.next(); - if (_305 === rt.DONE) { + const _323: string | rt.Done = iter5.next(); + if (_323 === rt.DONE) { break; } - const _311: cows_Named.Named = cows_Named.new(_305, 0); - rt.hashAdd(names, _311); + const _329: cows_Named.Named = cows_Named.new(_323, 0); + rt.hashAdd(names, _329); } rt.byteVecPush(out, rt.hashLen(names) & 0xff); - const _321: cows_Named.Named = cows_Named.new(first, 0); - rt.byteVecPush(out, rt.hashContains(names, _321) ? 1 : 0); + const _339: cows_Named.Named = cows_Named.new(first, 0); + rt.byteVecPush(out, rt.hashContains(names, _339) ? 1 : 0); } else { const c4: string = cow(s, owned); const args6: rt.Fmt[] = [(f: rt.Formatter) => rt.fmtPad(f, c4), (f: rt.Formatter) => rt.fmtStr(c4, f)]; diff --git a/ts/fixtures/lang/expected/strs.ts b/ts/fixtures/lang/expected/strs.ts index f09ce273b..f76df6da0 100644 --- a/ts/fixtures/lang/expected/strs.ts +++ b/ts/fixtures/lang/expected/strs.ts @@ -553,14 +553,27 @@ export function edit(s: rt.Place, op: rt.u8, a: rt.u8, b: rt.u8, out: rt if (_24 === 0) { s.v = s.v + piece; } else if (_24 === 1) { - s.v = s.v + piece; + const _28: rt.u8 = rt.u8Rem(b, 4); + if (_28 === 0) { + s.v = s.v + piece; + } else if (_28 === 1) { + const taken2 = s.v; + s.v = ""; + s.v = taken2 + piece; + } else if (_28 === 2) { + const taken = s.v; + s.v = ""; + s.v = taken + "" + piece + piece; + } else { + s.v = rt.stringWithCapacity(a as rt.usize) + s.v + ""; + } } else if (_24 === 2) { if (!(rt.u8Rem(a, 4) === 0)) { - const _35: rt.usize = rt.u8Rem(b, 16) as rt.usize; - if (!(_35 < 16)) { - rt.panic("index out of bounds: the len is " + 16 + " but the index is " + _35); + const _59: rt.usize = rt.u8Rem(b, 16) as rt.usize; + if (!(_59 < 16)) { + rt.panic("index out of bounds: the len is " + 16 + " but the index is " + _59); } - s.v = s.v + String.fromCodePoint([47, 92, 97, 48, 57, 127, 128, 233, 2047, 2048, 55295, 57344, 65535, 65536, 128512, 1114111][_35]); + s.v = s.v + String.fromCodePoint([47, 92, 97, 48, 57, 127, 128, 233, 2047, 2048, 55295, 57344, 65535, 65536, 128512, 1114111][_59]); } else { s.v = ""; } @@ -575,11 +588,11 @@ export function edit(s: rt.Place, op: rt.u8, a: rt.u8, b: rt.u8, out: rt s.v = edit2[0]; putChar(out, edit2[1]); } else if (_24 === 6) { - const _44: rt.usize = rt.u8Rem(b, 16) as rt.usize; - if (!(_44 < 16)) { - rt.panic("index out of bounds: the len is " + 16 + " but the index is " + _44); + const _68: rt.usize = rt.u8Rem(b, 16) as rt.usize; + if (!(_68 < 16)) { + rt.panic("index out of bounds: the len is " + 16 + " but the index is " + _68); } - s.v = rt.stringInsertStr(s.v, i, String.fromCodePoint([47, 92, 97, 48, 57, 127, 128, 233, 2047, 2048, 55295, 57344, 65535, 65536, 128512, 1114111][_44])); + s.v = rt.stringInsertStr(s.v, i, String.fromCodePoint([47, 92, 97, 48, 57, 127, 128, 233, 2047, 2048, 55295, 57344, 65535, 65536, 128512, 1114111][_68])); } else if (_24 === 7) { s.v = rt.stringInsertStr(s.v, i, piece); } else if (_24 === 8) { @@ -599,48 +612,48 @@ export function edit(s: rt.Place, op: rt.u8, a: rt.u8, b: rt.u8, out: rt s.v = edit[0]; fmt_put(out, edit[1]); } else if (_24 === 10) { - const _67: rt.u8 = rt.u8Rem(b, 7); - if (_67 === 0) { - const _69: rt.Range = new rt.Range(i, j); - s.v = rt.stringReplaceRange(s.v, _69, piece); - } else if (_67 === 1) { - const _72: rt.RangeFrom = new rt.RangeFrom(i, rt.usizeStep); - s.v = rt.stringReplaceRange(s.v, _72, piece); - } else if (_67 === 2) { - const _75: { end: rt.usize } = { end: j }; - s.v = rt.stringReplaceRange(s.v, _75, piece); - } else if (_67 === 3) { + const _91: rt.u8 = rt.u8Rem(b, 7); + if (_91 === 0) { + const _93: rt.Range = new rt.Range(i, j); + s.v = rt.stringReplaceRange(s.v, _93, piece); + } else if (_91 === 1) { + const _96: rt.RangeFrom = new rt.RangeFrom(i, rt.usizeStep); + s.v = rt.stringReplaceRange(s.v, _96, piece); + } else if (_91 === 2) { + const _99: { end: rt.usize } = { end: j }; + s.v = rt.stringReplaceRange(s.v, _99, piece); + } else if (_91 === 3) { s.v = rt.stringReplaceRange(s.v, undefined, piece); - } else if (_67 === 4) { - const _80: rt.RangeInclusive = new rt.RangeInclusive(i, j, rt.usizeStep); - s.v = rt.stringReplaceRange(s.v, _80, piece); - } else if (_67 === 5) { - const _83: { end: rt.usize; inclusive: true } = { end: j, inclusive: true }; - s.v = rt.stringReplaceRange(s.v, _83, piece); + } else if (_91 === 4) { + const _104: rt.RangeInclusive = new rt.RangeInclusive(i, j, rt.usizeStep); + s.v = rt.stringReplaceRange(s.v, _104, piece); + } else if (_91 === 5) { + const _107: { end: rt.usize; inclusive: true } = { end: j, inclusive: true }; + s.v = rt.stringReplaceRange(s.v, _107, piece); } else { - const _86: [rt.RangeBound, rt.RangeBound] = [{ tag: "Excluded", _0: i } as rt.RangeBound, { tag: "Included", _0: j } as rt.RangeBound]; - s.v = rt.stringReplaceRange(s.v, _86, piece); + const _110: [rt.RangeBound, rt.RangeBound] = [{ tag: "Excluded", _0: i } as rt.RangeBound, { tag: "Included", _0: j } as rt.RangeBound]; + s.v = rt.stringReplaceRange(s.v, _110, piece); } } else if (_24 === 11) { - const _91: rt.u8 = rt.u8Rem(b, 5); - if (_91 === 0) { + const _115: rt.u8 = rt.u8Rem(b, 5); + if (_115 === 0) { s.v = rt.stringExtendFromWithin(s.v, new rt.Range(i, j)); - } else if (_91 === 1) { + } else if (_115 === 1) { s.v = rt.stringExtendFromWithin(s.v, new rt.RangeFrom(i, rt.usizeStep)); - } else if (_91 === 2) { + } else if (_115 === 2) { s.v = rt.stringExtendFromWithin(s.v, { end: j, inclusive: true }); - } else if (_91 === 3) { + } else if (_115 === 3) { s.v = rt.stringExtendFromWithin(s.v, [{ tag: "Included", _0: i } as rt.RangeBound, { tag: "Excluded", _0: j } as rt.RangeBound] as [rt.RangeBound, rt.RangeBound]); } else { s.v = rt.stringExtendFromWithin(s.v, undefined); } } else if (_24 === 12) { - const _99: rt.u8 = rt.u8Rem(a, 4); - if (_99 === 0) { + const _123: rt.u8 = rt.u8Rem(a, 4); + if (_123 === 0) { rt.stringReserve(s.v, rt.usizeSub(4294967295, b as rt.usize)); - } else if (_99 === 1) { - const _104: rt.usize = rt.usizeAdd(rt.usizeAdd(rt.usizeSub(2147483647, rt.stringLen(s.v)), 1), rt.u8Rem(b, 2) as rt.usize); - rt.stringReserve(s.v, _104); + } else if (_123 === 1) { + const _128: rt.usize = rt.usizeAdd(rt.usizeAdd(rt.usizeSub(2147483647, rt.stringLen(s.v)), 1), rt.u8Rem(b, 2) as rt.usize); + rt.stringReserve(s.v, _128); } else { rt.stringReserve(s.v, a as rt.usize); rt.stringReserve(s.v, b as rt.usize); @@ -712,6 +725,7 @@ export function edits(kind: rt.u8, dataData: Uint8Array, dataStart: number, data fmt_put(out, s); fmt_put(out, named.name); } + fmt_put(out, s + "😀" + named.name); } /** diff --git a/ts/fixtures/lang/src/cows.rs b/ts/fixtures/lang/src/cows.rs index d51dc5ad3..04381e173 100644 --- a/ts/fixtures/lang/src/cows.rs +++ b/ts/fixtures/lang/src/cows.rs @@ -109,6 +109,12 @@ pub fn cows(kind: u8, data: &[u8], out: &mut Vec) { put(out, c); } put(out, &c.into_owned()); + // `+` and `+=` of a `&str` and of another `Cow`, which borrow the right side when the + // left is empty. + let mut joined = cow("", owned) + first + cow(&s, !owned); + joined += cow("", !owned); + joined += "+"; + put(out, &joined); let r = match kind & 0x40 { 0 => Ok(cow(first, owned)), _ => Err(kind), diff --git a/ts/fixtures/lang/src/strs.rs b/ts/fixtures/lang/src/strs.rs index 978597a31..c18e33db1 100644 --- a/ts/fixtures/lang/src/strs.rs +++ b/ts/fixtures/lang/src/strs.rs @@ -448,7 +448,14 @@ fn edit(s: &mut String, op: u8, a: u8, b: u8, out: &mut Vec) { let (i, j) = (offset(s, a), offset(s, b)); match op % 14 { 0 => s.push_str(&piece), - 1 => *s += &piece, + // `+=`, and `+`, which moves the `String` it pushes onto: of a `&str`, and through + // deref of a `&String`, a `&&str`, and the `&mut String` itself, chained. + 1 => match b % 4 { + 0 => *s += &piece, + 1 => *s = std::mem::take(s) + &piece, + 2 => *s = std::mem::take(s) + "" + &&*piece + &piece, + _ => *s = String::with_capacity(usize::from(a)) + s + "", + }, 2 if a % 4 == 0 => s.clear(), 2 => s.push(ALPHABET[usize::from(b % 16)]), 3 => s.truncate(i), @@ -533,6 +540,9 @@ pub fn edits(kind: u8, data: &[u8], out: &mut Vec) { put(out, &s); put(out, &named.name); } + // `+` of the local itself, moved. + let s = s + "😀" + &named.name; + put(out, &s); } /// Where `bool`, `char`, and `NonZero` parse or fail: case, whitespace, each UTF-8 width, diff --git a/ts/src/lower/library.rs b/ts/src/lower/library.rs index f81938327..de33b1969 100644 --- a/ts/src/lower/library.rs +++ b/ts/src/lower/library.rs @@ -2309,6 +2309,11 @@ fn mapping<'tcx>( // A `Box` is its text, as a `String` is. ("String", "as_str" | "into_boxed_str") | ("str", "into_string") => args.pop()?, ("String", "new") => Expr::Str(String::new()), + // `String + &str` and `Cow`'s, which push onto the moved text as `+=` does. + ("Add", "add") if string(tcx, targ(gargs, 0)) => { + let text = args.pop()?; + args.pop()?.binary("+", text) + } ("String", "with_capacity") => Expr::rt("stringWithCapacity", args), // Of the place the `&mut` is, which it writes as it drops, and of a range, as `Vec::drain`. ("String", "drain") => { diff --git a/ts/src/lower/mir/call.rs b/ts/src/lower/mir/call.rs index c20754836..0c68e1c37 100644 --- a/ts/src/lower/mir/call.rs +++ b/ts/src/lower/mir/call.rs @@ -613,9 +613,11 @@ impl<'m, 'b, 'a, 'tcx> Fx<'m, 'b, 'a, 'tcx> { ); done(next, true) } - // A JS string can't change in place, so what the `&mut` borrows gets a new one. + // A JS string can't change in place, so what the `&mut` borrows gets a new one. A + // `Cow` is its text, as a `String` is. ("String", "push" | "push_str") | ("AddAssign", "add_assign") - if owner == "String" || targ(0).is_some_and(|t| self.b.t.std_of(t) == Some(Std::String)) => + if owner == "String" + || (targ(0).is_some_and(|t| library::string(tcx, t)) && !self.b.t.translated(def_id, gargs)) => { let place = self.borrowed_place(&args[0].node, span, out)?; let text = self.operand(&args[1].node, span, out);