From 868a06eaf9a317bd61a9ceb530eb3a29afb68a12 Mon Sep 17 00:00:00 2001
From: jcorporation <mail@jcgames.de>
Date: Tue, 27 Sep 2022 19:45:15 +0200
Subject: [PATCH] Add starts_with to filter expressions

---
 doc/protocol.rst           |  2 ++
 src/lib/icu/Compare.cxx    | 29 +++++++++++++++++++++++++++++
 src/lib/icu/Compare.hxx    |  3 +++
 src/song/Filter.cxx        | 24 +++++++++++++++++++-----
 src/song/StringFilter.cxx  |  8 ++++++--
 src/song/StringFilter.hxx  | 13 ++++++++++---
 test/TestTagSongFilter.cxx | 32 +++++++++++++++++++++++---------
 7 files changed, 92 insertions(+), 19 deletions(-)

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<TagSongFilter>(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<UniqueRegex>(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<UriSongFilter>(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<typename V>
-	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<V>(_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")));