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

4
.gitignore vendored
View file

@ -32,4 +32,6 @@ build/
.vscode/ .vscode/
### Mac OS ### ### Mac OS ###
.DS_Store .DS_Store
/endpoints-output/**

106
README.md
View file

@ -32,6 +32,17 @@ parser and a code generator using [freemarker](https://freemarker.apache.org).
| mytemplate.xxx.ftl | | mytemplate.xxx.ftl |
\____________________\ \____________________\
``` ```
Endgen currently contains two separate parsers:
* endpoint - A DSL for expressing HTTP endpoints.
* state - A DSL for expressing state and transitions.
Which parser that is used to read the input file is determined by the file name ending
'.endpoints' or '.states' or by a command line argument.
The endpoint DSL and the state DSL share the grammar for expressing configuration and data types
,see below for details.
## How to Run ## How to Run
You need a Java 21 (or later) runtime and java in the path. A very convenient way to install a java runtime is [SdkMan](https://sdkman.io). You need a Java 21 (or later) runtime and java in the path. A very convenient way to install a java runtime is [SdkMan](https://sdkman.io).
@ -39,20 +50,23 @@ Unpack the archive, run the provided shellscript file.
### Usage ### Usage
``` ```
Usage: run.sh [-hvV] [-o=<outputDir>] [-t=<templateDir>] <file> sage: run.sh [-hvV] [-o=<outputDir>] [-p=<parser>] [-t=<templateDir>] <file>
Generate source code from an endpoints specification file. Generate source code from an endpoints specification file.
<file> The source endpoints DSL file. <file> The source endpoints DSL file.
-h, --help Show this help message and exit. -h, --help Show this help message and exit.
-o, --output=<outputDir> The directory to write the generated code to. -o, --output=<outputDir> The directory to write the generated code to.
Default is ~/endpoints-output Default is endpoints-output
-p, --parser=<parser> Force use of a specific parser instead of
determining from filename. Valid values:
Endpoints, States.
-t, --template=<templateDir> -t, --template=<templateDir>
The template directory. Default is The template directory. Default is
~/endpoints-templates endpoints-template
-v, --verbose Print verbose debug messages. -v, --verbose Print verbose debug messages.
-V, --version Print version information and exit. -V, --version Print version information and exit.
``` ```
## DSL example ## Endpoint DSL example
In the simplest form the DSL looks like this In the simplest form the DSL looks like this
``` ```
/some/endpoint <- SomeType(foo:String) /some/endpoint <- SomeType(foo:String)
@ -80,6 +94,8 @@ This is the ANTLR grammar for the root of the DSL
```antlrv4 ```antlrv4
document : generatorconfig? (namedTypeDeclaration|endpoint)* ; document : generatorconfig? (namedTypeDeclaration|endpoint)* ;
``` ```
### Configuration block
Meaning that the DSL file has an optional `generatorconfig` block at the top. Then you can write either; a type Meaning that the DSL file has an optional `generatorconfig` block at the top. Then you can write either; a type
definition, or an endpoint declaration, as many times as you like. definition, or an endpoint declaration, as many times as you like.
@ -99,7 +115,9 @@ Embedded(foo:Bar)
This consists of a config block with 2 items, the 'package' and the 'mykey' definition. These are available to be used This consists of a config block with 2 items, the 'package' and the 'mykey' definition. These are available to be used
in the freemarker template as a Map of String-keys to String-values. in the freemarker template as a Map of String-keys to String-values.
`/some/endpoint <- SomeType(foo:String)` is an endpoint declaration. It declares one endpoint that have a request body ### Endpoint definition
`/some/endpoint <- SomeType(foo:String)` is an endpoint definition. It declares one endpoint that have a request body
data type called `SomeType` that has a field called `foo` of the type `String`. data type called `SomeType` that has a field called `foo` of the type `String`.
### Data types ### Data types
@ -110,7 +128,7 @@ just named field-name and the other string is named field-type.
`Embedded(foo:Bar)` is a `namedTypeDeclaration` which is parsed the same way as the request type above. But isn't tied `Embedded(foo:Bar)` is a `namedTypeDeclaration` which is parsed the same way as the request type above. But isn't tied
to a specific endpoint. to a specific endpoint.
### Automatically named data types ### Automatically named endpoint data types
`/some/other/endpoint <- (bar:Seq[Embedded])` is another endpoint declaration. However this time the request body is `/some/other/endpoint <- (bar:Seq[Embedded])` is another endpoint declaration. However this time the request body is
not named in the DSL. But all datatypes must have a name so it will simply name it after the last path segment and not named in the DSL. But all datatypes must have a name so it will simply name it after the last path segment and
tack on the string 'Request' at the end. So the AST till contain a datatype named `endpointRequest` with a field named tack on the string 'Request' at the end. So the AST till contain a datatype named `endpointRequest` with a field named
@ -122,7 +140,7 @@ decide to generate in the templates.
The only 'semantic' validation the parser performs is to check that not two types have the same name. The only 'semantic' validation the parser performs is to check that not two types have the same name.
### Reponse data types ### Endpoint reponse data type
It is possible to have an optional response data type declared like so: It is possible to have an optional response data type declared like so:
`/some/other/endpoint <- (bar:Seq[Embedded]) -> ResponseType(foo: Bar)` `/some/other/endpoint <- (bar:Seq[Embedded]) -> ResponseType(foo: Bar)`
@ -130,9 +148,45 @@ It is possible to have an optional response data type declared like so:
The right pointing arrow `->` denotes a response type, it can be an anonymous data type in which case the parser till The right pointing arrow `->` denotes a response type, it can be an anonymous data type in which case the parser till
name it from the last path segment and add 'Response' to the end of the data type name. name it from the last path segment and add 'Response' to the end of the data type name.
### DSL config ### State grammar
The only key in the config block the generator looks at is called `ending`, this will be used as the file ending for
the resulting file of applying the freemarker template. This is an example of a state file:
```
start -> middle: message,
middle -> middle: selfmessage,
middle -> end: endmessage
```
It contains 3 state definitions `start`, `middle` and `end`. A state definition will be parsed as a data type with
the name of the state as the type name.
It also contains 3 message definitions `message`, `selfmessage` and `endmessage`. Message definitions will also be
parsed as data types.
Since the parser will extract datatypes it is possible to define the fields of the data types. This is a slightly more
complicated example:
```
start(foo:Foo) -> middle: message(a: String),
middle(bar:Bar) -> middle: selfmessage,
middle -> end: endmessage
```
Where for example the data type for `middle` will have the field declaration with the name `bar` and the type `Bar`.
Fields for the same state data type, or message data type, will be merged. Here is a complex example:
```
start(s:S) -> middle(foo:foo): message(foo:foo),
middle -> middle(bar:bar): selfmessage(bar:bar),
middle -> end: message(bar:baz)
```
Not that we can declare fields on both the `from` and `to` state declarations. The `middle` datat type will have field
definitons for `foo` and `bar`.
The data type for `message` will have fields for `foo` and `bar`.
One restriction is that states and messages may not have the same name, i.e. be parsed as the same data type.
## Generating ## Generating
If the parser is successful it will hold the following data in the AST If the parser is successful it will hold the following data in the AST
@ -140,12 +194,17 @@ If the parser is successful it will hold the following data in the AST
```java ```java
public record DocumentNode( public record DocumentNode(
Map<String, String> config, Map<String, String> config,
List<TypeNode> typeDefinitions, Set<TypeNode> typeDefinitions,
List<EndpointNode> endpoints) { List<EndpointNode> endpoints,
Set<StateNode> states) {
} }
``` ```
This will be passed to the freemarker engine as the 'root' data object, meaning you have access to the parts in your freemarker template like this: Depending on the parser used the endpoints or the states will be null but config and typeDefinitions are populated the
same for both parsers.
This will be passed to the freemarker engine as the 'root' data object, meaning you have access to the parts in your
freemarker template like this:
```injectedfreemarker ```injectedfreemarker
<#list typeDefinitions as type> <#list typeDefinitions as type>
@ -153,7 +212,7 @@ This will be passed to the freemarker engine as the 'root' data object, meaning
</#list> </#list>
``` ```
That is, you can directly reference `typeDefinitions`, `endpoints` or `config`. That is, you can directly reference `typeDefinitions`, `endpoints`, `states` or `config`.
### Config ### Config
The config object is simply a String-map with the keys and values unfiltered from the input file. Here is an example The config object is simply a String-map with the keys and values unfiltered from the input file. Here is an example
@ -212,4 +271,21 @@ Output data type.
</#list> </#list>
</#list> </#list>
``` ```
### States
The set of states will hold items of this shape:
```injectedfreemarker
public record StateNode(String name, String data, Set<TransitionNode> transitions) {
}
```
and the transitions has this structure:
```injectedfreemarker
public record TransitionNode(String message, String toState) {
}
```

View file

@ -57,6 +57,7 @@
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId> <artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
<configuration> <configuration>
<archive> <archive>
<manifest> <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 // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
grammar Endpoints; grammar Endpoints;
import Common;
document : generatorconfig? (namedTypeDeclaration|endpoint)* ; document : generatorconfig? (namedTypeDeclaration|endpoint)* ;
generatorconfig : '{' (configitem)? (',' configitem)* '}'; requestBody : LEFT_ARROW (namedTypeDeclaration | typeDeclaration | IDENTIFIER) ;
configitem : configkey ':' configvalue ; responseBody : RIGHT_ARROW (namedTypeDeclaration | typeDeclaration | IDENTIFIER) ;
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) ;
endpoint : path requestBody responseBody?; endpoint : path requestBody responseBody?;
path : (pathSegment)+ ; path : (pathSegment)+ ;
pathSegment : SLASH (IDENTIFIER|VALUE) ; 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.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
public record DocumentNode( public record DocumentNode(
Map<String, String> config, Map<String, String> config,
List<TypeNode> typeDefinitions, Set<TypeNode> typeDefinitions,
List<EndpointNode> endpoints) { List<EndpointNode> endpoints,
Set<StateNode> states) {
} }

View file

@ -19,7 +19,8 @@ import org.antlr.v4.runtime.tree.TerminalNode;
import java.util.*; 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 ArrayList<EndpointNode> endpoints = new ArrayList<>();
private final HashMap<String,String> config = new HashMap<>(); private final HashMap<String,String> config = new HashMap<>();
private final HashSet<TypeNode> dataTypes = new HashSet<>(); private final HashSet<TypeNode> dataTypes = new HashSet<>();
@ -35,8 +36,8 @@ public class EndpointsVisitorTransformer extends EndpointsBaseVisitor<EndpointsP
return Map.copyOf(config); return Map.copyOf(config);
} }
public List<TypeNode> getDataTypes() { public Set<TypeNode> getDataTypes() {
return List.copyOf(dataTypes); return Set.copyOf(dataTypes);
} }
@Override @Override
@ -130,7 +131,7 @@ public class EndpointsVisitorTransformer extends EndpointsBaseVisitor<EndpointsP
).toList(); ).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. // and you just want the text from whichever is not null.
private String getText(TerminalNode identifier, TerminalNode value) { private String getText(TerminalNode identifier, TerminalNode value) {
return return

View file

@ -13,23 +13,43 @@
// limitations under the License. // limitations under the License.
package nu.zoom.dsl.ast; package nu.zoom.dsl.ast;
import nu.zoom.dsl.parser.EndpointsLexer; import nu.zoom.dsl.parser.*;
import nu.zoom.dsl.parser.EndpointsParser;
import org.antlr.v4.runtime.CharStreams; import org.antlr.v4.runtime.CharStreams;
import org.antlr.v4.runtime.CommonTokenStream; import org.antlr.v4.runtime.CommonTokenStream;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List;
import java.util.Set;
public class ParserWrapper { 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); var ins = CharStreams.fromPath(sourcePath, StandardCharsets.UTF_8);
EndpointsLexer lexer = new EndpointsLexer(ins); EndpointsLexer lexer = new EndpointsLexer(ins);
EndpointsParser parser = new EndpointsParser(new CommonTokenStream(lexer)); EndpointsParser parser = new EndpointsParser(new CommonTokenStream(lexer));
var document = parser.document(); var document = parser.document();
var astTransformer = new EndpointsVisitorTransformer(); var astTransformer = new EndpointsVisitorTransformer();
astTransformer.visit(document); 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." description = "Generate source code from an endpoints specification file."
) )
public class EndpointsCLI implements Callable<Integer> { public class EndpointsCLI implements Callable<Integer> {
public enum ParserType {
Endpoints,
States
}
@SuppressWarnings("unused") @SuppressWarnings("unused")
@Parameters(index = "0", description = "The source endpoints DSL file.") @Parameters(index = "0", description = "The source endpoints DSL file.")
private Path file; private Path file;
@SuppressWarnings("CanBeFinal") @SuppressWarnings("CanBeFinal")
@Option(names = {"-t", "--template"}, description = "The template directory. Default is ~/endpoints-templates") @Option(names = {"-t", "--template"}, defaultValue = "endpoints-template", description = "The template directory. Default is ${DEFAULT-VALUE}")
private Path templateDir = Paths.get(System.getProperty("user.dir"), "endpoints-templates"); private Path templateDir ;
@SuppressWarnings("CanBeFinal") @SuppressWarnings("CanBeFinal")
@Option(names = {"-o", "--output"}, description = "The directory to write the generated code to. Default is ~/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 = Paths.get(System.getProperty("user.dir"), "endpoints-output"); private Path outputDir ;
@SuppressWarnings("unused") @SuppressWarnings("unused")
@Option(names = {"-v", "--verbose"}, description = "Print verbose debug messages.") @Option(names = {"-v", "--verbose"}, description = "Print verbose debug messages.")
private Boolean verbose = false; 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) { public static void main(String[] args) {
int exitCode = new CommandLine(new EndpointsCLI()).execute(args); int exitCode = new CommandLine(new EndpointsCLI()).execute(args);
System.exit(exitCode); System.exit(exitCode);
@ -61,8 +68,20 @@ public class EndpointsCLI implements Callable<Integer> {
validateTemplateDirectory(); validateTemplateDirectory();
validateInputFile(); validateInputFile();
validateOutputDirectory(); validateOutputDirectory();
verbose("Parsing " + file.toAbsolutePath()); verbose("Parsing: " + file.toAbsolutePath());
DocumentNode rootNode = ParserWrapper.parse(file); 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("AST: " + rootNode);
verbose("Generating from templates in: " + templateDir.toAbsolutePath()); verbose("Generating from templates in: " + templateDir.toAbsolutePath());
Generator generator = new Generator(templateDir, rootNode, outputDir); Generator generator = new Generator(templateDir, rootNode, outputDir);

View file

@ -0,0 +1,19 @@
// 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.
package ${config.package}
object Codecs:
<#list typeDefinitions as type>
given Codec[${type.name?cap_first}] = deriveCodec
</#list>

View file

@ -0,0 +1,23 @@
// 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.
package ${config.package}
object StateAndMessageTypes:
<#list typeDefinitions?sort as type>
case class ${type.name?cap_first}(
<#list type.fields as field>
${field.name} : ${field.type},
</#list>
)
</#list>

View file

@ -0,0 +1,29 @@
```
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.
```
# The nodes
```mermaid
---
title: ${config.title}
---
stateDiagram-v2
<#list states as state>
<#list state.transitions as transition>
${state.name} --> ${transition.toState} : ${transition.message}
</#list>
</#list>
```

21
test01.states Normal file
View file

@ -0,0 +1,21 @@
/*
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.
*/
{ title: SomeNodes, package: nu.zoom.dsl.states }
start(s:S) -> middle(foo:foo): message(foo:foo),
middle -> middle(bar:bar): selfmessage(bar:bar),
middle -> end: message(bar:baz)