Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 47 additions & 18 deletions builtin/stringbuilder_buffer.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@

///|
struct StringBuilder {
mut data : FixedArray[UInt16]
mut data : UninitializedArray[UInt16]
mut len : Int
mut buffer_is_shared : Bool
}

///|
Expand All @@ -32,8 +33,8 @@ struct StringBuilder {
#alias(new, deprecated="Use `StringBuilder()` instead")
pub fn StringBuilder::StringBuilder(size_hint? : Int = 0) -> StringBuilder {
let initial = if size_hint < 1 { 1 } else { (size_hint + 1) / 2 }
let data : FixedArray[UInt16] = FixedArray::make(initial, 0)
{ data, len: 0 }
let data : UninitializedArray[UInt16] = UninitializedArray::make(initial)
{ data, len: 0, buffer_is_shared: false }
}

///|
Expand Down Expand Up @@ -77,13 +78,26 @@ fn StringBuilder::grow(self : StringBuilder, required : Int) -> Unit {
self.len,
required,
)
let new_data = FixedArray::make_and_blit(
let new_data = UninitializedArray::make_and_blit(
self.data,
allocate_len=new_capacity,
init=(Default::default() : UInt16),
len=self.len,
)
self.data = new_data
self.buffer_is_shared = false
}

///|
fn UninitializedArray::unsafe_blit_from_string_uninit(
self : UninitializedArray[UInt16],
dst_offset : Int,
str : String,
str_offset : Int,
len : Int,
) -> Unit {
unsafe_uint16_buffer_as_fixedarray(self).unsafe_blit_from_string(
dst_offset, str, str_offset, len,
)
}

///|
Expand All @@ -100,6 +114,12 @@ fn FixedArray::unsafe_blit_from_string(
}
}

///|
/// Reinterpret the backing storage. Callers must only read initialized slots.
fn unsafe_uint16_buffer_as_fixedarray(
buffer : UninitializedArray[UInt16],
) -> FixedArray[UInt16] = "%identity"

