Text · Lesson 14.14

Lines and joining

Source
Walk the lines of a text with Lines, put pieces back together with Join, and change the case of whole texts with ToUpper, ToLower and FoldCase, which follow Unicode's full mappings.

Text rarely arrives one value at a time. A file, a pasted list or a reply from a server is one run of bytes with line breaks in it, and a program that wants the entries has to cut it into lines. Going the other way, a program that has a list of names and wants to show them needs them in one text, with a comma between each two.

The Text package does both, and changes the case of whole texts too:

import Text::{ FoldCase, Join, StringView, TextError, ToLower, ToUpper };

Walking the lines

Lines is a method of StringView. It returns an iterator, so a for loop walks it:

for line in text.Lines() {
    count += 1;
    PrintLine("line {}  [{}]", count, line);
}

Each line is a StringView of the original bytes, without its line break, so the walk copies nothing and allocates nothing. The text must therefore outlive the loop, as with every view. Three rules decide where the lines are:

TextLines
"a\nb"a, b — the last line needs no break
"a\r\nb\n"a, b — \r\n is one break, and a final break adds no line
"a\n\nb"a, an empty line, b — a blank line is kept
""none at all

So the program's guest list, with five lines, one of them blank and one ending in \r\n as Windows writes it, prints five lines with no break characters left in them.

Joining pieces

Join takes a slice of views and a separator, and makes one String with the separator between each two parts:

let joined = Join(allocator, names[..kept], StringView::FromValidated(", "))?;
flowchart LR
    p1["`Ada Lovelace`"] --> j["`Join` with `, `"]
    p2["`grace hopper`"] --> j
    p3["`Alan Turing`"] --> j
    p4["`Katherine Johnson`"] --> j
    j --> s["one `String`:<br/>three separators"]

There is always one separator fewer than there are parts: one part comes back on its own, and no parts give empty text. That is the job usually done with a loop and a flag that remembers whether a comma is due, and getting it wrong leaves a stray comma at one end. Join works out the final length first and allocates once. Like every call that allocates text, it returns String ! TextError, so ? passes a failure on.

Changing case

ToUpper, ToLower and FoldCase make a new String from a view. They apply Unicode's full mappings — the ones Unicode case wrote out a scalar at a time with ToUpperFull — to the whole text in one call:

let upper = ToUpper(allocator, StringView::FromValidated("Straße"))?;
let lower = ToLower(allocator, StringView::FromValidated("ÉCOLE"))?;

ß has no single capital letter, so Straße, six characters, becomes STRASSE, seven. What the three leave out are the mappings that depend on the language or on the letters around one: a Turkish dotless i, or a Greek capital sigma that should become the final ς at the end of a word. ToLower always gives σ.

FunctionStraßeSTRASSEUse it for
ToUpperSTRASSESTRASSEshowing text in capitals
ToLowerstraßestrasseshowing text in small letters
FoldCasestrassestrassecomparing text, ignoring case

Comparing without case

The last column is the point of FoldCase. Two texts that differ only in case fold to the same bytes, so folding both sides and comparing the folds finds a name however it was typed:

let query = FoldCase(allocator, StringView::FromValidated("GRACE HOPPER"))?;
for i in 0..kept {
    let folded = FoldCase(allocator, names[i])?;
    if folded.Equals(query) {
        PrintLine("found    {} at position {}", names[i], i + 1);
    }
}

The comparison is Equals. A String carries the allocator it came from, which has no ==, so folded == query does not compile.

The program

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

Src/Main.rux
// Text read from a file or typed by a person arrives as one run of bytes with line breaks in it.
// `Lines` walks that run one line at a time. Each line is a view of the original bytes, without
// its break, so the walk copies nothing and allocates nothing.
//
// `Join` goes the other way: it takes several pieces and makes one String, with a separator
// between each two. It is the tidy answer to "a comma after every item but the last".
//
// `ToUpper`, `ToLower` and `FoldCase` change the case of a whole text and hand back a new String.
// They apply Unicode's full mappings, so a letter may become two and the length may change.
// `FoldCase` is the one for comparing: two texts that differ only in case fold to the same bytes.
import Allocator::{ Allocator, SystemAllocator };
import Io::PrintLine;
import Text::{ FoldCase, Join, StringView, TextError, ToLower, ToUpper };

