Rust coding challenge - JSON parser
Published at 07-09-2026
Table of Contents
- Prologue
- TL;DR:
- Challenge
- Specifications
- High level design
- High level diagram of parser
- Reading from file
- Tokenization
- Handling buffered read
- Buffer boundaries
- Tokenizer as an Iterator
- Token state
- String
- Number
- Number case: Integers
- Number case: Exponent
- Number case: Fraction
- Keywords
- Handling EOF
- Parser - Endgame
- Nesting limit
- Unit testing and coverage
- References
Prologue
- Web browsers - HTML, CSS and JavaScript parsers turn plain code into dynamic pages.
- Programming languages - Compilers and interpreters parse source code into a tree structures before running it.
- Network traffic - Routers and firewalls parse packet headers to direct internet traffic securely.
- Command lines - Shells parse text commands and flags, do file resolution based on paths and execute system commands.
- Database systems - Parse database queries to optimize and retrieve data.
TL;DR:
Challenge
Rust as my target language.{
"name": "Flying Zebra",
"job": "Writer"
} JsonValue::Object(HashMap::from([("name".to_string(), "Flying Zebra".to_string()),("job".to_string(), "Writer".to_string())])) Specifications
High level design
- Lexical analysis (aka Tokenization),
- Syntactic analysis (aka Grammar check)
}{ High level diagram of parser
[Parser.parse()]
|
|
|
V
[Tokenizer.next()] -> We turn tokenizer into an iterator
|
|
|
V
[Reads raw bytes from the BufReader, builds valid tokens] -> Tokenizer needs to manage split tokens across buffers,
| token state
|
|
V
[Returns `Some(Ok(JsonToken))`] -> Iterator returns one token at a time
|
|
|
V
[Parser receives a token] -> updates its syntax state, loops back to step 1 until we reach end of the content or error.
Reading from file
let file_name = args_os()
.skip(1)
.take(1)
.next()
.expect("Please provide a file path with json");
let file =
File::open(&file_name).expect("Failed to open the file, please enter a valid file path");
let reader = BufReader::new(file); > ./json-parser tests/valid.json Read trait, that could include file, network stream, or stdio, even slices !
This is extremely useful for programs like mine since I can avoid reading large amount of data in memory, by default,
BufReader will take in a certain chunk size of data (8 KB) and keep it in local memory and when the program reads
from buf reader, it will read from the local memory instead of source and reduce system calls.Tokenization
ObjectArraynumbertruefalsenullstring
- Object
{ [whitespace] [key should be a string] [whitespace] :
[whitespace] [value] [whitespace] (comma , as separator)
} - Array
[ [whitespace] [value] [whitespace] (comma , as a separator) ] - Strings
[Quote][any codepoint except " or or control characters]
[ when using it should be followed by ", ,/,b,f,n,r,t, u<4 hex digits>]
[Quote] - Number - Is like a number except that hexadecimal or octal formats are not used. This one is a bit loose on purpose, but we will see its complexity down the line.
- Whitespace - Can be inserted between any tokens (whitespace includes space, linefeed, carriage return, horizontal tab)
- a string -> token::string(
) - a number -> token::number(
) - true || false || null -> token::bool(
) or token::null
{ "name": "Flying Zebra", "hobby": "Writing" } #[derive(Debug, PartialEq)]
enum PairStart {
LeftBrace,
LeftBracket,
}
#[derive(Debug, PartialEq)]
enum PairEnd {
RightBrace,
RightBracket,
}
#[derive(Debug, PartialEq)]
enum Delimiter {
Colon,
Comma,
}
#[derive(Debug, PartialEq)]
enum Literal {
String(String),
Number(f64),
Bool(bool),
Null,
}
#[derive(Debug, PartialEq)]
enum JsonToken {
PairStart(PairStart),
PairClose(PairEnd),
Delimiter(Delimiter),
Literal(Literal),
} f64 means in Rust ?1 bit -> sign (+ / -)
11 bit -> exponent (digits of exponent, for example. 2^(exponent)
52 bit -> mantissa JSON.parse() in JavaScript faces this issue.Handling buffered read
fill_buf() and consume() to read efficiently from a file, these apis help me read a fairly large file
efficiently by avoiding repeated system calls to Read.Vec<JsonToken> but that will grow proportionally in relation to user input, and we lose any
advantage that we gain by buffered read, instead I decided to implement an iterator for a tokenizer, which is something
known as lazy evaluation in programming paradigms.fill_buf() fills roughly 8 KB of data and stores in local memory - so that is read optimization,
a parser will need tokens to check if the structure of the input JSON is valid, this can be either done by giving a
list of all tokens all at once or one token at a time, advantage of second approach is we are incrementally reading,
generating tokens and also parsing those tokens, so we will have an fairly low memory footprint throughout the
execution of the program.Buffer boundaries
struct Tokenizer<R: BufRead> {
col: usize, // current col at which we are reading the byte
line: usize, // current line at which we are reading the byte
reader: R, // the buffer reader
scratch: Vec<u8>, // intermediate state to hold values between iterations or buffer boundaries
state: TokenState, // current reading state
} Tokenizer as an Iterator
impl<R: BufRead> Iterator for Tokenizer<R> {
type Item = std::io::Result<JsonToken>
fn next(&mut self) -> Option<Self::Item> {
loop {
let buffer = self.reader.fill_buf().ok()?;
let mut consume_amt=0;
let buff_len = buffer.len();
if buffer.is_empty() {
/*handle pending values in scratch, throw error for invalid state on EOF*/
}
while consume_amt < buff_len {
let b = buffer[consume_amt];
match self.state {
/*Token state matching against the current byte we are reading*/
TokenState::Idle => match b {
/*match conditions for start of tokens or token themselves*/
b'{' => {
self.col += 1;
consume_amt += 1;
self.reader.consume(consume_amt);
return Some(Ok(JsonToken::PairStart(PairStart::LeftBrace)));
}
}
}
}
self.reader.consume(consume_amt);
}
}
} for token_result in tokenizer.by_ref() {
let token = token_result?;
/*process token*/
} Token state
#[derive(Copy, Clone)]
enum TokenState {
Idle,
InKeyword { state: ExpectKeywordState }, // true, false, null
InNumber { state: NumberState }, // 1.0e-002, 1.0e000000000022222, -0.0, +0.0,1.
InString { escaped: bool },
InUnicodeEscape { collected: [u8; 4], count: usize },
ExpectLowSurrogate { state: SurrogatePairState },
} TokenState::Idle is essentially a state you see during start or you
would want to transition into once you are done generating a token.let b = buffer[consume_amt];
TokenState::Idle => match b {
b'{' => {
self.col += 1;
consume_amt += 1;
self.reader.consume(consume_amt);
return Some(Ok(JsonToken::PairStart(PairStart::LeftBrace)));
}
b'}' => {
self.col += 1;
consume_amt += 1;
self.reader.consume(consume_amt);
return Some(Ok(JsonToken::PairClose(PairEnd::RightBrace)));
}
b'[' => {
self.col += 1;
consume_amt += 1;
self.reader.consume(consume_amt);
return Some(Ok(JsonToken::PairStart(PairStart::LeftBracket)));
}
b']' => {
self.col += 1;
consume_amt += 1;
self.reader.consume(consume_amt);
return Some(Ok(JsonToken::PairClose(PairEnd::RightBracket)));
}
b':' => {
self.col += 1;
consume_amt += 1;
self.reader.consume(consume_amt);
return Some(Ok(JsonToken::Delimiter(Delimiter::Colon)));
}
b',' => {
self.col += 1;
consume_amt += 1;
self.reader.consume(consume_amt);
return Some(Ok(JsonToken::Delimiter(Delimiter::Comma)));
}
b' ' | b'\t' | b'\r' => {
// for radical simplicity, and to keep my head intact
// I am not getting into text parsing for damn json parser, sorry
// that's a scope creep
self.col += 1;
consume_amt += 1;
}
b'\n' => {
self.line += 1;
// reset col to make sure it increments to 1 on next line
self.col = 0;
consume_amt += 1;
}
b't' => {
self.col += 1;
consume_amt += 1;
self.scratch.push(b);
self.state = TokenState::InKeyword {
state: ExpectKeywordState::True,
};
}
b'f' => {
self.col += 1;
consume_amt += 1;
self.scratch.push(b);
self.state = TokenState::InKeyword {
state: ExpectKeywordState::False,
};
}
b'n' => {
self.col += 1;
consume_amt += 1;
self.scratch.push(b);
self.state = TokenState::InKeyword {
state: ExpectKeywordState::Null,
};
}
b'-' => {
self.col += 1;
consume_amt += 1;
self.scratch.push(b);
self.state = TokenState::InNumber {
state: NumberState::LeadingMinus,
};
}
b'0'..=b'9' => {
self.col += 1;
consume_amt += 1;
self.scratch.push(b);
self.state = TokenState::InNumber {
state: if b == b'0' {
NumberState::LeadingZero
} else {
NumberState::Integer
},
}
}
b'"' => {
self.col += 1;
consume_amt += 1;
self.state = TokenState::InString { escaped: false };
}
_ => {
self.col += 1;
consume_amt += 1;
self.reader.consume(consume_amt);
let error_message =
format!("Invalid symbol at line:{},col:{}", self.line, self.col);
return Some(Err(Error::new(ErrorKind::InvalidData, error_message)));
}
} +1 gets handled here as an error, since we can’t have + as sign before a number,
well before we get into parsing, along with any edge cases that can have any weird symbols or notations at the start
of token.StringNumberKeywords
String and Number types more or less rule the JSON tokenizer, almost 90% of the code in the tokenizer will be handling
string specific sub states or number specific states. I break them down below in great detail.String
#[derive(Copy, Clone)]
enum TokenState {
Idle,
InKeyword { state: ExpectKeywordState, last: u8 }, // true, false, null
InNumber { state: NumberState }, // 1.0e-002, 1.0e000000000022222, -0.0, 0, 0.1
InString { escaped: bool },
InUnicodeEscape { collected: [u8; 4], count: usize },
ExpectLowSurrogate { state: SurrogatePairState },
} match self.state {
TokenState::Idle => match b {
b'"' => {
self.col += 1;
consume_amt += 1;
self.state = TokenState::InString { escaped: false };
}
}
} "Flying Zebra"), so we match for a byte that corresponds to
a quotation mark and transition to a InString state.- Good old regular characters in string - escaped = false
- we see a backslash in string (
\) - escaped = true- Either a escape character (
\b, \n, \t, \r) or - a unicode (
\u83D8)
- Either a escape character (
TokenState::InString { escaped } => {
if escaped {
match b {
b'"' => {
self.col += 1;
consume_amt += 1;
self.scratch.push(b);
self.state = TokenState::InString { escaped: false }
}
b'\\' => {}
b'/' => {}
b'b' => {}
b'f' => {}
b'u' => {
self.col += 1;
consume_amt += 1;
self.state = TokenState::InUnicodeEscape {
collected: [0u8; 4],
count: 0,
};
break;
}
b'n' => {}
b't' => {}
b'r' => {}
_ => {}
}
} else {
match b {
b'\\' => {
self.col += 1;
consume_amt += 1;
self.state = TokenState::InString { escaped: true }
}
0x00..=0x1F => {
self.col += 1;
consume_amt += 1;
self.reader.consume(consume_amt);
return Some(Err(Error::new(
ErrorKind::InvalidData,
format!(
"Illegal character {} at line:{} at col:{}",
b.to_ascii_lowercase(),
self.line,
self.col
),
)));
}
b'"' => {
self.col += 1;
consume_amt += 1;
self.reader.consume(consume_amt);
match String::from_utf8(std::mem::take(&mut self.scratch)) {
Ok(v) => {
self.state = TokenState::Idle;
return Some(Ok(JsonToken::Literal(Literal::String(
v,
))));
}
Err(_err) => {
return Some(Err(Error::new(
ErrorKind::InvalidData,
format!(
"Failed to parse the string at line:{}, col:{}",
self.line, self.col
),
)));
}
}
_ => {
self.col += 1;
consume_amt += 1;
self.scratch.push(b);
}
}
}
} `
Hello
Flying
Zebra
`; 0x00..=0x1F define the range of control codes in JSON spec,All Unicode characters may be placed within the quotation marks, except for the characters that MUST be escaped: quotation mark, reverse solidus, and the control characters (U+0000 through U+001F).
escaped:true or TokenState::InUnicode sub state.InString.String case: Unicode escape
"a\\b" should be equal to "a\u005Cb"
a\\b is just a\b and a\u005Cb is also a\b since \u005C corresponds to \ in unicode, so its advised,
to encode these escape sequences.[u8;4] to be precise, but
there is one more catch, where languages such as JavaScript use UTF-16 (internally) to encode emojis, so when it stringify
the payload with JSON.stringify() to be compliant with UTF-8 requirement we will get a surrogate pair for such
characters!JSON.stringify(obj); // Could be '{"emoji":"💩"}' OR '{"emoji":"💩"}' \u,let hexadecimal_digit_range = b'0'..=b'9' | b'a'..=b'f' | b'A'..=b'F' u16 representation since we need that to check if the sequences within the range of surrogate pair,
(high / low surrogate),fn hex_to_u16(collected: [u8; 4]) -> u16 {
fn hex_value(b: u8) -> u16 {
match b {
b'0'..=b'9' => (b - b'0') as u16,
b'a'..=b'f' => (b - b'a' + 10) as u16,
b'A'..=b'F' => (b - b'A' + 10) as u16,
_ => unreachable!("Internal error: Cannot call hex_to_u16"),
}
}
hex_value(collected[0]) << 12
| hex_value(collected[1]) << 8
| hex_value(collected[2]) << 4
| hex_value(collected[3])
} u8 and convert it to the corresponding number,b'0', or b'a' or b'A',a in \uaaaa,is 10,
we have the hexadecimal digits but not the number itself yet, so since we have 4 digits,┌─────┬─────┬─────┬─────┐
│ '0' │ '0' │ '4' │ '1' │
└─────┴─────┴─────┴─────┘
In bytes we get this, b'0' = 48 and so on
┌────┬────┬────┬────┐
│ 48 │ 48 │ 52 │ 49 │
└────┴────┴────┴────┘
Now normalize back to their original values, by substracting either b'0' or b'a' or b'A' (and adding back 10)
┌────┬────┬────┬────┐
│ 0 │ 0 │ 4 │ 1 │
└─┬──┴─┬──┴─┬──┴──┬─┘
│ │ │ │
│ │ │ │
│ │ │ │
└───────────────────────────────────🠞 0000 0000 0000 0000 << 12 ──🠞 0000 0000 0000 0000 │
│ │ │
└──────────────────────────────🠞 0000 0000 0000 0000 << 8 ──🠞 0000 0000 0000 0000 │
│ │
└─────────────────────────🠞 0000 0000 0000 0100 << 4 ──🠞 0000 0000 0100 0000 │
│
└───────────────────🠞 0000 0000 0000 0001 ────────🠞 0000 0000 0000 0001 │
────────────────────
0000 0000 0100 0001
1 hex digit can represent 16 states (0 - 15), that means a digit in a binary sense is made up of 4 bits,
Shifting by 12, 8, 4 means we are shifting the digits in right place and then creating the number as a hexadecimal. \u0041 to a u16 ?- Most unicode characters we use in day to day life fall under BMP (Basic Multilingual Plane) that can be represented
by number 0 to (2^16 - 1) or (0 to 65,535). For example,
“\u0041”
isA\u0041is 65 in decimal and0x0041. - There are some characters that go beyond this range that include emojis, rare kanji characters and some historical
scripts. So instead of using
u32for every character which doubles the file size for everything, Windows, Java and JavaScript decided to use 2u16to decode these characters. - Because surrogate pairs use special mathematical ranges, we need to look at the bytes as
u16. - So to make sure I account for this, I need to convert
[u8;4]to anu16.
const high_surrogate_range = 0xD800..=0xDBFF;
const low_surrogate_range = 0xDC00..=0xDFFF; \u, collects it in an array of u8 convert that to u16 and check
if we have high or low surrogate pair, in case we encounter a high surrogate pair we transition into states related
to handling surrogate pairs, if we encounter a low surrogate here that means the string is invalid and we throw error,
and the last blocks just collects the bytes and tries to parse the collected bytes to a character with char::from_u32.TokenState::InUnicodeEscape {
mut collected,
mut count,
} => match b {
b'0'..=b'9' | b'a'..=b'f' | b'A'..=b'F' => {
collected[count] = b;
count += 1;
self.col += 1;
consume_amt += 1;
self.state = TokenState::InUnicodeEscape { collected, count };
if count == 4 {
let code_unit = hex_to_u16(collected);
match code_unit {
// high surrogate range
0xD800..=0xDBFF => {
self.state = TokenState::ExpectLowSurrogate {
state: SurrogatePairState::ExpectSlash {
high: code_unit,
},
}
}
// low surrogate range
0xDC00..=0xDFFF => {
return Some(Err(Error::new(
ErrorKind::InvalidData,
format!(
"Cannot have a lone low surrogate in string at line:{}, col:{}",
self.line, self.col
),
)));
}
_ => {
let c = char::from_u32(code_unit as u32).unwrap();
let mut buf = [0u8; 4];
let encoded = c.encode_utf8(&mut buf);
self.scratch.extend_from_slice(encoded.as_bytes());
self.state = TokenState::InString { escaped: false }
}
}
}
}
_ => {
self.col += 1;
consume_amt += 1;
self.reader.consume(consume_amt);
return Some(Err(Error::new(
ErrorKind::InvalidData,
format!(
"Invalid unicode escape at line:{} col:{}",
self.line, self.col
),
)));
}
} Unicode edge case: Surrogate pairs - 🚀😀😭
SurrogatePairState::ExpectSlash, we keep the high
surrogate as part of the state high: u16, and shift it to every state transition#[derive(Copy, Clone)]
enum SurrogatePairState {
ExpectSlash {
high: u16,
},
ExpectU {
high: u16,
},
ExpectCode {
high: u16,
collected: [u8; 4],
count: usize,
},
} --> Transition in TokenState::ExpectLowSurrogate
--> SurroagatePairState::ExpectSlash
--> SurrogatePairState::ExpectU
--> SurrogatePairState::ExpectCode
--> Transition back to InString {escaped: false} TokenState::ExpectLowSurrogate { state } => {
match state {
SurrogatePairState::ExpectSlash { high } => {
if b'\\' == b {
consume_amt += 1;
self.col += 1;
self.state = TokenState::ExpectLowSurrogate {
state: SurrogatePairState::ExpectU { high },
}
} else {
/*throw error*/
}
}
SurrogatePairState::ExpectU { high } => {
if b'u' == b {
consume_amt += 1;
self.col += 1;
self.state = TokenState::ExpectLowSurrogate {
state: SurrogatePairState::ExpectCode {
high,
collected: [0; 4],
count: 0,
},
}
} else {
/*throw error*/
}
}
SurrogatePairState::ExpectCode {
high,
mut collected,
mut count,
} => match b {
b'0'..=b'9' | b'a'..=b'f' | b'A'..=b'F' => {
collected[count] = b;
count += 1;
consume_amt += 1;
self.col += 1;
self.state = TokenState::ExpectLowSurrogate {
state: SurrogatePairState::ExpectCode {
high,
collected,
count,
},
};
if count == 4 {
let low = hex_to_u16(collected);
if !(0xDC00..=0xDFFF).contains(&low) {
/*throw error*/
}
let code_units = vec![high, low];
if let Some(Ok(c)) = char::decode_utf16(code_units).next() {
let mut buf = [0u8; 4];
self.scratch.extend_from_slice(
c.encode_utf8(&mut buf).as_bytes(),
);
} else {
/*throw error*/
}
self.state = TokenState::InString { escaped: false }
}
}
_ => {/*throw error*/}
},
}
} SurrogatePair::ExpectCode, we use char::decode_utf16 to convert the surrogate pair to the valid character,
and encode it back as bytes and push it to scratch. Final Step is to transition back to TokenState::InString.throw error.return Some(Err(Error::new(
ErrorKind::InvalidData,
format!(
"Invalid UTF-8, at line:{}, col:{}",
self.line, self.col
),
))); Number
TokenState::InNumber,
further there is a separate state required to transition within number to handle all valid variants,- Integer
- Fractions
- Exponents
Integers - Should have digit 0-9
- Leading zeros are not allowed
- 0 on its own is not a leading zero
- Only negative sign is allowed i.e. (’-’ sign)
Fraction - Should have .
- At least one digit (0-9) before .
Exponents - ‘e’ or ‘E’ transitions to exponent
- exponent must be followed by digits
- ’+’ or ’-’ are allowed with exponent part of digits
b'-' => {
self.col += 1;
consume_amt += 1;
self.scratch.push(b);
self.state = TokenState::InNumber {
state: NumberState::LeadingMinus,
};
}
b'0'..=b'9' => {
self.col += 1;
consume_amt += 1;
self.scratch.push(b);
self.state = TokenState::InNumber {
state: if b == b'0' {
NumberState::LeadingZero
} else {
NumberState::Integer
},
}
} #[derive(Copy, Clone)]
enum NumberState {
DotSeen,
Exponent, // emit
ExponentSign,
ExponentMarker,
LeadingMinus,
LeadingZero, // emit
Integer, // emit
Fraction, // emit
} DotSeen case, we transition to this one.look-back rather than look-ahead, we know with self.state what the last state
the number was in and check what the current byte is, with this we avoid unnecessarily looking ahead.0. represents a TokenState::InNumber {state::NumberState::DotSeen} state transition, and further transition
depends on the fact that what comes next,- a number then transition into a
NumberState::Fraction - EOF - throw error since we cannot have a
0.as a number - anything else but a digit - throw error
Number case: Integers
NumberState::Integer => {
match b {
b'.' => {
self.col += 1;
consume_amt += 1;
self.scratch.push(b);
self.state = TokenState::InNumber {
state: NumberState::DotSeen,
}
}
b'0'..=b'9' => {
self.col += 1;
consume_amt += 1;
self.scratch.push(b);
self.state = TokenState::InNumber {
state: NumberState::Integer,
}
}
b'e' | b'E' => {
self.col += 1;
consume_amt += 1;
self.scratch.push(b);
self.state = TokenState::InNumber {
state: NumberState::ExponentMarker,
}
}
b' ' | b'\t' | b'\n' | b'\r' | b',' | b'}' | b']' => {
self.reader.consume(consume_amt);
self.state = TokenState::Idle;
return Some(
parse_number(std::mem::take(&mut self.scratch)).map(|v| JsonToken::Literal(Literal::Number(v))).map_err(|_| Error::new(ErrorKind::InvalidData,format!("Invalid number found at line:{}, col:{}",self.line,self.col)))
);
}
_ => {/*throw error*/}
}
} - If you encounter a
.transition to aDotSeenstate. - If a digit is seen, push the byte to scratch and keep it in
Integerstate. - If a exponent marker is seen, transition to a
ExponentMarkerstate. - If you encounter whitespace or newline, that means we have reached end of the number, and its time to emit the token
Vec<u8> -> f64,fn parse_number(seq: Vec<u8>) -> Result<f64, Error> {
let format_str = String::from_utf8(seq).map_err(|e| Error::new(ErrorKind::InvalidData, e))?;
let number: f64 = format_str
.parse()
.map_err(|e| Error::new(ErrorKind::InvalidData, e))?;
Ok(number)
}
// used like this
parse_number(std::mem::take(&mut self.scratch)).map(|v| JsonToken::Literal(Literal::Number(v))).map_err(|_| Error::new(ErrorKind::InvalidData,format!("Invalid number found at line:{}, col:{}",self.line,self.col))) - Parse number - Takes in a
Vec<u8>converts toStringand then parses back to af64- This is sub optimal, and probably would not fly in a production grade parsers, but its not that bad for learning purposes and makes sense for me since its just less code and does things safely enough. Other options include, convertingVec<u8>tof64directly with just UTF-8 validation, and not converting to string first, which improves speed greatly or write a custom parser for just number. Other option includes directly parsing from bytes, but I probably need to write that custom parser, and it will be fastest option, since we would skip UTF-8 validation + parsing string into a f64. std::mem::take(&mut self.scratch)- This is worth mentioning since I want theself.scratchto be re-initialized to an empty vector,std::mem::takemoves ownership from the tokenizerself.scratchto the function and reinitializes,self.scratchwith an empty vector..mapand.map_err- Used to handleResultType, it takes in a callback and calls it with either the wrapped value or error in case ofmap_err, which makes it easier to cast in appropriate Token or throw error, basically I am choosing to write methods chained than do one more match statement here.
Number case: Exponent
- Integer
- Fraction
- Leading Zero (0e212 or 0e3 both are valid)
--> TokenState::InNumber
--> NumberState::Integer | NumberState::Fraction | NumberState::LeadingZero
---> ExponentMarker
---> ExponentSign (optional)
---> Exponent
---> emits a JSONToken::Number
---> TokenState::Idle f64 equivalent.NumberState::ExponentMarker => match b {
b'-' | b'+' => {
self.col += 1;
consume_amt += 1;
if b == b'-' {
// we can skip + sign in buffer since its optional and apparent if there
// is no sign its positive
self.scratch.push(b);
}
self.state = TokenState::InNumber {
state: NumberState::ExponentSign,
}
}
b'0'..=b'9' => {
// only see the byte to transition, don't consume it
self.state = TokenState::InNumber {
state: NumberState::Exponent,
}
}
_ => {
/*throw error*/
}
} NumberState::ExponentSign => match b {
b'0'..=b'9' => {
self.col += 1;
consume_amt += 1;
self.scratch.push(b);
self.state = TokenState::InNumber {
state: NumberState::Exponent,
}
}
_ => {
/*throw error*/
}
} NumberState::Exponent => {
match b {
b'0'..=b'9' => {
self.col += 1;
consume_amt += 1;
self.scratch.push(b);
}
b' ' | b'\t' | b'\n' | b'\r' | b',' | b'}' | b']' => {
self.reader.consume(consume_amt);
self.state = TokenState::Idle;
return Some(
parse_number(std::mem::take(&mut self.scratch)).map(|v| JsonToken::Literal(Literal::Number(v))).map_err(|_| Error::new(ErrorKind::InvalidData,format!("Invalid number found at line:{}, col:{}",self.line,self.col)))
);
}
_ => {/*throw error*/}
}
} Number case: Fraction
---> TokenState::InNumber
--> NumberState::Integer
--> NumberState::DotSeen
---> NumberState::Fraction
---> return JsonToken::Number
---> TokenState::Idle
NumberState::Fraction => {
match b {
b'.' => {
/*throw error*/
}
b'0'..=b'9' => {
self.col += 1;
consume_amt += 1;
self.scratch.push(b);
}
b'e' | b'E' => {
self.col += 1;
consume_amt += 1;
self.scratch.push(b);
self.state = TokenState::InNumber {
state: NumberState::ExponentMarker,
}
}
b' ' | b'\t' | b'\n' | b'\r' | b',' | b'}' | b']' => {
self.reader.consume(consume_amt);
self.state = TokenState::Idle;
return Some(
parse_number(std::mem::take(&mut self.scratch))
.map(|v| JsonToken::Literal(Literal::Number(v)))
.map_err(|_| Error::new(ErrorKind::InvalidData,format!("Invalid number found at line:{}, col:{}",self.line,self.col)))
);
}
_ => {
/*throw error*/
}
}
} LeadingZero and LeadingMinus but they don’t emit anything rather serve
as a way to handle edge cases of leading zero or leading minus sign and they will either throw an error or transition
to integer state. NumberState::LeadingMinus => match b {
b'0' => {
self.col += 1;
consume_amt += 1;
self.scratch.push(b);
self.state = TokenState::InNumber {
state: NumberState::LeadingZero,
};
}
b'1'..=b'9' => {
self.col += 1;
consume_amt += 1;
self.scratch.push(b);
self.state = TokenState::InNumber {
state: NumberState::Integer,
};
}
_ => {
/*throw error*/
}
},
// emit here
NumberState::LeadingZero => {
match b {
b'0'..=b'9' => {//throw error}
// handling cases where the number ends naturally
// we skip processing here the byte to avoid duplicate logic
b' ' | b'\t' | b'\n' | b'\r' | b',' | b'}' | b']' => {
self.reader.consume(consume_amt);
self.state = TokenState::Idle;
return Some(
parse_number(std::mem::take(&mut self.scratch)).map(|v| JsonToken::Literal(Literal::Number(v))).map_err(|_| Error::new(ErrorKind::InvalidData,format!("Invalid number found at line:{}, col:{}",self.line,self.col)))
);
}
_ => {
//throw error
)));
}
}
} Keywords
null, true and false in JSON, the mechanism for handling is more or less the same,b'n', b't', b'f'), and
we transition into one of the ExpectKeywordState,#[derive(Copy, Clone)]
enum ExpectKeywordState {
True,
False,
Null,
} b't' => {
self.col += 1;
consume_amt += 1;
self.scratch.push(b);
self.state = TokenState::InKeyword {
state: ExpectKeywordState::True,
};
}
b'f' => {
self.col += 1;
consume_amt += 1;
self.scratch.push(b);
self.state = TokenState::InKeyword {
state: ExpectKeywordState::False,
};
}
b'n' => {
self.col += 1;
consume_amt += 1;
self.scratch.push(b);
self.state = TokenState::InKeyword {
state: ExpectKeywordState::Null,
};
} TokenState::InKeyword { state, last } => match state {
ExpectKeywordState::True => {
let last =
self.scratch.last().copied().expect(
"ExpectKeywordState state always has ≥1 byte in scratch",
);
let valid_next =
matches!((last, b), (b't', b'r') | (b'r', b'u') | (b'u', b'e'));
if valid_next && self.scratch.len() < 4 {
self.col += 1;
consume_amt += 1;
self.scratch.push(b);
self.state = TokenState::InKeyword {
state: ExpectKeywordState::True,
};
break;
};
match b {
b' ' | b'\t' | b'\r' | b'\n' | b'{' | b'}' | b'[' | b']' | b',' => {
// no need to change self.line, self.col since we are not consuming the byte here
// for newline or any other characters
self.reader.consume(consume_amt);
if self.scratch == b"true" {
self.scratch = vec![];
self.state = TokenState::Idle;
return Some(Ok(JsonToken::Literal(Literal::Bool(true))));
} else {
/*error handling*/
}
}
_ => {
/*error handling*/
}
};
}
} match b {} block if the scratch holds what I intended to hold, if yes then I return
the keyword token else throw an error, while that error should not happen in ideal conditions, there is some chance of
bug or indirect manipulation of scratch, that could break this btw, so that check if present under each keyword.Handling EOF
- Fraction
- Exponent
- Integer
- LeadingZero
- DotSeen
- ExponentMarker
- ExponentSign
- LeadingMinus
None which marks end of iterator.true,false and null we check if the values in scratch correspond to valid keyword, if they do,
return the token else throw an error.if buffer.is_empty() {
match self.state {
TokenState::InNumber { state } => {
match state {
NumberState::Fraction
| NumberState::Exponent
| NumberState::Integer
| NumberState::LeadingZero => {
self.state = TokenState::Idle;
return Some(
parse_number(std::mem::take(&mut self.scratch))
.map(|v| JsonToken::Literal(Literal::Number(v)))
.map_err(|_| {
Error::new(
ErrorKind::InvalidData,
format!(
"Invalid number found at line:{}, col:{}",
self.line, self.col
),
)
}),
);
}
NumberState::DotSeen
| NumberState::ExponentSign
| NumberState::ExponentMarker
| NumberState::LeadingMinus => {
self.state = TokenState::Idle;
// throw error - incomplete number passed into the parser
return Some(Err(Error::new(
ErrorKind::InvalidData,
format!(
"Incomplete number at line:{}, col:{}",
self.line, self.col
),
)));
}
}
}
TokenState::Idle => return None,
TokenState::InKeyword { state, .. } => match state {
ExpectKeywordState::True => {
if self.scratch == b"true" {
self.scratch = vec![];
self.state = TokenState::Idle;
return Some(Ok(JsonToken::Literal(Literal::Bool(true))));
} else {
self.scratch = vec![];
return Some(Err(Error::new(
ErrorKind::InvalidData,
format!(
"Invalid true keyword at line:{}, col:{}",
self.line, self.col
),
)));
}
}
ExpectKeywordState::False => {
if self.scratch == b"false" {
self.scratch = vec![];
self.state = TokenState::Idle;
return Some(Ok(JsonToken::Literal(Literal::Bool(false))));
} else {
self.scratch = vec![];
return Some(Err(Error::new(
ErrorKind::InvalidData,
format!(
"Invalid false keyword at line:{}, col:{}",
self.line, self.col
),
)));
}
}
ExpectKeywordState::Null => {
if self.scratch == b"null" {
self.scratch = vec![];
self.state = TokenState::Idle;
return Some(Ok(JsonToken::Literal(Literal::Null)));
} else {
self.scratch = vec![];
return Some(Err(Error::new(
ErrorKind::InvalidData,
format!(
"Invalid null keyword at line:{}, col:{}",
self.line, self.col
),
)));
}
}
},
TokenState::InString { escaped: _ } => {
return Some(Err(Error::new(
ErrorKind::InvalidData,
format!("Incomplete string at line:{}, col:{}", self.line, self.col),
)));
}
TokenState::InUnicodeEscape {
collected: _,
count: _,
} => {
return Some(Err(Error::new(
ErrorKind::InvalidData,
format!("Incomplete string at line:{}, col:{}", self.line, self.col),
)));
}
TokenState::ExpectLowSurrogate { .. } => {
return Some(Err(Error::new(
ErrorKind::InvalidData,
format!("Invalid unicode at line:{}, col:{}", self.line, self.col),
)));
}
}
}; Parser - Endgame
[0, 'hello',true] is a valid array in JSON, but the equivalent is not possible in many strictly typed
languages in this case enum come in handy where each value can be wrapped in a variant of an enum, same value can be
represented aslet output = JsonValue::Array(
vec![
JsonValue::Number(0_f64),
JsonValue::String("hello".to_string()),
JsonValue::Bool(true)
]
); #[derive(Debug, PartialEq)]
enum JsonValue {
String(String),
Number(f64),
Bool(bool),
Null,
Array(Vec<JsonValue>),
Object(HashMap<String, JsonValue>),
} stack:Vec<Container> on parser, Container represents either an array or object.map:HashMap<String,JsonValue> - Simply means a key value pair like JSON, I am also holding a current key, reason
being we could have an object as a value and we might enter some deeply nested context, so we need to know what
key for which level points to what value.enum Container {
Object {
state: ObjectState,
map: HashMap<String, JsonValue>,
current_key: Option<String>,
},
Array {
state: ArrayState,
vec: Vec<JsonValue>,
},
}
struct Parser<R: BufRead> {
stack: Vec<Container>,
tokenizer: Tokenizer<R>,
}
impl<R: BufRead> Parser<R> {
fn new(tokenizer: Tokenizer<R>) -> Self {
Self {
stack: vec![],
tokenizer,
}
}
fn parse(&mut self) -> Result<JsonValue, Error> {} Object - ObjectState::Start -> Transition when you see JsonToken::PairStart(PairStart::LeftBrace) -> Push the container on stack
- ObjectState::Key -> Transition after start or a comma, when you see a string
- ObjectState::Colon -> Transition after you see a colon after a key
- ObjectState::Comma -> Transition after you see a comma after a value
- ObjectValue::Value -> Transition after you see a value after a colon
Array - ArrayState::Start -> When you see a
[ - ArrayState::Value -> When you see a value either after a start or a separator like comma
- ArrayState::Comma -> When you see it after a value
- ArrayState::Start -> When you see a
enum ObjectState {
Start, // at the start when { is encountered
Key, // has to be of type JsonToken::String
Colon, // separator
Comma, // separator
Value, // can be of type JsonToken::String, JsonToken::Number, JsonToken::Bool, JsonToken::Null or object or Array
}
enum ArrayState {
Start, // at the start when [is seen
Value, // value within array is seen
Comma, // comma is seen
} parse() method to pull in JsonToken.struct Parser<R: BufRead> {
stack: Vec<Container>,
tokenizer: Tokenizer<R>,
} self.stack.last() while a look ahead might sound fancy or
cool, I decide against using that simply because I decided to work with previous state that is accumulated already in
a stack and generate a token just once, and then no more, this keeps the flow simple, simple enough for debugging
and understanding transitions, there are some cases where I had to pull out my paper and pen and draw the stack, but
nonetheless a lot easier to deal with than look ahead.impl<R: BufRead> Parser<R> {
fn new(tokenizer: Tokenizer<R>) -> Self {
Self {
stack: vec![],
tokenizer,
}
}
fn parse(&mut self) -> Result<JsonValue, Error> {
let tokenizer = &mut self.tokenizer;
let mut error_msg: Option<String> = None;
let mut output: Option<JsonValue> = None;
for token_result in tokenizer.by_ref() {
let token = token_result?;
if output.is_some() {
match token {
/*handling top level value edge cases*/
}
};
match token {
JsonToken::PairStart(PairStart::LeftBrace) => match self.stack.last_mut() {
Some(Container::Object { state, .. }) => match state {
ObjectState::Start => {
error_msg = Some("Cannot have duplicate { in object".to_string());
break;
}
ObjectState::Key => {
error_msg = Some("Cannot have { in object after key".to_string());
break;
}
ObjectState::Colon => {
let next_stack_frame = Container::Object {
state: ObjectState::Start,
map: HashMap::new(),
current_key: None,
};
*state = ObjectState::Value;
if allow_push_to_stack(self.stack.len()) {
self.stack.push(next_stack_frame);
} else {
error_msg = Some("Exceeded nesting limit of 512".to_string());
break;
}
}
ObjectState::Value => {
error_msg = Some("Cannot have { in object after a value".to_string());
break;
}
ObjectState::Comma => {
error_msg = Some("Cannot have { in object after a comma".to_string());
break;
}
},
Some(Container::Array { state, .. }) => match state {
ArrayState::Start => {
let next_stack_frame = Container::Object {
state: ObjectState::Start,
map: HashMap::new(),
current_key: None,
};
*state = ArrayState::Value;
if allow_push_to_stack(self.stack.len()) {
self.stack.push(next_stack_frame);
} else {
error_msg = Some("Exceeded nesting limit of 512".to_string());
break;
}
}
ArrayState::Value => {
error_msg = Some("Cannot have { in an array after a value".to_string());
break;
}
ArrayState::Comma => {
let next_stack_frame = Container::Object {
state: ObjectState::Start,
map: HashMap::new(),
current_key: None,
};
*state = ArrayState::Value;
if allow_push_to_stack(self.stack.len()) {
self.stack.push(next_stack_frame);
} else {
error_msg = Some("Exceeded nesting limit of 512".to_string());
break;
}
}
},
None => {
let next_stack_frame = Container::Object {
state: ObjectState::Start,
map: HashMap::new(),
current_key: None,
};
if allow_push_to_stack(self.stack.len()) {
self.stack.push(next_stack_frame);
} else {
error_msg = Some("Exceeded nesting limit of 512".to_string());
break;
}
}
},
// much more code in here than I wish to share in this blog
}
}
// handling the out put at the end of loop
match (output, error_msg) {
(Some(_), Some(mut err)) => {
let line_msg = format!(
", at line:{}, col:{}",
self.tokenizer.line, self.tokenizer.col
);
err.push_str(&line_msg);
Err(Error::new(ErrorKind::InvalidData, err))
}
(None, None) => Err(Error::new(
ErrorKind::InvalidData,
"No valid input was provided",
)),
(None, Some(mut err)) => {
let line_msg = format!(
", at line:{}, col:{}",
self.tokenizer.line, self.tokenizer.col
);
err.push_str(&line_msg);
Err(Error::new(ErrorKind::InvalidData, err))
}
(Some(v), None) => {
if self.stack.is_empty() {
Ok(v)
} else {
Err(Error::new(ErrorKind::InvalidData, "Invalid input"))
}
}
}
}
} Nesting limit
const NEST_LIMIT: i32 = 512;
fn allow_push_to_stack(stack_size: usize) -> bool {
(stack_size as i32 + 1) < NEST_LIMIT
} if allow_push_to_stack(self.stack.len()) {
self.stack.push(next_stack_frame);
} else {
error_msg = Some("Exceeded nesting limit of 512".to_string());
break;
} Unit testing and coverage
#[cfg(test)]
mod test {
use super::*;
fn get_tokenizer(mock_string: &str) -> Tokenizer<BufReader<&[u8]>> {
let reader = BufReader::new(mock_string.as_bytes());
Tokenizer::new(reader)
}
fn expect_message(mock_string: &str) -> String {
format!("Expected {}, got None", mock_string)
}
mod tokenizer_tests {
use super::*;
#[test]
fn test_tokenizer_left_brace() {
let mock_string = r"{";
let mut tokenizer = get_tokenizer(mock_string);
let message = expect_message(mock_string);
let result = tokenizer.next().expect(&message);
assert_eq!(result.unwrap(), JsonToken::PairStart(PairStart::LeftBrace));
assert_eq!(tokenizer.line, 1);
assert_eq!(tokenizer.col, 1);
}
}
mod parser_tests {
use super::*;
mod primitive_tests {
use super::*;
#[test]
fn parse_primitive_number() {
let mock_input = r"9";
let tokenizer = get_tokenizer(mock_input);
let mut parser = Parser::new(tokenizer);
let message = expect_message(mock_input);
let out = parser.parse().expect(&message);
assert_eq!(out, JsonValue::Number(9_f64));
}
}
}
} > cargo test // runs all tests
> cargo test parser_tests // runs all parser tests
> cargo test parser_tests::primitive_tests // runs only primitive tests under parser tests llvm-cov for getting coverage, after installing llvm-cov you can view the coverage in your browser with nice red-green color,
coding and a coverage report, just run> cargo llvm-cov --html --open | Filename | Function | Coverage | Line |
|---|---|---|---|
| home/flyingzebra/repo/coding-challenges-rust/json-parser/src/main.rs | 96.32% (183/190) | 97.26% (2837/2917) | 97.80% (4943/5054) |
| Totals | 96.32% (183/190) | 97.26% (2837/2917) | 97.80% (4943/5054) |
running 167 tests
// A wall of tests with ok at its end
test result: ok. 167 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s