orpheus' hacky guide to writing a programming language

chapters

Orpheus finds an easel in the mail

Orpheus writes a lexer

Orpheus writes a parser

Orpheus writes an interpreter

Orpheus decodes the program, and extras

Hack Club is running a programming language jam from June 08 - 29, where you'll get to hack on and ship a programming language in the span of three weeks and possibly win a chance to get a signed copy of Crafting Interpreters. Sign up here before June 07!

High schooler? Get stickers!

Orpheus decodes the program, and extras

And now it's time for the grand finale: getting the easel to work.

Orpheus picks up the easel again and flips it over. Oops. We forgot one question. How do we turn this thing on? No, I don't think smacking the easel is the correct move to make here, Orpheus.

What if we took a look at the source code of the easel? It looks something like this:

<!DOCTYPE html>
<html>
  <head>
    <style>
      * {
        box-sizing: border-box;
      }

      body, html {
        margin: 0;
        overflow-x: hidden;
        padding: 0;
        display: flex;
        align-items: center;
        justify-content: center;
      }
    </style>
  </head>
  <body>
    <canvas id="canvas"></canvas>
    <script type="module">
      const defaultColor = '#ddd'
      const width = 64
      const height = 64
      const gap = 2
      let canvas, ctx, cellSize

      const clear = () => {
        ctx.fillStyle = 'white'
        ctx.fillRect(0, 0, canvas.width, canvas.height)
        ctx.fillStyle = defaultColor
        for (let y = 0; y < height; y++) {
          for (let x = 0; x < width; x++) {
            ctx.fillRect(
              x * cellSize + gap * x,
              y * cellSize + gap * y,
              cellSize,
              cellSize
            )
          }
        }
      }

      const resize = () => {
        const innerWidth = window.innerWidth
        const innerHeight = window.innerHeight
        const size = Math.min(innerWidth, innerHeight)

        canvas.width = canvas.height = size

        cellSize = size / width - gap
      }

      window.onload = () => {
        canvas = document.getElementById('canvas')
        ctx = canvas.getContext('2d')

        resize()
        clear()

        window.addEventListener('resize', resize)
      }
    </script>
  </body>
</html>

Our first step should be to tweak that canvas class of ours to fit this. Remember how Orpheus mentioned while we were writing our interpreter that the idea was to take our digital canvas and map it onto a real canvas? We're going to do that by creating a CustomCanvas that extends from our OG canvas:

import stdlib, { Canvas } from "./stdlib.js"

// ...
let canvas, ctx, cellSize

class CustomCanvas extends Canvas {
  fill([x, y, color]) {
    let cell = this.grid[y * this.cols + x]
    if (cell) {
      cell.r = color.r
      cell.g = color.g
      cell.b = color.b

      ctx.fillStyle = `rgb(${cell.r}, ${cell.g}, ${cell.b})`
      ctx.fillRect(
        x * cellSize + gap * x,
        y * cellSize + gap * y,
        cellSize,
        cellSize
      )
    }
  }

  erase([x, y]) {
    ctx.fillStyle = defaultColor
    ctx.fillRect(
      x * cellSize + gap * x,
      y * cellSize + gap * y,
      cellSize,
      cellSize
    )
  }
}

Now that we have an extension (it's not that different, is it?), we can run program.easel! We're going to have the same pipeline of lexer → parser → interpreter, but this time around, we'll going to add one condition - we'll refresh our easel every so often if our Easel program has a painting function. How do we know if it does? Why, by searching our global scope, of course!

import stdlib, { Canvas } from "./stdlib.js"
import { Lexer } from "./lexer.js"
import { Parser } from "./parser.js"
import { Interpreter } from "./interpreter.js"

window.onload = () => {
  // ...
  clear()

  fetch("/program.easel").then(res => res.text()).then(code => {
    // Run program
    const lexer = new Lexer(code)
    lexer.scanTokens()
    const parser = new Parser(lexer.tokens)
    parser.parse()
    const interpreter = new Interpreter()
    let scope = interpreter.run(parser.ast, {
      ...stdlib,
      Canvas: new CustomCanvas()
    })

    const interval = setInterval(() => {
      if (!interpreter.inScope(scope, "painting")) return clearInterval(interval)

      ctx.fillStyle = "white"
      ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height)

      try {
        const lexer = new Lexer("painting()")
        lexer.scanTokens()
        const parser = new Parser(lexer.tokens)
        parser.parse()
        scope = interpreter.run(parser.ast, scope)
      } catch {
        clearInterval(interval)
      }
    }, 100)
  }).catch(err => console.log(err))

  window.addEventListener("resize", resize)
}

If you head over to the Easel tab and click Run, it works now! You know what that means?

In which we find out our mysterious sender

Heck yeah! We can finally use the little program our sender has given to us. It's our only clue. Let's run the program now.

What???

Just then there's another knock on the door. Orpheus, I think that's our man. Now's our chance to find out who he is, exactly!

Orpheus runs stomps over to the door, but there's no one there, again. The only difference is that it's sunny outside now. Oh well.

He left us another letter, though.


Congratulations on figuring out the easel.

I know you're a tad bit disappointed that you haven't figured out who I am, but it's about the journey along the way. You're probably doing something funky with your face right now.

But I digress! You just made magic happen! Isn't that amazing? You got the easel working. You're an artist a magician and a hacker, Orpheus.

Signed, your mysterious sender


I guess we'll never figure out who our mysterious sender is. But he's right, I guess?

Ooh, Orpheus. You know what we should do? We should celebrate with a picnic.

...

I can't tell if I'm always hungry or if Orpheus is. Maybe the both of us.


Well, smokes. That was fun. It's almost Monday again. Ugh. Orpheus hates Mondays. Mondays are always so boring and dull. And she has to do the dishes. Yes, she's unfortunately collected an intolerable pile of dishes from the past few days. A few too many pancakes have been consumed, and too many cups of tea have been drunk.

Hm, Orpheus says out loud. You know what we would be fun? A competition! I'm a little bit interested now. To see who can write the best, silliest language while still managing to be Turing-complete!!!

Just between the two of us? I point at the two of us. Orpheus shakes her head. No. We should have more people!

I like the idea of this.


Interested? If you're a teenager, Hack Club is running a programming language jam where you can win a signed copy of Crafting Interpreters.

Interested! Get more info at langjam.hackclub.com.


Resources

Crafting Interpreters is a great, accessible next step.

Check out #building-programming-languages on the Hack Club Slack if you're a teenager to hang out with other teenagers working on building programming languages!

Acknowledgements

Hi! I'm @jc. I wrote this with Orpheus, but this wouldn't have been possible without: Hack Club. Friends. Some of Orpheus' silly habits or choices like dinner for breakfast, and running a marathon (on an impulse) are things I do with friends, or friends have convinced me to do. One such decision was to drive to Boston on weekends to stay up extra late and hack on projects like this.

All code, including this website, is fully open source on GitHub.

If you're a teenager and find this cool, join Hack Club! We work on extra cool projects we call You Ship, We Ship, where you build something cool with friends and we do too, like Sprig and OnBoard!