Skip to content

Commit

Permalink
Allow two-digit-max-year customization
Browse files Browse the repository at this point in the history
Fixes #1720, at least the main feature.

Additional potential features to be implemented later:

- WithTwoDigitYearMaxFromCalendar
- An AppContext switch to prohibit two-digit years in patterns
  • Loading branch information
jskeet committed Jun 3, 2023
1 parent b81a841 commit 2fe5b16
Show file tree
Hide file tree
Showing 22 changed files with 392 additions and 118 deletions.
23 changes: 23 additions & 0 deletions src/NodaTime.Test/Text/LocalDatePatternTest.cs
Expand Up @@ -261,6 +261,29 @@ public void CreateWithCurrentCulture()
[Test]
public void ParseNull() => AssertParseNull(LocalDatePattern.Iso);

[Test]
[TestCase(0, "00-01-01", 2000)]
[TestCase(0, "01-01-01", 1901)]
[TestCase(50, "49-01-01", 2049)]
[TestCase(50, "50-01-01", 2050)]
[TestCase(50, "51-01-01", 1951)]
[TestCase(99, "00-01-01", 2000)]
[TestCase(99, "99-01-01", 2099)]
public void WithTwoDigitYearMax(int twoDigitYearMax, string text, int expectedYear)
{
var pattern = LocalDatePattern.CreateWithInvariantCulture("yy-MM-dd").WithTwoDigitYearMax(twoDigitYearMax);
var value = pattern.Parse(text).Value;
Assert.AreEqual(expectedYear, value.Year);
}

[Test]
[TestCase(-1)]
[TestCase(100)]
[TestCase(int.MinValue)]
[TestCase(int.MaxValue)]
public void WithTwoDigitYearMax_Invalid(int twoDigitYearMax) =>
Assert.Throws<ArgumentOutOfRangeException>(() => LocalDatePattern.Iso.WithTwoDigitYearMax(twoDigitYearMax));

