Skip to content
Learn Rust
Theme

Working with Text

Standard library · 3 of 5

Rust's text tools are methods on str itself: trim, split, split_once, replace, parse, starts_with, find. Most of them hand back views into your text instead of copies.

Coming from GoThe strings package becomes methods on str: trim is TrimSpace, split_once is strings.Cut, parse replaces strconv. find returns an Option<usize> instead of -1, and split gives a lazy iterator, not a slice.

Coming from KotlinThese are close to Kotlin's string methods: trim, split, replace, starts_with. find returns Option<usize> instead of -1, and split gives a lazy iterator rather than a List, so you can't index it.

Coming from Pythonstrip is trim, partition is split_once (which returns an Option), and int(s) is s.parse(). find gives Option<usize> instead of -1, and split is a lazy iterator, not a list.

Coming from TypeScripttrim, split and startsWith (starts_with) carry over. Rust's replace changes every match, where JS's replace with a string changes only the first (replaceAll is the equal). parse returns a Result instead of NaN, find returns an Option instead of -1, and split is a lazy iterator, not an array.

Coming from Javatrim, split, replace and starts_with correspond to Java's strip or trim, split, replace and startsWith, but most return views into your text instead of new Strings. split takes a plain pattern, not a regex, and returns a lazy iterator, so parts[0] doesn't work; use .next() or split_once.

Coming from C#trim, split, replace and find match Trim, Split, Replace and IndexOf, but trim and split hand back views instead of new strings. split returns a lazy iterator rather than an array, so take pieces with split_once, .next() or collect().

Coming from CRust's trim, split and find do what you'd build from isspace, memchr and strstr, as methods on str. split only borrows the text and never writes into it the way strtok does, and parse returns a Result instead of a strtol end pointer.

Coming from C++These methods work on &str like std::string_view functions, and split is lazy like std::views::split. Rust has trim and split_once built in, where C++20 has neither. parse is closest to from_chars, which stops at the first non-digit instead of failing.

The quick comparison

Rustsrc/main.rsRun ↗
fn main() {
let line = " name=Ana, age=30, city=Lisbon ";
let clean = line.trim(); // a &str view into line: nothing copied
println!("[{clean}]");
// split on ',', trim each piece, split once on '='
for field in clean.split(',') {
if let Some((key, value)) = field.trim().split_once('=') {
println!("{key:>5} -> {value}");
}
}
// text to number: parse returns Result
let age: u32 = "30".parse().unwrap_or(0);
println!("next year: {}", age + 1);
// these build new Strings
let shout = clean.to_uppercase().replace(", ", " | ");
println!("{shout}");
println!("starts with name: {}", clean.starts_with("name"));
println!("position of age: {:?}", clean.find("age"));
let words = ["build", "with", "rust"];
println!("{}", words.join("-"));
// grow a String, like strings.Builder
let mut out = String::new();
for i in 1..=3 {
out.push_str(&i.to_string());
out.push(';');
}
println!("{out}");
}
$ cargo run
[name=Ana, age=30, city=Lisbon]
 name -> Ana
  age -> 30
 city -> Lisbon
next year: 31
NAME=ANA | AGE=30 | CITY=LISBON
starts with name: true
position of age: Some(10)
build-with-rust
1;2;3;
package main
import (
"fmt"
"strconv"
"strings"
)
func main() {
line := " name=Ana, age=30, city=Lisbon "
clean := strings.TrimSpace(line)
fmt.Printf("[%s]\n", clean)
for _, field := range strings.Split(clean, ",") {
if key, value, ok := strings.Cut(strings.TrimSpace(field), "="); ok {
fmt.Printf("%5s -> %s\n", key, value)
}
}
age, err := strconv.Atoi("30")
if err != nil {
age = 0
}
fmt.Println("next year:", age+1)
shout := strings.ReplaceAll(strings.ToUpper(clean), ", ", " | ")
fmt.Println(shout)
fmt.Println("starts with name:", strings.HasPrefix(clean, "name"))
fmt.Println("position of age:", strings.Index(clean, "age"))
fmt.Println(strings.Join([]string{"build", "with", "rust"}, "-"))
var b strings.Builder
for i := 1; i <= 3; i++ {
b.WriteString(strconv.Itoa(i))
b.WriteByte(';')
}
fmt.Println(b.String())
}
$ go run main.go
[name=Ana, age=30, city=Lisbon]
 name -> Ana
  age -> 30
 city -> Lisbon
next year: 31
NAME=ANA | AGE=30 | CITY=LISBON
starts with name: true
position of age: 10
build-with-rust
1;2;3;
fun main() {
val line = " name=Ana, age=30, city=Lisbon "
val clean = line.trim()
println("[$clean]")
for (field in clean.split(",")) {
val parts = field.trim().split("=", limit = 2)
if (parts.size == 2) println("${parts[0].padStart(5)} -> ${parts[1]}")
}
val age = "30".toIntOrNull() ?: 0
println("next year: ${age + 1}")
println(clean.uppercase().replace(", ", " | "))
println("starts with name: ${clean.startsWith("name")}")
println("position of age: ${clean.indexOf("age")}")
println(listOf("build", "with", "rust").joinToString("-"))
val sb = StringBuilder()
for (i in 1..3) sb.append(i).append(';')
println(sb)
}
$ kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar
[name=Ana, age=30, city=Lisbon]
 name -> Ana
  age -> 30
 city -> Lisbon
next year: 31
NAME=ANA | AGE=30 | CITY=LISBON
starts with name: true
position of age: 10
build-with-rust
1;2;3;
line = " name=Ana, age=30, city=Lisbon "
clean = line.strip() # a new str: Python strings never change in place
print(f"[{clean}]")
# split on ',', strip each piece, split once on '='
for field in clean.split(","):
key, sep, value = field.strip().partition("=")
if sep:
print(f"{key:>5} -> {value}")
# text to number: int raises ValueError on bad input
try:
age = int("30")
except ValueError:
age = 0
print(f"next year: {age + 1}")
shout = clean.upper().replace(", ", " | ")
print(shout)
print(f"starts with name: {clean.startswith('name')}")
print(f"position of age: {clean.find('age')}") # -1 when missing
words = ["build", "with", "rust"]
print("-".join(words))
# collect pieces in a list and join once, like strings.Builder
parts = []
for i in range(1, 4):
parts.append(f"{i};")
print("".join(parts))
$ python3 main.py
[name=Ana, age=30, city=Lisbon]
 name -> Ana
  age -> 30
 city -> Lisbon
next year: 31
NAME=ANA | AGE=30 | CITY=LISBON
starts with name: True
position of age: 10
build-with-rust
1;2;3;
const line = " name=Ana, age=30, city=Lisbon ";
const clean = line.trim(); // a new string: strings never change in place
console.log(`[${clean}]`);
// split on ',', trim each piece, split once on '='
for (const field of clean.split(",")) {
const piece = field.trim();
const at = piece.indexOf("=");
if (at !== -1) {
console.log(`${piece.slice(0, at).padStart(5)} -> ${piece.slice(at + 1)}`);
}
}
// text to number: Number gives NaN on bad input instead of throwing
let age = Number("30");
if (Number.isNaN(age)) age = 0;
console.log(`next year: ${age + 1}`);
console.log(clean.toUpperCase().replaceAll(", ", " | "));
console.log(`starts with name: ${clean.startsWith("name")}`);
console.log(`position of age: ${clean.indexOf("age")}`); // -1 when missing
console.log(["build", "with", "rust"].join("-"));
// += on a string is fast enough in JS engines; no builder needed
let out = "";
for (let i = 1; i <= 3; i++) {
out += `${i};`;
}
console.log(out);
$ tsc --strict --module nodenext --types node main.ts && node main.js
[name=Ana, age=30, city=Lisbon]
 name -> Ana
  age -> 30
 city -> Lisbon
next year: 31
NAME=ANA | AGE=30 | CITY=LISBON
starts with name: true
position of age: 10
build-with-rust
1;2;3;
public class Main {
public static void main(String[] args) {
String line = " name=Ana, age=30, city=Lisbon ";
String clean = line.strip(); // a new String: Java strings never share a view
System.out.println("[" + clean + "]");
// split on ',', strip each piece, split once on '='
for (String field : clean.split(",")) {
String[] kv = field.strip().split("=", 2);
if (kv.length == 2) {
System.out.printf("%5s -> %s%n", kv[0], kv[1]);
}
}
// text to number: parseInt throws on bad input
int age;
try {
age = Integer.parseInt("30");
} catch (NumberFormatException e) {
age = 0;
}
System.out.println("next year: " + (age + 1));
String shout = clean.toUpperCase().replace(", ", " | ");
System.out.println(shout);
System.out.println("starts with name: " + clean.startsWith("name"));
System.out.println("position of age: " + clean.indexOf("age")); // -1 if missing
System.out.println(String.join("-", "build", "with", "rust"));
// grow a string piece by piece
StringBuilder out = new StringBuilder();
for (int i = 1; i <= 3; i++) {
out.append(i).append(';');
}
System.out.println(out);
}
}
$ javac Main.java && java Main
[name=Ana, age=30, city=Lisbon]
 name -> Ana
  age -> 30
 city -> Lisbon
