File Coverage

File:blib/lib/Date/Cmp.pm
Coverage:92.8%

linestmtbrancondsubtimecode
1package 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);
17our @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")
30Readonly 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
44our $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()".
48our $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
359sub 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# ─────────────────────────────────────────────────────────────────────────────
728sub _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# ─────────────────────────────────────────────────────────────────────────────
745sub _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
8191;