A Bytecode VM for Arithmetic: The Parser

3 months ago 2

In this series of posts, we write a bytecode compiler and a virtual machine for arithmetic in Haskell. We explore the following topics:

  • Parsing arithmetic expressions to Abstract Syntax Trees (ASTs).
  • Compiling ASTs to bytecode.
  • Interpreting ASTs.
  • Efficiently executing bytecode in a virtual machine (VM).
  • Disassembling bytecode and decompiling opcodes for debugging and testing.
  • Unit testing and property-based testing for our compiler and VM.
  • Benchmarking our code to see how the different passes perform.
  • All the while keeping an eye on performance.

In this series of posts, we write a bytecode compiler and a virtual machine for arithmetic in Haskell. We explore the following topics:

  • Parsing arithmetic expressions to Abstract Syntax Trees (ASTs).
  • Compiling ASTs to bytecode.
  • Interpreting ASTs.
  • Efficiently executing bytecode in a virtual machine (VM).
  • Disassembling bytecode and decompiling opcodes for debugging and testing.
  • Unit testing and property-based testing for our compiler and VM.
  • Benchmarking our code to see how the different passes perform.
  • All the while keeping an eye on performance.

This is the first post in a series of posts:

  1. A Bytecode VM for Arithmetic: The Parser
  2. A Bytecode VM for Arithmetic: The Compiler
  3. A Bytecode VM for Arithmetic: The Virtual Machine

Contents

  1. Introduction
  2. Expressions
  3. Parsing Expressions
  4. Error Handling
  5. The Parser
  6. Testing the Parser
  7. The AST Interpreter
  8. Testing the Interpreter

In this post, we write the parser for our expression language to an AST, and an AST interpreter.

Introduction

The language that we are going to work with is that of basic arithmetic expressions, with integer values, and addition, subtraction, multiplication and integer division operations. However, our expression language has a small twist: it is possible to introduce a variable using a let binding and use the variable in the expressions in the body of let1. Furthermore, we use the same syntax for let as Haskell does. Here are some examples of valid expressions in our language:

1 + 2 - 3 * 4 + 5 / 6 / 0 + 1 let x = 4 in x + 1 let x = 4 in let y = 5 in x + y let x = 4 in let y = 5 in x + let z = y in z * z let x = 4 in (let y = 5 in x + 1) + let z = 2 in z * z let x = (let y = 3 in y + y) in x * 3 let x = let y = 3 in y + y in x * 3 let x = let y = 1 + let z = 2 in z * z in y + 1 in x * 3

The only gotcha here is that the body of a let expression extends as far as possible while accounting for nested lets. It becomes clear when we look at parsed expressions later.

The eventual product is a command-line tool that can run different commands. Let’s start with a demo of the tool:

$ arith-vm -h Bytecode VM for Arithmetic written in Haskell Usage: arith-vm COMMAND Available options: -h,--help Show this help text Available commands: parse Parse expression to AST compile Parse and compile expression to bytecode disassemble Disassemble bytecode to opcodes decompile Disassemble and decompile bytecode to expression interpret-ast Parse expression and interpret AST interpret-bytecode Parse, compile and assemble expression, and interpret bytecode run Run bytecode generate Generate a random arithmetic expression $ arith-vm parse -h Usage: arith-vm parse [FILE] Parse expression to AST Available options: FILE Input file, pass - to read from STDIN (default) -h,--help Show this help text $ echo -n "let x = 1 in let y = 2 in y + x * 3" | arith-vm parse ( let x = 1 in ( let y = 2 in ( y + ( x * 3 ) ) ) ) $ echo -n "let x = 1 in let y = 2 in y + x * 3" | arith-vm compile > a.tbc $ hexdump -C a.tbc 00000000 00 01 00 00 02 00 03 01 03 00 00 03 00 06 04 02 |................| 00000010 01 02 01 |...| 00000013 $ arith-vm disassemble a.tbc OPush 1 OPush 2 OGet 1 OGet 0 OPush 3 OMul OAdd OSwap OPop OSwap OPop $ arith-vm decompile a.tbc ( let v0 = 1 in ( let v1 = 2 in ( v1 + ( v0 * 3 ) ) ) ) $ echo -n "let x = 1 in let y = 2 in y + x * 3" | arith-vm interpret-ast 5 $ echo -n "let x = 1 in let y = 2 in y + x * 3" | arith-vm interpret-bytecode 5 $ arith-vm run a.tbc 5 $ arith-vm generate ( ( ( ( let nD = ( 11046 - -20414 ) in ( let xqf = ( -15165 * nD ) in nD ) ) * 26723 ) / ( ( let phMuOI = ( let xQ = ( let mmeBy = -28095 in 22847 ) in 606 ) in 25299 ) * ( let fnoNQm = ( let mzZaZk = 29463 in 18540 ) in ( -2965 / fnoNQm ) ) ) ) * 21400 )