next year: 31
NAME=ANA | AGE=30 | CITY=LISBON
starts with name: true
position of age: 10
build-with-rust
1;2;3;
using System.Text;
var line = " name=Ana, age=30, city=Lisbon ";
var clean = line.Trim(); // a new string: .NET strings never share a view
Console.WriteLine($"[{clean}]");
// split on ',', trim each piece, split once on '='
foreach (var field in clean.Split(','))
{
var kv = field.Trim().Split('=', 2);
if (kv.Length == 2)
{
Console.WriteLine($"{kv[0],5} -> {kv[1]}");
}
}
// text to number: TryParse reports success with a bool
var age = int.TryParse("30", out var n) ? n : 0;
Console.WriteLine($"next year: {age + 1}");
var shout = clean.ToUpperInvariant().Replace(", ", " | ");
Console.WriteLine(shout);
Console.WriteLine($"starts with name: {clean.StartsWith("name", StringComparison.Ordinal)}");
Console.WriteLine($"position of age: {clean.IndexOf("age", StringComparison.Ordinal)}"); // -1 if missing
Console.WriteLine(string.Join("-", "build", "with", "rust"));
// grow a string piece by piece
var sb = new StringBuilder();
for (var i = 1; i <= 3; i++)
{
sb.Append(i).Append(';');
}
Console.WriteLine(sb);
$ dotnet run
[name=Ana, age=30, city=Lisbon]
 name -> Ana
  age -> 30
 city -> Lisbon
next year: 31
NAME=ANA | AGE=30 | CITY=LISBON
starts with name: True
position of age: 10
build-with-rust
1;2;3;
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
const char *line = " name=Ana, age=30, city=Lisbon ";
// trim: move the start forward and the end back. Nothing is copied.
const char *start = line;
while (isspace((unsigned char)*start)) start++;
const char *end = start + strlen(start);
while (end > start && isspace((unsigned char)end[-1])) end--;
int clean_len = (int)(end - start);
printf("[%.*s]\n", clean_len, start);
// split on ',', skip spaces, split once on '='
const char *p = start;
while (p < end) {
const char *comma = memchr(p, ',', (size_t)(end - p));
const char *stop = comma ? comma : end;
while (p < stop && *p == ' ') p++;
const char *eq = memchr(p, '=', (size_t)(stop - p));
if (eq) {
printf("%5.*s -> %.*s\n", (int)(eq - p), p, (int)(stop - eq - 1), eq + 1);
}
p = stop + 1;
}
// text to number: strtol tells you where it stopped
char *rest;
long age = strtol("30", &rest, 10);
if (*rest != '\0') age = 0;
printf("next year: %ld\n", age + 1);
// upper-case and replace ", " with " | " into a new buffer
char shout[64];
int n = 0;
for (const char *c = start; c < end; c++) {
if (c[0] == ',' && c[1] == ' ') {
n += snprintf(shout + n, sizeof shout - (size_t)n, " | ");
c++;
} else {
shout[n++] = (char)toupper((unsigned char)*c);
}
}
shout[n] = '\0';
printf("%s\n", shout);
printf("starts with name: %s\n", strncmp(start, "name", 4) == 0 ? "true" : "false");
const char *found = strstr(start, "age"); // NULL when not found
printf("position of age: %d\n", found ? (int)(found - start) : -1);
const char *words[] = {"build", "with", "rust"};
char joined[32] = "";
for (int i = 0; i < 3; i++) {
if (i > 0) strcat(joined, "-");
strcat(joined, words[i]);
}
printf("%s\n", joined);
// grow a string: track the length yourself
char out[32];
int len = 0;
for (int i = 1; i <= 3; i++) {
len += snprintf(out + len, sizeof out - (size_t)len, "%d;", i);
}
printf("%s\n", out);
return 0;
}
$ gcc -Wall -Wextra main.c -o main && ./main
[name=Ana, age=30, city=Lisbon]
 name -> Ana
  age -> 30
 city -> Lisbon
next year: 31
NAME=ANA | AGE=30 | CITY=LISBON
starts with name: true
position of age: 10
build-with-rust
1;2;3;
#include <algorithm>
#include <cctype>
#include <charconv>
#include <format>
#include <iostream>
#include <ranges>
#include <string>
#include <string_view>
#include <vector>
// std has no trim: move the ends of a view inward
std::string_view trim(std::string_view s) {
auto start = s.find_first_not_of(' ');
if (start == std::string_view::npos) return {};
return s.substr(start, s.find_last_not_of(' ') - start + 1);
}
int main() {
std::string_view line = " name=Ana, age=30, city=Lisbon ";
auto clean = trim(line); // a view into line: nothing copied
std::cout << "[" << clean << "]\n";
// split on ',', trim each piece, split once on '='
for (auto part : clean | std::views::split(',')) {
auto field = trim(std::string_view(part.begin(), part.end()));
if (auto eq = field.find('='); eq != std::string_view::npos) {
std::cout << std::format("{:>5} -> {}\n", field.substr(0, eq), field.substr(eq + 1));
}
}
// text to number: from_chars reports errors without exceptions
std::string_view digits = "30";
unsigned age = 0;
if (std::from_chars(digits.data(), digits.data() + digits.size(), age).ec != std::errc{}) age = 0;
std::cout << "next year: " << age + 1 << "\n";
// these build new strings
std::string shout(clean);
std::ranges::transform(shout, shout.begin(), [](unsigned char c) { return std::toupper(c); });
for (size_t at; (at = shout.find(", ")) != std::string::npos;) shout.replace(at, 2, " | ");
std::cout << shout << "\n";
std::cout << std::boolalpha << "starts with name: " << clean.starts_with("name") << "\n";
auto pos = clean.find("age"); // npos when not found
std::cout << "position of age: " << (pos == std::string_view::npos ? -1 : static_cast<long>(pos)) << "\n";
std::vector<std::string> words{"build", "with", "rust"};
std::string joined;
for (const auto &w : words) joined += (joined.empty() ? "" : "-") + w;
std::cout << joined << "\n";
// grow a string with +=
std::string out;
for (int i = 1; i <= 3; i++) {
out += std::to_string(i);
out += ';';
}
std::cout << out << "\n";
}
$ g++ -std=c++20 -Wall -Wextra main.cpp -o main && ./main
[name=Ana, age=30, city=Lisbon]
 name -> Ana
  age -> 30
 city -> Lisbon
next year: 31
NAME=ANA | AGE=30 | CITY=LISBON
starts with name: true
position of age: 10
build-with-rust
1;2;3;

What's different

  • trim, split, split_once and friends return &str views into the original text. Cutting allocates and copies no text. You get a new String only when you ask for one with .to_string() or .to_owned().
  • Methods that have to change the text, like to_uppercase and replace, return a new String. You can tell from the signature: -> String means new memory, -> &str means a view.
  • find returns Option<usize>: Some(10) or None. Go's strings.Index, Java's indexOf and Python's str.find return -1 instead; here the type makes you handle the missing case.
  • "30".parse() works for any type that implements FromStr: numbers, bools, IP addresses, your own types. The type you write on the left (let age: u32) picks which one.
  • split is lazy: it finds the next piece only when you ask for it. That's why it can't be indexed like a list. See the gotcha.
  • To build text in a loop, push_str onto one String. It grows in place, like a string builder. format! makes a new String from a format string.
  • Need regular expressions? They're not in std. The usual choice is the regex crate, maintained by the Rust project's regex team. Regex::new returns a Result.
Gotcha

Taking the first piece of a split with [0]

Looks fine: in Go, Kotlin, Python, TypeScript, Java and C# it runs fine; in C it crashes at runtime. Rust won't compile it.

