commit 1b273cae14c5c7da3854690671b19783284da4c0 Author: Pat201290 Date: Fri Mar 27 13:33:21 2026 +0100 First INIT diff --git a/.classpath b/.classpath new file mode 100644 index 0000000..65622ff --- /dev/null +++ b/.classpath @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..09e3bc9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/bin/ +/target/ diff --git a/.project b/.project new file mode 100644 index 0000000..6e08cef --- /dev/null +++ b/.project @@ -0,0 +1,23 @@ + + + DQFL-Library + + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.m2e.core.maven2Builder + + + + + + org.eclipse.m2e.core.maven2Nature + org.eclipse.jdt.core.javanature + + diff --git a/.settings/org.eclipse.core.resources.prefs b/.settings/org.eclipse.core.resources.prefs new file mode 100644 index 0000000..99f26c0 --- /dev/null +++ b/.settings/org.eclipse.core.resources.prefs @@ -0,0 +1,2 @@ +eclipse.preferences.version=1 +encoding/=UTF-8 diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs new file mode 100644 index 0000000..43c8d71 --- /dev/null +++ b/.settings/org.eclipse.jdt.core.prefs @@ -0,0 +1,15 @@ +eclipse.preferences.version=1 +org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled +org.eclipse.jdt.core.compiler.codegen.targetPlatform=17 +org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve +org.eclipse.jdt.core.compiler.compliance=17 +org.eclipse.jdt.core.compiler.debug.lineNumber=generate +org.eclipse.jdt.core.compiler.debug.localVariable=generate +org.eclipse.jdt.core.compiler.debug.sourceFile=generate +org.eclipse.jdt.core.compiler.problem.assertIdentifier=error +org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=disabled +org.eclipse.jdt.core.compiler.problem.enumIdentifier=error +org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning +org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=warning +org.eclipse.jdt.core.compiler.release=enabled +org.eclipse.jdt.core.compiler.source=17 diff --git a/.settings/org.eclipse.m2e.core.prefs b/.settings/org.eclipse.m2e.core.prefs new file mode 100644 index 0000000..f897a7f --- /dev/null +++ b/.settings/org.eclipse.m2e.core.prefs @@ -0,0 +1,4 @@ +activeProfiles= +eclipse.preferences.version=1 +resolveWorkspaceProjects=true +version=1 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..888d60c --- /dev/null +++ b/pom.xml @@ -0,0 +1,54 @@ + + 4.0.0 + + de.dogfire.dqfl + dqfl-core + 1.0.0-SNAPSHOT + jar + + DQFL Core Library + Dogfire Quiz Flow Language V1 - Parser, Validator, and Model + + + UTF-8 + 17 + 17 + 17 + 17 + 5.10.2 + + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + ${maven.compiler.release} + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + diff --git a/src/main/java/de/dogfire/dqfl/Dqfl.java b/src/main/java/de/dogfire/dqfl/Dqfl.java new file mode 100644 index 0000000..5a1e84e --- /dev/null +++ b/src/main/java/de/dogfire/dqfl/Dqfl.java @@ -0,0 +1,117 @@ + +package de.dogfire.dqfl; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import de.dogfire.dqfl.error.DqflParseException; +import de.dogfire.dqfl.error.DqflValidationResult; +import de.dogfire.dqfl.model.DqflDocument; +import de.dogfire.dqfl.parser.DqflParser; +import de.dogfire.dqfl.validator.DqflValidator; + +/** + * Zentrale Fassade der DQFL V1 Library. + * Bietet bequeme Methoden zum Laden, Parsen und Validieren von DQFL-Dokumenten. + * + *
+ * // Beispiel: Datei laden, parsen und validieren
+ * DqflDocument doc = Dqfl.parseFile(path);
+ * DqflValidationResult result = Dqfl.validate(doc);
+ * if (!result.isValid()) { ... }
+ *
+ * // Beispiel: String direkt parsen
+ * DqflDocument doc = Dqfl.parse(dqflString);
+ * 
+ */ +public final class Dqfl +{ + private Dqfl() + { + } + + /** + * Parst einen DQFL-String und gibt das Dokumentmodell zurück. + * + * @throws DqflParseException bei Syntaxfehlern + */ + public static DqflDocument parse(final String input) throws DqflParseException + { + return new DqflParser().parse(input); + } + + /** + * Liest und parst eine .dqfl-Datei. + * + * @throws DqflParseException bei Syntaxfehlern + * @throws IOException bei Dateizugriffsfehlern + */ + public static DqflDocument parseFile(final Path path) throws DqflParseException, IOException + { + final String content = Files.readString(path, StandardCharsets.UTF_8); + return Dqfl.parse(content); + } + + /** + * Liest und parst aus einem InputStream (z.B. Classpath-Resource). + * + * @throws DqflParseException bei Syntaxfehlern + * @throws IOException bei Lesefehlern + */ + public static DqflDocument parseStream(final InputStream stream) throws DqflParseException, IOException + { + final String content = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + return Dqfl.parse(content); + } + + /** + * Validiert ein geparstes DqflDocument gegen alle V1-Fachregeln. + */ + public static DqflValidationResult validate(final DqflDocument document) + { + return new DqflValidator().validate(document); + } + + /** + * Parst und validiert in einem Schritt. + * Gibt das Dokument nur zurück wenn die Validierung keine Fehler enthält. + * + * @throws DqflParseException bei Syntaxfehlern + * @throws DqflValidationException wenn die Validierung fehlschlägt + */ + public static DqflDocument parseAndValidate(final String input) + throws DqflParseException, DqflValidationException + { + final DqflDocument doc = Dqfl.parse(input); + final DqflValidationResult result = Dqfl.validate(doc); + + if(!result.isValid()) + { + throw new DqflValidationException(result); + } + + return doc; + } + + /** + * Exception die geworfen wird wenn ein Dokument die Validierung nicht besteht. + */ + public static class DqflValidationException extends Exception + { + private final DqflValidationResult result; + + public DqflValidationException(final DqflValidationResult result) + { + super("DQFL validation failed: " + result.getErrorsOnly().size() + " error(s)"); + this.result = result; + } + + public DqflValidationResult getResult() + { + return this.result; + } + } +} diff --git a/src/main/java/de/dogfire/dqfl/error/DqflError.java b/src/main/java/de/dogfire/dqfl/error/DqflError.java new file mode 100644 index 0000000..4ca87d1 --- /dev/null +++ b/src/main/java/de/dogfire/dqfl/error/DqflError.java @@ -0,0 +1,79 @@ + +package de.dogfire.dqfl.error; + +import java.util.Objects; + +/** + * Einzelner Fehler aus Parsing oder Validierung. + */ +public final class DqflError +{ + public enum Severity + { + ERROR, + WARNING + } + + private final Severity severity; + private final int line; + private final String message; + + public DqflError(final Severity severity, final int line, final String message) + { + this.severity = Objects.requireNonNull(severity); + this.line = line; + this.message = Objects.requireNonNull(message); + } + + public static DqflError error(final int line, final String message) + { + return new DqflError(Severity.ERROR, line, message); + } + + public static DqflError warning(final int line, final String message) + { + return new DqflError(Severity.WARNING, line, message); + } + + /** Fehler ohne Zeilenbezug (z.B. Validierung auf Dokumentebene). */ + public static DqflError error(final String message) + { + return new DqflError(Severity.ERROR, -1, message); + } + + public static DqflError warning(final String message) + { + return new DqflError(Severity.WARNING, -1, message); + } + + public Severity getSeverity() + { + return this.severity; + } + + public int getLine() + { + return this.line; + } + + public String getMessage() + { + return this.message; + } + + public boolean isError() + { + return this.severity == Severity.ERROR; + } + + @Override + public String toString() + { + final String prefix = this.severity == Severity.ERROR ? "ERROR" : "WARN"; + if(this.line > 0) + { + return "[" + prefix + " line " + this.line + "] " + this.message; + } + return "[" + prefix + "] " + this.message; + } +} diff --git a/src/main/java/de/dogfire/dqfl/error/DqflParseException.java b/src/main/java/de/dogfire/dqfl/error/DqflParseException.java new file mode 100644 index 0000000..bcd1b8b --- /dev/null +++ b/src/main/java/de/dogfire/dqfl/error/DqflParseException.java @@ -0,0 +1,42 @@ + +package de.dogfire.dqfl.error; + +import java.util.Collections; +import java.util.List; + +/** + * Exception die geworfen wird wenn das Parsen eines DQFL-Dokuments fehlschlägt. + * Enthält alle gesammelten Fehler. + */ +public class DqflParseException extends Exception +{ + private final List errors; + + public DqflParseException(final String message, final List errors) + { + super(message); + this.errors = Collections.unmodifiableList(errors); + } + + public DqflParseException(final String message) + { + super(message); + this.errors = List.of(); + } + + public List getErrors() + { + return this.errors; + } + + @Override + public String toString() + { + final StringBuilder sb = new StringBuilder(this.getMessage()); + for(final DqflError error : this.errors) + { + sb.append("\n ").append(error); + } + return sb.toString(); + } +} diff --git a/src/main/java/de/dogfire/dqfl/error/DqflValidationResult.java b/src/main/java/de/dogfire/dqfl/error/DqflValidationResult.java new file mode 100644 index 0000000..f702ed6 --- /dev/null +++ b/src/main/java/de/dogfire/dqfl/error/DqflValidationResult.java @@ -0,0 +1,60 @@ + +package de.dogfire.dqfl.error; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Ergebnis einer DQFL-Validierung. + */ +public final class DqflValidationResult +{ + private final List errors; + + public DqflValidationResult(final List errors) + { + this.errors = Collections.unmodifiableList(new ArrayList<>(errors)); + } + + public boolean isValid() + { + return this.errors.stream().noneMatch(DqflError::isError); + } + + public boolean hasWarnings() + { + return this.errors.stream().anyMatch(e -> !e.isError()); + } + + public List getErrors() + { + return this.errors; + } + + public List getErrorsOnly() + { + return this.errors.stream().filter(DqflError::isError).toList(); + } + + public List getWarningsOnly() + { + return this.errors.stream().filter(e -> !e.isError()).toList(); + } + + @Override + public String toString() + { + if(this.isValid() && !this.hasWarnings()) + { + return "Validation OK"; + } + final StringBuilder sb = new StringBuilder("Validation "); + sb.append(this.isValid() ? "OK (with warnings)" : "FAILED"); + for(final DqflError error : this.errors) + { + sb.append("\n ").append(error); + } + return sb.toString(); + } +} diff --git a/src/main/java/de/dogfire/dqfl/model/DqflCategory.java b/src/main/java/de/dogfire/dqfl/model/DqflCategory.java new file mode 100644 index 0000000..e77cdf0 --- /dev/null +++ b/src/main/java/de/dogfire/dqfl/model/DqflCategory.java @@ -0,0 +1,130 @@ + +package de.dogfire.dqfl.model; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Repräsentiert einen CATEGORY-Block innerhalb einer ROUND. + */ +public final class DqflCategory +{ + private final String alias; + private final String title; + private final String usePool; + private final LayoutType layout; + private final GridSize size; + private final List phases; + + private DqflCategory(final Builder builder) + { + this.alias = Objects.requireNonNull(builder.alias, "alias must not be null"); + this.title = builder.title; + this.usePool = builder.usePool; + this.layout = builder.layout; + this.size = builder.size; + this.phases = Collections.unmodifiableList(new ArrayList<>(builder.phases)); + } + + public String getAlias() + { + return this.alias; + } + + /** Kann null sein — TITLE ist optional. */ + public String getTitle() + { + return this.title; + } + + public String getUsePool() + { + return this.usePool; + } + + public LayoutType getLayout() + { + return this.layout; + } + + /** Kann null sein — nur gültig zusammen mit LAYOUT. */ + public GridSize getSize() + { + return this.size; + } + + public List getPhases() + { + return this.phases; + } + + @Override + public String toString() + { + return "CATEGORY " + this.alias + + " [title=" + this.title + + ", pool=" + this.usePool + + ", phases=" + this.phases.size() + "]"; + } + + // ================================================================ + // Builder + // ================================================================ + + public static Builder builder(final String alias) + { + return new Builder(alias); + } + + public static final class Builder + { + private final String alias; + private String title; + private String usePool; + private LayoutType layout; + private GridSize size; + private final List phases = new ArrayList<>(); + + private Builder(final String alias) + { + this.alias = alias; + } + + public Builder title(final String title) + { + this.title = title; + return this; + } + + public Builder usePool(final String usePool) + { + this.usePool = usePool; + return this; + } + + public Builder layout(final LayoutType layout) + { + this.layout = layout; + return this; + } + + public Builder size(final GridSize size) + { + this.size = size; + return this; + } + + public Builder addPhase(final DqflPhase phase) + { + this.phases.add(Objects.requireNonNull(phase)); + return this; + } + + public DqflCategory build() + { + return new DqflCategory(this); + } + } +} diff --git a/src/main/java/de/dogfire/dqfl/model/DqflDocument.java b/src/main/java/de/dogfire/dqfl/model/DqflDocument.java new file mode 100644 index 0000000..dea6cdb --- /dev/null +++ b/src/main/java/de/dogfire/dqfl/model/DqflDocument.java @@ -0,0 +1,162 @@ + +package de.dogfire.dqfl.model; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Repräsentiert ein vollständig geparstes DQFL-Dokument. + * Dies ist der Root-Knoten des AST. + */ +public final class DqflDocument +{ + private final String dqflVersion; + private final String flowName; + private final Integer scriptRevision; + private final Map sources; + private final Map pools; + private final List rounds; + + private DqflDocument(final Builder builder) + { + this.dqflVersion = Objects.requireNonNull(builder.dqflVersion, "dqflVersion must not be null"); + this.flowName = Objects.requireNonNull(builder.flowName, "flowName must not be null"); + this.scriptRevision = builder.scriptRevision; + this.sources = Collections.unmodifiableMap(new LinkedHashMap<>(builder.sources)); + this.pools = Collections.unmodifiableMap(new LinkedHashMap<>(builder.pools)); + this.rounds = Collections.unmodifiableList(new ArrayList<>(builder.rounds)); + } + + public String getDqflVersion() + { + return this.dqflVersion; + } + + public String getFlowName() + { + return this.flowName; + } + + /** Kann null sein — SCRIPT_REVISION ist optional. */ + public Integer getScriptRevision() + { + return this.scriptRevision; + } + + public Map getSources() + { + return this.sources; + } + + public DqflSource getSource(final String alias) + { + return this.sources.get(alias); + } + + public Map getPools() + { + return this.pools; + } + + public DqflPool getPool(final String alias) + { + return this.pools.get(alias); + } + + public List getRounds() + { + return this.rounds; + } + + /** + * Gibt eine flache Liste aller Kategorien über alle Runden zurück, + * in Dokumentreihenfolge. + */ + public List getAllCategoriesFlat() + { + final List all = new ArrayList<>(); + for(final DqflRound round : this.rounds) + { + all.addAll(round.getCategories()); + } + return Collections.unmodifiableList(all); + } + + @Override + public String toString() + { + return "DqflDocument [version=" + this.dqflVersion + + ", flow=" + this.flowName + + ", sources=" + this.sources.size() + + ", pools=" + this.pools.size() + + ", rounds=" + this.rounds.size() + "]"; + } + + // ================================================================ + // Builder + // ================================================================ + + public static Builder builder() + { + return new Builder(); + } + + public static final class Builder + { + private String dqflVersion; + private String flowName; + private Integer scriptRevision; + private final Map sources = new LinkedHashMap<>(); + private final Map pools = new LinkedHashMap<>(); + private final List rounds = new ArrayList<>(); + + private Builder() + { + } + + public Builder dqflVersion(final String dqflVersion) + { + this.dqflVersion = dqflVersion; + return this; + } + + public Builder flowName(final String flowName) + { + this.flowName = flowName; + return this; + } + + public Builder scriptRevision(final Integer scriptRevision) + { + this.scriptRevision = scriptRevision; + return this; + } + + public Builder addSource(final DqflSource source) + { + this.sources.put(source.getAlias(), source); + return this; + } + + public Builder addPool(final DqflPool pool) + { + this.pools.put(pool.getAlias(), pool); + return this; + } + + public Builder addRound(final DqflRound round) + { + this.rounds.add(Objects.requireNonNull(round)); + return this; + } + + public DqflDocument build() + { + return new DqflDocument(this); + } + } +} diff --git a/src/main/java/de/dogfire/dqfl/model/DqflPhase.java b/src/main/java/de/dogfire/dqfl/model/DqflPhase.java new file mode 100644 index 0000000..e1c81cc --- /dev/null +++ b/src/main/java/de/dogfire/dqfl/model/DqflPhase.java @@ -0,0 +1,167 @@ + +package de.dogfire.dqfl.model; + +import java.util.Objects; + +/** + * Repräsentiert einen PHASE-Block innerhalb einer CATEGORY. + */ +public final class DqflPhase +{ + private final PhaseType phaseType; + private final QuestionSetType questionSet; + private final Integer cycles; + private final Integer turnsPerCycle; + private final TurnOrderType turnOrder; + private final PickModeType pickMode; + private final TeamModeType teamMode; + private final TeamAssignmentType teamAssignment; + private final ScoringProfile scoring; + + private DqflPhase(final Builder builder) + { + this.phaseType = Objects.requireNonNull(builder.phaseType, "phaseType must not be null"); + this.questionSet = builder.questionSet; + this.cycles = builder.cycles; + this.turnsPerCycle = builder.turnsPerCycle; + this.turnOrder = builder.turnOrder; + this.pickMode = builder.pickMode; + this.teamMode = builder.teamMode; + this.teamAssignment = builder.teamAssignment; + this.scoring = builder.scoring; + } + + public PhaseType getPhaseType() + { + return this.phaseType; + } + + public QuestionSetType getQuestionSet() + { + return this.questionSet; + } + + public Integer getCycles() + { + return this.cycles; + } + + public Integer getTurnsPerCycle() + { + return this.turnsPerCycle; + } + + public TurnOrderType getTurnOrder() + { + return this.turnOrder; + } + + public PickModeType getPickMode() + { + return this.pickMode; + } + + public TeamModeType getTeamMode() + { + return this.teamMode; + } + + public TeamAssignmentType getTeamAssignment() + { + return this.teamAssignment; + } + + public ScoringProfile getScoring() + { + return this.scoring; + } + + @Override + public String toString() + { + return "PHASE " + this.phaseType + + " [questionSet=" + this.questionSet + + ", teamMode=" + this.teamMode + + ", scoring=" + this.scoring + "]"; + } + + // ================================================================ + // Builder + // ================================================================ + + public static Builder builder(final PhaseType phaseType) + { + return new Builder(phaseType); + } + + public static final class Builder + { + private final PhaseType phaseType; + private QuestionSetType questionSet; + private Integer cycles; + private Integer turnsPerCycle; + private TurnOrderType turnOrder; + private PickModeType pickMode; + private TeamModeType teamMode; + private TeamAssignmentType teamAssignment; + private ScoringProfile scoring; + + private Builder(final PhaseType phaseType) + { + this.phaseType = phaseType; + } + + public Builder questionSet(final QuestionSetType questionSet) + { + this.questionSet = questionSet; + return this; + } + + public Builder cycles(final int cycles) + { + this.cycles = cycles; + return this; + } + + public Builder turnsPerCycle(final int turnsPerCycle) + { + this.turnsPerCycle = turnsPerCycle; + return this; + } + + public Builder turnOrder(final TurnOrderType turnOrder) + { + this.turnOrder = turnOrder; + return this; + } + + public Builder pickMode(final PickModeType pickMode) + { + this.pickMode = pickMode; + return this; + } + + public Builder teamMode(final TeamModeType teamMode) + { + this.teamMode = teamMode; + return this; + } + + public Builder teamAssignment(final TeamAssignmentType teamAssignment) + { + this.teamAssignment = teamAssignment; + return this; + } + + public Builder scoring(final ScoringProfile scoring) + { + this.scoring = scoring; + return this; + } + + public DqflPhase build() + { + return new DqflPhase(this); + } + } +} diff --git a/src/main/java/de/dogfire/dqfl/model/DqflPool.java b/src/main/java/de/dogfire/dqfl/model/DqflPool.java new file mode 100644 index 0000000..f0653e6 --- /dev/null +++ b/src/main/java/de/dogfire/dqfl/model/DqflPool.java @@ -0,0 +1,55 @@ + +package de.dogfire.dqfl.model; + +import java.util.Objects; + +/** + * Repräsentiert einen POOL-Block im DQFL-Dokument. + */ +public final class DqflPool +{ + private final String alias; + private final String sourceAlias; + private final String remoteId; + private final String title; + + public DqflPool( + final String alias, + final String sourceAlias, + final String remoteId, + final String title) + { + this.alias = Objects.requireNonNull(alias, "alias must not be null"); + this.sourceAlias = Objects.requireNonNull(sourceAlias, "sourceAlias must not be null"); + this.remoteId = Objects.requireNonNull(remoteId, "remoteId must not be null"); + this.title = title; // optional gemäß Spec + } + + public String getAlias() + { + return this.alias; + } + + public String getSourceAlias() + { + return this.sourceAlias; + } + + public String getRemoteId() + { + return this.remoteId; + } + + /** Kann null sein — Titel ist optional. */ + public String getTitle() + { + return this.title; + } + + @Override + public String toString() + { + return "POOL " + this.alias + " [source=" + this.sourceAlias + + ", remoteId=" + this.remoteId + "]"; + } +} diff --git a/src/main/java/de/dogfire/dqfl/model/DqflRound.java b/src/main/java/de/dogfire/dqfl/model/DqflRound.java new file mode 100644 index 0000000..b333bb5 --- /dev/null +++ b/src/main/java/de/dogfire/dqfl/model/DqflRound.java @@ -0,0 +1,86 @@ + +package de.dogfire.dqfl.model; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Repräsentiert einen ROUND-Block auf Root-Ebene. + */ +public final class DqflRound +{ + private final String alias; + private final String title; + private final List categories; + + private DqflRound(final Builder builder) + { + this.alias = Objects.requireNonNull(builder.alias, "alias must not be null"); + this.title = builder.title; + this.categories = Collections.unmodifiableList(new ArrayList<>(builder.categories)); + } + + public String getAlias() + { + return this.alias; + } + + /** Kann null sein — TITLE ist optional. */ + public String getTitle() + { + return this.title; + } + + public List getCategories() + { + return this.categories; + } + + @Override + public String toString() + { + return "ROUND " + this.alias + + " [title=" + this.title + + ", categories=" + this.categories.size() + "]"; + } + + // ================================================================ + // Builder + // ================================================================ + + public static Builder builder(final String alias) + { + return new Builder(alias); + } + + public static final class Builder + { + private final String alias; + private String title; + private final List categories = new ArrayList<>(); + + private Builder(final String alias) + { + this.alias = alias; + } + + public Builder title(final String title) + { + this.title = title; + return this; + } + + public Builder addCategory(final DqflCategory category) + { + this.categories.add(Objects.requireNonNull(category)); + return this; + } + + public DqflRound build() + { + return new DqflRound(this); + } + } +} diff --git a/src/main/java/de/dogfire/dqfl/model/DqflSource.java b/src/main/java/de/dogfire/dqfl/model/DqflSource.java new file mode 100644 index 0000000..9a1f947 --- /dev/null +++ b/src/main/java/de/dogfire/dqfl/model/DqflSource.java @@ -0,0 +1,42 @@ + +package de.dogfire.dqfl.model; + +import java.util.Objects; + +/** + * Repräsentiert einen SOURCE-Block im DQFL-Dokument. + */ +public final class DqflSource +{ + private final String alias; + private final SourceType type; + private final String connection; + + public DqflSource(final String alias, final SourceType type, final String connection) + { + this.alias = Objects.requireNonNull(alias, "alias must not be null"); + this.type = Objects.requireNonNull(type, "type must not be null"); + this.connection = Objects.requireNonNull(connection, "connection must not be null"); + } + + public String getAlias() + { + return this.alias; + } + + public SourceType getType() + { + return this.type; + } + + public String getConnection() + { + return this.connection; + } + + @Override + public String toString() + { + return "SOURCE " + this.alias + " [" + this.type + " -> " + this.connection + "]"; + } +} diff --git a/src/main/java/de/dogfire/dqfl/model/GridSize.java b/src/main/java/de/dogfire/dqfl/model/GridSize.java new file mode 100644 index 0000000..02a49f2 --- /dev/null +++ b/src/main/java/de/dogfire/dqfl/model/GridSize.java @@ -0,0 +1,96 @@ + +package de.dogfire.dqfl.model; + +import java.util.Objects; + +/** + * Repräsentiert eine SIZE-Angabe bei GRID_2D Layout. + * Format: rows x cols (z.B. "6x6"). + */ +public final class GridSize +{ + private final int rows; + private final int cols; + + public GridSize(final int rows, final int cols) + { + if(rows <= 0 || cols <= 0) + { + throw new IllegalArgumentException( + "rows and cols must be positive integers, got " + rows + "x" + cols); + } + this.rows = rows; + this.cols = cols; + } + + public int getRows() + { + return this.rows; + } + + public int getCols() + { + return this.cols; + } + + public int getTotalCells() + { + return this.rows * this.cols; + } + + /** + * Parst eine SIZE-Angabe im Format "NxM". + * + * @return GridSize oder null bei ungültigem Format + */ + public static GridSize parse(final String value) + { + if(value == null || value.isBlank()) + { + return null; + } + + final String[] parts = value.split("x", 2); + if(parts.length != 2) + { + return null; + } + + try + { + final int rows = Integer.parseInt(parts[0].trim()); + final int cols = Integer.parseInt(parts[1].trim()); + return new GridSize(rows, cols); + } + catch(final NumberFormatException e) + { + return null; + } + } + + @Override + public String toString() + { + return this.rows + "x" + this.cols; + } + + @Override + public boolean equals(final Object o) + { + if(this == o) + { + return true; + } + if(!(o instanceof final GridSize that)) + { + return false; + } + return this.rows == that.rows && this.cols == that.cols; + } + + @Override + public int hashCode() + { + return Objects.hash(this.rows, this.cols); + } +} diff --git a/src/main/java/de/dogfire/dqfl/model/LayoutType.java b/src/main/java/de/dogfire/dqfl/model/LayoutType.java new file mode 100644 index 0000000..b3354c1 --- /dev/null +++ b/src/main/java/de/dogfire/dqfl/model/LayoutType.java @@ -0,0 +1,23 @@ + +package de.dogfire.dqfl.model; + +/** + * Layout-Typen gemäß DQFL V1. + * V1 unterstützt ausschließlich GRID_2D. + */ +public enum LayoutType +{ + GRID_2D; + + public static LayoutType parse(final String value) + { + try + { + return LayoutType.valueOf(value); + } + catch(final IllegalArgumentException e) + { + return null; + } + } +} diff --git a/src/main/java/de/dogfire/dqfl/model/PhaseType.java b/src/main/java/de/dogfire/dqfl/model/PhaseType.java new file mode 100644 index 0000000..9da5a02 --- /dev/null +++ b/src/main/java/de/dogfire/dqfl/model/PhaseType.java @@ -0,0 +1,23 @@ + +package de.dogfire.dqfl.model; + +/** + * Phase-Typen gemäß DQFL V1: NORMAL und BOSS. + */ +public enum PhaseType +{ + NORMAL, + BOSS; + + public static PhaseType parse(final String value) + { + try + { + return PhaseType.valueOf(value); + } + catch(final IllegalArgumentException e) + { + return null; + } + } +} diff --git a/src/main/java/de/dogfire/dqfl/model/PickModeType.java b/src/main/java/de/dogfire/dqfl/model/PickModeType.java new file mode 100644 index 0000000..b9ab8e7 --- /dev/null +++ b/src/main/java/de/dogfire/dqfl/model/PickModeType.java @@ -0,0 +1,23 @@ + +package de.dogfire.dqfl.model; + +/** + * PICK_MODE Werte gemäß DQFL V1. + * V1 unterstützt ausschließlich FREE_UNANSWERED. + */ +public enum PickModeType +{ + FREE_UNANSWERED; + + public static PickModeType parse(final String value) + { + try + { + return PickModeType.valueOf(value); + } + catch(final IllegalArgumentException e) + { + return null; + } + } +} diff --git a/src/main/java/de/dogfire/dqfl/model/QuestionSetType.java b/src/main/java/de/dogfire/dqfl/model/QuestionSetType.java new file mode 100644 index 0000000..d8c5940 --- /dev/null +++ b/src/main/java/de/dogfire/dqfl/model/QuestionSetType.java @@ -0,0 +1,23 @@ + +package de.dogfire.dqfl.model; + +/** + * QUESTION_SET Werte gemäß DQFL V1. + */ +public enum QuestionSetType +{ + NORMAL, + BOSS; + + public static QuestionSetType parse(final String value) + { + try + { + return QuestionSetType.valueOf(value); + } + catch(final IllegalArgumentException e) + { + return null; + } + } +} diff --git a/src/main/java/de/dogfire/dqfl/model/ScoringProfile.java b/src/main/java/de/dogfire/dqfl/model/ScoringProfile.java new file mode 100644 index 0000000..7601233 --- /dev/null +++ b/src/main/java/de/dogfire/dqfl/model/ScoringProfile.java @@ -0,0 +1,45 @@ + +package de.dogfire.dqfl.model; + +/** + * Vordefinierte Scoring-Profile gemäß DQFL V1. + */ +public enum ScoringProfile +{ + /** Richtige Antwort +1, falsche Antwort 0. */ + SOLO_PLUS1_NO_PENALTY(1, 0), + + /** Jeder Spieler eines richtigen Teams erhält +2; Teams werden unabhängig ausgewertet. */ + BOSS_2V2_PLUS2_PER_CORRECT_PLAYER(2, 0); + + private final int pointsCorrect; + private final int pointsWrong; + + ScoringProfile(final int pointsCorrect, final int pointsWrong) + { + this.pointsCorrect = pointsCorrect; + this.pointsWrong = pointsWrong; + } + + public int getPointsCorrect() + { + return this.pointsCorrect; + } + + public int getPointsWrong() + { + return this.pointsWrong; + } + + public static ScoringProfile parse(final String value) + { + try + { + return ScoringProfile.valueOf(value); + } + catch(final IllegalArgumentException e) + { + return null; + } + } +} diff --git a/src/main/java/de/dogfire/dqfl/model/SourceType.java b/src/main/java/de/dogfire/dqfl/model/SourceType.java new file mode 100644 index 0000000..5dd8f16 --- /dev/null +++ b/src/main/java/de/dogfire/dqfl/model/SourceType.java @@ -0,0 +1,22 @@ + +package de.dogfire.dqfl.model; + +/** + * SOURCE TYPE Werte gemäß DQFL V1. + */ +public enum SourceType +{ + REST; + + public static SourceType parse(final String value) + { + try + { + return SourceType.valueOf(value); + } + catch(final IllegalArgumentException e) + { + return null; + } + } +} diff --git a/src/main/java/de/dogfire/dqfl/model/TeamAssignmentType.java b/src/main/java/de/dogfire/dqfl/model/TeamAssignmentType.java new file mode 100644 index 0000000..8403205 --- /dev/null +++ b/src/main/java/de/dogfire/dqfl/model/TeamAssignmentType.java @@ -0,0 +1,23 @@ + +package de.dogfire.dqfl.model; + +/** + * TEAM_ASSIGNMENT Werte gemäß DQFL V1. + * V1 unterstützt ausschließlich RANDOM. + */ +public enum TeamAssignmentType +{ + RANDOM; + + public static TeamAssignmentType parse(final String value) + { + try + { + return TeamAssignmentType.valueOf(value); + } + catch(final IllegalArgumentException e) + { + return null; + } + } +} diff --git a/src/main/java/de/dogfire/dqfl/model/TeamModeType.java b/src/main/java/de/dogfire/dqfl/model/TeamModeType.java new file mode 100644 index 0000000..f65d894 --- /dev/null +++ b/src/main/java/de/dogfire/dqfl/model/TeamModeType.java @@ -0,0 +1,23 @@ + +package de.dogfire.dqfl.model; + +/** + * TEAM_MODE Werte gemäß DQFL V1. + */ +public enum TeamModeType +{ + SOLO, + TWO_VS_TWO; + + public static TeamModeType parse(final String value) + { + try + { + return TeamModeType.valueOf(value); + } + catch(final IllegalArgumentException e) + { + return null; + } + } +} diff --git a/src/main/java/de/dogfire/dqfl/model/TurnOrderType.java b/src/main/java/de/dogfire/dqfl/model/TurnOrderType.java new file mode 100644 index 0000000..248a6b5 --- /dev/null +++ b/src/main/java/de/dogfire/dqfl/model/TurnOrderType.java @@ -0,0 +1,23 @@ + +package de.dogfire.dqfl.model; + +/** + * TURN_ORDER Werte gemäß DQFL V1. + * V1 unterstützt ausschließlich ROTATING_START. + */ +public enum TurnOrderType +{ + ROTATING_START; + + public static TurnOrderType parse(final String value) + { + try + { + return TurnOrderType.valueOf(value); + } + catch(final IllegalArgumentException e) + { + return null; + } + } +} diff --git a/src/main/java/de/dogfire/dqfl/parser/DqflParser.java b/src/main/java/de/dogfire/dqfl/parser/DqflParser.java new file mode 100644 index 0000000..5a147a3 --- /dev/null +++ b/src/main/java/de/dogfire/dqfl/parser/DqflParser.java @@ -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 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 "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 : ""); + } + } +} diff --git a/src/main/java/de/dogfire/dqfl/validator/DqflValidator.java b/src/main/java/de/dogfire/dqfl/validator/DqflValidator.java new file mode 100644 index 0000000..63e283f --- /dev/null +++ b/src/main/java/de/dogfire/dqfl/validator/DqflValidator.java @@ -0,0 +1,265 @@ + +package de.dogfire.dqfl.validator; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +import de.dogfire.dqfl.error.DqflError; +import de.dogfire.dqfl.error.DqflValidationResult; +import de.dogfire.dqfl.model.*; + +/** + * Validator für DQFL V1 Dokumente. + * Prüft alle Fachregeln aus Kapitel 10 der Spezifikation. + */ +public final class DqflValidator +{ + /** Empfohlenes Validierungsmuster für REMOTE_ID: ^[A-Z0-9]{8}[0-9]{10,}$ */ + private static final Pattern REMOTE_ID_PATTERN = + Pattern.compile("^[A-Z0-9]{8}[0-9]{10,}$"); + + /** + * Validiert ein geparstes DqflDocument und gibt das Ergebnis zurück. + */ + public DqflValidationResult validate(final DqflDocument document) + { + final List errors = new ArrayList<>(); + + // 1. DQFL_VERSION muss vorhanden sein + if(document.getDqflVersion() == null || document.getDqflVersion().isBlank()) + { + errors.add(DqflError.error("DQFL_VERSION is required")); + } + + // 2. FLOW muss vorhanden sein + if(document.getFlowName() == null || document.getFlowName().isBlank()) + { + errors.add(DqflError.error("FLOW is required")); + } + + // 3. SOURCE-Aliase müssen eindeutig sein (bereits durch Map im Parser sichergestellt, + // aber wir prüfen trotzdem ob Sources vorhanden sind wenn Pools sie referenzieren) + + // 4. POOL-Validierung + this.validatePools(document, errors); + + // 5. ROUND-Validierung + this.validateRounds(document, errors); + + // 6. Alias-Eindeutigkeit über Objektarten + this.validateAliasUniqueness(document, errors); + + return new DqflValidationResult(errors); + } + + // ================================================================ + // Pool-Validierung + // ================================================================ + + private void validatePools(final DqflDocument document, final List errors) + { + for(final DqflPool pool : document.getPools().values()) + { + // SOURCE-Referenz muss existieren + if(document.getSource(pool.getSourceAlias()) == null) + { + errors.add(DqflError.error( + "POOL '" + pool.getAlias() + + "' references unknown SOURCE '" + pool.getSourceAlias() + "'")); + } + + // REMOTE_ID Format prüfen (als Warnung, nicht als Fehler) + if(!REMOTE_ID_PATTERN.matcher(pool.getRemoteId()).matches()) + { + errors.add(DqflError.warning( + "POOL '" + pool.getAlias() + + "' REMOTE_ID '" + pool.getRemoteId() + + "' does not match recommended pattern ^[A-Z0-9]{8}[0-9]{10,}$")); + } + } + } + + // ================================================================ + // Round-Validierung + // ================================================================ + + private void validateRounds(final DqflDocument document, final List errors) + { + if(document.getRounds().isEmpty()) + { + errors.add(DqflError.error("Document must contain at least one ROUND")); + return; + } + + final Set roundAliases = new HashSet<>(); + final Set categoryAliases = new HashSet<>(); + + for(final DqflRound round : document.getRounds()) + { + // ROUND-Alias Eindeutigkeit + if(!roundAliases.add(round.getAlias())) + { + errors.add(DqflError.error( + "Duplicate ROUND alias: " + round.getAlias())); + } + + if(round.getCategories().isEmpty()) + { + errors.add(DqflError.error( + "ROUND '" + round.getAlias() + "' must contain at least one CATEGORY")); + } + + for(final DqflCategory category : round.getCategories()) + { + this.validateCategory(category, document, categoryAliases, errors); + } + } + } + + // ================================================================ + // Category-Validierung + // ================================================================ + + private void validateCategory( + final DqflCategory category, + final DqflDocument document, + final Set categoryAliases, + final List errors) + { + // CATEGORY-Alias Eindeutigkeit + if(!categoryAliases.add(category.getAlias())) + { + errors.add(DqflError.error( + "Duplicate CATEGORY alias: " + category.getAlias())); + } + + // Jede CATEGORY muss genau einen USE_POOL enthalten + if(category.getUsePool() == null || category.getUsePool().isBlank()) + { + errors.add(DqflError.error( + "CATEGORY '" + category.getAlias() + "' must have exactly one USE_POOL")); + } + else + { + // USE_POOL muss auf existierenden POOL verweisen + if(document.getPool(category.getUsePool()) == null) + { + errors.add(DqflError.error( + "CATEGORY '" + category.getAlias() + + "' references unknown POOL '" + category.getUsePool() + "'")); + } + } + + // SIZE nur gültig wenn LAYOUT vorhanden + if(category.getSize() != null && category.getLayout() == null) + { + errors.add(DqflError.error( + "CATEGORY '" + category.getAlias() + + "' has SIZE without LAYOUT")); + } + + // Bei GRID_2D muss SIZE vorhanden sein + if(category.getLayout() == LayoutType.GRID_2D && category.getSize() == null) + { + errors.add(DqflError.error( + "CATEGORY '" + category.getAlias() + + "' has LAYOUT GRID_2D but no SIZE specified")); + } + + // Jede CATEGORY muss mindestens eine PHASE enthalten + if(category.getPhases().isEmpty()) + { + errors.add(DqflError.error( + "CATEGORY '" + category.getAlias() + "' must contain at least one PHASE")); + } + + for(final DqflPhase phase : category.getPhases()) + { + this.validatePhase(phase, category.getAlias(), errors); + } + } + + // ================================================================ + // Phase-Validierung + // ================================================================ + + private void validatePhase( + final DqflPhase phase, + final String categoryAlias, + final List errors) + { + final String ctx = "CATEGORY '" + categoryAlias + "' PHASE " + phase.getPhaseType(); + + // SCORING muss gesetzt sein + if(phase.getScoring() == null) + { + errors.add(DqflError.error(ctx + " requires SCORING")); + } + + // TEAM_MODE muss gesetzt sein + if(phase.getTeamMode() == null) + { + errors.add(DqflError.error(ctx + " requires TEAM_MODE")); + } + + // NORMAL-Phase Regeln + if(phase.getPhaseType() == PhaseType.NORMAL) + { + if(phase.getCycles() == null) + { + errors.add(DqflError.warning(ctx + " has no CYCLES defined")); + } + if(phase.getTurnsPerCycle() == null) + { + errors.add(DqflError.warning(ctx + " has no TURNS_PER_CYCLE defined")); + } + } + + // BOSS-Phase: TEAM_MODE sollte TWO_VS_TWO sein + if(phase.getPhaseType() == PhaseType.BOSS) + { + if(phase.getTeamMode() == TeamModeType.SOLO) + { + errors.add(DqflError.warning( + ctx + " is BOSS phase but TEAM_MODE is SOLO — typically TWO_VS_TWO")); + } + + if(phase.getTeamMode() == TeamModeType.TWO_VS_TWO + && phase.getTeamAssignment() == null) + { + errors.add(DqflError.error( + ctx + " has TEAM_MODE TWO_VS_TWO but no TEAM_ASSIGNMENT")); + } + } + } + + // ================================================================ + // Alias-Eindeutigkeit (über Objektarten) + // ================================================================ + + private void validateAliasUniqueness( + final DqflDocument document, + final List errors) + { + // SOURCE-Aliase eindeutig (Map-Struktur prüft bereits implizit, + // aber bei Duplikaten im Dokument wurde der erste überschrieben) + // POOL-Aliase eindeutig (gleiche Map-Logik) + + // Wir prüfen explizit ob Pool-Aliase und Source-Aliase kollidieren (optional, sauber) + final Set sourceAliases = document.getSources().keySet(); + final Set poolAliases = document.getPools().keySet(); + + for(final String poolAlias : poolAliases) + { + if(sourceAliases.contains(poolAlias)) + { + errors.add(DqflError.warning( + "POOL alias '" + poolAlias + + "' collides with a SOURCE alias — this may cause confusion")); + } + } + } +} diff --git a/src/test/java/de/dogfire/dqfl/DqflTest.java b/src/test/java/de/dogfire/dqfl/DqflTest.java new file mode 100644 index 0000000..b2662ae --- /dev/null +++ b/src/test/java/de/dogfire/dqfl/DqflTest.java @@ -0,0 +1,411 @@ + +package de.dogfire.dqfl; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.InputStream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import de.dogfire.dqfl.error.DqflParseException; +import de.dogfire.dqfl.error.DqflValidationResult; +import de.dogfire.dqfl.model.*; + +/** + * Tests für die DQFL V1 Library: Parser + Validator. + */ +class DqflTest +{ + // ================================================================ + // Parser Tests + // ================================================================ + + @Nested + @DisplayName("Parser") + class ParserTests + { + @Test + @DisplayName("Parst das vollständige Referenzbeispiel aus der Spec") + void parseReferenceExample() throws Exception + { + final DqflDocument doc = loadReferenceDocument(); + + assertEquals("V1", doc.getDqflVersion()); + assertEquals("Dogfire Quizabend", doc.getFlowName()); + assertEquals(1, doc.getScriptRevision()); + + // Sources + assertEquals(1, doc.getSources().size()); + final DqflSource src = doc.getSource("SRC1"); + assertNotNull(src); + assertEquals(SourceType.REST, src.getType()); + assertEquals("USER_POOL_MAIN", src.getConnection()); + + // Pools + assertEquals(2, doc.getPools().size()); + final DqflPool history = doc.getPool("HISTORY"); + assertNotNull(history); + assertEquals("SRC1", history.getSourceAlias()); + assertEquals("AB12CD340000004711", history.getRemoteId()); + assertEquals("Geschichte", history.getTitle()); + + final DqflPool movies = doc.getPool("MOVIES"); + assertNotNull(movies); + assertEquals("ZX82LM550000009321", movies.getRemoteId()); + + // Rounds + assertEquals(1, doc.getRounds().size()); + final DqflRound round = doc.getRounds().get(0); + assertEquals("R1", round.getAlias()); + assertEquals("Rematch", round.getTitle()); + + // Categories + assertEquals(2, round.getCategories().size()); + } + + @Test + @DisplayName("Parst Category-Details korrekt") + void parseCategoryDetails() throws Exception + { + final DqflDocument doc = loadReferenceDocument(); + final DqflCategory c1 = doc.getRounds().get(0).getCategories().get(0); + + assertEquals("C1", c1.getAlias()); + assertEquals("Geschichte", c1.getTitle()); + assertEquals("HISTORY", c1.getUsePool()); + assertEquals(LayoutType.GRID_2D, c1.getLayout()); + assertEquals(new GridSize(6, 6), c1.getSize()); + assertEquals(2, c1.getPhases().size()); + } + + @Test + @DisplayName("Parst NORMAL Phase korrekt") + void parseNormalPhase() throws Exception + { + final DqflDocument doc = loadReferenceDocument(); + final DqflPhase normal = doc.getRounds().get(0).getCategories().get(0).getPhases().get(0); + + assertEquals(PhaseType.NORMAL, normal.getPhaseType()); + assertEquals(QuestionSetType.NORMAL, normal.getQuestionSet()); + assertEquals(3, normal.getCycles()); + assertEquals(4, normal.getTurnsPerCycle()); + assertEquals(TurnOrderType.ROTATING_START, normal.getTurnOrder()); + assertEquals(PickModeType.FREE_UNANSWERED, normal.getPickMode()); + assertEquals(TeamModeType.SOLO, normal.getTeamMode()); + assertEquals(ScoringProfile.SOLO_PLUS1_NO_PENALTY, normal.getScoring()); + } + + @Test + @DisplayName("Parst BOSS Phase korrekt") + void parseBossPhase() throws Exception + { + final DqflDocument doc = loadReferenceDocument(); + final DqflPhase boss = doc.getRounds().get(0).getCategories().get(0).getPhases().get(1); + + assertEquals(PhaseType.BOSS, boss.getPhaseType()); + assertEquals(QuestionSetType.BOSS, boss.getQuestionSet()); + assertEquals(TeamModeType.TWO_VS_TWO, boss.getTeamMode()); + assertEquals(TeamAssignmentType.RANDOM, boss.getTeamAssignment()); + assertEquals(ScoringProfile.BOSS_2V2_PLUS2_PER_CORRECT_PLAYER, boss.getScoring()); + } + + @Test + @DisplayName("getAllCategoriesFlat liefert alle Kategorien in Dokumentreihenfolge") + void flatCategoryList() throws Exception + { + final DqflDocument doc = loadReferenceDocument(); + final var flat = doc.getAllCategoriesFlat(); + + assertEquals(2, flat.size()); + assertEquals("C1", flat.get(0).getAlias()); + assertEquals("C2", flat.get(1).getAlias()); + } + + @Test + @DisplayName("Leerer Input wirft DqflParseException") + void emptyInputThrows() + { + assertThrows(DqflParseException.class, () -> Dqfl.parse("")); + assertThrows(DqflParseException.class, () -> Dqfl.parse(" \n \n ")); + } + + @Test + @DisplayName("Unbekanntes Root-Keyword erzeugt Fehler") + void unknownRootKeyword() + { + final String input = """ + DQFL_VERSION V1 + FLOW "Test" + BOGUS_KEYWORD value + """; + + final DqflParseException ex = assertThrows(DqflParseException.class, + () -> Dqfl.parse(input)); + + assertTrue(ex.getErrors().stream() + .anyMatch(e -> e.getMessage().contains("BOGUS_KEYWORD"))); + } + } + + // ================================================================ + // Validator Tests + // ================================================================ + + @Nested + @DisplayName("Validator") + class ValidatorTests + { + @Test + @DisplayName("Referenzbeispiel besteht die Validierung") + void referenceExampleIsValid() throws Exception + { + final DqflDocument doc = loadReferenceDocument(); + final DqflValidationResult result = Dqfl.validate(doc); + + assertTrue(result.isValid(), + "Reference example should be valid. Errors: " + result.getErrorsOnly()); + } + + @Test + @DisplayName("Fehlender USE_POOL wird erkannt") + void missingUsePool() throws Exception + { + final String input = """ + DQFL_VERSION V1 + FLOW "Test" + SOURCE SRC1 + TYPE REST + CONNECTION CONN1 + POOL P1 + SOURCE SRC1 + REMOTE_ID "AB12CD340000004711" + ROUND R1 + CATEGORY C1 + LAYOUT GRID_2D + SIZE 6x6 + PHASE NORMAL + TEAM_MODE SOLO + SCORING SOLO_PLUS1_NO_PENALTY + """; + + final DqflDocument doc = Dqfl.parse(input); + final DqflValidationResult result = Dqfl.validate(doc); + + assertFalse(result.isValid()); + assertTrue(result.getErrorsOnly().stream() + .anyMatch(e -> e.getMessage().contains("USE_POOL"))); + } + + @Test + @DisplayName("SIZE ohne LAYOUT wird erkannt") + void sizeWithoutLayout() throws Exception + { + final String input = """ + DQFL_VERSION V1 + FLOW "Test" + SOURCE SRC1 + TYPE REST + CONNECTION CONN1 + POOL P1 + SOURCE SRC1 + REMOTE_ID "AB12CD340000004711" + ROUND R1 + CATEGORY C1 + USE_POOL P1 + SIZE 6x6 + PHASE NORMAL + TEAM_MODE SOLO + SCORING SOLO_PLUS1_NO_PENALTY + """; + + final DqflDocument doc = Dqfl.parse(input); + final DqflValidationResult result = Dqfl.validate(doc); + + assertFalse(result.isValid()); + assertTrue(result.getErrorsOnly().stream() + .anyMatch(e -> e.getMessage().contains("SIZE without LAYOUT"))); + } + + @Test + @DisplayName("Fehlende PHASE wird erkannt") + void missingPhase() throws Exception + { + final String input = """ + DQFL_VERSION V1 + FLOW "Test" + SOURCE SRC1 + TYPE REST + CONNECTION CONN1 + POOL P1 + SOURCE SRC1 + REMOTE_ID "AB12CD340000004711" + ROUND R1 + CATEGORY C1 + USE_POOL P1 + """; + + final DqflDocument doc = Dqfl.parse(input); + final DqflValidationResult result = Dqfl.validate(doc); + + assertFalse(result.isValid()); + assertTrue(result.getErrorsOnly().stream() + .anyMatch(e -> e.getMessage().contains("at least one PHASE"))); + } + + @Test + @DisplayName("Unbekannter POOL-Verweis wird erkannt") + void unknownPoolReference() throws Exception + { + final String input = """ + DQFL_VERSION V1 + FLOW "Test" + SOURCE SRC1 + TYPE REST + CONNECTION CONN1 + ROUND R1 + CATEGORY C1 + USE_POOL DOES_NOT_EXIST + PHASE NORMAL + TEAM_MODE SOLO + SCORING SOLO_PLUS1_NO_PENALTY + """; + + final DqflDocument doc = Dqfl.parse(input); + final DqflValidationResult result = Dqfl.validate(doc); + + assertFalse(result.isValid()); + assertTrue(result.getErrorsOnly().stream() + .anyMatch(e -> e.getMessage().contains("DOES_NOT_EXIST"))); + } + + @Test + @DisplayName("BOSS Phase ohne TEAM_ASSIGNMENT wird erkannt") + void bossWithoutTeamAssignment() throws Exception + { + final String input = """ + DQFL_VERSION V1 + FLOW "Test" + SOURCE SRC1 + TYPE REST + CONNECTION CONN1 + POOL P1 + SOURCE SRC1 + REMOTE_ID "AB12CD340000004711" + ROUND R1 + CATEGORY C1 + USE_POOL P1 + PHASE BOSS + QUESTION_SET BOSS + TEAM_MODE TWO_VS_TWO + SCORING BOSS_2V2_PLUS2_PER_CORRECT_PLAYER + """; + + final DqflDocument doc = Dqfl.parse(input); + final DqflValidationResult result = Dqfl.validate(doc); + + assertFalse(result.isValid()); + assertTrue(result.getErrorsOnly().stream() + .anyMatch(e -> e.getMessage().contains("TEAM_ASSIGNMENT"))); + } + + @Test + @DisplayName("parseAndValidate Convenience-Methode funktioniert") + void parseAndValidateConvenience() throws Exception + { + final String input = loadReferenceString(); + final DqflDocument doc = Dqfl.parseAndValidate(input); + + assertNotNull(doc); + assertEquals("V1", doc.getDqflVersion()); + } + } + + // ================================================================ + // GridSize Tests + // ================================================================ + + @Nested + @DisplayName("GridSize") + class GridSizeTests + { + @Test + void parseValid() + { + final GridSize gs = GridSize.parse("6x6"); + assertNotNull(gs); + assertEquals(6, gs.getRows()); + assertEquals(6, gs.getCols()); + assertEquals(36, gs.getTotalCells()); + } + + @Test + void parseRectangular() + { + final GridSize gs = GridSize.parse("4x9"); + assertNotNull(gs); + assertEquals(4, gs.getRows()); + assertEquals(9, gs.getCols()); + assertEquals(36, gs.getTotalCells()); + } + + @Test + void parseInvalid() + { + assertNull(GridSize.parse("abc")); + assertNull(GridSize.parse("6")); + assertNull(GridSize.parse(null)); + assertNull(GridSize.parse("")); + assertNull(GridSize.parse("0x6")); // 0 ist nicht positiv + } + + @Test + void equality() + { + assertEquals(GridSize.parse("6x6"), GridSize.parse("6x6")); + assertNotEquals(GridSize.parse("6x6"), GridSize.parse("4x9")); + } + } + + // ================================================================ + // ScoringProfile Tests + // ================================================================ + + @Nested + @DisplayName("ScoringProfile") + class ScoringProfileTests + { + @Test + void soloPoints() + { + assertEquals(1, ScoringProfile.SOLO_PLUS1_NO_PENALTY.getPointsCorrect()); + assertEquals(0, ScoringProfile.SOLO_PLUS1_NO_PENALTY.getPointsWrong()); + } + + @Test + void bossPoints() + { + assertEquals(2, ScoringProfile.BOSS_2V2_PLUS2_PER_CORRECT_PLAYER.getPointsCorrect()); + assertEquals(0, ScoringProfile.BOSS_2V2_PLUS2_PER_CORRECT_PLAYER.getPointsWrong()); + } + } + + // ================================================================ + // Helpers + // ================================================================ + + private static DqflDocument loadReferenceDocument() throws Exception + { + return Dqfl.parse(loadReferenceString()); + } + + private static String loadReferenceString() throws Exception + { + try(final InputStream is = DqflTest.class.getResourceAsStream("/reference-example.dqfl")) + { + assertNotNull(is, "reference-example.dqfl not found on classpath"); + return new String(is.readAllBytes()); + } + } +} diff --git a/src/test/resources/reference-example.dqfl b/src/test/resources/reference-example.dqfl new file mode 100644 index 0000000..0d3bded --- /dev/null +++ b/src/test/resources/reference-example.dqfl @@ -0,0 +1,56 @@ +DQFL_VERSION V1 +FLOW "Dogfire Quizabend" +SCRIPT_REVISION 1 + +SOURCE SRC1 + TYPE REST + CONNECTION USER_POOL_MAIN + +POOL HISTORY + SOURCE SRC1 + REMOTE_ID "AB12CD340000004711" + TITLE "Geschichte" + +POOL MOVIES + SOURCE SRC1 + REMOTE_ID "ZX82LM550000009321" + TITLE "Filme" + +ROUND R1 + TITLE "Rematch" + CATEGORY C1 + TITLE "Geschichte" + USE_POOL HISTORY + LAYOUT GRID_2D + SIZE 6x6 + PHASE NORMAL + QUESTION_SET NORMAL + CYCLES 3 + TURNS_PER_CYCLE 4 + TURN_ORDER ROTATING_START + PICK_MODE FREE_UNANSWERED + TEAM_MODE SOLO + SCORING SOLO_PLUS1_NO_PENALTY + PHASE BOSS + QUESTION_SET BOSS + TEAM_MODE TWO_VS_TWO + TEAM_ASSIGNMENT RANDOM + SCORING BOSS_2V2_PLUS2_PER_CORRECT_PLAYER + CATEGORY C2 + TITLE "Filme" + USE_POOL MOVIES + LAYOUT GRID_2D + SIZE 6x6 + PHASE NORMAL + QUESTION_SET NORMAL + CYCLES 3 + TURNS_PER_CYCLE 4 + TURN_ORDER ROTATING_START + PICK_MODE FREE_UNANSWERED + TEAM_MODE SOLO + SCORING SOLO_PLUS1_NO_PENALTY + PHASE BOSS + QUESTION_SET BOSS + TEAM_MODE TWO_VS_TWO + TEAM_ASSIGNMENT RANDOM + SCORING BOSS_2V2_PLUS2_PER_CORRECT_PLAYER