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
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}");}[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())}[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)}[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 placeprint(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 inputtry: age = int("30")except ValueError: age = 0print(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.Builderparts = []for i in range(1, 4): parts.append(f"{i};")print("".join(parts))[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 placeconsole.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 throwinglet 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 neededlet out = "";for (let i = 1; i <= 3; i++) { out += `${i};`;}console.log(out);[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); }}[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 viewConsole.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 boolvar 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 piecevar sb = new StringBuilder();for (var i = 1; i <= 3; i++){ sb.Append(i).Append(';');}Console.WriteLine(sb);[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;}[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 inwardstd::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";}[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_onceand friends return&strviews into the original text. Cutting allocates and copies no text. You get a newStringonly when you ask for one with.to_string()or.to_owned().- Methods that have to change the text, like
to_uppercaseandreplace, return a newString. You can tell from the signature:-> Stringmeans new memory,-> &strmeans a view. findreturnsOption<usize>:Some(10)orNone. Go'sstrings.Index, Java'sindexOfand Python'sstr.findreturn -1 instead; here the type makes you handle the missing case."30".parse()works for any type that implementsFromStr: numbers, bools, IP addresses, your own types. The type you write on the left (let age: u32) picks which one.splitis 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_stronto oneString. It grows in place, like a string builder.format!makes a newStringfrom a format string. - Need regular expressions? They're not in std. The usual choice is the
regexcrate, maintained by the Rust project's regex team.Regex::newreturns aResult.
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.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)}ana
fun main() { val line = "ana,30,lisbon" val parts = line.split(",") val name = parts[0] println(name)}ana
line = "ana,30,lisbon"parts = line.split(",")name = parts[0]print(name)ana
const line = "ana,30,lisbon";const parts = line.split(",");const first = parts[0];console.log(first);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); }}ana
var line = "ana,30,lisbon";var parts = line.Split(',');var name = parts[0];Console.WriteLine(name);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;}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==ABORTINGC++ 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 detailWhat does the compiler say? Guess first, then open.
error[E0608]: cannot index into a value of type `std::str::Split<'_, char>` --> src/main.rs:4:21 | 4 | let name = parts[0]; | ^^^
Go runs this and prints ana. strings.Split builds a whole slice right away, so [0] works.
Kotlin runs this and prints ana. split builds a whole List right away, so [0] works.
Python prints ana. split builds a whole list right away, so [0] works.
Prints ana. split returns a real array, so [0] gives the first piece.
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.
Split returns a plain string array, so parts[0] is ana. Every call builds a new array and new strings for each piece.
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.
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.
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].
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.
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]);}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:
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()) );}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:
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<_>>() );}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_terminatordrops it. "".split(',')gives one empty piece, but"".split_whitespace()gives none.linesis notsplit('\n'). It strips a\rbefore a\n, and gives no empty last line for a final newline. A lone\rstays 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:
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"), } }}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 markdownFinding 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:
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..] );}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:
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>());}"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 and0xall fail. A leading+is accepted. - A number outside the target type is an error (
PosOverflow,NegOverflow), not a wrap-around:3000000000fits ani64, not ani32.-1for au8is an invalid digit. - Floats accept
1e3,.5,NaNandinf, and refuse a comma as the decimal mark: std has no locale. "٣".parse::<i32>()fails: only ASCII digits count.boolaccepts exactlytrueandfalse, andcharexactly 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:
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}"), }}Ok((3, 4))
Ok((3, -4))
Err(ParseIntError { kind: Empty })
error: invalid digit found in stringOne 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.
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));}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): NoneUse 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:
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));}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.
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() );}'ß'.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 saystr::to_lowercasehandles this andchar::to_lowercasedoes not; the run shows both. - Neither method reads a locale. The docs say the Turkish and Azeri rules for
Iare not handled:"I"becomesieverywhere, andİbecomesiplus a combining dot. eq_ignore_ascii_casecompares ASCII only:Éandédiffer. Lowercasing both sides equates them, yetSTRASSEandstraßestill 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:
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);}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.
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:?}");}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:
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());}word: noél "noe\u{301}l"
reversed: ĺeon "l\u{301}eon"
chars: 5, bytes: 6The 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:
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());}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.
// 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));}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.)
/// 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
/// 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");}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.
/// "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 messagefn 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
/// "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 messagefn 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");}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).
/// 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
/// 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");}all checks passed
Read more
- The standard library:
str,char,FromStrandPath::extension. - The Rust Book, chapter 8.2: Storing UTF-8 Encoded Text with Strings.
- Rust by Example: Strings and Parse.
- The Unicode Standard, chapter 3 defines case mapping and case folding.
- Crates mentioned:
regex,unicode-segmentation,unicode-normalization,icu_collator,icu_casemap.
Learn Rust is an independent project, not affiliated with the Rust Project or the Rust Foundation. Code and text are under the MIT licence.Source on GitHub.