Works in Go. In Rust it's just a rule.Works in Kotlin. In Rust it's just a rule.Works in Python. In Rust it's just a rule.Works in TypeScript. In Rust it's just a rule.Works in Java. In Rust it's just a rule.Works in C#. In Rust it's just a rule.Broken in C. Rust catches it.C++ refuses this too. Same idea, different words.
Rustsrc/main.rs✗ won't compile
fn main() {
let line = "ana,30,lisbon";
let parts = line.split(',');
let name = parts[0];
println!("{name}");
}
package main
import (
"fmt"
"strings"
)
func main() {
line := "ana,30,lisbon"
parts := strings.Split(line, ",")
name := parts[0]
fmt.Println(name)
}
$ go run main.go✓ runs
ana
fun main() {
val line = "ana,30,lisbon"
val parts = line.split(",")
val name = parts[0]
println(name)
}
$ kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar✓ runs
ana
line = "ana,30,lisbon"
parts = line.split(",")
name = parts[0]
print(name)
$ python3 main.py✓ runs
ana
const line = "ana,30,lisbon";
const parts = line.split(",");
const first = parts[0];
console.log(first);
$ tsc --strict --module nodenext --types node main.ts && node main.js✓ runs
ana
public class Main {
public static void main(String[] args) {
String line = "ana,30,lisbon";
String[] parts = line.split(",");
String name = parts[0];
System.out.println(name);
}
}
$ javac Main.java && java Main✓ runs
ana
var line = "ana,30,lisbon";
var parts = line.Split(',');
var name = parts[0];
Console.WriteLine(name);
$ dotnet run✓ runs
ana
#include <stdio.h>
#include <string.h>
int main(void) {
char *line = "ana,30,lisbon";
char *name = strtok(line, ",");
printf("%s\n", name);
return 0;
}
$ gcc -fsanitize=address -g main.c -o main && ./main! crashes at runtime
AddressSanitizer:DEADLYSIGNAL
=================================================================
==1==ERROR: AddressSanitizer: SEGV on unknown address 0x············ (pc 0x············ bp 0x············ sp 0x············ T0)
==1==The signal is caused by a WRITE memory access.
    #0 0x············ in __strtok_r (/lib/aarch64-linux-gnu/libc.so.6+0xa0ae0)
    #1 0x············ in strtok ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors.inc:662
    #2 0x············ in main main.c:6
    #3 0x············  (/lib/aarch64-linux-gnu/libc.so.6+0x22258)
    #4 0x············ in __libc_start_main (/lib/aarch64-linux-gnu/libc.so.6+0x22338)
    #5 0x············ in _start (main+0x9ec)

AddressSanitizer can not provide additional info.
SUMMARY: AddressSanitizer: SEGV (/lib/aarch64-linux-gnu/libc.so.6+0xa0ae0) in __strtok_r
==1==ABORTING
C++✗ refuses too

C++ refuses this too: std::views::split is a lazy view like Rust's split, so it has no operator[], and g++ prints a long list of unmet constraints.

#include <iostream>
#include <ranges>
#include <string_view>
int main() {
std::string_view line = "ana,30,lisbon";
auto parts = line | std::views::split(',');
auto name = parts[0];
std::cout << std::string_view(name.begin(), name.end()) << "\n";
}
main.cpp: In function ‘int main()’:
main.cpp:8:22: error: no match for ‘operator[]’ (operand types are ‘std::ranges::split_view<std::basic_string_view<char>, std::ranges::single_view<char> >’ and ‘int’)
    8 |     auto name = parts[0];
      |                      ^
In file included from /usr/include/c++/14/tuple:44,
                 from /usr/include/c++/14/bits/uses_allocator_args.h:39,
                 from /usr/include/c++/14/bits/memory_resource.h:41,
                 from /usr/include/c++/14/string:68,
                 from /usr/include/c++/14/bits/locale_classes.h:40,
                 from /usr/include/c++/14/bits/ios_base.h:41,
                 from /usr/include/c++/14/ios:44,
                 from /usr/include/c++/14/ostream:40,
                 from /usr/include/c++/14/iostream:41,
                 from main.cpp:1:
/usr/include/c++/14/bits/ranges_util.h:187:9: note: candidate: ‘template<class _Range>  requires  random_access_range<_Range> constexpr decltype(auto) std::ranges::view_interface<_Derived>::operator[](std::ranges::range_difference_t<_Range>) [with _Derived = std::ranges::split_view<std::basic_string_view<char>, std::ranges::single_view<char> >]’
  187 |         operator[](range_difference_t<_Range> __n)
      |         ^~~~~~~~
/usr/include/c++/14/bits/ranges_util.h:187:9: note:   template argument deduction/substitution failed:
/usr/include/c++/14/bits/ranges_util.h:187:9: note: constraints not satisfied
In file included from /usr/include/c++/14/compare:40,
                 from /usr/include/c++/14/bits/char_traits.h:56,
                 from /usr/include/c++/14/ios:42:
/usr/include/c++/14/concepts: In substitution of ‘template<class _Range>  requires  random_access_range<_Range> constexpr decltype(auto) std::ranges::view_interface<std::ranges::split_view<std::basic_string_view<char>, std::ranges::single_view<char> > >::operator[](std::ranges::range_difference_t<_Range>) [with _Range = std::ranges::split_view<std::basic_string_view<char>, std::ranges::single_view<char> >]’:
main.cpp:8:24:   required from here
    8 |     auto name = parts[0];
      |                        ^
/usr/include/c++/14/concepts:74:13:   required for the satisfaction of ‘derived_from<typename std::__detail::__iter_concept_impl<_Iter>::type, std::bidirectional_iterator_tag>’ [with _Iter = std::ranges::split_view<std::basic_string_view<char, std::char_traits<char> >, std::ranges::single_view<char> >::_Iterator]
/usr/include/c++/14/bits/iterator_concepts.h:685:13:   required for the satisfaction of ‘bidirectional_iterator<decltype (std::ranges::__access::__begin(declval<_Container&>()))>’ [with _Tp = std::ranges::split_view<std::basic_string_view<char, std::char_traits<char> >, std::ranges::single_view<char> >]
/usr/include/c++/14/bits/ranges_base.h:597:13:   required for the satisfaction of ‘bidirectional_range<_Tp>’ [with _Tp = std::ranges::split_view<std::basic_string_view<char, std::char_traits<char> >, std::ranges::single_view<char> >]
/usr/include/c++/14/bits/ranges_base.h:602:13:   required for the satisfaction of ‘random_access_range<_Range>’ [with _Range = std::ranges::split_view<std::basic_string_view<char, std::char_traits<char> >, std::ranges::single_view<char> >]
/usr/include/c++/14/concepts:74:28: note:   ‘std::bidirectional_iterator_tag’ is not a base of ‘std::forward_iterator_tag’
   74 |     concept derived_from = __is_base_of(_Base, _Derived)
      |                            ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~
/usr/include/c++/14/bits/ranges_util.h:192:9: note: candidate: ‘template<class _Range>  requires  random_access_range<_Range> constexpr decltype(auto) std::ranges::view_interface<_Derived>::operator[](std::ranges::range_difference_t<_Range>) const [with _Derived = std::ranges::split_view<std::basic_string_view<char>, std::ranges::single_view<char> >]’
  192 |         operator[](range_difference_t<_Range> __n) const
      |         ^~~~~~~~
/usr/include/c++/14/bits/ranges_util.h:192:9: note:   template argument deduction/substitution failed:
/usr/include/c++/14/bits/ranges_util.h:192:9: note: constraints not satisfied
In file included from /usr/include/c++/14/string_view:56,
                 from /usr/include/c++/14/bits/basic_string.h:47,
                 from /usr/include/c++/14/string:54:
/usr/include/c++/14/bits/ranges_base.h: In substitution of ‘template<class _Range>  requires  random_access_range<_Range> constexpr decltype(auto) std::ranges::view_interface<std::ranges::split_view<std::basic_string_view<char>, std::ranges::single_view<char> > >::operator[](std::ranges::range_difference_t<_Range>) const [with _Range = std::ranges::split_view<std::basic_string_view<char>, std::ranges::single_view<char> >]’:
main.cpp:8:24:   required from here
    8 |     auto name = parts[0];
      |                        ^
/usr/include/c++/14/bits/ranges_base.h:499:13:   required for the satisfaction of ‘range<_Tp>’ [with _Tp = const std::ranges::split_view<std::basic_string_view<char, std::char_traits<char> >, std::ranges::single_view<char> >]
/usr/include/c++/14/bits/ranges_base.h:588:13:   required for the satisfaction of ‘input_range<_Tp>’ [with _Tp = const std::ranges::split_view<std::basic_string_view<char, std::char_traits<char> >, std::ranges::single_view<char> >]
/usr/include/c++/14/bits/ranges_base.h:592:13:   required for the satisfaction of ‘forward_range<_Tp>’ [with _Tp = const std::ranges::split_view<std::basic_string_view<char, std::char_traits<char> >, std::ranges::single_view<char> >]
/usr/include/c++/14/bits/ranges_base.h:597:13:   required for the satisfaction of ‘bidirectional_range<_Tp>’ [with _Tp = const std::ranges::split_view<std::basic_string_view<char, std::char_traits<char> >, std::ranges::single_view<char> >]
/usr/include/c++/14/bits/ranges_base.h:602:13:   required for the satisfaction of ‘random_access_range<_Range>’ [with _Range = const std::ranges::split_view<std::basic_string_view<char, std::char_traits<char> >, std::ranges::single_view<char> >]
/usr/include/c++/14/bits/ranges_base.h:499:21:   in requirements with ‘_Tp& __t’ [with _Tp = const std::ranges::split_view<std::basic_string_view<char, std::char_traits<char> >, std::ranges::single_view<char> >]
/usr/include/c++/14/bits/ranges_base.h:501:22: note: the required expression ‘std::ranges::_Cpo::begin(__t)’ is invalid
  501 |         ranges::begin(__t);
      |         ~~~~~~~~~~~~~^~~~~
