Text · Lesson 14.3

String view

Source
Borrow text with StringView, checked once to be UTF-8, then trim, search, cut and split it without copying a byte.
You'll need: Encoding, Coalesce, Iterator

A char8[..] is just bytes. Nothing promises they are valid UTF-8, so every function handed one has to check them again — or simply hope. StringView, from the Text package, is the same borrowed slice with that promise attached. The bytes are checked once, when the view is made, and everything after may rely on them being text. A view also owns nothing: making one copies no bytes and allocates nothing, and every operation on it answers with another view into the same bytes.

Making a view

There are two ways in, depending on who vouches for the bytes:

let line = StringView::FromValidated("  Ada Lovelace, mathematician  ");
let checked = StringView::FromBytes("Grace Hopper") ?? StringView();
CallChecks the bytes?ReturnsUse it for
StringView::FromValidated(b)no — trusts youStringViewliterals, which are always UTF-8
StringView::FromBytes(b)yesStringView?bytes from a file, a socket, a user
StringView()—an empty viewa fallback after ??

FromBytes returns none when the bytes are not text, so it pairs naturally with ??. A view prints with {} like any text.

Trim, search and cut

Every one of these returns a new view of the same bytes. Nothing is copied, and the original text never changes.

let trimmed = line.Trim();

let comma = StringView::FromValidated(",");
let at = trimmed.IndexOf(comma) ?? trimmed.Length();
PrintLine("starts with Ada {}", trimmed.StartsWith(StringView::FromValidated("Ada")));

let name = trimmed.Part(0, at) ?? StringView();
  • Trim drops white space — spaces, tabs and line ends — from both ends. TrimStart and TrimEnd do one end each.
  • IndexOf answers with a byte position, or none when there is nothing to find — so ?? trimmed.Length() means "the whole text, if there is no comma". StartsWith, EndsWith and Contains answer yes or no.
  • Part(start, end) cuts out bytes start up to end, as an optional view.

Note that the needle is a view too. Every search takes a StringView, so a literal needle is wrapped with FromValidated first.

flowchart LR
    b["The bytes of the literal<br/>␣␣Ada Lovelace, mathematician␣␣"]
    line["line<br/>the whole literal"] --> b
    t["trimmed = line.Trim()<br/>without the spaces"] --> b
    n["name = trimmed.Part(0, 12)<br/>Ada Lovelace"] --> b

Because all three point into the same bytes, those bytes must outlive every view of them — exactly as an array must outlive a slice of it. A literal lives as long as the program, so here nothing can go wrong.

Split into pieces

Split walks the pieces between separators. It is an iterator, so a for loop takes the pieces one at a time, each a view of its own:

let space = StringView::FromValidated(" ");
for word in name.Split(space) {
    PrintLine("word           [{}] {} bytes", word, word.Length());
}

Positions are bytes, and cuts must fall between characters

Length, IndexOf and Part all count bytes, like the literals underneath. But a view promised to be text, so it will not cut a character in half. In née the é covers bytes 1 and 2:

let accented = StringView::FromValidated("née");
let whole = accented.Part(0, 4) ?? StringView();
let broken = accented.Part(0, 2) ?? StringView::FromValidated("(refused)");

Part(0, 4) is the whole word. Part(0, 2) would end in the middle of é, so it is refused and returns none. A plain slice, "née"[0..2], would have cut it anyway — UTF-8 shows what such a cut leaves behind.

The program

The whole lesson is one package in the Examples repository. Its comments explain every step.

Src/Main.rux
// A `char8[..]` is just bytes: nothing promises they are valid UTF-8, so every function handed
// one has to check them again or simply hope. `StringView`, from the Text package, is the same
// borrowed slice with that promise attached. The bytes are checked once, when the view is made,
// and everything after may rely on them being text.
//
// A view owns nothing. Making one copies no bytes and allocates nothing, and each operation
// below answers with another view into the same bytes — so the text it looks at must outlive
// it, exactly as an array must outlive a slice of it.
import Io::PrintLine;
import Text::StringView;

func Main() -> int {
    // `FromValidated` takes the caller's word that the bytes are UTF-8, which a literal always
    // is. `FromBytes` checks instead, and returns `none` when they are not text.
    let line = StringView::FromValidated("  Ada Lovelace, mathematician  ");
    let checked = StringView::FromBytes("Grace Hopper") ?? StringView();
    PrintLine("checked        [{}]", checked);

    // Trimming does not change the text. It returns a narrower view of the same bytes.
    let trimmed = line.Trim();
    PrintLine("trimmed        [{}]", trimmed);

    // Searching answers with a byte position, or `none` when there is nothing to find.
    let comma = StringView::FromValidated(",");
    let at = trimmed.IndexOf(comma) ?? trimmed.Length();
    PrintLine("comma at byte  {}", at);
    PrintLine("starts with Ada {}", trimmed.StartsWith(StringView::FromValidated("Ada")));

    // `Part` cuts out a smaller view by byte positions, here the name before the comma.
    let name = trimmed.Part(0, at) ?? StringView();
    PrintLine("name           [{}]", name);

    // `Split` walks the pieces between separators, each one a view of its own.
    let space = StringView::FromValidated(" ");
    for word in name.Split(space) {
        PrintLine("word           [{}] {} bytes", word, word.Length());
    }

    // Positions are bytes, and a cut inside a character is refused rather than made. In "née"
    // the `é` covers bytes 1 and 2, so ending a part at byte 2 would split it in half.
    let accented = StringView::FromValidated("née");
    let whole = accented.Part(0, 4) ?? StringView();
    let broken = accented.Part(0, 2) ?? StringView::FromValidated("(refused)");
    PrintLine("part 0..4      {}", whole);
    PrintLine("part 0..2      {}", broken);
    return 0;
}

Besides Io, its Rux.toml lists Text under [Dependencies].

Run it

cd Examples/Text/StringView
rux run
checked        [Grace Hopper]
trimmed        [Ada Lovelace, mathematician]
comma at byte  12
starts with Ada true
name           [Ada Lovelace]
word           [Ada] 3 bytes
word           [Lovelace] 8 bytes
part 0..4      née
part 0..2      (refused)

Common mistakes

Searching with a plain literal.
trimmed.IndexOf(",") fails with error: argument 1 to 'IndexOf' has type 'char8[..]', but parameter 'needle' requires 'StringView'. Wrap the needle: StringView::FromValidated(",").
Forgetting that Part and FromBytes may fail.
Both return an optional. Without ??, PrintLine("{}", name) fails with has type 'StringView?', but variadic parameter 'args' requires 'Display', and name.Split(space) with error: type 'StringView?' has no field 'Split'.
.length instead of Length().
A view is a struct, not a slice, so word.length fails with error: struct 'StringView' has no field 'length'. Its size is a method: word.Length().

Try it yourself

  1. Use LastIndexOf to cut out the surname, Lovelace, without splitting.
  2. Split "red,green,,blue" on a comma. How many pieces do you get, and what is the third one?
  3. Check that the trimmed line EndsWith "mathematician" and Contains "Love".
  4. Print accented.ScalarCount() next to accented.Length().

Learn more

  • Text in the API reference
  • Slice — the borrowed view underneath
  • String — text that owns its bytes, for when a view is not enough
  • UTF-8 — the check that FromBytes makes