Skip to content

HW 6: PicPlot Part 2: Parsing

Finish and submit HW 5 before you open this assignment's repo

The HW 6 starter repo contains PiqEvaluation.hs, a complete, working solution to HW 5's compiler. You need it there so your parser has something to feed into. But that also means you must not open, copy, or clone the piq-parser repo until you (and your partner, if you have one) have completed and submitted HW 5.

When you submit HW 6, Gradescope will ask you to check a box confirming you didn't look at HW 6's repo until after you had submitted HW 5. If you can't honestly check that box, email your instructor before you submit. Don't guess.

You're welcome to read this page before then. Nothing on it gives away HW 5.

Last week you wrote a compiler that turns piq programs, written as Haskell abstract syntax, into Python programs for a pen plotter. This week you write the parser: the part that reads piq source code and recovers its structure as abstract syntax. Once your parser works, piq becomes a real language you can write in .piq files and turn into drawings with one command. This is the full-sized version of what you started in Lab 06, built with the parser combinators from Module 6.1 and Module 6.2. Keep the Parser Combinator Reference handy. The ParserBase Reference covers the lower-level pieces, though you won't need them.

How you'll get materials and submit this assignment

Only after you've submitted HW 5 (see the box above): getting the starter code and turning in your work follows the same process as every homework this semester. See The Homework Workflow for the full walkthrough. In short: from the piq-parser template repo, make your own private copy, clone it to the course server, and submit your work on Gradescope. The rest of this page covers everything the assignment asks of you.

As in HW 5, you'll also need Python 3 with matplotlib for preview pictures. It's already set up on the course server.

Here's the kind of source code your parser will read:

define box(size, gap) {
    square size
    move right size + gap
}
for i from 1 to 5 {
    box(i * 8, 10)
}

and the abstract syntax it must produce, the same AST you compiled in HW 5:

