diff --git a/doc/protocol.rst b/doc/protocol.rst index 70d953f1c..a39e35893 100644 --- a/doc/protocol.rst +++ b/doc/protocol.rst @@ -196,6 +196,8 @@ of: - ``(TAG contains 'VALUE')`` checks if the given value is a substring of the tag value. +- ``(TAG starts_with 'VALUE')`` checks if the tag value starts with the given value. + - ``(TAG =~ 'VALUE')`` and ``(TAG !~ 'VALUE')`` use a Perl-compatible regular expression instead of doing a simple string comparison. (This feature is only available if :program:`MPD` was compiled with diff --git a/src/lib/icu/Compare.cxx b/src/lib/icu/Compare.cxx index 2e1f08524..7c6fb7d1d 100644 --- a/src/lib/icu/Compare.cxx +++ b/src/lib/icu/Compare.cxx @@ -112,3 +112,32 @@ IcuCompare::IsIn(const char *haystack) const noexcept return false; #endif } + +bool +IcuCompare::StartsWith(const char *haystack) const noexcept +{ +#ifdef HAVE_ICU_CASE_FOLD + return StringIsEqual(IcuCaseFold(haystack).c_str(), + needle.c_str(), strlen(needle.c_str())); +#elif defined(_WIN32) + if (needle == nullptr) + /* the MultiByteToWideChar() call in the constructor + has failed, so let's always fail the comparison */ + return false; + + try { + auto w_haystack = MultiByteToWideChar(CP_UTF8, haystack); + return FindNLSStringEx(LOCALE_NAME_INVARIANT, + FIND_STARTSWITH|NORM_IGNORECASE, + w_haystack.c_str(), -1, + needle.c_str(), -1, + nullptr, + nullptr, nullptr, 0) == 0; + } catch (...) { + /* MultiByteToWideChar() has failed */ + return false; + } +#else + return strncmp(haystack, needle.c_str(), strlen(needle.c_str())); +#endif +} diff --git a/src/lib/icu/Compare.hxx b/src/lib/icu/Compare.hxx index 1d5afdbbc..6ab39d7ce 100644 --- a/src/lib/icu/Compare.hxx +++ b/src/lib/icu/Compare.hxx @@ -72,6 +72,9 @@ public: [[gnu::pure]] bool IsIn(const char *haystack) const noexcept; + + [[gnu::pure]] + bool StartsWith(const char *haystack) const noexcept; }; #endif diff --git a/src/song/Filter.cxx b/src/song/Filter.cxx index 25cc5b046..d3266d681 100644 --- a/src/song/Filter.cxx +++ b/src/song/Filter.cxx @@ -91,7 +91,7 @@ SongFilter::SongFilter(TagType tag, const char *value, bool fold_case) /* for compatibility with MPD 0.20 and older, "fold_case" also switches on "substring" */ and_filter.AddItem(std::make_unique(tag, - StringFilter(value, fold_case, fold_case, false))); + StringFilter(value, fold_case, fold_case, false, false))); } /* this destructor exists here just so it won't get inlined */ @@ -209,13 +209,25 @@ ParseStringFilter(const char *&s, bool fold_case) if (auto after_contains = StringAfterPrefixIgnoreCase(s, "contains ")) { s = StripLeft(after_contains); auto value = ExpectQuoted(s); - return {std::move(value), fold_case, true, false}; + return {std::move(value), fold_case, true, false, false}; } if (auto after_not_contains = StringAfterPrefixIgnoreCase(s, "!contains ")) { s = StripLeft(after_not_contains); auto value = ExpectQuoted(s); - return {std::move(value), fold_case, true, true}; + return {std::move(value), fold_case, true, false, true}; + } + + if (auto after_starts_with = StringAfterPrefixIgnoreCase(s, "starts_with ")) { + s = StripLeft(after_starts_with); + auto value = ExpectQuoted(s); + return {std::move(value), fold_case, false, true, false}; + } + + if (auto after_not_starts_with = StringAfterPrefixIgnoreCase(s, "!starts_with ")) { + s = StripLeft(after_not_starts_with); + auto value = ExpectQuoted(s); + return {std::move(value), fold_case, false, true, true}; } bool negated = false; @@ -225,7 +237,7 @@ ParseStringFilter(const char *&s, bool fold_case) negated = s[0] == '!'; s = StripLeft(s + 2); auto value = ExpectQuoted(s); - StringFilter f(std::move(value), fold_case, false, negated); + StringFilter f(std::move(value), fold_case, false, false, negated); f.SetRegex(std::make_shared(f.GetValue().c_str(), false, false, fold_case)); @@ -241,7 +253,7 @@ ParseStringFilter(const char *&s, bool fold_case) s = StripLeft(s + 2); auto value = ExpectQuoted(s); - return {std::move(value), fold_case, false, negated}; + return {std::move(value), fold_case, false, false, negated}; } ISongFilterPtr @@ -390,6 +402,7 @@ SongFilter::Parse(const char *tag_string, const char *value, bool fold_case) and_filter.AddItem(std::make_unique(StringFilter(value, fold_case, fold_case, + false, false))); break; @@ -403,6 +416,7 @@ SongFilter::Parse(const char *tag_string, const char *value, bool fold_case) StringFilter(value, fold_case, fold_case, + false, false))); break; } diff --git a/src/song/StringFilter.cxx b/src/song/StringFilter.cxx index bd755fd99..301466b92 100644 --- a/src/song/StringFilter.cxx +++ b/src/song/StringFilter.cxx @@ -35,11 +35,15 @@ StringFilter::MatchWithoutNegation(const char *s) const noexcept if (fold_case) { return substring ? fold_case.IsIn(s) - : fold_case == s; + : (starts_with + ? fold_case.StartsWith(s) + : fold_case == s); } else { return substring ? StringFind(s, value.c_str()) != nullptr - : value == s; + : (starts_with + ? StringIsEqual(s, value.c_str(), value.length()) + : value == s); } } diff --git a/src/song/StringFilter.hxx b/src/song/StringFilter.hxx index b8ff38685..eceee242f 100644 --- a/src/song/StringFilter.hxx +++ b/src/song/StringFilter.hxx @@ -47,16 +47,21 @@ class StringFilter { */ bool substring; + /** + * Search for substrings instead of matching the whole string? + */ + bool starts_with; + bool negated; public: template - StringFilter(V &&_value, bool _fold_case, bool _substring, bool _negated) + StringFilter(V &&_value, bool _fold_case, bool _substring, bool _starts_with, bool _negated) :value(std::forward(_value)), fold_case(_fold_case ? IcuCompare(value) : IcuCompare()), - substring(_substring), negated(_negated) {} + substring(_substring), starts_with(_starts_with), negated(_negated) {} bool empty() const noexcept { return value.empty(); @@ -98,7 +103,9 @@ public: ? (negated ? "!~" : "=~") : (substring ? (negated ? "!contains" : "contains") - : (negated ? "!=" : "==")); + : (starts_with + ? (negated ? "!starts_with" : "starts_with") + : (negated ? "!=" : "=="))); } [[gnu::pure]] diff --git a/test/TestTagSongFilter.cxx b/test/TestTagSongFilter.cxx index 478c056dd..dda46e0c7 100644 --- a/test/TestTagSongFilter.cxx +++ b/test/TestTagSongFilter.cxx @@ -33,7 +33,7 @@ InvokeFilter(const TagSongFilter &f, const Tag &tag) noexcept TEST(TagSongFilter, Basic) { const TagSongFilter f(TAG_TITLE, - StringFilter("needle", false, false, false)); + StringFilter("needle", false, false, false, false)); EXPECT_TRUE(InvokeFilter(f, MakeTag(TAG_TITLE, "needle"))); EXPECT_TRUE(InvokeFilter(f, MakeTag(TAG_TITLE, "foo", TAG_TITLE, "needle"))); @@ -54,7 +54,7 @@ TEST(TagSongFilter, Basic) TEST(TagSongFilter, Empty) { const TagSongFilter f(TAG_TITLE, - StringFilter("", false, false, false)); + StringFilter("", false, false, false, false)); EXPECT_TRUE(InvokeFilter(f, MakeTag())); @@ -65,7 +65,7 @@ TEST(TagSongFilter, Empty) TEST(TagSongFilter, Substring) { const TagSongFilter f(TAG_TITLE, - StringFilter("needle", false, true, false)); + StringFilter("needle", false, true, false, false)); EXPECT_TRUE(InvokeFilter(f, MakeTag(TAG_TITLE, "needle"))); EXPECT_TRUE(InvokeFilter(f, MakeTag(TAG_TITLE, "needleBAR"))); @@ -76,10 +76,24 @@ TEST(TagSongFilter, Substring) EXPECT_FALSE(InvokeFilter(f, MakeTag(TAG_TITLE, "eedle"))); } +TEST(TagSongFilter, Startswith) +{ + const TagSongFilter f(TAG_TITLE, + StringFilter("needle", false, false, true, false)); + + EXPECT_TRUE(InvokeFilter(f, MakeTag(TAG_TITLE, "needle"))); + EXPECT_TRUE(InvokeFilter(f, MakeTag(TAG_TITLE, "needleBAR"))); + EXPECT_FALSE(InvokeFilter(f, MakeTag(TAG_TITLE, "FOOneedle"))); + EXPECT_FALSE(InvokeFilter(f, MakeTag(TAG_TITLE, "FOOneedleBAR"))); + + EXPECT_FALSE(InvokeFilter(f, MakeTag())); + EXPECT_FALSE(InvokeFilter(f, MakeTag(TAG_TITLE, "eedle"))); +} + TEST(TagSongFilter, Negated) { const TagSongFilter f(TAG_TITLE, - StringFilter("needle", false, false, true)); + StringFilter("needle", false, false, false, true)); EXPECT_TRUE(InvokeFilter(f, MakeTag())); EXPECT_FALSE(InvokeFilter(f, MakeTag(TAG_TITLE, "needle"))); @@ -92,7 +106,7 @@ TEST(TagSongFilter, Negated) TEST(TagSongFilter, EmptyNegated) { const TagSongFilter f(TAG_TITLE, - StringFilter("", false, false, true)); + StringFilter("", false, false, false, true)); EXPECT_FALSE(InvokeFilter(f, MakeTag())); EXPECT_TRUE(InvokeFilter(f, MakeTag(TAG_TITLE, "foo"))); @@ -104,7 +118,7 @@ TEST(TagSongFilter, EmptyNegated) TEST(TagSongFilter, MultiNegated) { const TagSongFilter f(TAG_TITLE, - StringFilter("needle", false, false, true)); + StringFilter("needle", false, false, false, true)); EXPECT_TRUE(InvokeFilter(f, MakeTag(TAG_TITLE, "foo", TAG_TITLE, "bar"))); EXPECT_FALSE(InvokeFilter(f, MakeTag(TAG_TITLE, "needle", TAG_TITLE, "bar"))); @@ -118,7 +132,7 @@ TEST(TagSongFilter, MultiNegated) TEST(TagSongFilter, Fallback) { const TagSongFilter f(TAG_ALBUM_ARTIST, - StringFilter("needle", false, false, false)); + StringFilter("needle", false, false, false, false)); EXPECT_TRUE(InvokeFilter(f, MakeTag(TAG_ALBUM_ARTIST, "needle"))); EXPECT_TRUE(InvokeFilter(f, MakeTag(TAG_ARTIST, "needle"))); @@ -138,7 +152,7 @@ TEST(TagSongFilter, Fallback) TEST(TagSongFilter, EmptyFallback) { const TagSongFilter f(TAG_ALBUM_ARTIST, - StringFilter("", false, false, false)); + StringFilter("", false, false, false, false)); EXPECT_TRUE(InvokeFilter(f, MakeTag())); @@ -152,7 +166,7 @@ TEST(TagSongFilter, EmptyFallback) TEST(TagSongFilter, NegatedFallback) { const TagSongFilter f(TAG_ALBUM_ARTIST, - StringFilter("needle", false, false, true)); + StringFilter("needle", false, false, false, true)); EXPECT_TRUE(InvokeFilter(f, MakeTag())); EXPECT_TRUE(InvokeFilter(f, MakeTag(TAG_ALBUM_ARTIST, "foo")));