private void AssertBclNodaEquality(CultureInfo culture, string patternText)
{
// The BCL never seems to use abbreviated month genitive names.
Expand Down
24 changes: 24 additions & 0 deletions src/NodaTime.Test/Text/LocalDateTimePatternTest.cs
Expand Up @@ -8,6 +8,7 @@
using NodaTime.Text;
using NUnit.Framework;
using NodaTime.Test.Calendars;
using System;

namespace NodaTime.Test.Text
{
Expand Down Expand Up @@ -187,6 +188,29 @@ public void CreateWithCurrentCulture()
[Test]
public void ParseNull() => AssertParseNull(LocalDateTimePattern.ExtendedIso);

[Test]
[TestCase(0, "00-01-01T00:00:00", 2000)]
[TestCase(0, "01-01-01T00:00:00", 1901)]
[TestCase(50, "49-01-01T00:00:00", 2049)]
[TestCase(50, "50-01-01T00:00:00", 2050)]
[TestCase(50, "51-01-01T00:00:00", 1951)]
[TestCase(99, "00-01-01T00:00:00", 2000)]
[TestCase(99, "99-01-01T00:00:00", 2099)]
public void WithTwoDigitYearMax(int twoDigitYearMax, string text, int expectedYear)
{
var pattern = LocalDateTimePattern.CreateWithInvariantCulture("yy-MM-dd'T'HH:mm:ss").WithTwoDigitYearMax(twoDigitYearMax);
var value = pattern.Parse(text).Value;
Assert.AreEqual(expectedYear, value.Year);
}

[Test]
[TestCase(-1)]
[TestCase(100)]
[TestCase(int.MinValue)]
[TestCase(int.MaxValue)]
public void WithTwoDigitYearMax_Invalid(int twoDigitYearMax) =>
Assert.Throws<ArgumentOutOfRangeException>(() => LocalDateTimePattern.ExtendedIso.WithTwoDigitYearMax(twoDigitYearMax));

[Test]
[TestCaseSource(nameof(AllCulturesStandardPatterns))]
public void BclStandardPatternComparison(CultureInfo culture, string pattern)
Expand Down
24 changes: 24 additions & 0 deletions src/NodaTime.Test/Text/OffsetDatePatternTest.cs
Expand Up @@ -5,6 +5,7 @@
using NodaTime.Globalization;
using NodaTime.Text;
using NUnit.Framework;
using System;
using System.Collections.Generic;
using System.Linq;

Expand Down Expand Up @@ -142,6 +143,29 @@ public void WithCalendar()
[Test]
public void ParseNull() => AssertParseNull(OffsetDatePattern.GeneralIso);

[Test]
[TestCase(0, "00-01-01 +01", 2000)]
[TestCase(0, "01-01-01 +01", 1901)]
[TestCase(50, "49-01-01 +01", 2049)]
[TestCase(50, "50-01-01 +01", 2050)]
[TestCase(50, "51-01-01 +01", 1951)]
[TestCase(99, "00-01-01 +01", 2000)]
[TestCase(99, "99-01-01 +01", 2099)]
public void WithTwoDigitYearMax(int twoDigitYearMax, string text, int expectedYear)
{
var pattern = OffsetDatePattern.CreateWithInvariantCulture("yy-MM-dd o<g>").WithTwoDigitYearMax(twoDigitYearMax);
var value = pattern.Parse(text).Value;
Assert.AreEqual(expectedYear, value.Year);
}

[Test]
[TestCase(-1)]
[TestCase(100)]
[TestCase(int.MinValue)]
[TestCase(int.MaxValue)]
public void WithTwoDigitYearMax_Invalid(int twoDigitYearMax) =>
Assert.Throws<ArgumentOutOfRangeException>(() => OffsetDatePattern.GeneralIso.WithTwoDigitYearMax(twoDigitYearMax));

internal sealed class Data : PatternTestData<OffsetDate>
{
// Default to the start of the year 2000 UTC
Expand Down
24 changes: 24 additions & 0 deletions src/NodaTime.Test/Text/OffsetDateTimePatternTest.cs
Expand Up @@ -5,6 +5,7 @@
using NodaTime.Globalization;
using NodaTime.Text;
using NUnit.Framework;
using System;
using System.Collections.Generic;
using System.Linq;

Expand Down Expand Up @@ -190,6 +191,29 @@ public void WithCalendar()
[Test]
public void ParseNull() => AssertParseNull(OffsetDateTimePattern.ExtendedIso);

[Test]
[TestCase(0, "00-01-01T00:00:00 +01", 2000)]
[TestCase(0, "01-01-01T00:00:00 +01", 1901)]
[TestCase(50, "49-01-01T00:00:00 +01", 2049)]
[TestCase(50, "50-01-01T00:00:00 +01", 2050)]
[TestCase(50, "51-01-01T00:00:00 +01", 1951)]
[TestCase(99, "00-01-01T00:00:00 +01", 2000)]
[TestCase(99, "99-01-01T00:00:00 +01", 2099)]
public void WithTwoDigitYearMax(int twoDigitYearMax, string text, int expectedYear)
{
var pattern = OffsetDateTimePattern.CreateWithInvariantCulture("yy-MM-dd'T'HH:mm:ss o<g>").WithTwoDigitYearMax(twoDigitYearMax);
var value = pattern.Parse(text).Value;
Assert.AreEqual(expectedYear, value.Year);
}

[Test]
[TestCase(-1)]
[TestCase(100)]
[TestCase(int.MinValue)]
[TestCase(int.MaxValue)]
public void WithTwoDigitYearMax_Invalid(int twoDigitYearMax) =>
Assert.Throws<ArgumentOutOfRangeException>(() => OffsetDateTimePattern.GeneralIso.WithTwoDigitYearMax(twoDigitYearMax));

internal sealed class Data : PatternTestData<OffsetDateTime>
{
// Default to the start of the year 2000 UTC
Expand Down
23 changes: 23 additions & 0 deletions src/NodaTime.Test/Text/YearMonthPatternTest.cs
Expand Up @@ -211,6 +211,29 @@ public void CreateWithTemplateValue()
[Test]
public void ParseNull() => AssertParseNull(YearMonthPattern.Iso);

[Test]
[TestCase(0, "00-01", 2000)]
[TestCase(0, "01-01", 1901)]
[TestCase(50, "49-01", 2049)]
[TestCase(50, "50-01", 2050)]
[TestCase(50, "51-01", 1951)]
[TestCase(99, "00-01", 2000)]
[TestCase(99, "99-01", 2099)]
public void WithTwoDigitYearMax(int twoDigitYearMax, string text, int expectedYear)
{
var pattern = YearMonthPattern.CreateWithInvariantCulture("yy-MM").WithTwoDigitYearMax(twoDigitYearMax);
var value = pattern.Parse(text).Value;
Assert.AreEqual(expectedYear, value.Year);
}

[Test]
[TestCase(-1)]
[TestCase(100)]
[TestCase(int.MinValue)]
[TestCase(int.MaxValue)]
public void WithTwoDigitYearMax_Invalid(int twoDigitYearMax) =>
Assert.Throws<ArgumentOutOfRangeException>(() => YearMonthPattern.Iso.WithTwoDigitYearMax(twoDigitYearMax));

public sealed class Data : PatternTestData<YearMonth>
{
// Default to the start of the year 2000.
Expand Down
24 changes: 24 additions & 0 deletions src/NodaTime.Test/Text/ZonedDateTimePatternTest.cs
Expand Up @@ -6,6 +6,7 @@
using NodaTime.Text;
using NodaTime.TimeZones;
using NUnit.Framework;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
Expand Down Expand Up @@ -321,6 +322,29 @@ public void FindLongestZoneId()
[Test]
public void ParseNull() => AssertParseNull(ZonedDateTimePattern.ExtendedFormatOnlyIso.WithZoneProvider(TestProvider));

[Test]
[TestCase(0, "00-01-01T00:00:00 abc", 2000)]
[TestCase(0, "01-01-01T00:00:00 abc", 1901)]
[TestCase(50, "49-01-01T00:00:00 abc", 2049)]
[TestCase(50, "50-01-01T00:00:00 abc", 2050)]
[TestCase(50, "51-01-01T00:00:00 abc", 1951)]
[TestCase(99, "00-01-01T00:00:00 abc", 2000)]
[TestCase(99, "99-01-01T00:00:00 abc", 2099)]
public void WithTwoDigitYearMax(int twoDigitYearMax, string text, int expectedYear)
{
var pattern = ZonedDateTimePattern.CreateWithInvariantCulture("yy-MM-dd'T'HH:mm:ss z", TestProvider).WithTwoDigitYearMax(twoDigitYearMax);
var value = pattern.Parse(text).Value;
Assert.AreEqual(expectedYear, value.Year);
}

[Test]
[TestCase(-1)]
[TestCase(100)]
[TestCase(int.MinValue)]
[TestCase(int.MaxValue)]
public void WithTwoDigitYearMax_Invalid(int twoDigitYearMax) =>
Assert.Throws<ArgumentOutOfRangeException>(() => ZonedDateTimePattern.GeneralFormatOnlyIso.WithTwoDigitYearMax(twoDigitYearMax));

public sealed class Data : PatternTestData<ZonedDateTime>
{
// Default to the start of the year 2000 UTC
Expand Down
14 changes: 7 additions & 7 deletions src/NodaTime/Globalization/NodaFormatInfo.cs
Expand Up @@ -203,16 +203,16 @@ private static IReadOnlyList<string> ConvertGenitiveMonthArray(IReadOnlyList<str

internal FixedFormatInfoPatternParser<Duration> DurationPatternParser => EnsureFixedFormatInitialized(ref durationPatternParser, () => new DurationPatternParser());
internal FixedFormatInfoPatternParser<Offset> OffsetPatternParser => EnsureFixedFormatInitialized(ref offsetPatternParser, () => new OffsetPatternParser());
internal FixedFormatInfoPatternParser<Instant> InstantPatternParser => EnsureFixedFormatInitialized(ref instantPatternParser, () => new InstantPatternParser(InstantPattern.DefaultTemplateValue));
internal FixedFormatInfoPatternParser<Instant> InstantPatternParser => EnsureFixedFormatInitialized(ref instantPatternParser, () => new InstantPatternParser(InstantPattern.DefaultTemplateValue, LocalDatePattern.DefaultTwoDigitYearMax));
internal FixedFormatInfoPatternParser<LocalTime> LocalTimePatternParser => EnsureFixedFormatInitialized(ref localTimePatternParser, () => new LocalTimePatternParser(LocalTime.Midnight));
internal FixedFormatInfoPatternParser<LocalDate> LocalDatePatternParser => EnsureFixedFormatInitialized(ref localDatePatternParser, () => new LocalDatePatternParser(LocalDatePattern.DefaultTemplateValue));
internal FixedFormatInfoPatternParser<LocalDateTime> LocalDateTimePatternParser => EnsureFixedFormatInitialized(ref localDateTimePatternParser, () => new LocalDateTimePatternParser(LocalDateTimePattern.DefaultTemplateValue));
internal FixedFormatInfoPatternParser<OffsetDateTime> OffsetDateTimePatternParser => EnsureFixedFormatInitialized(ref offsetDateTimePatternParser, () => new OffsetDateTimePatternParser(OffsetDateTimePattern.DefaultTemplateValue));
internal FixedFormatInfoPatternParser<OffsetDate> OffsetDatePatternParser => EnsureFixedFormatInitialized(ref offsetDatePatternParser, () => new OffsetDatePatternParser(OffsetDatePattern.DefaultTemplateValue));
internal FixedFormatInfoPatternParser<LocalDate> LocalDatePatternParser => EnsureFixedFormatInitialized(ref localDatePatternParser, () => new LocalDatePatternParser(LocalDatePattern.DefaultTemplateValue, LocalDatePattern.DefaultTwoDigitYearMax));
internal FixedFormatInfoPatternParser<LocalDateTime> LocalDateTimePatternParser => EnsureFixedFormatInitialized(ref localDateTimePatternParser, () => new LocalDateTimePatternParser(LocalDateTimePattern.DefaultTemplateValue, LocalDatePattern.DefaultTwoDigitYearMax));
internal FixedFormatInfoPatternParser<OffsetDateTime> OffsetDateTimePatternParser => EnsureFixedFormatInitialized(ref offsetDateTimePatternParser, () => new OffsetDateTimePatternParser(OffsetDateTimePattern.DefaultTemplateValue, LocalDatePattern.DefaultTwoDigitYearMax));
internal FixedFormatInfoPatternParser<OffsetDate> OffsetDatePatternParser => EnsureFixedFormatInitialized(ref offsetDatePatternParser, () => new OffsetDatePatternParser(OffsetDatePattern.DefaultTemplateValue, LocalDatePattern.DefaultTwoDigitYearMax));
internal FixedFormatInfoPatternParser<OffsetTime> OffsetTimePatternParser => EnsureFixedFormatInitialized(ref offsetTimePatternParser, () => new OffsetTimePatternParser(OffsetTimePattern.DefaultTemplateValue));
internal FixedFormatInfoPatternParser<ZonedDateTime> ZonedDateTimePatternParser => EnsureFixedFormatInitialized(ref zonedDateTimePatternParser, () => new ZonedDateTimePatternParser(ZonedDateTimePattern.DefaultTemplateValue, Resolvers.StrictResolver, null));
internal FixedFormatInfoPatternParser<ZonedDateTime> ZonedDateTimePatternParser => EnsureFixedFormatInitialized(ref zonedDateTimePatternParser, () => new ZonedDateTimePatternParser(ZonedDateTimePattern.DefaultTemplateValue, Resolvers.StrictResolver, null, LocalDatePattern.DefaultTwoDigitYearMax));
internal FixedFormatInfoPatternParser<AnnualDate> AnnualDatePatternParser => EnsureFixedFormatInitialized(ref annualDatePatternParser, () => new AnnualDatePatternParser(AnnualDatePattern.DefaultTemplateValue));
internal FixedFormatInfoPatternParser<YearMonth> YearMonthPatternParser => EnsureFixedFormatInitialized(ref yearMonthPatternParser, () => new YearMonthPatternParser(YearMonthPattern.DefaultTemplateValue));
internal FixedFormatInfoPatternParser<YearMonth> YearMonthPatternParser => EnsureFixedFormatInitialized(ref yearMonthPatternParser, () => new YearMonthPatternParser(YearMonthPattern.DefaultTemplateValue, LocalDatePattern.DefaultTwoDigitYearMax));

private FixedFormatInfoPatternParser<T> EnsureFixedFormatInitialized<T>(ref FixedFormatInfoPatternParser<T>? field,
Func<IPatternParser<T>> patternParserFactory)
Expand Down
33 changes: 24 additions & 9 deletions src/NodaTime/Text/InstantPattern.cs
Expand Up @@ -69,16 +69,22 @@ private static class Patterns
/// <value>The value used as a template for parsing.</value>
public Instant TemplateValue { get; }

/// <summary>
/// Maximum two-digit-year in the template to treat as the current century.
/// </summary>
public int TwoDigitYearMax { get; }

/// <summary>
/// Gets the localization information used in this pattern.
/// </summary>
private NodaFormatInfo FormatInfo { get; }

private InstantPattern(string patternText, NodaFormatInfo formatInfo, Instant templateValue, IPattern<Instant> pattern)
private InstantPattern(string patternText, NodaFormatInfo formatInfo, Instant templateValue, int twoDigitYearMax, IPattern<Instant> pattern)
{
PatternText = patternText;
FormatInfo = formatInfo;
TemplateValue = templateValue;
TwoDigitYearMax = twoDigitYearMax;
this.pattern = pattern;
}

Expand Down Expand Up @@ -115,16 +121,17 @@ private InstantPattern(string patternText, NodaFormatInfo formatInfo, Instant te
/// <param name="patternText">Pattern text to create the pattern for</param>
/// <param name="formatInfo">The format info to use in the pattern</param>
/// <param name="templateValue">The template value to use in the pattern</param>
/// <param name="twoDigitYearMax">Maximum two-digit-year in the template to treat as the current century.</param>
/// <returns>A pattern for parsing and formatting instants.</returns>
/// <exception cref="InvalidPatternException">The pattern text was invalid.</exception>
private static InstantPattern Create(string patternText, NodaFormatInfo formatInfo, Instant templateValue)
private static InstantPattern Create(string patternText, NodaFormatInfo formatInfo, Instant templateValue, int twoDigitYearMax)
{
Preconditions.CheckNotNull(patternText, nameof(patternText));
Preconditions.CheckNotNull(formatInfo, nameof(formatInfo));
// Note: no check for the default template value, as that ends up being done in the
// underlying LocalDateTimePattern creation.
var pattern = new InstantPatternParser(templateValue).ParsePattern(patternText, formatInfo);
return new InstantPattern(patternText, formatInfo, templateValue, pattern);
var pattern = new InstantPatternParser(templateValue, twoDigitYearMax).ParsePattern(patternText, formatInfo);
return new InstantPattern(patternText, formatInfo, templateValue, twoDigitYearMax, pattern);
}

/// <summary>
Expand All @@ -138,7 +145,7 @@ private static InstantPattern Create(string patternText, NodaFormatInfo formatIn
/// <returns>A pattern for parsing and formatting instants.</returns>
/// <exception cref="InvalidPatternException">The pattern text was invalid.</exception>
public static InstantPattern Create(string patternText, [ValidatedNotNull] CultureInfo cultureInfo) =>
Create(patternText, NodaFormatInfo.GetFormatInfo(cultureInfo), DefaultTemplateValue);
Create(patternText, NodaFormatInfo.GetFormatInfo(cultureInfo), DefaultTemplateValue, LocalDatePattern.DefaultTwoDigitYearMax);

/// <summary>
/// Creates a pattern for the given pattern text in the current thread's current culture.
Expand All @@ -152,7 +159,7 @@ private static InstantPattern Create(string patternText, NodaFormatInfo formatIn
/// <returns>A pattern for parsing and formatting instants.</returns>
/// <exception cref="InvalidPatternException">The pattern text was invalid.</exception>
public static InstantPattern CreateWithCurrentCulture(string patternText) =>
Create(patternText, NodaFormatInfo.CurrentInfo, DefaultTemplateValue);
Create(patternText, NodaFormatInfo.CurrentInfo, DefaultTemplateValue, LocalDatePattern.DefaultTwoDigitYearMax);

/// <summary>
/// Creates a pattern for the given pattern text in the invariant culture.
Expand All @@ -164,15 +171,15 @@ private static InstantPattern Create(string patternText, NodaFormatInfo formatIn
/// <returns>A pattern for parsing and formatting instants.</returns>
/// <exception cref="InvalidPatternException">The pattern text was invalid.</exception>
public static InstantPattern CreateWithInvariantCulture(string patternText) =>
Create(patternText, NodaFormatInfo.InvariantInfo, DefaultTemplateValue);
Create(patternText, NodaFormatInfo.InvariantInfo, DefaultTemplateValue, LocalDatePattern.DefaultTwoDigitYearMax);

/// <summary>
/// Creates a pattern for the same original pattern text as this pattern, but with the specified
/// localization information.
/// </summary>
/// <param name="formatInfo">The localization information to use in the new pattern.</param>
/// <returns>A new pattern with the given localization information.</returns>
private InstantPattern WithFormatInfo(NodaFormatInfo formatInfo) => Create(PatternText, formatInfo, TemplateValue);
private InstantPattern WithFormatInfo(NodaFormatInfo formatInfo) => Create(PatternText, formatInfo, TemplateValue, TwoDigitYearMax);

/// <summary>
/// Creates a pattern for the same original pattern text as this pattern, but with the specified
Expand All @@ -189,6 +196,14 @@ private static InstantPattern Create(string patternText, NodaFormatInfo formatIn
/// <param name="newTemplateValue">The template value for the new pattern, used to fill in unspecified fields.</param>
/// <returns>A new pattern with the given template value.</returns>
public InstantPattern WithTemplateValue(Instant newTemplateValue) =>
Create(PatternText, FormatInfo, newTemplateValue);
Create(PatternText, FormatInfo, newTemplateValue, TwoDigitYearMax);

/// <summary>
/// Creates a pattern like this one, but with a different <see cref="TwoDigitYearMax"/> value.
/// </summary>
/// <param name="twoDigitYearMax">The value to use for <see cref="TwoDigitYearMax"/> in the new pattern.</param>
/// <returns>A new pattern with the specified maximum two-digit-year.</returns>
public InstantPattern WithTwoDigitYearMax(int twoDigitYearMax) =>
Create(PatternText, FormatInfo, TemplateValue, twoDigitYearMax);
}
}

0 comments on commit 2fe5b16

Please sign in to comment.