Two optional tasks to try after the CSV parser. Change the separator without breaking quoted fields, then reuse a parser through a small public API.
Each editor is independent and works in the browser or as a local Rust test file. The module task includes a working line parser; it doesn't depend on finishing the delimiter task. This bonus chapter does not count toward course completion.
A spreadsheet export arrives with semicolons instead of commas. Implement
parse_delimited_line(line, delimiter) without changing the quoting rules. A
delimiter inside quotes is still data, and doubled quotes still mean one literal
quote. Commas become ordinary data when the delimiter is ;.
If you'd like to adapt your earlier quote parser, copy its body into this
editor; changes in earlier editors don't carry over. You can also work directly
from the starter below. Keep parse_csv_line as a thin wrapper that chooses a
comma; don't maintain two parsing loops.
For this exercise, callers supply a delimiter other than ", \r, or \n.
Inputs have balanced quotes, quotes wrap whole fields, and records occupy one
line. Preserve whitespace, empty fields, and trailing empty fields. An empty
line is one empty field. Rejecting malformed CSV is outside this small
challenge.
Before running the tests, predict the fields in "a;""b";c with ; as the
delimiter. Then try a tab or a Unicode delimiter. Does your loop operate on
characters or bytes?
fn parse_delimited_line(line: &str, delimiter: char) -> Vec<String> {
let mut fields = Vec::new();
let mut field = String::new();
let mut in_quotes = false;
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
match c {
'"' if in_quotes => {
// A doubled quote inside a quoted field is a literal quote; a
// lone quote ends the quoted section.
if chars.peek() == Some(&'"') {
field.push('"');
chars.next();
} else {
in_quotes = false;
}
}
'"' => in_quotes = true,
c if c == delimiter && !in_quotes => {
fields.push(std::mem::take(&mut field));
}
_ => field.push(c),
}
}
fields.push(field);
fields
}
fn parse_csv_line(line: &str) -> Vec<String> {
parse_delimited_line(line, ',')
}
#[test]
fn semicolon_fields_and_literal_commas() {
assert_eq!(parse_delimited_line("a,b;c;", ';'), vec!["a,b", "c", ""]);
}
#[test]
fn delimiter_inside_quotes_is_data() {
assert_eq!(parse_delimited_line(r#""a;b";c"#, ';'), vec!["a;b", "c"]);
}
#[test]
fn escaped_quote_next_to_delimiter() {
assert_eq!(
parse_delimited_line(r#""a;""b";c"#, ';'),
vec!["a;\"b", "c"]
);
assert_eq!(parse_delimited_line("\"\"\"\";\"\"", ';'), vec!["\"", ""]);
}
#[test]
fn tabs_unicode_and_whitespace() {
assert_eq!(
parse_delimited_line(" left \tright ", '\t'),
vec![" left ", "right "]
);
assert_eq!(
parse_delimited_line("é🦀\"a🦀b\"🦀", '🦀'),
vec!["é", "a🦀b", ""]
);
}
#[test]
fn empty_fields_are_preserved() {
assert_eq!(parse_delimited_line("", ';'), vec![""]);
assert_eq!(parse_delimited_line(";;", ';'), vec!["", "", ""]);
}
#[test]
fn comma_wrapper_keeps_quote_and_escape_behavior() {
assert_eq!(parse_csv_line(r#""a,b","c""d","#), vec!["a,b", "c\"d", ""]);
}
Rust checks the parser's module boundary at compile time, even when the caller
and helper live in the same file. This page includes a working comma-line parser
inside an inline csv module, so it runs in the browser without extra files.
Use it as supplied; this task doesn't depend on your delimiter parser.
Implement csv::parse_file by calling the existing parse_line for both the
headers and every data row. Don't paste another copy into the file parser.
Expose only parse_file; leave the line parser private. Empty input returns
empty headers and rows. Keep blank interior lines as rows with a single empty
field, and preserve trailing empty fields within a row. A final newline adds no
extra record. The line parser's quoting rules still apply; malformed quoting and
quoted newlines remain outside our supported format.
The tests sit outside csv, like application code. The starter exposes
parse_file, so it compiles before you implement the body.
Once the tests pass, check what this API lets callers access with two visibility experiments, one at a time.
pub from parse_file. Predict what happens at the calls in the
tests, then compile and inspect the caller's privacy error. Restore pub and
run the tests again.csv::parse_line("a,b") in a test outside csv. Compile and
inspect the privacy error for the helper, then remove the call and run the
tests again. Why can parse_file call this private helper while the test
cannot?These errors are temporary experiments; finish with both the public API and the tests working.
For an optional local multi-file experiment, move the contents of
mod csv { ... } (without the outer braces) into csv.rs beside
3_parser_module.rs. Replace the inline module with:
#[path = "csv.rs"]
mod csv;
Keep the tests in 3_parser_module.rs. The explicit path finds the sibling file
both when this step is compiled alone and when the chapter's generated main.rs
includes it as a module. Don't edit the generated aggregator. Callers still use
csv::parse_file; only the file layout changes. Keep the inline version in the
browser, where the editor submits a single source file.
mod csv {
fn parse_line(line: &str) -> Vec<String> {
let mut fields = Vec::new();
let mut field = String::new();
let mut in_quotes = false;
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
match c {
'"' if in_quotes => {
// A doubled quote inside a quoted field is a literal quote;
// a lone quote ends the quoted section.
if chars.peek() == Some(&'"') {
field.push('"');
chars.next();
} else {
in_quotes = false;
}
}
'"' => in_quotes = true,
',' if !in_quotes => {
fields.push(std::mem::take(&mut field));
}
_ => field.push(c),
}
}
fields.push(field);
fields
}
pub fn parse_file(content: &str) -> (Vec<String>, Vec<Vec<String>>) {
let mut lines = content.lines();
let headers = lines.next().map(parse_line).unwrap_or_default();
let rows = lines.map(parse_line).collect();
(headers, rows)
}
}
#[test]
fn public_api_parses_headers_and_rows_with_the_same_rules() {
let (headers, rows) =
csv::parse_file("\"last, first\",note\n\"Doe, Jane\",\"said \"\"hi\"\"\"\n");
assert_eq!(headers, vec!["last, first", "note"]);
assert_eq!(rows, vec![vec!["Doe, Jane", "said \"hi\""]]);
}
#[test]
fn empty_file_and_headers_only() {
assert_eq!(csv::parse_file(""), (vec![], vec![]));
assert_eq!(
csv::parse_file("a,b\n"),
(vec!["a".into(), "b".into()], vec![])
);
}
#[test]
fn blank_interior_line_is_one_empty_field() {
let (headers, rows) = csv::parse_file("a,b\n\n1,2\n");
assert_eq!(headers, vec!["a", "b"]);
assert_eq!(rows, vec![vec![""], vec!["1", "2"]]);
}
#[test]
fn trailing_empty_field_is_preserved_without_an_extra_record() {
let (headers, rows) = csv::parse_file("a,b\n1,\n");
assert_eq!(headers, vec!["a", "b"]);
assert_eq!(rows, vec![vec!["1", ""]]);
}
#[test]
fn multiple_rows_and_crlf() {
let (headers, rows) = csv::parse_file("a,b\r\n1,2\r\n3,4\r\n");
assert_eq!(headers, vec!["a", "b"]);
assert_eq!(rows, vec![vec!["1", "2"], vec!["3", "4"]]);
}
Extra practice to explore at your own pace. These chapters do not count toward course progress.