677 lines
15 KiB
Java
677 lines
15 KiB
Java
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/V1_1 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 "USE_CATEGORY" -> {
|
|
catBuilder.useCategory(this.unquote(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 : "");
|
|
}
|
|
}
|
|
} |