diff --git a/.gitignore b/.gitignore index 48010bb..7acc024 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,6 @@ build/ .vscode/ ### Mac OS ### -.DS_Store \ No newline at end of file +.DS_Store + +/endpoints-output/** \ No newline at end of file diff --git a/parser/pom.xml b/parser/pom.xml index 02c7c9c..a81607b 100644 --- a/parser/pom.xml +++ b/parser/pom.xml @@ -57,6 +57,7 @@ org.apache.maven.plugins maven-jar-plugin + 3.4.2 diff --git a/parser/src/main/antlr4/imports/Common.g4 b/parser/src/main/antlr4/imports/Common.g4 new file mode 100644 index 0000000..8844343 --- /dev/null +++ b/parser/src/main/antlr4/imports/Common.g4 @@ -0,0 +1,41 @@ +// Copyright 2025 "Johan Maasing" +// +// 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 : ':' ; \ No newline at end of file diff --git a/parser/src/main/antlr4/nu/zoom/dsl/parser/Endpoints.g4 b/parser/src/main/antlr4/nu/zoom/dsl/parser/Endpoints.g4 index 4f47bf8..1a74607 100644 --- a/parser/src/main/antlr4/nu/zoom/dsl/parser/Endpoints.g4 +++ b/parser/src/main/antlr4/nu/zoom/dsl/parser/Endpoints.g4 @@ -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]+ ; diff --git a/parser/src/main/antlr4/nu/zoom/dsl/parser/States.g4 b/parser/src/main/antlr4/nu/zoom/dsl/parser/States.g4 new file mode 100644 index 0000000..dbfe628 --- /dev/null +++ b/parser/src/main/antlr4/nu/zoom/dsl/parser/States.g4 @@ -0,0 +1,22 @@ +// Copyright 2025 "Johan Maasing" +// +// 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? ; \ No newline at end of file diff --git a/parser/src/main/java/nu/zoom/dsl/ast/DocumentNode.java b/parser/src/main/java/nu/zoom/dsl/ast/DocumentNode.java index 24ac244..fcb8621 100644 --- a/parser/src/main/java/nu/zoom/dsl/ast/DocumentNode.java +++ b/parser/src/main/java/nu/zoom/dsl/ast/DocumentNode.java @@ -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 config, - List typeDefinitions, - List endpoints) { + Set typeDefinitions, + List endpoints, + Set states) { } diff --git a/parser/src/main/java/nu/zoom/dsl/ast/EndpointsVisitorTransformer.java b/parser/src/main/java/nu/zoom/dsl/ast/EndpointsVisitorTransformer.java index 5d846d6..e530a80 100644 --- a/parser/src/main/java/nu/zoom/dsl/ast/EndpointsVisitorTransformer.java +++ b/parser/src/main/java/nu/zoom/dsl/ast/EndpointsVisitorTransformer.java @@ -19,7 +19,8 @@ import org.antlr.v4.runtime.tree.TerminalNode; import java.util.*; -public class EndpointsVisitorTransformer extends EndpointsBaseVisitor { +public class EndpointsVisitorTransformer + extends EndpointsBaseVisitor { private final ArrayList endpoints = new ArrayList<>(); private final HashMap config = new HashMap<>(); private final HashSet dataTypes = new HashSet<>(); @@ -35,8 +36,8 @@ public class EndpointsVisitorTransformer extends EndpointsBaseVisitor getDataTypes() { - return List.copyOf(dataTypes); + public Set getDataTypes() { + return Set.copyOf(dataTypes); } @Override @@ -130,7 +131,7 @@ public class EndpointsVisitorTransformer extends EndpointsBaseVisitor transitions) { +} diff --git a/parser/src/main/java/nu/zoom/dsl/ast/StatesVisitorTransformer.java b/parser/src/main/java/nu/zoom/dsl/ast/StatesVisitorTransformer.java new file mode 100644 index 0000000..e1360a7 --- /dev/null +++ b/parser/src/main/java/nu/zoom/dsl/ast/StatesVisitorTransformer.java @@ -0,0 +1,106 @@ +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 { + private final HashMap config = new HashMap<>(); + private final HashSet nodeTypes = new HashSet<>(); + private final HashSet messageTypes = new HashSet<>(); + // from -> + private final HashMap> 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 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 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 getStates() { + HashSet states = new HashSet<>(); + this.transitions.forEach((state,v)->{ + HashSet transitionNodes = new HashSet<>(); + v.forEach((to, message) -> transitionNodes.add(new TransitionNode(message, to))); + states.add(new StateNode(state, "", transitionNodes)) ; + }) ; + return states ; + } + + public Map getConfig() { + return Map.copyOf(config); + } + + public Set getTypes() { + // TODO calculate data types from NodeTypes and MessageTypes with duplicate check. + HashMap typeNodes = new HashMap<>(); + this.nodeTypes.forEach(typeNode -> { + if (typeNodes.containsKey(typeNode.name())) { + throw new RuntimeException("Duplicate type name: " + typeNode.name()); + } else { + typeNodes.put(typeNode.name(), typeNode); + } + }) ; + this.messageTypes.forEach(typeNode -> { + if (typeNodes.containsKey(typeNode.name())) { + throw new RuntimeException("Duplicate type name: " + typeNode.name()); + } else { + typeNodes.put(typeNode.name(), typeNode); + } + }) ; + return Set.of(typeNodes.values().toArray(new TypeNode[0])) ; + } + + private List 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() : ""); + } + +} diff --git a/parser/src/main/java/nu/zoom/dsl/ast/TransitionNode.java b/parser/src/main/java/nu/zoom/dsl/ast/TransitionNode.java new file mode 100644 index 0000000..90ebd3e --- /dev/null +++ b/parser/src/main/java/nu/zoom/dsl/ast/TransitionNode.java @@ -0,0 +1,4 @@ +package nu.zoom.dsl.ast; + +public record TransitionNode(String message, String toState) { +} diff --git a/parser/src/main/java/nu/zoom/dsl/cli/EndpointsCLI.java b/parser/src/main/java/nu/zoom/dsl/cli/EndpointsCLI.java index b26cb9c..724d9b4 100644 --- a/parser/src/main/java/nu/zoom/dsl/cli/EndpointsCLI.java +++ b/parser/src/main/java/nu/zoom/dsl/cli/EndpointsCLI.java @@ -34,6 +34,10 @@ import java.util.concurrent.Callable; description = "Generate source code from an endpoints specification file." ) public class EndpointsCLI implements Callable { + public enum ParserType { + Endpoints, + States + } @SuppressWarnings("unused") @Parameters(index = "0", description = "The source endpoints DSL file.") private Path file; @@ -50,6 +54,9 @@ public class EndpointsCLI implements Callable { @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 { 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); diff --git a/states-templates/nodes.md.ftl b/states-templates/nodes.md.ftl new file mode 100644 index 0000000..89a5603 --- /dev/null +++ b/states-templates/nodes.md.ftl @@ -0,0 +1,29 @@ +``` +Copyright 2025 "Johan Maasing" + + 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. +``` + +# The nodes + +```mermaid +--- +title: ${config.title} +--- +stateDiagram-v2 +<#list states as state> + <#list state.transitions as transition> + ${state.name} --> ${transition.toState} : ${transition.message} + + +``` \ No newline at end of file diff --git a/test01.states b/test01.states new file mode 100644 index 0000000..d1f78a9 --- /dev/null +++ b/test01.states @@ -0,0 +1,4 @@ +{ title: SomeNodes } +start(s:S) -> middle: message(foo:bar), +middle -> middle: selfmessage, +middle -> end: endmessage(bar:baz) \ No newline at end of file