Chapter 22

State machines and stateful parsing

πŸ‘‹ Anyone can read and edit this exercise. Sign up to save your progress.

Reach for split(',') and CSV looks solved in one line. Then a field contains a comma: the row "a,b",c is meant to hold two fields, a,b and c. Split on every comma and you get three pieces ("a, b", c), the quotes still attached and the first field torn in half.

The fix is to stop treating every comma as a separator. Walk the input one character at a time and track a single piece of state: am I currently inside a quoted field? A comma inside quotes is data; a comma outside quotes is a separator.

This "for each character, update some state, occasionally emit a result" pattern is called a state machine. It comes up in any non-trivial parsing task: JSON, command-line arguments, terminal escape sequences, markup languages.

A skeleton

fn parse(line: &str) -> Vec<String> {
    let mut fields = Vec::new();
    let mut current = String::new();
    let mut in_quotes = false;
    let mut chars = line.chars().peekable();

    while let Some(c) = chars.next() {
        match (c, in_quotes) {
            ('"', false) => in_quotes = true,
            ('"', true) if chars.peek() == Some(&'"') => {
                // Escaped quote inside a quoted field.
                current.push('"');
                chars.next();
            }
            ('"', true) => in_quotes = false,
            (',', false) => {
                fields.push(std::mem::take(&mut current));
            }
            (c, _) => current.push(c),
        }
    }
    fields.push(current);
    fields
}

Each less familiar tool removes one bit of bookkeeping from the loop:

A note on while let

You used if let with Option earlier; while let repeats the same pattern until it stops matching. That extra control matters here because the loop sometimes calls chars.next() again to consume the second ".

You also used tuple patterns in let (a, b) = pair; here, match inspects the character and quote state together. A guard adds the lookahead check only to the escaped-quote arm.

These tests use the raw strings you met in the env-file parser, so the CSV examples can contain literal commas and quotes without an escape forest.

A useful tactic

When stateful parsing gets hairy, write the simple version first (split_once, split(',')) and let the easy tests pass. Then upgrade to the state-machine version for the harder cases. Failing tests give you concrete examples to think against, instead of trying to imagine every edge case up front.

For real CSV in production code, reach for the csv crate; it handles all the corners that this exercise glosses over. This exercise focuses on the state-machine loop rather than a production-ready CSV implementation.

A first pass: comma-splitting

Before tackling the messy realities of CSV (quotes, escapes, embedded commas), let's handle the trivial case: a line that's nothing but plain values separated by commas, possibly with surrounding whitespace. This is what most "I'll just split on commas" CSV parsers do, and it's also why so many of them break.

Use str::split and str::trim. Collect into a Vec<String>.

Useful from the standard library

  • str::split with a ',' argument yields each comma-separated piece as a &str.
  • str::trim drops leading/trailing whitespace from each piece.
  • str::to_string in a map step turns the borrowed pieces into the owned Strings the return type wants.
  • Iterator::collect finishes the chain. The body fits on one line: line.split(',').map(|s| s.trim().to_string()).collect().
Exercise 1 of 3
Open in Web Editor

