Implementing an SPL interpreter
TL;DR
We implement an interpreter for the PetitParser SPL case study using a small-step operational semantics in which the state of a running program consists of: (1) an AST representing the rest of the code to be executed, (2) the environment of variables and their values, and (3) the output produced so far.
Operational semantics of SPL
We will directly implement a so-called Structural Operation Semantics for SPL. In essence, we model a running SPL program as a transition system, or state machine, in which execution means taking a “small step” from one program state to another.
An SPL program state consists of three parts, which we call a “context” for running the program. The context is updated with each execution step.
1. The rest of the code
An AST is used to represent the code to be executed. With each step, this tree will be “reduced”, or transformed, to another tree. For normal, terminating programs, this AST will shrink in size until it reachs the empty program, and stop.
2. The environment
SPL has variables, so we must keep track of their values. With each variable declaration, a new variable is added to the environment. With each variable initialization or assignment, the environment is updated.
3. The output
The output is simply a growing collection of strings produced by print
statements.
At the end of the program, the only side effect is the output produced.
Implementing the SPL semantics
To implement the semantics we need
(1) the SPLContext
object to keep track of the execution states, and
(2) stepInContext:
methods for every node in the SPLNode
hierarchy.
Furthermore, every node must know if it is fully reduced or not. This is false in general (see SPLNode>>#isReduced
), and is only true for values (like 3
or true
) and empty blocks of statements.
Let's see the example of the print
statement. SPLPrintStatement>>#stepInContext:
A print statement prints an expression. If the expression is fully reduced to a value, then we print that value to the output and replace the print
statement by an SPL expression statement, which will take the next step.
If, on the other hand, the expression is not reduced, we ask the expression take a step, and then replace the print
statement by a new print
for the partially reduced expression.
Running a simple program
Consider this simple SPL program. The three boxes show the execution context. The code to execute is print (3+4);
, and the environment and output are empty.
After one step, the environment is updated with x=3
, and the variable declaration has reduced to an expression statement.
After another step, the expression statement has been discarded, and we move to the print statement.
After a few more steps, the print
statement has been evaluated, and the output has been produced.
Testing the interpreter
We define SPLContextExamples
containing numerous examples to test all of the step rules.
A typical example is this “hello world” test.
printHello <gtExample> | result context | context := self context: '// Hello program in SPL print "hello";'. result := context. result := context reduce. self assert: context output allButLast equals: 'hello'. ^ context
In this example we send reduce
to the context to tell it to step until the end.
Inspector views for SPL contexts
By adding a few custom inspector views to the SPLContext
class, we obtain a simple back-in-time debugger almost for free.
The “Continuation” view shows the code to be executed in a text editor element, here for a factorial program.
The “AST” view shows the AST of the code.
The “Environment” view shows the current variable bindings, here after 7 steps of the factorial program.
The “Output” is another text editor view, here for the reduced factorial.
The “State” view composes these three views into one, here one step before the end.
Finally, after every step, we save the new context in a trace collection, so the “History” view summarizes all the previous states.
Naturally, the Inspector view combines all of these views, and adds “step” and “reduce” buttons to the “State” view.