Skip to content

std.strings

str is the built-in counted UTF-8 shared view; &str is mutable and ^str owns its buffer. U+0000 is ordinary text and no role promises a terminator. Shared-view operations (iter, slice_bytes, split, lines, repeat, and clone) and the size field are available directly from text. The owner additionally exposes readonly capacity and consuming append, reserve, and clear. std.strings supplies opaque RuneCursor, SplitCursor, and LinesCursor types plus free functions without a single receiver; today the sole free function is join.

Contract

  • A hole-free literal such as "text" is a static shared str; expected type never turns it into an owner. Any hole-free owning literal whose decoded content is statically empty is allocation-free and infallible. Every statically nonempty owning literal is written explicitly as try ^"text", and interpolation is accepted only in the owning form, for example try ^"name={name}". Nonempty construction and interpolation report AllocError; there is no hidden stack or contextual allocation.
  • ^str is a move value. clone, repeat, and join build exact-capacity owners; a nonempty result performs one allocation and reports AllocError. Every empty result is the canonical allocation-free owner, although the general function signatures remain checked and are still called with try.
  • append and reserve consume an owning receiver and return its successor. Taking a named receiver is always explicit: (<-s).append(rest) or (<-s).reserve(total). They reuse the allocation when its logical capacity is sufficient and otherwise perform at most one growth call. clear is the corresponding infallible consuming transform and retains the allocation.
  • iter creates an opaque affine RuneCursor without allocating or scanning. It yields rune values and advances by decoded UTF-8 byte width until the stored byte end; it never treats U+0000 as exhaustion. This is the sole direct scalar traversal and the path used by for scalar in text.
  • slice_bytes(start, size) returns a zero-copy source-region str?. It returns null when the range is out of bounds or either endpoint splits a UTF-8 scalar. There is no integer subscript, ordinal-scalar index, or grapheme index.
  • split breaks the receiver at each non-overlapping occurrence of the separator. It returns an opaque affine SplitCursor without allocating or throwing; each next lends a str view into the original source. An empty or too-long separator yields the complete source exactly once. Otherwise n non-overlapping left-to-right matches yield n + 1 pieces, so a leading, trailing, or doubled separator produces an empty view. Splitting an empty source also yields one empty view.
  • lines splits on \n, treating an immediately preceding \r as part of the terminator. It likewise returns an allocation-free, infallible opaque affine LinesCursor that lends source views. Exactly one immediate preceding CR is absorbed; a lone CR remains content. A trailing terminator adds no final view, an empty receiver yields none, and interior blank lines are preserved. Thus "\n" yields one empty view, "\n\n" yields two, and "a\r\r\n" yields "a\r".
  • join(parts, separator) concatenates the pieces with the separator between consecutive elements. parts is an explicit borrowed [^str] slice and is left intact. A cursor is not a slice, so join(s.split(sep), sep) is rejected; materialize each lent piece explicitly when an owning collection is wanted.
  • str has no raw-byte, scalar-position, or grapheme-position push/pop/insert/remove/replace family. Build a distinct owner with reserve and append(str | rune) so source loans and byte offsets cannot be invalidated by hidden in-place editing. Convert explicitly to an owning byte container only when the result is binary data.
  • RFC 0064 reserves public text append for concatenating one complete str or rune suffix. Text does not gain sequence push, collection extend, or mutation aliases such as delete, erase, or take.
  • size is the byte-extent field on every text role. capacity is a readonly owner-only field. Empty testing has one spelling, text.size == 0; there is no is_empty method or compatibility alias.

Quick Example

split lends source views one at a time. Cursor construction and traversal are infallible and allocation-free:

Zynx
// RFC 0050 target; cursor implementation pending.
import std.strings.{ SplitCursor };

fn main() {
	let parts: ^SplitCursor in _ = ("a,b,c").split(",");

	assert parts.next() == "a";
	assert parts.next() == "b";
	assert parts.next() == "c";
	assert parts.next() == null;
	assert parts.next() == null; // sticky exhaustion
}

Materialization names every allocation. Clone each lent piece, grow the owning slice explicitly, then call join:

Zynx
// RFC 0050 target; cursor implementation pending.
import std.strings.{ join };
import std.mem.{ AllocError };

fn round_trip(text: str, separator: str) throws(AllocError) -> ^str {
	var parts: ^[^str] = ^[];
	for part: str in _ in text.split(separator) {
		let copy: ^str = try part.clone();
		parts = try (<-parts).push(<-copy);
	}
	return try join(parts, separator);
}

Lines And Repeat

lines handles LF and CRLF terminators uniformly, dropping the terminator bytes from each line. It produces a cursor rather than an owning collection. repeat builds an exact-capacity owner of count back-to-back copies; a zero-size result normalizes to the canonical empty owner without allocating.

Zynx
// RFC 0050 target; cursor implementation pending.
import std.mem.{ AllocError };
import std.strings.{ LinesCursor };

fn main() throws(AllocError) {
	let text = "first\r\nsecond\nthird";
	let rows: ^LinesCursor in _ = text.lines();

	assert rows.next() == "first";
	assert rows.next() == "second";
	assert rows.next() == "third";
	assert rows.next() == null;

	let bar = try ("ab").repeat(3);
	assert bar == "ababab";
	assert bar.size == 6 as usize;
}

