[Card] Bind search language per FilterString instance

The peg parser rules are set up once per process, so the GenericQuery and
OracleQuery rule actions could not capture per-instance state. Instead of
storing the search language in a process-global that FilterString instance
methods mutate, hand it to the rule actions through a thread-local parse
context and copy it into the filter closures they produce. Card evaluation
in FilterString::check no longer reads any process-global state, and each
instance keeps the language it was built with; constructing one instance no
longer changes what unrelated instances (deck filter, drop-to-hand, zone
views) match against.

The card database display model stores the raw query and rebuilds the
FilterString when the search language changes, since the language is now
bound at parse time.

Add tests for the English/Selected/Both search modes, the English fallback
for untranslated cards, and per-instance language independence.
This commit is contained in:
Lukas Brübach 2026-09-19 10:47:24 +02:00
parent e029173e29
commit 14cf4ee2a9
5 changed files with 81 additions and 33 deletions

View file

@ -74,13 +74,18 @@ NumericValue <- [0-9]+
static std::once_flag init; static std::once_flag init;
// The peg parser rules are set up once per process, which means the GenericQuery/ // The peglib parser rules (and therefore their rule actions) are set up once per
// OracleQuery lambdas cannot capture per-instance state. The card language the // process, so a rule action cannot capture per-instance state. The card language
// plain-text name and text queries search in is therefore kept here and applied // plain-text name and text queries search in is therefore handed to the GenericQuery
// by those lambdas; every FilterString shares it because it reflects a single // and OracleQuery rule actions through this thread-local context, which is live only
// global user setting. // while a FilterString is being parsed. The rule actions copy it into the filter
static QString globalSearchLanguage; // closures they produce, so card evaluation never reads process-global state.
static CardSearchLanguage globalSearchLanguageMode = CardSearchLanguage::English; struct SearchLanguageContext
{
QString searchLanguage;
CardSearchLanguage searchLanguageMode = CardSearchLanguage::English;
};
thread_local SearchLanguageContext searchLanguageContext;
namespace namespace
{ {
@ -365,9 +370,11 @@ static void setupParserRules()
search["OracleQuery"] = [](const peg::SemanticValues &sv) -> Filter { search["OracleQuery"] = [](const peg::SemanticValues &sv) -> Filter {
const auto matcher = std::any_cast<StringMatcher>(sv[0]); const auto matcher = std::any_cast<StringMatcher>(sv[0]);
const QString searchLanguage = searchLanguageContext.searchLanguage;
const CardSearchLanguage searchLanguageMode = searchLanguageContext.searchLanguageMode;
return [=](const CardData &x) { return [=](const CardData &x) {
return matchesInSearchLanguage(x->getText(), x->getLocalizedText(globalSearchLanguage), return matchesInSearchLanguage(x->getText(), x->getLocalizedText(searchLanguage), searchLanguage,
globalSearchLanguage, globalSearchLanguageMode, matcher); searchLanguageMode, matcher);
}; };
}; };
@ -445,9 +452,11 @@ static void setupParserRules()
}; };
search["GenericQuery"] = [](const peg::SemanticValues &sv) -> Filter { search["GenericQuery"] = [](const peg::SemanticValues &sv) -> Filter {
const auto matcher = std::any_cast<StringMatcher>(sv[0]); const auto matcher = std::any_cast<StringMatcher>(sv[0]);
const QString searchLanguage = searchLanguageContext.searchLanguage;
const CardSearchLanguage searchLanguageMode = searchLanguageContext.searchLanguageMode;
return [=](const CardData &x) { return [=](const CardData &x) {
return matchesInSearchLanguage(x->getName(), x->getLocalizedName(globalSearchLanguage), return matchesInSearchLanguage(x->getName(), x->getLocalizedName(searchLanguage), searchLanguage,
globalSearchLanguage, globalSearchLanguageMode, matcher); searchLanguageMode, matcher);
}; };
}; };
@ -463,7 +472,7 @@ FilterString::FilterString()
_error = "Not initialized"; _error = "Not initialized";
} }
FilterString::FilterString(const QString &expr) FilterString::FilterString(const QString &expr, const QString &searchLanguage, CardSearchLanguage searchLanguageMode)
{ {
QByteArray ba = expr.simplified().toUtf8(); QByteArray ba = expr.simplified().toUtf8();
@ -476,6 +485,8 @@ FilterString::FilterString(const QString &expr)
return; return;
} }
searchLanguageContext = SearchLanguageContext{searchLanguage, searchLanguageMode};
search.set_logger([&](size_t /*ln*/, size_t col, const std::string &msg) { search.set_logger([&](size_t /*ln*/, size_t col, const std::string &msg) {
_error = QString("Error at position %1: %2").arg(col).arg(QString::fromStdString(msg)); _error = QString("Error at position %1: %2").arg(col).arg(QString::fromStdString(msg));
}); });
@ -485,9 +496,3 @@ FilterString::FilterString(const QString &expr)
result = [](const CardData &) -> bool { return false; }; result = [](const CardData &) -> bool { return false; };
} }
} }
void FilterString::setSearchLanguage(const QString &searchLanguage, CardSearchLanguage searchLanguageMode)
{
globalSearchLanguage = searchLanguage;
globalSearchLanguageMode = searchLanguageMode;
}

