Files
DogfireQ-DQFL/src/main/java/de/dogfire/dqfl/parser/DqflParser.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 : "");
}
}
}