Building And Growing

clone makes an independent exact-capacity copy of borrowed text. Owner growth is a consuming transformation because borrowing a ^str as &str exposes only the counted text range, not the owner's {ptr, size, capacity} descriptor. reserve(total) requests a total logical capacity, never an additional amount; append reuses that capacity; and clear sets size to zero while retaining the allocation.

Zynx
// RFC 0051 target; consuming owner-growth implementation pending.
import std.mem.{ AllocError };

fn main() throws(AllocError) {
	var greeting = try ("Hello").clone();
	greeting = try (<-greeting).reserve(32);
	greeting = try (<-greeting).append(", world");

	assert greeting == "Hello, world";
	assert greeting.size == 12 as usize;
	assert greeting.capacity >= 32 as usize;

	let blank = try ("").clone();
	assert blank.size == 0 as usize;
	assert blank.capacity == 0 as usize;

	greeting = (<-greeting).clear();
	assert greeting.size == 0 as usize;
	assert greeting.capacity >= 32 as usize;
}

Call a method on a bare string literal through parentheses — ("Hello").clone(), not "Hello".clone() — because the unparenthesized form does not parse.

Ownership, Allocation, And Errors

An owning nonempty ^str owns its heap buffer and drops it deterministically; the canonical empty owner allocates nothing. Its logical capacity counts UTF-8 bytes. For example, try ^"text" has size == 4 and capacity == 4. Every role guarantees exactly its size readable bytes and makes no claim about ptr[size]; only the owner exposes capacity. The logical capacity range is not reduced to reserve a physical terminator slot.

reserve(total) is an exact no-op when total <= capacity, including zero. After success, capacity >= total; the rounding strategy and resulting spare capacity are deliberately unspecified. It never shrinks. append first checks size + suffix.size without wrapping. An empty suffix returns the exact input owner. Sufficient spare capacity preserves the allocation and capacity; otherwise the operation makes at most one allocator growth call. Both operations publish only the exact counted range. clear performs no allocation or deallocation, preserves pointer and capacity, and sets size to zero. It does not securely erase bytes outside the new logical range.

For one successful owner lineage, a sequence of appends has amortized-linear total growth work: writing appended bytes, bytes copied because storage grows, and the aggregate storage requested by growth calls are O(initial_size + total_appended_size). Producing the suffix values and any work the caller performs to read them are outside this bound. The implementation must satisfy the bound but exposes no fixed growth factor, minimum capacity, capacity sequence, or allocation addresses.

The consuming receiver is evaluated before explicit arguments. Taking it is incompatible with every outstanding view derived from the same owner, and the taken binding is already moved before the suffix is evaluated. Consequently safe code cannot use the receiver or one of its subviews as its own suffix; no runtime self-alias exception exists.

If evaluating the suffix itself fails, the receiver has already become an owned argument temporary. Cleanup drops that temporary exactly once and the source binding remains moved. Bind a fallible suffix first when it must succeed before the receiver is taken.

If append or reserve reports AllocError, its already-taken owner is dropped exactly once and the caller's binding remains moved. This is intentionally different from ordinary non-consuming replacement, whose failed right-hand side leaves the old value installed. There is no recovery payload, rollback, hidden clone, or owner-preserving overload. A caller that needs an independently recoverable value must create that owner explicitly before the consuming call.

SplitCursor<region Life> and LinesCursor<region Source> are package-created, opaque, affine, non-Copy, non-Clone, and next-only. They own their cursor state but allocate no heap storage and do not own the viewed text. Split uses one common Life region for both source and separator:

Zynx
split<region Life>(self in Life, separator: str in Life) -> ^SplitCursor in Life;
SplitCursor.next<region Step>(&self in Step) -> str? in Life;

lines<region Source>(self in Source) -> ^LinesCursor in Source;
LinesCursor.next<region Step>(&self in Step) -> str? in Source;

The source and, for split, separator must therefore outlive the cursor and all yielded views. Constructing a split cursor evaluates the receiver once and then the separator once, left to right; constructing either cursor is O(1), performs no scan, allocation, copy, or user call, and cannot throw. Each successful next scans only as far as its next result, commits the new cursor position before publishing that result, and returns a view into the source. Normal exhaustion is sticky and every later next is effect-free. There is no cursor clone, reset, size hint, indexing, collection terminal, or implicit materialization. Dropping a cursor does not scan or drain the remainder; it only discards cursor state. Already yielded views remain valid after cursor drop for their declared Life or Source region. A named cursor can resume after break from a loop. join returns an exact-capacity owner for nonempty output and the canonical empty owner otherwise. The borrowing operations — the size field and reading a [str] element through equality — do not transfer ownership. Interpolation is not a borrowing operation: its result is a fresh owning ^str.

Send and Sync derive only from the cursor's stored loans and scalar state; neither cursor receives a blanket capability. Moving a borrowed cursor to a child requires a scope whose mandatory join ends before its source loans, and next(&self) still requires exclusive access to the single cursor position, so Sync never permits concurrent advancement.

