Recall that in our jsonic language specification, we had the idea to treat ordinary JSON as valid source code for our DSL.
We’ll take a similar approach here. Our puzzle input describes the wires in a circuit. But we could also think of it as the source code of a language that doesn’t yet exist. If we create that language, we can convert the puzzle input from an inert file to something we can run with Racket. And then we can get the solution to our puzzle question.
That language is wires.
The puzzle gives us a smaller working circuit that will also be a valid wires program. Let’s start there and see what we can figure out:
1 2 3 4 5 6 7 8
x AND y -> d x OR y -> e x LSHIFT 2 -> f y RSHIFT 2 -> g NOT x -> h NOT y -> i 123 -> x 456 -> y
According to the puzzle, each line describes the input to a wire. The name of the wire is on the right end of the line. The input is on the left, separated by ->. A wire receives the result of one of six types of input:
A constant value, like 123 or 456.
A NOT operation, which takes one other constant value or wire as its argument.
An operation combining two other constant values or wires—either AND,
If we can handle these six kinds of input, we should be able to model any wire.
The puzzle also asks us to determine the value on a wire. So we’ll print the value of every wire as demonstrated in the puzzle description. We can see how this works now by running our sample circuit as a wires-demo program:
1 2 3 4 5 6 7 8 9
#lang wires-demo x AND y -> d x OR y -> e x LSHIFT 2 -> f y RSHIFT 2 -> g NOT x -> h NOT y -> i 123 -> x 456 -> y
1 2 3 4 5 6 7 8
d: 72 e: 507 f: 492 g: 114 h: 65412 i: 65079 x: 123 y: 456
First, a circuit description is a flat list of wires (like a stacker program was a flat list of arguments). Therefore, the wires language won’t have a recursive structure. This will let us simplify our reader. Specifically, we can avoid using a separate grammar-based parser.
Second, even though the circuit description is a flat list, the dependencies between wires might be in any order. For instance, in the circuit above, the first six wire definitions rely on values from wires x and y, which aren’t defined until the end.
As a rule of thumb, we want to model each element of a domain-specific language using its closest equivalent in Racket. Why? Because we’re making a source-to-source compiler, so we’d like to avoid reinventing wheels, or pounding round pegs into square holes. Wise modeling choices lead to simpler implementations, because we’re delegating more of the heavy lifting to Racket.
For wires, our key insight is that each wire name can be thought of as an identifier, and each wire description as a define expression binding that identifier. Furthermore, the bitwise operations appearing on the left side of each wire description—like NOT, OR, and LSHIFT—are just functions that take wires as their arguments.
In pseudocode, we could imagine transforming the sample circuit into a Racket program that looks like this (assuming for now that we’ve implemented the bitwise operations):
1 2 3 4 5 6 7 8
This meets some of our requirements. The big problem is that the wire definitions are out of order. So if we were to run this program, it would fail in the first line when evaluating (AND x y), because x and y aren’t bound yet. We can trigger the same error with this analogous test program:
x: undefined; cannot reference an identifier before its definition
So we need a way of setting up relationships between wires, while delaying the actual evaluation until requested. The simplest fix is to instead model each wire as a function (again in pseudocode):
1 2 3 4 5 6 7 8
Converting the wires into functions doesn’t change their underlying values. But it does delay the evaluation of each wire until we invoke that particular wire function. It also makes it possible to have references between wires that are out of order.
Again, we can see how this works by updating our test program and running it again:
This time, no error. And if we evaluate (d) on the REPL:
We get the right answer:
This gives us enough of a roadmap to start building the language.
We’ll also need to figure out how to implement our bitwise operations and our printing routine. We’ll deal with those as they arise.
Follow the instructions in the master recipe to make a wires directory and install it as a package. As with stacker, we’ll be able to build this language using one (hard-working) source file called "main.rkt".
#lang br/quicklang ···
We’re building our language using a single source file. So the "main.rkt" module will contain both the reader and the expander.