| File: | blib/lib/Date/Cmp.pm |
| Coverage: | 92.8% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package Date::Cmp; | |||||
| 2 | ||||||
| 3 | # Compare two genealogy-style date strings with approximate-date support. | |||||
| 4 | # TODO: handle when only months are known (no year). | |||||
| 5 | ||||||
| 6 | 10 10 10 | 727705 9 126 | use strict; | |||
| 7 | 10 10 10 | 14 7 208 | use warnings; | |||
| 8 | ||||||
| 9 | 10 10 10 | 767 34879 23 | use autodie 2.06 qw(:all); | |||
| 10 | 10 10 10 | 51345 11 272 | use Carp qw(croak); | |||
| 11 | 10 9 9 | 1797 3104311 179 | use DateTime::Format::Genealogy 0.11; | |||
| 12 | 9 9 9 | 34 10 204 | use Readonly; | |||
| 13 | 9 9 9 | 20 7 142 | use Scalar::Util qw(blessed); | |||
| 14 | 9 9 9 | 2574 34295 328 | use Term::ANSIColor; | |||
| 15 | ||||||
| 16 | 9 9 9 | 28 8 14943 | use Exporter qw(import); | |||
| 17 | our @EXPORT_OK = qw(datecmp); | |||||
| 18 | ||||||
| 19 | # Characters permitted in any date string. | |||||
| 20 | # Covers every character that appears in a supported genealogy date: | |||||
| 21 | # A-Za-z â month abbreviations; BEF/BET/AFT/AND/Abt/ca prefixes | |||||
| 22 | # 0-9 â year and day digits | |||||
| 23 | # (space) â component separator | |||||
| 24 | # . â Abt. prefix dot | |||||
| 25 | # , â occasional list separator | |||||
| 26 | # / â slash-dates (M/D/YYYY) and month ranges (Oct/Nov/Dec) | |||||
| 27 | # - â ISO dates (YYYY-MM-DD) and year ranges (1830-1832) | |||||
| 28 | # ? â uncertainty suffix ("1828 ?") | |||||
| 29 | # : â ISO T-timestamp ("1941-08-02T00:00:00") | |||||
| 30 | Readonly my $DATE_CHARS => qr/[A-Ya-y0-9 .,\/\-?:]/; # No month contains Z | |||||
| 31 | ||||||
| 32 | =encoding utf-8 | |||||
| 33 | ||||||
| 34 - 42 | =head1 NAME Date::Cmp - Compare two dates with approximate parsing support =head1 VERSION Version 0.06 =cut | |||||
| 43 | ||||||
| 44 | our $VERSION = '0.06'; | |||||
| 45 | ||||||
| 46 | # Singleton DFG parser reused across calls for speed. Tests may swap it | |||||
| 47 | # with a mock under "local $Date::Cmp::dfg = MockDFG->new()". | |||||
| 48 | our $dfg = DateTime::Format::Genealogy->new(); | |||||
| 49 | ||||||
| 50 - 357 | =head1 SYNOPSIS
use Date::Cmp qw(datecmp);
my $cmp = datecmp('1914', '1918'); # -1 (1914 is earlier)
my $cmp = datecmp('Abt. 1850', '1855'); # -1
my $cmp = datecmp('BET 1830 AND 1832', '1831'); # 0 (within range)
# Optional complaint callback for ambiguous range edge-cases:
$cmp = datecmp('1996-2000', '1996',
sub { warn "ambiguous: @_" });
=head1 DESCRIPTION
C<Date::Cmp> provides a single exported function, C<datecmp>, which compares
two date strings or date-like objects, returning a numeric result like Perl's
C<< <=> >> operator.
The comparison handles approximate dates (C<Abt. 1902>, C<BET 1830 AND 1832>,
C<Oct/Nov/Dec 1950>), partial dates (year-only), and the common genealogy
qualifiers C<BEF> and C<AFT>. Exact parsing delegates to
L<DateTime::Format::Genealogy>; a cascade of fast-path heuristics handles
the most common year-only comparisons without invoking the heavier parser.
=head1 FUNCTIONS
=head2 datecmp
=head3 Purpose
Compare two genealogy-style date strings (or date-like objects) and return
a value equivalent to Perl's spaceship operator (C<< <=> >>): C<-1> if the
left operand is earlier, C<0> if equivalent, or C<1> if later.
=head3 Arguments
=over 4
=item C<$left> (required)
The left-hand date. Accepted types:
=over 8
=item * A string in any format listed under L</SUPPORTED FORMATS>.
=item * A blessed object with a C<date()> method returning a date string.
=item * A hash reference with a C<date> key whose value is a date string.
=back
=item C<$right> (required)
The right-hand date. Accepts the same types as C<$left>.
=item C<$complain> (optional)
A CODE reference invoked with a diagnostic string for ambiguous conditions:
equal range endpoints or an inverted range. C<undef> and other falsy values
are silently ignored (the guard is never triggered). A truthy non-CODE
value causes an immediate C<croak>.
=back
=head3 Returns
=over 4
=item * C<-1> â C<$left> is earlier than C<$right>
=item * C<0> â the two dates are considered equivalent
=item * C<1> â C<$left> is later than C<$right>
=back
When either argument is C<undef> (or resolves to C<undef> after unwrapping),
the function prints a diagnostic to STDERR and returns C<0> rather than dying.
On a fatal parse failure it dies; the exception string begins with
C<"Date parse failure: ">.
=head3 Side Effects
May print coloured diagnostics to STDERR when dates cannot be parsed, when a
range is inverted, or when an argument is undefined. The C<$complain>
callback is invoked (instead of STDERR output) for selected ambiguous
conditions.
=head3 EXAMPLE
use Date::Cmp qw(datecmp);
# Plain years
datecmp('1900', '1950'); # -1
# Approximate prefixes are stripped
datecmp('Abt. 1850', '1850'); # 0
datecmp('ca. 1799', '1800'); # -1
# Year ranges â any year within the range is "equal"
datecmp('1 Jan 1831', '1830-1832'); # 0
datecmp('BET 1830 AND 1832', '1829'); # 1 (range is later)
# Blessed object with date() method
package MyDate;
sub new { bless { d => $_[1] }, $_[0] }
sub date { $_[0]->{d} }
package main;
datecmp(MyDate->new('1900'), '1950'); # -1
# Hash ref with 'date' key
datecmp({ date => '1900' }, '1950'); # -1
# Sort a list of dates
my @sorted = sort { datecmp($a, $b) } qw(1832 Abt. 1800 1756 BET 1815 AND 1820);
=head3 API SPECIFICATION
=head4 Input
$left : Str | Object(date) | HashRef(date => Str) # required
$right : Str | Object(date) | HashRef(date => Str) # required
$complain: CodeRef | undef | false # optional
Valid string formats (see L</SUPPORTED FORMATS>):
exact => qr/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2})?$/
slash => qr{^\d+/\d+/\d{4}$}
year => qr/^\d{3,4}$/
approx => qr/^(?:Abt\.?|ca?\.?)\s+.+/i | qr/.+\s?\?$/
range => qr/^\d{3,4}-\d{3,4}$/ | qr/^BET \d+ AND \d+$/i
month_rng=> qr/^[a-z\/]+\s+\d{3,4}$/i
before => qr/^bef\b/i
after => qr/^aft\b/i
=head4 Output
Int: -1 | 0 | 1
Or croaks("Date parse failure: ...") when a date cannot be parsed.
Returns 0 (after STDERR output) when either argument is undef.
=head3 MESSAGES
The following diagnostics may be emitted. B<[STDERR]> entries print a
message and stack trace then return 0. B<[croak]> entries die (catchable
with C<eval {}>).
=over 4
=item B<[STDERR]> "left not defined" / "right not defined"
C<$left> or C<$right> was C<undef> on entry.
=item B<[STDERR]> "left date is undefined after input normalisation" / "right ..."
A hashref was passed with no C<date> key, or C<date =E<gt> undef>.
=item B<[croak]> "Third argument to datecmp() must be a CODE reference"
C<$complain> was truthy but not a C<CODE> reference (e.g. a string or
array-ref).
=item B<[croak]> "Date parse failure: left is an unsupported reference type (...)"
C<$left> was a reference that could not be unwrapped (not a blessed
C<date()>-capable object and not a hash).
=item B<[croak]> "Date parse failure: right is an unsupported reference type (...)"
Same for C<$right>.
=item B<[croak]> "Date parse failure: left contains characters not permitted in a date string"
C<$left> contained a character outside the allowed set
C<[A-Za-z0-9 .,/-?:]>. The string is rejected before any parsing.
=item B<[croak]> "Date parse failure: right contains characters not permitted in a date string"
Same for C<$right>.
=item B<[croak]> "Date parse failure: left = ... (year must be 3-4 digits)"
C<$left> was a bare integer with 5 or more digits.
=item B<[croak]> "Date parse failure: right = ... (year must be 3-4 digits)"
Same for C<$right>.
=item B<[croak]> "Date parse failure: left = ..."
C<$left> failed the first-character semantic check or could not be parsed
by DFG.
=item B<[croak]> "Date parse failure: right = ..."
C<$right> could not be parsed by DFG (and no year suffix was extractable).
=item B<[STDERR]> "... <=> ...: not handled yet"
A C<BEF>/C<AFT> qualifier on the left with a right-hand value that does not
fit any handled pattern. Returns 0.
=item B<[STDERR]> "... <=> ...: Before not handled"
A C<BEF> qualifier on the right with a left-hand value that is not a plain
integer. Returns 0.
=item B<[STDERR]> "datecmp(): N > M in daterange ..."
The right-hand year range was inverted (C<from E<gt> to>). Returns 0.
=back
=head3 PSEUDOCODE
1. Validate $complain: croak if truthy but not a CODE reference.
2. Guard undef: if either input is undef â STDERR + stack trace + return 0.
3. Normalise: blessed date()-objects â call date(); hash refs â 'date' key.
4. Guard post-normalise undef â STDERR + stack trace + return 0.
5. Reject surviving reference types (croak).
6. Identity short-circuit: return 0 if left eq right (no parsing needed).
7. Taint-scrub: validate all characters against $DATE_CHARS;
also produces untainted copies safe for use under perl -T.
8. First-char semantic check: first char must be [A-S0-9] (croak if not).
9. Reject 5+ digit bare integers (croak).
10. Fast path 1: 4-digit year in both non-range strings â return if differ.
11. Fast path 2: trailing 3-4 digit year in both â return if differ.
12. Normalise LEFT (if string):
a. Strip trailing ISO T-timestamp.
b. Fast path 3: trailing 4-digit years in both â return if differ.
c. BEF/AFT on left: numeric-right or BEF-with-4-digit-right handled;
otherwise STDERR + stack trace + return 0.
d. Strip approximate prefix (Abt./ca.) or suffix (?) or month-range.
e. Fast path 4: digit-starting years on both sides â return if differ.
f. Fast path 5: left year vs right starting with lowercase "bet".
g. "YEAR or YEAR" form: use first year; fire $complain if both equal.
h. Dash / BET range on left: compare $right against [from, to].
i. Complex date string: parse via DFG (croak if DFG returns nothing).
13. Normalise RIGHT (if string):
a. BEF on right: numeric left â compare; otherwise STDERR + return 0.
b. Strip approximate prefix/suffix/month-range.
c. Bare 3-4 digit year: compare directly with left (unwrap if ref).
d. Dash / BET range on right: compare $left against [from, to].
e. Fast path 6: matching year in both â return if differ.
f. Parse right via DFG (croak if DFG returns nothing).
14. Final comparison: unwrap any remaining DateTime objects via ->year(); <=>.
=head1 SUPPORTED FORMATS
=over 4
=item * Exact dates: C<1941-08-02>, C<5/27/1872>
=item * Years only: C<1828>, C<822>
=item * Approximate dates: C<Abt. 1802>, C<ca. 1802>, C<1802 ?>
=item * Date ranges: C<1802-1803>, C<BET 1830 AND 1832>
=item * Month ranges: C<Oct/Nov/Dec 1950>
=item * Qualifiers: C<BEF 1940>, C<AFT 1855>
=back
=head1 ERROR HANDLING
When a date cannot be parsed, diagnostic messages are printed to STDERR and
the function either returns 0 (for recoverable conditions such as undef
input) or C<croak>s with a string beginning C<"Date parse failure: ">.
All C<croak> exceptions are catchable via C<eval {}>.
=head1 LIMITATIONS
=over 4
=item * B<Month-only dates>
Dates where only the month is known (no year) are not supported.
=item * B<Incomplete BEF/AFT handling>
Many C<BEF>/C<AFT> combinationsâespecially C<AFT> on the left or C<BEF>
against a non-integer rightâare not implemented and fall back to returning
0 with a STDERR diagnostic.
=item * B<Dead fast-path code>
Certain fast-path branches and a redundant C<ref($right)> guard inside the
left-side range handler are unreachable at runtime (documented in
C<t/extended_tests.t> subtest 10).
=item * B<Public singleton>
C<$Date::Cmp::dfg> is a package variable. Concurrent threads replacing it
with different mocks are not safe.
=item * B<No Sub::Private enforcement>
The C<_sanitize_for_diag> and C<_emit_stack_trace> private helpers rely on
naming convention only. Runtime enforcement via C<Sub::Private> is not yet
declared as a dependency.
=back
=cut | |||||
| 358 | ||||||
| 359 | sub datecmp | |||||
| 360 | { | |||||
| 361 | 631 | 1503632 | my ($left, $right, $complain) = @_; | |||
| 362 | ||||||
| 363 | # Reject truthy non-CODE $complain early. A falsy value (undef, 0, "") | |||||
| 364 | # is never invoked because every call site guards with "if($complain)", | |||||
| 365 | # so skipping validation for falsy values is safe. A truthy string would | |||||
| 366 | # call an arbitrary named sub under no strict refs, which is a security | |||||
| 367 | # risk â reject it now. | |||||
| 368 | 631 | 925 | if($complain && ref($complain) ne 'CODE') { | |||
| 369 | 2 | 8 | croak 'Third argument to datecmp() must be a CODE reference'; | |||
| 370 | } | |||||
| 371 | ||||||
| 372 | # Undef on entry: recoverable â print diagnostic and return 0. | |||||
| 373 | 629 | 1030 | if((!defined $left) || !defined $right) { | |||
| 374 | 11 | 91 | print STDERR "\n"; | |||
| 375 | 11 | 24 | print STDERR "left not defined\n" if !defined $left; | |||
| 376 | 11 | 24 | print STDERR "right not defined\n" if !defined $right; | |||
| 377 | 11 | 15 | _emit_stack_trace(); | |||
| 378 | 11 | 21 | return 0; | |||
| 379 | } | |||||
| 380 | ||||||
| 381 | # Unwrap: blessed object with date() â string; hashref â 'date' key. | |||||
| 382 | 618 17 | 825 24 | if(blessed($left) && $left->can('date')) { $left = $left->date(); } | |||
| 383 | 617 14 | 710 17 | if(blessed($right) && $right->can('date')) { $right = $right->date(); } | |||
| 384 | 616 36 | 531 28 | if(ref($left) eq 'HASH') { $left = $left->{'date'}; } | |||
| 385 | 616 37 | 469 30 | if(ref($right) eq 'HASH') { $right = $right->{'date'}; } | |||
| 386 | ||||||
| 387 | # A hashref without a 'date' key, or with date => undef, arrives here as | |||||
| 388 | # undef. Any other surviving reference would stringify silently â reject. | |||||
| 389 | 616 | 743 | if(!defined $left || !defined $right) { | |||
| 390 | 7 | 44 | print STDERR "\n"; | |||
| 391 | 7 | 11 | print STDERR "left date is undefined after input normalisation\n" if !defined $left; | |||
| 392 | 7 | 11 | print STDERR "right date is undefined after input normalisation\n" if !defined $right; | |||
| 393 | 7 | 8 | _emit_stack_trace(); | |||
| 394 | 7 | 16 | return 0; | |||
| 395 | } | |||||
| 396 | 609 | 417 | if(ref($left)) { | |||
| 397 | 6 | 41 | croak 'Date parse failure: left is an unsupported reference type (' . ref($left) . ')'; | |||
| 398 | } | |||||
| 399 | 603 | 404 | if(ref($right)) { | |||
| 400 | 2 | 9 | croak 'Date parse failure: right is an unsupported reference type (' . ref($right) . ')'; | |||
| 401 | } | |||||
| 402 | ||||||
| 403 | # Identical strings â no parsing needed at all. | |||||
| 404 | 601 | 525 | return 0 if $left eq $right; | |||
| 405 | ||||||
| 406 | # Taint-scrub: validate every character in both inputs. A full-string | |||||
| 407 | # anchored capture against the allowed class (a) rejects shell | |||||
| 408 | # metacharacters, newlines, NUL bytes, and HTML characters, and (b) | |||||
| 409 | # produces an untainted copy, making datecmp safe under perl -T. | |||||
| 410 | 555 | 1171 | my ($safe_left) = ($left =~ /^($DATE_CHARS+)$/); | |||
| 411 | 555 | 2968 | my ($safe_right) = ($right =~ /^($DATE_CHARS+)$/); | |||
| 412 | ||||||
| 413 | 555 | 1644 | if(!defined $safe_left) { | |||
| 414 | 21 | 110 | croak 'Date parse failure: left contains characters not permitted in a date string'; | |||
| 415 | } | |||||
| 416 | 534 | 391 | if(!defined $safe_right) { | |||
| 417 | 3 | 24 | croak 'Date parse failure: right contains characters not permitted in a date string'; | |||
| 418 | } | |||||
| 419 | ||||||
| 420 | 531 | 339 | $left = $safe_left; | |||
| 421 | 531 | 272 | $right = $safe_right; | |||
| 422 | ||||||
| 423 | # Semantic first-character check: all supported formats begin with a | |||||
| 424 | # letter A-S (month abbreviations Jan-Sep, Oct=O, Nov=N, Dec=D; prefixes | |||||
| 425 | # BEF/BET/AFT/Abt/ca) or a digit (year-first formats). Letters T-Z as | |||||
| 426 | # the leading character do not appear in any supported format. | |||||
| 427 | 531 | 647 | if($left !~ /^[A-S0-9]/i) { | |||
| 428 | 15 | 22 | _emit_stack_trace(); | |||
| 429 | 15 | 24 | croak 'Date parse failure: left = ' . _sanitize_for_diag($left); | |||
| 430 | } | |||||
| 431 | 516 | 450 | if($right !~ /^[A-S0-9]/i) { | |||
| 432 | 2 | 5 | _emit_stack_trace(); | |||
| 433 | 2 | 5 | croak 'Date parse failure: right = ' . _sanitize_for_diag($right); | |||
| 434 | } | |||||
| 435 | ||||||
| 436 | # Reject bare integers with 5+ digits â they are not valid year strings | |||||
| 437 | # and the fast-path regexes would silently extract the wrong substring. | |||||
| 438 | 514 | 1478 | if($left =~ /^\d{5,}$/) { | |||
| 439 | 2 | 8 | croak 'Date parse failure: left = ' . $left . ' (year must be 3-4 digits)'; | |||
| 440 | } | |||||
| 441 | 512 | 419 | if($right =~ /^\d{5,}$/) { | |||
| 442 | 1 | 5 | croak 'Date parse failure: right = ' . $right . ' (year must be 3-4 digits)'; | |||
| 443 | } | |||||
| 444 | ||||||
| 445 | # ââ Fast path 1 âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 446 | # Both strings contain a 4-digit year, neither is a BET/dash-range. | |||||
| 447 | # If the years differ we can return immediately without further parsing. | |||||
| 448 | 511 | 2731 | if((!ref($left)) && (!ref($right)) | |||
| 449 | && ($left =~ /\d{3,4}/) && ($right =~ /\d{3,4}/) | |||||
| 450 | && ($left !~ /^bet/i) && ($left !~ /\-/) | |||||
| 451 | && ($right !~ /^bet/i) && ($right !~ /^\d{3,4}\-\d{3,4}$/)) | |||||
| 452 | { | |||||
| 453 | 317 | 568 | if($left =~ /(\d{4})/) { | |||
| 454 | 293 | 233 | my $lyear = $1; | |||
| 455 | 293 | 263 | if($right =~ /(\d{4})/) { | |||
| 456 | 293 | 188 | my $ryear = $1; | |||
| 457 | 293 | 680 | return $lyear <=> $ryear if $lyear != $ryear; | |||
| 458 | } | |||||
| 459 | } | |||||
| 460 | } | |||||
| 461 | ||||||
| 462 | # ââ Fast path 2 âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 463 | # Both strings END with a 3-4 digit year, neither is a BET/dash-range. | |||||
| 464 | 276 | 1167 | if((!ref($left)) && (!ref($right)) | |||
| 465 | && ($left =~ /(\d{3,4})$/) && ($left !~ /^bet/i) && ($left !~ /\-/) | |||||
| 466 | && ($right !~ /^bet/i) && ($right !~ /\-/)) | |||||
| 467 | { | |||||
| 468 | 78 | 58 | my $yol = $1; | |||
| 469 | 78 | 125 | if($right =~ /(\d{3,4})$/) { | |||
| 470 | 74 | 64 | my $yor = $1; | |||
| 471 | 74 | 111 | return $yol <=> $yor if $yol != $yor; | |||
| 472 | } | |||||
| 473 | } | |||||
| 474 | ||||||
| 475 | 252 | 208 | if(!ref($left)) { | |||
| 476 | # Strip trailing ISO T-timestamp before further checks. | |||||
| 477 | 252 | 219 | $left =~ s/T\d\d:\d\d:\d\d$//; | |||
| 478 | ||||||
| 479 | # ââ Fast path 3 âââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 480 | # Both strings end with a 4-digit year (after space or slash). | |||||
| 481 | # Note: fast-paths 1 and 2 handle all cases where the extracted | |||||
| 482 | # years differ, so the return inside this block is unreachable in | |||||
| 483 | # practice â but the outer if still serves to tie-break below. | |||||
| 484 | 252 | 905 | if((!ref($right)) | |||
| 485 | && ($left =~ /(^|[\s\/])\d{4}$/) | |||||
| 486 | && ($left !~ /^bet/i) && ($left !~ /\-/) | |||||
| 487 | && ($right !~ /^bet/i) && ($right !~ /\-/) | |||||
| 488 | && ($right =~ /(^|[\s\/,])(\d{4})$/)) | |||||
| 489 | { | |||||
| 490 | 49 | 38 | my $ryear = $2; | |||
| 491 | 49 | 52 | $left =~ /(^|[\s\/])(\d{4})$/; | |||
| 492 | 49 | 33 | my $lyear = $2; | |||
| 493 | 49 | 48 | return $lyear <=> $ryear if $lyear != $ryear; | |||
| 494 | } | |||||
| 495 | ||||||
| 496 | # ââ BEF / AFT on left side âââââââââââââââââââââââââââââââââââââââ | |||||
| 497 | 252 | 378 | if($left =~ /^(bef|aft)/i) { | |||
| 498 | 4 | 6 | if($right =~ /^\d+$/) { | |||
| 499 | # e.g. "bef 1 Jun 1965" vs "1969" | |||||
| 500 | 3 | 5 | if($left =~ /\s(\d+)$/) { | |||
| 501 | 3 | 12 | return -1 if $1 < $right; | |||
| 502 | } | |||||
| 503 | } | |||||
| 504 | 1 | 2 | if($right =~ /(\d{4})/) { | |||
| 505 | # e.g. "BEF. 1932" vs "2005-06-16" | |||||
| 506 | 1 | 2 | my $ryear = $1; | |||
| 507 | 1 | 2 | if($left =~ /^bef/i && $left =~ /(\d{4})/) { | |||
| 508 | 0 | 0 | return -1 if $1 < $ryear; | |||
| 509 | } | |||||
| 510 | } | |||||
| 511 | 1 | 2 | print STDERR _sanitize_for_diag($left) . ' <=> ' . _sanitize_for_diag($right) . ": not handled yet\n"; | |||
| 512 | 1 | 2 | _emit_stack_trace(); | |||
| 513 | 1 | 2 | return 0; | |||
| 514 | } | |||||
| 515 | ||||||
| 516 | # ââ Approximate prefix / suffix stripping ââââââââââââââââââââââââ | |||||
| 517 | 248 | 565 | if($left =~ /^(Abt|ca?)\.?\s+(.+)/i) { | |||
| 518 | 14 | 14 | $left = $2; | |||
| 519 | } elsif($left =~ /(.+?)\s?\?$/) { | |||||
| 520 | 5 | 6 | $left = $1; | |||
| 521 | } elsif(($left =~ /\//) && ($left =~ /^[a-z\/]+\s+(.+)/i)) { | |||||
| 522 | # e.g. "Oct/Nov/Dec 1950" â "1950" | |||||
| 523 | 4 | 3 | $left = $1; | |||
| 524 | } | |||||
| 525 | ||||||
| 526 | # ââ Fast path 4 âââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 527 | # After stripping, both sides start with a 3-4 digit year. | |||||
| 528 | 248 | 563 | if(($left =~ /^\d{3,4}/) && ($left !~ /\-/) | |||
| 529 | && ($right =~ /^\d{3,4}/) && ($right !~ /\-/)) | |||||
| 530 | { | |||||
| 531 | 29 29 | 40 19 | $left =~ /^(\d{3,4})/; my $start = $1; | |||
| 532 | 29 29 | 29 20 | $right =~ /^(\d{3,4})/; my $end = $1; | |||
| 533 | 29 | 28 | return $start <=> $end if $start != $end; | |||
| 534 | } | |||||
| 535 | ||||||
| 536 | # ââ Fast path 5 âââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 537 | # Left contains a year; right starts with lowercase "bet" (the | |||||
| 538 | # BET range handler below uses case-insensitive /^Bet .../i so | |||||
| 539 | # this catches the bare lowercase form first). | |||||
| 540 | 248 | 289 | if($left =~ /(\d{3,4})/) { | |||
| 541 | 243 | 192 | my $start = $1; | |||
| 542 | 243 | 379 | if(($left !~ /^bet/i) && ($right =~ /^bet/)) { | |||
| 543 | 3 | 3 | if($right =~ /(\d{3,4})/) { | |||
| 544 | 3 | 2 | my $end = $1; | |||
| 545 | 3 | 12 | return $start <=> $end if $start != $end; | |||
| 546 | } | |||||
| 547 | } | |||||
| 548 | } | |||||
| 549 | ||||||
| 550 | # ââ "YEAR or YEAR" format ââââââââââââââââââââââââââââââââââââââââ | |||||
| 551 | 245 | 584 | if($left =~ /^(\d{3,4})\sor\s(\d{3,4})$/) { | |||
| 552 | 4 | 8 | my ($start, $end) = ($1, $2); | |||
| 553 | 4 | 19 | $complain->("the years are the same '$left'") if $complain && $start == $end; | |||
| 554 | 4 | 8 | $left = $start; | |||
| 555 | } | |||||
| 556 | # ââ Left-side dash / BET range âââââââââââââââââââââââââââââââââââ | |||||
| 557 | elsif(($left =~ /^(\d{3,4})\-(\d{3,4})$/) | |||||
| 558 | || ($left =~ /^Bet (\d{3,4})\sand\s(\d{3,4})$/i)) | |||||
| 559 | { | |||||
| 560 | 95 | 110 | my ($from, $to) = ($1, $2); | |||
| 561 | ||||||
| 562 | 95 | 139 | if($from == $to) { | |||
| 563 | # Degenerate range: collapse to single year. | |||||
| 564 | 6 | 13 | $complain->("from == to, $from") if $complain; | |||
| 565 | 6 | 8 | $left = $from; | |||
| 566 | ||||||
| 567 | } elsif($from > $to) { | |||||
| 568 | # Inverted range: fire complain and give up. | |||||
| 569 | 4 | 15 | $complain->("datecmp(): $from > $to in daterange '$left'") if $complain; | |||
| 570 | 4 | 12 | return 0; | |||
| 571 | ||||||
| 572 | } else { | |||||
| 573 | # Parse $right to a plain year integer if it isn't already. | |||||
| 574 | 85 | 102 | if($right !~ /^\d{4}$/) { | |||
| 575 | 13 | 40 | my @r = $dfg->parse_datetime({ date => $right, quiet => 1 }); | |||
| 576 | 13 | 247347 | if(!defined $r[0]) { | |||
| 577 | 7 | 13 | if($right =~ /[\s\/](\d{4})$/) { | |||
| 578 | # e.g. 'BET 1830 AND 1832' vs 'Oct/Nov/Dec 1821' | |||||
| 579 | # $left is the range string here; use $from/$to. | |||||
| 580 | 5 | 6 | my $year = $1; | |||
| 581 | 5 | 22 | return $year < $from ? 1 | |||
| 582 | : $year > $to ? -1 | |||||
| 583 | : 0; | |||||
| 584 | } | |||||
| 585 | # croak is catchable via eval{}, resolving the prior TODO. | |||||
| 586 | 2 | 2 | _emit_stack_trace(); | |||
| 587 | 2 | 2 | croak 'Date parse failure: right = ' . _sanitize_for_diag($right); | |||
| 588 | } | |||||
| 589 | 6 | 12 | $right = $r[0]->year(); | |||
| 590 | } | |||||
| 591 | ||||||
| 592 | # Compare the single right year against [from, to]. | |||||
| 593 | # Any year within the interval (inclusive) is "equal". | |||||
| 594 | 78 | 135 | return 1 if $right < $from; # right predates range: range is later | |||
| 595 | 67 | 84 | return -1 if $right > $to; # right postdates range: range is earlier | |||
| 596 | 51 | 104 | return 0; # right within [from, to] | |||
| 597 | } | |||||
| 598 | } | |||||
| 599 | # ââ Complex left-side date string â DFG âââââââââââââââââââââââââ | |||||
| 600 | elsif($left !~ /^\d{3,4}$/) { | |||||
| 601 | 53 | 95 | if($left !~ /^\d{4}\-\d{2}\-\d{2}$/) { | |||
| 602 | # Not an ISO date â validate it has letters in valid positions. | |||||
| 603 | 35 | 92 | if(($left !~ /[a-z]/i) || ($left =~ /[a-z]$/)) { | |||
| 604 | 6 | 8 | _emit_stack_trace(); | |||
| 605 | 6 | 14 | croak 'Date parse failure: left = ' . _sanitize_for_diag($left); | |||
| 606 | } | |||||
| 607 | } | |||||
| 608 | ||||||
| 609 | # $l[1] is preferred over $l[0]: for genealogy date ranges DFG | |||||
| 610 | # may return (start_DateTime, end_DateTime); using the end date | |||||
| 611 | # gives a consistent upper-bound for range inputs. | |||||
| 612 | 47 | 153 | my @l = $dfg->parse_datetime({ date => $left, quiet => 1 }); | |||
| 613 | 46 | 1421081 | my $rc = $l[1] || $l[0]; | |||
| 614 | 46 | 114 | if(!defined $rc) { | |||
| 615 | 2 | 5 | _emit_stack_trace(); | |||
| 616 | 2 | 13 | croak 'Date parse failure: left = ' . _sanitize_for_diag($left); | |||
| 617 | } | |||||
| 618 | 44 | 55 | $left = $rc; | |||
| 619 | } | |||||
| 620 | } | |||||
| 621 | ||||||
| 622 | # ââ Right side ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 623 | 147 | 127 | if(!ref($right)) { | |||
| 624 | # ââ BEF on right ââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 625 | 147 | 196 | if($right =~ /^bef/i) { | |||
| 626 | 5 | 7 | if($left =~ /^\d+$/) { | |||
| 627 | # e.g. "1939" vs "bef 1 Jun 1965" | |||||
| 628 | 4 | 6 | if($right =~ /\s(\d+)$/) { | |||
| 629 | 4 | 13 | return $left <=> $1; | |||
| 630 | } | |||||
| 631 | } | |||||
| 632 | 1 | 27 | print STDERR _sanitize_for_diag($left) . ' <=> ' . _sanitize_for_diag($right) . ": Before not handled\n"; | |||
| 633 | 1 | 3 | _emit_stack_trace(); | |||
| 634 | 1 | 4 | return 0; | |||
| 635 | } | |||||
| 636 | ||||||
| 637 | # ââ Approximate prefix / suffix stripping ââââââââââââââââââââââââ | |||||
| 638 | 142 | 353 | if($right =~ /^(Abt|ca?)\.?\s+(.+)/i) { | |||
| 639 | 7 | 7 | $right = $2; | |||
| 640 | } elsif($right =~ /(.+?)\s?\?$/) { | |||||
| 641 | 3 | 4 | $right = $1; | |||
| 642 | } elsif(($right =~ /\//) && ($right =~ /^[a-z\/]+\s+(.+)/i)) { | |||||
| 643 | 2 | 4 | $right = $1; | |||
| 644 | } | |||||
| 645 | ||||||
| 646 | # ââ Bare year on right âââââââââââââââââââââââââââââââââââââââââââ | |||||
| 647 | 142 | 207 | if($right =~ /^\d{3,4}$/) { | |||
| 648 | 49 | 135 | return ref($left) ? $left->year() <=> $right : $left <=> $right; | |||
| 649 | } | |||||
| 650 | ||||||
| 651 | # ââ Right-side dash / BET range ââââââââââââââââââââââââââââââââââ | |||||
| 652 | 93 | 227 | if(($right =~ /^(\d{3,4})\-(\d{3,4})$/) | |||
| 653 | || ($right =~ /^Bet (\d{3,4})\sand\s(\d{3,4})$/i)) | |||||
| 654 | { | |||||
| 655 | 69 | 105 | my ($from, $to) = ($1, $2); | |||
| 656 | ||||||
| 657 | 69 | 107 | if($from == $to) { | |||
| 658 | # Degenerate range: fire complain then compare directly. | |||||
| 659 | 8 | 19 | $complain->("from == to, $from") if $complain; | |||
| 660 | # Return immediately rather than falling through to DFG, | |||||
| 661 | # which cannot parse a bare integer. | |||||
| 662 | 8 | 24 | return ref($left) ? $left->year() <=> $from : $left <=> $from; | |||
| 663 | ||||||
| 664 | } elsif($from > $to) { | |||||
| 665 | 4 | 7 | print STDERR 'datecmp(): ' . $from . ' > ' . $to . ' in daterange ' . _sanitize_for_diag($right) . "\n"; | |||
| 666 | 4 | 6 | _emit_stack_trace(); | |||
| 667 | 4 | 10 | return 0; | |||
| 668 | ||||||
| 669 | } else { | |||||
| 670 | # Unwrap any DateTime before numeric comparison. | |||||
| 671 | 57 15 | 71 17 | if(ref($left)) { $left = $left->year(); } | |||
| 672 | ||||||
| 673 | # Compare left year against [from, to]. | |||||
| 674 | 57 | 123 | return -1 if $left < $from; # left predates range: left is earlier | |||
| 675 | 46 | 64 | return 1 if $left > $to; # left postdates range: left is later | |||
| 676 | 35 | 95 | return 0; # left within [from, to] | |||
| 677 | } | |||||
| 678 | } | |||||
| 679 | ||||||
| 680 | # ââ Fast path 6 ââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 681 | # Both sides still contain a 3-4 digit year; return if they differ. | |||||
| 682 | 24 | 38 | if($left =~ /(\d{3,4})/) { | |||
| 683 | 24 | 397 | my $start = $1; | |||
| 684 | 24 | 38 | if($right =~ /(\d{3,4})/) { | |||
| 685 | 23 | 23 | my $end = $1; | |||
| 686 | 23 | 86 | return $start <=> $end if $start != $end; | |||
| 687 | } | |||||
| 688 | } | |||||
| 689 | ||||||
| 690 | # ââ Right-side DFG parse âââââââââââââââââââââââââââââââââââââââââ | |||||
| 691 | 12 | 31 | my @r = $dfg->parse_datetime({ date => $right, quiet => 1 }); | |||
| 692 | 12 | 37259 | if(!defined $r[0]) { | |||
| 693 | 5 | 10 | if($right =~ /[\s\/](\d{4})$/) { | |||
| 694 | # e.g. "1891" vs "Oct/Nov/Dec 1892" or "5/27/1872" | |||||
| 695 | 3 | 1 | my $year = $1; | |||
| 696 | 3 | 5 | if(ref($left)) { | |||
| 697 | 2 | 3 | return $left->year() <=> $year if $left->year() != $year; | |||
| 698 | } else { | |||||
| 699 | 1 | 2 | return $left <=> $year if $left != $year; | |||
| 700 | } | |||||
| 701 | } | |||||
| 702 | # croak is catchable via eval{}, resolving the prior TODO. | |||||
| 703 | 3 | 4 | _emit_stack_trace(); | |||
| 704 | 3 | 5 | croak 'Date parse failure: right = ' . _sanitize_for_diag($right); | |||
| 705 | } | |||||
| 706 | 7 | 9 | $right = $r[0]; | |||
| 707 | } | |||||
| 708 | ||||||
| 709 | # ââ Final comparison âââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 710 | # Unwrap any remaining DateTime objects and compare numerically. | |||||
| 711 | 7 | 17 | return $left <=> $right->year() if !ref($left) && ref($right); | |||
| 712 | 4 | 12 | return $left->year() <=> $right if ref($left) && !ref($right); | |||
| 713 | 4 | 7 | return $left <=> $right; | |||
| 714 | } | |||||
| 715 | ||||||
| 716 | # âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 717 | # _sanitize_for_diag($val) | |||||
| 718 | # | |||||
| 719 | # Purpose: Produce a safe printable-ASCII rendering of a user-supplied | |||||
| 720 | # string for inclusion in log/error output. Prevents log | |||||
| 721 | # injection (CWE-117) via embedded newlines, NUL bytes, or ANSI | |||||
| 722 | # escape sequences. | |||||
| 723 | # Entry: $val â any scalar; may be undef. | |||||
| 724 | # Exit: Defined printable string â¤200 chars. '(undef)' if input is | |||||
| 725 | # undef. Non-printable bytes (outside \x20â\x7E) replaced with | |||||
| 726 | # literal periods. | |||||
| 727 | # âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 728 | sub _sanitize_for_diag { | |||||
| 729 | 38 | 44 | my ($val) = @_; | |||
| 730 | 38 | 45 | return '(undef)' if !defined $val; | |||
| 731 | 38 | 74 | (my $safe = substr($val, 0, 200)) =~ s/[^\x20-\x7E]/./g; | |||
| 732 | 38 | 345 | return $safe; | |||
| 733 | } | |||||
| 734 | ||||||
| 735 | # âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 736 | # _emit_stack_trace() | |||||
| 737 | # | |||||
| 738 | # Purpose: Write a coloured call-stack listing to STDERR so that the | |||||
| 739 | # caller can locate which line of their code triggered a | |||||
| 740 | # datecmp warning or failure. | |||||
| 741 | # Entry: Called at any depth inside datecmp. | |||||
| 742 | # Exit: Nothing returned (void). | |||||
| 743 | # Side Effects: Writes tab-indented, red-coloured lines to STDERR. | |||||
| 744 | # âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 745 | sub _emit_stack_trace { | |||||
| 746 | 54 | 49 | my $i = 0; | |||
| 747 | 54 | 82 | while(my @c = caller($i++)) { | |||
| 748 | 391 | 14311 | print STDERR "\t", colored($c[2] . ' of ' . $c[1], 'red'), "\n"; | |||
| 749 | } | |||||
| 750 | 54 | 2247 | return; | |||
| 751 | } | |||||
| 752 | ||||||
| 753 - 817 | =head1 AUTHOR Nigel Horne, C<< <njh at nigelhorne.com> >> =head1 SEE ALSO =over 4 =item * L<Test Dashboard|https://nigelhorne.github.io/Date-Cmp/coverage/> =item * L<Sort::Key::DateTime> =back =head1 SUPPORT This module is provided as-is without any warranty. Please report bugs to C<bug-date-cmp at rt.cpan.org> or via L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Date-Cmp>. perldoc Date::Cmp =head1 FORMAL SPECIFICATION =head2 datecmp [DATESTR, DIAGMSG] DATE ::= exactâ¨year: ââ© | approxâ¨year: ââ© | beforeâ¨year: ââ© | afterâ¨year: ââ© | rangeâ¨from: â; to: ââ© | invalid COMPARISON ::= lt | eq | gt | error DateCmp left?, right?: DATESTR diagnostic!: â DIAGMSG result!: COMPARISON âd: DATESTR @ validDate(d) â â l, r: DATE ⢠l = parse(left?) â§ r = parse(right?) â§ ( (l = invalid ⨠r = invalid â result! = error) â§ (l = r â result! = eq) â§ (compare(l, r, diagnostic!) = -1 â result! = lt) â§ (compare(l, r, diagnostic!) = 0 â result! = eq) â§ (compare(l, r, diagnostic!) = 1 â result! = gt) ) =head1 LICENCE AND COPYRIGHT Copyright 2025-2026 Nigel Horne. Usage is subject to the GPL2 licence terms. If you use it, please let me know. =cut | |||||
| 818 | ||||||
| 819 | 1; | |||||