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 errors = new ArrayList<>(); private List lines; private int cursor; private final Set seenSourceAliases = new HashSet<>(); private final Set 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 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 tokenize(final String input) { final List 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 : ""); } } }