How to parse JSON using shift-reduce parsing approach
Parsing JSON is something that the majority of programmers have done. Almost all languages have means to deserialize (unmarshall) JSON from text into some data structure. In this article, we are going to try doing exactly that.
Grammar
Just like in natural languages, the programming languages and various data formats have a grammar. The grammar consists of rules that show how the text input needs to be structured. For JSON the following is a simplified version of a part of the grammar:
1. json ::= value
2. value ::= object | array | string
3. object ::= '{' members '}'
4. members ::= member | member ',' members
5. member ::= string ':' value
6. string ::= '"' characters '"'
Check out the full grammar here, but we will also go over the grammar later. The format shown above is called the BNF or Backus-Naur form. Most programming languages have a BNF grammar. Each line in the grammar above is called a production rule. Anything that's in single quotes above is called a terminal symbol. Terminal means that we can no longer expand that part of the rule to something else. For example, if we take a look at the "object" rule above, the opening and closing braces are terminals which mean those exact characters need to be present for any piece of text to be even considered as JSON object. The remaining ones are called non-terminals. Non-terminals get expanded into their constituent parts. The following is just a simplified example of an expansion to match {"name": "jane"}:
value : := object
// object -> '{' members '}'
value : := '{' members '}'
// members -> member
value : := '{' member '}'
// member -> string ':' value
value : := '{' string ':' value '}'
// value -> string
value : := '{' string ':' string '}'
// string -> '"' characters '"'
value : := '{' '"' characters '"' ':' string '}'
// characters -> "name"
value : := '{' '"' name '"' ':' string '}'
// string -> '"' characters '"'
value : := '{' '"' name '"' ':' '"' characters '"' '}'
// characters -> "jane"
value : := '{' '"' name '"' ':' '"' jane '"' '}'
Parsing is the process of taking a text input and producing some sort of a data structure (oftentimes a tree). There are different ways of parsing. One of which is called recursive descent or top-down parsing. In this type of parsing, we start from the first character of the input and recursively apply different production rules (like we did above) to create a tree structure. So, let's take a look at a simple example.
Assume this is our input:
{ "age" : 37 }
In recursive descent parsing, we'll start from the first character and try to apply production rules to create the tree. The process will unfold roughly this way:
- we need to parse value, proceed with any rule that matches
{matches with object rule, let's try to parse the rest as members- members start with parsing a member
- member needs a string first, then colon and finally another value
- well the next char is
"and it matches with string rule, so parse as string until we meet another" - the next is colon, so that matches with our rule
- we then have to parse a value (refer to step 0)
- finally we encounter
}- which means the object rule is complete,
- which then completes the value rule,
- which then completes json
The most obvious feature of recusive descent is the fact that the code actually resembles the grammar. It is used to parse JSON in golang's standard library. I've also written a top-down JSON parser.
Recursive descent/top-down parsing is awesome, but there are other ways of parsing as well. Bottom-up parsing is another approach that's extremely common. Especially with parser generators like yacc. As the name implies, in bottom-up parsing, we go from ground up. What this means is that we try to construct the parts of the parse tree that we know, and slowly grow the parse tree. For the example above, this could mean that we can construct a string first, and based on those, we can recognize that we have a member, and then create members and object and finally end up with the root json.
You can also check out Professor Brailsford's video on Computerphile on this subject as he explains stuff way better than I can:
In this tutorial, we will use a bottom-up approach, namely shift-reduce, to parse JSON. We will use Golang, but the concepts can be coded in any language.
Shift-Reduce Parsing
The shift-reduce parsing approach is a table-driven parsing approach that consists of 2 operations - shift and reduce. Here's the simplified version of the algorithm:
init:
0. the input to be parsed
1. the parse table (PT)
2. the parse stack (PS)
process:
3. read the next lookahead(LA) from input
4. does the combination of top element(s) of PS and LA
match with anything in PT?
5. yes, read the action from PT, if the action is:
5.1 shift - push LA to PS
5.2 reduce - replace the combination with
the production rule specified
in the PT
6. no, that's a parsing error, goto failure
7. if the input is fully consumed and the PS is empty
then goto return
8. goto step 3
There are more nuances, of course, but the above algorithm captures the main idea of the concept. The biggest challenge of implementing this algorithm is to create the parse table. Below is a very simple grammar and its parse tree taken from Wikipedia.
Grammar:
- E → E * B
- E → E + B
- E → B
- B → 0
- B → 1
It is able to parse expressions such as 1+1 or 1*1+0.
The Parse Tree:
| state | action | goto | |||||
|---|---|---|---|---|---|---|---|
| * | + | 0 | 1 | $ | E | B | |
| 0 | s1 | s2 | 3 | 4 | |||
| 1 | r4 | r4 | r4 | r4 | r4 | ||
| 2 | r5 | r5 | r5 | r5 | r5 | ||
| 3 | s5 | s6 | acc | ||||
| 4 | r3 | r3 | r3 | r3 | r3 | ||
| 5 | s1 | s2 | 7 | ||||
| 6 | s1 | s2 | 8 | ||||
| 7 | r1 | r1 | r1 | r1 | r1 | ||
| 8 | r2 | r2 | r2 | r2 | r2 | ||
rnis reducing to the rule numbernsnis shifting the lookahead and moving to statenaccindicates successful parsing- goto column indicates which state to move into
- A state in this context refers to a state machine state. The creation of a parse table is a long process that requires creating state machines. Check out this slide deck from MIT 6.035 to learn more about this process.
Long story short, even a parse table of a very small grammar is pretty large. Constructing it for JSON grammar is a big task. So, we will try to implement the shift-reduce algorithm without any parse table.
The rest of the article will be divided into the following chapters:
Grammar
We have already seen a glimpse of the JSON grammar. In this section, we will provide the complete grammar.
Let's start with some basic building blocks.
type elementType = string
const (
number elementType = "<number>"
integer elementType = "<integer>"
value elementType = "<value>"
array elementType = "<array>"
members elementType = "<object fields>"
member elementType = "<object field>"
element elementType = "<array element>"
elements elementType = "<array elements>"
object elementType = "<object>"
boolean elementType = "<boolean>"
exponent elementType = "<exponent>"
fraction elementType = "<fraction>"
/* the rest represents literal tokens */
ltObjectStart elementType = "{"
ltObjectEnd elementType = "}"
ltArrayStart elementType = "["
ltArrayEnd elementType = "]"
ltComma elementType = ","
ltColon elementType = ":"
ltFractionSymbol elementType = "."
ltBoolean elementType = "<bool_literal>"
ltExponent elementType = "e/E"
ltDigits elementType = "[0-9] (digits)"
ltNull elementType = "<null>"
ltSign elementType = "+/-"
ltString elementType = "<string_literal>"
)
- The following are the types of elements we will encounter while parsing the input JSON
- If you recall the Backus-Naur form we introduced earlier, there were the concepts of terminal and non-terminal tokens.
- In above code snippet, constants prefixed with
lt(meaning literal) are terminal tokens, while the rest are non-terminals.
Next is the structure that represents a grammar rule:
type grammarRule struct {
lhs string
rhs [ ] [ ] elementType
}
- It's pretty simple, a rule consists of a left-hand side (lhs) and the right-hand side (rhs).
And the following is the grammar:
var grammar = [ ] grammarRule {
{ value , [ ] [ ] elementType {
{ object } ,
{ array } ,
{ number } ,
{ boolean } ,
{ ltString } ,
{ ltNull } ,
} } ,
{ boolean , [ ] [ ] elementType {
{ ltBoolean } ,
} } ,
{ object , [ ] [ ] elementType {
{ ltObjectStart , ltObjectEnd } ,
{ ltObjectStart , members , ltObjectEnd } ,
} } ,
{ members , [ ] [ ] elementType {
{ member } ,
{ members , ltComma , member } ,
} } ,
{ member , [ ] [ ] elementType {
{ ltString , ltColon , value } ,
} } ,
{ array , [ ] [ ] elementType {
{ ltArrayStart , ltArrayEnd } ,
{ ltArrayStart , elements , ltArrayEnd } ,
} } ,
{ elements , [ ] [ ] elementType {
{ element } ,
{ elements , ltComma , element } ,
} } ,
{ element , [ ] [ ] elementType {
{ value } ,
} } ,
{ number , [ ] [ ] elementType {
{ integer , fraction , exponent } ,
{ integer , fraction } ,
{ integer , exponent } ,
{ integer } ,
} } ,
{ integer , [ ] [ ] elementType {
{ ltDigits } ,
{ ltSign , ltDigits } ,
} } ,
{ fraction , [ ] [ ] elementType {
{ ltFractionSymbol , ltDigits } ,
} } ,
{ exponent , [ ] [ ] elementType {
{ ltExponent , integer } ,
} } ,
}
- Let's take the rule
integer. It means that it's either a string of digits or a string of digits prefixed with a sign. numberis simply a combination of an integer, a fraction and an exponent where the fraction and the exponent could be omitted.
Lexer
Lexical analysis (or lexing, tokenization) is a process where we take a string input and try to tokenize it. A token is still a string but with a meaning attached to it, such as an identifier, a right or left parenthesis, keyword, etc. In the case of lexing JSON, our token types will be the following:
ltObjectStartltObjectEndltArrayStartltArrayEndltCommaltColonltFractionSymbolltBooleanltExponentltDigitsltNullltSignltString
When parsing JSON with the recursive descent, we do not need to tokenize the input, but tokenization makes things a bit easier for us when it comes to shift-reduce parsing.
Let's take a look at how lexing is done. This is the token structure that will hold the each lexed value.
type token struct {
value any
tokenType elementType
}
valueis the value of the token. For example,{value:"1234", tokenType:ltDigits}tokenTypeis any of the token types shown above
func lex ( input string ) ( [ ] token , * Error ) {
var tokens [ ] token
for i := 0 ; i < len ( input ) ; {
ch := input [ i ] // 1
if _ , ok := specialSymbols [ ch ] ; ok { // 2
tokens = append ( tokens , token {
tokenType : specialSymbols [ ch ] ,
} )
i ++
} else if ch == '"' { // 3
token , offset , err := lexString ( input , i )
if err != nil {
return nil , err
}
tokens = append ( tokens , token )
i += offset
} else if ch == 't' { // 4
if "true" == input [ i : i + 4 ] {
tokens = append ( tokens , token {
value : "true" ,
tokenType : ltBoolean ,
} )
i += 4
} else {
return nil , newError ( i , "unrecognized token" )
}
} else if ch == 'f' { // 5
if "false" == input [ i : i + 5 ] {
tokens = append ( tokens , token {
value : "false" ,
tokenType : ltBoolean ,
} )
i += 5
} else {
return nil , newError ( i , "unrecognized token" )
}
} else if ch == 'n' { // 6
if "null" == input [ i : i + 4 ] {
tokens = append ( tokens , token {
tokenType : ltNull ,
} )
i += 4
} else {
return nil , newError ( i , "unrecognized token" )
}
} else if isWhitespace ( ch ) {
for i < len ( input ) && isWhitespace ( input [ i ] ) {
i ++
}
} else if ch == 'e' || ch == 'E' {
tokens = append ( tokens , token {
tokenType : ltExponent ,
} )
i ++
} else if ch == '+' || ch == '-' {
tokens = append ( tokens , token {
value : ch ,
tokenType : ltSign ,
} )
i ++
} else if isDigit ( ch ) {
token , offset := lexDigits ( input , i )
tokens = append ( tokens , token )
i += offset
} else {
return nil , newError ( i , "unrecognized token" )
}
}
return tokens , nil
}
- Take each character of the input string, try to match it to a condition and lex accordingly
specialSymbolsis just a map of the symbols{}[],:.where the symbol maps to the token type.- If we encounter a double quotes, it means a string has started. The
lexStringfunction simply scans the input string from the positioniuntil it sees another double quote. If it does not, it will return an error.offsetis added to the index because we're done lexing untili+offsetposition. - If we encounter the char
t, we check if it is the start oftrue - If we encounter the char
f, we check if it is the start offalse - If we encounter the char
n, we check if it is the start ofnull - The rest of it is following the same logic. If we cannot find any match, that's an error.
This is an example of what the lexer produces for the string: {"hello": 12345}
[
{ < nil > { } // object start
{ hello < string_literal > } // hello string
{ < nil > : } // colon
{ 12345 [ 0 - 9 ] ( digits ) } // the digits
{ < nil > } } // object end
]
Parsing
As we already know the shift-reduce parsing approach has (at least) two operations: shift and reduce.
Shifting a token simply means pushing that token to the top of the parse stack. In our tableless approach, we will only shift in the following cases: - If lookahead is a prefix of the right hand side of any rule. - If a combination of top stack elements and the lookahead is a prefix of the right hand side of any rule.
There's a case, however, in which the combination might have a complete match, not just prefix: - If the full match is the only match, we still shift, but we have to reduce afterwards - If there are potentially longer matches than the full match, we only shift as shown above
Reducing operates on the elements at the top of the stacks. If the elements line up in a certain way that match the right hand side of a rule completely, then those elements are popped off the stack and replaced with the left-hand side of that rule.
In shift-reduce, we try to maximize the amount of elements we reduce in one iteration. This is the reason why we will shift the tokens if there's a prefix match which simply means that we can potentially reduce a longer rule. If we just cannot find any match, we do not shift, we try to reduce first, and then try with the same token to see if it matches with anything, given a reduce is performed. Finally, if we cannot perform neither shift nor reduce, that's an error.
Let's actually see this in action before we introduce the code. Let's take the grammar we saw earlier.
- E → E * B
- E → E + B
- E → B
- B → 0
- B → 1
and let's parse 1+1 with it
| parse stack | lookahead | unparsed tokens | explanation |
|---|---|---|---|
| [] | 1 | + 1 | nothing parsed yet, lookahead is 1 |
| [1] | + 1 | 1 completely matches with rule 5, so we shift and we'll reduce next | |
| [B] | + 1 | reduce 1 -> B by following rule 5 | |
| [B] | + | 1 | neither +, nor B+ is a prefix of anything - no shifting reduce B -> E by rule 3 |
| [E] | + | 1 | E+ is prefix of rule 2, so we shift |
| [E+] | 1 | 1 completely matches with rule 5, so we shift and we'll reduce next | |
| [E+1] | reduce 1 -> B by rule 5 | ||
| [E+B] | reduce E+B -> E by rule 2 | ||
| [E] | done |
Now that we've seen an example of how shift-reduce works, let's take a look at the code. But before that, we have a couple of useful structs and constants:
type stackElement struct {
value token
rule * jsonElement
}
type jsonElement struct {
value interface { }
jsonElementType elementType
}
const (
STRING JsonValueType = "STRING"
NUMBER JsonValueType = "NUMBER"
BOOL JsonValueType = "BOOLEAN"
NULL JsonValueType = "NULL"
OBJECT JsonValueType = "OBJECT"
ARRAY JsonValueType = "ARRAY"
)
type JsonValue struct {
Value interface { }
ValueType JsonValueType
}
stackElementholds the elements of the parse stack, it can hold either a literal token (terminal), or the left-hand side of the rule (non-terminal)jsonElementrepresents a non-terminal. It can be an integer, for example, and its value would be the digits of an integer.- The constants are all the potential types of values a JSON can have
JsonValueis a struct that simply holds a particular JSON value. It can be a number, it can be a boolean, or it can be anotherJsonValuethat holds a number or an object. You get the idea.
Now, finally the code (some currently irrelevant parts have been omitted for clarity):
func Parse ( input string ) ( JsonValue , * Error ) {
tokens , err := lex ( input ) // 1
// err check (omitted)
var stack [ ] * stackElement // 2
size := len ( tokens )
reducePerformed := true // 3
for i := 0 ; i < size ; {
lookahead := tokens [ i ] // 4
// 5
if matchType := checkIfAnyPrefixExists ( stack , lookahead ) ; matchType != noMatch {
i ++
// 5-1
stack = append ( stack , & stackElement { value : lookahead } )
if matchType == partialMatch { // 5-2
continue
}
// 5-3
// full match means that there's something we can reduce now
} else if ! reducePerformed { // 6
// return err (omitted)
}
// 7
if jsonElement , offset := action ( stack ) ; offset != 0 {
stack = stack [ : len ( stack ) - offset ] // 7-1
stack = append ( stack , & stackElement { // 7-2
rule : jsonElement ,
} )
reducePerformed = true // 7-3
} else {
reducePerformed = false
}
}
// 8
for {
if jsonElement , offset := action ( stack ) ; offset != 0 {
stack = stack [ : len ( stack ) - offset ]
stack = append ( stack , & stackElement {
rule : jsonElement ,
} )
} else {
break
}
}
// 9-1
if len ( stack ) != 1 {
return JsonValue { } , newError ( - 1 , "parsing failed..." )
}
// 9-2
values := stack [ 0 ] . rule . value . ( JsonValue ) . Value . ( [ ] JsonValue )
if len ( values ) != 1 {
return JsonValue { } , newError ( - 1 , "parsing failed..." )
}
return values [ 0 ] , nil
}
- Lexing gives us the tokens to parse.
tokensis simply an array of tokens stackis the parse stackreducePerformedkeeps track of whether a reduce was performed in the previous iteration of the loop or not. Because if we cannot shift and reduce, then that's an error.- We get the current lookahead. Lookahead is simply a
token - We will examine
checkIfAnyPrefixExistsfunction soon, but for now all we need to know is that it returns a value that's one of:noMatch, partialMatch, fullMatch- In any match condition, we push the value to the stack
- If it's a
partialMatch, however, we do not reduce, we move on to the next token, because there's a chance for reducing a longer rule. - If it's a
fullMatch, we move on to reducing
- If we couldn't shift anything and we didn't reduce in the previous step, that's an error.
- We will examine
actionfunction soon, but for now all we need to know is that it performs the reductions if there are any.offsetis the number of elements we need to remove off the top of the stack. If it is0, it simply means no reduction was possible.- We remove the necessary number of elements from the parse stack.
- We push the new element to the stack.
- We set the flag to true to indicate that a reduction was performed.
- Once we exhaust all the tokens, we try to repeatedly apply reduction until it is not possible.
- At the end of the parsing, our stack should only contain one element. This is because our JSON grammar can be reduced to a single rule.
- Checking to see if we have indeed one element in the parse stack.
- This line looks complicated, and a lot of casting is going on. To completely understand what's going on, we need to know what
toJsonfunction does which hasn't been introduced yet. So, it'd be a better idea to come back here once you read the corresponding section. Here's what happens:- In the case of successful parsing, no matter if we have an object, array, boolean, etc., these are reduced to
valuerule. - However, if we take a look at the grammar. We see that the
valueitself is a part ofelementwhich itself is a part ofelements. So, at the end, the actual parsed value is inside the JSON array. That's why we need these long casts. stack[0].rule.value.(JsonValue)takes the only element of the stack, extracts the value and casts it toJsonValuebecause the field we used to store values in our parse stack is generic.- The type of this new parsed object is
ARRAYand it'sValueis a[]JsonValue. So,.Value.([]JsonValue)this gives us the whole array - Then, after the size check to make sure we have indeed 1 element, we extract that one parsed JSON value.
- In the case of successful parsing, no matter if we have an object, array, boolean, etc., these are reduced to
Let's start talking about the functions we glossed over first, namely, checkIfAnyPrefixExists and action.
func checkIfAnyPrefixExists ( stack [ ] * stackElement , lookahead token ) prefixMatch {
var elems [ ] elementType // 1
stackSize := len ( stack )
if stackSize >= 2 { // 2
elems = append ( elems , stackToToken ( stack [ stackSize - 2 : ] ) ... )
} else if stackSize == 1 { // 3
elems = append ( elems , stackToToken ( stack [ 0 : 1 ] ) ... )
}
elems = append ( elems , lookahead . tokenType ) // 4
size := len ( elems )
for i := size - 1 ; i >= 0 ; i -- { // 5
// 6
if matchType := checkPrefix ( elems [ i : size ] ... ) ; matchType != noMatch {
return matchType
}
}
return noMatch // 7
}
- The basic idea is that we want to try out different combinations of the top 2 stack elements and the lookahead to see if they match with any rule. The reason why the max combination length is 3 (2 stack elements + 1 lookahead) is because that's the max length of the right hand side expansion of any rule in our JSON grammar.
elemsholds all the elements from the stack- If stack has more than 2 elements, we can simply pick the last 2
- If stack has exactly 1 element, we simply pick that element
- Finally we also add the lookahead to the mix
- We check the combinations in the following way:
- Assume
elemsis[s1 s2 la] - We wil check
[la]first - We will check
[s2 la]next - We will finally check
[s1 s2 la]
- Assume
- If there's any match, we will return the type of that match.
- If no match is found, we return
noMatch
We will skip over stackToToken and checkPrefix functions, but you're welcome to check them out on the Github repository.
Let's move on to the action function.
func action ( stack [ ] * stackElement ) ( * jsonElement , int ) {
stackSize := len ( stack )
// 1
var je * jsonElement
var offset int
for _ , rule := range grammar { // 2
for _ , production := range rule . rhs { // 3
size := len ( production )
if size > stackSize { // 4
continue
}
actual := topNOfStack ( stack , size ) // 5
matches := compare ( production , actual ) // 6
if matches && size > offset { // 7
je = & jsonElement {
// 8
value : rule . toJson ( stack [ len ( stack ) - size : ] ... ) ,
// 9
jsonElementType : rule . lhs ,
}
offset = size // 10
}
}
}
return je , offset // 11
}
- These are the values we will set and return
- Go over each rule in the grammar
- Go over each production of each rule
- If the production is longer than the stack itself, skip that rule
- Take the required number of elements off the top of the stack.
- Compare the actual stack elements with the production elements
- If there's a match, and the rule is bigger than the previously matched rule, set the values
toJsonis a new field added to thegrammarRulestruct which simply creates aJsonValuefor us, otherwise we would simply lose the parsed value. We'll take a look at how it works in a bit.- The type of the
jsonElementis the left-hand side of the rule. This means that if the matching combination was:[ltSign, ltDigit], thejsonElementTypewill beinteger(this rule exists in the grammar shared above). - Update the offset to the new, bigger size.
- Return the values.
After putting together all the functions we've described, we are going to be able to create a parse tree, but we will not be able to preserve the actual values of the JSON. We need to keep track of the actual values while building up the tree. That's where toJson function comes in. Let's start by checking the signature:
type grammarRule struct {
lhs string
rhs [ ] [ ] elementType
toJson func ( values ... * stackElement ) JsonValue
}
- It will take some stack elements. These elements will be the ones that have matched in the
actionstep (refer to #8 inactionfunction above). - Given those matched elements, it will try constructing the appropriate
JsonValue. - Each grammar entry will have a different strategy for generating the
JsonValue.
Let's take a look at an example:
grammarRule { integer , [ ] [ ] elementType {
{ ltDigits } ,
{ ltSign , ltDigits } ,
} , func ( values ... * stackElement ) JsonValue {
size := len ( values )
digits := values [ size - 1 ] // 1
var sign uint8 = '+' // 2
if size == 2 { // 3
sign = values [ 0 ] . Value ( ) . ( uint8 ) // - or +
}
v := fmt . Sprintf ( "%c%s" , sign , digits . Value ( ) ) // 4
// 5
return JsonValue {
Value : v ,
ValueType : NUMBER ,
}
} } ,
- The
integerrule has two productions. The last element of both productions is the digits. - An integer might have a sign, so the default value for sign is
+. - If
valueshas two elements, it means we have found a match for the second production rule:{ltSign, ltDigits}, so we will try to extract the value of that stack entry. - We create the integer string. We will have values such as
+2or-10 - Return the created
JsonValue
integer is a part of a bigger rule which is number. number has two more components which are fraction and exponent. Let's take a look at each and finally how they are all put together.
grammarRule { fraction , [ ] [ ] elementType {
{ ltFractionSymbol , ltDigits } ,
} , func ( values ... * stackElement ) JsonValue {
// 1
var fractionDigits = fmt . Sprintf ( ".%s" , values [ 1 ] . Value ( ) )
return JsonValue {
Value : fractionDigits ,
ValueType : NUMBER ,
}
} } ,
grammarRule { exponent , [ ] [ ] elementType {
{ ltExponent , integer } ,
} , func ( values ... * stackElement ) JsonValue {
// 2
var exponentExpr = fmt . Sprintf ( "e%s" , values [ 1 ] . asJsonValue ( ) . Value . ( string ) )
return JsonValue {
Value : exponentExpr ,
ValueType : NUMBER ,
}
} } ,
- The code for both
fractionandexponentare almost the same. So, let's take a look at the difference. - For
fraction, the most important part is thedigits, because the fraction symbol is always.anyway. So, we simply take the digits value, make it a string and return. - For
exponent, the most important part is theinteger. We saw earlier whatintegerreturned. TheValueof anintegeris a string, so we simply cast the last element toJsonValue(asJsonValueis just a helper method), and grab the string value.
Now that we have all the constituents of the number rule. Let's see how it's all put together. It's simpler than it looks:
grammarRule { number , [ ] [ ] elementType {
{ integer , fraction , exponent } ,
{ integer , fraction } ,
{ integer , exponent } ,
{ integer } ,
} , func ( values ... * stackElement ) JsonValue {
size := len ( values )
// 1
var integerValue = values [ 0 ] . asJsonValue ( ) . Value . ( string )
// 2
var fraction string
if size >= 2 && strings . HasPrefix ( values [ 1 ] . asJsonValue ( ) . Value . ( string ) , "." ) {
fraction = values [ 1 ] . asJsonValue ( ) . Value . ( string )
} else {
fraction = ""
}
// 3
var exponent string
if size == 2 && strings . HasPrefix ( values [ 1 ] . asJsonValue ( ) . Value . ( string ) , "e" ) {
exponent = values [ 1 ] . asJsonValue ( ) . Value . ( string )
} else if size == 3 && strings . HasPrefix ( values [ 2 ] . asJsonValue ( ) . Value . ( string ) , "e" ) {
exponent = values [ 2 ] . asJsonValue ( ) . Value . ( string )
} else {
exponent = ""
}
// 4
expression := fmt . Sprintf ( "%s%s%s" , integerValue , fraction , exponent )
// 5
value , err := strconv . ParseFloat ( expression , 64 ) // TODO: potential for an error!
if err != nil {
fmt . Printf ( "%s\n" , err . Error ( ) )
}
// 6
return JsonValue {
Value : value ,
ValueType : NUMBER ,
}
} } ,
- From the production rules, we know that the first element is always
integer. So, we grab the value as string fractionmay or or may not be the second element. So, if it is, we save the value to thefractionvariable.exponentcan be the second or the third element, or non-existent. So, based on these cases, we try to grab the string value and save it toexponentvariable- The format for the final expression is:
<integer><fraction><exponent>. Keep in mind thatfractionandexponentcan be empty. - With the help of golang's
strconv.ParseFloatmethod, we parse the expression to a numeric value. All the numeric values will be infloat64type. - Return the number.
There are a lot more toJson implementations that we did not cover, but the logic is the same across all. Take the passed values argument and based on the known grammar rules, try to create the target JsonValue object. Feel free to check out the Github repository for the full implementation.
That's technically it! We now have a working shift-reduce parser. Let's take a look at a test to get a better understanding of the input and the output.
func TestParse ( t * testing . T ) {
var inputJson = `{
"value": [
1239,
123.45
],
"name": "renault",
"token": true,
"hello": null
}`
t . Run ( "check json" , func ( t * testing . T ) {
json , err := Parse ( inputJson )
if err != nil {
t . Fatalf ( "%s" , err . Error ( ) )
}
expected := JsonValue {
ValueType : OBJECT ,
Value : map [ string ] JsonValue {
"value" : {
ValueType : ARRAY ,
Value : [ ] JsonValue {
{ ValueType : NUMBER , Value : float64 ( 1239 ) } ,
{ ValueType : NUMBER , Value : float64 ( 123.45 ) } ,
} ,
} ,
"name" : {
ValueType : STRING ,
Value : "renault" ,
} ,
"token" : {
ValueType : BOOL ,
Value : true ,
} ,
"hello" : {
ValueType : NULL ,
Value : nil ,
} ,
} ,
}
if ! reflect . DeepEqual ( json , expected ) {
t . Fail ( )
}
} )
}
Unmarshalling
We have already achieved our goal, but more often than not, people want to deserialize their JSON payload into a custom struct or a custom object. In this section, we will make use of Golang's reflect package to achive this. This section is going to be code-heavy.
// Unmarshal deserializes the parsed JsonValue into the provided object.
// Please keep in mind that obj needs to be a pointer
// to the object we want to deserialize the json into
func ( jv * JsonValue ) Unmarshal ( ptr any ) error {
v := reflect . ValueOf ( ptr ) // 1
if v . Kind ( ) != reflect . Pointer { // 2
return errors . New ( "expected: a pointer" )
}
kind := v . Elem ( ) . Kind ( ) // 3
if _ , ok := isSupported ( kind ) ; ! ok { // 4
return errors . New ( fmt . Sprintf ( "unsupported type: %s" , kind . String ( ) ) )
}
return jv . setValue ( kind , v . Elem ( ) ) // 5
}
Unmarshalis a function that takes aJsonValueobject and a pointer to a supposedly valid type.- We get the value of this provided object
- If it is not a pointer, we return an error
- If it's a pointer, we extract the type it's pointing to
- If that type is not supported, we return an error
isSupportedis simply a function that checks if thekindis a string, boolean, number, slice, etc.
- Once we pass all the checks, it's time to set the value.
type numberConverter = func ( i float64 ) interface { }
var numbers = map [ reflect . Kind ] numberConverter {
reflect . Int : func ( i float64 ) interface { } {
return int ( i )
} ,
reflect . Int8 : func ( i float64 ) interface { } {
return int8 ( i )
} ,
reflect . Int16 : func ( i float64 ) interface { } {
return int16 ( i )
} ,
// most of the converters are omitted
}
func ( jv * JsonValue ) setValue ( kind reflect . Kind , v reflect . Value ) error {
jt , _ := isSupported ( kind )
if jt != jv . ValueType { // 1
return errors . New ( fmt . Sprintf ( "type mismatch: expected: %s, provided: %s" , jv . ValueType , jt ) )
}
if kind == reflect . String { // 2
v . Set ( reflect . ValueOf ( jv . Value ) )
} else if kind == reflect . Bool { // 3
v . Set ( reflect . ValueOf ( jv . Value ) )
} else if converter , ok := numbers [ kind ] ; ok { // 4
v . Set ( reflect . ValueOf ( converter ( jv . Value . ( float64 ) ) ) )
} else if kind == reflect . Slice { // 5
if err := jv . handleSlice ( v , jt , ok ) ; err != nil {
return err
}
} else if kind == reflect . Struct { // 6
m := jv . Value . ( map [ string ] JsonValue ) // 6-1
for k , val := range m {
f := v . FieldByName ( k ) // 6-2
if err := val . setValue ( f . Kind ( ) , f ) ; err != nil { // 6-3
return err
}
}
}
return nil // 7
}
- If the type of the provided pointer does not match with what the JSON object contains, then that's a mismatch
- If the kind is string, we simply set the value to that object (
jv.Valueis already a string) - If the kind is boolean, we simply set the value to that object (
jv.Valueis already a boolean) - If the kind is a number, we have to find the correct converter for that number. The provided pointer can have a type of
uint,int32,float32etc.numbermap contains these converters - If the kind is a slice, we handle it in a separate function that'll be covered next
- if the kind is a struct
- We cast the value of the JSON object to a map of string to
JsonValueentries - For each entry in the map, we try to get the field with the same name from the struct. So, if our JSON contains
Namefield, we will look for aNamefield in the struct as well. Please keep in mind that in Golang, capitalized names indicate public accessibility.FieldByNamesimply cannot find the field if it's lowercase. Thus we have the restriction of making our JSON fields capitalized. This issue can be tackled in several ways, such as simply introducing a field name mapper, but it's out of scope for this tutorial. - Since value of the map entry is a
JsonValue, we can call thesetValuefunction recursively. If any error occurs, we return it
- We cast the value of the JSON object to a map of string to
- If no error occurs, we return
nilindicating success
Let's now take a look at handleSlice:
func ( jv * JsonValue ) handleSlice ( v reflect . Value , jt JsonValueType ) error {
dataType := v . Type ( ) . Elem ( ) . Kind ( ) // 1
values := jv . Value . ( [ ] JsonValue ) // 2
var jsonType = values [ 0 ] . ValueType // 3
for _ , value := range values { // 4
if value . ValueType != jsonType { // 4-1
return errors . New ( "json array does not have elements of one type" )
}
}
// 5
if jt , ok := isSupported ( dataType ) ; ! ok || jt != jsonType {
return errors . New ( "type mismatch for array" )
}
// 6
refSlice := reflect . MakeSlice ( reflect . SliceOf ( v . Type ( ) . Elem ( ) ) , len ( values ) , len ( values ) )
// 7
for i := 0 ; i < len ( values ) ; i ++ {
// 7-1
if err := values [ i ] . setValue ( dataType , refSlice . Index ( i ) ) ; err != nil {
return err
}
}
// 8
v . Set ( refSlice )
// 9
return nil
}
- We get the data type of the user-provided slice
- We cast the JSON value to a slice of
JsonValue - We get the actual type of the parsed JSON array
- We make sure that types of all the array entries match
- If they do not, we return an error
- If the sliec data type is not actually supported or the type of the slice and the type of the actual JSON array mismatch, we return an error
- We create a slice of the required size and of required type
- We go through each value in the JSON array
- We try to set the value for each entry, one by one. If we encounter an error, we return the error
refSliceis a slice we created inside the function, it's not the actual reference passed by the user, so we need to set user provided pointer to the slice we created.- Return
nilbecause no error occurred.
Here's a test showing how it's actually used:
type Person struct {
Name string
Age uint8
}
type ComplexPerson struct {
Person Person
Job string
LuckyNumbers [ ] int
}
func TestUnmarshalling ( t * testing . T ) {
input := `{
"Person": {
"Name": "John",
"Age": 25
},
"Job": "Plumber",
"LuckyNumbers": [-1, 0, 1, 1022]
}`
var cPerson ComplexPerson
expected := ComplexPerson {
Person : Person { Name : "John" , Age : 25 } ,
Job : "Plumber" ,
LuckyNumbers : [ ] int { - 1 , 0 , 1 , 1022 } ,
}
t . Run ( "test" , func ( t * testing . T ) {
json , _ := Parse ( input )
_ = json . Unmarshal ( & cPerson )
if ! reflect . DeepEqual ( cPerson , expected ) {
t . Fatalf ( "expected: '%-v'" , expected )
}
} )
}
That's it! You can find the full implementation in the Github repository.
Cheers!
References
- My code is mostly based on these videos from Shawn Lupoli:
- To learn more about parse trees:
- General useful information: