Skip to content

Standard Library

String functions ​

string itself owns almost all of the surface. Strings is that chapter. str:: is the plumbing under it, plus the two functions that cross the C boundary. Nothing here is a string library.

echo
echo str::from(42);         // 42
echo str::from(true);       // true
echo str::from(1.5);        // 1.5

str::buf ​

A string is a window onto a str::buf, which owns the actual bytes. The buffer is shared and reference counted, so two strings holding the same text usually hold one buffer between them, and the bytes are freed once when the last of them goes.

That's why copying a string is cheap, and it's the only thing about str::buf you need. It is not a type to build yourself. It has to be public, because string holds one as a property and anyone holding a string can already name the type, so hiding it would be incoherent. What is actually enforced is the shape: every mutator is private on string's side, so no caller reaches one through the owner it holds. The type is public. The mutators are private. Don't construct one. See Visibility.

You'll meet the name in two places: in a debugger, and in mem::refs<str::buf> if you want to see the sharing for yourself.

str::text ​

string is the value you pass around. Every append on it first asks whether this value uniquely owns the buffer. That is the right question for a shared window, and the wrong one for a loop that only ever writes.

str::text skips the question: copying is refused, growth doubles, take() plants a string once. Building a string is the choice.

Literals ​

echo
string $literal = "0123456789abcdef";
echo mem::refs($literal->owner);     // 0
echo mem::live_allocations();        // 0

string $built = "";
$built->append("0123456789abcdef");
echo mem::refs($built->owner);       // 1
echo mem::live_allocations();        // 2

string $shared = $built;
echo mem::refs($built->owner);       // 2
echo mem::live_allocations();        // 2

A literal is a window over bytes baked into your binary, with no buffer behind it at all, so its count reads 0 and it costs you no memory at all. A string you actually build costs two allocations. Copying either of them costs nothing, which is the number to remember: passing strings around is free, building them is not.

C strings: copy or borrow ​

An extern function that hands you a ptr<const uint8> has given you a NUL-terminated C string and no claim on it. Two functions turn that into something Echo can use, and the choice between them is one question: will the source outlive the result?

echo
string $gate = "Chulak";
ptr<const uint8> $raw = $gate->cstr();

// borrow: no allocation, valid only while the owner's bytes are
string::view $window = str::cview($raw);
echo $window->size;         // 6

// copy: owning, safe to keep
string $owned = str::from($raw);
echo $owned;                // Chulak

cview walks to the NUL to find the length and points at the same bytes. Use it when the source lives at least as long as you need the window, which is exactly the case for argv entries and environment variables. That's why Environment hands back views rather than strings.

from allocates and copies. Use it when the source is about to be freed, or is behind something like setenv that may replace it out from under you.

Note: string::view has a $size property rather than a size() method, so it is $v->size with no parentheses. string has the method. That inconsistency is real and will catch you once.

str::from ​

echo
echo str::from(42);         // 42
echo str::from(-42);        // -42
echo str::from(true);       // true
echo str::from(1.5);        // 1.5

It's an overload set, not an interface, and I didn't get to choose that: a primitive cannot declare a conformance, so no interface could ever cover int32. It's the same reason hash::of is one.

Which means your own type joins it by declaring one function, in namespace str, anywhere in your own module:

echo
struct Chevron
{
    int32 $number;
    bool $locked;
}

namespace str;

public function from(const Chevron& $c) : string
{
    if ($c->locked) {
        return "chevron {$c->number} locked";
    }

    return "chevron {$c->number} open";
}

// and from there, this works
$c = Chevron(7, true);
echo "status: {$c}";        // status: chevron 7 locked

That's all interpolation is: "{$x}" becomes str::from($x), and "{$x:spec}" becomes str::from($x, 'spec'). A type that renders but does not want to honour format specs simply has no two-argument overload, and "{$c:>8}" says so by name rather than silently ignoring it.

str::from(1.5) is 1.5 where echo 1.5 is 1.500000. echo reaches printf's %f and always has, while str::from uses the shortest form that reads well in a sentence: six significant digits for a float, fifteen for a float64. Ask for {$x:.17g} when you need a value to round-trip.

split / join / trim ​

echo
str::parts $parts = str::split('SG-1,SG-9,SG-11', ',');
echo $parts->count();               // 3

echo str::join($parts, ' / ');      // SG-1 / SG-9 / SG-11
echo str::join(['SG-1', 'SG-9'], ' / ');  // SG-1 / SG-9

string $padded = '  Chulak  ';
string $trimmed = str::trim($padded);

echo "|{$trimmed}|";                // |Chulak|

n separators give n + 1 parts, always. Remember that one, because it's the rule that makes str::join(str::split($t, $s), $s) give $t back. An empty text splits to one empty part, and ',a,' on ',' gives three.