/usr/include/c++/14/bits/ranges_base.h:502:20: note: the required expression ‘std::ranges::_Cpo::end(__t)’ is invalid
  502 |         ranges::end(__t);
      |         ~~~~~~~~~~~^~~~~
cc1plus: note: set ‘-fconcepts-diagnostics-depth=’ to at least 2 for more detail
What does the compiler say? Guess first, then open.
$ cargo buildWhat is E0608?
error[E0608]: cannot index into a value of type `std::str::Split<'_, char>`
 --> src/main.rs:4:21
  |
4 |     let name = parts[0];
  |                     ^^^
What happens in Go

Go runs this and prints ana. strings.Split builds a whole slice right away, so [0] works.

What happens in Kotlin

Kotlin runs this and prints ana. split builds a whole List right away, so [0] works.

What happens in Python

Python prints ana. split builds a whole list right away, so [0] works.

What happens in TypeScript

Prints ana. split returns a real array, so [0] gives the first piece.

What happens in Java

split returns a plain String array, so parts[0] is ana. One surprise: split takes a regex, so split(".") or split("|") won't do what you expect.

What happens in C#

Split returns a plain string array, so parts[0] is ana. Every call builds a new array and new strings for each piece.

What happens in C

C has no split, so strtok is the natural pick, and it writes into the text it splits. Here that text is a string literal in read-only memory, so gcc says nothing and AddressSanitizer reports a SEGV inside strtok.

What happens in C++

C++ refuses this too: std::views::split is a lazy view like Rust's split, so it has no operator[], and g++ prints a long list of unmet constraints.

What Rust sees

split doesn't give you a list. It gives you an iterator that finds pieces one at a time as you ask, and an iterator has no [0].

Why Rust says no

Being lazy means split allocates nothing until you decide what you need. If you only want the first piece, .next() gets it without building a list at all. If you want to index around, .collect() into a Vec<&str>: that allocates one small list of views, and still copies none of the text.

The fix

Compiles and runs.

Rust, fixedsrc/main.rsRun ↗
fn main() {
let line = "ana,30,lisbon";
// only need the first piece? no list needed
let name = line.split(',').next().unwrap_or("");
println!("{name}");
// want to index around? collect into a Vec of views
let parts: Vec<&str> = line.split(',').collect();
println!("{} is {}", parts[0], parts[1]);
}
$ cargo run
ana
ana is 30

The toolkit

Outputs come from Linux on 64-bit ARM with Rust 1.99.1, debug build. Other languages ran in the same image: Go 1.27, Java 25, Kotlin 2.4, .NET 10, Python 3.13, Node 24, GCC 14.

Strings explains the types: String, &str, UTF-8, bytes and chars. This page is the everyday toolkit on top of them: cut text apart, find things, turn text into numbers, and compare it. Most methods return a view: a &str that points into the original text instead of copying it.

Your text calls in Rust

Your language’s table comes first; the rest of the page uses Rust’s names. The Rust side is asserted in the lesson’s source, and the code beside a table is the other language’s real run.

Patterns: one idea behind every method

Most methods that look inside a string take a pattern. According to the str docs, it can be a &str, a char, a slice of chars (an array works too), or a function or closure that decides for one char (a closure is a small function written in place, like |c: char| c.is_ascii_digit()). The same kinds work in split, find, trim_matches, replace, matches and starts_with:

Rustsrc/main.rsRun ↗
fn main() {
let text = "ab1,cd22;ef";
// The same method with five kinds of pattern.
println!("char: {:?}", text.split(',').collect::<Vec<_>>());
println!("&str: {:?}", text.split(",c").collect::<Vec<_>>());
println!(
"char array: {:?}",
text.split([',', ';']).collect::<Vec<_>>()
);
println!(
"closure: {:?}",
text.split(|c: char| c.is_ascii_digit()).collect::<Vec<_>>()
);
println!(
"fn item: {:?}",
text.split(char::is_numeric).collect::<Vec<_>>()
);
// Every method that searches takes the same kinds.
println!("find a digit: {:?}", text.find(char::is_numeric));
println!("rfind , or ;: {:?}", text.rfind([',', ';']));
println!("digits counted: {}", text.matches(char::is_numeric).count());
println!("trim_matches: {:?}", ",;ab,;".trim_matches([',', ';']));
println!(
"starts with a letter: {}",
text.starts_with(|c: char| c.is_alphabetic())
);
}
$ cargo run
char:       ["ab1", "cd22;ef"]
&str:       ["ab1", "d22;ef"]
char array: ["ab1", "cd22", "ef"]
closure:    ["ab", ",cd", "", ";ef"]
fn item:    ["ab", ",cd", "", ";ef"]
find a digit:   Some(2)
rfind , or ;:   Some(8)
digits counted: 3
trim_matches:   "ab"
starts with a letter: true

A pattern is never a regular expression (What std leaves out).

Cutting text apart

Most split methods return iterators (Iterators), so nothing is cut until you ask for a piece. (split_once returns an Option of a pair.) Every row of the table is asserted in the lesson’s source:

Call Pieces
"a,b,c".split(',') a b c
"a,b,".split(',') a b ""
"a,b,".split_terminator(',') a b
"a,b,c".splitn(2, ',') a b,c
"a,b,c".rsplitn(2, ',') c a,b
"a,b,c".rsplit(',') c b a
"a\nb\n".split_inclusive('\n') a\n b\n
" a b\t".split_whitespace() a b
"a\r\nb\n".lines() a b
"k=v=w".split_once('=') Some(("k", "v=w"))
"k=v=w".rsplit_once('=') Some(("k=v", "w"))
"kvw".split_once('=') None

Three edge cases cause most surprises:

Rustsrc/main.rsRun ↗
fn main() {
// A separator at the end gives an empty last piece.
println!("split(','): {:?}", "a,b,".split(',').collect::<Vec<_>>());
println!(
"split_terminator(','): {:?}",
"a,b,".split_terminator(',').collect::<Vec<_>>()
);
// Nothing to split still gives one piece, but only for split(sep).
println!("\"\".split(','): {:?}", "".split(',').collect::<Vec<_>>());
println!(
"\"\".split_whitespace(): {:?}",
"".split_whitespace().collect::<Vec<_>>()
);
// Limits: the last piece keeps the rest.
println!(
"splitn(2, '='): {:?}",
"k=v=w".splitn(2, '=').collect::<Vec<_>>()
);
println!(
"rsplitn(2, '='): {:?}",
"k=v=w".rsplitn(2, '=').collect::<Vec<_>>()
);
println!("rsplit_once('='): {:?}", "k=v=w".rsplit_once('='));
// Keep the separator at the end of each piece.
println!(
"split_inclusive('\\n'): {:?}",
"one\ntwo\n".split_inclusive('\n').collect::<Vec<_>>()
);
// split_whitespace uses Unicode White_Space: U+0085 splits; U+001C and U+FEFF do not.
for sep in ['\u{85}', '\u{1c}', '\u{feff}'] {
let text = format!("a{sep}b");
println!(
"split_whitespace around U+{:04X}: {:?}",
sep as u32,
text.split_whitespace().collect::<Vec<_>>()
);
}
// lines is not split('\n').
let text = "foo\r\nbar\n\nbaz\r";
println!("lines: {:?}", text.lines().collect::<Vec<_>>());
println!("split('\\n'): {:?}", text.split('\n').collect::<Vec<_>>());
println!(
"lines, lone \\r inside: {:?}",
"a\rb\r\nc\n".lines().collect::<Vec<_>>()
);
println!(
"lines on text ending in \\n: {:?}",
"one\ntwo\n".lines().collect::<Vec<_>>()
);
}
$ cargo run
split(','): ["a", "b", ""]
split_terminator(','): ["a", "b"]
"".split(','): [""]
"".split_whitespace(): []
splitn(2, '='): ["k", "v=w"]
rsplitn(2, '='): ["w", "k=v"]
rsplit_once('='): Some(("k=v", "w"))
split_inclusive('\n'): ["one\n", "two\n"]
split_whitespace around U+0085: ["a", "b"]
split_whitespace around U+001C: ["a\u{1c}b"]
split_whitespace around U+FEFF: ["a\u{feff}b"]
lines: ["foo", "bar", "", "baz\r"]
split('\n'): ["foo\r", "bar", "", "baz\r"]
lines, lone \r inside: ["a\rb", "c"]
lines on text ending in \n: ["one", "two"]
  • A separator at the end gives an empty last piece. split_terminator drops it.
  • "".split(',') gives one empty piece, but "".split_whitespace() gives none.
  • lines is not split('\n'). It strips a \r before a \n, and gives no empty last line for a final newline. A lone \r stays in the line.