We can parse an expression, or compile it to bytecode. We can also disassemble bytecode to opcodes, or decompile it back to an expression. We can interpret an expression either as an AST or as bytecode. We can also run a bytecode file directly. Finally, we have a handy command to generate random expressions for testing/benchmarking purposes2.

Let’s start.

Expressions

Since this is Haskell, we start with listing many language extensions and imports:

{-# LANGUAGE GHC2021 #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE UndecidableInstances #-} module ArithVMLib ( Expr(..), Ident(..), Op(..), Pass(..), Error(..), Opcode(..), Bytecode, parse, compile', compile, decompile, disassemble, exprGen, interpretAST, interpretBytecode', interpretBytecode ) where import Control.Applicative ((<|>)) import Control.DeepSeq (NFData) import Control.Monad (void) import Control.Monad.Except (MonadError (..), runExcept, runExceptT) import Control.Monad.ST.Strict (runST) import Data.Attoparsec.ByteString.Char8 qualified as P import Data.Bits (shiftL, shiftR, (.&.), (.|.)) import Data.ByteString qualified as BS import Data.ByteString.Char8 qualified as BSC import Data.ByteString.Unsafe qualified as BS import Data.Char (toUpper) import Data.DList qualified as DList import Data.Foldable (toList) import Data.Int (Int16) import Data.List qualified as List import Data.Map.Strict qualified as Map import Data.Maybe (fromMaybe) import Data.Sequence (Seq (..), (|>)) import Data.Sequence qualified as Seq import Data.Set qualified as Set import Data.Vector.Primitive.Mutable qualified as MV import Data.Word (Word16, Word8) import GHC.Generics (Generic) import Test.QuickCheck qualified as Q
ArithVMLib.hs

We use the GHC2021 extension here that enables a lot of useful GHC extensions by default. We are using the bytestring and attoparsec libraries for parsing, dlist and containers for compilation, deepseq, mtl and vector for interpreting, and QuickCheck for testing.

The first step is to parse an expression into an Abstract Syntax Tree (AST). We represent the AST as Haskell Algebraic Data Types (ADTs):

data Expr = Num !Int16 | Var !Ident | BinOp !Op Expr Expr | Let !Ident Expr Expr deriving (Eq, Generic) newtype Ident = Ident {unIdent :: BS.ByteString} deriving (Eq, Ord, Generic) data Op = Add | Sub | Mul | Div deriving (Eq, Enum, Generic) instance NFData Expr instance Show Expr where show = \case Num n -> show n Var (Ident x) -> BSC.unpack x BinOp op a b -> "(" <> show a <> " " <> show op <> " " <> show b <> ")" Let (Ident x) a b -> "(let " <> BSC.unpack x <> " = " <> show a <> " in " <> show b <> ")" instance NFData Ident instance Show Ident where show (Ident x) = BSC.unpack x mkIdent :: String -> Ident mkIdent = Ident . BSC.pack instance NFData Op instance Show Op where show = \case Add -> "+" Sub -> "-" Mul -> "*" Div -> "/"
ArithVMLib.hs

We add Show instances for ADTs so that we can pretty-print the parsed AST3. Now, we can start parsing.

Parsing Expressions

The EBNF grammar for expressions is as follows:

expr ::= term | term space* ("+" | "-") term term ::= factor | factor space* ("*" | "/") factor factor ::= space* (grouping | num | var | let) grouping ::= "(" expr space* ")" num ::= "-"? [1-9] [0-9]* var ::= ident ident ::= ([a-z] | [A-Z])+ let ::= "let" space+ ident space* "=" expr space* "in" space+ expr space* space ::= " " | "\t" | "\n" | "\f" | "\r"

The expr, term, factor, and grouping productions take care of having the right precedence of arithmetic operations. The num and var productions are trivial. Our language is fairly oblivious of whitespaces; we allow zero-or-more spaces at most places.

The let expressions grammar is pretty standard, except we require one-or-more spaces after the let and in keywords to make them unambiguous.

We use the parser combinator library attoparsec for creating the parser. attoparsec works directly with bytestrings so we don’t incur the cost of decoding unicode characters45.

We write the parser in a top-down fashion, same as the grammar, starting with the expr parser:

-- expr ::= term | term space* ("+" | "-") term exprParser :: P.Parser Expr exprParser = chainBinOps termParser $ \case '+' -> pure Add '-' -> pure Sub op -> fail $ "Expected '+' or '-', got: " <> show op -- term ::= factor | factor space* ("*" | "/") factor termParser :: P.Parser Expr termParser = chainBinOps factorParser $ \case '*' -> pure Mul '/' -> pure Div op -> fail $ "Expected '*' or '/', got: " <> show op chainBinOps :: P.Parser Expr -> (Char -> P.Parser Op) -> P.Parser Expr chainBinOps operandParser operatorParser = operandParser >>= rest where rest !expr = ( do P.skipSpace c <- P.anyChar operator <- operatorParser c operand <- operandParser rest $ BinOp operator expr operand ) <|> pure expr {-# INLINE chainBinOps #-}
ArithVMLib.hs

Both exprParser and termParser chain the right higher precedence parsers with the right operators between them6 using the chainBinOps combinator.

-- factor ::= space* (grouping | num | var | let) factorParser :: P.Parser Expr factorParser = do P.skipSpace P.peekChar' >>= \case '(' -> groupingParser '-' -> numParser c | P.isDigit c -> numParser c | c /= 'l' -> varParser _ -> varParser <|> letParser -- grouping ::= "(" expr space* ")" groupingParser :: P.Parser Expr groupingParser = P.char '(' *> exprParser <* P.skipSpace <* P.char ')'
ArithVMLib.hs

factorParser uses lookahead to dispatch between one of the primary parsers, which is faster than using backtracking. groupingParser simply skips the parenthesis, and recursively calls exprParser.

-- num ::= "-"? [1-9] [0-9]* numParser :: P.Parser Expr numParser = do n <- P.signed P.decimal P.<?> "number" if validInt16 n then pure $ Num $ fromIntegral n else fail $ "Expected a valid Int16, got: " <> show n where validInt16 :: Integer -> Bool validInt16 i = fromIntegral (minBound @Int16) <= i && i <= fromIntegral (maxBound @Int16)
ArithVMLib.hs

numParser uses the signed and decimal parsers from the attoparsec library to parse an optionally signed integer. We restrict the numbers to 2-byte integers (-32768–32767 inclusive)7. The <?> helper from attoparsec names parsers so that the error message shown in case of failures point to the right parser.

-- var ::= ident varParser :: P.Parser Expr varParser = Var <$> identParser -- ident ::= ([a-z] | [A-Z])+ identParser :: P.Parser Ident identParser = do ident <- P.takeWhile1 P.isAlpha_ascii P.<?> "identifier" if isReservedKeyword ident then fail $ "Expected identifier, got: \"" <> BSC.unpack ident <> "\", which is a reversed keyword" else pure $ Ident ident {-# INLINE identParser #-} isReservedKeyword :: BSC.ByteString -> Bool isReservedKeyword = \case "let" -> True "in" -> True _ -> False {-# INLINE isReservedKeyword #-}
ArithVMLib.hs

varParser and identParser are straightforward. We restrict identifiers to upper-and-lowercase ASCII alphabetic letters. We also check that our reserved keywords (let and in) are not used as identifiers.

Finally, we write the parser for let expressions:

-- let ::= "let" space+ ident space* "=" expr space* "in" space+ expr space* letParser :: P.Parser Expr letParser = do expect "let" <* skipSpace1 !x <- identParser P.skipSpace *> expect "=" assign <- exprParser P.skipSpace *> expect "in" <* skipSpace1 body <- exprParser <* P.skipSpace pure $ Let x assign body where expect s = void (P.string s) <|> do found <- P.manyTill P.anyChar (void P.space <|> P.endOfInput) let found' = if found == "" then "end-of-input" else "\"" <> found <> "\"" fail $ "Expected: \"" <> BSC.unpack s <> "\", got: " <> found' skipSpace1 = P.space *> P.skipSpace
ArithVMLib.hs

In letParser we use identParser to parse the variable name, and recursively call exprParser to parse the assignment and body expressions, while making sure to correctly parse the spaces. The helper parser expect is used to parse known string tokens (let, = and in), and provide good error messages in case of failures. Talking about error messages …

Error Handling

Let’s figure out an error handling strategy. We use an Error type wrapped in Either to propagate the errors in our program:

data Error = Error !Pass !String deriving (Generic) instance Eq Error where (Error _ m1) == (Error _ m2) = m1 == m2 instance Show Error where show (Error pass msg) = show pass <> " error: " <> msg instance NFData Error data Pass = Parse | Compile | Decompile | Disassemble | InterpretAST | InterpretBytecode deriving (Show, Eq, Generic) instance NFData Pass type Result = Either Error
ArithVMLib.hs

The Error type also captures the Pass in which the error is thrown. Result is a type alias that represents either an error or a result. Finally, we put all the parsers together to write the parse function.

The Parser

Our parse function uses the parse function from attoparsec to run the exprParser over an input.

parse :: BS.ByteString -> Result Expr parse = processResult . P.parse (exprParser <* P.skipSpace) where processResult = \case P.Done "" res -> pure res P.Done leftover _ -> throwParseError $ "Leftover input: \"" <> BSC.unpack leftover <> "\"" P.Partial f -> processResult $ f "" P.Fail _ [] err -> throwParseError . capitalize . fromMaybe err $ List.stripPrefix "Failed reading: " err P.Fail "" ctxs _ -> throwParseError $ "Expected: " <> formatExpected ctxs <> ", got: end-of-input" P.Fail leftover ctxs _ -> throwParseError $ "Expected: " <> formatExpected ctxs <> ", got: \"" <> head (words $ BSC.unpack leftover) <> "\"" capitalize ~(c : cs) = toUpper c : cs formatExpected ctxs = case last ctxs of [c] -> "\'" <> [c] <> "\'" s -> s throwParseError = throwError . Error Parse
ArithVMLib.hs

The processResult function deals with intricacies of how attoparsec returns the parsing result. Basically, we inspect the returned result and throw appropriate errors with useful error messages. We use throwError from the MonadError typeclass that works with all its instances, which Either is one of.

The parser is done. But as good programmers, we must make sure that it works correctly. Let’s write some unit tests.

Testing the Parser

We use the hspec library to write unit tests for our program. Each test is written as a spec8.

{-# LANGUAGE GHC2021 #-} {-# LANGUAGE OverloadedStrings #-} module Main (main) where import ArithVMLib import Control.Monad (forM_, (>=>)) import Data.ByteString.Char8 qualified as BSC import Data.Int (Int16) import Data.Sequence qualified as Seq import Test.Hspec import Test.Hspec.QuickCheck import Test.QuickCheck qualified as Q parserSpec :: Spec parserSpec = describe "Parser" $ do forM_ parserSuccessTests $ \(input, result) -> it ("parses: \"" <> BSC.unpack input <> "\"") $ do (show <$> parse input) `shouldBe` Right result forM_ parserErrorTests $ \(input, err) -> it ("fails for: \"" <> BSC.unpack input <> "\"") $ do parse input `shouldSatisfy` \case Left (Error Parse msg) | err == msg -> True _ -> False parserSuccessTests :: [(BSC.ByteString, String)] parserSuccessTests = [ ( "1 + 2 - 3 * 4 + 5 / 6 / 0 + 1", "((((1 + 2) - (3 * 4)) + ((5 / 6) / 0)) + 1)" ), ( "1+2-3*4+5/6/0+1", "((((1 + 2) - (3 * 4)) + ((5 / 6) / 0)) + 1)" ), ( "1 + -1", "(1 + -1)" ), ( "let x = 4 in x + 1", "(let x = 4 in (x + 1))" ), ( "let x=4in x+1", "(let x = 4 in (x + 1))" ), ( "let x = 4 in let y = 5 in x + y", "(let x = 4 in (let y = 5 in (x + y)))" ), ( "let x = 4 in let y = 5 in x + let z = y in z * z", "(let x = 4 in (let y = 5 in (x + (let z = y in (z * z)))))" ), ( "let x = 4 in (let y = 5 in x + 1) + let z = 2 in z * z", "(let x = 4 in ((let y = 5 in (x + 1)) + (let z = 2 in (z * z))))" ), ( "let x=4in 2+let y=x-5in x+let z=y+1in z/2", "(let x = 4 in (2 + (let y = (x - 5) in (x + (let z = (y + 1) in (z / 2))))))" ), ( "let x = (let y = 3 in y + y) in x * 3", "(let x = (let y = 3 in (y + y)) in (x * 3))" ), ( "let x = let y = 3 in y + y in x * 3", "(let x = (let y = 3 in (y + y)) in (x * 3))" ), ( "let x = let y = 1 + let z = 2 in z * z in y + 1 in x * 3", "(let x = (let y = (1 + (let z = 2 in (z * z))) in (y + 1)) in (x * 3))" ) ] parserErrorTests :: [(BSC.ByteString, String)] parserErrorTests = [ ("", "Not enough input"), ("1 +", "Leftover input: \"+\""), ("1 & 1", "Leftover input: \"& 1\""), ("1 + 1 & 1", "Leftover input: \"& 1\""), ("1 & 1 + 1", "Leftover input: \"& 1 + 1\""), ("(", "Not enough input"), ("(1", "Expected: ')', got: end-of-input"), ("(1 + ", "Expected: ')', got: \"+\""), ("(1 + 2", "Expected: ')', got: end-of-input"), ("(1 + 2}", "Expected: ')', got: \"}\""), ("66666", "Expected a valid Int16, got: 66666"), ("-x", "Expected: number, got: \"-x\""), ("let 1", "Expected: identifier, got: \"1\""), ("let x = 1 in ", "Not enough input"), ( "let let = 1 in 1", "Expected identifier, got: \"let\", which is a reversed keyword" ), ( "let x = 1 in in", "Expected identifier, got: \"in\", which is a reversed keyword" ), ("let x=1 inx", "Expected: space, got: \"x\""), ("letx = 1 in x", "Leftover input: \"= 1 in x\""), ("let x ~ 1 in x", "Expected: \"=\", got: \"~\""), ("let x = 1 & 2 in x", "Expected: \"in\", got: \"&\""), ("let x = 1 inx", "Expected: space, got: \"x\""), ("let x = 1 in x +", "Leftover input: \"+\""), ("let x = 1 in x in", "Leftover input: \"in\""), ("let x = let x = 1 in x", "Expected: \"in\", got: end-of-input") ]
ArithVMSpec.hs

We have a bunch of tests for the parser, testing both success and failure cases. Notice how spaces are treated in the expressions. Also notice how the let expressions are parsed. We’ll add property-based tests for the parser in the next post.

There is not much we can do with the parsed ASTs at this point. Let’s write an interpreter to evaluate our ASTs.

The AST Interpreter

The AST interpreter is a standard and short recursive interpreter with an environment mapping variables to their values:

interpretAST :: Expr -> Result Int16 interpretAST = go Map.empty where go env = \case Num n -> pure n Var x -> case Map.lookup x env of Just v -> pure v Nothing -> throwError . Error InterpretAST $ "Unknown variable: " <> BSC.unpack (unIdent x) BinOp op a b -> do !a' <- go env a !b' <- go env b interpretOp InterpretAST a' b' op Let x assign body -> do !val <- go env assign go (Map.insert x val env) body interpretOp :: (MonadError Error m) => Pass -> Int16 -> Int16 -> Op -> m Int16 interpretOp pass a b = \case Add -> pure $! a + b Sub -> pure $! a - b Mul -> pure $! a * b Div | b == 0 -> throwError $ Error pass "Division by zero" Div | b == (-1) && a == minBound -> throwError $ Error pass "Arithmetic overflow" Div -> pure $! a `div` b {-# INLINE interpretOp #-}
ArithVMLib.hs

This interpreter serves both as a performance baseline for the bytecode VM we write later, and as a definitional interpreter for testing the VM. We extract the interpretOp helper function for later reuse9. interpretOp is careful in detecting division-by-zero and arithmetic overflow errors, but we ignore possible integer overflow/underflow errors that may be caused by the arithmetic operations.

Testing the Interpreter

We write some unit tests for the interpreter following the same pattern as the parser:

astInterpreterSpec :: Spec astInterpreterSpec = describe "AST interpreter" $ do forM_ astInterpreterSuccessTests $ \(input, result) -> it ("interprets: \"" <> BSC.unpack input <> "\"") $ do parseInterpret input `shouldBe` Right result forM_ astInterpreterErrorTests $ \(input, err) -> it ("fails for: \"" <> BSC.unpack input <> "\"") $ do parseInterpret input `shouldSatisfy` \case Left (Error InterpretAST msg) | err == msg -> True _ -> False where parseInterpret = parse >=> interpretAST astInterpreterSuccessTests :: [(BSC.ByteString, Int16)] astInterpreterSuccessTests = [ ("1", 1), ("1 + 2 - 3 * 4 + 5 / 6 / 1 + 1", -8), ("1 + (2 - 3) * 4 + 5 / 6 / (1 + 1)", -3), ("1 + -1", 0), ("1 * -1", -1), ("let x = 4 in x + 1", 5), ("let x = 4 in let y = 5 in x + y", 9), ("let x = 4 in let y = 5 in x + let z = y in z * z", 29), ("let x = 4 in (let y = 5 in x + y) + let z = 2 in z * z", 13), ("let x = let y = 3 in y + y in x * 3", 18), ("let x = let y = 1 + let z = 2 in z * z in y + 1 in x * 3", 18) ] astInterpreterErrorTests :: [(BSC.ByteString, String)] astInterpreterErrorTests = [ ("x", "Unknown variable: x"), ("let x = 4 in y + 1", "Unknown variable: y"), ("let x = y + 1 in x", "Unknown variable: y"), ("let x = x + 1 in x", "Unknown variable: x"), ("1/0", "Division by zero"), ("-32768 / -1", "Arithmetic overflow") ]
ArithVMSpec.hs

Now, we can run the parser and interpreter tests to make sure that everything works correctly.

main :: IO () main = hspec $ do parserSpec astInterpreterSpec
ArithVMSpec.hs
Output of the test run $ cabal test -O2 Running 1 test suites... Test suite specs: RUNNING... Parser parses: "1 + 2 - 3 * 4 + 5 / 6 / 0 + 1" [✔] parses: "1+2-3*4+5/6/0+1" [✔] parses: "1 + -1" [✔] parses: "let x = 4 in x + 1" [✔] parses: "let x=4in x+1" [✔] parses: "let x = 4 in let y = 5 in x + y" [✔] parses: "let x = 4 in let y = 5 in x + let z = y in z * z" [✔] parses: "let x = 4 in (let y = 5 in x + 1) + let z = 2 in z * z" [✔] parses: "let x=4in 2+let y=x-5in x+let z=y+1in z/2" [✔] parses: "let x = (let y = 3 in y + y) in x * 3" [✔] parses: "let x = let y = 3 in y + y in x * 3" [✔] parses: "let x = let y = 1 + let z = 2 in z * z in y + 1 in x * 3" [✔] fails for: "" [✔] fails for: "1 +" [✔] fails for: "1 & 1" [✔] fails for: "1 + 1 & 1" [✔] fails for: "1 & 1 + 1" [✔] fails for: "(" [✔] fails for: "(1" [✔] fails for: "(1 + " [✔] fails for: "(1 + 2" [✔] fails for: "(1 + 2}" [✔] fails for: "66666" [✔] fails for: "-x" [✔] fails for: "let 1" [✔] fails for: "let x = 1 in " [✔] fails for: "let let = 1 in 1" [✔] fails for: "let x = 1 in in" [✔] fails for: "let x=1 inx" [✔] fails for: "letx = 1 in x" [✔] fails for: "let x ~ 1 in x" [✔] fails for: "let x = 1 & 2 in x" [✔] fails for: "let x = 1 inx" [✔] fails for: "let x = 1 in x +" [✔] fails for: "let x = 1 in x in" [✔] fails for: "let x = let x = 1 in x" [✔] AST interpreter interprets: "1" [✔] interprets: "1 + 2 - 3 * 4 + 5 / 6 / 1 + 1" [✔] interprets: "1 + (2 - 3) * 4 + 5 / 6 / (1 + 1)" [✔] interprets: "1 + -1" [✔] interprets: "1 * -1" [✔] interprets: "let x = 4 in x + 1" [✔] interprets: "let x = 4 in let y = 5 in x + y" [✔] interprets: "let x = 4 in let y = 5 in x + let z = y in z * z" [✔] interprets: "let x = 4 in (let y = 5 in x + y) + let z = 2 in z * z" [✔] interprets: "let x = let y = 3 in y + y in x * 3" [✔] interprets: "let x = let y = 1 + let z = 2 in z * z in y + 1 in x * 3" [✔] fails for: "x" [✔] fails for: "let x = 4 in y + 1" [✔] fails for: "let x = y + 1 in x" [✔] fails for: "let x = x + 1 in x" [✔] fails for: "1/0" [✔] fails for: "-32768 / -1" [✔] Finished in 0.0061 seconds 53 examples, 0 failures Test suite specs: PASS

Awesome, it works! That’s it for this post. In the next part, we write a bytecode compiler for our expression AST.

If you have any questions or comments, please leave a comment below. If you liked this post, please share it. Thanks for reading!

Like, repost, or comment

Send a Webmention for this post

Posted by at

Like this post? Subscribe to get future posts by email.

Read Entire Article