Parsing out of a string¶
Level: 101 → 201 · working knowledge
One line: "42".parse() is a call to the FromStr ↗ trait, so nothing in the expression says what to build and the compiler asks you — and it hands back a Result, because text is input and input lies.
let annotated: i32 = "42".parse().unwrap(); // the annotation names it
let turbofished = "42".parse::<i32>().unwrap(); // the turbofish names it
Two spellings of the same call, and one of them is always required.
Nothing in .parse() names a type¶
error[E0284]: type annotations needed for `Result<_, _>`
--> parse_without_a_type.rs:2:9
|
2 | let n = "42".parse();
| ^ ----- type must be known at this point
|
= note: cannot satisfy `<_ as FromStr>::Err == _`
help: consider giving `n` an explicit type, where the type for type parameter `F` is specified
|
2 | let n: Result<F, _> = "42".parse();
| ++++++++++++++
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0284`.
parse is generic over its return type, which is the one position inference cannot fill from the arguments — there are no arguments. So the type comes from the annotation, from the turbofish, or from whatever the value flows into: a typed function parameter, a struct field, a return. Any of the three will do, and it is worth knowing there are three, because the turbofish is the ugliest and gets reached for first.
The verified output¶
Verified output of parsing_a_string.rs — regenerated by tools/run_examples.py, never hand-typed.
1. Three ways to say which type to build
annotation let n: i32 = "42".parse() -> 42
turbofish "42".parse::<i32>() -> 42
inferred from the Result's own type -> 42
Nothing in `.parse()` itself names a type, so one of these is
always required. Without one it is E0284, not a default of i32.
2. parse takes the WHOLE string, and it is not a tokenizer
"42" Ok(42)
"+42" Ok(42)
"-42" Ok(-42)
"042" Ok(42)
" 42 " Err — invalid digit found in string
"42 " Err — invalid digit found in string
"4_2" Err — invalid digit found in string
"1,000" Err — invalid digit found in string
"42.0" Err — invalid digit found in string
"" Err — cannot parse integer from empty string
`4_2` is the surprise: the underscore is LITERAL syntax, read by
the compiler, and parse never sees a literal. Trim before parsing.
3. Its digits are ASCII, not Unicode
"١٢٣".chars().all(char::is_numeric) = true
"١٢٣".parse::<i32>() = Err(ParseIntError { kind: InvalidDigit })
Every char says it is a digit and parse still refuses: it reads
b'0'..=b'9' and nothing else. Python's int() accepts these.
4. For an integer, the failure is structured
"" kind=Empty
"forty-two" kind=InvalidDigit
"999999999999" kind=PosOverflow
"-999999999999" kind=NegOverflow
ParseIntError::kind() — stable since 1.55 — is a value you can
match on, so "try again" and "pick a bigger type" are
distinguishable without reading an error message:
"7" -> 7
"" -> nothing was typed
"x" -> that is not a number
"999999999999" -> too big for an i32
5. For a float, it is not
"" -> cannot parse float from empty string
"forty" -> invalid float literal
different errors? true
ParseFloatError HAS a kind field, and it is pub(super) — there is
no accessor on stable, so you get Display and PartialEq and no
match. Comparing against a known error is the only way to ask.
6. Floats forgive more than integers do
"3.14" Ok(3.14)
"3." Ok(3.0)
".5" Ok(0.5)
"1e10" Ok(10000000000.0)
"inf" Ok(inf)
"NaN" Ok(NaN)
"-0" Ok(-0.0)
"3,14" Err — invalid float literal
" 1.0" Err — invalid float literal
The decimal separator is a POINT, always. Rust reads no locale, so
a CSV exported anywhere that writes 3,14 must be fixed before this.
7. Other targets, and another base
"true".parse::<bool>() = Ok(true)
"True".parse::<bool>() = Err(ParseBoolError) <- exact spelling only
"a".parse::<char>() = Ok('a')
i64::from_str_radix("1a2b", 16) = Ok(6699)
from_str_radix is the way in for hex, binary and octal — `parse`
is base 10 and has no argument for anything else.
8. FromStr for your own type, and Display as its mirror
"1.2.3" -> 1.2.3 (round trip: true)
"1.2" -> missing patch
"1.2.3.4" -> trailing "4"
"1.x.3" -> minor: invalid digit found in string (got "x")
Implement FromStr and `.parse::<Version>()` works; implement
Display and `.to_string()` works. One pair of traits, both
directions, and a round trip you can assert on.
What parse forgives¶
It takes the whole string. It is not a tokenizer and it does not skip anything:
| input | parse::<i32>() |
|
|---|---|---|
"42", "+42", "-42" |
Ok |
a leading sign is fine |
"042" |
Ok(42) |
leading zeros are fine |
" 42 ", "42 " |
Err |
whitespace is never trimmed, on either end |
"4_2" |
Err |
the underscore is literal syntax, read by the compiler |
"1,000", "42.0" |
Err |
one stray character fails the whole string |
"" |
Err |
and it is a different error — see below |
The "4_2" row is the one that surprises people, and the reason is worth holding onto: 1_000 in source is a number the compiler assembles, so the underscore never survives to run time. parse reads a &str at run time and has no idea that syntax exists.
The whitespace row is the practical one. Every hand-rolled reader that splits a line on = or , and parses the pieces breaks the first time somebody types a space after the separator, and the fix is a .trim() the author did not think they needed. Do it once inside your own from_str, as the kata does, rather than at every call site.
And its digits are ASCII. "١٢٣" is three Arabic-Indic digits; every char in it answers true to is_numeric, and parse still refuses — it reads b'0'..=b'9' and nothing else. Python's int() accepts them and returns 123.
The failure is structured — for integers¶
ParseIntError::kind() ↗ has been stable since 1.55, and it returns an IntErrorKind ↗ you can match:
| input | kind | what the caller should do |
|---|---|---|
"" |
Empty |
ask again — nothing was typed |
"forty-two" |
InvalidDigit |
reject it — that is not a number |
"999999999999" |
PosOverflow |
a wider type would work |
"-999999999999" |
NegOverflow |
likewise, signed |
Zero is the fifth, for the NonZero* types. IntErrorKind is #[non_exhaustive], so a match on it needs a _ arm whether or not you have covered today's variants — which is the point of the attribute.
Floats get none of this. ParseFloatError has a kind field too, and it is pub(super): there is no accessor on stable, and FloatErrorKind is not a public type. You get Display and PartialEq, so you can tell two failures apart by comparing against a known error or by reading the message — but you cannot branch on a value the way the integer side lets you. The asymmetry is real and is worth knowing before you design an error type around it.
Floats also forgive more. "3.", ".5", "1e10", "inf" and "NaN" all parse. What never parses is "3,14": the decimal separator is a point, always, because str reads no locale. A CSV exported anywhere that writes 3,14 has to be repaired before this call, not after.
Handling the Result¶
Four ways, and the choice is about who should decide what happens next:
| when it is right | |
|---|---|
? |
almost always — the caller decides, and you wrote one character |
match |
when this function is the one that knows what to do about each kind |
unwrap_or |
when a default is genuinely correct, and nobody needs to be told |
expect |
a scratch program, a test, or a value you have already proven |
unwrap_or is the one to watch: it is silent, so a config file full of typos runs on defaults and looks like it worked. expect in a tool someone else runs prints a stack trace where an error message belonged — what a panic costs is the longer version of that argument.
FromStr in, Display out¶
Implementing FromStr is all .parse::<YourType>() needs; implementing Display is all .to_string() needs. They are the two directions of one conversion, and writing both gives you a round trip you can assert on:
Pick the associated Err type deliberately. type Err = String is quick and lets the caller print the problem; an enum lets them branch on it, which is the same reason IntErrorKind exists. The kata below writes the enum version.
If you are coming from another language¶
Python. int() is far more forgiving than parse, and every difference is a real bug waiting in a port.
int(s) |
s.parse::<i32>() |
|
|---|---|---|
int(" 42 ") → 42 |
strips whitespace | Err — never trims |
int("4_2") → 42 |
underscores allowed at run time | Err — that is source syntax only |
int("١٢٣") → 123 |
any Unicode decimal digit | Err — ASCII 0–9 only |
int("999999999999") → fine |
integers are arbitrary-precision | PosOverflow — pick i64 or i128 |
raises ValueError |
one exception type for every cause | a Result whose kind() you match |
float("3,14") raises |
no locale either | Err — the one place they agree |
The direction of travel is the same in both: Python's convenience is doing work you did not ask for, and each row is a value that silently becomes something on one side and is refused on the other. The overflow row is the one that bites hardest, because Python has no equivalent failure at all.
ABAP (Not machine-checked — CI cannot run ABAP.) Text-to-number has always been a statement rather than an expression, and the failure has always been a runtime exception rather than a value.
| ABAP | Rust | |
|---|---|---|
lv_n = lv_text. |
implicit conversion on assignment | let n: i32 = text.parse()? — never implicit |
CX_SY_CONVERSION_NO_NUMBER |
a catchable exception, thrown | Err(ParseIntError), returned |
TRY … CATCH around the move |
the handling is a block | ?, match, unwrap_or — an expression |
CONVERT_TO_TEXT / WRITE TO |
the way back out | Display, then .to_string() |
What changes: ABAP will convert on plain assignment, so a field that happens to hold '80x' fails at the line that moves it, often far from where it was read. Rust has no implicit conversion at all — the failure surfaces at the parse call, which is where the text entered the program. The TRY/CATCH habit maps cleanly onto match; what does not map is ?, which has no ABAP equivalent and is the reason most Rust code handles this in one character.
Practice¶
One bad value, four handlings — then an error you can branch on. Take the config line port=80x and handle its failure four ways: expect (capture what it actually prints), unwrap_or with a fallback, a match that describes the problem, and a function returning Result that uses ?. Say which one you would ship and why.
Then find the whitespace bug. Run port=8080, port= 8080, port =8080, port=, port=99999 and port=0 through a naive reader that splits on = and parses the right-hand side, and note which lines a human would call valid and the reader rejects.
Finish by writing impl FromStr for Port with an error enum — not a String — carrying the cases the caller would act on differently: missing, not a number, out of range. Trim once inside from_str. Make sure port=4294967296 reports the number that was typed rather than one you invented, and say why that variant cannot carry a u32.
Solution
parsing_a_string_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.
//! Kata solution: one config line handled four ways, the whitespace that breaks
//! three of them, and a FromStr whose error you can match on instead of read.
//!
//! rustc --edition 2024 parsing_a_string_kata.rs -o /tmp/pask && /tmp/pask
use std::fmt;
use std::num::IntErrorKind;
use std::str::FromStr;
/// What went wrong, as a value — the shape `ParseIntError::kind()` has, and the
/// reason to write an enum here rather than a `String` you can only print.
#[derive(Debug, PartialEq)]
enum PortError {
Missing,
NotANumber(String),
OutOfRange(String),
}
impl fmt::Display for PortError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PortError::Missing => write!(f, "no port given"),
PortError::NotANumber(text) => write!(f, "{text:?} is not a number"),
PortError::OutOfRange(text) => write!(f, "{text} is not a usable port"),
}
}
}
#[derive(Debug, PartialEq)]
struct Port(u16);
impl FromStr for Port {
type Err = PortError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.trim().is_empty() {
return Err(PortError::Missing);
}
match s.trim().parse::<u32>() {
Ok(n) if (1..=65535).contains(&n) => Ok(Port(n as u16)),
Ok(n) => Err(PortError::OutOfRange(n.to_string())),
// Too big for a u32, so there is no number to carry back — only the
// text that was typed. That is why the variant holds a String.
Err(e) if *e.kind() == IntErrorKind::PosOverflow => {
Err(PortError::OutOfRange(s.trim().to_string()))
}
Err(_) => Err(PortError::NotANumber(s.trim().to_string())),
}
}
}
/// The `?` handling: one function, every failure propagated to the caller.
fn read_port(line: &str) -> Result<Port, PortError> {
let value = line.split_once('=').map(|(_, v)| v).unwrap_or("");
let port: Port = value.parse()?;
Ok(port)
}
fn main() {
println!("Round 1 -- one bad value, four handlings");
let value = "80x";
// expect: the message the user actually sees. Caught so the run can finish.
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let caught = std::panic::catch_unwind(|| value.parse::<u16>().expect("port must be a number"));
std::panic::set_hook(hook);
let panic_message = caught
.unwrap_err()
.downcast_ref::<String>()
.map(|s| s.lines().next().unwrap_or("").to_string())
.unwrap_or_default();
println!(" expect panics: {panic_message}");
println!(" -- honest in a scratch program; in a tool someone else");
println!(" runs it is a stack trace where an error belonged.");
let fallback = value.parse::<u16>().unwrap_or(8080);
println!(" unwrap_or {fallback} <- no complaint, and no way to know it fired");
let described = match value.parse::<u16>() {
Ok(n) => format!("{n}"),
Err(e) => format!("kept the default, because {e}"),
};
println!(" match {described}");
println!(" ? {:?}", read_port("port=80x"));
println!(" -- the only one that hands the caller the decision.");
println!("\nRound 2 -- the whitespace that breaks a hand-rolled reader");
for line in ["port=8080", "port= 8080", "port =8080 ", "port=", "port=99999", "port=0"] {
let raw = line.split_once('=').map(|(_, v)| v).unwrap_or("");
let naive = raw.parse::<u16>();
let ours = read_port(line);
println!(
" {:>14} raw={:<8} -> {:<44} ours -> {}",
format!("{line:?}"),
format!("{raw:?}"),
match &naive {
Ok(n) => format!("Ok({n})"),
Err(e) => format!("Err({e})"),
},
match &ours {
Ok(p) => format!("Ok({})", p.0),
Err(e) => format!("Err({e})"),
}
);
}
println!(" `parse` never trims, so the space a human left after `=` is a");
println!(" parse error in every reader that forgets `.trim()`. Ours trims");
println!(" once, in `from_str`, so no caller has to remember.");
println!("\nRound 3 -- an error you can match on, not just print");
for line in ["port=443", "port=", "port=http", "port=99999", "port=4294967296"] {
let advice = match read_port(line) {
Ok(p) => format!("listening on {}", p.0),
Err(PortError::Missing) => "fill in the port".into(),
Err(PortError::NotANumber(t)) => format!("{t:?} looks like a name, not a port"),
Err(PortError::OutOfRange(text)) => format!("{text} is above 65535 — pick another"),
};
println!(" {:>20} {advice}", format!("{line:?}"));
}
println!(" Same shape as IntErrorKind, for the same reason: the caller wants");
println!(" to BRANCH on the failure, and a String can only be shown to");
println!(" someone. Note the last row -- 4294967296 overflows u32 before any");
println!(" range check could run -- there is no u32 left to report, so the");
println!(" variant carries the TEXT and says what was actually typed.");
}
Verified output of parsing_a_string_kata.rs — regenerated by tools/run_examples.py, never hand-typed.
Round 1 -- one bad value, four handlings
expect panics: port must be a number: ParseIntError { kind: InvalidDigit }
-- honest in a scratch program; in a tool someone else
runs it is a stack trace where an error belonged.
unwrap_or 8080 <- no complaint, and no way to know it fired
match kept the default, because invalid digit found in string
? Err(NotANumber("80x"))
-- the only one that hands the caller the decision.
Round 2 -- the whitespace that breaks a hand-rolled reader
"port=8080" raw="8080" -> Ok(8080) ours -> Ok(8080)
"port= 8080" raw=" 8080" -> Err(invalid digit found in string) ours -> Ok(8080)
"port =8080 " raw="8080 " -> Err(invalid digit found in string) ours -> Ok(8080)
"port=" raw="" -> Err(cannot parse integer from empty string) ours -> Err(no port given)
"port=99999" raw="99999" -> Err(number too large to fit in target type) ours -> Err(99999 is not a usable port)
"port=0" raw="0" -> Ok(0) ours -> Err(0 is not a usable port)
`parse` never trims, so the space a human left after `=` is a
parse error in every reader that forgets `.trim()`. Ours trims
once, in `from_str`, so no caller has to remember.
Round 3 -- an error you can match on, not just print
"port=443" listening on 443
"port=" fill in the port
"port=http" "http" looks like a name, not a port
"port=99999" 99999 is above 65535 — pick another
"port=4294967296" 4294967296 is above 65535 — pick another
Same shape as IntErrorKind, for the same reason: the caller wants
to BRANCH on the failure, and a String can only be shown to
someone. Note the last row -- 4294967296 overflows u32 before any
range check could run -- there is no u32 left to report, so the
variant carries the TEXT and says what was actually typed.
A tokenizer with quotes, and the error it cannot return. Write tokenize(input, delimiters) that splits on any of several delimiter characters and keeps a double-quoted run together as one token, quotes removed. The three tests below pass under more than one set of rules, so settle three yourself: what "a,,b" gives, whether "a \"\" b" contains an empty token, and what a quote in the middle of a word does. Then give it "a \"b c", which never closes its quote. A Vec<String> cannot say the input was wrong, so write try_tokenize, returning a Result that names the byte where the quote opened.
// rustc --edition 2024 --test tokenize.rs -o t && ./t
fn tokenize(input: &str, delimiters: &[char]) -> Vec<String> {
todo!()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tokenize() {
let delims = vec![' ', ',', ';'];
assert_eq!(
tokenize("hello,world;rust lang", &delims),
vec!["hello", "world", "rust", "lang"]
);
assert_eq!(
tokenize("hello \"rust lang\" world", &[' ']),
vec!["hello", "rust lang", "world"]
);
assert_eq!(tokenize("", &delims), Vec::<String>::new());
}
}
Solution
tokenize_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.
//! Kata solution: a tokenizer that splits on several delimiters and keeps a
//! quoted run together — plus the unterminated quote a `Vec` cannot report.
//!
//! rustc --edition 2024 tokenize_kata.rs -o /tmp/tk && /tmp/tk
//! rustc --edition 2024 --test tokenize_kata.rs -o /tmp/tkt && /tmp/tkt
/// Walks the input by `char`, so a delimiter may be any character, not only
/// an ASCII one. Inside double quotes delimiters are ordinary text, and the
/// quotes themselves are dropped. A run of delimiters yields no empty tokens,
/// but a quoted empty string ("") is a token: the quotes are what make it one.
fn tokenize(input: &str, delimiters: &[char]) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut quoted = false;
let mut started = false;
for c in input.chars() {
if c == '"' {
quoted = !quoted;
started = true;
} else if !quoted && delimiters.contains(&c) {
if started {
tokens.push(std::mem::take(&mut current));
started = false;
}
} else {
current.push(c);
started = true;
}
}
if started {
tokens.push(current);
}
tokens
}
/// The same walk, except an unterminated quote is an error naming where it
/// opened — the failure Adam's signature has no way to express.
fn try_tokenize(input: &str, delimiters: &[char]) -> Result<Vec<String>, String> {
let mut open_at = None;
for (i, c) in input.char_indices() {
if c == '"' {
open_at = if open_at.is_some() { None } else { Some(i) };
}
}
match open_at {
Some(i) => Err(format!("unterminated quote opened at byte {i}")),
None => Ok(tokenize(input, delimiters)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tokenize() {
let delims = vec![' ', ',', ';'];
assert_eq!(
tokenize("hello,world;rust lang", &delims),
vec!["hello", "world", "rust", "lang"]
);
assert_eq!(
tokenize("hello \"rust lang\" world", &[' ']),
vec!["hello", "rust lang", "world"]
);
assert_eq!(tokenize("", &delims), Vec::<String>::new());
}
}
fn main() {
println!("1. Adam's three cases");
let delims = [' ', ',', ';'];
let a = tokenize("hello,world;rust lang", &delims);
let b = tokenize("hello \"rust lang\" world", &[' ']);
let c = tokenize("", &delims);
assert_eq!(a, vec!["hello", "world", "rust", "lang"]);
assert_eq!(b, vec!["hello", "rust lang", "world"]);
assert_eq!(c, Vec::<String>::new());
println!(" {a:?}");
println!(" {b:?}");
println!(" {c:?}");
println!();
println!("2. Three rules the tests leave to you");
for input in ["a,,b", "a \"\" b", "ab\"c d\"e"] {
println!(" {:<14} -> {:?}", format!("{input:?}"), tokenize(input, &delims));
}
println!(" Adam's cases pass whichever way these go, so each one is a decision:");
println!(" an empty run vanishes, an empty quoted token stays, and a quote in the");
println!(" middle of a word only switches the delimiter rule off and back on.");
println!();
println!("3. The failure the return type cannot hold");
let input = "a \"b c";
println!(" tokenize -> {:?}", tokenize(input, &[' ']));
println!(" try_tokenize -> {:?}", try_tokenize(input, &[' ']));
println!(" A Vec<String> can only say what it found. A missing closing quote is");
println!(" the input being wrong, and only a Result can say that.");
println!();
println!("4. A delimiter wider than a byte");
println!(" {:?}", tokenize("αβγ·δε·ζ", &['·']));
}
Verified output of tokenize_kata.rs — regenerated by tools/run_examples.py, never hand-typed.
1. Adam's three cases
["hello", "world", "rust", "lang"]
["hello", "rust lang", "world"]
[]
2. Three rules the tests leave to you
"a,,b" -> ["a", "b"]
"a \"\" b" -> ["a", "", "b"]
"ab\"c d\"e" -> ["abc de"]
Adam's cases pass whichever way these go, so each one is a decision:
an empty run vanishes, an empty quoted token stays, and a quote in the
middle of a word only switches the delimiter rule off and back on.
3. The failure the return type cannot hold
tokenize -> ["a", "b c"]
try_tokenize -> Err("unterminated quote opened at byte 2")
A Vec<String> can only say what it found. A missing closing quote is
the input being wrong, and only a Result can say that.
4. A delimiter wider than a byte
["αβγ", "δε", "ζ"]
A CSV reader, and the newline inside the quotes. Write parse(input: &str) -> Result<Vec<Vec<String>>, String> for comma-separated text where a field in double quotes may contain commas, newlines, and "" standing for one literal quote. Keep empty fields — a,,c is three — and trim the whitespace around an unquoted field but not inside quotes. The tokenizer above made the opposite call about empty runs; say why a CSV reader cannot. Then take a file whose quoted field spans two lines and parse it one lines() line at a time: what you get is the bug most hand-rolled CSV readers ship with. Finish with the two ways the input can be wrong, each reported with the byte where it went wrong.
Solution
csv_parser_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.
//! Kata solution: a CSV reader with quotes — a comma inside quotes, a doubled
//! quote for a literal one, empty fields kept, and the newline inside a quoted
//! field that a line-by-line reader cuts in two.
//!
//! rustc --edition 2024 csv_parser_kata.rs -o /tmp/cvk && /tmp/cvk
#[derive(Clone, Copy, PartialEq)]
enum State {
Start,
Quoted,
AfterQuote,
}
/// One pass over the whole input, character by character.
///
/// Outside quotes a comma ends a field and a newline ends a record, and an
/// unquoted field is trimmed. A field that starts with `"` is quoted: inside
/// it `""` stands for one literal quote, and commas and newlines are ordinary
/// text. Only whitespace may follow the closing quote.
fn parse(input: &str) -> Result<Vec<Vec<String>>, String> {
let mut records = Vec::new();
let mut record = Vec::new();
let mut field = String::new();
let mut state = State::Start;
let mut opened_at = 0;
let mut chars = input.char_indices().peekable();
while let Some((i, c)) = chars.next() {
match state {
State::Quoted => match c {
'"' if matches!(chars.peek(), Some((_, '"'))) => {
field.push('"');
chars.next();
}
'"' => state = State::AfterQuote,
_ => field.push(c),
},
_ if c == ',' || c == '\n' => {
record.push(finish(&mut field, state));
state = State::Start;
if c == '\n' {
records.push(std::mem::take(&mut record));
}
}
// CRLF: skip the carriage return and let the newline end the record.
_ if c == '\r' && matches!(chars.peek(), Some((_, '\n'))) => {}
State::Start if c == '"' && field.trim().is_empty() => {
field.clear();
state = State::Quoted;
opened_at = i;
}
State::AfterQuote if c.is_whitespace() => {}
State::AfterQuote => return Err(format!("text after a closing quote at byte {i}")),
State::Start => field.push(c),
}
}
if state == State::Quoted {
return Err(format!("unterminated quote opened at byte {opened_at}"));
}
if !field.is_empty() || !record.is_empty() || state == State::AfterQuote {
record.push(finish(&mut field, state));
records.push(record);
}
Ok(records)
}
/// A quoted field is taken exactly as written; an unquoted one is trimmed.
fn finish(field: &mut String, state: State) -> String {
let text = std::mem::take(field);
if state == State::AfterQuote { text } else { text.trim().to_string() }
}
fn main() {
println!("1. Quotes: a comma inside them, and a doubled quote for a literal one");
for record in parse("id,name,said\n1,\"Doe, Jane\",\"She said \"\"hi\"\"\"\n").unwrap() {
println!(" {record:?}");
}
println!();
println!("2. An empty field is data");
for record in parse("a,,c\n,,\n").unwrap() {
println!(" {record:?}");
}
println!(" Three fields on both lines. The tokenizer kata on this page drops the");
println!(" empty run between two delimiters on purpose; a CSV reader cannot, or");
println!(" every column after a blank one shifts one place to the left.");
println!();
println!("3. Trim outside the quotes, keep what is inside them");
println!(" {:?}", parse(" a , \" b \" ,c").unwrap()[0]);
println!();
println!("4. The newline inside the quotes");
let text = "name,note\nAda,\"first line\nsecond line\"\nBen,short\n";
let records = parse(text).unwrap();
println!(" the whole input: {} records from {} lines", records.len(), text.lines().count());
for record in &records {
println!(" {record:?}");
}
println!(" and one line at a time instead:");
for line in text.lines() {
println!(" {:<20} -> {:?}", format!("{line:?}"), parse(line));
}
println!(" Splitting on lines first is the bug. It cuts the record at the quoted");
println!(" newline, and neither half means anything on its own.");
println!();
println!("5. Two failures, and where each one is");
for bad in ["a,\"b", "\"a\"x,b"] {
println!(" {:<10} -> {:?}", format!("{bad:?}"), parse(bad));
}
println!();
println!("6. Windows line endings");
println!(" {:?}", parse("a,b\r\nc,d\r\n").unwrap());
}
Verified output of csv_parser_kata.rs — regenerated by tools/run_examples.py, never hand-typed.
1. Quotes: a comma inside them, and a doubled quote for a literal one
["id", "name", "said"]
["1", "Doe, Jane", "She said \"hi\""]
2. An empty field is data
["a", "", "c"]
["", "", ""]
Three fields on both lines. The tokenizer kata on this page drops the
empty run between two delimiters on purpose; a CSV reader cannot, or
every column after a blank one shifts one place to the left.
3. Trim outside the quotes, keep what is inside them
["a", " b ", "c"]
4. The newline inside the quotes
the whole input: 3 records from 4 lines
["name", "note"]
["Ada", "first line\nsecond line"]
["Ben", "short"]
and one line at a time instead:
"name,note" -> Ok([["name", "note"]])
"Ada,\"first line" -> Err("unterminated quote opened at byte 4")
"second line\"" -> Ok([["second line\""]])
"Ben,short" -> Ok([["Ben", "short"]])
Splitting on lines first is the bug. It cuts the record at the quoted
newline, and neither half means anything on its own.
5. Two failures, and where each one is
"a,\"b" -> Err("unterminated quote opened at byte 2")
"\"a\"x,b" -> Err("text after a closing quote at byte 3")
6. Windows line endings
[["a", "b"], ["c", "d"]]
See also¶
- Making a
String— the other direction, and why you implementDisplayrather thanToString - Walking a
String— the splitting that feeds a parser, and the empty fields it produces unwrap_or— the eager fallback, and when it runs- What a panic costs — the argument against
expectin anything shipped str::parse— the method in reference form- Strings: links, books and videos
Po polsku¶
"42".parse() to nie rzutowanie w stylu as, tylko wywołanie cechy (trait) FromStr — a to znaczy, że kompilator nie ma skąd wziąć typu docelowego i trzeba mu go podać turbofishem (parse::<i32>()), adnotacją zmiennej, albo pozwolić, by wynik wpłynął do czegoś, co typ już zna. Bez żadnej z tych trzech rzeczy zobaczysz E0284: type annotations needed — nie E0282, jak podpowiada wiele starszych materiałów; komunikat zmienił się wraz z rozwojem solvera cech, a ten na tej stronie pochodzi z kompilatora, do którego przypięta jest cała biblioteka.
Wynikiem jest Result, bo tekst przychodzi z zewnątrz i bywa nieprawdziwy. Trzy pułapki, których polski czytelnik doświadcza częściej niż autor angielskiego oryginału. Po pierwsze przecinek dziesiętny: "3,14".parse::<f64>() zwraca Err, ponieważ Rust zna wyłącznie kropkę i nie ogląda się na ustawienia lokalne — liczby wyeksportowane z polskiego Excela trzeba poprawić przed parsowaniem, nie po. Po drugie spacje: parse nigdy nie przycina białych znaków, więc " 42 " to błąd, a .trim() jest twoją odpowiedzialnością — najlepiej raz, wewnątrz własnego from_str. Po trzecie cyfry są wyłącznie ASCII: znaki, które is_numeric uznaje za cyfry, parse odrzuca, jeśli nie są 0–9.
Warto też znać asymetrię, o której łatwo się przekonać za późno: dla liczb całkowitych ParseIntError::kind() zwraca wartość, na której da się zrobić match (Empty, InvalidDigit, PosOverflow, NegOverflow), więc „wpisz coś" i „weź szerszy typ" są rozróżnialne bez czytania komunikatu. Dla liczb zmiennoprzecinkowych takiego dostępu nie ma — pole kind w ParseFloatError jest prywatne, zostaje Display i PartialEq.
Szukaj po polsku: parsowanie łańcucha znaków · konwersja tekstu na liczbę · rust parse FromStr · rust ParseIntError kind · rust turbofish · rust E0284 type annotations needed