Bonus chapter

CSV Parser Challenges

👋 Anyone can read and edit this exercise. Sign up to save your progress.

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.

Optional: Choose Your Separator

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?

Exercise 1 of 2
Open in Web Editor

Results

    Compiler / runtime output
    
                
    Reveal the full solution Spoiler: the complete answer
    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", ""]);
    }
    

    Optional: Give the Parser a Module Boundary

    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.

    1. Remove 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.
    2. Add a call to 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.

    Exercise 2 of 2
    Open in Web Editor

    Results

      Compiler / runtime output
      
                  
      Reveal the full solution Spoiler: the complete answer
      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"]]);
      }
      
      Next chapter 23Rust Fundamentals Quiz

      Optional Chapters

      Extra practice to explore at your own pace. These chapters do not count toward course progress.