Every potentially allocating operation reports overflow or out-of-memory as AllocError.OutOfMemory and is called with try. clone, repeat, and join size once up front; a failed pure construction leaves every borrowed input unchanged and publishes no partial owner. Their empty output performs no allocation but retains the checked effect of the general signature. split, lines, their next methods, clear, size, and reading owner capacity never allocate or throw.

Nonempty literals, interpolation, clone, repeat, and join produce exact capacity. Spare capacity arises only through explicit reserve or growth by append. There is no reserve_exact, shrink operation, builder, concatenation operator, byte/rune/grapheme-position mutator, or formatting writer in this surface. The two append overloads accept borrowed str and scalar rune.

Use std.slice when explicitly materializing the owning ^[^str] that join consumes. Bind an explicit comparator that delegates to ordering.compare_str, then pass it by & to the sole slice.sort or slice.binary_search operation. Use std.ordering for that lexicographic comparator and std.mem for the AllocError thrown by allocating string operations.

API Reference

str Methods

Shared methods operate on the built-in text view. Owner methods select only a consuming ^self receiver. A named receiver must therefore be visibly taken, for example s = try (<-s).append(rest).

MethodSignatureParametersReturnsDescription
iteriter<region Source>(self in Source) -> ^RuneCursor in SourceNoneallocation-free cursorTraverses the counted source as Unicode scalar rune values; this is the direct for path.
slice_bytesslice_bytes<region Source>(self in Source, start: usize, size: usize) -> str? in Sourcestart, size: explicit byte rangeregion-tied nullable viewLends a zero-copy text range, or null for bounds or UTF-8-boundary failure.
splitsplit<region Life>(self in Life, separator: str in Life) -> ^SplitCursor in Lifeseparator: delimiter text in the common source lifetimeallocation-free cursorBreaks the receiver at non-overlapping left-to-right separators and lends each source view. Empty/too-long separator yields the whole source once.
lineslines<region Source>(self in Source) -> ^LinesCursor in SourceNoneallocation-free cursorLends LF-delimited source views, absorbs one immediate CR, preserves interior blank lines, and adds no view after a trailing terminator.
repeatrepeat(count: usize) throws(AllocError) -> ^strcount: number of copies^str throws(AllocError)Builds a fresh string of count back-to-back copies; count == 0 yields an empty string.
cloneclone() throws(AllocError) -> ^strNone^str throws(AllocError)Copies borrowed text into a new owning string.
appendappend(^self, suffix: str) throws(AllocError) -> ^strsuffix: distinct borrowed text to append^str throws(AllocError)Consumes the owner, reuses sufficient capacity or grows at most once, and returns its successor. Empty suffix is an exact no-op.
appendappend(^self, suffix: rune) throws(AllocError) -> ^strsuffix: one Unicode scalar, including U+0000^str throws(AllocError)Encodes and appends one proven scalar while preserving counted UTF-8.
reservereserve(^self, total: usize) throws(AllocError) -> ^strtotal: requested total logical content capacity^str throws(AllocError)Consumes the owner and returns one with capacity >= total; an already-satisfied request is an exact no-op.
clearclear(^self) -> ^strNone^strConsumes the owner, retains its allocation and capacity, and returns it with size == 0.

size is a field, not a method: s.size reads the byte extent as a usize without consuming or allocating. capacity is likewise a readonly field, but only on the owning ^str role. Projecting an owner to str or &str forgets that field. Test emptiness with s.size == 0.

Functions

FunctionSignatureReturnsDescription
joinjoin(parts: [^str], separator: str) throws(AllocError) -> ^str^str throws(AllocError)Concatenates an explicitly materialized owner slice with separator; cursors are not accepted.

Cursor Types

TypeContract
RuneCursor<region Source>Opaque affine cursor borrowing its source and byte end; sole operation next<region Step>(&self in Step) -> rune?, with sticky exhaustion.
SplitCursor<region Life>Opaque affine cursor borrowing source and separator in one Life; sole operation next<region Step>(&self in Step) -> str? in Life.
LinesCursor<region Source>Opaque affine cursor borrowing its source; sole operation next<region Step>(&self in Step) -> str? in Source.

Status

^str is the owning-string type, with invariant-preserving per-receiver operations exposed as methods; std.strings holds the free functions that lack a single receiver. RFC 0049 makes ownership explicit at literal sites and removes contextual/interpolated hidden allocation. RFC 0050 makes bare text a strictly counted, not necessarily terminated view and replaces allocating split/lines collections with opaque borrowed cursors. RFC 0051 selects one consuming capacity-growth path, canonicalizes pure empty results, and removes the redundant is_empty. RFC 0046 removes the checked-in raw-byte mutators and TakenByte from the accepted target. RFC 0061 removes every text-level terminator and no-NUL promise, makes ordinary iteration yield rune, adds checked byte-bound views and scalar append, and keeps grapheme segmentation explicit. RFC 0064 gives the removed byte push, pop, insert, and remove spellings no aliases and fixes append as the text-suffix verb. For the full surface inventory, see Standard Library Inventory.

Released under the MIT License.