split_whitespace splits on Unicode white space (U+0085 yes, U+001C and U+FEFF no); split_ascii_whitespace only on ASCII ones.

Every piece borrows the original, so it cannot outlive it; copy one with to_string() to keep it (Borrowing and Lifetimes explain why).

Trimming and stripping

trim removes Unicode white space from both ends, including characters your terminal may not show; trim_ascii only ASCII ones. The trim_*_matches methods take a pattern and remove it repeatedly; the strip_* methods remove it once, and tell you whether it was there. A &str pattern is matched as a whole text: trim_start_matches("xa") removes the text xa, not the characters x and a. For a set of characters, pass an array:

Rustsrc/main.rsRun ↗
fn main() {
// trim removes Unicode White_Space, which includes a no-break space and
// the wide space used in Japanese text. trim_ascii removes only ASCII ones.
let padded = "\u{a0}\u{3000}hi\t\n";
println!("trim: {:?}", padded.trim());
println!("trim_ascii: {:?}", padded.trim_ascii());
// A control character is not White_Space, so trim keeps it.
println!("trim on U+0001: {:?}", "\u{1}hi\u{1}".trim());
// trim_*_matches removes the pattern again and again; strip_* removes it once.
println!(
"trim_start_matches('/'): {:?}",
"///a/b".trim_start_matches('/')
);
println!(
"strip_prefix(\"/\"): {:?}",
"///a/b".strip_prefix("/")
);
// A &str pattern is matched as a whole text: only the text "xa" goes.
// To cut a SET of characters, pass an array of chars.
println!(
"trim_start_matches(\"xa\"): {:?} {:?}",
"xxabxx".trim_start_matches("xa"),
"xaxab".trim_start_matches("xa")
);
println!(
"trim_start_matches(['x', 'a']): {:?}",
"xxabxx".trim_start_matches(['x', 'a'])
);
// A closure says which characters to cut away.
println!(
"trim_end_matches(digits): {:?}",
"build-42".trim_end_matches(char::is_numeric)
);
println!(
"trim_matches(not alphanumeric): {:?}",
"(\"hello!\")".trim_matches(|c: char| !c.is_alphanumeric())
);
// strip_suffix tells you whether the suffix was there.
for name in ["notes.md", "notes.txt"] {
match name.strip_suffix(".md") {
Some(stem) => println!("{name}: markdown, stem {stem:?}"),
None => println!("{name}: not markdown"),
}
}
}
$ cargo run
trim:       "hi"
trim_ascii: "\u{a0}\u{3000}hi"
trim on U+0001: "\u{1}hi\u{1}"
trim_start_matches('/'): "a/b"
strip_prefix("/"):       Some("//a/b")
trim_start_matches("xa"):       "xxabxx" "b"
trim_start_matches(['x', 'a']): "bxx"
trim_end_matches(digits): "build-"
trim_matches(not alphanumeric): "hello"
notes.md: markdown, stem "notes"
notes.txt: not markdown

Finding and replacing

find gives a byte offset. A match starts on a character, so slicing at an offset that find or match_indices returned does not panic. To show a person a column, count chars up to it. replace changes every match, and replacen(pattern, to, n) the first n:

Rustsrc/main.rsRun ↗
fn main() {
let line = "naïve café, naïve bar";
// match_indices gives every match with its byte position.
println!(
"match_indices: {:?}",
line.match_indices("naïve").collect::<Vec<_>>()
);
// The last match, then the text on either side of it.
if let Some(at) = line.rfind("naïve") {
println!("before: {:?}", &line[..at]);
println!("from: {:?}", &line[at..]);
}
// find returns bytes. A column for a person is a count of chars.
let at = line.find("café").unwrap();
println!("byte offset: {at}");
println!("char column: {}", line[..at].chars().count());
// replace takes a pattern too; replacen stops after n.
println!("replace: {:?}", line.replace("naïve", "naive"));
println!("replacen: {:?}", line.replacen("naïve", "naive", 1));
println!("closure: {:?}", line.replace(|c: char| !c.is_ascii(), "?"));
// A file extension is the part after the LAST dot.
let file = "archive.tar.gz";
println!("rsplit_once('.'): {:?}", file.rsplit_once('.'));
println!(
"find('.') slice: {:?}",
&file[file.find('.').unwrap() + 1..]
);
}
$ cargo run
match_indices: [(0, "naïve"), (14, "naïve")]
before: "naïve café, "
from:   "naïve bar"
byte offset: 7
char column: 6
replace: "naive café, naive bar"
replacen: "naive café, naïve bar"
closure: "na?ve caf?, na?ve bar"
rsplit_once('.'): Some(("archive.tar", "gz"))
find('.') slice:  "tar.gz"

rsplit_once('.') suits a file extension: it cuts at the last dot, find('.') at the first, as the last two lines show. The first exercise builds on it.

Text to numbers

parse turns text into any type that implements FromStr. The error type belongs to the target type, and its Display text is the message you can show a user:

Rustsrc/main.rsRun ↗
fn main() {
// What parse::<i32> accepts and refuses. The whole text must be the number.
for text in ["42", "+42", "-0", " 42", "42 ", "1_000", "4.0", "0x1f", ""] {
println!("{text:?}: {:?}", text.parse::<i32>());
}
// The target type decides what fits.
println!(
"200 as i16, u8, i8: {:?} {:?} {:?}",
"200".parse::<i16>(),
"200".parse::<u8>(),
"200".parse::<i8>()
);
// Out of range is an error for the target type, not a wrap-around.
println!("300 as u8: {:?}", "300".parse::<u8>());
println!("-1 as u8: {:?}", "-1".parse::<u8>());
println!(
"3000000000 as i32, i64: {:?} {:?}",
"3000000000".parse::<i32>(),
"3000000000".parse::<i64>()
);
println!("-129 as i8: {:?}", "-129".parse::<i8>());
// The error type has a message (Display) and a kind you can match on.
let err = "x1".parse::<i32>().unwrap_err();
println!("message: {err}");
println!("kind: {:?}", err.kind());
// Other FromStr types.
println!(
"f64: {:?} {:?} {:?} {:?}",
"1e3".parse::<f64>(),
".5".parse::<f64>(),
"NaN".parse::<f64>(),
"inf".parse::<f64>()
);
println!("f64 with a comma: {:?}", "1,5".parse::<f64>());
println!(
"bool: {:?} {:?}",
"true".parse::<bool>(),
"True".parse::<bool>()
);
println!("char: {:?} {:?}", "x".parse::<char>(), "xy".parse::<char>());
println!("radix 16: {:?}", i32::from_str_radix("ff", 16));
println!("Arabic-Indic three: {:?}", "٣".parse::<i32>());
}
$ cargo run
"42": Ok(42)
"+42": Ok(42)
"-0": Ok(0)
" 42": Err(ParseIntError { kind: InvalidDigit })
"42 ": Err(ParseIntError { kind: InvalidDigit })
"1_000": Err(ParseIntError { kind: InvalidDigit })
"4.0": Err(ParseIntError { kind: InvalidDigit })
"0x1f": Err(ParseIntError { kind: InvalidDigit })
"": Err(ParseIntError { kind: Empty })
200 as i16, u8, i8: Ok(200) Ok(200) Err(ParseIntError { kind: PosOverflow })
300 as u8:  Err(ParseIntError { kind: PosOverflow })
-1 as u8:   Err(ParseIntError { kind: InvalidDigit })
3000000000 as i32, i64: Err(ParseIntError { kind: PosOverflow }) Ok(3000000000)
-129 as i8: Err(ParseIntError { kind: NegOverflow })
message: invalid digit found in string
kind:    InvalidDigit
f64: Ok(1000.0) Ok(0.5) Ok(NaN) Ok(inf)
f64 with a comma: Err(ParseFloatError { kind: Invalid })
bool: Ok(true) Err(ParseBoolError)
char: Ok('x') Err(ParseCharError { kind: TooManyChars })
radix 16: Ok(255)
Arabic-Indic three: Err(ParseIntError { kind: InvalidDigit })
  • The whole text must be the number. Spaces, _, a . for an integer and 0x all fail. A leading + is accepted.
  • A number outside the target type is an error (PosOverflow, NegOverflow), not a wrap-around: 3000000000 fits an i64, not an i32. -1 for a u8 is an invalid digit.
  • Floats accept 1e3, .5, NaN and inf, and refuse a comma as the decimal mark: std has no locale.
  • "٣".parse::<i32>() fails: only ASCII digits count.
  • bool accepts exactly true and false, and char exactly one char.

