Strings and Text · about 45 min

Working with Text: Splitting, Trimming, Parsing, Building

Why this matters

A huge share of everyday programming is text processing: reading configuration files, parsing log lines, cleaning up CSV exports, formatting reports. Rust's str type has a rich, efficient toolbox for this, and most of it hands out &str slices into the original text instead of copying. This lesson is a tour of the tools you will reach for constantly, organized by task, with the ownership of each result made explicit.

Trimming

fn main() {
    let raw = "  \t hello world \n";
    println!("[{}]", raw.trim());
    println!("[{}]", raw.trim_start());
    println!("[{}]", raw.trim_end());
    println!("[{}]", "xxabcxx".trim_matches('x'));
    println!("[{}]", "v1.2.3".trim_start_matches('v'));
    println!("{:?}", "report.txt".strip_suffix(".txt"));
    println!("{:?}", "report.csv".strip_suffix(".txt"));
}
[hello world]
[hello world 
]
[       hello world]
[abc]
[1.2.3]
Some("report")
None

All of these return a &str slice of the original: no allocation. strip_prefix and strip_suffix return an Option so you can tell whether the prefix was there.

Searching

fn main() {
    let line = "2024-03-15 ERROR disk full on /dev/sda1";
    println!("{}", line.contains("ERROR"));
    println!("{}", line.starts_with("2024"));
    println!("{:?}", line.find(' '));
    println!("{:?}", line.rfind('/'));
    println!("{:?}", line.find("WARN"));
    if let Some(pos) = line.find(" on ") {
        println!("device: {}", &line[pos + 4..]);
    }
}
true
true
Some(10)
Some(34)
None
device: /dev/sda1

find returns Some(byte_position) or None. Positions from find are always character boundaries, so slicing with them is safe. (if let Some(pos) = ... { } runs the block only when there is a value, binding it to pos; module 7 covers it properly.)

Splitting

Splitting is the workhorse. Each splitter returns an iterator of &str slices:

fn main() {
    let csv = "alice,30,,london";
    let parts: Vec<&str> = csv.split(',').collect();
    println!("{:?}", parts);

    println!("{:?}", "a  b   c".split(' ').collect::<Vec<_>>());
    println!("{:?}", "a  b   c".split_whitespace().collect::<Vec<_>>());

    println!("{:?}", "key=value=more".splitn(2, '=').collect::<Vec<_>>());
    println!("{:?}", "key=value=more".split_once('='));
    println!("{:?}", "no equals here".split_once('='));

    println!("{:?}", "one\ntwo\r\nthree\n".lines().collect::<Vec<_>>());
}
["alice", "30", "", "london"]
["a", "", "b", "", "", "c"]
["a", "b", "c"]
["key", "value=more"]
Some(("key", "value=more"))
None
["one", "two", "three"]

All the pieces borrow from the original string, so the original must outlive them; that is the borrowing rule from module 5, and the compiler checks it.

Parsing pieces

Combine splitting with parse to read structured data:

fn main() {
    let line = "  12, 7 ,  -3 ";
    let mut sum = 0;
    for piece in line.split(',') {
        let n: i32 = piece.trim().parse().unwrap();
        sum += n;
    }
    println!("sum = {sum}");

    let point = "(3,-4)";
    let inner = point.strip_prefix('(').unwrap().strip_suffix(')').unwrap();
    let (x, y) = inner.split_once(',').unwrap();
    let (x, y): (i32, i32) = (x.parse().unwrap(), y.parse().unwrap());
    println!("x={x} y={y}");
}
sum = 16
x=3 y=-4

Every unwrap here is a place where malformed input would crash the program. That is acceptable in these exercises; module 8 replaces the unwraps with real error handling, and the structure of the code stays the same.

Transforming: these allocate

Operations that produce different text must create a new String:

fn main() {
    let s = "Hello, World";
    println!("{}", s.to_lowercase());
    println!("{}", s.to_uppercase());
    println!("{}", s.replace("World", "Rust"));
    println!("{}", s.replacen('l', "L", 2));
    let cleaned: String = s.chars().filter(|c| c.is_alphanumeric()).collect();
    println!("{cleaned}");
}
hello, world
HELLO, WORLD
Hello, Rust
HeLLo, World
HelloWorld

A useful mental split: trimming, searching and splitting borrow (cheap slices of the original); case changes, replacing and filtering allocate (a new String).

Building output efficiently

When producing a lot of output text, build into one String (or print directly) rather than concatenating many temporaries. write! from std::fmt::Write appends formatted text to a String:

use std::fmt::Write;

fn main() {
    let items = [("apple", 3), ("kiwi", 12), ("fig", 1)];
    let mut report = String::new();
    for (name, qty) in items {
        writeln!(report, "{name:<8}{qty:>4}").unwrap();
    }
    print!("{report}");
    let names: Vec<&str> = items.iter().map(|(n, _)| *n).collect();
    println!("{}", names.join(", "));
}
apple      3
kiwi      12
fig        1
apple, kiwi, fig

writeln!(target, ...) is println! into a String (it returns a Result, hence unwrap). For a list with separators, join is simplest.

Common mistakes