Program [ProcDef "box" ["size","gap"] [Square (Var "size"), Move Rt (Add (Var "size") (Var "gap"))]]
        [For "i" (Num 1.0) (Num 5.0) [Call "box" [Mul (Var "i") (Num 8.0), Num 10.0]]]
    programs/example.piq   (source code)
        |
        |  YOUR WORK: PiqParser.hs
        v
    AST
        |
        |  PROVIDED: a complete compiler (HW 5's assignment)
        v
    Python  ──>  preview picture  ──>  the real pen plotter (in the lab)

As in HW 5, the work is split into many small steps, and every step ends with a Check your work box saying exactly what to type and what you should see. Don't move on until the check passes.

What You'll Do

  • Read a grammar and turn each rule into a parser.
  • Use parser combinators to build big parsers out of small ones.
  • Handle reserved words, and see why a keyword must be matched as a whole word.
  • Implement operator precedence (* before +) and left associativity (10 - 3 - 2 means (10 - 3) - 2).
  • Parse statements, blocks, loops, procedure calls and definitions, and whole programs.
  • Check your parser with round trips: printing an AST as source code and parsing it back gives the same AST.
  • Compile real .piq files into drawings.

Checklist

  • Submit HW 5 first (see the box at the top of this page)
  • Get the assignment materials (see The Homework Workflow)
  • Part 1: names and keywords
  • Part 2: atoms
  • Part 3: precedence and associativity
  • Part 4: basic statements
  • Part 5: statement sequences and blocks
  • Part 6: loops
  • Part 7: procedure calls
  • Part 8: procedure definitions
  • Part 9: whole programs
  • Part 10: the whole pipeline, and your own drawing
  • Turn in the assignment (see Turning It In)

Collaboration

You can work with a partner on this assignment. If you do, you must cooperate on every part, follow the pair-programming rules from the syllabus at all times (even while writing prose or math), and share roles equally.


Getting Started

Everything in this handout is run from the top folder of your repository. Read The piq Language below (it's also in your repo as SYNTAX.md). It's the exact description of what your parser must accept.

The Files

File What it is
PiqParser.hs YOU WILL EDIT. The parser. Every place to fill in is marked -- TODO (Part N).
SYNTAX.md PROVIDED. The grammar of piq, the same as The piq Language below. It also shows which parser handles which rule.
ParserCombinators.hs, ParserBase.hs PROVIDED: DO NOT EDIT. The course's parser-combinator library. You do not need to understand how these files are implemented. You only use what ParserCombinators exports (see the cheat sheet).
PiqAST.hs PROVIDED: DO NOT EDIT. The abstract syntax, the same as HW 5.
PiqEvaluation.hs PROVIDED: DO NOT EDIT. A complete compiler: a HW 5 solution (see below).
PiqPretty.hs PROVIDED: DO NOT EDIT. The pretty-printer: abstract syntax back into source code. Used for round trips.
Python.hs, Tools.hs, piq2py.hs, py2png.hs, piq2png.hs, python/ PROVIDED: DO NOT EDIT. The Python side and the command-line tools.
Makefile PROVIDED. make (build the tools), make test, make part1 … make part10, make clean.
test/*.hs TESTS. One module per group of parts (NameSpec, ExprParserSpec, BasicStmtParserSpec, BlockParserSpec, ProcParserSpec, RoundTripSpec, PipelineSpec).
Examples.hs EXAMPLES. ghci Examples.hs loads everything. It also contains HW 5's example programs, the abstract syntax of the programs in programs/, and some GHCI helpers.
programs/*.piq EXAMPLES. piq programs in source form.

The piq Language

This is what piq source code looks like. It is exactly what your parser (PiqParser.hs) must read, and what the provided pretty-printer (PiqPretty.hs) writes. Sizes and distances are in millimetres. up is toward the top of the page.

Example

define box(size, gap) {
    square size
    move right size + gap
}

x = 20
for i from 1 to 5 {
    box(i * 8, 10)
}
circle x / 2
line down 30
dot

Grammar

program    ::= procdef* stmt*                        -- all definitions come first

procdef    ::= "define" name "(" names? ")" block
names      ::= name ("," name)*
block      ::= "{" stmt* "}"

stmt       ::= name "=" expr                         -- assignment
             | "dot"
             | "square" expr                         -- side length
             | "rectangle" expr expr                 -- width, then height
             | "circle" expr                         -- radius
             | "move" direction expr                 -- travel without drawing
             | "line" direction expr                 -- travel while drawing
             | "for" name "from" expr "to" expr block
             | name "(" exprs? ")"                   -- procedure call
exprs      ::= expr ("," expr)*
direction  ::= "left" | "right" | "up" | "down"

expr       ::= term (("+" | "-") term)*              -- left associative
term       ::= factor (("*" | "/") factor)*          -- left associative, binds tighter
factor     ::= number | name | "(" expr ")"

Which Parser Handles Which Rule

Grammar rule Parser in PiqParser.hs Part
names and reserved words name, keyword 1
factor factor 2
expr, term, operators expr, term, addOp, mulOp (with combineLeft) 3
direction, and each stmt alternative except for and calls direction, dotStmt, squareStmt, rectangleStmt, circleStmt, moveStmt, lineStmt, assignment 4
stmt*, block stmts, block 5
"for" … forLoop 6
name "(" exprs? ")" call 7
procdef procDef 8
program program 9

Tokens

  • Whitespace: spaces, tabs and newlines are all the same, and only separate tokens. There are no semicolons, and a whole program could be written on one line.
  • Names (variables, procedures, parameters): a letter followed by letters and digits. This is the course library's identifier, so there are no underscores: starGap is fine, star_gap is not.
  • Reserved words: these cannot be names. dot square rectangle circle move line left right up down for from to define
  • Numbers: the course library's double: 20, 2.5, -3, 1.0e-2. A leading - is part of the number. There is no minus operator for anything other than a number literal; write 0 - x to negate a variable.
  • Case: keywords are lowercase.

Rules Worth Knowing

  • Distances are always required: move right 10, never move right.
  • rectangle W H with a negative H needs parentheses: rectangle 30 (-10). Without them, rectangle 30 -10 reads as rectangle (30 - 10) followed by a missing height.
  • Calls always have parentheses, even with no arguments: star().
  • Definitions come before statements, but a procedure may call any procedure, including one defined later.
  • Recursion (a procedure that calls itself, directly or indirectly) is not allowed. The compiler does not check for it.

How the Pretty-Printer Writes Programs

  • Operations: every one gets its own parentheses. x + 5 * 2 prints as (x + (5 * 2)). This is valid input. It's just more parentheses than a person would write.
  • Whole numbers print without .0 (20, not 20.0).
  • Negative numbers print in parentheses: move left (-45).
  • Indentation: four spaces per block level. Each definition is followed by a blank line.
  • Not preserved: blank lines and layout inside the program, and the choice of fewer parentheses. The AST does not record them.

How to Work: GHCI, Tests, the Tools

GHCI

$ ghci Examples.hs
This loads your parser, the parser library, the compiler and the pretty-printer. After editing PiqParser.hs, type :reload (or :r).

When a parse fails, GHCI shows an exception. The first line is the message, starting with where in the input the problem was (<input>:line:column):

ghci> parse number "x"
*** Exception: <input>:1:1 -- <num> expected
CallStack (from HasCallStack):
  error, called at ./ParserBase.hs:80:13 in main:ParserBase
If the parser's result is a string, GHCI has already printed the opening quote " when the error arrives, so you see "*** Exception: …. That's just GHCI; the " isn't part of the message.

Tests

Command Runs
make test all the tests
make part1 … make part10 only one part's tests
runghc -itest test/Spec.hs --match "Step 3.2" only one step's tests

Each step below says which command to run and exactly what the last line should say.

About tests that pass early: many tests check that bad input is rejected ("… is rejected"). An unfinished parser rejects everything, so those tests pass from the start. They become meaningful when your parser starts accepting things: then they make sure it doesn't accept too much. The test summary for a step is only fully meaningful once you've done that step.

The Tools

make builds three command-line tools into bin/. They need your parser, so they're most useful at the end (Part 10):

bin/piq2py  file.piq [-o other.py]    piq source -> Python (default: file.py)
bin/py2png  file.py  [-o other.png]   Python -> picture, drawn by the mock plotter (default: file.png)
bin/piq2png file.piq [-o other.png]   both: file.py and file.png


The Provided Compiler

PiqEvaluation.hs is provided. It is one possible solution to HW 5's compiler assignment. You do not need to modify or understand every detail of it to complete this week's work.

You may replace it with your own HW 5 PiqEvaluation.hs if you want, but you don't need to. The supplied compiler lets you work on parsing independently of any bugs that may remain in your first assignment.

This file is a HW 5 solution

This is the reason for the warning at the top of this page: PiqEvaluation.hs is a complete, working answer to HW 5. You should only be reading it after you've submitted HW 5, and Gradescope will ask you to confirm that you did.


The Parser Library: A Cheat Sheet

A Parser a reads some input and produces a value of type a. parse p "input" runs parser p on the whole input (it's an error if anything is left over). All of these skip whitespace (spaces, tabs, newlines) before what they read.

Combinator Meaning Example (in GHCI) → result
number, double an integer / a decimal number parse double "2.5" → 2.5
text "abc" exactly the characters abc parse (text "hello") " hello" → "hello"
sym 'c' exactly the character c used as a separator: sym ','
identifier a letter followed by letters and digits parse identifier "size2" → "size2"
p <+> q p then q; the result is a pair parse (number <+> number) "3 4" → (3,4)
p <-+> q p then q; keep only q's result parse (text "move" <-+> number) "move 5" → 5
p <+-> q p then q; keep only p's result
p <|> q try p; if it fails, try q instead parse (text "a" <|> text "b") "b" → "b"
p >>=: f run p, then apply the function f to its result parse (number >>=: (* 2)) "21" → 42
p >>: v run p, then ignore its result and give v parse (text "yes" >>: True) "yes" → True
p <=> test run p, but fail unless its result passes test see Part 1
many p p zero or more times; a list of results parse (many digit) "123" → "123"
sepBy p sep zero or more ps separated by seps parse (sepBy number (sym ',')) "1, 2, 3" → [1,2,3]
parens p, braces p p inside ( ) / { } parse (parens number) "( 7 )" → 7
pfail always fails the starting value of unfinished parsers

How they group. >>=: and >>: bind most loosely, <|> binds more tightly, and <+>, <-+>, <+-> bind most tightly. So keyword "circle" <-+> expr >>=: Circle means "(keyword then expr), then apply Circle", which is what you want. But when you combine alternatives, put each one in parentheses: (p1 >>=: f) <|> (p2 >>=: g). Without them, p1 >>=: f <|> p2 >>=: g would group the wrong way.

Sequencing makes nested pairs: a <+> b <+> c gives ((ra, rb), rc). A lambda with a matching pattern takes it apart: >>=: \((ra, rb), rc) -> …. A pair result can also go straight to a two-argument function with uncurry: uncurry f (x, y) = f x y.


Part 0: Orientation

Nothing to implement yet. Get to know the library, and the compiler you've been given.

Step 0.1: The Parser Library

Start ghci Examples.hs and try the examples from the cheat sheet. Then try a parse that fails.

✅ Check your work (Step 0.1)

ghci> parse number "42"
42
ghci> parse (number <+> number) "3 4"
(3,4)
ghci> parse (text "move" <-+> number) "move 5"
5
ghci> parse (number >>=: (* 2)) "21"
42
ghci> parse (sepBy number (sym ',')) "1, 2, 3"
[1,2,3]
ghci> parse (identifier <=> (\w -> length w > 3)) "long"
"long"
ghci> parse number "x"
*** Exception: <input>:1:1 -- <num> expected
…

Step 0.2: The Provided Compiler, and Your Starting Point

The compiler works right away, on abstract syntax. previewProgram compiles an AST, writes python/<name>.py, and draws python/<name>.png. showPretty prints an AST as source code; that's the language your parser will read. And one parser already works: parseExpr can read a number.

✅ Check your work (Step 0.2)

ghci> mapM_ putStrLn (compileProgram (Program [] [Dot]))
pen_down()
pen_up()
ghci> previewProgram "hello" helloProgram
Writing python/hello.py
[nextdraw_backend] Using MOCK NextDraw (preview only)
[mock_nextdraw] Saved preview to …/python/hello.png
Preview: python/hello.png
ghci> showPretty exampleProgram
define target(r) {
    for k from 1 to 3 {
        circle ((k * r) / 3)
    }
    dot
}

move left 60
for i from 1 to 3 {
    target((10 + (i * 5)))
    move right 60
}
move left 180
move down 40
line right 120
ghci> parseExpr "42"
Num 42.0
python/hello.png shows a 40 mm square with a circle inside it and a dot at the centre. The pretty-printer puts parentheses around every operation (circle ((k * r) / 3)); your parser will accept both that and the simpler circle k * r / 3 in programs/example.piq.
$ runghc -itest test/Spec.hs --match "Step 2.1"
7 examples, 0 failures
(Step 2.1 tests number parsing, which is provided.)


Part 1: Names and Keywords

piq has reserved words (dot, square, …, define): they have a special meaning, so they can't be used as names. Names are made of a letter followed by letters and digits, which is exactly what the library's identifier reads.

Why text "dot" Is Not Good Enough

To read the keyword dot, you might use text "dot". But text only checks the next three characters. So given the name dotty, it happily reads dot and leaves ty behind:

✅ Try this (no code to write)

ghci> parse (text "dot" <+> identifier) "dotty"
("dot","ty")

So dotty = 3 could be read as the statement dot followed by ty = 3, which is wrong. The fix: read a whole identifier, then check that it's exactly the keyword.

Step 1.1: isReserved

Implement isReserved word: is word one of reservedWords (provided)? One line with elem.

✅ Check your work (Step 1.1)

ghci> isReserved "left"
True
ghci> isReserved "leftover"
False
$ runghc -itest test/Spec.hs --match "Step 1.1"
3 examples, 0 failures

Step 1.2: keyword

Implement keyword word: read an identifier, and keep it only if it's exactly word. Use <=> with a small test function (a lambda). keyword "dot" then accepts dot but rejects dotty, because identifier reads all of dotty, and "dotty" isn't "dot".

✅ Check your work (Step 1.2)

ghci> parse (keyword "dot") "dot"
"dot"
ghci> parse (keyword "dot") "dotty"
"*** Exception: <input>:1:6 -- No parse (via pfail)
…
$ runghc -itest test/Spec.hs --match "Step 1.2"
6 examples, 0 failures

Step 1.3: name

Implement name: an identifier that is not reserved. Same idea as keyword, with a different test.

Now the provided dot statement works too, because it's keyword "dot" >>: Dot.

✅ Check your work (Step 1.3, and all of Part 1)

ghci> parse name "dotty"
"dotty"
ghci> parse name "circle"
"*** Exception: <input>:1:7 -- No parse (via pfail)
…
ghci> parseStmt "dot"
Dot
$ runghc -itest test/Spec.hs --match "Step 1.3"
10 examples, 0 failures
$ make part1
19 examples, 0 failures


Part 2: Atoms

From here on, keep the grammar (also in your repo as SYNTAX.md) open. The grammar for expressions is

expr   ::= term   (("+" | "-") term)*
term   ::= factor (("*" | "/") factor)*
factor ::= number | name | "(" expr ")"

A factor is an atom: the smallest piece of an expression. factor's number case is provided: double >>=: Num reads a number and wraps it in the Num constructor.

For now expr and term are placeholders: expr = term and term = factor, so that expressions work at all. You'll replace them in Part 3.

Step 2.1: Numbers (provided)

Already works: the tests passed in Part 0.

Step 2.2: Variables

Add an alternative to factor: a name, wrapped in Var. Add it with <|> before the <???> line (that line only improves the error message).

✅ Check your work (Step 2.2)

ghci> parseExpr "size"
Var "size"
ghci> parseExpr "dot"
*** Exception: <input>:1:4 -- No parse (via pfail)
…
dot is rejected because it's reserved, not a name.
$ runghc -itest test/Spec.hs --match "Step 2.2"
10 examples, 0 failures

Step 2.3: Parentheses

Add a third alternative: an expr inside parentheses (parens). Notice that factor uses expr, and expr (through term) uses factor. That recursion is what lets expressions nest.

✅ Check your work (Step 2.3, and all of Part 2)

ghci> parseExpr "((x))"
Var "x"
ghci> parseExpr "(1 + 2)"
*** Exception: <input>:1:3 -- ')' expected
…
The second one fails for now, because there's no + yet (Part 3).
$ runghc -itest test/Spec.hs --match "Step 2.3"
7 examples, 0 failures
$ make part2
24 examples, 0 failures


Part 3: Precedence and Associativity

Two rules decide what an expression means:

  • Precedence: * and / bind more tightly than + and -. So x + 5 * 2 means x + (5 * 2):
    Add (Var "x") (Mul (Num 5) (Num 2))
    
  • Left associativity: operators of the same level group from the left. So 10 - 3 - 2 means (10 - 3) - 2 = 5, not 10 - (3 - 2) = 9:
    Sub (Sub (Num 10) (Num 3)) (Num 2)
    

The grammar gets precedence from its levels: an expr is made of terms joined by +/-, and a term is made of factors joined by *//. In x + 5 * 2, the 5 * 2 is one term, so the * happens "inside" the +.

Left associativity comes from how you combine the pieces. Reading 10 - 3 - 2 as "a first operand, then a list of (operator, operand) pairs" gives

first operand:  Num 10
pairs:          [(Sub, Num 3), (Sub, Num 2)]

Combining from the left means: combine the first operand with the first pair, then combine that result with the next pair, and so on.

Step 3.1: combineLeft

Implement combineLeft :: Expr -> [(Expr -> Expr -> Expr, Expr)] -> Expr. The operators in the pairs are AST constructors such as Sub, which are functions of two Exprs. Recursion on the list of pairs:

  • no pairs: the result is the expression so far;
  • a pair (op, right) followed by more pairs: combine the expression so far with right using op, and carry that on to the rest of the pairs.

✅ Check your work (Step 3.1)

ghci> combineLeft (Num 10) [(Sub, Num 3), (Sub, Num 2)]
Sub (Sub (Num 10.0) (Num 3.0)) (Num 2.0)
ghci> combineLeft (Num 1) []
Num 1.0
$ runghc -itest test/Spec.hs --match "Step 3.1"
4 examples, 0 failures

Step 3.2: Terms: mulOp and term

Implement mulOp: a parser for * or / whose result is the AST constructor (Mul or Div). Two alternatives, each "read the symbol, give the constructor" (>>:).

Replace the placeholder term, following its grammar rule: a factor, then zero or more (operator, factor) pairs. Sequencing a factor with many of (mulOp then factor) produces a pair: the first factor, and a list of (operator, factor) pairs. Those are exactly combineLeft's two arguments; hand them over with >>=: and a lambda.

✅ Check your work (Step 3.2)

ghci> parseExpr "8 / 4 / 2"
Div (Div (Num 8.0) (Num 4.0)) (Num 2.0)
ghci> parseExpr "2 * x"
Mul (Num 2.0) (Var "x")
$ runghc -itest test/Spec.hs --match "Step 3.2"
5 examples, 0 failures

Step 3.3: Expressions: addOp and expr

Implement addOp (+/-, giving Add/Sub) and replace the placeholder expr the same way, one level up: terms joined by addOp.

✅ Check your work (Step 3.3)

ghci> parseExpr "2 + 3 * 4"
Add (Num 2.0) (Mul (Num 3.0) (Num 4.0))
ghci> parseExpr "x + 5 * 2"
Add (Var "x") (Mul (Num 5.0) (Num 2.0))
ghci> parseExpr "10 - 3 - 2"
Sub (Sub (Num 10.0) (Num 3.0)) (Num 2.0)
ghci> parseExpr "(1 + 2) * 3"
Mul (Add (Num 1.0) (Num 2.0)) (Num 3.0)
$ runghc -itest test/Spec.hs --match "Step 3.3"
22 examples, 0 failures

Step 3.4: Round Trips

The provided pretty-printer turns an AST back into source code. It puts parentheses around every operation, so its output is unambiguous. For every expression e, parsing the printed expression must give back e exactly:

parseExpr (prettyExpr e) == e

This is a strong test: if your parser recovers the structure of what the printer wrote, precedence and grouping must be right. Nothing new to write.

✅ Check your work (Step 3.4, and all of Part 3)

ghci> prettyExpr (parseExpr "x + 5 * 2")
"(x + (5 * 2))"
ghci> parseExpr (prettyExpr (Sub (Num 10) (Sub (Num 3) (Num 2))))
Sub (Num 10.0) (Sub (Num 3.0) (Num 2.0))
$ runghc -itest test/Spec.hs --match "Step 3.4"
14 examples, 0 failures
$ make part3
45 examples, 0 failures


Part 4: Basic Statements

A statement is one of several kinds. The provided stmt tries each kind in turn:

stmt = assignment <|> call <|> forLoop <|> dotStmt <|> squareStmt <|> rectangleStmt
       <|> circleStmt <|> moveStmt <|> lineStmt

Each unfinished one is pfail for now, so stmt already works for the kinds you've finished. Every statement except assignment and call starts with its own keyword, which is why the keywords from Part 1 matter.

The provided example is dotStmt = keyword "dot" >>: Dot. It reads the keyword, then gives the constructor.

Step 4.1: Dot (provided)

Already works since Part 1.

✅ Check your work (Step 4.1)

$ runghc -itest test/Spec.hs --match "Step 4.1"
4 examples, 0 failures

Step 4.2: Square, Rectangle, Circle

Implement squareStmt, rectangleStmt, circleStmt.

  • square EXPR: the keyword, then an expression. Keep only the expression (<-+>) and apply Square to it (>>=:).
  • circle EXPR works the same way.
  • rectangle EXPR EXPR: two expressions, width then height. Sequencing them gives a pair, and uncurry Rectangle turns a pair into a Rectangle.

✅ Check your work (Step 4.2)

ghci> parseStmt "square 20"
Square (Num 20.0)
ghci> parseStmt "rectangle w + 1 h * 2"
Rectangle (Add (Var "w") (Num 1.0)) (Mul (Var "h") (Num 2.0))
ghci> parseStmt "circle r / 2"
Circle (Div (Var "r") (Num 2.0))
In rectangle w + 1 h * 2, the first expression ends where it can't continue: after w + 1, the next token h isn't an operator.
$ runghc -itest test/Spec.hs --match "Step 4.2"
18 examples, 0 failures
One of the tests checks that rectangle 30 -10 is rejected: 30 -10 reads as the single expression 30 - 10, and then the height is missing. Programs write rectangle 30 (-10) instead.

Step 4.3: Directions, Move, Line

Add the other three alternatives to direction (right, up, down; left is provided), then implement moveStmt and lineStmt: the keyword, a direction, an expression. The direction and the expression form a pair, and uncurry Move turns that pair into a statement.

Distances are always required: move right without a number is an error.

✅ Check your work (Step 4.3)

ghci> parse direction "up"
Up
ghci> parseStmt "move left 20"
Move Lt (Num 20.0)
ghci> parseStmt "line down 2.5"
Line Dn (Num 2.5)
ghci> parseStmt "move right"
*** Exception: <input>:1:11 -- a number, a name, or ( expected
…
$ runghc -itest test/Spec.hs --match "Step 4.3"
17 examples, 0 failures

Step 4.4: Assignment

Implement assignment: NAME = EXPR. Keep the name and the expression, drop the = (<+-> keeps the left result), and build an Assign.

Because name never accepts a reserved word, dotty = 3 is an assignment, and dot = 1 is rejected.

✅ Check your work (Step 4.4)

ghci> parseStmt "size = size * 2"
Assign "size" (Mul (Var "size") (Num 2.0))
ghci> parseStmt "dotty = 3"
Assign "dotty" (Num 3.0)
$ runghc -itest test/Spec.hs --match "Step 4.4"
14 examples, 0 failures

Step 4.5: Statement Round Trips

As in Step 3.4: every basic statement in HW 5's examples, printed and parsed back, must come out the same.

✅ Check your work (Step 4.5, and all of Part 4)

ghci> prettyStmt (Rectangle (Num 30) (Num (-10)))
"rectangle 30 (-10)"
ghci> parseStmt (prettyStmt (Rectangle (Num 30) (Num (-10))))
Rectangle (Num 30.0) (Num (-10.0))
$ runghc -itest test/Spec.hs --match "Step 4.5"
1 example, 0 failures
$ make part4
54 examples, 0 failures


Part 5: Statement Sequences and Blocks

Newlines and semicolons are not statement terminators. piq has no separators at all: whitespace, including newlines, only separates tokens. A statement simply ends where its last part ends, so these mean the same thing:

x = 10               x = 10 square x x = x * 2 square x
square x
x = x * 2
square x

Step 5.1: stmts

Implement stmts: zero or more statements. That's one library combinator.

✅ Check your work (Step 5.1)

ghci> parseStmts "x = 10 square x x = x * 2 square x"
[Assign "x" (Num 10.0),Square (Var "x"),Assign "x" (Mul (Var "x") (Num 2.0)),Square (Var "x")]
ghci> parseStmts "dot\n  dot"
[Dot,Dot]
ghci> parseStmts ""
[]
$ runghc -itest test/Spec.hs --match "Step 5.1"
13 examples, 0 failures
One test compares with text "dot", showing dotty = 3 wrongly split into a dot and ty = 3. Your keyword is what prevents that.

Step 5.2: block

Implement block: statements between braces. A block may be empty.

✅ Check your work (Step 5.2, and all of Part 5)

ghci> parse block "{ dot square 3 }"
[Dot,Square (Num 3.0)]
ghci> parse block "{}"
[]
$ runghc -itest test/Spec.hs --match "Step 5.2"
8 examples, 0 failures
$ make part5
21 examples, 0 failures


Part 6: Loops

for i from 1 to 5 {
    square i * 10
}

Step 6.1: forLoop

Implement forLoop from four pieces, in order:

  1. keyword "for", then a name (the loop variable);
  2. keyword "from", then an expr;
  3. keyword "to", then an expr;
  4. a block.

Keep only the interesting results (<-+> drops each keyword). Sequencing the four pieces with <+> gives nested pairs, (((var, from), to), body). Take them apart with a lambda and build For var from to body.

✅ Check your work (Step 6.1)

ghci> parseStmt "for i from 1 to 5 { square i * 10 }"
For "i" (Num 1.0) (Num 5.0) [Square (Mul (Var "i") (Num 10.0))]
$ runghc -itest test/Spec.hs --match "Step 6.1"
13 examples, 0 failures

Step 6.2: Nested Loops

Nothing new: a loop's body is a block, a block holds statements, and a statement can be a loop.

✅ Check your work (Step 6.2, and all of Part 6)

ghci> parseStmt "for i from 1 to 2 { for j from 1 to 3 { dot } }"
For "i" (Num 1.0) (Num 2.0) [For "j" (Num 1.0) (Num 3.0) [Dot]]
$ runghc -itest test/Spec.hs --match "Step 6.2"
2 examples, 0 failures
$ make part6
15 examples, 0 failures


Part 7: Procedure Calls

f()
box(20, 5)
foo(x + 1, y * 2)

Step 7.1: call

Implement call: a name, then, inside parentheses, zero or more expressions separated by commas. sepBy does exactly that (sym ',' reads a comma). Build Call name args (uncurry again).

Calls and assignments both start with a name. stmt tries assignment first, and it fails when there's no =, so then call gets its turn.

✅ Check your work (Step 7.1, and all of Part 7)

ghci> parseStmt "f()"
Call "f" []
ghci> parseStmt "box(20, 5)"
Call "box" [Num 20.0,Num 5.0]
ghci> parseStmt "foo(x + 1, y * 2)"
Call "foo" [Add (Var "x") (Num 1.0),Mul (Var "y") (Num 2.0)]
$ runghc -itest test/Spec.hs --match "Step 7.1"
16 examples, 0 failures
$ make part7
16 examples, 0 failures


Part 8: Procedure Definitions

define box(size, gap) {
    square size
    move right size + gap
}

Step 8.1: procDef

Implement procDef: keyword "define", a name, a comma-separated list of parameter names in parentheses, and a block. As with forLoop, sequencing gives nested pairs; build ProcDef name params body.

✅ Check your work (Step 8.1, and all of Part 8)

ghci> parse procDef "define box(size, gap) { square size move right size + gap }"
ProcDef "box" ["size","gap"] [Square (Var "size"),Move Rt (Add (Var "size") (Var "gap"))]
$ runghc -itest test/Spec.hs --match "Step 8.1"
14 examples, 0 failures
$ make part8
14 examples, 0 failures


Part 9: Whole Programs

program ::= procdef* stmt*
All definitions come first, then the statements. A define after a statement is an error.

Step 9.1: program

Implement program: any number of definitions, then the statements, built into a Program (uncurry once more).

✅ Check your work (Step 9.1)

ghci> parseProgram "define star() { dot } star() star()"
Program [ProcDef "star" [] [Dot]] [Call "star" [],Call "star" []]
ghci> parseProgram "dot define f() { }"
*** Exception: <input>:1:11 -- No parse (via pfail)
…
$ runghc -itest test/Spec.hs --match "Step 9.1"
11 examples, 0 failures

Step 9.2: Program Round Trips

The final round trip: every one of HW 5's 21 example programs is printed as source code and parsed back, and must come out identical. Parsing the printed program must also compile to exactly the same Python. And two programs written the way a person would (fewer parentheses, different layout) must parse to exactly their ASTs.

✅ Check your work (Step 9.2, and all of Part 9)

ghci> parseProgram (prettyProgram exampleProgram) == exampleProgram
True
$ runghc -itest test/Spec.hs --match "Step 9.2"
24 examples, 0 failures
$ make part9
35 examples, 0 failures


Part 10: The Whole Pipeline

Your parser is complete. Now piq is a language you can write in files.

Step 10.1: From a File to a Picture

Build the tools, then run a program through the whole pipeline: your parser, the provided compiler, the Python file, and the mock plotter's picture.

✅ Check your work (Step 10.1)

$ make
…
$ bin/piq2png programs/example.piq
Writing programs/example.py
Rendering programs/example.py to programs/example.png
[nextdraw_backend] Using MOCK NextDraw (preview only)
[mock_nextdraw] Saved preview to …/programs/example.png
Drawing check: programs/example.png shows three targets, each three circles and a centre dot, growing from left to right, with a line underneath. Blue is drawn; the thin pink lines are pen-up travel (including the trip from the plotter's home corner at the top left). The grey dashed box is the safe area: see Step 10.3.
$ runghc -itest test/Spec.hs --match "Step 10.1"
7 examples, 0 failures
Also try bin/piq2png programs/garden.piq (flowers on a ground line, with a sun) and bin/piq2png programs/woven.piq (an abstract pattern: curves made only of straight lines).

Step 10.2: Errors Stop the Pipeline

An invalid program must never become a Python file. A syntax error stops at your parser; an error like an undefined variable stops at the compiler.

✅ Check your work (Step 10.2, and all of Part 10)

$ echo 'square 10 circle r' > programs/bad.piq
$ bin/piq2py programs/bad.piq
error: undefined variable: r
No programs/bad.py is written. (Delete programs/bad.piq afterwards.)
$ runghc -itest test/Spec.hs --match "Step 10.2"
5 examples, 0 failures
$ make part10
12 examples, 0 failures

Step 10.3: Your Own Program

Write your own drawing in a new file, programs/mine.piq. Use SYNTAX.md for reference, and look at programs/garden.piq for ideas. Then run bin/piq2png programs/mine.piq. Keep the drawing inside the safe area, the grey dashed box in the preview: at least 1 inch from the paper's edges, which means within 190.5 mm left/right and 114.3 mm up/down of the start. Anything outside it is drawn in red with a warning, and the real plotter refuses to draw it.


Common Pitfalls

  • Alternatives without parentheses. p1 >>=: f <|> p2 >>=: g groups wrongly; write (p1 >>=: f) <|> (p2 >>=: g).
  • Using text for a keyword. text "move" also matches the start of movement; use keyword.
  • Right associativity by accident. If 10 - 3 - 2 gives Sub (Num 10.0) (Sub …), combineLeft (or the way you combine) is grouping from the right.
  • Forgetting that the first operand isn't in the list. In a + b + c, the list of pairs has two entries, not three.
  • Nested pairs. a <+> b <+> c produces ((a, b), c), not (a, b, c).
  • Tests that pass too early: "is rejected" tests pass while a parser rejects everything. Check the step's full summary line after you finish the step.
  • Forgetting :reload in GHCI after editing.
  • The " before *** Exception is GHCI starting to print a string result. It's not part of the message.

Final Checklist

  • No placeholders are left in PiqParser.hs: grep -nE 'error "TODO|pfail$' PiqParser.hs prints nothing. (In the starter it finds 18.)
  • bin/piq2png programs/example.piq produces the three targets.
  • Your own programs/mine.piq draws what you intended.
  • You didn't edit the provided files (including ParserCombinators.hs, ParserBase.hs, and test/).

Turning It In

Submit on Gradescope, under HW 06: PicPlot Part 2: Parsing:

  1. Confirm the HW 5 → HW 6 order. Check the box confirming that you (and your partner, if any) didn't open the piq-parser repo or look at PiqEvaluation.hs until after completing and submitting HW 5. See the box at the top of this page.
  2. From the top of the repo, run the full test suite one last time: make test. Copy the entire terminal output and paste it into the test-output text box.
  3. Upload PiqParser.hs, the only file you edit for this assignment.
  4. Upload your own program from Step 10.3, programs/mine.piq, together with the picture it produced, programs/mine.png.

Gradescope also asks you to explain two pieces of your parser (precedence and associativity, and keyword), plus a few short reflection questions.

The Homework Workflow covers the submission step in context.

Make sure your final code is committed and pushed to your repo as well.


Appendix: The Real Plotter

Drawing on the real NextDraw plotter happens in the lab, with your instructor's setup and permission. Follow the course procedure:

  • Preview first. Never send a drawing to the plotter that you haven't previewed (bin/piq2png) and checked.
  • The plotter refuses any drawing that leaves the safe area (190.5 mm left/right and 114.3 mm up/down of the start).
  • Before plotting, the carriage must be at its home position (the top-left corner).
  • On the plotter computer, which has the NextDraw software installed, a generated program runs with --doplot:
    python programs/example.py --doplot