[DeckList] Extract plain-text deck parser into own file

DeckList::loadFromStream_Plain was a 160-line god-method mixing
deck clearing, name/comment detection, sideboard heuristics, set
and multiplier extraction and normalization. The parsing logic
moves verbatim into DeckListPlainText::parse() so it lives in a
dedicated, testable unit; DeckList keeps a thin delegating wrapper
and still refreshes the deck hash exactly as before (also on the
empty-input path, to match cleanList's original behavior). The
*F* foil suffix handling is relocated unchanged.
This commit is contained in:
Lukas Brübach 2026-09-17 15:17:41 +02:00 committed by GitHub
parent 2486f73067
commit d71b614c44
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 217 additions and 154 deletions

View file

@ -11,6 +11,7 @@ set(HEADERS
libcockatrice/deck_list/deck_list_history_manager.h
libcockatrice/deck_list/deck_list_node_tree.h
libcockatrice/deck_list/deck_list_memento.h
libcockatrice/deck_list/deck_list_plain_text_parser.h
libcockatrice/deck_list/playmat_resolver.h
libcockatrice/deck_list/sideboard_plan.h
)
@ -27,6 +28,7 @@ add_library(
libcockatrice/deck_list/deck_list.cpp
libcockatrice/deck_list/deck_list_history_manager.cpp
libcockatrice/deck_list/deck_list_node_tree.cpp
libcockatrice/deck_list/deck_list_plain_text_parser.cpp
libcockatrice/deck_list/playmat_resolver.cpp
libcockatrice/deck_list/sideboard_plan.cpp
)

View file

@ -1,6 +1,7 @@
#include "deck_list.h"
#include "deck_list_memento.h"
#include "deck_list_plain_text_parser.h"
#include "tree/abstract_deck_list_node.h"
#include "tree/deck_list_card_node.h"
#include "tree/inner_deck_list_node.h"
@ -8,7 +9,6 @@
#include <QCryptographicHash>
#include <QDebug>
#include <QFile>
#include <QRegularExpression>
#include <QSet>
#include <QTextStream>
#include <algorithm>
@ -250,160 +250,9 @@ bool DeckList::loadFromStream_Plain(QTextStream &in,
bool preserveMetadata,
const std::function<QString(const QString &)> &cardNameNormalizer)
{
const QRegularExpression reCardLine(R"(^\s*[\w\[\(\{].*$)", QRegularExpression::UseUnicodePropertiesOption);
const QRegularExpression reEmpty("^\\s*$");
const QRegularExpression reComment(R"([\w\[\(\{].*$)", QRegularExpression::UseUnicodePropertiesOption);
const QRegularExpression reSBMark("^\\s*sb:\\s*(.+)", QRegularExpression::CaseInsensitiveOption);
const QRegularExpression reSBComment("^sideboard\\b.*$", QRegularExpression::CaseInsensitiveOption);
const QRegularExpression reDeckComment("^((main)?deck(list)?|mainboard)\\b",
QRegularExpression::CaseInsensitiveOption);
// Regex for advanced card parsing
const QRegularExpression reMultiplier(R"(^[xX\(\[]*(\d+)[xX\*\)\]]* ?(.+))");
// Regex for extracting set code and collector number with attached symbols
const QRegularExpression reHyphenFormat(R"(\((\w{3,})\)\s+(\w{3,})-(\d+[^\w\s]*))");
const QRegularExpression reRegularFormat(R"(\((\w{3,})\)\s+(\d+[^\w\s]*))");
cleanList(preserveMetadata);
auto inputs = in.readAll().trimmed().split('\n');
auto max_line = inputs.size();
// Start at the first empty line before the first card line
auto deckStart = inputs.indexOf(reCardLine);
if (deckStart == -1) {
if (inputs.indexOf(reComment) == -1) {
return false; // Input is empty
}
deckStart = max_line;
} else {
deckStart = inputs.lastIndexOf(reEmpty, deckStart);
if (deckStart == -1) {
deckStart = 0;
}
}
// find sideboard position, if marks are used this won't be needed
int sBStart = -1;
if (inputs.indexOf(reSBMark, deckStart) == -1) {
sBStart = inputs.indexOf(reSBComment, deckStart);
if (sBStart == -1) {
sBStart = inputs.indexOf(reEmpty, deckStart + 1);
if (sBStart == -1) {
sBStart = max_line;
}
auto nextCard = inputs.indexOf(reCardLine, sBStart + 1);
if (inputs.indexOf(reEmpty, nextCard + 1) != -1) {
sBStart = max_line;
}
}
}
int index = 0;
QRegularExpressionMatch match;
// Parse name and comments
while (index < deckStart) {
const auto &current = inputs.at(index++);
if (!current.contains(reEmpty)) {
match = reComment.match(current);
metadata.name = match.captured();
break;
}
}
while (index < deckStart) {
const auto &current = inputs.at(index++);
if (!current.contains(reEmpty)) {
match = reComment.match(current);
metadata.comments += match.captured() + '\n';
}
}
metadata.comments.chop(1);
// Discard empty lines
while (index < max_line && inputs.at(index).contains(reEmpty)) {
++index;
}
// Discard line if it starts with deck or mainboard, all cards until the sideboard starts are in the mainboard
if (inputs.at(index).contains(reDeckComment)) {
++index;
}
// Parse decklist
for (; index < max_line; ++index) {
// check if line is a card
match = reCardLine.match(inputs.at(index));
if (!match.hasMatch()) {
continue;
}
QString cardName = match.captured().simplified();
bool sideboard = false;
// Sideboard detection
if (sBStart < 0) {
match = reSBMark.match(cardName);
if (match.hasMatch()) {
sideboard = true;
cardName = match.captured(1);
}
} else {
if (index == sBStart) {
continue;
}
sideboard = index > sBStart;
}
// Extract set code, collector number, and foil
QString setCode;
QString collectorNumber;
bool isFoil = false;
// Check for foil status at the end of the card name
if (cardName.endsWith("*F*", Qt::CaseInsensitive)) {
isFoil = true;
cardName.chop(3); // Remove the "*F*" from the card name
}
Q_UNUSED(isFoil);
// Attempt to match the hyphen-separated format (PLST-2094)
match = reHyphenFormat.match(cardName);
if (match.hasMatch()) {
setCode = match.captured(2).toUpper();
collectorNumber = match.captured(3);
cardName = cardName.left(match.capturedStart()).trimmed();
} else {
// Attempt to match the regular format (PLST) 2094
match = reRegularFormat.match(cardName);
if (match.hasMatch()) {
setCode = match.captured(1).toUpper();
collectorNumber = match.captured(2);
cardName = cardName.left(match.capturedStart()).trimmed();
}
}
// check if a specific amount is mentioned
int amount = 1;
match = reMultiplier.match(cardName);
if (match.hasMatch()) {
amount = match.captured(1).toInt();
cardName = match.captured(2);
}
// Normalize the card name
cardName = cardNameNormalizer(cardName);
// Determine the zone (mainboard/sideboard)
QString zoneName = sideboard ? DECK_ZONE_SIDE : DECK_ZONE_MAIN;
// make new entry in decklist
tree.addCard(cardName, amount, zoneName, -1, setCode, collectorNumber);
}
bool ok = DeckListPlainText::parse(in, preserveMetadata, cardNameNormalizer, metadata, tree);
refreshDeckHash();
return true;
return ok;
}
bool DeckList::loadFromFile_Plain(QIODevice *device, const std::function<QString(const QString &)> &cardNameNormalizer)

View file

@ -0,0 +1,176 @@
#include "deck_list_plain_text_parser.h"
#include "deck_list_node_tree.h"
#include "tree/inner_deck_list_node.h"
#include <QRegularExpression>
#include <QTextStream>
namespace DeckListPlainText
{
bool parse(QTextStream &in,
bool preserveMetadata,
const std::function<QString(const QString &)> &cardNameNormalizer,
DeckList::Metadata &metadata,
DecklistNodeTree &tree)
{
tree.clear();
if (!preserveMetadata) {
metadata = {};
}
const QRegularExpression reCardLine(R"(^\s*[\w\[\(\{].*$)", QRegularExpression::UseUnicodePropertiesOption);
const QRegularExpression reEmpty("^\\s*$");
const QRegularExpression reComment(R"([\w\[\(\{].*$)", QRegularExpression::UseUnicodePropertiesOption);
const QRegularExpression reSBMark("^\\s*sb:\\s*(.+)", QRegularExpression::CaseInsensitiveOption);
const QRegularExpression reSBComment("^sideboard\\b.*$", QRegularExpression::CaseInsensitiveOption);
const QRegularExpression reDeckComment("^((main)?deck(list)?|mainboard)\\b",
QRegularExpression::CaseInsensitiveOption);
// Regex for advanced card parsing
const QRegularExpression reMultiplier(R"(^[xX\(\[]*(\d+)[xX\*\)\]]* ?(.+))");
// Regex for extracting set code and collector number with attached symbols
const QRegularExpression reHyphenFormat(R"(\((\w{3,})\)\s+(\w{3,})-(\d+[^\w\s]*))");
const QRegularExpression reRegularFormat(R"(\((\w{3,})\)\s+(\d+[^\w\s]*))");
auto inputs = in.readAll().trimmed().split('\n');
auto max_line = inputs.size();
// Start at the first empty line before the first card line
auto deckStart = inputs.indexOf(reCardLine);
if (deckStart == -1) {
if (inputs.indexOf(reComment) == -1) {
return false; // Input is empty
}
deckStart = max_line;
} else {
deckStart = inputs.lastIndexOf(reEmpty, deckStart);
if (deckStart == -1) {
deckStart = 0;
}
}
// find sideboard position, if marks are used this won't be needed
int sBStart = -1;
if (inputs.indexOf(reSBMark, deckStart) == -1) {
sBStart = inputs.indexOf(reSBComment, deckStart);
if (sBStart == -1) {
sBStart = inputs.indexOf(reEmpty, deckStart + 1);
if (sBStart == -1) {
sBStart = max_line;
}
auto nextCard = inputs.indexOf(reCardLine, sBStart + 1);
if (inputs.indexOf(reEmpty, nextCard + 1) != -1) {
sBStart = max_line;
}
}
}
int index = 0;
QRegularExpressionMatch match;
// Parse name and comments
while (index < deckStart) {
const auto &current = inputs.at(index++);
if (!current.contains(reEmpty)) {
match = reComment.match(current);
metadata.name = match.captured();
break;
}
}
while (index < deckStart) {
const auto &current = inputs.at(index++);
if (!current.contains(reEmpty)) {
match = reComment.match(current);
metadata.comments += match.captured() + '\n';
}
}
metadata.comments.chop(1);
// Discard empty lines
while (index < max_line && inputs.at(index).contains(reEmpty)) {
++index;
}
// Discard line if it starts with deck or mainboard, all cards until the sideboard starts are in the mainboard
if (inputs.at(index).contains(reDeckComment)) {
++index;
}
// Parse decklist
for (; index < max_line; ++index) {
// check if line is a card
match = reCardLine.match(inputs.at(index));
if (!match.hasMatch()) {
continue;
}
QString cardName = match.captured().simplified();
bool sideboard = false;
// Sideboard detection
if (sBStart < 0) {
match = reSBMark.match(cardName);
if (match.hasMatch()) {
sideboard = true;
cardName = match.captured(1);
}
} else {
if (index == sBStart) {
continue;
}
sideboard = index > sBStart;
}
// Extract set code, collector number, and foil
QString setCode;
QString collectorNumber;
bool isFoil = false;
// Check for foil status at the end of the card name
if (cardName.endsWith("*F*", Qt::CaseInsensitive)) {
isFoil = true;
cardName.chop(3); // Remove the "*F*" from the card name
}
Q_UNUSED(isFoil);
// Attempt to match the hyphen-separated format (PLST-2094)
match = reHyphenFormat.match(cardName);
if (match.hasMatch()) {
setCode = match.captured(2).toUpper();
collectorNumber = match.captured(3);
cardName = cardName.left(match.capturedStart()).trimmed();
} else {
// Attempt to match the regular format (PLST) 2094
match = reRegularFormat.match(cardName);
if (match.hasMatch()) {
setCode = match.captured(1).toUpper();
collectorNumber = match.captured(2);
cardName = cardName.left(match.capturedStart()).trimmed();
}
}
// check if a specific amount is mentioned
int amount = 1;
match = reMultiplier.match(cardName);
if (match.hasMatch()) {
amount = match.captured(1).toInt();
cardName = match.captured(2);
}
// Normalize the card name
cardName = cardNameNormalizer(cardName);
// Determine the zone (mainboard/sideboard)
QString zoneName = sideboard ? DECK_ZONE_SIDE : DECK_ZONE_MAIN;
// make new entry in decklist
tree.addCard(cardName, amount, zoneName, -1, setCode, collectorNumber);
}
return true;
}
} // namespace DeckListPlainText

View file

@ -0,0 +1,36 @@
#ifndef COCKATRICE_DECK_LIST_PLAIN_TEXT_PARSER_H
#define COCKATRICE_DECK_LIST_PLAIN_TEXT_PARSER_H
#include "deck_list.h"
#include <QString>
#include <functional>
class QTextStream;
namespace DeckListPlainText
{
/**
* @brief Parses a plain-text deck list into a tree and its metadata.
*
* Clears the tree first, and clears the metadata unless @p preserveMetadata is
* true, then fills both from the text.
*
* @param in The text to load
* @param preserveMetadata If true, don't clear the existing metadata
* @param cardNameNormalizer Function that takes the parsed card name string
* in the text and returns the name to store
* @param metadata Deck metadata written by the parser
* @param tree Deck tree the parser adds cards to
* @return False if the input was empty, true otherwise.
*/
bool parse(QTextStream &in,
bool preserveMetadata,
const std::function<QString(const QString &)> &cardNameNormalizer,
DeckList::Metadata &metadata,
DecklistNodeTree &tree);
} // namespace DeckListPlainText
#endif // COCKATRICE_DECK_LIST_PLAIN_TEXT_PARSER_H