I have a requirement which is relatively obscure, but it feels like it should be possible using the BCL.
For context, I\'m parsing a date/time string in Nod
This is actually possible without normalization and without using IsPrefix
.
We need to compare the same number of text elements as opposed to the same number of characters, but still return the number of matching characters.
I've created a copy of the MatchCaseInsensitive
method from ValueCursor.cs in Noda Time and modified it slightly so that it can be used in a static context:
// Noda time code from MatchCaseInsensitive in ValueCursor.cs
static int IsMatch_Original(string source, int index, string match, CompareInfo compareInfo)
{
unchecked
{
if (match.Length > source.Length - index)
{
return 0;
}
// TODO(V1.2): This will fail if the length in the input string is different to the length in the
// match string for culture-specific reasons. It's not clear how to handle that...
if (compareInfo.Compare(source, index, match.Length, match, 0, match.Length, CompareOptions.IgnoreCase) == 0)
{
return match.Length;
}
return 0;
}
}
(Just included for reference, it is the code that won't compare properly as you know)
The following variant of that method uses StringInfo.GetNextTextElement which is provided by the framework. The idea is to compare text element by text element to find a match and if found return the actual number of matching characters in the source string:
// Using StringInfo.GetNextTextElement to match by text elements instead of characters
static int IsMatch_New(string source, int index, string match, CompareInfo compareInfo)
{
int sourceIndex = index;
int matchIndex = 0;
// Loop until we reach the end of source or match
while (sourceIndex < source.Length && matchIndex < match.Length)
{
// Get text elements at the current positions of source and match
// Normally that will be just one character but may be more in case of Unicode combining characters
string sourceElem = StringInfo.GetNextTextElement(source, sourceIndex);
string matchElem = StringInfo.GetNextTextElement(match, matchIndex);
// Compare the current elements.
if (compareInfo.Compare(sourceElem, matchElem, CompareOptions.IgnoreCase) != 0)
{
return 0; // No match
}
// Advance in source and match (by number of characters)
sourceIndex += sourceElem.Length;
matchIndex += matchElem.Length;
}
// Check if we reached end of source and not end of match
if (matchIndex != match.Length)
{
return 0; // No match
}
// Found match. Return number of matching characters from source.
return sourceIndex - index;
}
That method works just fine at least according to my test cases (which basically just test a couple of variants of the strings you've provided: "b\u00e9d"
and "be\u0301d"
).
However, the GetNextTextElement method creates a substring for each text element so this implementation requires alot of substring comparisons - which will have an impact on performance.
So, I created another variant that does not use GetNextTextElement but instead skips over Unicode combining characters to find the actual match length in characters:
// This should be faster
static int IsMatch_Faster(string source, int index, string match, CompareInfo compareInfo)
{
int sourceLength = source.Length;
int matchLength = match.Length;
int sourceIndex = index;
int matchIndex = 0;
// Loop until we reach the end of source or match
while (sourceIndex < sourceLength && matchIndex < matchLength)
{
sourceIndex += GetTextElemLen(source, sourceIndex, sourceLength);
matchIndex += GetTextElemLen(match, matchIndex, matchLength);
}
// Check if we reached end of source and not end of match
if (matchIndex != matchLength)
{
return 0; // No match
}
// Check if we've found a match
if (compareInfo.Compare(source, index, sourceIndex - index, match, 0, matchIndex, CompareOptions.IgnoreCase) != 0)
{
return 0; // No match
}
// Found match. Return number of matching characters from source.
return sourceIndex - index;
}
That method uses the following two helpers:
static int GetTextElemLen(string str, int index, int strLen)
{
bool stop = false;
int elemLen;
for (elemLen = 0; index < strLen && !stop; ++elemLen, ++index)
{
stop = !IsCombiningCharacter(str, index);
}
return elemLen;
}
static bool IsCombiningCharacter(string str, int index)
{
switch (CharUnicodeInfo.GetUnicodeCategory(str, index))
{
case UnicodeCategory.NonSpacingMark:
case UnicodeCategory.SpacingCombiningMark:
case UnicodeCategory.EnclosingMark:
return true;
default:
return false;
}
}
I haven't done any bench marking, so I don't really know whether the faster method is actually faster. Nor have I done any extended testing.
But this should answer your question on how to perform cultural sensitive substring matching for strings that may include Unicode combining characters.
These are the test cases I've used:
static Tuple[] tests = new []
{
Tuple.Create("x b\u00e9d y", 2, "be\u0301d", 3),
Tuple.Create("x be\u0301d y", 2, "b\u00e9d", 4),
Tuple.Create("x b\u00e9d", 2, "be\u0301d", 3),
Tuple.Create("x be\u0301d", 2, "b\u00e9d", 4),
Tuple.Create("b\u00e9d y", 0, "be\u0301d", 3),
Tuple.Create("be\u0301d y", 0, "b\u00e9d", 4),
Tuple.Create("b\u00e9d", 0, "be\u0301d", 3),
Tuple.Create("be\u0301d", 0, "b\u00e9d", 4),
Tuple.Create("b\u00e9", 0, "be\u0301d", 0),
Tuple.Create("be\u0301", 0, "b\u00e9d", 0),
};
The tuple values are:
Running those tests on the three methods yields this result:
Test #0: Orignal=BAD; New=OK; Faster=OK
Test #1: Orignal=BAD; New=OK; Faster=OK
Test #2: Orignal=BAD; New=OK; Faster=OK
Test #3: Orignal=BAD; New=OK; Faster=OK
Test #4: Orignal=BAD; New=OK; Faster=OK
Test #5: Orignal=BAD; New=OK; Faster=OK
Test #6: Orignal=BAD; New=OK; Faster=OK
Test #7: Orignal=BAD; New=OK; Faster=OK
Test #8: Orignal=OK; New=OK; Faster=OK
Test #9: Orignal=OK; New=OK; Faster=OK
The last two tests are testing the case when the source string is shorter than the match string. In this case the original (Noda time) method will succeed as well.