The target type decides the range and the sign: "200" parses as an i16 or a u8 but not an i8. For text a user typed, avoid unwrap_or(0): it makes "abc" and "0" look the same.

? passes a parse error up to the caller (Errors). With split_once, a small parser is a few lines:

Rustsrc/main.rsRun ↗
use std::num::ParseIntError;
// "3,4" -> (3, 4). split_once finds the comma, parse does the numbers,
// and ? hands the first parse error to the caller.
fn parse_pair(text: &str) -> Result<(i32, i32), ParseIntError> {
let (left, right) = text.split_once(',').unwrap_or((text, ""));
Ok((left.trim().parse()?, right.trim().parse()?))
}
fn main() {
println!("{:?}", parse_pair("3,4"));
println!("{:?}", parse_pair(" 3 , -4 "));
println!("{:?}", parse_pair("3"));
match parse_pair("3,x") {
Ok(pair) => println!("pair {pair:?}"),
Err(e) => println!("error: {e}"),
}
}
$ cargo run
Ok((3, 4))
Ok((3, -4))
Err(ParseIntError { kind: Empty })
error: invalid digit found in string

One char at a time

A char is one Unicode scalar value. Its classification methods come in two families: is_alphabetic, is_numeric and is_alphanumeric follow Unicode, while the is_ascii_* ones accept only ASCII.

Rustsrc/main.rsRun ↗
fn main() {
// Unicode classes versus ASCII classes. U+0663 is the Arabic-Indic digit three.
let three = '٣';
println!("is_numeric: {}", three.is_numeric());
println!("is_ascii_digit: {}", three.is_ascii_digit());
println!("to_digit(10): {:?}", three.to_digit(10));
println!("é is_alphabetic: {}", 'é'.is_alphabetic());
println!("é is_ascii_alphabetic: {}", 'é'.is_ascii_alphabetic());
// to_digit takes a radix up to 36. from_digit goes the other way.
println!("'7'.to_digit(10): {:?}", '7'.to_digit(10));
println!("'f'.to_digit(16): {:?}", 'f'.to_digit(16));
println!("'z'.to_digit(36): {:?}", 'z'.to_digit(36));
println!("'f'.to_digit(10): {:?}", 'f'.to_digit(10));
println!("from_digit(7, 10): {:?}", char::from_digit(7, 10));
// Between chars and numbers. A u8 is always a valid char; a u32 may not be.
println!("'A' as u32: {}", 'A' as u32);
println!("97u8 as char: {}", 97u8 as char);
println!("from_u32(233): {:?}", char::from_u32(233));
println!("from_u32(0xD800): {:?}", char::from_u32(0xD800));
}
$ cargo run
is_numeric:      true
is_ascii_digit:  false
to_digit(10):    None
é is_alphabetic: true
é is_ascii_alphabetic: false
'7'.to_digit(10): Some(7)
'f'.to_digit(16): Some(15)
'z'.to_digit(36): Some(35)
'f'.to_digit(10): None
from_digit(7, 10): Some('7')
'A' as u32:    65
97u8 as char:  a
from_u32(233): Some('é')
from_u32(0xD800): None

Use to_digit(radix), where the radix is the base (10, 16, up to 36), to read a digit, rather than subtracting '0': it returns None for a non-digit. char::from_u32 returns None for numbers that are not valid chars, such as 0xD800, a surrogate (a code reserved for UTF-16 pairs). Strings says when to walk chars and when bytes.

Building text

Strings covers push_str, format!, join and concat. These are the others you will want:

Rustsrc/main.rsRun ↗
use std::fmt::{self, Write};
// A Display impl may fail, and write! passes that on.
struct Broken;
impl fmt::Display for Broken {
fn fmt(&self, _: &mut fmt::Formatter) -> fmt::Result {
Err(fmt::Error)
}
}
fn main() {
// repeat, and collecting chars or pieces into a String.
println!("repeat: {}", "=-".repeat(4));
let shout: String = "quiet".chars().map(|c| c.to_ascii_uppercase()).collect();
println!("collect chars: {shout}");
let glued: String = ["ab", "cd", "ef"].into_iter().collect();
println!("collect pieces: {glued}");
// join works on a Vec<String> as well as on &str items.
let owned: Vec<String> = vec!["x".to_string(), "y".to_string()];
println!("join: {}", owned.join(" + "));
// extend appends many pieces to one String.
let mut line = String::from("ids:");
line.extend([" 1", " 2", " 3"]);
println!("extend: {line}");
// write! formats into an existing String, with no temporary String.
let mut report = String::new();
for (name, qty) in [("tea", 2), ("jam", 10)] {
writeln!(report, "{name:<5}{qty:>3}").unwrap();
}
print!("{report}");
// Writing into a String is not what fails; a Display impl can.
println!("write! of a number: {:?}", write!(report, "{}", 7));
println!("write! of Broken: {:?}", write!(report, "{}", Broken));
}
$ cargo run
repeat:  =-=-=-=-
collect chars:  QUIET
collect pieces: abcdef
join:    x + y
extend:  ids: 1 2 3
tea    2
jam   10
write! of a number: Ok(())
write! of Broken:   Err(Error)

collect::<String>() turns an iterator of chars, &strs or Strings into one String. write! into a String needs use std::fmt::Write and returns a Result. Writing into the String does not fail; the Result is Err only when a Display impl you format returns an error, as the last line shows (Formatting goes further).

Going deeper

Case is more than A to Z

to_uppercase and to_lowercase follow Unicode, which is why str returns a String and char returns an iterator: one char can become several.

Rustsrc/main.rsRun ↗
fn main() {
// One char can become several: to_uppercase returns an iterator of chars.
let up = 'ß'.to_uppercase();
println!("'ß'.to_uppercase(): {up} ({} chars)", up.len());
println!("'ß'.to_ascii_uppercase(): {}", 'ß'.to_ascii_uppercase());
println!("\"straße\".to_uppercase(): {}", "straße".to_uppercase());
// The str method knows that a Greek capital sigma ends a word as ς.
println!("\"ΑΣ\".to_lowercase(): {}", "ΑΣ".to_lowercase());
let per_char: String = "ΑΣ".chars().flat_map(char::to_lowercase).collect();
println!("char by char: {per_char}");
// No locale: "I" lowercases to "i" everywhere. Turkish wants a dotless ı.
println!("\"I\".to_lowercase(): {}", "I".to_lowercase());
println!(
"\"İ\".to_lowercase() chars: {:?}",
"İ".to_lowercase().chars().collect::<Vec<_>>()
);
// Comparing without regard to case: ASCII only, or lowercase both sides.
println!(
"ascii-ignore-case(\"Rust\", \"RUST\"): {}",
"Rust".eq_ignore_ascii_case("RUST")
);
println!(
"ascii-ignore-case(\"É\", \"é\"): {}",
"É".eq_ignore_ascii_case("é")
);
println!(
"lowercase both(\"É\", \"é\"): {}",
"É".to_lowercase() == "é".to_lowercase()
);
println!(
"lowercase both(\"STRASSE\", \"straße\"): {}",
"STRASSE".to_lowercase() == "straße".to_lowercase()
);
}
$ cargo run
'ß'.to_uppercase(): SS (2 chars)
'ß'.to_ascii_uppercase(): ß
"straße".to_uppercase(): STRASSE
"ΑΣ".to_lowercase():     ας
char by char:            ασ
"I".to_lowercase():      i
"İ".to_lowercase() chars: ['i', '\u{307}']
ascii-ignore-case("Rust", "RUST"):    true
ascii-ignore-case("É", "é"):          false
lowercase both("É", "é"):             true
lowercase both("STRASSE", "straße"):  false
  • A Greek sigma is σ or ς by its place in the word. The docs say str::to_lowercase handles this and char::to_lowercase does not; the run shows both.
  • Neither method reads a locale. The docs say the Turkish and Azeri rules for I are not handled: "I" becomes i everywhere, and İ becomes i plus a combining dot.
  • eq_ignore_ascii_case compares ASCII only: É and é differ. Lowercasing both sides equates them, yet STRASSE and straße still differ: lowercase ß stays ß.

Unicode’s tool for this is case folding. str has to_casefold_unnormalized, and eq_ignore_case_unnormalized to compare without allocating. On 1.99.1 both are nightly-only: the nightly compiler (the experimental channel for unfinished features) accepts them, and the stable one refuses:

Rustsrc/main.rs✗ won't compile
fn main() {
// Case folding is what Unicode defines for comparing text without case.
// std has a method for it, but on Rust 1.99.1 it is nightly-only.
let a = "Straße".to_casefold_unnormalized();
let b = "STRASSE".to_casefold_unnormalized();
println!("equal ignoring case: {}", a == b);
}
$ cargo buildWhat is E0658?
error[E0658]: use of unstable library feature `casefold`
 --> src/main.rs:4:22
  |
