The objective of this step is the following:
- Use a classical meta-compiler (ANTLER) to define a grammar and implement a lexer/parser for our language;
- Leverage the
Listener
pattern to build a model from a given word
Guess what? The LED example is back!
For this step, we model the LED example using a textual syntax for the FSM
application led {
actuator led: 13
-> off {
led is LOW
goto on
}
on {
led is HIGH
goto off
}
}
The language is self-explainable:
- We declare a LED on pin #13
- We declare two states, on and off.
- Off is an initial state (incoming arrow)
- the goto keyword indicates which state is the next one.
Modelling the grammar
ANTLR expects grammars to be defined in eBNF-like format, in a g4 file
app : 'application' name=IDENTIFIER '{' actuator+ state+ '}';
actuator : 'actuator' location ;
location : id=IDENTIFIER ':' port=PORT_NUMBER;
state : initial? name=IDENTIFIER '{' action+ next '}';
action : receiver=IDENTIFIER 'is' value=SIGNAL;
next : 'goto' target=IDENTIFIER ;
initial : '->';
Instantiating models
When using maven to build the code, the antlr
compiler is called on this grammar file, and generate the associated lexer and parser in target/generated-sources/antlr4
. If you are using an IDE, do not forget to include this directory in your classpath.
The ANTLR tool supports two patterns to interact with the DSL code: a classical visitor or the listener pattern. As the visitor was previously described, we use here the listener one. By extending ArduinoMLBaseListener
, one can implement a listener for a word conform to the ArduinoML
grammar. For each rule R
declared in the grammar, the listener provides two methods:
enterR
: triggered each time the system enters in theR
rule;exitR
: triggered each time the system enters in theR
rule.
Using this pattern, we override several methods to implement a listener named ModelBuilder
that build instances of the previously defined meta-model (step 4). We rely on shared variables (e.g., a symbol table for states, the current application to build, the current state) to share information between method calls.
private App theApp = null;
private Map<String, State> states = new HashMap<>();
private State currentState = null;
// …
@Override public void enterState(ArduinoMLParser.StateContext ctx) {
State local = new State();
local.setName(ctx.name.getText());
this.currentState = local;
this.states.put(local.getName(), local); // Symbol table for states
}
@Override public void exitState(ArduinoMLParser.StateContext ctx) {
this.theApp.getStates().add(this.currentState);
this.currentState = null;
}
//…
Calling the compiler
To call the compiler (see the Main
class), we bind the lexer and parser together and associate our ModelBuilder as a parse tree walker.
private static App buildModel(CharStream stream) {
ArduinoMLLexer lexer = new ArduinoMLLexer(stream);
lexer.removeErrorListeners();
lexer.addErrorListener(new StopErrorListener());
ArduinoMLParser parser = new ArduinoMLParser(new CommonTokenStream(lexer));
parser.removeErrorListeners();
parser.addErrorListener(new StopErrorListener());
ParseTreeWalker walker = new ParseTreeWalker();
ModelBuilder builder = new ModelBuilder();
// parser.app() is the entry point of the grammar
walker.walk(builder, parser.app());
return builder.retrieve();
}
Expected Work
- Adapt the language to support sensors and transitions associated to sensors;
- Identify the abstractions needed in the language to support the 7-segment display;
- Adapt the language to support it.
Stepback Questions
- Who is the intended user ? What about the tooling associated with the language?
- More generally, what is the cost of such an approach?
- To what extent is the language fragile to the introduction of new features?
- What is the relationship between the meta-model and the grammar?
- How to validate that the defined syntax is the right one?