Add states parser

This commit is contained in:
Johan Maasing 2025-04-20 11:46:55 +02:00
parent 35ef968ed1
commit f5ff4e06bc
17 changed files with 454 additions and 60 deletions

View file

@ -57,6 +57,7 @@
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
<configuration>
<archive>
<manifest>

View file

@ -0,0 +1,41 @@
// Copyright 2025 "Johan Maasing" <johan@zoom.nu>
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
grammar Common;
generatorconfig : '{' (configitem)? (',' configitem)* '}';
configitem : configkey ':' configvalue ;
configkey : IDENTIFIER ;
configvalue : (IDENTIFIER|VALUE) ;
namedTypeDeclaration : typeName typeDeclaration ;
typeName : IDENTIFIER ;
typeDeclaration : '(' typeField (',' typeField)* ')' ;
typeField : fieldName COLON fieldType ;
fieldName : IDENTIFIER ;
fieldType : IDENTIFIER ;
fragment LOWERCASE : [a-z] ;
fragment UPPERCASE : [A-Z] ;
fragment GENERICS : '['|']'|'<'|'>' ;
fragment DOT : '.' ;
fragment COMMENT_BEGIN : '/*' ;
fragment COMMENT_END : '*/' ;
fragment DIGIT : [0-9] ;
WS : [ \t\n\r]+ -> skip;
COMMENT : COMMENT_BEGIN .*? COMMENT_END -> skip;
LEFT_ARROW : '<-' ;
RIGHT_ARROW : '->' ;
IDENTIFIER : (LOWERCASE | UPPERCASE) (LOWERCASE | UPPERCASE | DIGIT | GENERICS | DOT)* ;
VALUE : ~[ ,{}:()/="#';*\n\r\t]+ ;
SLASH : '/' ;
COLON : ':' ;

View file

@ -12,35 +12,11 @@
// See the License for the specific language governing permissions and
// limitations under the License.
grammar Endpoints;
import Common;
document : generatorconfig? (namedTypeDeclaration|endpoint)* ;
generatorconfig : '{' (configitem)? (',' configitem)* '}';
configitem : configkey ':' configvalue ;
configkey : IDENTIFIER ;
configvalue : (IDENTIFIER|VALUE) ;
namedTypeDeclaration : typeName typeDeclaration ;
typeName : IDENTIFIER ;
typeDeclaration : '(' typeField (',' typeField)* ')' ;
typeField : fieldName ':' fieldType ;
fieldName : IDENTIFIER ;
fieldType : IDENTIFIER ;
requestBody : REQUEST_PREFIX (namedTypeDeclaration | typeDeclaration | IDENTIFIER) ;
responseBody : RESPONSE_PREFIX (namedTypeDeclaration | typeDeclaration | IDENTIFIER) ;
requestBody : LEFT_ARROW (namedTypeDeclaration | typeDeclaration | IDENTIFIER) ;
responseBody : RIGHT_ARROW (namedTypeDeclaration | typeDeclaration | IDENTIFIER) ;
endpoint : path requestBody responseBody?;
path : (pathSegment)+ ;
pathSegment : SLASH (IDENTIFIER|VALUE) ;
fragment DIGIT : [0-9] ;
fragment LOWERCASE : [a-z] ;
fragment UPPERCASE : [A-Z] ;
fragment GENERICS : '['|']'|'<'|'>' ;
fragment DOT : '.' ;
fragment COMMENT_BEGIN : '/*' ;
fragment COMMENT_END : '*/' ;
WS : [ \t\n\r]+ -> skip;
COMMENT : COMMENT_BEGIN .*? COMMENT_END -> skip;
REQUEST_PREFIX : '<-' ;
RESPONSE_PREFIX : '->' ;
SLASH : '/' ;
IDENTIFIER : (LOWERCASE | UPPERCASE) (LOWERCASE | UPPERCASE | DIGIT | GENERICS | DOT)* ;
VALUE : ~[ ,{}:()/="#';*\n\r\t]+ ;

View file

@ -0,0 +1,22 @@
// Copyright 2025 "Johan Maasing" <johan@zoom.nu>
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
grammar States;
import Common;
document : generatorconfig? transition (',' transition)* ;
transition : from RIGHT_ARROW to COLON message ;
from : state ;
to : state ;
message : typeName typeDeclaration? ;
state : typeName typeDeclaration? ;

View file

@ -15,9 +15,11 @@ package nu.zoom.dsl.ast;
import java.util.List;
import java.util.Map;
import java.util.Set;
public record DocumentNode(
Map<String, String> config,
List<TypeNode> typeDefinitions,
List<EndpointNode> endpoints) {
Set<TypeNode> typeDefinitions,
List<EndpointNode> endpoints,
Set<StateNode> states) {
}

View file

@ -19,7 +19,8 @@ import org.antlr.v4.runtime.tree.TerminalNode;
import java.util.*;
public class EndpointsVisitorTransformer extends EndpointsBaseVisitor<EndpointsParser.DocumentContext> {
public class EndpointsVisitorTransformer
extends EndpointsBaseVisitor<EndpointsParser.DocumentContext> {
private final ArrayList<EndpointNode> endpoints = new ArrayList<>();
private final HashMap<String,String> config = new HashMap<>();
private final HashSet<TypeNode> dataTypes = new HashSet<>();
@ -35,8 +36,8 @@ public class EndpointsVisitorTransformer extends EndpointsBaseVisitor<EndpointsP
return Map.copyOf(config);
}
public List<TypeNode> getDataTypes() {
return List.copyOf(dataTypes);
public Set<TypeNode> getDataTypes() {
return Set.copyOf(dataTypes);
}
@Override
@ -130,7 +131,7 @@ public class EndpointsVisitorTransformer extends EndpointsBaseVisitor<EndpointsP
).toList();
}
// Concatenate the text from to terminal nodes. Useful for contexts that are either an identifier or a value,
// Concatenate the text from two terminal nodes. Useful for contexts that are either an identifier or a value,
// and you just want the text from whichever is not null.
private String getText(TerminalNode identifier, TerminalNode value) {
return

View file

@ -13,23 +13,43 @@
// limitations under the License.
package nu.zoom.dsl.ast;
import nu.zoom.dsl.parser.EndpointsLexer;
import nu.zoom.dsl.parser.EndpointsParser;
import nu.zoom.dsl.parser.*;
import org.antlr.v4.runtime.CharStreams;
import org.antlr.v4.runtime.CommonTokenStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.List;
import java.util.Set;
public class ParserWrapper {
public static DocumentNode parse(Path sourcePath) throws IOException {
public static DocumentNode parseEndpoints(Path sourcePath) throws IOException {
var ins = CharStreams.fromPath(sourcePath, StandardCharsets.UTF_8);
EndpointsLexer lexer = new EndpointsLexer(ins);
EndpointsParser parser = new EndpointsParser(new CommonTokenStream(lexer));
var document = parser.document();
var astTransformer = new EndpointsVisitorTransformer();
astTransformer.visit(document);
return new DocumentNode(astTransformer.getConfig(), astTransformer.getDataTypes(), astTransformer.getEndpoints());
return new DocumentNode(
astTransformer.getConfig(),
astTransformer.getDataTypes(),
astTransformer.getEndpoints(),
Set.of()
);
}
public static DocumentNode parseStates(Path sourcePath) throws IOException {
var ins = CharStreams.fromPath(sourcePath, StandardCharsets.UTF_8);
StatesLexer lexer = new StatesLexer(ins);
StatesParser parser = new StatesParser(new CommonTokenStream(lexer));
var document = parser.document();
var astTransformer = new StatesVisitorTransformer();
astTransformer.visit(document);
return new DocumentNode(
astTransformer.getConfig(),
astTransformer.getTypes(),
List.of(),
astTransformer.getStates()
);
}
}

View file

@ -0,0 +1,6 @@
package nu.zoom.dsl.ast;
import java.util.Set;
public record StateNode(String name, String data, Set<TransitionNode> transitions) {
}

View file

@ -0,0 +1,132 @@
package nu.zoom.dsl.ast;
import nu.zoom.dsl.parser.StatesBaseVisitor;
import nu.zoom.dsl.parser.StatesParser;
import org.antlr.v4.runtime.tree.TerminalNode;
import java.util.*;
import java.util.stream.Stream;
public class StatesVisitorTransformer extends StatesBaseVisitor<StatesParser.DocumentContext> {
private final HashMap<String,String> config = new HashMap<>();
private final HashSet<TypeNode> nodeTypes = new HashSet<>();
private final HashSet<TypeNode> messageTypes = new HashSet<>();
// from -> <to, message>
private final HashMap<String,HashMap<String, String>> transitions = new HashMap<>();
@Override
public StatesParser.DocumentContext visitTransition(StatesParser.TransitionContext ctx) {
String from = ctx.from().state().typeName().IDENTIFIER().getText() ;
String to = ctx.to().state().typeName().IDENTIFIER().getText() ;
String message = ctx.message().typeName().IDENTIFIER().getText() ;
this.transitions.computeIfAbsent(from, k -> new HashMap<>()).put(to, message);
return super.visitTransition(ctx);
}
@Override
public StatesParser.DocumentContext visitState(StatesParser.StateContext ctx) {
String stateName = ctx.typeName().IDENTIFIER().getText() ;
List<FieldNode> fields = extractFields(ctx.typeDeclaration()) ;
this.nodeTypes.add(new TypeNode(stateName, fields));
return super.visitState(ctx);
}
@Override
public StatesParser.DocumentContext visitMessage(StatesParser.MessageContext ctx) {
String messageName = ctx.typeName().IDENTIFIER().getText() ;
List<FieldNode> fields = extractFields(ctx.typeDeclaration()) ;
this.messageTypes.add(new TypeNode(messageName, fields));
return super.visitMessage(ctx);
}
@Override
public StatesParser.DocumentContext visitConfigitem(StatesParser.ConfigitemContext ctx) {
String configKey = ctx.configkey().IDENTIFIER().getText();
String configValue = getText(ctx.configvalue().IDENTIFIER(), ctx.configvalue().VALUE());
this.config.put(configKey, configValue);
return super.visitConfigitem(ctx);
}
public Set<StateNode> getStates() {
HashSet<StateNode> states = new HashSet<>();
this.transitions.forEach((state,v)->{
HashSet<TransitionNode> transitionNodes = new HashSet<>();
v.forEach((to, message) -> transitionNodes.add(new TransitionNode(message, to)));
states.add(new StateNode(state, "", transitionNodes)) ;
}) ;
return states ;
}
public Map<String,String> getConfig() {
return Map.copyOf(config);
}
public Set<TypeNode> getTypes() {
final HashMap<String, TypeNode> stateTypeNodes = new HashMap<>();
this.nodeTypes.forEach(typeNode -> {
if (stateTypeNodes.containsKey(typeNode.name())) {
TypeNode mergedNode = mergeTypeFields(typeNode, stateTypeNodes.get(typeNode.name()));
stateTypeNodes.put(typeNode.name(), mergedNode);
} else {
stateTypeNodes.put(typeNode.name(), typeNode);
}
}) ;
final HashMap<String, TypeNode> messageTypeNodes = new HashMap<>();
this.messageTypes.forEach(typeNode -> {
if (stateTypeNodes.containsKey(typeNode.name())) {
throw new ParseException("Message " + typeNode.name() + " conflicts with state with the same type name");
}
if (messageTypeNodes.containsKey(typeNode.name())) {
TypeNode mergedNode = mergeTypeFields(typeNode, messageTypeNodes.get(typeNode.name()));
messageTypeNodes.put(typeNode.name(), mergedNode);
} else {
messageTypeNodes.put(typeNode.name(), typeNode);
}
}) ;
HashSet<TypeNode> allTypeNodes = new HashSet<>(stateTypeNodes.values());
allTypeNodes.addAll(messageTypeNodes.values());
return allTypeNodes ;
}
private TypeNode mergeTypeFields(TypeNode t1, TypeNode t2) {
List<FieldNode> t1Fields = (t1 != null) ? t1.fields() : List.of() ;
List<FieldNode> t2Fields = (t2 != null) ? t2.fields() : List.of() ;
HashMap<String, FieldNode> mergedFields = new HashMap<>();
t1Fields.forEach(field -> {
if (mergedFields.containsKey(field.name())) {
throw new ParseException("Duplicate field name: " + field.name());
}
mergedFields.put(field.name(), field);
});
t2Fields.forEach(field -> {
if (mergedFields.containsKey(field.name())) {
throw new ParseException("Duplicate field name: " + field.name());
}
mergedFields.put(field.name(), field);
});
return new TypeNode(t1.name(), mergedFields.values().stream().toList()) ;
}
private List<FieldNode> extractFields(StatesParser.TypeDeclarationContext declaration) {
if (declaration == null) {
return Collections.emptyList();
}
return declaration
.typeField()
.stream()
.map(
ctx ->
new FieldNode(ctx.fieldName().getText(), ctx.fieldType().getText())
)
.toList();
}
// Concatenate the text from two terminal nodes. Useful for contexts that are either an identifier or a value,
// and you just want the text from whichever is not null.
private String getText(TerminalNode identifier, TerminalNode value) {
return
((identifier != null) ? identifier.getText() : "") +
((value != null) ? value.getText() : "");
}
}

View file

@ -0,0 +1,4 @@
package nu.zoom.dsl.ast;
public record TransitionNode(String message, String toState) {
}

View file

@ -34,22 +34,29 @@ import java.util.concurrent.Callable;
description = "Generate source code from an endpoints specification file."
)
public class EndpointsCLI implements Callable<Integer> {
public enum ParserType {
Endpoints,
States
}
@SuppressWarnings("unused")
@Parameters(index = "0", description = "The source endpoints DSL file.")
private Path file;
@SuppressWarnings("CanBeFinal")
@Option(names = {"-t", "--template"}, description = "The template directory. Default is ~/endpoints-templates")
private Path templateDir = Paths.get(System.getProperty("user.dir"), "endpoints-templates");
@Option(names = {"-t", "--template"}, defaultValue = "endpoints-template", description = "The template directory. Default is ${DEFAULT-VALUE}")
private Path templateDir ;
@SuppressWarnings("CanBeFinal")
@Option(names = {"-o", "--output"}, description = "The directory to write the generated code to. Default is ~/endpoints-output")
private Path outputDir = Paths.get(System.getProperty("user.dir"), "endpoints-output");
@Option(names = {"-o", "--output"}, defaultValue = "endpoints-output", description = "The directory to write the generated code to. Default is ${DEFAULT-VALUE}")
private Path outputDir ;
@SuppressWarnings("unused")
@Option(names = {"-v", "--verbose"}, description = "Print verbose debug messages.")
private Boolean verbose = false;
@Option(names = {"-p", "--parser"}, description = "Force use of a specific parser instead of determining from filename. Valid values: ${COMPLETION-CANDIDATES}.")
private ParserType parser = null;
public static void main(String[] args) {
int exitCode = new CommandLine(new EndpointsCLI()).execute(args);
System.exit(exitCode);
@ -61,8 +68,20 @@ public class EndpointsCLI implements Callable<Integer> {
validateTemplateDirectory();
validateInputFile();
validateOutputDirectory();
verbose("Parsing " + file.toAbsolutePath());
DocumentNode rootNode = ParserWrapper.parse(file);
verbose("Parsing: " + file.toAbsolutePath());
if (parser == null) {
if (file.getFileName().toString().endsWith(".states")) {
parser = ParserType.States;
}
}
final DocumentNode rootNode ;
if (parser == ParserType.States) {
verbose("using state grammar.") ;
rootNode = ParserWrapper.parseStates(file);
} else {
verbose("using endpoints grammar.") ;
rootNode = ParserWrapper.parseEndpoints(file);
}
verbose("AST: " + rootNode);
verbose("Generating from templates in: " + templateDir.toAbsolutePath());
Generator generator = new Generator(templateDir, rootNode, outputDir);