First INIT
This commit is contained in:
673
src/main/java/de/dogfire/dqfl/parser/DqflParser.java
Normal file
673
src/main/java/de/dogfire/dqfl/parser/DqflParser.java
Normal file
@@ -0,0 +1,673 @@
|
||||
package de.dogfire.dqfl.parser;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import de.dogfire.dqfl.error.DqflError;
|
||||
import de.dogfire.dqfl.error.DqflParseException;
|
||||
import de.dogfire.dqfl.model.*;
|
||||
|
||||
/**
|
||||
* Parser für DQFL V1 Dokumente.
|
||||
* Liest ein einrückungssensitives Textformat und erzeugt einen {@link DqflDocument} AST.
|
||||
*/
|
||||
public final class DqflParser
|
||||
{
|
||||
private final List<DqflError> errors = new ArrayList<>();
|
||||
|
||||
private List<ParsedLine> lines;
|
||||
private int cursor;
|
||||
private final Set<String> seenSourceAliases = new HashSet<>();
|
||||
private final Set<String> seenPoolAliases = new HashSet<>();
|
||||
|
||||
/**
|
||||
* Parst den gegebenen DQFL-Text und gibt ein DqflDocument zurück.
|
||||
*
|
||||
* @throws DqflParseException wenn fatale Parse-Fehler auftreten
|
||||
*/
|
||||
public DqflDocument parse(final String input) throws DqflParseException
|
||||
{
|
||||
this.errors.clear();
|
||||
this.cursor = 0;
|
||||
this.seenSourceAliases.clear();
|
||||
this.seenPoolAliases.clear();
|
||||
|
||||
if(input == null || input.isBlank())
|
||||
{
|
||||
throw new DqflParseException("Input is empty or null");
|
||||
}
|
||||
|
||||
this.lines = this.tokenize(input);
|
||||
|
||||
if(this.lines.isEmpty())
|
||||
{
|
||||
throw new DqflParseException("No parseable lines found");
|
||||
}
|
||||
|
||||
final DqflDocument.Builder docBuilder = DqflDocument.builder();
|
||||
boolean hasVersion = false;
|
||||
boolean hasFlow = false;
|
||||
|
||||
while(this.cursor < this.lines.size())
|
||||
{
|
||||
final ParsedLine line = this.lines.get(this.cursor);
|
||||
|
||||
if(line.indent != 0)
|
||||
{
|
||||
this.addError(line, "Unexpected indented line at root level");
|
||||
this.cursor++;
|
||||
continue;
|
||||
}
|
||||
|
||||
switch(line.keyword)
|
||||
{
|
||||
case "DQFL_VERSION" -> {
|
||||
if(hasVersion)
|
||||
{
|
||||
this.addError(line, "DQFL_VERSION must appear exactly once");
|
||||
}
|
||||
else if(line.value == null || line.value.isBlank())
|
||||
{
|
||||
this.addError(line, "DQFL_VERSION requires a value");
|
||||
}
|
||||
else
|
||||
{
|
||||
docBuilder.dqflVersion(line.value);
|
||||
hasVersion = true;
|
||||
}
|
||||
this.cursor++;
|
||||
}
|
||||
case "FLOW" -> {
|
||||
if(hasFlow)
|
||||
{
|
||||
this.addError(line, "FLOW must appear exactly once");
|
||||
}
|
||||
else if(line.value == null || line.value.isBlank())
|
||||
{
|
||||
this.addError(line, "FLOW requires a value");
|
||||
}
|
||||
else
|
||||
{
|
||||
docBuilder.flowName(this.unquote(line.value));
|
||||
hasFlow = true;
|
||||
}
|
||||
this.cursor++;
|
||||
}
|
||||
case "SCRIPT_REVISION" -> {
|
||||
docBuilder.scriptRevision(this.parseIntSafe(line, line.value));
|
||||
this.cursor++;
|
||||
}
|
||||
case "SOURCE" -> {
|
||||
final DqflSource source = this.parseSource(line);
|
||||
if(source != null)
|
||||
{
|
||||
docBuilder.addSource(source);
|
||||
}
|
||||
}
|
||||
case "POOL" -> {
|
||||
final DqflPool pool = this.parsePool(line);
|
||||
if(pool != null)
|
||||
{
|
||||
docBuilder.addPool(pool);
|
||||
}
|
||||
}
|
||||
case "ROUND" -> {
|
||||
final DqflRound round = this.parseRound(line);
|
||||
if(round != null)
|
||||
{
|
||||
docBuilder.addRound(round);
|
||||
}
|
||||
}
|
||||
default -> {
|
||||
this.addError(line, "Unknown root keyword: " + line.keyword);
|
||||
this.cursor++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(!hasVersion)
|
||||
{
|
||||
this.errors.add(DqflError.error("DQFL_VERSION is required"));
|
||||
}
|
||||
if(!hasFlow)
|
||||
{
|
||||
this.errors.add(DqflError.error("FLOW is required"));
|
||||
}
|
||||
|
||||
if(this.errors.stream().anyMatch(DqflError::isError))
|
||||
{
|
||||
throw new DqflParseException(
|
||||
"DQFL parsing failed with " + this.errors.size() + " error(s)",
|
||||
this.errors);
|
||||
}
|
||||
|
||||
return docBuilder.build();
|
||||
}
|
||||
|
||||
public List<DqflError> getWarnings()
|
||||
{
|
||||
return this.errors.stream().filter(e -> !e.isError()).toList();
|
||||
}
|
||||
|
||||
private DqflSource parseSource(final ParsedLine headerLine)
|
||||
{
|
||||
final String alias = headerLine.value;
|
||||
if(alias == null || alias.isBlank())
|
||||
{
|
||||
this.addError(headerLine, "SOURCE requires an alias");
|
||||
this.cursor++;
|
||||
return null;
|
||||
}
|
||||
if(!this.seenSourceAliases.add(alias))
|
||||
{
|
||||
this.addError(headerLine, "Duplicate SOURCE alias: " + alias);
|
||||
}
|
||||
|
||||
this.cursor++;
|
||||
|
||||
String type = null;
|
||||
String connection = null;
|
||||
|
||||
while(this.cursor < this.lines.size())
|
||||
{
|
||||
final ParsedLine child = this.lines.get(this.cursor);
|
||||
if(child.indent <= headerLine.indent)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
switch(child.keyword)
|
||||
{
|
||||
case "TYPE" -> type = child.value;
|
||||
case "CONNECTION" -> connection = child.value;
|
||||
default -> this.addError(child,
|
||||
"Unknown keyword in SOURCE block: " + child.keyword);
|
||||
}
|
||||
this.cursor++;
|
||||
}
|
||||
|
||||
if(type == null || connection == null)
|
||||
{
|
||||
this.addError(headerLine,
|
||||
"SOURCE " + alias + " requires both TYPE and CONNECTION");
|
||||
return null;
|
||||
}
|
||||
|
||||
final SourceType sourceType = SourceType.parse(type);
|
||||
if(sourceType == null)
|
||||
{
|
||||
this.addError(headerLine, "Unknown SOURCE TYPE: " + type);
|
||||
return null;
|
||||
}
|
||||
|
||||
return new DqflSource(alias, sourceType, connection);
|
||||
}
|
||||
|
||||
private DqflPool parsePool(final ParsedLine headerLine)
|
||||
{
|
||||
final String alias = headerLine.value;
|
||||
if(alias == null || alias.isBlank())
|
||||
{
|
||||
this.addError(headerLine, "POOL requires an alias");
|
||||
this.cursor++;
|
||||
return null;
|
||||
}
|
||||
if(!this.seenPoolAliases.add(alias))
|
||||
{
|
||||
this.addError(headerLine, "Duplicate POOL alias: " + alias);
|
||||
}
|
||||
|
||||
this.cursor++;
|
||||
|
||||
String source = null;
|
||||
String remoteId = null;
|
||||
String title = null;
|
||||
|
||||
while(this.cursor < this.lines.size())
|
||||
{
|
||||
final ParsedLine child = this.lines.get(this.cursor);
|
||||
if(child.indent <= headerLine.indent)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
switch(child.keyword)
|
||||
{
|
||||
case "SOURCE" -> source = child.value;
|
||||
case "REMOTE_ID" -> remoteId = this.unquote(child.value);
|
||||
case "TITLE" -> title = this.unquote(child.value);
|
||||
default -> this.addError(child,
|
||||
"Unknown keyword in POOL block: " + child.keyword);
|
||||
}
|
||||
this.cursor++;
|
||||
}
|
||||
|
||||
if(source == null || remoteId == null)
|
||||
{
|
||||
this.addError(headerLine,
|
||||
"POOL " + alias + " requires both SOURCE and REMOTE_ID");
|
||||
return null;
|
||||
}
|
||||
|
||||
return new DqflPool(alias, source, remoteId, title);
|
||||
}
|
||||
|
||||
private DqflRound parseRound(final ParsedLine headerLine)
|
||||
{
|
||||
final String alias = headerLine.value;
|
||||
if(alias == null || alias.isBlank())
|
||||
{
|
||||
this.addError(headerLine, "ROUND requires an alias");
|
||||
this.cursor++;
|
||||
return null;
|
||||
}
|
||||
|
||||
this.cursor++;
|
||||
final DqflRound.Builder roundBuilder = DqflRound.builder(alias);
|
||||
|
||||
while(this.cursor < this.lines.size())
|
||||
{
|
||||
final ParsedLine child = this.lines.get(this.cursor);
|
||||
if(child.indent <= headerLine.indent)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
switch(child.keyword)
|
||||
{
|
||||
case "TITLE" -> {
|
||||
roundBuilder.title(this.unquote(child.value));
|
||||
this.cursor++;
|
||||
}
|
||||
case "CATEGORY" -> {
|
||||
final DqflCategory category = this.parseCategory(child);
|
||||
if(category != null)
|
||||
{
|
||||
roundBuilder.addCategory(category);
|
||||
}
|
||||
}
|
||||
default -> {
|
||||
this.addError(child,
|
||||
"Unknown keyword in ROUND block: " + child.keyword);
|
||||
this.cursor++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return roundBuilder.build();
|
||||
}
|
||||
|
||||
private DqflCategory parseCategory(final ParsedLine headerLine)
|
||||
{
|
||||
final String alias = headerLine.value;
|
||||
if(alias == null || alias.isBlank())
|
||||
{
|
||||
this.addError(headerLine, "CATEGORY requires an alias");
|
||||
this.cursor++;
|
||||
return null;
|
||||
}
|
||||
|
||||
this.cursor++;
|
||||
final DqflCategory.Builder catBuilder = DqflCategory.builder(alias);
|
||||
|
||||
while(this.cursor < this.lines.size())
|
||||
{
|
||||
final ParsedLine child = this.lines.get(this.cursor);
|
||||
if(child.indent <= headerLine.indent)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
switch(child.keyword)
|
||||
{
|
||||
case "TITLE" -> {
|
||||
catBuilder.title(this.unquote(child.value));
|
||||
this.cursor++;
|
||||
}
|
||||
case "USE_POOL" -> {
|
||||
catBuilder.usePool(child.value);
|
||||
this.cursor++;
|
||||
}
|
||||
case "LAYOUT" -> {
|
||||
final LayoutType lt = LayoutType.parse(child.value);
|
||||
if(lt == null)
|
||||
{
|
||||
this.addError(child, "Unknown LAYOUT type: " + child.value);
|
||||
}
|
||||
else
|
||||
{
|
||||
catBuilder.layout(lt);
|
||||
}
|
||||
this.cursor++;
|
||||
}
|
||||
case "SIZE" -> {
|
||||
final GridSize gs = GridSize.parse(child.value);
|
||||
if(gs == null)
|
||||
{
|
||||
this.addError(child,
|
||||
"Invalid SIZE format: " + child.value + " (expected NxM)");
|
||||
}
|
||||
else
|
||||
{
|
||||
catBuilder.size(gs);
|
||||
}
|
||||
this.cursor++;
|
||||
}
|
||||
case "PHASE" -> {
|
||||
final DqflPhase phase = this.parsePhase(child);
|
||||
if(phase != null)
|
||||
{
|
||||
catBuilder.addPhase(phase);
|
||||
}
|
||||
}
|
||||
default -> {
|
||||
this.addError(child,
|
||||
"Unknown keyword in CATEGORY block: " + child.keyword);
|
||||
this.cursor++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return catBuilder.build();
|
||||
}
|
||||
|
||||
private DqflPhase parsePhase(final ParsedLine headerLine)
|
||||
{
|
||||
final PhaseType phaseType = PhaseType.parse(headerLine.value);
|
||||
if(phaseType == null)
|
||||
{
|
||||
this.addError(headerLine, "Unknown PHASE type: " + headerLine.value);
|
||||
this.cursor++;
|
||||
return null;
|
||||
}
|
||||
|
||||
this.cursor++;
|
||||
final DqflPhase.Builder phaseBuilder = DqflPhase.builder(phaseType);
|
||||
|
||||
while(this.cursor < this.lines.size())
|
||||
{
|
||||
final ParsedLine child = this.lines.get(this.cursor);
|
||||
if(child.indent <= headerLine.indent)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
switch(child.keyword)
|
||||
{
|
||||
case "QUESTION_SET" -> {
|
||||
final QuestionSetType qs = QuestionSetType.parse(child.value);
|
||||
if(qs == null)
|
||||
{
|
||||
this.addError(child, "Unknown QUESTION_SET: " + child.value);
|
||||
}
|
||||
else
|
||||
{
|
||||
phaseBuilder.questionSet(qs);
|
||||
}
|
||||
}
|
||||
case "CYCLES" -> {
|
||||
final Integer v = this.parseIntSafe(child, child.value);
|
||||
if(v != null)
|
||||
{
|
||||
phaseBuilder.cycles(v);
|
||||
}
|
||||
}
|
||||
case "TURNS_PER_CYCLE" -> {
|
||||
final Integer v = this.parseIntSafe(child, child.value);
|
||||
if(v != null)
|
||||
{
|
||||
phaseBuilder.turnsPerCycle(v);
|
||||
}
|
||||
}
|
||||
case "TURN_ORDER" -> {
|
||||
final TurnOrderType to = TurnOrderType.parse(child.value);
|
||||
if(to == null)
|
||||
{
|
||||
this.addError(child, "Unknown TURN_ORDER: " + child.value);
|
||||
}
|
||||
else
|
||||
{
|
||||
phaseBuilder.turnOrder(to);
|
||||
}
|
||||
}
|
||||
case "PICK_MODE" -> {
|
||||
final PickModeType pm = PickModeType.parse(child.value);
|
||||
if(pm == null)
|
||||
{
|
||||
this.addError(child, "Unknown PICK_MODE: " + child.value);
|
||||
}
|
||||
else
|
||||
{
|
||||
phaseBuilder.pickMode(pm);
|
||||
}
|
||||
}
|
||||
case "TEAM_MODE" -> {
|
||||
final TeamModeType tm = TeamModeType.parse(child.value);
|
||||
if(tm == null)
|
||||
{
|
||||
this.addError(child, "Unknown TEAM_MODE: " + child.value);
|
||||
}
|
||||
else
|
||||
{
|
||||
phaseBuilder.teamMode(tm);
|
||||
}
|
||||
}
|
||||
case "TEAM_ASSIGNMENT" -> {
|
||||
final TeamAssignmentType ta = TeamAssignmentType.parse(child.value);
|
||||
if(ta == null)
|
||||
{
|
||||
this.addError(child, "Unknown TEAM_ASSIGNMENT: " + child.value);
|
||||
}
|
||||
else
|
||||
{
|
||||
phaseBuilder.teamAssignment(ta);
|
||||
}
|
||||
}
|
||||
case "SCORING" -> {
|
||||
final ScoringProfile sp = ScoringProfile.parse(child.value);
|
||||
if(sp == null)
|
||||
{
|
||||
this.addError(child, "Unknown SCORING profile: " + child.value);
|
||||
}
|
||||
else
|
||||
{
|
||||
phaseBuilder.scoring(sp);
|
||||
}
|
||||
}
|
||||
default -> this.addError(child,
|
||||
"Unknown keyword in PHASE block: " + child.keyword);
|
||||
}
|
||||
this.cursor++;
|
||||
}
|
||||
|
||||
return phaseBuilder.build();
|
||||
}
|
||||
|
||||
private List<ParsedLine> tokenize(final String input)
|
||||
{
|
||||
final List<ParsedLine> result = new ArrayList<>();
|
||||
final String[] rawLines = input.split("\\r?\\n");
|
||||
|
||||
Character indentChar = null;
|
||||
int indentUnit = -1;
|
||||
int previousIndent = 0;
|
||||
|
||||
for(int i = 0; i < rawLines.length; i++)
|
||||
{
|
||||
final String raw = rawLines[i];
|
||||
final int lineNumber = i + 1;
|
||||
final String trimmed = raw.trim();
|
||||
|
||||
if(trimmed.isEmpty() || trimmed.startsWith("#"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
final int contentStart = this.findFirstNonWhitespace(raw);
|
||||
if(contentStart < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
final String indentation = raw.substring(0, contentStart);
|
||||
|
||||
if(!indentation.isEmpty())
|
||||
{
|
||||
final boolean hasSpaces = indentation.indexOf(' ') >= 0;
|
||||
final boolean hasTabs = indentation.indexOf('\t') >= 0;
|
||||
if(hasSpaces && hasTabs)
|
||||
{
|
||||
this.errors.add(DqflError.error(lineNumber,
|
||||
"Mixed tabs and spaces in indentation"));
|
||||
continue;
|
||||
}
|
||||
|
||||
final char lineIndentChar = indentation.charAt(0);
|
||||
if(indentChar == null)
|
||||
{
|
||||
indentChar = lineIndentChar;
|
||||
}
|
||||
else if(lineIndentChar != indentChar)
|
||||
{
|
||||
this.errors.add(DqflError.error(lineNumber,
|
||||
"Inconsistent indentation character; use either tabs or spaces consistently"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
int indent = 0;
|
||||
if(!indentation.isEmpty())
|
||||
{
|
||||
if(indentUnit < 0)
|
||||
{
|
||||
indentUnit = indentation.length();
|
||||
if(indentUnit <= 0)
|
||||
{
|
||||
this.errors.add(DqflError.error(lineNumber, "Invalid indentation width"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if(indentation.length() % indentUnit != 0)
|
||||
{
|
||||
this.errors.add(DqflError.error(lineNumber,
|
||||
"Indentation does not match the configured indentation width of " + indentUnit));
|
||||
continue;
|
||||
}
|
||||
|
||||
indent = indentation.length() / indentUnit;
|
||||
if(indent > previousIndent + 1)
|
||||
{
|
||||
this.errors.add(DqflError.error(lineNumber,
|
||||
"Indentation may only increase by one level at a time"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
indent = 0;
|
||||
}
|
||||
|
||||
final int spaceIdx = trimmed.indexOf(' ');
|
||||
final String keyword;
|
||||
final String value;
|
||||
|
||||
if(spaceIdx < 0)
|
||||
{
|
||||
keyword = trimmed;
|
||||
value = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
keyword = trimmed.substring(0, spaceIdx);
|
||||
value = trimmed.substring(spaceIdx + 1).trim();
|
||||
}
|
||||
|
||||
result.add(new ParsedLine(lineNumber, indent, keyword, value));
|
||||
previousIndent = indent;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private int findFirstNonWhitespace(final String raw)
|
||||
{
|
||||
for(int i = 0; i < raw.length(); i++)
|
||||
{
|
||||
final char ch = raw.charAt(i);
|
||||
if(ch != ' ' && ch != '\t')
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private String unquote(final String value)
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
String v = value.trim();
|
||||
if(v.length() >= 2 && v.startsWith("\"") && v.endsWith("\""))
|
||||
{
|
||||
v = v.substring(1, v.length() - 1);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
private Integer parseIntSafe(final ParsedLine line, final String value)
|
||||
{
|
||||
if(value == null || value.isBlank())
|
||||
{
|
||||
this.addError(line, line.keyword + " requires a numeric value");
|
||||
return null;
|
||||
}
|
||||
try
|
||||
{
|
||||
final int v = Integer.parseInt(value.trim());
|
||||
if(v <= 0)
|
||||
{
|
||||
this.addError(line, line.keyword + " must be a positive integer, got " + v);
|
||||
return null;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
catch(final NumberFormatException e)
|
||||
{
|
||||
this.addError(line,
|
||||
line.keyword + " is not a valid integer: " + value);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void addError(final ParsedLine line, final String message)
|
||||
{
|
||||
this.errors.add(DqflError.error(line.lineNumber, message));
|
||||
}
|
||||
|
||||
private static final class ParsedLine
|
||||
{
|
||||
final int lineNumber;
|
||||
final int indent;
|
||||
final String keyword;
|
||||
final String value;
|
||||
|
||||
ParsedLine(final int lineNumber, final int indent, final String keyword, final String value)
|
||||
{
|
||||
this.lineNumber = lineNumber;
|
||||
this.indent = indent;
|
||||
this.keyword = keyword;
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return "L" + this.lineNumber + " [" + this.indent + "] "
|
||||
+ this.keyword + (this.value != null ? " " + this.value : "");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user