Coding Interview Workshop · Lesson 13

Parse quoted fields with an explicit state machine

Parse one bounded comma-separated record with quoted fields and doubled quotes inside a quoted field.

Clarify the observable contract

Parse one bounded comma-separated record with quoted fields and doubled quotes inside a quoted field. A quote may open only at the beginning of a field; after its closing quote only a delimiter or end is accepted. Preserve empty fields, including a trailing one. This defined classroom grammar is not a claim to implement every CSV dialect, multiline document or streaming encoding rule.

A state name should describe which next characters are legal, not just whether a quote was seen somewhere earlier. Unquoted, quoted and after-quote states make that grammar visible. End-of-input is also a transition: it finalizes unquoted or closed fields but rejects a field still inside quotes.

Walk through the verified program

The unquoted a becomes the first field. The comma inside quoted b,c remains data, and doubled quotes inside d double-quote e decode into one literal quote. The final delimiter contributes an empty fourth field. A separate unclosed opening quote fails instead of silently accepting partial data. State zero is unquoted, one quoted and two after a closing quote.

End materializes the current field, clears its builder and enforces the field-count limit. The final End call is necessary even after a trailing delimiter because the grammar preserves a final empty field. Doubling a quote consumes the second quote immediately, ensuring it is data rather than a second closing transition.

using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Text;

Console.WriteLine(string.Join("|",Parse("a,\"b,c\",\"d\"\"e\",",100,10)));
bool rejected=false; try { Parse("\"unfinished",100,10); } catch(FormatException) { rejected=true; } Console.WriteLine("unclosed rejected:"+rejected.ToString().ToLowerInvariant());
static string[] Parse(string text,int maxLength,int maxFields) {
    if(text.Length>maxLength || maxFields<1) throw new ArgumentOutOfRangeException();
    var fields=new List<string>(); var value=new StringBuilder(); int state=0;
    void End() { if(fields.Count>=maxFields) throw new FormatException("too many fields"); fields.Add(value.ToString()); value.Clear(); }
    for(int i=0;i<text.Length;i++) { char c=text[i];
        if(state==1) { if(c=='"') { if(i+1<text.Length && text[i+1]=='"') { value.Append('"'); i++; } else state=2; } else value.Append(c); }
        else if(c==',') { End(); state=0; }
        else if(state==2) throw new FormatException("after quote");
        else if(c=='"') { if(value.Length!=0) throw new FormatException("quote position"); state=1; }
        else value.Append(c);
    }
    if(state==1) throw new FormatException("unclosed quote"); End(); return fields.ToArray();
}

Account for time, storage and allocations

Each input code unit is inspected a bounded number of times, giving O(n) time and O(n) output/buffer storage within the accepted input limit. The maxFields check bounds the number of fields. StringBuilder and final strings still allocate; this is not a zero-allocation parser.

The length limit is measured in UTF-16 code units because the input is already a string. A byte limit would belong before decoding or require a separate encoded-size calculation. Bounding one record does not automatically bound a whole file containing many records, and StringBuilder capacity is not an allocation-free guarantee.

Review assumptions and likely defects

Splitting on commas first destroys the information needed to recognize quoted delimiters. Accepting arbitrary characters after a closing quote creates ambiguous recovery behavior. Limits must apply before unbounded accumulation, and the parser should not return a partially accepted record after a grammar error.

Grammar changes need explicit decisions for whitespace after closing quotes, line breaks, escape conventions and malformed input. Recovering by guessing a delimiter can misalign every later field. For an interview task, strict failure with a known record boundary is often easier to defend than undocumented best-effort recovery.

Practice one: discriminate edge cases

Test an empty record, an empty quoted field, an embedded quote in an unquoted field and characters after a closing quote. Hint: specify the expected field count for an empty input before deciding whether it is an error.

Write a table from each state and character class to the expected next state or error. Use it to derive tests, including the empty record and doubled quote at the boundary. This creates an oracle independent of the control-flow branches and catches missing transitions that a single happy-path example cannot expose.

Practice two: defend an extension

Design a chunked parser that can stop in the middle of a doubled quote or UTF-8 sequence. Define retained state, byte versus character limits and error position reporting. Explain which responsibilities belong to decoding and which belong to field grammar.

A chunked parser should retain its grammar state and any pending escape decision across chunks, while a decoder separately retains incomplete byte sequences. Report an error position in one chosen coordinate system and enforce total-record bounds across all chunks. Reinitializing limits for each chunk would allow an oversized record to bypass them.

Evaluate correctness and interview reasoning

The interview explanation should walk a quoted comma, a doubled quote, a trailing empty field and an invalid unfinished field. It should identify the three states and show why every code unit is processed only a bounded number of times. The real implementation intentionally solves a specified single-record grammar rather than claiming universal CSV compatibility. A senior extension separates decoding, chunk ownership, field grammar and resource bounds. Correctness here comes from explicit transitions and complete failure behavior, not from recognizing a familiar file-format name and assuming Split will suffice.

A working example

using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Text;

Console.WriteLine(string.Join("|",Parse("a,\"b,c\",\"d\"\"e\",",100,10)));
bool rejected=false; try { Parse("\"unfinished",100,10); } catch(FormatException) { rejected=true; } Console.WriteLine("unclosed rejected:"+rejected.ToString().ToLowerInvariant());
static string[] Parse(string text,int maxLength,int maxFields) {
    if(text.Length>maxLength || maxFields<1) throw new ArgumentOutOfRangeException();
    var fields=new List<string>(); var value=new StringBuilder(); int state=0;
    void End() { if(fields.Count>=maxFields) throw new FormatException("too many fields"); fields.Add(value.ToString()); value.Clear(); }
    for(int i=0;i<text.Length;i++) { char c=text[i];
        if(state==1) { if(c=='"') { if(i+1<text.Length && text[i+1]=='"') { value.Append('"'); i++; } else state=2; } else value.Append(c); }
        else if(c==',') { End(); state=0; }
        else if(state==2) throw new FormatException("after quote");
        else if(c=='"') { if(value.Length!=0) throw new FormatException("quote position"); state=1; }
        else value.Append(c);
    }
    if(state==1) throw new FormatException("unclosed quote"); End(); return fields.ToArray();
}

Expected output

a|b,c|d"e|
unclosed rejected:true
Try this
Test an empty record, an empty quoted field, an embedded quote in an unquoted field and characters after a closing quote. Hint: specify the expected field count for an empty input before deciding whether it is an error.

Further reading

Interview questions for this lesson