diff --git a/docs/KNOWN-ISSUES.md b/docs/KNOWN-ISSUES.md
index 3b9a50c..a725087 100644
--- a/docs/KNOWN-ISSUES.md
+++ b/docs/KNOWN-ISSUES.md
@@ -64,17 +64,25 @@ than read as csv. Matters if anyone sends one.
## Contracts looser than they read
-### A date must carry two separators, so `15 Jan 2023` is text
+### A date must carry two separators, or a month in words and a four-digit year at its end
`DateReading.LooksLikeOne` requires two of `-`, `/`, `.`, or a colon, before a parse is attempted.
That is what refuses a decimal (`1.5` reads as the fifth of January under en-US) and a day-and-month
-(`3/15` takes the current year), and the price is a date written with spaces and a month name.
+(`3/15` takes the current year). A date with its month in words — `17 SEP 1992`,
+`September 17, 1992` — counts its spaces as separators too, but only when it ends in a year of four
+digits, because the parser completes `Mon 17 Sep` with the current year just as it does `3/15`. So
+`17 SEP 92` and `1992 SEP 17` are text.
+
+A month and a year alone (`MAY-19`) and a day and a month alone (`8-MAR`) stay text whatever the
+spelling: the cultures disagree on what they mean — under en-US `MAY-19` is the nineteenth of May this
+year, under de-DE it is May 2019 — and a guess would import a date the file may never have held.
A genuine `0001-01-01` is refused for the neighbouring reason: year one is the marker that the text
named no year.
-**Matters when** a customer exports dates in a long form. The fix is a parse against the culture's
-year-bearing patterns rather than a shape test, which is more code than the case has so far earned.
+**Matters when** a customer exports dates with a two-digit year in words, or months without days. The
+fix for the first is a parse against the culture's year-bearing patterns rather than a shape test; the
+second needs a reading the caller chooses, not one the library guesses.
### Dates cannot carry a range
`MinValue`/`MaxValue` compare numbers, so a range on a date field is refused by the validator
diff --git a/src/TriasDev.Tabular/Analysis/DateReading.cs b/src/TriasDev.Tabular/Analysis/DateReading.cs
index 872066b..01384cd 100644
--- a/src/TriasDev.Tabular/Analysis/DateReading.cs
+++ b/src/TriasDev.Tabular/Analysis/DateReading.cs
@@ -24,9 +24,9 @@ namespace TriasDev.Tabular;
/// names all three parts.
///
///
-/// The cost is real and worth stating: 15 Jan 2023 has no separators and is refused, as is a
-/// genuine 0001-01-01. Both are recorded in the known-issues register rather than pretended
-/// away.
+/// A month in words passes the shape test when the value ends in a four-digit year:
+/// 15 Jan 2023 is a date, 15 Jan 23 is text. A genuine 0001-01-01 is refused,
+/// and is recorded in the known-issues register rather than pretended away.
///
///
internal static class DateReading
@@ -96,9 +96,17 @@ private static bool HasZone(ReadOnlySpan text)
/// Whether the text is shaped like a date at all.
///
///
+ ///
/// Two separators, because one is how a decimal and a day-and-month are written and the parser
/// takes both. A colon admits a time of day so that the parse can happen and the year check can
/// refuse it, which is more honest than never looking.
+ ///
+ ///
+ /// A month in words — 17 SEP 1992, September 17, 1992 — is set apart by spaces, which
+ /// the count above does not see. It cannot simply count them: Mon 17 Sep has two, and the
+ /// parser completes it with the current year, which the year check cannot tell from a year the
+ /// text stated. So a value in words must also end in a year of four digits.
+ ///
///
public static bool LooksLikeOne(string text)
{
@@ -110,6 +118,46 @@ public static bool LooksLikeOne(string text)
ReadOnlySpan span = text.AsSpan();
int separators = span.Count('-') + span.Count('/') + span.Count('.');
- return separators >= 2;
+ return separators >= 2 || LooksLikeOneInWords(span, separators);
+ }
+
+ ///
+ /// Whether the text could be a date with its month in words: a year of exactly four digits at its
+ /// end, a letter before it, and at least two separators once spaces count among them.
+ ///
+ ///
+ /// The profile asks this of every value the separator count refused, which is most text, so a
+ /// value that does not end in four digits is refused by its last characters, before any scan.
+ /// The year must end the value because an address leads with its number: in a file of five million
+ /// locations, a four-digit run anywhere sent 1.4 million street names on to three failing parses
+ /// and cost the analysis 7%; at the end, thirty thousand.
+ ///
+ private static bool LooksLikeOneInWords(ReadOnlySpan text, int separators)
+ {
+ text = text.TrimEnd();
+
+ if (text.Length is < 8 or > 40
+ || !char.IsAsciiDigit(text[^1]) || !char.IsAsciiDigit(text[^2])
+ || !char.IsAsciiDigit(text[^3]) || !char.IsAsciiDigit(text[^4])
+ || char.IsAsciiDigit(text[^5]))
+ {
+ return false;
+ }
+
+ bool letter = false;
+
+ foreach (char c in text[..^4])
+ {
+ if (c == ' ')
+ {
+ separators++;
+ }
+ else
+ {
+ letter |= char.IsLetter(c);
+ }
+ }
+
+ return letter && separators >= 2;
}
}
diff --git a/tests/TriasDev.Tabular.Tests/Analysis/PartialDateTests.cs b/tests/TriasDev.Tabular.Tests/Analysis/PartialDateTests.cs
index 2a7829d..ac98f5a 100644
--- a/tests/TriasDev.Tabular.Tests/Analysis/PartialDateTests.cs
+++ b/tests/TriasDev.Tabular.Tests/Analysis/PartialDateTests.cs
@@ -28,6 +28,8 @@ private static FileProfile Profile(string csv, AnalysisOptions? options = null)
[InlineData("January 2023", "en-US")]
[InlineData("08:30", "en-US")]
[InlineData("1.5", "en-US")]
+ [InlineData("Mon 17 Sep", "en-US")]
+ [InlineData("17 Sep", "")]
public void DoesNotInventTheMissingHalfOfADate(string value, string culture)
{
// The parser fills in whatever the text leaves out, from the clock: a bare time becomes
@@ -72,6 +74,50 @@ public void TheExtractorRefusesWhatTheProfilerRefuses()
Assert.Equal(1, run.Summary.RowsFailed);
}
+ // -- A date written in words names itself as completely as one written in figures ------------
+
+ [Theory]
+ [InlineData("17 SEP 1992", "")]
+ [InlineData("17 SEP 1992", "en-US")]
+ [InlineData("17 SEP 1992", "de-DE")]
+ [InlineData("17 September 1992", "en-US")]
+ [InlineData("September 17, 1992", "en-US")]
+ [InlineData("17. September 1992", "de-DE")]
+ public void ReadsADateWithTheMonthInWords(string value, string culture)
+ {
+ // Spaces were not separators to the shape test, so a day, a month name and a year was text
+ // while the same date with dots in it was a date.
+ FileProfile profile = Profile($"when\n{value}\n", new AnalysisOptions { Cultures = [culture] });
+
+ Assert.Equal(new DateTime(1992, 9, 17, 0, 0, 0, DateTimeKind.Unspecified), profile.Sheets[0].Columns[0].Facts.MinDate);
+ }
+
+ [Fact]
+ public void TheExtractorReadsADateWithTheMonthInWords()
+ {
+ ImportSchema schema = new() { Fields = [ImportField.Date("when")] };
+
+ MappingPlan plan = new()
+ {
+ Culture = "",
+ Bindings = [new ColumnBinding { ColumnIndex = 0, Header = "when", FieldName = "when" }],
+ };
+
+ using MemoryStream stream = new(Utf8NoBom.GetBytes("when\n17 SEP 1992\n"), writable: false);
+
+ using ImportRun run = TabularImporter.Import(
+ stream,
+ "t.csv",
+ plan,
+ schema,
+ row => row[schema.Fields[0] as DateImportField ?? throw new InvalidOperationException()], cancellationToken: TestContext.Current.CancellationToken);
+
+ ImportResult result = run.ReadAll(cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Empty(result.Errors);
+ Assert.Equal([new DateTime(1992, 9, 17, 0, 0, 0, DateTimeKind.Unspecified)], result.Items);
+ }
+
[Fact]
public void StillReadsADateThatNamesItselfCompletely()
{