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"]
split(sep)splits at every separator and keeps empty pieces (note""between the two commas and the empty strings between repeated spaces).split_whitespace()splits on runs of any whitespace and never yields empty pieces: the right choice for "words".splitn(n, sep)stops afternpieces;split_once(sep)splits at the first separator into a tuple, returningNoneif there is none. Ideal forkey=value.lines()splits on\nand also removes a\rbefore it (Windows line endings)..collect::<Vec<_>>()gathers an iterator into a vector;_lets the compiler fill in&str. Module 9 coversVec, module 11 iterators. You can also take pieces one at a time with.next(), as in module 3.
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
- Trim with
trim,trim_start/end,trim_matches,strip_prefix/suffix(all borrow). - Search with
contains,starts_with,find,rfind; positions are safe slice boundaries. - Split with
split,split_whitespace,splitn,split_once,lines; pieces borrow from the source. parseeach trimmed piece; module 8 replacesunwrapwith real error handling.to_lowercase,replace,filter+collectallocate new Strings;write!/writeln!andjoinbuild output.
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.