View file

@ -36,7 +36,9 @@ class FilterString
{ {
public: public:
FilterString(); FilterString();
explicit FilterString(const QString &exp); explicit FilterString(const QString &exp,
const QString &searchLanguage = QString(),
CardSearchLanguage searchLanguageMode = CardSearchLanguage::English);
[[nodiscard]] bool check(const CardData &card) const [[nodiscard]] bool check(const CardData &card) const
{ {
if (card.isNull()) { if (card.isNull()) {
@ -56,17 +58,6 @@ public:
return _error; return _error;
} }
/**
* @brief Sets the card language plain-text name and text queries run against.
*
* The peg parser rules are set up once per process, so this propagates to
* every FilterString instance created through the shared parser.
*
* @param searchLanguage Empty string for English only, otherwise the card language code.
* @param searchLanguageMode The search language mode from CardSearchLanguage.
*/
void setSearchLanguage(const QString &searchLanguage, CardSearchLanguage searchLanguageMode);
private: private:
QString _error; QString _error;
Filter result; Filter result;

View file

@ -239,9 +239,9 @@ void CardDatabaseDisplayModel::setFilterTree(FilterTree *_filterTree)
void CardDatabaseDisplayModel::setStringFilter(const QString &_src) void CardDatabaseDisplayModel::setStringFilter(const QString &_src)
{ {
searchText = _src;
delete filterString; delete filterString;
filterString = new FilterString(_src); filterString = new FilterString(_src, searchLanguage, searchLanguageMode);
filterString->setSearchLanguage(searchLanguage, searchLanguageMode);
dirty(); dirty();
} }
@ -255,7 +255,7 @@ void CardDatabaseDisplayModel::setSearchLanguage(const QString &searchLang, Card
searchLanguageMode = mode; searchLanguageMode = mode;
if (filterString != nullptr) { if (filterString != nullptr) {
filterString->setSearchLanguage(searchLanguage, searchLanguageMode); setStringFilter(searchText);
} }
dirty(); dirty();
} }

View file

@ -35,6 +35,7 @@ private:
QTimer dirtyTimer; QTimer dirtyTimer;
QString searchLanguage; QString searchLanguage;
CardSearchLanguage searchLanguageMode = CardSearchLanguage::English; CardSearchLanguage searchLanguageMode = CardSearchLanguage::English;
QString searchText;
/** The translation table that will be used for sanitizeCardName. */ /** The translation table that will be used for sanitizeCardName. */
static QMap<wchar_t, wchar_t> characterTranslation; static QMap<wchar_t, wchar_t> characterTranslation;

View file

@ -73,6 +73,57 @@ QUERY(Color4, cat, "c!gw", false)
QUERY(BracketNextToUnquotedString, cat, "(o:woof OR o:meow)", true) QUERY(BracketNextToUnquotedString, cat, "(o:woof OR o:meow)", true)
CardInfoPtr localizedCat()
{
CardInfoPtr localized = CardInfo::newInstance("Cat", "Meow!", false, {}, {}, {}, {}, {});
localized->setLocalizedName("de", "Kater");
localized->setLocalizedText("de", "miaut");
return localized;
}
TEST_F(CardQuery, SearchLanguageEnglishMatchesOnlyEnglish)
{
const CardData localized = localizedCat();
ASSERT_TRUE(FilterString("Cat", "de", CardSearchLanguage::English).check(localized));
ASSERT_FALSE(FilterString("Kater", "de", CardSearchLanguage::English).check(localized));
}
TEST_F(CardQuery, SearchLanguageSelectedMatchesLocalizedNameAndText)
{
const CardData localized = localizedCat();
ASSERT_TRUE(FilterString("Kater", "de", CardSearchLanguage::Selected).check(localized));
ASSERT_TRUE(FilterString("o:miaut", "de", CardSearchLanguage::Selected).check(localized));
ASSERT_FALSE(FilterString("Cat", "de", CardSearchLanguage::Selected).check(localized));
}
TEST_F(CardQuery, SearchLanguageSelectedFallsBackToEnglishForUntranslatedCards)
{
const CardData localized = localizedCat();
ASSERT_TRUE(FilterString("Cat", "fr", CardSearchLanguage::Selected).check(localized));
ASSERT_FALSE(FilterString("Kater", "fr", CardSearchLanguage::Selected).check(localized));
}
TEST_F(CardQuery, SearchLanguageBothMatchesEitherLanguage)
{
const CardData localized = localizedCat();
ASSERT_TRUE(FilterString("Cat", "de", CardSearchLanguage::Both).check(localized));
ASSERT_TRUE(FilterString("Kater", "de", CardSearchLanguage::Both).check(localized));
}
TEST_F(CardQuery, SearchLanguageIsBoundPerInstance)
{
const CardData localized = localizedCat();
FilterString germanQuery("Kater", "de", CardSearchLanguage::Selected);
ASSERT_TRUE(germanQuery.check(localized));
// Constructing an English-bound instance afterwards must not change the
// language the earlier instance searches in.
FilterString englishQuery("Kater", "", CardSearchLanguage::English);
ASSERT_FALSE(englishQuery.check(localized));
ASSERT_TRUE(germanQuery.check(localized));
}
} // namespace } // namespace
int main(int argc, char **argv) int main(int argc, char **argv)