func Main() -> ! TextError {
    var system = SystemAllocator();
    let allocator: Allocator = system;

    // A guest list as a file might hold it: one line ends in `\r\n`, as Windows writes it, one is
    // blank, and the text ends with a break.
    let text = StringView::FromValidated(
        "Ada Lovelace\r\ngrace hopper\n\n  Alan Turing  \nKatherine Johnson\n");

    // A line ends at `\n` or `\r\n`, and neither is part of the line. The blank line is kept as an
    // empty line, but the final break does not start another one: this text has five lines.
    var count = 0;
    for line in text.Lines() {
        count += 1;
        PrintLine("line {}  [{}]", count, line);
    }

    // Keep the names, trimmed, in an array of views; the blank line is skipped.
    var names: StringView[8] = [StringView(); 8];
    var kept: uint = 0;
    for line in text.Lines() {
        let name = line.Trim();
        if !name.IsEmpty() {
            names[kept] = name;
            kept += 1;
        }
    }

    // Four names, three separators. `Join` allocates the result once, at its final length.
    let joined = Join(allocator, names[..kept], StringView::FromValidated(", "))?;
    PrintLine("joined   {}", joined);

    // The parts and the separator are views, not bare literals. Passing ", " as it is fails:
    //
    //     error: argument 3 to 'Join' has type 'char8[..]', but parameter 'separator' requires
    //     'StringView'

    // Case changes make new text. `ß` has no single capital letter, so in upper case it becomes
    // "SS": six characters in, seven out.
    let upper = ToUpper(allocator, StringView::FromValidated("Straße"))?;
    let lower = ToLower(allocator, StringView::FromValidated("ÉCOLE"))?;
    PrintLine("upper    {} ({} characters)", upper, upper.View().ScalarCount());
    PrintLine("lower    {}", lower);

    // To compare without regard to case, fold both sides and compare the folds with `Equals`.
    // Folding the query once and each name once finds "grace hopper" however it was typed.
    // `folded == query` is refused, since a String carries its allocator, which has no `==`:
    //
    //     error: structural equality for 'String' is unavailable because element type
    //     'Allocator' has no '==' operator
    let query = FoldCase(allocator, StringView::FromValidated("GRACE HOPPER"))?;
    for i in 0..kept {
        let folded = FoldCase(allocator, names[i])?;
        if folded.Equals(query) {
            PrintLine("found    {} at position {}", names[i], i + 1);
        }
    }

    // `Straße` and `STRASSE` differ in case and in length, and still fold to the same text.
    let street = FoldCase(allocator, StringView::FromValidated("Straße"))?;
    let shouted = FoldCase(allocator, StringView::FromValidated("STRASSE"))?;
    PrintLine("same     {}", street.Equals(shouted));
}

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

Run it

cd Examples/Text/JoinLines
rux run
line 1  [Ada Lovelace]
line 2  [grace hopper]
line 3  []
line 4  [  Alan Turing  ]
line 5  [Katherine Johnson]
joined   Ada Lovelace, grace hopper, Alan Turing, Katherine Johnson
upper    STRASSE (7 characters)
lower    école
found    grace hopper at position 2
same     true

Common mistakes

Try it yourself

  1. Join the names with "\n" instead of ", ", then walk the result with Lines. Do you get the same four names back?
  2. Number the non-blank lines only, so that the blank line does not use up a number.
  3. Search for "KATHERINE JOHNSON" and "katherine". Which one is found, and why?
  4. Print ToUpper of "fix", written with the single ligature character fi, and count its characters with ScalarCount.

Learn more