diff --git a/src/Ramstack.Parsing/Parser.String.cs b/src/Ramstack.Parsing/Parser.String.cs
index 9aa105a..5f51f44 100644
--- a/src/Ramstack.Parsing/Parser.String.cs
+++ b/src/Ramstack.Parsing/Parser.String.cs
@@ -11,14 +11,37 @@ partial class Parser
///
/// A parser that attempts to match the specified literal using the provided comparison type.
///
+ ///
+ /// Comparisons that use the current culture capture it when the parser is created.
+ /// Later changes to the current culture do not affect the parser or its named and void variants.
+ /// Culture-aware matches consume the matched source segment, whose length may differ from
+ /// the literal length, including zero for literals with no collation weight.
+ ///
public static Parser L(string literal, StringComparison comparison = StringComparison.Ordinal)
{
Argument.ThrowIfNullOrEmpty(literal);
var expected = literal.ToPrintable();
- return comparison == StringComparison.Ordinal
- ? new OrdinalStringParser(literal) { Name = expected }
- : new StringParser(literal, comparison) { Name = expected };
+ if (comparison == StringComparison.Ordinal)
+ return new OrdinalStringParser(literal) { Name = expected };
+
+ if (comparison == StringComparison.OrdinalIgnoreCase)
+ return new OrdinalStringParser(literal) { Name = expected };
+
+ var culture = comparison switch
+ {
+ StringComparison.CurrentCulture or
+ StringComparison.CurrentCultureIgnoreCase => CultureInfo.CurrentCulture,
+ _ => CultureInfo.InvariantCulture
+ };
+
+ var options = comparison switch
+ {
+ StringComparison.CurrentCulture or StringComparison.InvariantCulture => CompareOptions.None,
+ _ => CompareOptions.IgnoreCase,
+ };
+
+ return new StringParser(literal, culture.CompareInfo, options) { Name = expected };
}
///
@@ -72,36 +95,69 @@ public static Parser OneOf(string literal1, string literal2, string lite
///
/// A parser that parses one of the specified literals using the provided comparison type.
///
+ ///
+ /// When multiple literals match the beginning of the source, the parser returns the one that comes
+ /// first when the literals are sorted in descending order using the specified comparison.
+ /// For ordinal comparisons, this means the longest matching literal.
+ /// Ties are resolved by the original order of the literals.
+ /// Comparisons that use the current culture capture it when the parser is created,
+ /// using the same culture for ordering, deduplication and matching.
+ /// Later changes to the current culture do not affect the parser or its named and void variants.
+ /// Culture-aware matches consume the matched source segment, whose length may differ from
+ /// the literal length, including zero for literals with no collation weight.
+ ///
public static Parser OneOf(string[] literals, StringComparison comparison = StringComparison.Ordinal)
{
Argument.ThrowIfNullOrEmpty(literals);
+ for (var i = 0; i < literals.Length; i++)
+ Argument.ThrowIfNullOrEmpty(literals[i], $"{nameof(literals)}[{i}]");
+
if (literals.Length == 1)
return L(literals[0], comparison);
var expected = literals.Select(l => l.ToPrintable()).ToArray();
- var comparer = StringComparer.FromComparison(comparison);
-
- var dictionary = new CharMap(
- Permute(literals, comparison)
- .GroupBy(l => l.Key)
- .ToDictionary(
- g => g.Key,
- g => g
- .Select(p => p.Value)
- .OrderDescending(comparer)
- .Distinct(comparer)
- .ToArray()));
-
- return comparison switch
+
+ if (comparison is StringComparison.Ordinal or StringComparison.OrdinalIgnoreCase)
{
- StringComparison.CurrentCulture => new StringDictionaryParser(in dictionary, CultureInfo.CurrentCulture, CompareOptions.None, expected),
- StringComparison.CurrentCultureIgnoreCase => new StringDictionaryParser(in dictionary, CultureInfo.CurrentCulture, CompareOptions.IgnoreCase, expected),
- StringComparison.InvariantCulture => new StringDictionaryParser(in dictionary, CultureInfo.InvariantCulture, CompareOptions.None, expected),
- StringComparison.InvariantCultureIgnoreCase => new StringDictionaryParser(in dictionary, CultureInfo.InvariantCulture, CompareOptions.IgnoreCase, expected),
- StringComparison.Ordinal => new OrdinalStringDictionaryParser(in dictionary, expected),
- _ => new OrdinalStringDictionaryParser(in dictionary, expected)
+ var ordinalComparer = StringComparer.FromComparison(comparison);
+ var ignoreCase = comparison == StringComparison.OrdinalIgnoreCase;
+ var dictionary = new CharMap(
+ literals
+ .GroupBy(l => GetBucketKey(l[0], ignoreCase))
+ .ToDictionary(
+ g => g.Key,
+ g => g
+ .Distinct(ordinalComparer)
+ .OrderDescending(ordinalComparer)
+ .ToArray()));
+
+ return ignoreCase
+ ? new OrdinalStringDictionaryParser(dictionary, expected)
+ : new OrdinalStringDictionaryParser(dictionary, expected);
+ }
+
+ var culture = comparison switch
+ {
+ StringComparison.CurrentCulture or StringComparison.CurrentCultureIgnoreCase => CultureInfo.CurrentCulture,
+ StringComparison.InvariantCulture or StringComparison.InvariantCultureIgnoreCase => CultureInfo.InvariantCulture,
+ _ => throw new ArgumentOutOfRangeException(nameof(comparison))
+ };
+
+ var options = comparison switch
+ {
+ StringComparison.CurrentCultureIgnoreCase or
+ StringComparison.InvariantCultureIgnoreCase => CompareOptions.IgnoreCase,
+ _ => CompareOptions.None
};
+
+ var comparer = StringComparer.Create(culture, options);
+ var candidates = literals
+ .Distinct(comparer)
+ .OrderDescending(comparer)
+ .ToArray();
+
+ return new CultureStringParser(candidates, culture.CompareInfo, options, expected);
}
#region Inner type: OrdinalStringParser
@@ -111,12 +167,13 @@ public static Parser OneOf(string[] literals, StringComparison compariso
/// This parser is specialized for performance with ordinal comparison.
///
/// The type of the value produced by the parser.
- private sealed class OrdinalStringParser : Parser
+ /// The compare options used for matching strings.
+ private sealed class OrdinalStringParser : Parser
{
private readonly string _literal;
///
- /// Initializes a new instance of the class.
+ /// Initializes a new instance of the class.
///
/// The string to match.
public OrdinalStringParser(string literal) =>
@@ -125,14 +182,23 @@ public OrdinalStringParser(string literal) =>
///
public override bool TryParse(ref ParseContext context, [NotNullWhen(true)] out T? value)
{
- if (context.Remaining.StartsWith(_literal))
+ var literal = _literal;
+ // Null-check trick: prove to the JIT that literal is non-null so the
+ // string -> ReadOnlySpan conversion stays branch-free.
+ _ = literal.Length;
+
+ var result = typeof(TCompareOptions) == typeof(CompareOptionsIgnoreCase)
+ ? context.Remaining.StartsWith(literal.AsSpan(), StringComparison.OrdinalIgnoreCase)
+ : context.Remaining.StartsWith(literal.AsSpan());
+
+ if (result)
{
if (typeof(T) != typeof(Unit))
- value = ((T)(object)_literal)!;
+ value = (T)(object)literal;
else
value = default!;
- context.Advance(_literal.Length);
+ context.Advance(literal.Length);
return true;
}
@@ -144,11 +210,11 @@ public override bool TryParse(ref ParseContext context, [NotNullWhen(true)] out
///
protected internal override Parser ToNamedParser(string? name) =>
- new OrdinalStringParser(_literal) { Name = name };
+ new OrdinalStringParser(_literal) { Name = name };
///
protected internal override Parser ToVoidParser() =>
- new OrdinalStringParser(_literal) { Name = Name };
+ new OrdinalStringParser(_literal) { Name = Name };
}
#endregion
@@ -162,27 +228,29 @@ protected internal override Parser ToVoidParser() =>
private sealed class StringParser : Parser
{
private readonly string _literal;
- private readonly StringComparison _comparison;
+ private readonly CompareInfo _compareInfo;
+ private readonly CompareOptions _compareOptions;
///
/// Initializes a new instance of the class.
///
/// The string to match.
- /// The string comparison type to use for matching the literal.
- public StringParser(string literal, StringComparison comparison) =>
- (_literal, _comparison) = (literal, comparison);
+ /// The comparison information captured when the parser is created.
+ /// The comparison options to use for matching the literal.
+ public StringParser(string literal, CompareInfo compareInfo, CompareOptions compareOptions) =>
+ (_literal, _compareInfo, _compareOptions) = (literal, compareInfo, compareOptions);
///
public override bool TryParse(ref ParseContext context, [NotNullWhen(true)] out T? value)
{
- if (context.Remaining.StartsWith(_literal, _comparison))
+ if (_compareInfo.IsPrefix(context.Remaining, _literal, _compareOptions, out var length))
{
if (typeof(T) != typeof(Unit))
- value = ((T)(object)_literal)!;
+ value = (T)(object)_literal;
else
value = default!;
- context.Advance(_literal.Length);
+ context.Advance(length);
return true;
}
@@ -193,11 +261,11 @@ public override bool TryParse(ref ParseContext context, [NotNullWhen(true)] out
///
protected internal override Parser ToNamedParser(string? name) =>
- new StringParser(_literal, _comparison) { Name = name };
+ new StringParser(_literal, _compareInfo, _compareOptions) { Name = name };
///
protected internal override Parser ToVoidParser() =>
- new StringParser(_literal, _comparison) { Name = Name };
+ new StringParser(_literal, _compareInfo, _compareOptions) { Name = Name };
}
#endregion
@@ -219,7 +287,7 @@ private sealed class OrdinalStringDictionaryParser : Parser<
///
/// A dictionary mapping initial characters to arrays of string literals expected at the beginning of the source.
/// An array of error messages describing expected literals, used when parsing fails.
- public OrdinalStringDictionaryParser(in CharMap literals, string[] expected)
+ public OrdinalStringDictionaryParser(CharMap literals, string[] expected)
{
_literals = literals;
_expected = expected;
@@ -229,18 +297,19 @@ public OrdinalStringDictionaryParser(in CharMap literals, string[] expected)
public override bool TryParse(ref ParseContext context, [NotNullWhen(true)] out T? value)
{
var s = context.Remaining;
-
- if (s.Length != 0 && _literals[s[0]] is {} literals)
+ if (s.Length != 0)
{
- foreach (var literal in literals)
+ var literals = typeof(TCompareOptions) == typeof(CompareOptionsIgnoreCase)
+ ? _literals[GetBucketKey(s[0], ignoreCase: true)]
+ : _literals[s[0]];
+
+ foreach (var literal in literals ?? [])
{
_ = literal.Length;
- bool result;
- if (typeof(TCompareOptions) == typeof(CompareOptionsIgnoreCase))
- result = s.StartsWith(literal, StringComparison.OrdinalIgnoreCase);
- else
- result = s.StartsWith(literal);
+ var result = typeof(TCompareOptions) == typeof(CompareOptionsIgnoreCase)
+ ? s.StartsWith(literal.AsSpan(), StringComparison.OrdinalIgnoreCase)
+ : s.StartsWith(literal.AsSpan());
if (result)
{
@@ -271,43 +340,41 @@ public override bool TryParse(ref ParseContext context, [NotNullWhen(true)] out
///
protected internal override Parser ToNamedParser(string? name) =>
- new OrdinalStringDictionaryParser(in _literals, _expected) { Name = name };
+ new OrdinalStringDictionaryParser(_literals, _expected) { Name = name };
///
protected internal override Parser ToVoidParser() =>
- new OrdinalStringDictionaryParser(in _literals, _expected) { Name = Name };
+ new OrdinalStringDictionaryParser(_literals, _expected) { Name = Name };
}
#endregion
- #region Inner type: StringDictionaryParser
+ #region Inner type: CultureStringParser
///
- /// Represents a parser that attempts to match one of the specified literals based on specific cultural and comparison options.
+ /// Represents a parser that attempts to match one of the specified literals using culture-specific string comparison.
///
/// The type of the value produced by the parser.
- private sealed class StringDictionaryParser : Parser
+ private sealed class CultureStringParser : Parser
{
- private readonly CharMap _literals;
- private readonly CultureInfo _culture;
- private readonly CompareInfo _comparer;
+ private readonly string[] _literals;
+ private readonly CompareInfo _compareInfo;
+ private readonly CompareOptions _compareOptions;
private readonly string[] _expected;
- private readonly CompareOptions _options;
///
- /// Initializes a new instance of the class.
+ /// Initializes a new instance of the class.
///
- /// A dictionary mapping initial characters to arrays of string literals expected at the beginning of the source.
- /// The culture to use for comparisons, which affects how strings are compared and ordered.
- /// An optional combination of enumeration values to use during the match.
+ /// An array of literals sorted in descending order using the captured comparison.
+ /// The comparison information captured when the parser is created.
+ /// An optional combination of enumeration values to use during the match.
/// An array of error messages describing expected literals, used when parsing fails.
- public StringDictionaryParser(in CharMap literals, CultureInfo culture, CompareOptions options, string[] expected)
+ public CultureStringParser(string[] literals, CompareInfo compareInfo, CompareOptions compareOptions, string[] expected)
{
_literals = literals;
- _culture = culture;
- _comparer = culture.CompareInfo;
+ _compareInfo = compareInfo;
+ _compareOptions = compareOptions;
_expected = expected;
- _options = options;
}
///
@@ -315,22 +382,19 @@ public override bool TryParse(ref ParseContext context, [NotNullWhen(true)] out
{
var s = context.Remaining;
- if (s.Length != 0 && _literals[s[0]] is {} literals)
+ foreach (var literal in _literals)
{
- foreach (var literal in literals)
- {
- _ = literal.Length;
+ _ = literal.Length;
- if (_comparer.IsPrefix(s, literal, _options))
- {
- if (typeof(T) != typeof(Unit))
- value = (T)(object)literal;
- else
- value = default!;
+ if (_compareInfo.IsPrefix(s, literal, _compareOptions, out var length))
+ {
+ if (typeof(T) != typeof(Unit))
+ value = (T)(object)literal;
+ else
+ value = default!;
- context.Advance(literal.Length);
- return true;
- }
+ context.Advance(length);
+ return true;
}
}
@@ -350,31 +414,25 @@ public override bool TryParse(ref ParseContext context, [NotNullWhen(true)] out
///
protected internal override Parser ToNamedParser(string? name) =>
- new StringDictionaryParser(in _literals, _culture, _options, _expected) { Name = name };
+ new CultureStringParser(_literals, _compareInfo, _compareOptions, _expected) { Name = name };
///
protected internal override Parser ToVoidParser() =>
- new StringDictionaryParser(in _literals, _culture, _options, _expected) { Name = Name };
+ new CultureStringParser(_literals, _compareInfo, _compareOptions, _expected) { Name = Name };
}
#endregion
- private static IEnumerable<(char Key, string Value)> Permute(string[] literals, StringComparison comparison)
+ private static char GetBucketKey(char c, bool ignoreCase)
{
- var list = literals.Select(s => (Key: s[0], Value: s)).ToArray();
-
- if (comparison is StringComparison.Ordinal
- or StringComparison.InvariantCulture
- or StringComparison.CurrentCulture)
- return list.Distinct();
-
- var ci = comparison == StringComparison.CurrentCultureIgnoreCase
- ? CultureInfo.CurrentCulture
- : CultureInfo.InvariantCulture;
-
- return list
- .Concat(list.Select(s => s with { Key = ci.TextInfo.ToUpper(s.Key) }))
- .Concat(list.Select(s => s with { Key = ci.TextInfo.ToLower(s.Key) }));
+ if (!ignoreCase)
+ return c;
+
+ // All high surrogates share a single bucket.
+ // char.ToUpperInvariant cannot fold supplementary characters,
+ // so this keeps both parts of a case pair in the same bucket
+ // even if their leading code units would not match otherwise.
+ return char.IsHighSurrogate(c) ? '\uD800' : char.ToUpperInvariant(c);
}
///
diff --git a/tests/Ramstack.Parsing.Tests/ParsersTests.String.cs b/tests/Ramstack.Parsing.Tests/ParsersTests.String.cs
index e2a78a8..52a1ee9 100644
--- a/tests/Ramstack.Parsing.Tests/ParsersTests.String.cs
+++ b/tests/Ramstack.Parsing.Tests/ParsersTests.String.cs
@@ -1,3 +1,5 @@
+using System.Globalization;
+
using static Ramstack.Parsing.Parser;
namespace Ramstack.Parsing;
@@ -106,7 +108,7 @@ public void OneOf_String()
: value;
var upper = ((int)comparison & 1) == 1
- ? value.ToLower()
+ ? value.ToUpper()
: value;
var parser = OneOf(values, comparison);
@@ -132,4 +134,416 @@ public void OneOf_String()
Is.EqualTo("(1:1) Expected 'Sun', 'Rain', 'Lighthouse', 'Starlight', 'Bookstore', 'Home', 'Seashore', '12345', or 'Практика'"));
}
}
+
+ [Test]
+ public void OneOf_UnicodeCaseVariants_AgreesWithStartsWith()
+ {
+ var cases = new (string Literal, string Input)[]
+ {
+ ("Μx", "µx"), // Greek capital Mu / micro sign | ("\u039Cx", "\u00B5x")
+ ("Μx", "μx"), // Greek capital Mu / small Mu | ("\u039Cx", "\u03BCx")
+ ("Σx", "ςx"), // Greek capital Sigma / final Sigma
+ ("Σx", "σx"), // Greek capital Sigma / small Sigma
+ ("Вx", "вx"), // Cyrillic capital Ve / small Ve
+ ("Вx", "ᲀx"), // Cyrillic capital Ve / historic letter
+ ("Ιx", "ιx"), // Greek capital Iota / small Iota
+ ("Ιx", "\u0345x"),
+ ("Ιx", "\u1FBEx")
+ };
+
+ var comparisons = new[]
+ {
+ StringComparison.Ordinal,
+ StringComparison.OrdinalIgnoreCase,
+ StringComparison.InvariantCulture,
+ StringComparison.InvariantCultureIgnoreCase
+ };
+
+ foreach (var comparison in comparisons)
+ {
+ foreach (var (literal, input) in cases)
+ {
+ var expected = input.StartsWith(literal, comparison);
+ var actual = OneOf([literal, "zz"], comparison).Parse(input).Success;
+
+ Assert.That(
+ actual,
+ Is.EqualTo(expected),
+ $"comparison={comparison}, literal={literal}, input={input}");
+ }
+ }
+
+ var result = OneOf(["Μx", "zz"], StringComparison.OrdinalIgnoreCase).Parse("µx");
+ Assert.That(result.Success, Is.True);
+ Assert.That(result.Value, Is.EqualTo("Μx"));
+ }
+
+ [Test]
+ public void OneOf_OrdinalIgnoreCase_DoesNotMatchLongSWithS()
+ {
+ // The ordinal comparer does not treat U+017F (long s) as equivalent to 's'.
+ Assert.That("sun".StartsWith("ſun", StringComparison.OrdinalIgnoreCase), Is.False);
+ Assert.That(OneOf(["sun", "moon"], StringComparison.OrdinalIgnoreCase).Parse("ſun").Success, Is.False);
+ }
+
+ [Test]
+ public void OneOf_OrdinalIgnoreCase_MatchesEquivalentBmpInitials()
+ {
+ var comparer = StringComparer.OrdinalIgnoreCase;
+ var hashes = new Dictionary>();
+
+ for (var i = 0; i <= char.MaxValue; i++)
+ {
+ var c = (char)i;
+
+ if (char.IsSurrogate(c))
+ continue;
+
+ var hash = comparer.GetHashCode(c.ToString());
+ if (!hashes.TryGetValue(hash, out var bucket))
+ {
+ bucket = [];
+ hashes[hash] = bucket;
+ }
+
+ bucket.Add(c);
+ }
+
+ var classes = new List>();
+ foreach (var bucket in hashes.Values)
+ {
+ var local = new List>();
+
+ foreach (var c in bucket)
+ {
+ List? cls = null;
+
+ foreach (var candidate in local)
+ {
+ if (comparer.Equals(candidate[0].ToString(), c.ToString()))
+ {
+ cls = candidate;
+ break;
+ }
+ }
+
+ if (cls is null)
+ local.Add(new List { c });
+ else
+ cls.Add(c);
+ }
+
+ classes.AddRange(local);
+ }
+
+ var checkedClasses = 0;
+ foreach (var cls in classes)
+ {
+ if (cls.Count < 2)
+ continue;
+
+ checkedClasses++;
+
+ // The bucket key must be identical for all characters
+ // considered equal by the ordinal ignore case comparer.
+ var key = char.ToUpperInvariant(cls[0]);
+ foreach (var c in cls)
+ {
+ Assert.That(
+ char.ToUpperInvariant(c),
+ Is.EqualTo(key),
+ $"Equivalence class of U+{(int)cls[0]:X4} is split by U+{(int)c:X4}");
+ }
+
+ var parser = OneOf(new[] { cls[0] + "x", "zz" }, StringComparison.OrdinalIgnoreCase);
+ foreach (var c in cls)
+ {
+ Assert.That(
+ parser.Parse(c + "x").Success,
+ Is.True,
+ $"U+{(int)c:X4} should match the literal starting with U+{(int)cls[0]:X4}");
+ }
+ }
+
+ Assert.That(checkedClasses, Is.GreaterThan(0));
+ }
+
+ [Test]
+ public void OneOf_IgnoreCase_MatchesDeseretCasePair()
+ {
+ var comparisons = new[]
+ {
+ StringComparison.OrdinalIgnoreCase,
+ StringComparison.InvariantCultureIgnoreCase
+ };
+
+ foreach (var comparison in comparisons)
+ {
+ // Deseret capital and small letters share the same high surrogate.
+ var value = "\U00010400x";
+ var input = "\U00010428x";
+
+ Assert.That(input.StartsWith(value, comparison), Is.True, $"comparison={comparison}");
+
+ var actual = OneOf([value, "zz"], comparison).Parse(input).Success;
+ Assert.That(actual, Is.True, $"comparison={comparison}");
+ }
+ }
+
+ [Test]
+ public void OneOf_String_CultureSpecificMatches()
+ {
+ var literals = new[] { "kelvin", "abc", "xyz", "\u200Babc" };
+
+ Assert.That(L("kelvin", StringComparison.InvariantCultureIgnoreCase).Parse("\u212Aelvin").Success, Is.True);
+ Assert.That(OneOf(literals, StringComparison.InvariantCultureIgnoreCase).Parse("\u212Aelvin").Success, Is.True);
+
+ Assert.That(L("abc", StringComparison.CurrentCulture).Parse("\u200Babc").Success, Is.True);
+ Assert.That(OneOf(literals, StringComparison.CurrentCulture).Parse("\u200Babc").Success, Is.True);
+
+ var comparisons = new[]
+ {
+ StringComparison.CurrentCulture,
+ StringComparison.CurrentCultureIgnoreCase,
+ StringComparison.InvariantCulture,
+ StringComparison.InvariantCultureIgnoreCase
+ };
+
+ foreach (var comparison in comparisons)
+ {
+ foreach (var input in new[] { "kelvin", "KELVIN", "\u212Aelvin", "abc", "\u200Babc", "\0abc", "\u200Babcx" })
+ {
+ var expected = literals.Any(l => input.StartsWith(l, comparison));
+ var actual = OneOf(literals, comparison).Parse(input).Success;
+
+ Assert.That(actual, Is.EqualTo(expected), $"comparison={comparison}, input={input}");
+ }
+
+ // A literal with zero collation weight matches the empty input.
+ var parsers = new[]
+ {
+ L("\u200B", comparison),
+ OneOf(["\u200B", "zz"], comparison)
+ };
+
+ foreach (var parser in parsers)
+ {
+ Assert.That(
+ parser.Parse("").Success,
+ Is.EqualTo("".StartsWith("\u200B", comparison)),
+ $"comparison={comparison}");
+ }
+ }
+ }
+
+ [TestCase(StringComparison.CurrentCultureIgnoreCase, "en-US", "tr-TR", "i", "I", true)]
+ [TestCase(StringComparison.CurrentCultureIgnoreCase, "tr-TR", "en-US", "i", "I", false)]
+ [TestCase(StringComparison.CurrentCulture, "en-US", "cs-CZ", "c", "ch", true)]
+ [TestCase(StringComparison.CurrentCulture, "cs-CZ", "en-US", "c", "ch", false)]
+ public void String_CurrentCultureIsCapturedAtConstruction(StringComparison comparison, string creationCulture, string parseCulture, string literal, string input, bool expected)
+ {
+ var original = CultureInfo.CurrentCulture;
+
+ try
+ {
+ CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(creationCulture);
+ var parsers = new[]
+ {
+ L(literal, comparison),
+ OneOf([literal], comparison),
+ OneOf([literal, "zz"], comparison)
+ };
+
+ foreach (var parser in parsers)
+ Assert.That(parser.Parse(input).Success, Is.EqualTo(expected));
+
+ CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(parseCulture);
+
+ // Conversion after the culture switch must retain the original comparison.
+ foreach (var parser in parsers)
+ {
+ foreach (var variant in new[] { parser, parser.As("literal") })
+ {
+ var result = variant.Parse(input);
+ Assert.That(result.Success, Is.EqualTo(expected));
+
+ if (expected)
+ Assert.That(result.Value, Is.EqualTo(literal));
+
+ Assert.That(variant.Void().Parse(input).Success, Is.EqualTo(expected));
+ }
+ }
+
+ Assert.That(L(literal, comparison).Parse(input).Success, Is.EqualTo(!expected));
+ }
+ finally
+ {
+ CultureInfo.CurrentCulture = original;
+ }
+ }
+
+ [TestCase("i")]
+ [TestCase("\u200Bi")]
+ public void OneOf_String_CurrentCultureFreezesCandidateSelection(string literal)
+ {
+ var original = CultureInfo.CurrentCulture;
+
+ try
+ {
+ CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-US");
+ var literals = new[] { "I", literal };
+ var parser = OneOf(literals, StringComparison.CurrentCultureIgnoreCase);
+
+ Assert.That(parser.Parse("i").Value, Is.EqualTo("I"));
+
+ CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("tr-TR");
+
+ var actual = parser.Parse("i");
+ Assert.That(actual.Success, Is.True);
+ Assert.That(actual.Value, Is.EqualTo("I"));
+
+ var fresh = OneOf(literals, StringComparison.CurrentCultureIgnoreCase).Parse("i");
+ Assert.That(fresh.Success, Is.True);
+ Assert.That(fresh.Value, Is.EqualTo(literal));
+ }
+ finally
+ {
+ CultureInfo.CurrentCulture = original;
+ }
+ }
+
+ [TestCase("en-US", "tr-TR", "\u0131")]
+ [TestCase("tr-TR", "en-US", "I")]
+ public void OneOf_CurrentCultureChanges_PreservesSelectedLiteral(string creationCulture, string parseCulture, string expected)
+ {
+ var original = CultureInfo.CurrentCulture;
+
+ try
+ {
+ CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(creationCulture);
+ var parser = OneOf(["I", "\u0131"], StringComparison.CurrentCultureIgnoreCase);
+
+ Assert.That(parser.Parse("\u0131").Value, Is.EqualTo(expected));
+
+ CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(parseCulture);
+
+ var result = parser.Parse("\u0131");
+ Assert.That(result.Success, Is.True);
+ Assert.That(result.Value, Is.EqualTo(expected));
+ }
+ finally
+ {
+ CultureInfo.CurrentCulture = original;
+ }
+ }
+
+ [TestCase(StringComparison.CurrentCulture)]
+ [TestCase(StringComparison.CurrentCultureIgnoreCase)]
+ [TestCase(StringComparison.InvariantCulture)]
+ [TestCase(StringComparison.InvariantCultureIgnoreCase)]
+ [SetCulture("en-US")]
+ public void String_CultureComparison_ConsumesMatchedSourceLength(StringComparison comparison)
+ {
+ var cases = new (string Literal, string Input, string Matched, string Remaining)[]
+ {
+ ("abc", "\u200Babc!", "\u200Babc", "!"),
+ ("abc", "\0abc!", "\0abc", "!"),
+ ("\u200Babc", "abc!", "abc", "!"),
+ ("abc", "abc\u200B!", "abc", "\u200B!"),
+ ("\u200B", "!", "", "!"),
+ ("\u200B", "", "", ""),
+ ("é", "e\u0301!", "e\u0301", "!"),
+ ("e\u0301", "é!", "é", "!"),
+ ("\U00010400", "\u200B\U00010400!", "\u200B\U00010400", "!")
+ };
+
+ foreach (var (literal, input, matched, remaining) in cases)
+ {
+ var parsers = new[]
+ {
+ L(literal, comparison),
+ OneOf([literal], comparison),
+ OneOf([literal, "zz"], comparison)
+ };
+
+ foreach (var parser in parsers)
+ {
+ foreach (var variant in new[] { parser, parser.As("literal") })
+ {
+ var context = new ParseContext("@" + input);
+ context.Advance(1);
+
+ Assert.That(variant.TryParse(ref context, out var value), Is.True);
+ Assert.That(value, Is.EqualTo(literal));
+ Assert.That(context.Position, Is.EqualTo(1 + matched.Length));
+ Assert.That(context.Remaining.ToString(), Is.EqualTo(remaining));
+
+ var tail = remaining.Length == 0 ? Eof : L(remaining).Void().ThenIgnore(Eof);
+ var composed = L("@")
+ .Then(variant.Map(m => (m.Index, m.Length, Text: m.ToString())))
+ .ThenIgnore(tail)
+ .Parse("@" + input);
+
+ Assert.That(composed.Success, Is.True);
+ Assert.That(composed.Value, Is.EqualTo((1, matched.Length, matched)));
+ }
+ }
+ }
+ }
+
+ [Test]
+ public void OneOf_String_MatchesSingleLiteralParsers()
+ {
+ var alphabet = new[]
+ {
+ 'a', 'b', 'c', 'A', 'B', 'C',
+ 'µ', 'Μ', 'μ', 'σ', 'ς', 'Σ',
+ 'В', 'в', 'ᲀ', 'Ι', 'ι', '\u0345', '\u1FBE',
+ '\u200B', '\u212A', '\0', 'ß'
+ };
+
+ var random = new Random(12);
+ var comparisons = Enum.GetValues();
+
+ for (var iteration = 0; iteration < 300; iteration++)
+ {
+ var literals = new string[random.Next(2, 6)];
+ for (var i = 0; i < literals.Length; i++)
+ literals[i] = RandomString(random, alphabet, 1, 4);
+
+ var input = RandomString(random, alphabet, 0, 5);
+
+ foreach (var comparison in comparisons)
+ {
+ var expected = literals.Any(l => input.StartsWith(l, comparison));
+ var actual = OneOf(literals, comparison).Parse(input).Success;
+
+ Assert.That(
+ actual,
+ Is.EqualTo(expected),
+ $"comparison={comparison}, input={Display(input)}, literals={string.Join(", ", literals.Select(Display))}");
+ }
+ }
+
+ static string RandomString(Random random, char[] alphabet, int minLength, int maxLength)
+ {
+ var chars = new char[random.Next(minLength, maxLength + 1)];
+ for (var i = 0; i < chars.Length; i++)
+ chars[i] = alphabet[random.Next(alphabet.Length)];
+
+ return new string(chars);
+ }
+
+ static string Display(string value) =>
+ string.Join("", value.Select(c => c is >= ' ' and < '\u007F' ? c.ToString() : $"\\u{(int)c:X4}"));
+ }
+
+ [Test]
+ public void OneOf_String_ValidatesLiterals()
+ {
+ Assert.Throws(() => { _ = OneOf(["a", null!]); });
+ Assert.Throws(() => { _ = OneOf(["a", ""]); });
+ Assert.Throws(() => { _ = OneOf((string[])null!); });
+ Assert.Throws(() => { _ = OneOf([]); });
+ }
}