Results

    Compiler / runtime output
    
                
    Stuck? Show a hint No spoilers, just a nudge
    1. There's a method on &str that splits on a delimiter and gives you an iterator. Combine it with trim and collect.
    Reveal the full solution Spoiler: the complete answer
    /// Parses a simple CSV line without quotes.
    /// Splits on commas and trims whitespace.
    fn parse_simple_csv_line(line: &str) -> Vec<String> {
        line.split(',')
            .map(|field| field.trim().to_string())
            .collect()
    }
    
    #[test]
    fn test_parse_simple_csv_line() {
        let line = "name, age, city";
        let fields = parse_simple_csv_line(line);
        assert_eq!(fields, vec!["name", "age", "city"]);
    }
    

    Quotes, embedded commas, and escapes

    Real CSV is a state machine in disguise. A field can be wrapped in double quotes, in which case any commas inside the quotes are part of the field, not separators. And a literal " inside a quoted field is encoded as "" (two quotes).

    Implement the cases in the order shown by the tests:

    1. Plain a,b,c and simply quoted "a","b","c" (the basic test).
    2. Commas inside quoted fields: "a,b",c.
    3. Escaped quotes: "a""b",c -> [a"b, c].

    Walk the string character by character with a peekable iterator and keep a small in_quotes: bool flag. When you see " while already inside quotes, peek the next char: if it's another ", push a literal " and consume both; otherwise close the field.

    Useful from the standard library

    • str::chars is the entry point for character-level iteration.
    • Iterator::peekable wraps the iterator so you can look ahead one character. Essential for the "" -> " rule.
    • Peekable::peek returns Option<&Item> without advancing.
    • std::mem::take swaps the current String with a fresh empty one in a single move. Cleaner than current.clone() followed by current.clear().
    • A match (c, in_quotes) on the tuple lets you express each state transition as a single arm. Add a guard (if chars.peek() == Some(&'"')) for the escape rule.
    Exercise 2 of 3
    Open in Web Editor

    Results

      Compiler / runtime output
      
                  
      Stuck? Show a hint No spoilers, just a nudge
      1. A single bool (in_quotes) is enough state. Walk the input with line.chars().peekable() so you can look one character ahead.
      2. Match on the tuple (c, in_quotes). There are only five interesting cases:
        • ('"', false) β†’ enter quoted mode.
        • ('"', true) and the next char is also " β†’ push a literal ", consume the second one with chars.next().
        • ('"', true) β†’ exit quoted mode.
        • (',', false) β†’ finish the current field, start a new one.
        • anything else β†’ push the character into the current field.
      3. After the loop, push the final field. Use std::mem::take(&mut current) to harvest a field without cloning.
      4. The full skeleton is in the chapter intro; if you've read it and are still stuck, copy the skeleton verbatim and run the tests. The compiler errors will tell you what's left to wire up.
      Reveal the full solution Spoiler: the complete answer
      /// Parses a CSV line with proper quote handling.
      /// Handles: "field,with,commas", "field with \"quotes\"", etc.
      fn parse_csv_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(field.clone());
                      field.clear();
                  }
                  _ => field.push(c),
              }
          }
          fields.push(field);
          fields
      }
      
      #[test]
      fn test_parse_csv_line_basic() {
          // Warm-up: every field is quoted, no commas inside, no escapes.
          // Get this passing first; it forces you to enter and exit a quoted
          // field, but nothing trickier.
          let line = r#""a","b","c""#;
          let fields = parse_csv_line(line);
          assert_eq!(fields, vec!["a", "b", "c"]);
      }
      
      #[test]
      fn test_parse_csv_line_quoted() {
          let line = r#"name,"age, years","city""#;
          let fields = parse_csv_line(line);
          assert_eq!(fields, vec!["name", "age, years", "city"]);
      }
      
      #[test]
      fn test_parse_csv_line_escaped_quotes() {
          let line = r#""John ""Johnny"" Doe","25","New York""#;
          let fields = parse_csv_line(line);
          assert_eq!(fields, vec![r#"John "Johnny" Doe"#, "25", "New York"]);
      }
      

      Parsing a whole file

      With a working line parser, the file-level parser is mostly plumbing: split on newlines, treat the first line as headers, and parse the rest as data rows.

      Use str::lines to split: it handles trailing newlines gracefully, so "a,b\n" gives one line, not two.

      You'll reuse parse_csv_line from the previous page. To keep this page independently runnable, its signature is re-declared here as a stub with todo!(). Paste your earlier solution into the stub or call into it.

      Useful from the standard library

      • str::lines yields each line as a &str, stripping \n and \r\n. A trailing newline does not create an empty trailing line.
      • Iterator::next on the iterator pulls off the header line; an empty file should return empty headers and rows.
      • Iterator::map + parse_csv_line over the remaining lines builds the rows.
      • Iterator::collect to materialize both the headers and the rows into Vecs.
      Exercise 3 of 3
      Open in Web Editor

      Results

        Compiler / runtime output
        
                    
        Stuck? Show a hint No spoilers, just a nudge
        1. content.lines() gives you an iterator over &str lines.
        2. The first line is headers; the rest are rows. next() on the iterator pulls the first one off; the rest you can map(parse_csv_line).collect().
        Reveal the full solution Spoiler: the complete answer
        /// Parses a CSV line with proper quote handling.
        /// (Re-stubbed from step 4 so this file compiles on its own.)
        fn parse_csv_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 => {
                        if chars.peek() == Some(&'"') {
                            field.push('"');
                            chars.next();
                        } else {
                            in_quotes = false;
                        }
                    }
                    '"' => in_quotes = true,
                    ',' if !in_quotes => {
                        fields.push(field.clone());
                        field.clear();
                    }
                    _ => field.push(c),
                }
            }
            fields.push(field);
            fields
        }
        
        /// Parses a complete CSV file.
        /// First line is headers, remaining lines are data.
        ///
        /// Use [`str::lines`](https://doc.rust-lang.org/std/primitive.str.html#method.lines)
        /// to split on newlines. `lines()` already handles a trailing `\n`
        /// gracefully; it won't yield an empty last line for `"a,b\n"`. Real
        /// CSVs often end with a newline, so this is the right tool.
        /// Returns (headers, rows).
        fn parse_csv_file(content: &str) -> (Vec<String>, Vec<Vec<String>>) {
            let mut lines = content.lines();
            let headers = lines.next().map(parse_csv_line).unwrap_or_default();
            let rows = lines.map(parse_csv_line).collect();
            (headers, rows)
        }
        
        #[test]
        fn test_parse_csv_file() {
            let content = "name,age,city\nAlice,30,Boston\nBob,25,Seattle";
            let (headers, rows) = parse_csv_file(content);
            assert_eq!(headers, vec!["name", "age", "city"]);
            assert_eq!(rows.len(), 2);
            assert_eq!(rows[0], vec!["Alice", "30", "Boston"]);
        }
        

        Wrapping up the CSV parser

        The parser begins with split and trim, then adds state for quoted fields and escaped quotes before collecting headers and rows.

        What we learned

        • The same stateful parsing pattern appears in JSON, command lines, and terminal escape sequences. In each case, read one item, consult the current state, then update the state or emit a result.
        • A peekable iterator lets you inspect what comes next without consuming it, as the "" -> " escape rule requires.
        • match (token, state) { ... } over a tuple expresses each state transition in one line. Match guards (if cond) handle the cases where the transition depends on the lookahead.
        • std::mem::take(&mut s) gives you the current value and replaces it with Default in one move. Cleaner than clone-then-clear when you're harvesting an accumulator.
        • The simple split/trim version is worth writing first. It passes the easy tests and gives you a baseline; the state-machine upgrade then has concrete failing cases to react to.
        • Hand production CSV files to the csv crate, which handles BOMs, custom delimiters, and escaped newlines inside fields. Keep the state-machine loop for parsers you do need to write yourself.
        Next chapter 23Rust fundamentals quiz