Repository: dotnet/runtime
Pull request #114194: Fix "MMM.yy" date parsing in German culture. #114169
Author: login: pra2892
Labels: name: area-System.Globalization
name: community-contribution
description: Indicates that the PR has been added by a community member
Created: 2025-04-03T04:34:15Z | Merged: 2025-04-12T23:29:45Z

This solution handles all the edge cases:

- The `FormatContainsDayOfMonthSpecifier` method properly skips 'd' characters inside quotes.
- Both `\` and `%` escape characters are handled.
- We only consider 'd' or 'dd' as day-of-month specifiers, not 'ddd' or 'dddd' (day-of-week).
- We analyze the entire format string, so it doesn't matter if 'MMM' comes before or after 'd'.
- The entire format is analyzed, so multiple day or month patterns are handled correctly.
- We only scan the format string once, and only when needed.

This solution should work correctly in all scenarios, including the specific case mentioned with "MMM dd" format in German culture.


Diff:
diff --git a/src/libraries/System.Private.CoreLib/src/System/Globalization/DateTimeParse.cs b/src/libraries/System.Private.CoreLib/src/System/Globalization/DateTimeParse.cs
index 6d6a2ff24a724c..1ada31201cb99d 100644
--- a/src/libraries/System.Private.CoreLib/src/System/Globalization/DateTimeParse.cs
+++ b/src/libraries/System.Private.CoreLib/src/System/Globalization/DateTimeParse.cs
@@ -3289,6 +3289,51 @@ private static bool ParseTimeZoneOffset(ref __DTString str, int len, scoped ref
             return true;
         }
 
+        /// Determines if a format string contains a day-of-month specifier ('d' or 'dd').
+        /// Properly handles quoted sections and escape characters.
+        private static bool FormatContainsDayOfMonthSpecifier(ReadOnlySpan<char> format)
+        {
+            if (format.IsEmpty)
+            {
+                return false;
+            }
+            bool inQuote = false;
+            for (int i = 0; i < format.Length; i++)
+            {
+                char ch = format[i];
+                // Skip the next character if it's escaped
+                if (ch == '\\' || ch == '%')
+                {
+                    i++;
+                    continue;
+                }
+                // Toggle quote state
+                if (ch == '\'' || ch == '"')
+                {
+                    inQuote = !inQuote;
+                    continue;
+                }
+                // Only check for 'd' when not in quotes
+                if (!inQuote && ch == 'd')
+                {
+                    // Make sure it's a day-of-month specifier (d or dd)
+                    // and not a day-of-week specifier (ddd or dddd)
+                    int repeatCount = 1;
+                    while (i + 1 < format.Length && format[i + 1] == 'd')
+                    {
+                        repeatCount++;
+                        i++;
+                    }
+                    // Only day-of-month specifiers (d or dd) trigger genitive case
+                    if (repeatCount <= 2)
+                    {
+                        return true;
+                    }
+                }
+            }
+            return false;
+        }
+
         /*=================================MatchAbbreviatedMonthName==================================
         **Action: Parse the abbreviated month name from string starting at str.Index.
         **Returns: A value from 1 to 12 for the first month to the twelfth month.
@@ -3297,7 +3342,7 @@ private static bool ParseTimeZoneOffset(ref __DTString str, int len, scoped ref
         **Exceptions: FormatException if an abbreviated month name can not be found.
         ==============================================================================*/
 
-        private static bool MatchAbbreviatedMonthName(ref __DTString str, DateTimeFormatInfo dtfi, scoped ref int result)
+        private static bool MatchAbbreviatedMonthName(ref __DTString str, DateTimeFormatInfo dtfi, scoped ref int result, ReadOnlySpan<char> format)
         {
             int maxMatchStrLen = 0;
             result = -1;
@@ -3357,12 +3402,11 @@ private static bool MatchAbbreviatedMonthName(ref __DTString str, DateTimeFormat
                     }
                 }
 
-                // Search genitive form.
-                if ((dtfi.FormatFlags & DateTimeFormatFlags.UseGenitiveMonth) != 0)
+                // Search genitive form only if the format contains a day-of-month specifier
+                if ((dtfi.FormatFlags & DateTimeFormatFlags.UseGenitiveMonth) != 0 && FormatContainsDayOfMonthSpecifier(format))
                 {
                     int tempResult = str.MatchLongestWords(dtfi.InternalGetGenitiveMonthNames(abbreviated: true), ref maxMatchStrLen);
-
-                    // We found a longer match in the genitive month name.  Use this as the result.
+                    // We found a longer match in the genitive month name. Use this as the result.
                     // tempResult + 1 should be the month value.
                     if (tempResult >= 0)
                     {
@@ -3399,7 +3443,7 @@ private static bool MatchAbbreviatedMonthName(ref __DTString str, DateTimeFormat
         **Exceptions: FormatException if a month name can not be found.
         ==============================================================================*/
 
-        private static bool MatchMonthName(ref __DTString str, DateTimeFormatInfo dtfi, scoped ref int result)
+        private static bool MatchMonthName(ref __DTString str, DateTimeFormatInfo dtfi, scoped ref int result, ReadOnlySpan<char> format)
         {
             int maxMatchStrLen = 0;
             result = -1;
@@ -3458,7 +3502,7 @@ private static bool MatchMonthName(ref __DTString str, DateTimeFormatInfo dtfi,
                 }
 
                 // Search genitive form.
-                if ((dtfi.FormatFlags & DateTimeFormatFlags.UseGenitiveMonth) != 0)
+                if ((dtfi.FormatFlags & DateTimeFormatFlags.UseGenitiveMonth) != 0 && FormatContainsDayOfMonthSpecifier(format))
                 {
                     int tempResult = str.MatchLongestWords(dtfi.InternalGetGenitiveMonthNames(abbreviated: false), ref maxMatchStrLen);
                     // We found a longer match in the genitive month name.  Use this as the result.
@@ -4045,7 +4089,7 @@ private static bool ParseByFormat(
                     {
                         if (tokenLen == 3)
                         {
-                            if (!MatchAbbreviatedMonthName(ref str, dtfi, ref tempMonth))
+                            if (!MatchAbbreviatedMonthName(ref str, dtfi, ref tempMonth, format.Value))
                             {
                                 result.SetBadDateTimeFailure();
                                 return false;
@@ -4053,7 +4097,7 @@ private static bool ParseByFormat(
                         }
                         else
                         {
-                            if (!MatchMonthName(ref str, dtfi, ref tempMonth))
+                            if (!MatchMonthName(ref str, dtfi, ref tempMonth, format.Value))
                             {
                                 result.SetBadDateTimeFailure();
                                 return false;
diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/DateTimeTests.cs b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/DateTimeTests.cs
index a971d5b6f9e28c..2d31b68544abba 100644
--- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/DateTimeTests.cs
+++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/DateTimeTests.cs
@@ -14,6 +14,55 @@ namespace System.Tests
 {
     public class DateTimeTests
     {
+        [Fact]
+        public static void ParseExact_GenitiveMonthNames()
+        {
+            // Create a German culture with explicitly defined genitive month names
+            var culture = new CultureInfo("de-DE");
+            culture.DateTimeFormat.AbbreviatedMonthGenitiveNames = new[] { "Jan.", "Feb.", "März", "Apr.", "Mai", "Juni", "Juli", "Aug.", "Sept.", "Okt.", "Nov.", "Dez.", "" };
+            culture.DateTimeFormat.MonthGenitiveNames = new[] { "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember.", "" };
+            culture.DateTimeFormat.DayNames = new[] { "Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag" };
+            culture.DateTimeFormat.DateSeparator = ".";
+            // Regular month names (non-genitive)
+            culture.DateTimeFormat.AbbreviatedMonthNames = new[] { "Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez", "" };
+            culture.DateTimeFormat.MonthNames = new[] { "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember", "" };
+            // Test cases for abbreviated month names (MMM)
+            // Case 1: Format without day specifier - should use regular month name
+            DateTime result;
+            bool success = DateTime.TryParseExact("Dez.20", "MMM.yy", culture, DateTimeStyles.None, out result);
+            Assert.True(success);
+            Assert.Equal(12, result.Month);
+            Assert.Equal(2020, result.Year);
+            // Case 2: Format with day-of-month specifier - should use genitive month name
+            success = DateTime.TryParseExact("Dez.20 01", "MMM.yy dd", culture, DateTimeStyles.None, out result);
+            Assert.False(success);
+            // Case 3: Format with day-of-month specifier before month - should use genitive month name
+            success = DateTime.TryParseExact("01 Dez.20", "d MMM.yy", culture, DateTimeStyles.None, out result);
+            Assert.False(success);
+            // Case 4: Format with day-of-week specifier - should use regular month name
+            success = DateTime.TryParseExact("Dienstag Dez.20", "dddd MMM.yy", culture, DateTimeStyles.None, out result);
+            Assert.True(success);
+            Assert.Equal(12, result.Month);
+            Assert.Equal(2020, result.Year);
+            // Test cases for full month names (MMMM)
+            // Case 5: Format without day specifier - should use regular month name
+            success = DateTime.TryParseExact("Dezember.20", "MMMM.yy", culture, DateTimeStyles.None, out result);
+            Assert.True(success);
+            Assert.Equal(12, result.Month);
+            Assert.Equal(2020, result.Year);
+            // Case 6: Format with day-of-month specifier - should use genitive month name
+            success = DateTime.TryParseExact("Dezember.20 01", "MMMM.yy dd", culture, DateTimeStyles.None, out result);
+            Assert.False(success);
+            // Case 7: Format with day-of-month specifier before month - should use genitive month name
+            success = DateTime.TryParseExact("01 Dezember.20", "d MMMM.yy", culture, DateTimeStyles.None, out result);
+            Assert.False(success);
+            // Case 8: Format with day-of-week specifier - should use regular month name
+            success = DateTime.TryParseExact("Dienstag Dezember.20", "dddd MMMM.yy", culture, DateTimeStyles.None, out result);
+            Assert.True(success);
+            Assert.Equal(12, result.Month);
+            Assert.Equal(2020, result.Year);
+        }
+
         [Fact]
         public static void MaxValue()
         {


Reviews:
user: login: lilinus
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T08:18:59Z
user: login: lilinus
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T08:36:02Z
user: login: pra2892
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T08:41:17Z
user: login: pra2892
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T08:47:31Z
user: login: pra2892
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T11:22:51Z
user: login: pra2892
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T11:23:07Z
user: login: tarekgh
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T21:30:28Z
user: login: tarekgh
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-12T23:29:35Z

Review comments:
diff_hunk: @@ -3357,12 +3398,12 @@ private static bool MatchAbbreviatedMonthName(ref __DTString str, DateTimeFormat
                     }
                 }
 
-                // Search genitive form.
-                if ((dtfi.FormatFlags & DateTimeFormatFlags.UseGenitiveMonth) != 0)
+                // Search genitive form only if the format contains a day-of-month specifier
+                bool useDaySpecifier = FormatContainsDayOfMonthSpecifier(format);
+                if ((dtfi.FormatFlags & DateTimeFormatFlags.UseGenitiveMonth) != 0 && useDaySpecifier)
path: src/libraries/System.Private.CoreLib/src/System/Globalization/DateTimeParse.cs
user: login: lilinus
body: Perhaps it is more performant to call `FormatContainsDayOfMonthSpecifier` directly in the if-condition. That way it short circuits and doesn't need to be evaluated if `(dtfi.FormatFlags & DateTimeFormatFlags.UseGenitiveMonth) == 0`
created_at: 2025-04-03T08:18:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3289,6 +3289,47 @@ private static bool ParseTimeZoneOffset(ref __DTString str, int len, scoped ref
             return true;
         }
 
+        /// Determines if a format string contains a day-of-month specifier ('d' or 'dd').
+        /// Properly handles quoted sections and escape characters.
+        private static bool FormatContainsDayOfMonthSpecifier(ReadOnlySpan<char> format)
path: src/libraries/System.Private.CoreLib/src/System/Globalization/DateTimeParse.cs
user: login: lilinus
body: [#123123](https://github.com/dotnet/runtime/issues/114159#issuecomment-2773426634) mentions this should be called from `MatchMonthName` too.
created_at: 2025-04-03T08:36:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3357,12 +3398,12 @@ private static bool MatchAbbreviatedMonthName(ref __DTString str, DateTimeFormat
                     }
                 }
 
-                // Search genitive form.
-                if ((dtfi.FormatFlags & DateTimeFormatFlags.UseGenitiveMonth) != 0)
+                // Search genitive form only if the format contains a day-of-month specifier
+                bool useDaySpecifier = FormatContainsDayOfMonthSpecifier(format);
+                if ((dtfi.FormatFlags & DateTimeFormatFlags.UseGenitiveMonth) != 0 && useDaySpecifier)
path: src/libraries/System.Private.CoreLib/src/System/Globalization/DateTimeParse.cs
user: login: pra2892
body: You're absolutely right! That's an excellent optimization. By leveraging short-circuit evaluation, we can avoid the potentially expensive format string analysis when it's not needed.
created_at: 2025-04-03T08:41:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3289,6 +3289,47 @@ private static bool ParseTimeZoneOffset(ref __DTString str, int len, scoped ref
             return true;
         }
 
+        /// Determines if a format string contains a day-of-month specifier ('d' or 'dd').
+        /// Properly handles quoted sections and escape characters.
+        private static bool FormatContainsDayOfMonthSpecifier(ReadOnlySpan<char> format)
path: src/libraries/System.Private.CoreLib/src/System/Globalization/DateTimeParse.cs
user: login: pra2892
body: You're absolutely right!
created_at: 2025-04-03T08:47:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3357,12 +3398,12 @@ private static bool MatchAbbreviatedMonthName(ref __DTString str, DateTimeFormat
                     }
                 }
 
-                // Search genitive form.
-                if ((dtfi.FormatFlags & DateTimeFormatFlags.UseGenitiveMonth) != 0)
+                // Search genitive form only if the format contains a day-of-month specifier
+                bool useDaySpecifier = FormatContainsDayOfMonthSpecifier(format);
+                if ((dtfi.FormatFlags & DateTimeFormatFlags.UseGenitiveMonth) != 0 && useDaySpecifier)
path: src/libraries/System.Private.CoreLib/src/System/Globalization/DateTimeParse.cs
user: login: pra2892
body: I have Updated the same.
created_at: 2025-04-03T11:22:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3289,6 +3289,47 @@ private static bool ParseTimeZoneOffset(ref __DTString str, int len, scoped ref
             return true;
         }
 
+        /// Determines if a format string contains a day-of-month specifier ('d' or 'dd').
+        /// Properly handles quoted sections and escape characters.
+        private static bool FormatContainsDayOfMonthSpecifier(ReadOnlySpan<char> format)
path: src/libraries/System.Private.CoreLib/src/System/Globalization/DateTimeParse.cs
user: login: pra2892
body: This is also updated.
created_at: 2025-04-03T11:23:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -14,6 +14,55 @@ namespace System.Tests
 {
     public class DateTimeTests
     {
+        [Fact]
+        public static void ParseExact_GenitiveMonthNames()
+        {
+            // Create a German culture with explicitly defined genitive month names
+            var culture = new CultureInfo("de-DE");
+            culture.DateTimeFormat.AbbreviatedMonthGenitiveNames = new[] { "Jan.", "Feb.", "März", "Apr.", "Mai", "Juni", "Juli", "Aug.", "Sept.", "Okt.", "Nov.", "Dez.", "" };
+            culture.DateTimeFormat.MonthGenitiveNames = new[] { "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember", "" };
path: src/libraries/System.Runtime/tests/System.Runtime.Tests/System/DateTimeTests.cs
user: login: tarekgh
body: >Dezember [](http://example.com/codeflow?start=172&length=8)

Why did you change that and removed the dot as I suggested? most likely the test will fail. 

Also, did you run the test before you commit the change?
created_at: 2025-04-04T21:30:28Z
author_association: MEMBER

PR comments:
user: login: dotnet-policy-service[bot]
created_at: 2025-04-03T04:35:00Z
body: Tagging subscribers to this area: @dotnet/area-system-globalization
See info in [area-owners.md](https://github.com/dotnet/runtime/blob/main/docs/area-owners.md) if you want to be subscribed.
<!-- Policy app identification https://img.shields.io/static/v1?label=PullRequestIssueManagement. -->
author_association: CONTRIBUTOR
user: login: lilinus
created_at: 2025-04-03T08:31:16Z
body: It would probably be good with some tests for this kind of case.
author_association: CONTRIBUTOR
user: login: tarekgh
created_at: 2025-04-03T17:31:17Z
body: @pra2892 thanks again for submitting the PR. This looks good. We need to add some tests to ensure we'll not break the behavior in the future. I would suggest in the test you create a culture like the following and ensure setting `culture.DateTimeFormat.AbbreviatedMonthGenitiveNames` to guarantee the test will never fail in the future if the culture data changed. 

```C#
 var culture = new CultureInfo("de-DE");