split(' ') for words. Double spaces give empty strings; use split_whitespace.

Forgetting trim on split pieces: " 7".parse::<i32>() fails with InvalidDigit.

Holding slices while modifying the source:

fn main() {
    let mut text = String::from("a,b,c");
    let first = text.split(',').next().unwrap();
    text.push_str(",d");
    println!("{first}");
}
error[E0502]: cannot borrow `text` as mutable because it is also borrowed as immutable
 --> main.rs:4:5
  |
3 |     let first = text.split(',').next().unwrap();
  |                 ---- immutable borrow occurs here
4 |     text.push_str(",d");
  |     ^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here
5 |     println!("{first}");
  |               ------- immutable borrow later used here

first borrows from text. Use it before modifying, or convert it to an owned String with to_string().

Case-insensitive comparison by lowercasing both sides repeatedly in a loop: correct but allocates every time. For ASCII, eq_ignore_ascii_case allocates nothing.

Recap

Going further: browse the method list of str in the standard library docs; there are many more (char_indices, matches, rsplit, split_terminator, escape_debug). Pick three you have not used and try them.

Exercises

Exercise 1 · write code

Parse key=value lines

Read lines like name = Ada from stdin. For each line:

For the input

# config
name = Ada
 lang=Rust
formula = a=b+c
oops

print

NAME -> Ada
LANG -> Rust
FORMULA -> a=b+c
bad line: oops

File: main.rs

Starting point

use std::io;

fn main() {
    for line in io::stdin().lines() {
        let line = line.unwrap();
    }
}

Example

input

# config
name = Ada
 lang=Rust
formula = a=b+c
oops

expected output

NAME -> Ada
LANG -> Rust
FORMULA -> a=b+c
bad line: oops

Example

input


   
key =

expected output

KEY ->
Hint 1

line.split_once('=') returns Some((before, after)) split at the first =, or None.

Hint 2

match line.split_once('=') { Some((k, v)) => ..., None => ... }

Reference solution
use std::io;

fn main() {
    for line in io::stdin().lines() {
        let line = line.unwrap();
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        match line.split_once('=') {
            Some((key, value)) => println!("{} -> {}", key.trim().to_uppercase(), value.trim()),
            None => println!("bad line: {line}"),
        }
    }
}

Exercise 2 · write code

Column totals

The input is a small CSV file: a header line, then rows of name,quantity,price (quantity an integer, price a decimal number). Print each name with its line total (quantity times price, 2 decimals), then the grand total:

apple: 1.50
bread: 2.25
total: 3.75

for

name,quantity,price
apple,3,0.50
bread,1,2.25

Skip the header. Fields may have spaces around them; trim each field.

File: main.rs

Starting point

use std::io;

fn main() {
}

Example

input

name,quantity,price
apple,3,0.50
bread,1,2.25

expected output

apple: 1.50
bread: 2.25
total: 3.75

Example

input

name, quantity, price
 tea , 10 , 0.3

expected output

tea: 3.00
total: 3.00
Hint 1

.enumerate() on the lines gives the line number, so you can skip line 0.

Hint 2

Split with let mut parts = line.split(','); and take fields with parts.next().unwrap().trim(), or collect into a Vec<&str>.

Hint 3

Parse quantity as an integer and price as f64, then multiply with qty as f64 * price.

Reference solution
use std::io;

fn main() {
    let mut total = 0.0;
    for (i, line) in io::stdin().lines().enumerate() {
        let line = line.unwrap();
        if i == 0 || line.trim().is_empty() {
            continue;
        }
        let fields: Vec<&str> = line.split(',').map(|f| f.trim()).collect();
        let name = fields[0];
        let qty: u32 = fields[1].parse().unwrap();
        let price: f64 = fields[2].parse().unwrap();
        let line_total = qty as f64 * price;
        total += line_total;
        println!("{name}: {line_total:.2}");
    }
    println!("total: {total:.2}");
}

Exercise 3 · write code

Word filter

Write fn censor(text: &str, banned: &str) -> String that replaces every whole word equal to banned (compared case-insensitively) with asterisks of the same character length. Words are separated by single spaces in the input, and the output keeps single spaces. Punctuation is part of a word (so darn, is not equal to darn).

The given main prints:

well **** it, **** DARN!

(DARN! survives because the ! makes it a different word.)

File: main.rs

Starting point

fn censor(text: &str, banned: &str) -> String {
    text.to_string()
}

fn main() {
    println!("{}", censor("well darn it, darn DARN!", "darn"));
}

Example

expected output

well **** it, **** DARN!
Hint 1

Split with text.split(' '), decide per word, collect the pieces into a Vec<String> and join(" ").

Hint 2

word.to_lowercase() == banned.to_lowercase() compares ignoring case; "*".repeat(n) builds the stars.

Reference solution
fn censor(text: &str, banned: &str) -> String {
    let mut out = Vec::new();
    for word in text.split(' ') {
        if word.to_lowercase() == banned.to_lowercase() {
            out.push("*".repeat(word.chars().count()));
        } else {
            out.push(word.to_string());
        }
    }
    out.join(" ")
}

fn main() {
    println!("{}", censor("well darn it, darn DARN!", "darn"));
}