In this lab assignment, you will get to know the Amy language and implement an interpreter for it.
Setup
Download the project files from this link and decompress them.
This zip file contains a folder with the Scala 3 project that you will be working on in this lab. In particular, it contains the bare-bone structure of the interpreter and a compiled version of the Amy compiler’s frontend (lexer, parser, namer, typer) which are needed before your interpreter can be called.
You should be able to compile the project successfully by typing sbt in the console
and then typing compile.
You can also try the commands test and run library/Std.scala examples/Hello.scala,
which will initially crash with a scala.NotImplementedError exception,
as the project is still missing your interpreter implementation.
Part 1: Your first Amy programs
Write two example Amy programs and make sure you can compile them using the Amy Reference Compiler. Put them under /examples. Please be creative when writing your programs: they should be nontrivial and not reproduce the functionality of the examples in the /library and /examples directories of the repository. Of course you are welcome to browse these directories for inspiration.
Remember that you will use these programs in the remaining of the semester to test your compiler, so don’t make them too trivial! Try to test many features of the language.
If you have questions about how a feature of Amy works, you can always look at the Amy Specification. It’s a good idea to keep a local copy of this document handy – it will be your reference for whenever you are asked to implement an aspect of the Amy language throughout this semester.
Type check examples
You can use the provided Frontend to type check your programs. To do so, run the provided bash script:
./amytc.sh examples/your_program.amy
You can also run the type checker directly from sbt:
$ sbt
> run library/Std.amy examples/Hello.amy --type-check
Hello world!
This will run the compiler frontend up to type checking and report either Type checking successful! or an error message. If you get an error message, you should fix the error before moving on to the next step.
Please examine the bash script amytc.sh and its comments in your editor to understand how it works. Do not modify it.
Troubleshooting
- Your project must compile before you call the
amytc.shscript. - If you get unexpected errors or behaviour, try to delete the
target/scala-<version>/amyc-assembly-1.7.jarand retry.
Part 2: An Interpreter for Amy
The main task of the first lab is to write an interpreter for Amy.
Interpreters
The way to execute programs you have mostly seen so far is compilation to some kind of low-level code (bytecode for a virtual machine such as Java’s; native binary code in case of languages such as C). An alternative way to execute programs is interpretation. According to Wikipedia, “an interpreter is a computer program that directly executes, i.e. performs, instructions written in a programming or scripting language, without previously compiling them into a machine language program”. In other words, your interpreter is supposed to directly look at the code and interpret its meaning. For example, when encountering a call to the ‘printString’ function, your interpreter should print its argument on the standard output.
The general structure of the Interpreter
The skeleton of the assignment is provided by us in three files:
-
The
Main.scalasource file -
The
Interpreter.scalasource file, and -
the
amyc-frontend-1.7.jarbytecode file, which is located underlib/.
Now let’s look into the code in a little more detail.
In Main.scala, take a look at the main method, which is the entry point to your program. After processing the command line arguments of the interpreter, the main method creates a Pipeline, which contains the different stages of the compiler (more on it in later assignments). The Pipeline will first call the Amy frontend, which will parse the source program into an abstract syntax tree (AST) and check it for correctness according to the Amy specification, and then passes the result to the Interpreter. The implementation of the frontend is given to you in compiled form (it lives in the amyc-frontend-1.7.jar file), because you will need to write your own version in the next assignments. Note: You are only allowed to use this binary code to link against your interpreter.
So what is this AST we’ve mentioned? For the computer to “understand” the meaning of a program, it first has to transform it from source (text) form to a more convenient form, which we call an abstract syntax tree. The AST abstracts away uninteresting things of the program (e.g. parentheses, whitespace, operator precedence…) and keeps the essential structure of the program.
In Scala, we represent the AST as a tree-form object. The tree has different types of nodes, each one representing a different programming structure. The types of nodes are of course represented as different classes, which all inherit from a class called Tree. Conveniently enough, the classes correspond pretty much one-to-one to the rules of the BNF grammar given in the language specification. E.g. in the language spec we read that a module looks as follows:
Module ::= **object** Id Definition* Expr? **end** Id
and indeed in the implementation we find a class
case class ModuleDef(name: Identifier, defs: List[ClassOrFunDef], optExpr: Option[Expr]) extends Definition
You can find the source code of the AST in src/amyc/ast/TreeModule.scala, in the zip file linked above.
The Interpreter class
Now let’s delve into Interpreter.scala. This file currently only contains a partial implementation, and it is your task for to complete it! The entrypoint into the interpreter is interpret, which takes an expression as input and executes its meaning. The main loop at the end of the class will just take the modules in order and interpret their expression, if present.
interpret returns a Value, which is a type that represents a value that an Amy expression can produce. Value is inherited by classes which represent the different types of values present in Amy (Int, Booleans, Unit, String and ADT values). Value has convenience methods to cast to Int, Boolean and String (as*). Remember we can always call these methods safely when we know the types of an expression (e.g. the operands of an addition), since we know that the program type-checks.
interpret takes an additional implicit parameter as an argument, which is a mapping from variables to values (in the interpreted language). In Scala, when an implicit parameter is expected, the compiler will look in the scope for some binding of the correct type and pass it automatically. This way we do not have to pass the same mapping over and over to all recursive calls to interpret. Be aware, however, that there are some cases when you need to change the locals parameter! Think carefully about when you have to do so. You can explicitly pass implicit parameters with the using keyword. For example:
def f(x: Int)(implicit y: Int) = x + y
f(2)(using 2)
A few final notes:
-
You can print program output straight to the console.
-
You can assume the input programs are correct. This is guaranteed by the Amy frontend.
-
To find constructors and functions in the program, you have to search in the
SymbolTablepassed along with the program. To do this, use the three helper methods provided in the interpreter:-
isConstrutorwill return whether theIdentifierargument is a type constructor in the program -
findFunctionOwnerwill return the module which contains the givenIdentifier, which has to be a function in the program. E.g. if you give it theprintIntfunction of theStdmodule, you will get the string"Std". -
findFunctionwill return the function definition given a pair of Strings representing the module containing the function, and the function name. The return value is of typeFunDef(see the AST definition inSymbolicTreeModule).
-
-
When comparing Strings by reference, compare the two
StringValues directly and not the underlying Strings. The reason is that the JVM may return true when comparing Strings by equality when it is not expected (it has to do with JVM constant pools). -
Some functions contained in the
Stdmodule are built-in in the language, i.e. they are hard-coded in the interpreter because they cannot be implemented in Amy otherwise. An example of a built-in function isprintString. When you implement the interpreter for function calls, you should first check if the function is built-in, and if so, use the implementation provided in thebuiltInsmap in the interpreter. -
When a program fails (e.g. due to a call to
erroror a match fail), you should call the dedicated method in the Context:ctx.reporter.fatal.
Implementation skeleton
-
src/amyc/interpreter/Interpreter.scalacontains a partially implemented interpreter -
src/amyc/Main.scalacontains themainmethod which runs the interpreter on the input files -
The
librarydirectory contains library definitions you can call from your programs. -
The
examplesdirectory contains some example programs on which you can try your implementation. Remember that most of them also use library files from/library. -
lib/amyc-frontend-1.7.jarcontains the frontend of the compiler as a library, allowing you directly work with type-checked ASTs of input programs.
You will have to complete the interpreter by implementing the missing methods (marked with the placeholder ???).
Testing
When you are done, use sbt to try some of your programs from Part 1:
$ sbt
> run library/Std.amy examples/Hello.amy --interpret
Hello world!
You can also run your interpreter with the amyi.sh script in a similar way as you did with the type checker:
$ ./amyi.sh examples/Hello.amy
Hello world!
Note: if you use this method, you have to delete target/scala-<version>/amyc-assembly-1.7.jar before running the script when you modified your interpreter. Otherwise, the script will reuse the previously compiled version of the interpreter and your new modifications would not be taken into account. Therefore this method is more recommended for testing multiple amy programs, rather than testing your interpreter while you are developing it.
There is also testing infrastructure under /test. To add your own tests, you have to add your testcases under /test/resources/interpreter/passing
and the expected output under /test/resources/interpreter/outputs.
Then, you have to add the name of the new test in InterpreterTests, similarly to the examples given.
To allow a test to also use the standard library (e.g., Std.printString), you can copy Std.scala from library/Std.scala to /test/resources/interpreter/passing.
For example, to add a test that expects only “Hello world” to be printed, you can add /test/resources/interpreter/passing/Hello.amy containing:
object Hello
Std.printString("Hello world")
end Hello
and /test/resources/interpreter/outputs/Hello.txt containing
Hello world
(with a newline in the end!).
You will also have to add a line to /test/scala/amyc/test/InterpreterTests.scala: @Test def testHello = shouldOutput(List("Std", "Hello"), "Hello"). This will pass both files Std.amy and Hello.amy as inputs of the test. When you now run test from sbt, you should see the additional test case (called testHello).
To run the test suites, you can use sbt:
$ sbt
> test
Deliverables
You are given 2 weeks for this assignment. Deadline: Wed, Mar 11th, 11pm.
You will need to submit your src/amyc/interpreter/Interpreter.scala file to Canvas
– follow this link to the assignment.
Since you will submit a single Scala file, all your changes should be self-contained in that file.
To test it, we will replace our own Interpreter.scala file with yours and run the tests on our computer.
Related documentation
- End of Chapter 1 in the “Tiger Book” presents a similar problem for another mini-language. A comparison of the implementation of ASTs in Java (as shown in the book) and Scala is instructive.