4 |     let a = "Straße".to_casefold_unnormalized();
  |                      ^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: see issue #157000 <https://github.com/rust-lang/rust/issues/157000> for more information

error[E0658]: use of unstable library feature `casefold`
 --> src/main.rs:5:23
  |
5 |     let b = "STRASSE".to_casefold_unnormalized();
  |                       ^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: see issue #157000 <https://github.com/rust-lang/rust/issues/157000> for more information

So for a full caseless comparison you lowercase both sides (good for most text) or use a crate.

Equal-looking text that is not equal

Unicode can write é as one char (U+00E9) or as an e plus a combining accent (U+0301), a char that attaches to the one before it. Both display as é. Converting both to one form is normalization.

Rustsrc/main.rsRun ↗
fn main() {
// Two ways to write "é": one char, or an e followed by a combining accent.
let one = "caf\u{e9}";
let two = "cafe\u{301}";
println!("{one} and {two} look alike");
println!("equal: {}", one == two);
println!("bytes: {} and {}", one.len(), two.len());
println!(
"chars: {} and {}",
one.chars().count(),
two.chars().count()
);
println!("one: {one:?}");
println!("two: {two:?}");
}
$ cargo run
café and café look alike
equal:      false
bytes:      5 and 6
chars:      4 and 5
one: "café"
two: "cafe\u{301}"

== compares bytes, so these differ, and str has no normalization method. The same trouble reaches chars(): a char is not what a reader sees as one letter, a grapheme (Strings). Reversing by chars moves the accent to the wrong letter:

Rustsrc/main.rsRun ↗
fn main() {
let word = "noe\u{301}l"; // n, o, e, a combining acute accent, l
let reversed: String = word.chars().rev().collect();
println!("word: {word} {word:?}");
println!("reversed: {reversed} {reversed:?}");
println!("chars: {}, bytes: {}", word.chars().count(), word.len());
}
$ cargo run
word:     noél  "noe\u{301}l"
reversed: ĺeon  "l\u{301}eon"
chars: 5, bytes: 6

The accent now sits on the l. The chars docs say grapheme clusters are “not provided by Rust’s standard library, check crates.io instead”.

Sorting text

Strings showed that capitals sort first. Comparing bytes has two more surprises: é sorts after z, and file10 before file2. The docs promise that sort_by_cached_key calls the key function at most once per element, which matters when the key allocates:

Rustsrc/main.rsRun ↗
fn main() {
let mut names = vec!["file10", "file2", "Zoë", "éclair", "zebra", "File1"];
// sort compares bytes: capitals first, é after z, "file10" before "file2".
names.sort();
println!("sort: {names:?}");
// A lowercase key fixes the capitals, and nothing else.
// sort_by_cached_key calls the key function once per element.
names.sort_by_cached_key(|n| n.to_lowercase());
println!("sort_by_cached_key: {names:?}");
// The comparison itself, as bytes.
println!("\"é\" > \"z\": {}", "é" > "z");
println!("\"é\" bytes: {:?}", "é".as_bytes());
println!("\"z\" bytes: {:?}", "z".as_bytes());
}
$ cargo run
sort:                 ["File1", "Zoë", "file10", "file2", "zebra", "éclair"]
sort_by_cached_key:   ["File1", "file10", "file2", "zebra", "Zoë", "éclair"]
"é" > "z":   true
"é" bytes:   [195, 169]
"z" bytes:   [122]

é starts with byte 195 and z is 122. Std has no locale-aware or natural-order comparison; for names in a user’s language you need a crate.

Cutting at a byte limit

A database column or a network packet limits bytes, and &text[..max] panics when max falls inside a char. floor_char_boundary, stable since Rust 1.91 (docs), gives the nearest boundary at or below max. A char boundary is not always a letter boundary: the last lines cut a flag, which is two chars, in half.

Rustsrc/main.rsRun ↗
// The longest prefix of `text` that fits in `max` bytes and ends on a char boundary.
fn fit(text: &str, max: usize) -> &str {
&text[..text.floor_char_boundary(max)]
}
fn main() {
let text = "héllo 🦀";
println!("len: {} bytes", text.len());
for max in [0, 1, 2, 3, 8, 9, 100] {
println!("fit({max:>3}): {:?}", fit(text, max));
}
println!("is_char_boundary(2): {}", text.is_char_boundary(2));
println!("is_char_boundary(3): {}", text.is_char_boundary(3));
// A boundary between chars is not always a boundary between letters:
// a flag is two chars (regional indicators), 4 bytes each.
let flag = "🇵🇹";
println!("chars in the flag: {}", flag.chars().count());
println!("fit(flag, 4): {:?}", fit(flag, 4));
}
$ cargo run
len: 11 bytes
fit(  0): ""
fit(  1): "h"
fit(  2): "h"
fit(  3): "hé"
fit(  8): "héllo "
fit(  9): "héllo "
fit(100): "héllo 🦀"
is_char_boundary(2): false
is_char_boundary(3): true
chars in the flag: 2
fit(flag, 4): "🇵"

What std leaves out

The 1.99.1 str and char docs list no stable method for the jobs on the left (the casefold pair is nightly-only). The right column is where people go; each crate is on crates.io.

You need Look at
Regular expressions regex
Grapheme clusters, word boundaries unicode-segmentation
Normalization (NFC joins e and an accent into é; NFD splits them) unicode-normalization
Locale-aware sorting icu_collator (the ICU4X project)
Case folding, locale-specific case icu_casemap (the ICU4X project)

Finding and using crates shows how to add one. regex is maintained by the Rust project’s regex team (team page) but is a separate crate. Its docs say the syntax lacks look-around and backreferences, in exchange for a worst-case search time proportional to pattern size times text size. Regex::new returns a Result, since the pattern can be invalid, and compiling is not cached, so build a regex once. Not run here: the lessons’ runner has no crates.

Practice

Try it

Replace the todo!() and run until the checks print all checks passed. The starters compile and stop at todo!. Plain loops are fine; iterator methods are shorter (Iterators).

A file name's extension

Write extension, which returns the text after the last dot as a &str view. A name with no dot, a name that only starts with a dot (.bashrc), and a name that ends in a dot have none. The checks include archive.tar.gz, .hidden.txt, non-ASCII names, and that the answer points into the input. (The stem is the part before the last dot.)

Starterexercises/extension.rs
/// The extension of a file name: the text after its last dot.
/// A name with no dot, a name whose only dot is the first character (".bashrc",
/// a hidden file), and a name that ends in a dot have no extension.
/// (The stem is the part before the last dot.)
fn extension(name: &str) -> Option<&str> {
todo!("split at the LAST dot, then reject an empty stem or an empty extension")
}
fn main() {
assert_eq!(extension("notes.md"), Some("md"));
assert_eq!(extension("archive.tar.gz"), Some("gz"));
assert_eq!(extension(".hidden.txt"), Some("txt"));
assert_eq!(extension("README"), None);
assert_eq!(extension(".bashrc"), None);
assert_eq!(extension("trailing."), None);
assert_eq!(extension(""), None);
assert_eq!(extension("résumé.pdf"), Some("pdf"));
assert_eq!(extension("photo.ÉTÉ"), Some("ÉTÉ"));
// The answer is a view into the name, not a copy.
let name = String::from("data.csv");
let ext = extension(&name).unwrap();
assert_eq!(ext.as_ptr(), name[5..].as_ptr());
println!("all checks passed");
}

Open in the Rust Playground ↗Run it first: it stops at todo!() with not yet implemented and a hint, and the compiler warns that the parameter is unused. Both are expected. Replace the todo!() with your code and run it again until it prints all checks passed.

Show a solution
One solutionexercises/extension.solution.rsRun ↗
/// The extension of a file name: the text after its last dot.
/// A name with no dot, a name whose only dot is the first character (".bashrc",
/// a hidden file), and a name that ends in a dot have no extension.
/// (The stem is the part before the last dot.)
fn extension(name: &str) -> Option<&str> {
let (stem, ext) = name.rsplit_once('.')?;
if stem.is_empty() || ext.is_empty() {
None
} else {
Some(ext)
}
}
fn main() {
assert_eq!(extension("notes.md"), Some("md"));
assert_eq!(extension("archive.tar.gz"), Some("gz"));
assert_eq!(extension(".hidden.txt"), Some("txt"));
assert_eq!(extension("README"), None);
assert_eq!(extension(".bashrc"), None);
assert_eq!(extension("trailing."), None);
assert_eq!(extension(""), None);
assert_eq!(extension("résumé.pdf"), Some("pdf"));
assert_eq!(extension("photo.ÉTÉ"), Some("ÉTÉ"));
// The answer is a view into the name, not a copy.
let name = String::from("data.csv");
let ext = extension(&name).unwrap();
assert_eq!(ext.as_ptr(), name[5..].as_ptr());
println!("all checks passed");
}
$ cargo run
all checks passed