///|
/// Writes a string to the StringBuilder.
pub impl Logger for StringBuilder with fn write_string(self, str) {
Expand All @@ -111,7 +131,7 @@ pub impl Logger for StringBuilder with fn write_string(self, str) {
if required > self.data.length() || required < self.len {
self.grow(required)
}
self.data.unsafe_blit_from_string(self.len, str, 0, str_len)
self.data.unsafe_blit_from_string_uninit(self.len, str, 0, str_len)
self.len += str_len
}

Expand All @@ -123,15 +143,18 @@ pub impl Logger for StringBuilder with fn write_char(self, ch) {
if self.len >= self.data.length() {
self.grow(self.len + 1)
}
self.data[self.len] = code.to_uint16()
self.data.unsafe_set(self.len, code.to_uint16())
self.len += 1
} else if code <= 0x10FFFFU {
if self.data.length() - self.len < 2 {
self.grow(self.len + 2)
}
let code = code - 0x10000U
self.data[self.len] = (0xD800U + (code >> 10)).to_uint16()
self.data[self.len + 1] = (0xDC00U + code.land(0x3FFU)).to_uint16()
self.data.unsafe_set(self.len, (0xD800U + (code >> 10)).to_uint16())
self.data.unsafe_set(
self.len + 1,
(0xDC00U + code.land(0x3FFU)).to_uint16(),
)
self.len += 2
} else {
abort("invalid code point")
Expand Down Expand Up @@ -169,7 +192,7 @@ pub impl Logger for StringBuilder with fn write_view(
if required > self.data.length() || required < self.len {
self.grow(required)
}
self.data.unsafe_blit_from_string(
self.data.unsafe_blit_from_string_uninit(
self.len,
str.data(),
str.start_offset(),
Expand All @@ -184,15 +207,19 @@ pub fn StringBuilder::to_string(self : StringBuilder) -> String {
if self.len == 0 {
""
} else if self.len == self.data.length() {
unsafe_fixedarray_uint16_to_string(self.data)
// This conversion can reuse `data` without copying, so future resets must
// detach before the buffer is written again.
self.buffer_is_shared = true
unsafe_fixedarray_uint16_to_string(
unsafe_uint16_buffer_as_fixedarray(self.data),
)
} else {
let data = FixedArray::make_and_blit(
let data = UninitializedArray::make_and_blit(
self.data,
allocate_len=self.len,
init=(Default::default() : UInt16),
len=self.len,
)
unsafe_fixedarray_uint16_to_string(data)
unsafe_fixedarray_uint16_to_string(unsafe_uint16_buffer_as_fixedarray(data))
}
}

Expand All @@ -210,9 +237,11 @@ pub impl Show for StringBuilder with fn to_string(self) {
///|
/// Resets the string builder to an empty state.
pub fn StringBuilder::reset(self : StringBuilder) -> Unit {
self.data = FixedArray::make(
self.data.length(),
(Default::default() : UInt16),
)
// A full buffer may have been returned directly from `to_string`. Retain the
// backing storage in every other case, since it only contains value types.
if self.buffer_is_shared {
self.data = UninitializedArray::make(self.data.length())
self.buffer_is_shared = false
}
self.len = 0
}
71 changes: 71 additions & 0 deletions builtin/stringbuilder_large_bench_test.mbt
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Copyright 2026 International Digital Economy Academy
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

///|
let stringbuilder_large_sizes : Array[(Int, Int)] = [
(1 * 1024 * 1024, 512 * 1024),
(8 * 1024 * 1024, 4 * 1024 * 1024),
(32 * 1024 * 1024, 16 * 1024 * 1024),
]

///|
test "bench StringBuilder reserve 1 MiB" (it : @bench.T) {
let (bytes, _) = stringbuilder_large_sizes[0]
it.bench(fn() { it.keep(StringBuilder(size_hint=bytes)) })
}

///|
test "bench StringBuilder reserve 8 MiB" (it : @bench.T) {
let (bytes, _) = stringbuilder_large_sizes[1]
it.bench(fn() { it.keep(StringBuilder(size_hint=bytes)) })
}

///|
test "bench StringBuilder reserve 32 MiB" (it : @bench.T) {
let (bytes, _) = stringbuilder_large_sizes[2]
it.bench(fn() { it.keep(StringBuilder(size_hint=bytes)) })
}

///|
test "bench StringBuilder build 1 MiB" (it : @bench.T) {
let (bytes, units) = stringbuilder_large_sizes[0]
let source = String::make(units, 'x')
it.bench(fn() {
let builder = StringBuilder(size_hint=bytes)
builder.write_string(source)
it.keep(builder.to_string())
})
}

///|
test "bench StringBuilder build 8 MiB" (it : @bench.T) {
let (bytes, units) = stringbuilder_large_sizes[1]
let source = String::make(units, 'x')
it.bench(fn() {
let builder = StringBuilder(size_hint=bytes)
builder.write_string(source)
it.keep(builder.to_string())
})
}

///|
test "bench StringBuilder build 32 MiB" (it : @bench.T) {
let (bytes, units) = stringbuilder_large_sizes[2]
let source = String::make(units, 'x')
it.bench(fn() {
let builder = StringBuilder(size_hint=bytes)
builder.write_string(source)
it.keep(builder.to_string())
})
}
47 changes: 47 additions & 0 deletions builtin/stringbuilder_wbtest.mbt
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright 2026 International Digital Economy Academy
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

///|
#cfg(not(target="js"))
test "StringBuilder::reset reuses an unshared buffer" {
let builder = StringBuilder(size_hint=64)
let backing = builder.data
builder.write_string("hello")
let first = builder.to_string()
builder.reset()
builder.write_string("bye")
assert_true(first == "hello")
assert_true(backing[0] == ('b' : UInt16))
assert_true(backing[1] == ('y' : UInt16))
assert_true(backing[2] == ('e' : UInt16))
}

///|
#cfg(not(target="js"))
test "StringBuilder keeps initialized prefix valid across grow and reset" {
let builder = StringBuilder(size_hint=2)
builder.write_string("ab")
builder.write_string("cd")
inspect(builder.to_string(), content="abcd")

// The full-capacity conversion may share the backing buffer. Reset must
// detach before the next write, while a partial conversion must only expose
// the initialized prefix.
builder.reset()
builder.write_char('x')
inspect(builder.to_string(), content="x")
builder.reset()
builder.write_char('🤣')
inspect(builder.to_string(), content="🤣")
}
Loading