From 69d32ed85304294789586d2681630d646c93dae8 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:58:30 +0200 Subject: [PATCH] [DeckList] Extract plain-text deck parser into own file (#7305) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [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. * [DeckList] Harden plain-text parser regexes and move metadata clearing up --------- Co-authored-by: Lukas BrĂ¼bach --- libcockatrice_deck_list/CMakeLists.txt | 2 + .../libcockatrice/deck_list/deck_list.cpp | 158 +--------------- .../deck_list/deck_list_plain_text_parser.cpp | 172 ++++++++++++++++++ .../deck_list/deck_list_plain_text_parser.h | 33 ++++ 4 files changed, 212 insertions(+), 153 deletions(-) create mode 100644 libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.cpp create mode 100644 libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.h diff --git a/libcockatrice_deck_list/CMakeLists.txt b/libcockatrice_deck_list/CMakeLists.txt index c7a54a390..0c487466c 100644 --- a/libcockatrice_deck_list/CMakeLists.txt +++ b/libcockatrice_deck_list/CMakeLists.txt @@ -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 ) diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp index 28498cb84..c2134c21d 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp @@ -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 #include #include -#include #include #include #include @@ -250,160 +250,12 @@ bool DeckList::loadFromStream_Plain(QTextStream &in, bool preserveMetadata, const std::function &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; - } + if (!preserveMetadata) { + metadata = {}; } - - // 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 ¤t = inputs.at(index++); - if (!current.contains(reEmpty)) { - match = reComment.match(current); - metadata.name = match.captured(); - break; - } - } - while (index < deckStart) { - const auto ¤t = 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, cardNameNormalizer, metadata, tree); refreshDeckHash(); - return true; + return ok; } bool DeckList::loadFromFile_Plain(QIODevice *device, const std::function &cardNameNormalizer) diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.cpp new file mode 100644 index 000000000..de50d743b --- /dev/null +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.cpp @@ -0,0 +1,172 @@ +#include "deck_list_plain_text_parser.h" + +#include "deck_list_node_tree.h" +#include "tree/inner_deck_list_node.h" + +#include +#include + +namespace DeckListPlainText +{ + +bool parse(QTextStream &in, + const std::function &cardNameNormalizer, + DeckList::Metadata &metadata, + DecklistNodeTree &tree) +{ + tree.clear(); + + static const QRegularExpression reCardLine(R"(^\s*[\w\[\(\{].*$)", QRegularExpression::UseUnicodePropertiesOption); + static const QRegularExpression reEmpty("^\\s*$"); + static const QRegularExpression reComment(R"([\w\[\(\{].*$)", QRegularExpression::UseUnicodePropertiesOption); + static const QRegularExpression reSBMark("^\\s*sb:\\s*(.+)", QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression reSBComment("^sideboard\\b.*$", QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression reDeckComment("^((main)?deck(list)?|mainboard)\\b", + QRegularExpression::CaseInsensitiveOption); + + // Regex for advanced card parsing + static const QRegularExpression reMultiplier(R"(^[xX\(\[]*(\d+)[xX\*\)\]]* ?(.+))"); + + // Regex for extracting set code and collector number with attached symbols + static const QRegularExpression reHyphenFormat(R"(\((\w{3,})\)\s+(\w{3,})-(\d+[^\w\s]*))"); + static 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 ¤t = inputs.at(index++); + if (!current.contains(reEmpty)) { + match = reComment.match(current); + metadata.name = match.captured(); + break; + } + } + while (index < deckStart) { + const auto ¤t = 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 \ No newline at end of file diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.h new file mode 100644 index 000000000..e0f456a18 --- /dev/null +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.h @@ -0,0 +1,33 @@ +#ifndef COCKATRICE_DECK_LIST_PLAIN_TEXT_PARSER_H +#define COCKATRICE_DECK_LIST_PLAIN_TEXT_PARSER_H + +#include "deck_list.h" + +#include +#include + +class QTextStream; + +namespace DeckListPlainText +{ + +/** + * @brief Parses a plain-text deck list into a tree and its metadata. + * + * Clears the tree first, then fills both from the text. + * + * @param in The text to load + * @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, + const std::function &cardNameNormalizer, + DeckList::Metadata &metadata, + DecklistNodeTree &tree); + +} // namespace DeckListPlainText + +#endif // COCKATRICE_DECK_LIST_PLAIN_TEXT_PARSER_H \ No newline at end of file