From d65d23ffceb77a5deaa895619be9b74230c03e92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Mon, 21 Sep 2026 13:20:52 +0200 Subject: [PATCH] [Card] Search cards by Scryfall Tagger tag --- cockatrice/resources/help/search.md | 5 +++ .../libcockatrice/filters/filter_string.cpp | 18 +++++++- tests/carddatabase/filter_string_test.cpp | 41 +++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/cockatrice/resources/help/search.md b/cockatrice/resources/help/search.md index fd0a12507..2d35b7b25 100644 --- a/cockatrice/resources/help/search.md +++ b/cockatrice/resources/help/search.md @@ -22,6 +22,11 @@ In this list of examples below, each entry has an explanation and can be clicked
[t:basic](#t:basic) (Any card with the type basic)
[t:arcane t:instant](#t:arcane t:instant) (Any card with the types arcane and instant)
+
Tags:
+
[tags:ramp](#tags:ramp) (Any card tagged "ramp" by the Scryfall Tagger community)
+
[tags:draw tags:ramp](#tags:draw tags:ramp) (Any card tagged both "draw" and "ramp")
+
[tags:draw OR tags:ramp](#tags:draw OR tags:ramp) (Any card tagged "draw" or "ramp")
+
Colors:
[c:w](#c:w) (Any card that is white)
[c:wu](#c:wu) (Any card that is white or blue)
diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp index 1b63ca122..f9febbbbe 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include static peg::parser search(R"( @@ -17,7 +18,7 @@ ComplexQueryPart <- SomewhatComplexQueryPart ws "OR" ws ComplexQueryPart / Somew SomewhatComplexQueryPart <- [(] QueryPartList [)] / QueryPart -QueryPart <- NotQuery / SetQuery / RarityQuery / CMCQuery / FormatQuery / PowerQuery / ToughnessQuery / ColorQuery / TypeQuery / OracleQuery / FieldQuery / GenericQuery +QueryPart <- NotQuery / SetQuery / RarityQuery / CMCQuery / FormatQuery / PowerQuery / ToughnessQuery / ColorQuery / TagQuery / TypeQuery / OracleQuery / FieldQuery / GenericQuery NotQuery <- ('NOT' ws/'-') SomewhatComplexQueryPart SetQuery <- ('e'/'set') SetExpression / ([:] FlexStringValue) @@ -38,6 +39,8 @@ Legality <- [Ll] 'egal'? / [Bb] 'anned'? / [Rr] 'estricted' TypeQuery <- [tT] 'ype'? [:] StringValue +TagQuery <- [tT]('ags'/'ag') [:] String + Color <- < [Ww] 'hite'? / [Uu] / [Bb] 'lack'? / [Rr] 'ed'? / [Gg] 'reen'? / [Bb] 'lue'? > ColorEx <- Color / [mc] @@ -133,6 +136,19 @@ static void setupParserRules() const auto matcher = std::any_cast(sv[0]); return [=](const CardData &x) -> bool { return matcher(x->getCardType()); }; }; + search["TagQuery"] = [](const peg::SemanticValues &sv) -> Filter { + // Tags are stored space-separated, so matching whole tokens keeps + // `tags:ram` from matching `ramp`. Combine tags with AND: + // `tags:draw tags:ramp`. + const auto tag = std::any_cast(sv[0]).trimmed(); + return [=](const CardData &x) -> bool { + const QString stored = x->getProperty(Mtg::Tags); + if (stored.isEmpty() || tag.isEmpty()) { + return false; + } + return stored.split(" ", Qt::SkipEmptyParts).contains(tag, Qt::CaseInsensitive); + }; + }; search["SetQuery"] = [](const peg::SemanticValues &sv) -> Filter { if (sv.choice() == 1) { auto matcher = std::any_cast(sv[0]); diff --git a/tests/carddatabase/filter_string_test.cpp b/tests/carddatabase/filter_string_test.cpp index 2b92c65df..400b67caa 100644 --- a/tests/carddatabase/filter_string_test.cpp +++ b/tests/carddatabase/filter_string_test.cpp @@ -124,6 +124,47 @@ TEST_F(CardQuery, SearchLanguageIsBoundPerInstance) ASSERT_TRUE(germanQuery.check(localized)); } +CardInfoPtr taggedCard() +{ + return CardInfo::newInstance("Tagged Card", "text", false, {{"tags", "ramp removal"}}, {}, {}, {}, {}); +} + +TEST_F(CardQuery, TagsMatchWholeSlugs) +{ + const CardData tagged = taggedCard(); + ASSERT_TRUE(FilterString("tags:ramp").check(tagged)); + ASSERT_TRUE(FilterString("tags:removal").check(tagged)); + ASSERT_TRUE(FilterString("tags:RAMP").check(tagged)); + ASSERT_TRUE(FilterString("tag:ramp").check(tagged)); + ASSERT_FALSE(FilterString("tags:squirrel").check(tagged)); +} + +TEST_F(CardQuery, TagQueryDoesNotMatchPartialSlugs) +{ + const CardData tagged = taggedCard(); + ASSERT_FALSE(FilterString("tags:ram").check(tagged)); + ASSERT_FALSE(FilterString("tags:mov").check(tagged)); +} + +TEST_F(CardQuery, TagQueryCombinesWithAnd) +{ + const CardData tagged = taggedCard(); + ASSERT_TRUE(FilterString("tags:ramp tags:removal").check(tagged)); + ASSERT_FALSE(FilterString("tags:ramp tags:squirrel").check(tagged)); +} + +TEST_F(CardQuery, TagQueryTreatsCommasAsPartOfTheSlug) +{ + // Tag lists are not a thing: `tags:draw` and `tags:ramp` are separate terms. + const CardData tagged = taggedCard(); + ASSERT_FALSE(FilterString("tags:ramp,removal").check(tagged)); +} + +TEST_F(CardQuery, TagQueryFalseWhenCardHasNoTags) +{ + ASSERT_FALSE(FilterString("tags:ramp").check(cat)); +} + } // namespace int main(int argc, char **argv)