The parts are offsets into the source, not a new string each. That is why a 400 000-field split is one retain of the text plus an array of {off, len}, and why a short source can stay inline: at() rebuilds the window from the live source. $p->at($i) is a string::view. $p->owned() is the list of owning strings, and you reach for it when a part has to outlive $p. A part of at most string::INLINE bytes becomes its own inline value; a longer one retains the parent buffer.

Note the extra variable on that last line. A hole opens on {$ and nothing else, so "|{str::trim($p)}|" is refused as a lookalike. Bind the result, then interpolate that.

ltrim and rtrim do one side each. All three answer a window rather than a copy, so trimming is free. Call ->clone() on the result if you want the parent's buffer released.

lines ​

foreach over a file yields one string per line, newline gone. str::lines is that cut over a string you already have:

echo
str::parts $lines = str::lines("Abydos\r\nChulak\nDakara\n");
echo $lines->count();                       // 3
echo str::join($lines, ', ');               // Abydos, Chulak, Dakara

A \r in front of the \n is taken along. A newline ends the line before it rather than starting an empty one after, so "a\nb\n" is two lines and an empty text is none. split($t, "\n") answers three and one. That is the whole difference.

A third argument to split is a limit: at most that many parts, and the last one is the rest of the text, separators and all:

echo
str::parts $kv = str::split('gate=address=chevron', '=', 2);
echo $kv->at(0);                            // gate
echo $kv->at(1);                            // address=chevron

0 reads as 1. A split always answers at least one part. Both are windows, same str::parts as a plain split, one retain.

replace ​

echo
echo str::replace('SG-1, SG-9, SG-11', 'SG-', 'Team ');     // Team 1, Team 9, Team 11
echo str::replace_first('one, two, one', 'one', '1');      // 1, two, one
echo str::replace('aaa', 'aa', 'b');                        // ba

Left to right, and a match never overlaps the one before it, which is why 'aaa' is one replacement and an a. The replacement is not searched again.

An empty search string answers the text unchanged. Text with nothing to replace is the same string, sharing its buffer. When something does change, the result is sized before a byte is written: one allocation, however many matches.

int, uint, and float ​

echo
int64 $n = guard str::int('42') else {
    die('not a number');
}

echo $n;        // 42

The parsers answer a nullable, so failure is guard ... else rather than a sentinel you have to look up. Nothing is forgiven: no leading whitespace, no separators, no trailing text. str::trim first if the text came from a line of input.

echo
echo str::int('4kg') ?? -1;       // -1
echo str::int(' 4') ?? -1;        // -1
echo str::int('9223372036854775808') ?? -1;    // -1, overflow rather than a wrapped value

str::int is base ten, optional sign, int64?. str::uint is the unsigned twin: base ten, or hex with a leading 0x / 0X, accumulating uint64 so 0xFFFFFFFFFFFFFFFF parses. No sign. Overflow is still null, never a wrapped value.

echo
uint64 $hex = guard str::uint('0x10') else {
    die('not a number');
}

echo $hex;                              // 16
echo str::uint('0xFFFFFFFFFFFFFFFF') == null;   // 0
echo str::uint('0x10000000000000000') == null;  // 1
echo str::uint('-1') == null;            // 1

str::float goes through C's strtod and requires the whole text to be consumed, which is the difference between it and calling strtod yourself.

Case ​

echo
echo str::upper('straße');      // STRASSE
echo str::lower('CAFÉ');        // café
echo str::ucfirst('écho');      // Écho
echo str::lcfirst('ÉCHO');      // éCHO

Unicode Default Case Conversion, locale-independent. ß becomes SS, fi becomes FI, and Turkish I is not a different letter, because there is no locale. Greek capital sigma at the end of a word becomes ς rather than σ. That's the only contextual mapping, and it's hard-coded rather than a condition engine.

ucfirst titles the first codepoint and leaves the rest. Titlecase, not uppercase: they agree for almost every letter and disagree on a handful of digraphs. straße starts with s, so the answer is Straße. ß on its own titles to Ss.

Already-cased text is the same string, sharing its buffer. str::upper('HELLO') allocates nothing. Changing case always allocates; leaving it alone never does. That's trim's bargain, and the reason the parameter is a string rather than a view.

Invalid UTF-8 is copied, not rejected. $s->count() already counts it rather than validating, and these four do not invent a stricter contract.

Chars ​

$s->count() is how many characters. $s->size() is bytes. str::chars($s) (and $s->chars()) is the walk: each well-formed character as a window into $s, each invalid byte as a window of one. Same parts type split and lines hand you, so foreach works and $p->owned() is the list of owning strings.

echo
echo str::join(str::chars('ä€😀'), '|');    // ä|€|😀
echo str::chars('ä€😀')->count();           // 3

A byte index is still a byte index. Cutting a log line at 120 bytes can land on the second byte of é. str::boundary($text, $at) walks back to the start of that character, at most three bytes, so the cut stays a character boundary. Past the end is the end.

echo
string $t = "naïve";
echo str::boundary($t, 3);      // 2, the start of ï

$s->count() still counts junk. str::is_utf8($s) is the validator: true when every byte is part of a well-formed scalar. Empty is well-formed. A truncated sequence, an overlong encoding, a surrogate half, or a stray continuation is not.

echo
echo str::is_utf8("naïve");     // 1
string $bad = '';
$bad->push(255);
echo str::is_utf8($bad);        // 0

Hex ​

str::hex($n) is sixteen lowercase digits, zero-padded. "{$n:x}" drops the leading zeros; a stored fingerprint cannot. str::hex($bytes) is two digits per byte.

echo
echo str::hex(hash::fnv1a('hello'));    // a430d84680aabd0b
echo str::hex('hello');                 // 68656c6c6f

iequals ​

str::lower($a) == str::lower($b) is the Unicode answer, and it's two buffers. A header name, an HTTP token, a hex digit is none of that: the protocol said "case-insensitive" and meant A-Z.

echo
echo str::iequals('Content-Type', 'content-type');      // 1
echo str::iequals('Content-Type', 'content-length');    // 0
echo str::iequals('straße', 'STRASSE');                 // 0, ß is not SS here

A string reaches it without a cast. The parameters are views, so a comparison costs no allocation and no reference count, which is what a scan over thirty headers needs.

Surface ​

SignatureWhat it does
str::from(T $v) : stringthe value as text. one overload per primitive, plus string and string::view
str::from(T $v, const string& $spec) : stringthe same, honouring a format spec
str::spec_of(const string& $text) : specthe spec a {$x:...} hole spells, parsed
str::from(ptr<const uint8> $s) : stringan owning copy of a NUL-terminated C string
str::from(ptr<const uint8> $b, usize $n) : stringan owning copy of $n bytes, no terminator needed
str::cview(ptr<const uint8> $s) : string::viewa borrowing window over one. allocates nothing
string::data() / string::view::data()the bytes, no terminator required
string::spare() / room() / commit($n)let C write into the buffer
string::append(ptr<const uint8>, usize)append for a pointer and a length
string::capacity()text bytes the buffer can hold without growing
str::concat(const string& $a, const string& $b) : stringthe two joined. interpolation appends into one buffer instead
str::split(const string& $t, const string& $sep) : str::partsn separators give n + 1 windows into $t
str::split(const string& $t, const string& $sep, usize $limit) : str::partsat most $limit parts, the last is the rest
str::lines(const string& $t) : str::partscut at \n, a \r before it dropped, no empty last line
str::replace(const string& $t, const string& $from, const string& $to) : stringevery $from, left to right, non-overlapping
str::replace_first(const string& $t, const string& $from, const string& $to) : stringthe first $from only
str::parts::owned() : array<string>the same parts as owning strings. short ones stay inline; longer ones retain the parent
str::join(const str::parts& $p, const string& $sep) : stringthe inverse of split, in one allocation
str::join(const array<string>& $parts, const string& $sep) : stringthe same, over a list you built
str::trim / ltrim / rtrim (const string&) : stringASCII whitespace off both ends, or one
str::trim / ltrim / rtrim (string::view) : string::viewthe same, over a window
str::lpad / rpad (const string&, usize $width, uint8 $fill) : stringwiden to $width bytes
str::repeat(const string& $t, usize $times) : string$t written over
str::int(const string& $t) : int64?base ten, optional sign, nothing else
str::uint(const string& $t) : uint64?base ten, or hex with 0x / 0X. no sign. overflow is null
str::float(const string& $t) : float64?through strtod, whole text consumed
str::upper / lower (const string&) : stringUnicode uppercase / lowercase. already-cased text shares the buffer
str::ucfirst / lcfirst (const string&) : stringtitlecase / lowercase of the first codepoint
str::iequals(string::view $a, string::view $b) : boolsame bytes, ignoring ASCII case. allocates nothing
str::boundary(const string& $t, usize $at) : usize$at moved back to the start of the character it falls in
str::is_utf8(const string& $t) : boolwell-formed UTF-8. empty is
str::chars(const string& $t) : str::partseach character as a window. $s->count() is how many; $s->size() is bytes
str::hex(uint64 $n) : string16 lowercase digits, zero-padded
str::hex(const string::view& $b) : stringtwo lowercase digits per byte

Next ​

  • Strings for string and string::view, which is where the real surface is.
  • Arrays for arr::merge and arr::room.
  • C interop for the extern blocks these conversions exist to serve.

Echo is a work in progress. Nothing here is a promise of stability.