From c309b238ece59fa80a5c1f0a347710599ce7a1b4 Mon Sep 17 00:00:00 2001 From: rameel Date: Sun, 27 Sep 2026 23:57:41 +0500 Subject: [PATCH] parsers: correct Unicode and culture-aware string matching Normalize OrdinalIgnoreCase bucket keys for both literals and input so OneOf recognizes equivalent characters beyond simple upper/lower pairs. Match culture-aware candidates without first-character bucketing and consume the source length returned by CompareInfo.IsPrefix rather than the literal length. --- src/Ramstack.Parsing/Parser.String.cs | 248 +++++++---- .../ParsersTests.String.cs | 416 +++++++++++++++++- 2 files changed, 568 insertions(+), 96 deletions(-) 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([]); }); + } }