culture.DateTimeFormat.AbbreviatedMonthGenitiveNames = ["Jan.", "Feb.", "März", "Apr.", "Mai", "Juni", "Juli", "Aug.", "Sept", "Okt.", "Nov.", "Dez.", ""];
culture.DateTimeFormat.MonthGenitiveNames = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember.", ''"];
culture.DateTimeFormat.DayNames = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"];
culture.DateTimeFormat.DateSeparator = ".";
```

Then you add test cases:
- Parse exact `Dez.20` with the format `MMM.yy` and ensure the parsing succeeds.
- parse exact `Dez.20 01` with the format `MMM.yy dd` and ensure the parsing fails.
- parse exact `01 Dez.20` with the format `d MMM.yy` and ensure the parsing fails.
- parse exact `Dienstag Dez.20` with the format `dddd MMM.yy` and ensure the parsing succeed.
- then repeat the same tests with patterns using `MMMM` and month name `Dezember.`

You need to add the tests to the file https://github.com/dotnet/runtime/blob/main/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/DateTimeTests.cs
author_association: MEMBER
user: login: ANahr
created_at: 2025-04-03T21:29:29Z
body: Two small "corrections"/improvements (especially "Sept." because it is the only one with 5 chars):
```C#
var culture = new CultureInfo("de-DE");
culture.DateTimeFormat.AbbreviatedMonthGenitiveNames = ["Jan.", "Feb.", "März", "Apr.", "Mai", "Juni", "Juli", "Aug.", "Sept.", "Okt.", "Nov.", "Dez.", ""];
culture.DateTimeFormat.MonthGenitiveNames = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember", ''"];
culture.DateTimeFormat.DayNames = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"];
culture.DateTimeFormat.DateSeparator = ".";
```
author_association: CONTRIBUTOR
user: login: tarekgh
created_at: 2025-04-03T21:56:51Z
body: @ANahr I intentionally used dot at the end of the `Dezember.` so we can successfully test with the date patterns we are using. Fixing any other month names other than December is irrelevant if we are always testing with December. 
author_association: MEMBER
user: login: tarekgh
created_at: 2025-04-07T18:36:54Z
body: @pra2892 did you have a chance to address my comment https://github.com/dotnet/runtime/pull/114194#pullrequestreview-2744141369? 
author_association: MEMBER
user: login: tarekgh
created_at: 2025-04-09T17:22:43Z
body: @pra2892 I have pushed the test fix to this PR so we can merge it.
author_association: MEMBER
user: login: tarekgh
created_at: 2025-04-12T23:28:48Z
body: /ba-g the failure is already tracked with https://github.com/dotnet/runtime/issues/114465
author_association: MEMBER