The com.mbeddr.mpsutil.interpreter language allows to create an interpreter for any language implemented in MPS. Creating the interpreter should be as simple as possible, avoiding the boilerplate code required for any interpreter.
We describe the set of MPS Language(s) defining the concepts of the evaluated nodes in one Interpreter as "Interpreted Language". For example, the Interpreted Language of CExpressionsInterpreter, CFunctionInterpreter and CMathInterpreter is mbeddr C.
The Interpreter language consists of these main building blocks:
Complete examples are available for CExpressionsInterpreter, CExtInterpreter, CPointerInterpreter, CFunctionInterpreter, CModulesInterpreter, CStatementInterpreter, CMathInterpreter and ReqirementsInterpreter.
Interpreters exist in different steps within a lifecycle. At definition time, the instance of the Interpreter exists with a name, e. g. "SampleInterpreter". When the concept instance is being build at compile time, a Java class based on the com.mbeddr.mpsutil.interpreter.rt is created; following our example it would be named "Interpreter_SampleInterpreter". An interpreter is used by querying InterpreterRegistry for an instance of the generated Java class; we call this instantiation time. We feed a node to this interpreter to start actual evaluation. The evaluators of the interpreter are executed now at evaluation time.
An Interpreter Definition describes the interpreter. The name of the Interpreter should comply to Java Class Name standards. The category is a way of grouping associated interpreters. Categories don't affect the interpreters except a warning if we mix interpreters of different categories.
Interpreter Definitions need to be contained in a plugin model, as we generate an ExtensionDeclaration for Extension Point InterpreterExtensionPoint for each interpreter.
relatedInterpreters describe other interpreters somehow related to this interpreter. At definition time, Applicable Languages and Type Mappings of all related interpreters are considered additionally to the the ones defined locally. At instantiation time, if we combine several related interpreters, the execution engine ensures the evaluation order defined by related interpreters (before or after relations). This order is similar to MPS Generators priorities.
An error is flagged if we define unsolvable dependency cycles between interpreters.
applicableLanguages limit the types and concepts available in the Type Mappings and Evaluators to the languages defined here. This reduces possibility to mix concepts of different languages (e. g. BaseLanguage Statement and C Statement). Please note that we still need to add the languages to the dependencies of this model in order to use concepts contained within them.
typeMappings map types of the interpreted language to Java types. The most common mapping is the BaseLanguageTypeMapping.
The left-hand side of the mapping accepts BaseLanguage expressions to provide maximum flexibility. However, in most cases we just want to specify a type in the interpreted language. To do this we introduced the ConceptTypeExpression $ (dollar) expression.
The right-hand side accepts any Java type, both primitives and classes.
Another mapping is the TypeMapping. The left-hand side mirrors the BaseLanguageTypeMapping described above. The right-hand side is a BaseLanguage method body, expecting a compatible type of the left-hand side as result. We may use this to substitute types of the interpreted language if they don't change the interpreted result; otherwise, we would need to implement every evaluator twice.
The evaluators are the core of the interpreter. They describe the concepts to be interpreted and how to evaluate the result of these concepts.
Besides the concept the evaluator applies for, we may specify type constraints on the evaluated concept's child concepts, i. e. the AST sub-nodes below the evaluated node. For each child concept, we specify the role and the expected type. The $ (dollar) expression can be used here as well.
The actual evaluation can be implemented in BaseLanguage. For simpleEvaluator (starting with an equals sign), we may enter a simple BaseLanguage expression. More complexEvaluator may be entered in a method body (denoted with a pair of braces).
Evaluators are selected at evaluation time based on the order described in Section Related Interpreters and the order of evaluators within the interpreter. However, most times the sensible order of evaluators can be deducted from the matching concepts and constraints. A warning was displayed If this sensible order is violated. The following rules define the sensible order:
MySuperConcept
and sub-concepts MySubConceptA
and MySubConceptB
. Evaluators exist for MySuperConcept
and MySubConceptA
. When the interpretation encounters an instance of MySubConceptA
, the evaluator for MySubConceptA
is triggered. When the interpretation encounters an instance of MySubConceptB
, the evaluator for MySuperConcept
is used.
int16
is more specific than int32
, the evaluator for int16
will be chosen for int16
and int8
additions (as int8
is a subtype of int16
).
We may use any appropriate BaseLanguage constructs in the evaluator implementation. "Appropriate" meaning any BaseLanguage expression in simpleEvaluator and any BaseLanguage statement in a complexEvaluator. Additionally, we provide some new expressions specific for interpreters:
#(«childReference»)
) invokes the interpreter on the selected child and returns the evaluation result. If the selected child was constrained to a type by the containing evaluator, the evaluation result is of BaseLanguage type mapped to this constrained type in Section Type Mappings section. As an example, the type of right
is constrained to int16
, and int16
is mapped to int
. Thus, the result type of #right
is int
.
#(«expression»)
) invokes the interpreter on the result of the contained expression. The contained expression must evaluate to node<>
. The result type of this expression is always Object
, as we cannot determine a more specific type.
#->«functionRoleName»(«actualParametersRoleName» => «formalParametersRoleName»)
) invokes a function call semantic with positional arguments on the node contained in functionRoleName
. This semantic includes push/pop of a new environment before/after the evaluation of the function; evaluation of each actual parameter node; positional assignment of the results of these evaluations to the formal parameter nodes; and creating environment entries for all formal->actual parameter mappings so they can be used as variables.
node
) represents the currently interpreted node. Its type is defined by the concept of the evaluator.
context
) provides access to the current interpreter context. Its type is IContext.
env
) provides access to the current environment. Its type is IEnvironment, a subtype of Map
.
castUp(«value», «TargetType»)
) cares for boxing/unboxing primitive types and automatic compatible conversion, e. g. int
--> long
.
For each Interpreter Definition, one subclass of InterpreterBase implementing IInterpreter is generated. An instance of this is returned by the generated ExtensionDeclaration for Extension Point InterpreterExtensionPoint. The InterpreterRegistry provides access to this instance.
We use CombinedInterpreter to leverage more than one interpreter for the evaluation of our model. This container class takes care of ordering all related interpreters within the constructor argument list of interpreters, i. e. no other interpreters are taken into account, even if they appear in the Related Interpreters section of some of the listed interpreters.
The subclass finds all interpreters available in the context of the passed node and adds them to the list of combined interpreters.
Within all evaluators, an IContext is available through the context
expression. It allows access to the Environment and other information about the running interpreter. We may extend the context for our own purposes. However, the context also holds the internal state of an interpreter (which itself is stateless), so we need to pay attention if we alter existing functionality.
The interpreter runtime maintains a hierarchical environment of type IEnvironment available through the env
expression. The environment can be used to implement scopes and stack frames:
env
acts as a map of type (node<> -> Object)
. If we put an object into the environment (see example addToEnvironment), it is stored in the current environment. If we get an object from the environment, the key is first searched in the current environment. If it cannot be found there, we traverse the parent environments until we either found the key or reached the root environment. This mimics hierarchical scopes.
env.push(«anchorNode», «initialEntries»)
and the corresponding env.pop(«anchorNode»)
(See example complexEvaluator) allows to create and destroy sub-environments, implementing stack frames. To improve error recovery, we should always enclose the push/pop
pair into a try ... finally
block.
The specialized IPersistentEnvironment interface follows the same semantics, but keeps all environments even after they have been popped. This allows to access all intermediate states encountered during evaluation time.
The IContext may contain a INodeValueCache of evaluated result per node. If we request the evaluation of a node that's contained in the cache, the cached result is returned and no evaluation occurs.
The interpreter runtime tries to invalidate the cache on useful occations, like OperationCallExpression and Environment changes.
The cache can be disabled for single evaluators via the ToggleCaching intention or the inspector.
If we just want to evaluate a node based on a category with reasonable defaults, we'd choose Section Leveraging reasonable defaults with InterpreterEvaluationHelper.
In order to execute an interpreter, we need to follow these basic steps:
Each step is described in more detail below.
The first three readers of this section will get three free beers, each.
For no-thrills evaluation of a node based on an Interpreter Category, use InterpreterEvaluationHelper.
We call the constructor with the name of the requested Interpreter Category.
null
.
null
.
Fundamentally, we aquire an IInterpreter from the InterpreterRegistry singleton. the getInterpreterExecutable method returns the IInterpreter.
We provide several convenience classes supplementing the interpreter aquisition.
The InterpreterFinder finds Interpreter nodes (to be fed to the InterpreterRegistry) matching certain criteria.
The method findAllVisibleInterpreters looks up all Interpreter nodes visible from the context of the argument node.
The method findVisibleInterpretersForCategory acts similar, but filters the result by Interpreter Category.
The CachedInterpreterFinder singleton builds a cache of all globally available Interpreter nodes. The cache is flushed only on request, or if an InterpreterExtensionPoint is loaded or unloaded. The CachedInterpreterFinder provides methods similar to the InterpreterFinder (findAllInterpreters and findInterpretersForCategory, respectively).
The CombinedInterpreter takes care of combining several Interpreters, possibly related (see Section Related Interpreters, to one single IInterpreter containing the union of all Evaluators. This allows us to distribute Interpreter definitions over several languages, possibly close to the definition of the interpreted concept itself.
The constructor CombinedInterpreter takes an arbitary number of IInterpreters to combine.
The takes one node as constructor parameter, feeds this node to findAllVisibleInterpreters, and initializes the CombinedInterpreter with the resulting IInterpreters.
Interpreters themselves are stateless. An instance of IContext holds the complete state, both internal to the interpreter framework and our own additions.
The default implementation ContextImpl is suitable for most purposes. We may pass an IInterpreter as constructor parameter, which will be set as root interpreter (see Section Set the root interpreter).
We need to set the root interpreter for a context, because we can combine interpreters arbitrarily. We use the method setRootInterpreter to do this.
As we have an IInterpreter, an IContext, and a root interpreter set, we can finally start the interpreter. We use the method evaluate for this. The result of the method call will be the evaluated result.
The following exceptions might occur during evaluation. The exception messages contains a trace of the evaluated nodes.
The com.mbeddr.mpsutil.interpreter.test language provides the basis for custom test languages for the Interpreted Language. An example for a custom test language would be com.mbeddr.core.cinterpreter, examples for tests can be found at ExampleTest.
The AbstractInterpreterEvaluation concept provides a NodeAnnotation that checks if the evaluation result of the node conforms to an expected value. Similarly, the AbstractInterpreterCondition concept provides a NodeAnnotation that checks for the expected result of isEvaluable. If the expected result is not met, a typesystem error is flagged, which can be checked in a NodesTestCase.
In order to use these concepts, we need to create a sub-concept and implement the following behaviors:
IInterpreter
suitable for interpreting the annotated node.
An extended version of an Interpreter is a ConditionalInterpreter. It allows to define a isEvaluable method that decides if a node can be evaluated.
The isEvaluable()
operation can be added to any Evaluator inside a ConditionalInterpreter by using the addCondition intention. If no isEvaluable()
operation is present, it's assumed to return true
if and only if all its children are evaluable.
The isEvaluable()
operation can be implemented as single-line BaseLanguage expression or multi-line BaseLanguage statements. In addition to the expressions described in Section Evaluator Implementation, we can use the following expressions:
#?«childReference»
) returns if the referenced child is evaluable by this interpreter.
#?(«expression»)
) returns if the result of the contained expression is evaluable by this interpreter. The contained expression must evaluate to node<>
.
An ConditionalInterpreter implements IConditionalInterpreter, containing one additional method isEvaluable. It takes the same arguments as evaluate and returns whether this interpreter can evaluate the given node.
We provide conditional variants of CombinedInterpreter and , named CombinedConditionalInterpreter and , respectively. They provide the same semantics, limited to Conditional Interpreters.
We aquire our Conditional Interpreter as described in Section Aquire an IInterpreter and need to cast it to the IConditionalInterpreter interface. We might check the isEvaluable method result before calling evaluate, as it will not be called automatically.
An PersistentInterpreter is a wrapper around an arbitrary IInterpreter that keeps all intermediate results for evaluated nodes.
In order to leverage this feature, we need to wrap our original IInterpreter as a constructor parameter into a PersistentInterpreter. Some of the exposed interfaces are also subclassed:
After a call to evaluate, we may retrieve the intermediate results from the passed IPersistentContext. They are stored per IPersistentEnvironment in the associated IPersistentNodeValueCache.