HW 5: PicPlot Part 1: Compiling¶
This assignment has you write a real compiler in Haskell: it translates programs in piq, a tiny drawing language for a pen plotter, into Python programs that drive the plotter. It continues straight from Lab 05, where you saw piq and its compiled Python from the outside — now you're building the compiler itself. You won't write a parser this week; piq programs are written directly as Haskell values (their abstract syntax), the same way HW 4's little languages were. A real parser, so piq programs can be written as actual text, is coming in HW 6. Threading an explicit environment through your evaluator and compiler is exactly the idea from Module 5.1, now put to real use.
How you'll get materials and submit this assignment
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-compiler template repo, make your own private copy, clone it to the course server, and submit your test output and code on Gradescope. The rest of this page covers everything the assignment asks of you — you shouldn't need anything beyond this page and your cloned repo.
You'll also need Python 3 with matplotlib to generate preview pictures of your drawings — already set up on the course server, the same as the tools Lab 05 used.
The work is split into many small steps. After every step there is a Check your work box that tells you exactly what to type and exactly what you should see. Don't move on until the check passes.
What You'll Do¶
- Represent a program as an abstract syntax tree (algebraic datatypes).
- Write a recursive evaluator for arithmetic expressions with variables, using an environment.
- Write a compiler that translates each kind of statement into code in another language (Python).
- Thread an environment explicitly through a sequence of statements, so that assignments affect later statements.
- Implement loops with a locally scoped loop variable.
- Implement procedures with parameters and local environments.
- Watch your compiler's output drawn by a (previewed, or physical) pen plotter.
Checklist¶
- Get the assignment materials (see The Homework Workflow)
- Part 1: expressions and environments (
eval) - Part 2: basic drawing (
move,line,square,rectangle) - Part 3: circles
- Part 4: assignment and statement sequences
- Part 5: loops
- Part 6: procedures
- Part 7: whole programs
- Turn in the assignment (see The Homework Workflow)
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.
Background: From AST to Python¶
Instead of parsing text, you write piq programs directly as Haskell values. For example, this piq program:
x = 20
square x
move right 30
circle x / 2
is written this week as the Haskell value:
Program [] [ Assign "x" (Num 20)
, Square (Var "x")
, Move Rt (Num 30)
, Circle (Div (Var "x") (Num 2)) ]
and your compiler turns it into lines of Python like pen_up(), pen_down(), and move_rel(-10.0, -10.0). The provided infrastructure wraps those lines into a runnable Python program, which can draw a preview picture on your computer, or drive the real plotter:
AST (a Haskell value)
|
| YOUR WORK: evaluate expressions, compile statements
v
[String] (lines of Python: pen_up(), pen_down(), move_rel(dx, dy))
|
| PROVIDED: wrap into a complete Python program
v
python/<name>.py ──> preview picture python/<name>.png
└──> the real pen plotter (in the lab)
Getting Started¶
You need:
- GHC (the Haskell compiler, with
ghciandrunghc), with the hspec testing library; - Python 3 with matplotlib, for preview pictures. Check with:
No output means it works. If your Python has another name, set
python3 -c "import matplotlib"PIQ_PYTHON, e.g.export PIQ_PYTHON=/usr/local/bin/python3.
Everything in this handout is run from the top folder of your repository (the folder containing your cloned files).
The Files¶
| File | What it is |
|---|---|
PiqEvaluation.hs |
YOU WILL EDIT. The evaluator and compiler. Every place to fill in is marked -- TODO (Part N). |
PiqAST.hs |
PROVIDED: DO NOT EDIT. The abstract syntax of piq. Read it first. |
Python.hs |
PROVIDED: DO NOT EDIT. penUp, penDown, moveRel: the only Python your compiler writes. Also the code that wraps your lines into a complete Python file. |
Preview.hs |
PROVIDED: DO NOT EDIT. GHCI commands to print your compiled code, write Python files, and draw preview pictures. |
python/ |
PROVIDED: DO NOT EDIT. The Python side: the runtime your generated programs call, and the mock plotter that draws previews. |
Makefile |
PROVIDED. make test, make part1 … make part7, make clean. |
test/*.hs |
TESTS. One module per part (for example test/EvalSpec.hs for Part 1). Reading a test is a good way to understand what is wanted. |
Examples.hs |
EXAMPLES. Example piq programs, as abstract syntax, for each part. ghci Examples.hs is also the easiest way to start GHCI: it loads everything. |
How to Work: GHCI, Tests, Previews¶
GHCI¶
Start GHCI like this. It loads every module, so all the names in this handout are available:
$ ghci Examples.hs
PiqEvaluation.hs, type :reload (or :r) in GHCI to load your changes. :quit leaves.
When something has not been implemented yet, or a piq program has an error, GHCI shows an exception. The first line is the message. The lines after it (CallStack …) say where in the code it happened:
ghci> eval emptyEnv (Var "x")
*** Exception: TODO (Part 1): eval for Var
CallStack (from HasCallStack):
error, called at ./PiqEvaluation.hs:138:5 in main:PiqEvaluation
TODO (Part N): … message means "this is the part you haven't written yet". It is not a bug.
Tests¶
| Command | Runs |
|---|---|
make test |
all the tests (the final check) |
make part1 … make part7 |
only one part's tests |
runghc -itest test/Spec.hs --match "Step 2.3" |
only one step's tests |
Every step below tells you which command to run and exactly what the last line should say, for example 5 examples, 0 failures. Before you finish, tests for later parts will fail, with TODO messages. That's expected: run the command for the step you're on.
Previews¶
The provided Preview module writes your compiled code into a Python file and runs it with a mock plotter, which draws a picture instead of moving a real pen:
previewStmts "name" [ … ]for a list of drawing statements (Parts 0–3);previewProgram "name" (Program … )for a whole program (Part 4 on).
Both write python/name.py and python/name.png. Open the .png with any image viewer. In the picture:
- blue lines are drawn with the pen down;
- pink lines show where the pen travelled while it was up. The pen starts from the plotter's home corner (top left), goes to the centre of the paper where your program begins, and returns home at the end, so there are always a few pink lines to and from the corner;
- the grey dashed box is the safe area (below), and anything red went outside it.
The paper is 17 × 11 inches (about 432 × 279 mm). Your program starts at its centre, and must stay inside the safe area: 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. If a drawing leaves the safe area, the preview still works (with a warning, and the offending moves in red), but the real plotter refuses to draw it.
Tests vs. previews vs. the real plotter
Tests check correctness. Previews let you see your drawing. The real plotter is a separate, optional step (see the appendix). Automated tests never need Python or the plotter.
The piq Language¶
Expressions (Expr) are arithmetic on numbers (every value in piq is a Double):
| Abstract syntax | Meaning |
|---|---|
Num 3 |
the number 3 |
Var "x" |
the value of variable x |
Add e1 e2, Sub e1 e2, Mul e1 e2, Div e1 e2 |
e1 + e2, e1 - e2, e1 * e2, e1 / e2 |
For example, Add (Var "x") (Mul (Num 5) (Num 2)) means x + 5 * 2. A negative number needs parentheses in Haskell: Num (-5).
Directions (Direction) are page-relative: Lt, Rt, Up, Dn. "Up" always means toward the top of the page. There is no turning.
Statements (Stmt):
| Abstract syntax | Meaning |
|---|---|
Assign "x" e |
x = e: give variable x the value of e |
Dot |
a dot at the current position |
Square s |
a square with side s, centered on the current position |
Rectangle w h |
a w × h rectangle, centered on the current position |
Circle r |
a circle of radius r, centered on the current position |
Move d n |
travel n mm in direction d without drawing |
Line d n |
draw a line n mm long in direction d |
For "i" e1 e2 body |
repeat body (a list of statements) for i from e1 to e2 |
Call "f" args |
run procedure f with the given argument expressions |
A program is Program defs stmts: procedure definitions (ProcDef name params body), then the statements to run.
What the Compiler Produces¶
Your compiler produces a list of strings, [String], one line of Python each. Only three kinds of line are ever needed. You don't write the strings yourself; you use the functions from Python.hs:
Haskell (from Python.hs) |
Python line | Meaning |
|---|---|---|
penUp |
pen_up() |
raise the pen |
penDown |
pen_down() |
lower the pen onto the paper |
moveRel dx dy |
move_rel(dx, dy) |
travel dx mm right and dy mm up. It draws if the pen is down, and doesn't if it's up |
So moveRel 10 (-5) means "10 mm right and 5 mm down". Positive dy is up the page.
The rules that give piq its meaning (the "spatial semantics"):
- There is a current position. Moves are relative to it, and it changes as the pen travels.
- Closed shapes (
Dot,Square,Rectangle,Circle) are centered on the current position, and leave the current position unchanged: the pen ends exactly where it started. That makes shapes easy to place: move to a spot, then draw a shape around it. Movetravels without drawing;Linedraws and leaves the pen at the far end. Both change the position.- Between statements, the pen is always up. Every statement that lowers the pen raises it again before it ends. This is a promise every statement keeps, so no statement ever has to worry about what came before it. Extra
pen_up()lines are fine.
Part 0: Orientation¶
There is nothing to implement in Part 0. You will look around and make your first picture.
Step 0.1: The Abstract Syntax and the Three Python Functions¶
Start GHCI with ghci Examples.hs and type in some abstract syntax. GHCI shows each value back (numbers are Doubles, so 3 shows as 3.0). Then look at the three Python helpers, which are just strings.
✅ Check your work (Step 0.1)
ghci> Num 3
Num 3.0
ghci> Add (Num 2) (Num 5)
Add (Num 2.0) (Num 5.0)
ghci> Square (Num 20)
Square (Num 20.0)
ghci> Move Rt (Num 30)
Move Rt (Num 30.0)
ghci> penUp
"pen_up()"
ghci> moveRel 10 (-5)
"move_rel(10.0, -5.0)"
Step 0.2: The Provided Dot, and Your First Picture¶
One statement is already compiled for you: Dot. Find it in PiqEvaluation.hs:
compileStmt _ env Dot = (env, dotCode)
dotCode = [penDown, penUp]
compileStmt returns a pair: the environment after the statement (unchanged, for a dot) and the Python lines. GHCI prints an environment as fromList [...]: a list of (name, value) pairs, empty here.
✅ Check your work (Step 0.2)
ghci> compileStmt emptyProcEnv emptyEnv Dot
(fromList [],["pen_down()","pen_up()"])
ghci> showStmts [Dot, Dot]
pen_down()
pen_up()
pen_down()
pen_up()
ghci> previewStmts "dot" [Dot]
Writing python/dot.py
[nextdraw_backend] Using MOCK NextDraw (preview only)
[mock_nextdraw] Saved preview to …/python/dot.png
Preview: python/dot.png
python/dot.png. You should see a single blue dot at the centre of the paper, plus pink pen-up travel lines to and from the top-left corner. You can also open python/dot.py to see the complete Python program your code became.
Test check:
$ runghc -itest test/Spec.hs --match "Step 2.1"
1 example, 0 failures
Part 1: Expressions and Environments¶
You have written an arithmetic evaluator before. This one adds variables. To know what x + 1 means, you need to know x's value. An environment is a lookup table from variable names to values:
type Env = Map.Map Name Double
Data.Map is Haskell's lookup table. You need very little of it, and the comments at the top of PiqEvaluation.hs list the four functions you'll use. Environments are values: "changing" one builds a new one and leaves the old one alone. emptyEnv and assignVar are provided; read them.
The evaluator has type eval :: Env -> Expr -> Double: given the values of the variables, what number does this expression stand for?
Step 1.1: Numbers¶
eval _ (Num n) = n is provided. A number means itself.
✅ Check your work (Step 1.1)
ghci> eval emptyEnv (Num 3)
3.0
$ runghc -itest test/Spec.hs --match "Step 1.1"
2 examples, 0 failures
Step 1.2: Variables¶
New idea: the Maybe type
Heads up: we never actually covered the Maybe type back in the modules, even though you need it starting here. It's not complicated — Module 3.2 now has a quick introduction to it, which is exactly where this would have gone. The short version: Map.lookup below returns Just value when the key is there and Nothing when it isn't, and you handle both cases with a case expression.
Implement lookupVar and the Var case of eval.
lookupVar name envreturns the variable's value. UseMap.lookup name env, which returnsJust valueif the variable is there andNothingif it isn't. Handle both with acaseexpression.- A variable that isn't there is an error in the piq program. Stop with exactly this message:
error ("undefined variable: " ++ name) evalforVar nameis then one line.
✅ Check your work (Step 1.2)
ghci> lookupVar "x" (assignVar "x" 10 emptyEnv)
10.0
ghci> eval (assignVar "x" 10 emptyEnv) (Var "x")
10.0
ghci> lookupVar "x" emptyEnv
*** Exception: undefined variable: x
…
$ runghc -itest test/Spec.hs --match "Step 1.2"
5 examples, 0 failures
Step 1.3: Addition and Subtraction¶
Implement the Add and Sub cases. Evaluate both sub-expressions recursively, in the same environment, then combine the results. This is exactly your old evaluator's recursion, with an extra env argument passed along.
✅ Check your work (Step 1.3)
ghci> eval emptyEnv (Sub (Num 10) (Sub (Num 3) (Num 2)))
9.0
ghci> eval (assignVar "x" 10 emptyEnv) (Add (Var "x") (Num 5))
15.0
$ runghc -itest test/Spec.hs --match "Step 1.3"
6 examples, 0 failures
Step 1.4: Multiplication and Division¶
Implement Mul and Div. Division has a rule: if the divisor evaluates to 0, stop with error "division by zero". Haskell would otherwise quietly produce Infinity, which must never reach the plotter. Note that the divisor might be an expression that happens to be 0, like 2 - 2, not only the literal 0.
Strategy: a guard (| … = …) with a where clause naming the divisor's value.
✅ Check your work (Step 1.4)
ghci> eval emptyEnv (Add (Num 2) (Mul (Num 3) (Num 4)))
14.0
ghci> eval emptyEnv (Div (Num 7) (Num 2))
3.5
ghci> eval emptyEnv (Div (Num 1) (Sub (Num 2) (Num 2)))
*** Exception: division by zero
…
$ runghc -itest test/Spec.hs --match "Step 1.4"
7 examples, 0 failures
Step 1.5: Environments Are Values¶
Nothing new to write. These tests check that assignVar builds a new environment without changing the old one. Notice how eval always sees exactly the environment you hand it.
✅ Check your work (Step 1.5)
$ runghc -itest test/Spec.hs --match "Step 1.5"
4 examples, 0 failures
Step 1.6: Errors¶
Nothing new to write, if Steps 1.2 and 1.4 are right. These tests check the two error messages, including errors deep inside larger expressions.
✅ Check your work (Step 1.6, and all of Part 1)
$ runghc -itest test/Spec.hs --match "Step 1.6"
5 examples, 0 failures
$ make part1
29 examples, 0 failures
make part1 shows 29 examples, 0 failures.
Part 2: Basic Drawing¶
Now the compiler proper. compileStmt turns one statement into Python lines:
compileStmt :: ProcEnv -> Env -> Stmt -> (Env, [String])
It gets a procedure environment (ignore it until Part 6), the current variable environment, and a statement. It returns the environment after the statement, and the statement's Python lines. Drawing statements never change the environment, so for them the first part of the result is just env.
Each drawing statement has a small helper that builds its lines from plain numbers (moveCode, squareCode, …). compileStmt evaluates the statement's expressions with eval and passes the numbers to the helper. That keeps "what does this expression mean?" separate from "what lines draw this shape?".
Step 2.1: The Provided Dot¶
You already saw it in Part 0. Its test passes already.
Step 2.2: Directions¶
Implement the other three cases of offset :: Direction -> Double -> (Double, Double). It says how far a trip of distance mm in a direction travels, as (dx, dy). offset Rt distance = (distance, 0) is provided. Remember that up is positive y.
✅ Check your work (Step 2.2)
ghci> offset Lt 5
(-5.0,0.0)
ghci> offset Up 5
(0.0,5.0)
ghci> offset Dn 5
(0.0,-5.0)
$ runghc -itest test/Spec.hs --match "Step 2.2"
4 examples, 0 failures
Step 2.3: Move¶
Implement moveCode and the Move case of compileStmt.
- A move raises the pen, then travels by the offset: exactly two lines,
pen_up()thenmove_rel(dx, dy). (The pen is already up between statements, so thepen_up()is redundant, but it makes the move safe regardless.) - In
compileStmt, evaluate the distance witheval env distanceand callmoveCode.
✅ Check your work (Step 2.3)
ghci> moveCode Rt 30
["pen_up()","move_rel(30.0, 0.0)"]
ghci> showStmts [Move Up (Num 15)]
pen_up()
move_rel(0.0, 15.0)
ghci> previewStmts "moves" [Dot, Move Rt (Num 20), Dot, Move Up (Num 20), Dot]
Writing python/moves.py
[nextdraw_backend] Using MOCK NextDraw (preview only)
[mock_nextdraw] Saved preview to …/python/moves.png
Preview: python/moves.png
python/moves.png shows three dots: at the centre, 20 mm to its right, and 20 mm above that one. They're joined only by pink (pen-up) lines, because moves don't draw.
$ runghc -itest test/Spec.hs --match "Step 2.3"
7 examples, 0 failures
Step 2.4: Line¶
Implement lineCode and the Line case: pen_down(), the move, pen_up(). The pen ends at the far end of the line; that's the "current position changes" rule.
✅ Check your work (Step 2.4)
ghci> lineCode Dn 10
["pen_down()","move_rel(0.0, -10.0)","pen_up()"]
ghci> previewStmts "zigzag" [Line Rt (Num 20), Line Up (Num 20), Line Rt (Num 20), Line Dn (Num 20)]
Writing python/zigzag.py
[nextdraw_backend] Using MOCK NextDraw (preview only)
[mock_nextdraw] Saved preview to …/python/zigzag.png
Preview: python/zigzag.png
Up/Dn offsets are swapped.
$ runghc -itest test/Spec.hs --match "Step 2.4"
5 examples, 0 failures
Step 2.5: Square¶
Implement squareCode and the Square case. The current position is the centre of the square. Think in three stages:
- travel to one corner with the pen up (half a side left and half a side down reaches the bottom-left corner);
- lower the pen and draw the four sides (each
sidelong), ending back at that corner; - raise the pen and travel back to the centre.
The tests check what is drawn (exactly the four sides, centred on the start, in one stroke, meaning only one pen_down()), that the pen ends at the centre, and that it ends up. Any starting corner and either direction around is fine.
✅ Check your work (Step 2.5)
ghci> length (filter (== "pen_down()") (squareCode 20))
1
ghci> take 1 (squareCode 20)
["pen_up()"]
showStmts [Square (Num 20)]: you should see a pen-up trip to a corner, four sides of 20 mm with the pen down, and a pen-up trip back to the centre. Any starting corner, and either direction around, is fine.
ghci> previewStmts "squares" [Square (Num 60), Square (Num 40), Square (Num 20), Dot]
Writing python/squares.py
[nextdraw_backend] Using MOCK NextDraw (preview only)
[mock_nextdraw] Saved preview to …/python/squares.png
Preview: python/squares.png
$ runghc -itest test/Spec.hs --match "Step 2.5"
13 examples, 0 failures
Step 2.6: Rectangle¶
Implement rectangleCode and the Rectangle case. It's just like the square, but with half the width across and half the height up and down. Rectangle w h is w wide and h tall; don't swap them.
✅ Check your work (Step 2.6)
ghci> previewStmts "centred" part2Centred
Writing python/centred.py
[nextdraw_backend] Using MOCK NextDraw (preview only)
[mock_nextdraw] Saved preview to …/python/centred.png
Preview: python/centred.png
part2Centred (in Examples.hs) is three nested squares, a wide 100 × 20 rectangle and a tall 20 × 100 one crossing them, and a dot, all sharing one centre.
$ runghc -itest test/Spec.hs --match "Step 2.6"
9 examples, 0 failures
Step 2.7: Drawing Leaves the Environment Alone¶
Nothing new to write: this checks that every drawing statement returns the environment it was given.
✅ Check your work (Step 2.7, and all of Part 2)
ghci> fst (compileStmt emptyProcEnv (assignVar "x" 10 emptyEnv) (Square (Var "x")))
fromList [("x",10.0)]
$ runghc -itest test/Spec.hs --match "Step 2.7"
1 example, 0 failures
$ make part2
40 examples, 0 failures
previewStmts "row" part2Row, previewStmts "grid" part2DotGrid, and previewStmts "staircase" part2Staircase. Ready to continue when make part2 passes.
Part 3: Circles¶
A plotter can only draw straight lines, so a circle is drawn as a regular polygon with 36 sides (circleSegments, provided). At that size nobody can tell the difference.
The geometry (given to you). Put the centre of the circle at (0, 0). Number the corners of the polygon k = 0, 1, …, 36. Corner k is at angle θₖ = 2πk / 36 (in radians), which is the point
( r · cos θₖ , r · sin θₖ )
Corner 0 is the rightmost point of the circle, (r, 0), and corner 36 is the same point again: all the way round.
The plan for circleCode r:
- With the pen up, travel from the centre to corner 0:
move_rel(r, 0). - Lower the pen.
- For each k from 0 to 35, travel from corner k to corner k + 1. That's a relative move of
dx = r·cos θₖ₊₁ − r·cos θₖ dy = r·sin θₖ₊₁ − r·sin θₖ - Raise the pen and travel back to the centre:
move_rel(-r, 0).
In Haskell: cos, sin and pi are built in. Step 3 is a good place for map over the list [0 .. circleSegments - 1]: write a local helper (in a where) that takes k and produces the moveRel for step k. k is an Int, so use fromIntegral k to use it in the angle formula. Computing each step from the two corners (not by adding up the previous steps) makes the path close exactly.
Step 3.1: circleCode and the Circle Case¶
Implement both.
✅ Check your work (Step 3.1)
ghci> circleSegments
36
ghci> length (filter (== "pen_down()") (circleCode 10))
1
ghci> previewStmts "concentric" part3Concentric
Writing python/concentric.py
[nextdraw_backend] Using MOCK NextDraw (preview only)
[mock_nextdraw] Saved preview to …/python/concentric.png
Preview: python/concentric.png
$ runghc -itest test/Spec.hs --match "Step 3.1"
6 examples, 0 failures
Step 3.2: Circles as Statements¶
Nothing new: this checks that the circle returns to its centre, ends with the pen up, and evaluates its radius.
✅ Check your work (Step 3.2, and all of Part 3)
ghci> previewStmts "inscribed" part3Inscribed
Writing python/inscribed.py
[nextdraw_backend] Using MOCK NextDraw (preview only)
[mock_nextdraw] Saved preview to …/python/inscribed.png
Preview: python/inscribed.png
$ runghc -itest test/Spec.hs --match "Step 3.2"
6 examples, 0 failures
$ make part3
12 examples, 0 failures
Part 4: Assignment and Statement Sequences¶
Worked examples: tracing the environment by hand
Parts 4–6 are all about tracing the environment by hand, statement by statement. The Environment Walkthroughs page has fully worked traces for everything below — worth a look before you start, or when a test fails and you're not sure why.
So far every statement has been compiled on its own, starting with no variables. That's what previewStmts does, and it's why it can't handle this:
x = 10
square x
The square needs to know that x is 10, and that knowledge was created by the previous statement. This part makes statements run in sequence, passing the environment from each statement to the next.
Step 4.1: Assignment¶
Implement the Assign case of compileStmt. An assignment produces no Python. It returns a new environment in which the variable has the value of the expression, evaluated in the current environment. (So x = x * 2 uses the old x to compute the new one.)
Wrap your result with the provided forceValue: forceValue value (newEnv, []). Haskell is lazy: without this, the value wouldn't be computed until some later statement needed it, and a bad assignment such as x = 1 / 0 would never be reported if x were never used. One test checks exactly that.
See Example 1 and Example 2 (part4Doubling) for worked traces of assignment.
✅ Check your work (Step 4.1)
ghci> compileStmt emptyProcEnv emptyEnv (Assign "x" (Num 10))
(fromList [("x",10.0)],[])
ghci> compileStmt emptyProcEnv (assignVar "x" 10 emptyEnv) (Assign "x" (Mul (Var "x") (Num 2)))
(fromList [("x",20.0)],[])
x:
ghci> previewStmts "broken" [Assign "x" (Num 10), Square (Var "x")]
*** Exception: undefined variable: x
…
$ runghc -itest test/Spec.hs --match "Step 4.1"
8 examples, 0 failures
Step 4.2: Sequences¶
Implement the recursive case of compileStmts:
compileStmts :: ProcEnv -> Env -> [Stmt] -> (Env, [String])
The empty case is provided: no statements, no code, the environment unchanged. For stmt : stmts:
env ──[ compile stmt ]──> env after stmt ──[ compile stmts ]──> final env
│ │
code for stmt code for the rest
The invariant: the next statement sees the environment produced by the previous one. The result pairs the final environment with all of the code, in order. A where clause that names the two recursive results with tuple patterns (for example (nextEnv, firstCode) = …) expresses this directly. The environment is passed along explicitly: there's no hidden state.
See Examples 1–3 for the full before/after-threading trace this invariant describes.
Now you can use whole programs: showProgram and previewProgram go through the provided compileProgram, which uses your compileStmts.
✅ Check your work (Step 4.2, and all of Part 4)
ghci> compileStmts emptyProcEnv emptyEnv [Assign "x" (Num 10), Assign "y" (Mul (Var "x") (Num 2))]
(fromList [("x",10.0),("y",20.0)],[])
ghci> showProgram (Program [] [Assign "x" (Num 10), Dot])
pen_down()
pen_up()
ghci> previewProgram "doubling" (Program [] part4Doubling)
Writing python/doubling.py
[nextdraw_backend] Using MOCK NextDraw (preview only)
[mock_nextdraw] Saved preview to …/python/doubling.png
Preview: python/doubling.png
part4Doubling is x = 10; square x; x = x * 2; square x; x = x * 2; square x. It shows three concentric squares of 10, 20 and 40 mm. Also try previewProgram "frame" (Program [] part4Frame): a 120 × 60 frame with a 10 mm inner margin and a line across the middle.
$ runghc -itest test/Spec.hs --match "Step 4.2"
11 examples, 0 failures
$ make part4
19 examples, 0 failures
Part 5: Loops¶
For "i" from to body repeats body once for each whole number from from to to, inclusive, with i set to that number. The rules:
- the bounds are evaluated once, before the loop, in the environment from before the loop;
- both bounds must be whole numbers (
2.5is an error); - if
fromis bigger thanto, the body runs zero times; - assignments to other variables inside the body persist, into later iterations and after the loop;
- the loop variable is local to the loop. Afterwards, it gets back whatever value it had before the loop, or disappears if it didn't exist.
Why the last rule? A loop shouldn't clobber a variable that happens to share its loop variable's name:
i = 100
for i from 1 to 3 { dot }
square i -- still a 100 mm square
See Part 5 of the Environment Walkthroughs for traces of all these rules.
Step 5.1: The Loop Helpers¶
Implement:
saveVar name env: the variable's value (Just v) orNothing. This is exactly oneData.Mapfunction.restoreVar name saved env: put back whatsaveVarfound.Just vbinds the variable tov;Nothingremoves it (Map.delete). Two equations, one per case.toLoopBound bound: turn a whole-numberDoubleinto anInt, or stop witherror ("non-integral loop bound: " ++ show bound).roundgives the nearestInt; the bound is whole exactly when turning that back withfromIntegralgives the bound again.
✅ Check your work (Step 5.1)
ghci> saveVar "x" (assignVar "x" 10 emptyEnv)
Just 10.0
ghci> saveVar "y" (assignVar "x" 10 emptyEnv)
Nothing
ghci> restoreVar "x" (Just 1) (assignVar "x" 10 emptyEnv)
fromList [("x",1.0)]
ghci> restoreVar "x" Nothing (assignVar "x" 10 emptyEnv)
fromList []
ghci> toLoopBound 3
3
ghci> toLoopBound 2.5
*** Exception: non-integral loop bound: 2.5
…
$ runghc -itest test/Spec.hs --match "Step 5.1"
7 examples, 0 failures
Step 5.2: compileLoop and the For Case¶
Implement compileLoop procs var values env body. It compiles the body once for each value in the list values, in order:
- no values: no code, environment unchanged;
- value
vfollowed by the rest: compile the body (withcompileStmts) inenvwithvarset tov, then compile the rest of the values starting from the environment the body left. Combine the code.
It is shaped very much like compileStmts: the environment flows from one iteration into the next, which is how total = total + i accumulates. (v is an Int; assignVar wants a Double, so use fromIntegral v.)
Implement the For case of compileStmt:
- evaluate both bounds and turn them into
Ints withtoLoopBound; - remember the loop variable's old value with
saveVar; - run
compileLoopover the list[start .. end]. Haskell's[5 .. 1]is[], so "zero iterations" is free; - return the loop's final environment with the loop variable restored (
restoreVar), and the loop's code.
See Example 4 for a full trace of the accumulator pattern.
✅ Check your work (Step 5.2)
ghci> fst (compileStmts emptyProcEnv emptyEnv [Assign "total" (Num 0), For "i" (Num 1) (Num 4) [Assign "total" (Add (Var "total") (Var "i"))]])
fromList [("total",10.0)]
ghci> showProgram (Program [] [For "i" (Num 1) (Num 3) [Move Rt (Var "i")]])
pen_up()
move_rel(1.0, 0.0)
pen_up()
move_rel(2.0, 0.0)
pen_up()
move_rel(3.0, 0.0)
ghci> previewProgram "growing" (Program [] part5Growing)
Writing python/growing.py
[nextdraw_backend] Using MOCK NextDraw (preview only)
[mock_nextdraw] Saved preview to …/python/growing.png
Preview: python/growing.png
$ runghc -itest test/Spec.hs --match "Step 5.2"
9 examples, 0 failures
Steps 5.3–5.6: Zero Iterations, Bounds, Scope, Nesting¶
Nothing new to write. These tests check the rules above more thoroughly. If one fails, its name says which rule.
For step-by-step traces of each: Example 5 (a shadowed loop variable), Example 6 (bounds evaluated once), Example 7 (zero iterations and a bad bound), Example 8 (nested loops).
✅ Check your work (Steps 5.3–5.6, and all of Part 5)
ghci> showProgram (Program [] [For "i" (Num 5) (Num 1) [Dot]])
ghci> fst (compileStmts emptyProcEnv emptyEnv [Assign "i" (Num 100), For "i" (Num 1) (Num 3) [Dot]])
fromList [("i",100.0)]
ghci> fst (compileStmts emptyProcEnv emptyEnv [For "i" (Num 1) (Num 3) [Dot]])
fromList []
i restored; the third shows a new loop variable gone afterwards.
$ runghc -itest test/Spec.hs --match "Step 5.3"
3 examples, 0 failures
$ runghc -itest test/Spec.hs --match "Step 5.4"
7 examples, 0 failures
$ runghc -itest test/Spec.hs --match "Step 5.5"
7 examples, 0 failures
$ runghc -itest test/Spec.hs --match "Step 5.6"
8 examples, 0 failures
$ make part5
41 examples, 0 failures
previewProgram "targets" (Program [] part5Targets) shows a 3 × 3 grid of three-ring targets, 35 mm apart.
Part 6: Procedures¶
A procedure is a named, parameterised list of statements, defined at the top of a program:
ProcDef "box" ["size", "gap"] [ Square (Var "size"), Move Rt (Add (Var "size") (Var "gap")) ]
and called with Call "box" [Num 20, Num 5]. The procedure environment (ProcEnv) maps names to definitions. It's built for you by compileProgram (with makeProcEnv), and lookupProc finds a definition (stopping with unknown procedure: … if there isn't one). This is the procs argument that compileStmt has been passing along all this time.
What a call means:
- look up the procedure;
- check the number of arguments. If it's wrong, stop with exactly
procedure <name> expects <n> arguments, got <m>, e.g.procedure box expects 2 arguments, got 1; - evaluate all the arguments in the caller's environment;
- make a local environment: the caller's environment, with each parameter bound to its argument's value (a parameter hides a caller variable of the same name);
- compile the procedure's body in the local environment;
- keep only the body's code. The caller carries on with its own, unchanged environment: nothing the procedure assigns escapes.
See Example 9 for a full trace of a simple call.
Recursion is not allowed. A procedure that calls itself, directly or through other procedures, is an invalid piq program. The compiler does not check for this (compiling one never finishes), and no test uses it.
Step 6.1: Binding Parameters¶
Implement bindParams params values env: bind the first parameter to the first value, the second to the second, and so on, starting from env. Recurse on both lists at once. When they run out, return the environment.
✅ Check your work (Step 6.1)
ghci> bindParams ["a", "b"] [1, 2] emptyEnv
fromList [("a",1.0),("b",2.0)]
ghci> bindParams ["x"] [5] (assignVar "x" 1 emptyEnv)
fromList [("x",5.0)]
$ runghc -itest test/Spec.hs --match "Step 6.1"
3 examples, 0 failures
Step 6.2: Calls¶
Implement the Call case, following the six steps above. Suggestions:
- use a guard for the arity error, and a
whereclause for everything else; - a
whereclause can take a value apart with a pattern. For example, ifpis aProgram, thenProgram defs stmts = pnames both parts. Use the same idea on theProcDefthatlookupProcreturns; - evaluating a list of arguments in one environment is a job for
map; - compile the body with
compileStmts, and keep only its code. When awheretuple pattern contains a part you don't need, write_for it; - wrap your final result with the provided
forceValues, giving it the list of argument values, for the same laziness reason asforceValuein Step 4.1 (a bad argument that the procedure never uses must still be reported).
Start with a simple procedure before trying procedures that call procedures.
✅ Check your work (Step 6.2)
ghci> showProgram (Program [ProcDef "twoDots" [] [Dot, Move Rt (Num 5), Dot]] [Call "twoDots" []])
pen_down()
pen_up()
pen_up()
move_rel(5.0, 0.0)
pen_down()
pen_up()
ghci> previewProgram "boxes" part6Boxes
Writing python/boxes.py
[nextdraw_backend] Using MOCK NextDraw (preview only)
[mock_nextdraw] Saved preview to …/python/boxes.png
Preview: python/boxes.png
part6Boxes calls box(size, gap) from a loop: five squares growing from 8 to 40 mm, with exactly 10 mm between neighbouring edges.
$ runghc -itest test/Spec.hs --match "Step 6.2"
11 examples, 0 failures
Steps 6.3–6.5: Scope, Errors, Whole Programs¶
Nothing new to write. part6Shadowing is this program:
define foo(x) { x = x + 100; square x }
x = 10
foo(20)
square x
foo, x is the parameter (20), so it draws a square of 120. After the call, the caller's x is still 10.
Full traces: shadowing (Example 10, the same part6Shadowing program above), a call inside a loop (Example 11, part6Boxes), and nested procedure calls (Example 12, part6Flowers).
✅ Check your work (Steps 6.3–6.5, and all of Part 6)
ghci> fst (compileStmts (makeProcEnv [ProcDef "foo" ["x"] [Assign "x" (Add (Var "x") (Num 100)), Square (Var "x")]]) (assignVar "x" 10 emptyEnv) [Call "foo" [Num 20]])
fromList [("x",10.0)]
ghci> compileProgram (Program [] [Call "nope" []])
*** Exception: unknown procedure: nope
…
ghci> compileProgram (Program [ProcDef "ring" ["r"] [Circle (Var "r")]] [Call "ring" [Num 1, Num 2]])
*** Exception: procedure ring expects 1 arguments, got 2
…
ghci> previewProgram "shadowing" part6Shadowing
Writing python/shadowing.py
[nextdraw_backend] Using MOCK NextDraw (preview only)
[mock_nextdraw] Saved preview to …/python/shadowing.png
Preview: python/shadowing.png
previewProgram "flowers" part6Flowers: flower calls target, which contains a loop. You should see two flowers, each made of five three-ring targets.
$ runghc -itest test/Spec.hs --match "Step 6.3"
10 examples, 0 failures
$ runghc -itest test/Spec.hs --match "Step 6.4"
7 examples, 0 failures
$ runghc -itest test/Spec.hs --match "Step 6.5"
8 examples, 0 failures
$ make part6
39 examples, 0 failures
Part 7: Whole Programs¶
Nothing new to write: your compiler is complete. Now use it.
Step 7.1: Every Example¶
Examples.hs has example programs for every part. Its main compiles and previews all 21 of them into python/:
✅ Check your work (Step 7.1)
$ runghc Examples.hs
Writing python/part2_centred.py
[nextdraw_backend] Using MOCK NextDraw (preview only)
[mock_nextdraw] Saved preview to …/python/part2_centred.png
Preview: python/part2_centred.png
…
Preview: python/part7_tiles.png. Look through the pictures in python/.
$ runghc -itest test/Spec.hs --match "Step 7.1"
21 examples, 0 failures
Step 7.2: The Showcase¶
part7Showcase is a night city that uses every feature:
window(w)draws a square with pane lines;building(floors, cols, w)computes its own size and places its windows with nested loops;sun(r)draws a circle with rays;- two rows of stars are drawn with loops of dots.
Read it in Examples.hs. It's a real program in your language.
✅ Check your work (Step 7.2)
ghci> previewProgram "showcase" part7Showcase
Writing python/showcase.py
[nextdraw_backend] Using MOCK NextDraw (preview only)
[mock_nextdraw] Saved preview to …/python/showcase.png
Preview: python/showcase.png
$ runghc -itest test/Spec.hs --match "Step 7.2"
9 examples, 0 failures
$ make part7
30 examples, 0 failures
Step 7.3: Your Own Drawing¶
Write your own program as abstract syntax. Add a definition to Examples.hs (for example myDrawing :: Program), :reload, and use previewProgram "mine" myDrawing. Keep it inside the safe area (within 190.5 mm left/right and 114.3 mm up/down of the start): the preview shows anything outside it in red.
This step is optional and ungraded — a fun way to convince yourself your compiler really does everything you think it does.
The Final Check¶
✅ Check your work (everything)
$ make test
210 examples, 0 failures
Common Pitfalls¶
- "TODO (Part N): …" in a test failure means a function you haven't written yet is being used. Check the part number: tests for later parts are expected to fail until you get there.
- Forgetting
:reloadin GHCI after editingPiqEvaluation.hs. - Up is positive.
offset Upmust give a positivedy. The zigzag preview in Step 2.4 shows it immediately. - Numbers are
Doubles.3shows as3.0. Write negative literals with parentheses:Num (-5),moveRel 10 (-5). - A closed shape that doesn't return to its centre makes everything after it land in the wrong place. The nested-squares preview in Step 2.5 shows it immediately.
- Two
pen_down()s in one shape: the tests ask for one continuous stroke per shape. - Returning the wrong environment: drawing statements return the
envthey were given.Assignreturns the new one. A call returns the caller'senv, not the procedure's. - Evaluating in the wrong environment: a loop's bounds and a call's arguments are evaluated in the environment from before the loop or call.
- Forgetting
restoreVarafter a loop: the loop variable leaks out (Step 5.5 tests catch it). - Leaving out
forceValue/forceValues: one test in Step 4.1 and one in Step 6.4 will fail. - Error messages must match exactly, including spaces:
undefined variable: x,division by zero,non-integral loop bound: 2.5,procedure f expects 1 arguments, got 2.
Final Checklist¶
-
make testshows210 examples, 0 failures. - Every
TODOinPiqEvaluation.hsis replaced by your code (grep -n "TODO (Part" PiqEvaluation.hsshows only the note in the file's header comment). -
runghc Examples.hsdraws all 21 pictures, and they look like their descriptions. - You didn't edit the provided files (
PiqAST.hs,Python.hs,Preview.hs,python/,test/).
Turning It In¶
Submit on Gradescope, under HW 05: PicPlot Part 1: Compiling:
- 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. - Upload
PiqEvaluation.hs— the only file you edit for this assignment.
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. The basics:
- Preview first. Never send a drawing to the plotter that you haven't previewed and checked.
- Make sure it fits: the plotter refuses any drawing that leaves the safe area (190.5 mm left/right and 114.3 mm up/down of the start), so check the preview for red first.
- Before plotting, the carriage must be at its home position (the top-left corner), because the plotter treats its position when it connects as home. The program moves to the centre of the paper by itself.
- The plotter computer has its own Python environment with the NextDraw software. There, a generated program runs with
--doplot:Withoutpython python/showcase.py --doplot--doplot, the same file only previews.