Real programs use Path::extension, which also suits names that are not valid UTF-8 (Files and I/O); it passes these checks once an empty result becomes None. This exercise is about str and views.

Parse a score list

Turn "Ana: 90, Bo:85 ,Zoë : 7," into pairs of name and u32. Trim, skip empty items, and return the first error as a String: a missing : or the parse error’s own message, prefixed by the name. The checks include a negative score, a number too large, an empty score and padding around a bad item.

Starterexercises/parse-scores.rs
/// "Ana: 90, Bo: 85" -> [("Ana", 90), ("Bo", 85)].
/// Spaces around names and numbers are ignored, and empty items (a trailing
/// comma, two commas in a row) are skipped. The first bad item stops the parse:
/// - no ':' in it: Err(format!("missing ':' in {item:?}")), item already trimmed
/// - a score that is not a u32: Err(format!("{name}: {e}")) with the parse error's message
fn parse_scores(text: &str) -> Result<Vec<(String, u32)>, String> {
todo!(
"split on ',', trim, skip empty items, split_once(':') with ok_or_else, parse::<u32>() with map_err"
)
}
fn main() {
let ok = parse_scores("Ana: 90, Bo:85 ,Zoë : 7,").unwrap();
assert_eq!(
ok,
[
("Ana".to_string(), 90),
("Bo".to_string(), 85),
("Zoë".to_string(), 7)
]
);
assert_eq!(parse_scores(""), Ok(vec![]));
assert_eq!(parse_scores(" , ,"), Ok(vec![]));
assert_eq!(
parse_scores("Ana: 1, Bo: x"),
Err("Bo: invalid digit found in string".to_string())
);
assert_eq!(
parse_scores("Cy: -5"),
Err("Cy: invalid digit found in string".to_string())
);
assert_eq!(
parse_scores("Di: 99999999999"),
Err("Di: number too large to fit in target type".to_string())
);
assert_eq!(
parse_scores("Ed: "),
Err("Ed: cannot parse integer from empty string".to_string())
);
assert_eq!(
parse_scores("Fay 5, Bo: x"),
Err("missing ':' in \"Fay 5\"".to_string())
);
// The item in the message is already trimmed, however it was padded.
assert_eq!(
parse_scores("Ana: 1, Fay 5 ,"),
Err("missing ':' in \"Fay 5\"".to_string())
);
println!("all checks passed");
}

Open in the Rust Playground ↗Run it first: it stops at todo!() with not yet implemented and a hint, and the compiler warns that the parameter is unused. Both are expected. Replace the todo!() with your code and run it again until it prints all checks passed.

Show a solution
One solutionexercises/parse-scores.solution.rsRun ↗
/// "Ana: 90, Bo: 85" -> [("Ana", 90), ("Bo", 85)].
/// Spaces around names and numbers are ignored, and empty items (a trailing
/// comma, two commas in a row) are skipped. The first bad item stops the parse:
/// - no ':' in it: Err(format!("missing ':' in {item:?}")), item already trimmed
/// - a score that is not a u32: Err(format!("{name}: {e}")) with the parse error's message
fn parse_scores(text: &str) -> Result<Vec<(String, u32)>, String> {
let mut scores = Vec::new();
for item in text.split(',') {
let item = item.trim();
if item.is_empty() {
continue;
}
let (name, score) = item
.split_once(':')
.ok_or_else(|| format!("missing ':' in {item:?}"))?;
let name = name.trim();
let score = score
.trim()
.parse::<u32>()
.map_err(|e| format!("{name}: {e}"))?;
scores.push((name.to_string(), score));
}
Ok(scores)
}
fn main() {
let ok = parse_scores("Ana: 90, Bo:85 ,Zoë : 7,").unwrap();
assert_eq!(
ok,
[
("Ana".to_string(), 90),
("Bo".to_string(), 85),
("Zoë".to_string(), 7)
]
);
assert_eq!(parse_scores(""), Ok(vec![]));
assert_eq!(parse_scores(" , ,"), Ok(vec![]));
assert_eq!(
parse_scores("Ana: 1, Bo: x"),
Err("Bo: invalid digit found in string".to_string())
);
assert_eq!(
parse_scores("Cy: -5"),
Err("Cy: invalid digit found in string".to_string())
);
assert_eq!(
parse_scores("Di: 99999999999"),
Err("Di: number too large to fit in target type".to_string())
);
assert_eq!(
parse_scores("Ed: "),
Err("Ed: cannot parse integer from empty string".to_string())
);
assert_eq!(
parse_scores("Fay 5, Bo: x"),
Err("missing ':' in \"Fay 5\"".to_string())
);
// The item in the message is already trimmed, however it was padded.
assert_eq!(
parse_scores("Ana: 1, Fay 5 ,"),
Err("missing ':' in \"Fay 5\"".to_string())
);
println!("all checks passed");
}
$ cargo run
all checks passed

Palindromes with non-ASCII letters

Write is_palindrome: keep the chars that is_alphanumeric accepts, ignore case, compare with the reverse. Letters are precomposed (é is one char): "Été" must be one and "éaè" must not, so ASCII-only shortcuts fail, and so does comparing only the first and last letter. The last check records a limit: é as e plus U+0301 is two chars, the accent is dropped, and that text counts as a palindrome. Fixing it needs normalization (Equal-looking text).

Starterexercises/palindrome.rs
/// Is the text a palindrome? Compare the chars for which `is_alphanumeric` is true,
/// ignoring case, spaces and punctuation. The empty text, and text with no letters,
/// count as one. Letters are precomposed: "é" is one char.
fn is_palindrome(text: &str) -> bool {
todo!("keep the alphanumeric chars, lowercase them, compare with the reverse")
}
fn main() {
assert!(is_palindrome("A man, a plan, a canal: Panama"));
assert!(is_palindrome("Was it a car or a cat I saw?"));
assert!(is_palindrome("12321"));
assert!(is_palindrome(""));
assert!(is_palindrome("?!"));
// Not ASCII: É and é are the same letter, and the e is not dropped.
assert!(is_palindrome("Été"));
assert!(is_palindrome("été"));
assert!(!is_palindrome("éaè"));
assert!(!is_palindrome("Éa"));
assert!(!is_palindrome("ab"));
assert!(!is_palindrome("12 3"));
// Same first and last letter, different middle: comparing only the ends is not enough.
assert!(!is_palindrome("abca"));
assert!(!is_palindrome("1231"));
assert!(!is_palindrome("éaßé"));
// Known limit, not a goal: "é" written as e plus U+0301 is two chars, and the accent
// is not alphanumeric, so it is dropped. This text counts as a palindrome here.
assert!(is_palindrome("e\u{301}ae\u{300}"));
println!("all checks passed");
}

Open in the Rust Playground ↗Run it first: it stops at todo!() with not yet implemented and a hint, and the compiler warns that the parameter is unused. Both are expected. Replace the todo!() with your code and run it again until it prints all checks passed.

Show a solution
One solutionexercises/palindrome.solution.rsRun ↗
/// Is the text a palindrome? Compare the chars for which `is_alphanumeric` is true,
/// ignoring case, spaces and punctuation. The empty text, and text with no letters,
/// count as one. Letters are precomposed: "é" is one char.
fn is_palindrome(text: &str) -> bool {
let letters: Vec<char> = text
.chars()
.filter(|c| c.is_alphanumeric())
.flat_map(char::to_lowercase)
.collect();
letters.iter().eq(letters.iter().rev())
}
fn main() {
assert!(is_palindrome("A man, a plan, a canal: Panama"));
assert!(is_palindrome("Was it a car or a cat I saw?"));
assert!(is_palindrome("12321"));
assert!(is_palindrome(""));
assert!(is_palindrome("?!"));
// Not ASCII: É and é are the same letter, and the e is not dropped.
assert!(is_palindrome("Été"));
assert!(is_palindrome("été"));
assert!(!is_palindrome("éaè"));
assert!(!is_palindrome("Éa"));
assert!(!is_palindrome("ab"));
assert!(!is_palindrome("12 3"));
// Same first and last letter, different middle: comparing only the ends is not enough.
assert!(!is_palindrome("abca"));
assert!(!is_palindrome("1231"));
assert!(!is_palindrome("éaßé"));
// Known limit, not a goal: "é" written as e plus U+0301 is two chars, and the accent
// is not alphanumeric, so it is dropped. This text counts as a palindrome here.
assert!(is_palindrome("e\u{301}ae\u{300}"));
println!("all checks passed");
}
$ cargo run
all checks passed

Read more