File Coverage

File:blib/lib/Geo/Address/Parser/Country.pm
Coverage:96.2%

linestmtbrancondsubtimecode
1package Geo::Address::Parser::Country;
2
3
6
6
6
628024
6
83
use strict;
4
6
6
6
10
5
139
use warnings;
5
6
6
6
6
14
5
151
use Carp qw(croak carp);
7
6
6
6
1166
483302
101
use Locale::Object::Country;
8
6
6
6
1694
404546
106
use Object::Configure;
9
6
6
6
19
6
139
use Params::Get;
10
6
6
6
13
6
129
use Params::Validate::Strict qw(validate_strict);
11
6
6
6
16
9
3594
use Return::Set qw(set_return);
12
13our $VERSION = '0.04';
14
15# Direct component-to-country mappings, keyed on lowercase component.
16# Values are either a plain country string, or a hashref with
17# 'country' and optional 'warning' keys.
18my %DIRECT = (
19        'england'                => 'United Kingdom',
20        'scotland'               => 'United Kingdom',
21        'wales'                  => 'United Kingdom',
22        'isle of man'            => 'United Kingdom',
23        'northern ireland'       => 'United Kingdom',
24        'uk'                     => 'United Kingdom',
25        'england, uk'            => 'United Kingdom',
26        'england uk'             => 'United Kingdom',
27
28        # Malformed but seen in real data
29        'scot'                   => {
30                country => 'United Kingdom',
31                warning => "country should be 'Scotland' not 'Scot'",
32        },
33
34        # US variants
35        'usa'                    => 'United States',
36        'us'                     => 'United States',
37        'u.s.a.'                 => 'United States',
38        'united states of america' => 'United States',
39        'united states'          => 'United States',
40
41        # German historical names
42        'preussen'               => 'Germany',
43        "preu\x{00DF}en"         => 'Germany',
44        'deutschland'            => 'Germany',
45
46        # Dutch variants
47        'holland'                => 'Netherlands',
48        'the netherlands'        => 'Netherlands',
49        # 'nl'                   => {        # Not a common abbreviation and clashes with Newfoundland in Canada
50                # country => 'Netherlands',
51                # warning => 'assuming country is Netherlands',
52        # },
53
54        # Slovenian historical name
55        'slovenija'              => 'Slovenia',
56
57        # Canadian provinces/territories missing their country
58        'nova scotia'            => {
59                country => 'Canada',
60                warning => "country 'Canada' missing from record",
61        },
62        'newfoundland'           => {
63                country => 'Canada',
64                warning => "country 'Canada' missing from record",
65        },
66        'nfld'                   => {
67                country => 'Canada',
68                warning => "country 'Canada' missing from record",
69        },
70        'ns'                     => {
71                country => 'Canada',
72                warning => "country 'Canada' missing from record",
73        },
74        'can.'                   => {
75                country => 'Canada',
76                warning => "country 'Canada' missing from record",
77        },
78);
79
80# Schema for new() arguments, used by Params::Validate::Strict
81my $NEW_SCHEMA = {
82        us    => { type => 'object' },
83        ca_en => { type => 'object' },
84        ca_fr => { type => 'object' },
85        au    => { type => 'object' },
86        geonames => {
87                type     => 'object',
88                # can      => 'search',      # Geo::GeoNames is broken, it uses AUTOLOAD.  I must fix that.
89                optional => 1,
90        },
91};
92
93# Schema for resolve() arguments.
94# component is optional: when absent, resolve() extracts it from place
95# automatically as the last comma-separated token.
96my $RESOLVE_SCHEMA = {
97        place     => { type => 'string', min => 1 },
98        component => { type => 'string', min => 1, optional => 1 },
99};
100
101# Schema for the resolve() return value
102my $RESOLVE_RETURN_SCHEMA = {
103        type   => 'hashref',
104        schema => {
105                country  => { type => 'string',   optional => 1 },
106                place    => { type => 'string',   min => 1 },
107                warnings => { type => 'arrayref' },
108                unknown  => { type => 'boolean' },
109        },
110};
111
112# Schema for normalise_place() arguments
113my $NORMALISE_SCHEMA = {
114        place => { type => 'string', min => 1 },
115};
116
117# Schema for the normalise_place() return value
118my $NORMALISE_RETURN_SCHEMA = {
119        type   => 'hashref',
120        schema => {
121                place    => { type => 'string', min => 1 },
122                warnings => { type => 'arrayref' },
123        },
124};
125
126 - 284
=head1 NAME

Geo::Address::Parser::Country - Resolve a place string component to a
canonical country name

=head1 VERSION

Version 0.04

=head1 SYNOPSIS

    use Geo::Address::Parser::Country;
    use Locale::US;
    use Locale::CA;
    use Locale::AU;

    my $resolver = Geo::Address::Parser::Country->new({
        us    => Locale::US->new(),
        ca_en => Locale::CA->new(lang => 'en'),
        ca_fr => Locale::CA->new(lang => 'fr'),
        au    => Locale::AU->new(),
    });

    # Simple form: component extracted automatically from place
    my $result = $resolver->resolve(
        place => 'Ramsgate, Kent, England',
    );

    # Explicit form: caller supplies the component directly
    my $result = $resolver->resolve(
        component => 'England',
        place     => 'Ramsgate, Kent, England',
    );

    # $result->{country}  eq 'United Kingdom'
    # $result->{place}    eq 'Ramsgate, Kent, England'
    # $result->{warnings} is []
    # $result->{unknown}  is 0

=head1 DESCRIPTION

Resolves the last comma-separated component of a place string into a
canonical country name. Handles common variants, abbreviations, and
historical names found in genealogy data and other poorly-normalised
address sources.

Designed specifically to tolerate poor-quality data from software
imports where place strings may be inconsistent, abbreviated, or use
historical country names no longer in common use.

Resolution proceeds through the following steps in order:

=over 4

=item 1. Direct lookup table (covers historical names, abbreviations,
common variants)

=item 2. US state code or name via Locale::US

=item 3. Canadian province code or name via Locale::CA (English and French)

=item 4. Australian state code or name via Locale::AU

=item 5. Locale::Object::Country by name

=item 6. Geo::GeoNames search (optional, only if object provided at
construction)

=item 7. Unknown - returns with C<unknown =E<gt> 1>

=back

=head1 TODO

=over 4

=item * Complete C<normalise_place()> to handle missing commas before
country and state names in raw uncleaned input strings. Poor data
import means strings like C<"Houston TX USA"> or
C<"Some Place England"> need comma insertion before component
extraction can work correctly. This should be called before
C<resolve()> for raw uncleaned input.

=back

=head1 METHODS

=head2 new

=head3 Purpose

Constructs a new resolver object. The locale objects are used for
state and province lookups and are retained for the lifetime of the
object.

=head3 API Specification

=head4 Input

    {
        us    => { type => 'object' },  # Locale::US instance
        ca_en => { type => 'object' },  # Locale::CA English instance
        ca_fr => { type => 'object' },  # Locale::CA French instance
        au    => { type => 'object' },  # Locale::AU instance
        geonames => {                   # Optional Geo::GeoNames instance
            type     => 'object',
            optional => 1,
        },
    }

=head4 Output

    { type => 'object', isa => 'Geo::Address::Parser::Country' }

=head3 Arguments

=over 4

=item * C<us> - A L<Locale::US> instance. Required.

=item * C<ca_en> - A L<Locale::CA> instance with C<lang =E<gt> 'en'>. Required.

=item * C<ca_fr> - A L<Locale::CA> instance with C<lang =E<gt> 'fr'>. Required.

=item * C<au> - A L<Locale::AU> instance. Required.

=item * C<geonames> - An optional L<Geo::GeoNames> instance used as a
last-resort fallback when all other resolution methods fail.

=back

=head3 Returns

A blessed C<Geo::Address::Parser::Country> object.

=head3 Side Effects

None.

=head3 Notes

The locale objects are stored by reference and shared for all calls to
C<resolve()>. Constructing them once and reusing the resolver object
is more efficient than constructing a new resolver for each lookup.

C<Object::Configure> is used after validation to allow locale objects
to be supplied via environment variables or a config file rather than
always being passed explicitly.

=head3 Example

    my $resolver = Geo::Address::Parser::Country->new({
        us    => Locale::US->new(),
        ca_en => Locale::CA->new(lang => 'en'),
        ca_fr => Locale::CA->new(lang => 'fr'),
        au    => Locale::AU->new(),
    });

=cut
285
286sub new {
287
209
747394
        my $class = shift;
288
289        # Accept both hashref and flat list via Params::Get
290
209
432
        my $args = Params::Get::get_params(undef, @_);
291
292        # Validate all constructor arguments strictly
293
209
2562
        validate_strict({
294                description => 'Geo::Address::Parser::Country::new',
295                input       => $args,
296                schema      => $NEW_SCHEMA,
297        });
298
299
204
27389
        $args = Object::Configure::configure($class, $args);
300
301        # Build and return the blessed object
302        return bless {
303                us       => $args->{us},
304                ca_en    => $args->{ca_en},
305                ca_fr    => $args->{ca_fr},
306                au       => $args->{au},
307                geonames => $args->{geonames},
308
204
439746
        }, $class;
309}
310
311 - 412
=head2 resolve

=head3 Purpose

Resolves the last comma-separated component of a place string to a
canonical country name, and returns the (possibly modified) place
string alongside any warnings generated during resolution.

=head3 API Specification

=head4 Input

    {
        place     => { type => 'string', min => 1 },           # required
        component => { type => 'string', min => 1, optional => 1 },
    }

=head4 Output

    {
        type   => 'hashref',
        schema => {
            country  => { type => 'string',   optional => 1 },
            place    => { type => 'string',   min => 1 },
            warnings => { type => 'arrayref' },
            unknown  => { type => 'boolean' },
        },
    }

=head3 Arguments

=over 4

=item * C<place> - The full place string, e.g.
C<"Ramsgate, Kent, England">. Required. May be modified by appending a
country suffix where needed.

=item * C<component> - The last comma-separated component of the place
string, e.g. C<"England">, C<"TX">, C<"NSW">. Optional. When absent,
C<resolve()> extracts it automatically as the last comma-separated
token of C<place>. When C<place> contains no comma, the entire
C<place> string is used as the component. Supplying C<component>
explicitly is useful when the caller already has it available from a
structured data source.

=back

=head3 Returns

A hashref containing:

=over 4

=item * C<country> - The canonical country name as a string, e.g.
C<"United Kingdom">. C<undef> if resolution failed.

=item * C<place> - The full place string, possibly with a country
suffix appended (e.g. C<", USA">). Always returned even if unmodified.

=item * C<warnings> - An arrayref of warning strings generated during
resolution. May be empty. The caller is responsible for acting on
these, e.g. by passing them to a C<complain()> function.

=item * C<unknown> - A boolean. True if the country could not be
resolved by any method.

=back

=head3 Side Effects

None. All warnings are returned to the caller rather than emitted
directly.

=head3 Notes

Resolution order is: direct lookup, US state, Canadian province,
Australian state, Locale::Object::Country, GeoNames (if available).
The first successful match wins.

When a US state, Canadian province, or Australian state is recognised,
the appropriate country string (C<", USA">, C<", Canada">,
C<", Australia">) is appended to C<place> if not already present.

=head3 Example

    # Simple form - component extracted automatically
    my $result = $resolver->resolve(
        place => 'Houston, TX',
    );

    # Explicit form - component supplied by caller
    my $result = $resolver->resolve(
        component => 'TX',
        place     => 'Houston, TX',
    );

    # $result->{country}     eq 'United States'
    # $result->{place}       eq 'Houston, TX, USA'
    # $result->{warnings}[0] eq 'TX: assuming country is United States'
    # $result->{unknown}     is 0

=cut
413
414sub resolve {
415
1363
325802
        my $self = shift;
416
417        # Accept both hashref and flat list
418
1363
1587
        my $args = Params::Get::get_params(undef, @_);
419
420        # Validate input arguments
421
1363
13809
        validate_strict({
422                description => 'Geo::Address::Parser::Country::resolve',
423                input       => $args,
424                schema      => $RESOLVE_SCHEMA,
425        });
426
427
1363
122961
        my $place = $args->{place};
428
429        # Extract the component from the place string when the caller has not
430        # supplied it explicitly.  The component is the last comma-separated
431        # token, trimmed of surrounding whitespace.  When the place string
432        # contains no comma at all, the entire string is the component.
433        my $component = $args->{component}
434
1363
1484
                // do {
435
22
122
                        my ($c) = $place =~ /(?:^|,)\s*([^,]+?)\s*$/;
436
22
48
                        $c // $place;
437                };
438
439
1363
1017
        my $lc = lc($component);
440
441        # Accumulate warnings to return to caller rather than emitting them
442
1363
960
        my @warnings;
443        my $country;
444
445        # Step 1: check the direct lookup table first, handles historical
446        # names, common abbreviations and malformed but real-world input
447
1363
5205
        if(my $match = $DIRECT{$lc}) {
448
303
290
                if(ref($match) eq 'HASH') {
449
21
23
                        $country = $match->{country};
450
21
35
                        push @warnings, $match->{warning} if $match->{warning};
451                } else {
452
282
223
                        $country = $match;
453                }
454        }
455
456        # Step 2: two-letter US state code, e.g. "TX"
457        elsif($component =~ /^[A-Z]{2}$/i
458                && $self->{us}{code2state}{uc($component)}) {
459
652
481
                $country = 'United States';
460
652
579
                push @warnings, "$component: assuming country is United States";
461
652
656
                $place = $self->_append_country($place, 'USA');
462        }
463
464        # Step 3: US state full name, e.g. "Texas"
465        elsif($self->{us}{state2code}{uc($component)}) {
466
4
5
                $country = 'United States';
467
4
8
                push @warnings, "$component: assuming country is United States";
468
4
8
                $place = $self->_append_country($place, 'USA');
469        }
470
471        # Step 4: Canadian province code in English or French
472        elsif($component =~ /^[A-Z]{2}$/i
473                && ($self->{ca_en}{code2province}{uc($component)}
474                || $self->{ca_fr}{code2province}{uc($component)})) {
475
95
74
                $country = 'Canada';
476
95
107
                push @warnings, "$component: assuming country is Canada";
477
95
122
                $place = $self->_append_country($place, 'Canada');
478        }
479
480        # Step 5: Canadian province full name in English or French
481        elsif($self->{ca_en}{province2code}{uc($component)}
482                || $self->{ca_fr}{province2code}{uc($component)}) {
483
6
7
                $country = 'Canada';
484
6
11
                push @warnings, "$component: assuming country is Canada";
485
6
10
                $place = $self->_append_country($place, 'Canada');
486        }
487
488        # Step 6: Australian state code, e.g. "NSW", "VIC"
489        elsif($component =~ /^[A-Z]{2,3}$/i
490                && $self->{au}{code2state}{$component}) {
491
82
68
                $country = 'Australia';
492
82
93
                push @warnings, "$component: assuming country is Australia";
493
82
98
                $place = $self->_append_country($place, 'Australia');
494        }
495
496        # Step 7: Australian state full name
497        elsif($self->{au}{state2code}{uc($component)}) {
498
3
4
                $country = 'Australia';
499
3
5
                push @warnings, "$component: assuming country is Australia";
500
3
8
                $place = $self->_append_country($place, 'Australia');
501        }
502
503        # Step 8: fall back to Locale::Object::Country by name
504        elsif(my $loc = Locale::Object::Country->new(name => $component)) {
505
103
825178
                $country = $loc->name();
506        }
507
508        # Step 9: optional GeoNames fallback for anything still unresolved
509        elsif($self->{geonames}) {
510
18
6190
                $country = $self->_geonames_lookup(
511                        $place, $component, \@warnings
512                );
513        }
514
515        # Build and validate the return structure before handing back
516
1362
32564
        my $result = {
517                country  => $country,
518                place    => $place,
519                warnings => \@warnings,
520                unknown  => defined($country) ? 0 : 1,
521        };
522
523
1362
1574
        return set_return($result, $RESOLVE_RETURN_SCHEMA);
524}
525
526 - 601
=head2 normalise_place

=head3 Purpose

Inserts missing commas into a raw, uncleaned place string so that
C<resolve()> can reliably extract the last component. Raw input from
poor-quality data imports frequently omits the commas that separate
city, state, and country tokens.

=head3 API Specification

=head4 Input

    {
        place => { type => 'string', min => 1 },
    }

=head4 Output

    {
        type   => 'hashref',
        schema => {
            place    => { type => 'string', min => 1 },
            warnings => { type => 'arrayref' },
        },
    }

=head3 Arguments

=over 4

=item * C<place> - The raw place string to normalise, e.g.
C<"Houston TX USA"> or C<"Some Place England">. Required.

=back

=head3 Returns

A hashref containing:

=over 4

=item * C<place> - The normalised place string with commas inserted
where they were missing, e.g. C<"Houston, TX, USA">. Always returned
even if no changes were made.

=item * C<warnings> - An arrayref of warning strings generated during
normalisation, e.g. noting where commas were inserted. May be empty.

=back

=head3 Side Effects

None.

=head3 Notes

This method is not yet fully implemented. It currently returns the
place string unchanged. Implementation requires scanning the token
sequence against the locale tables (US states, Canadian provinces,
Australian states, and the %DIRECT country table) to identify where
comma boundaries belong.

Call this method before C<resolve()> when working with raw input that
may lack commas:

    my $norm   = $resolver->normalise_place(place => 'Houston TX USA');
    my $result = $resolver->resolve(place => $norm->{place});

=head3 Example

    my $norm = $resolver->normalise_place(place => 'Some Place England');
    # $norm->{place}    eq 'Some Place, England'   (once implemented)
    # $norm->{warnings} contains a note about comma insertion

=cut
602
603sub normalise_place {
604
17
7862
        my $self = shift;
605
606        # Accept both hashref and flat list
607
17
30
        my $args = Params::Get::get_params(undef, @_);
608
609        # Validate input arguments
610
17
190
        validate_strict({
611                description => 'Geo::Address::Parser::Country::normalise_place',
612                input       => $args,
613                schema      => $NORMALISE_SCHEMA,
614        });
615
616
16
1005
        my $place    = $args->{place};
617
16
12
        my @warnings;
618
619        # TODO: scan tokens against locale tables and %DIRECT, inserting
620        # commas where boundaries are identified.  For example:
621        #   'Houston TX USA'   -> 'Houston, TX, USA'
622        #   'Some Place England' -> 'Some Place, England'
623        # This requires iterating right-to-left through whitespace-separated
624        # tokens, matching each against known country/state/province names,
625        # and inserting a comma immediately before the first match found.
626
627
16
24
        my $result = {
628                place    => $place,
629                warnings => \@warnings,
630        };
631
632
16
21
        return set_return($result, $NORMALISE_RETURN_SCHEMA);
633}
634
635# _append_country
636#
637# Purpose:
638#   Appends a country suffix to a place string if not already present.
639#
640# Entry criteria:
641#   $self   - blessed object
642#   $place  - non-empty place string
643#   $suffix - country string to append, e.g. 'USA'
644#
645# Exit status:
646#   Returns the (possibly modified) place string.
647#
648# Side effects:
649#   None.
650#
651# Notes:
652#   Uses a case-insensitive check to avoid double-appending.
653#   E.g. 'Houston, TX' becomes 'Houston, TX, USA'.
654#   'Houston, TX, USA' is returned unchanged.
655
656sub _append_country {
657
852
1157
        my ($self, $place, $suffix) = @_;
658
659        # Return unchanged if suffix already present at end of string
660
852
5493
        return $place if $place =~ /,\s*\Q$suffix\E\s*$/i;
661
662
840
1104
        return "$place, $suffix";
663}
664
665# _geonames_lookup
666#
667# Purpose:
668#   Uses the optional Geo::GeoNames object to search for a country
669#   name when all other resolution methods have failed.
670#
671# Entry criteria:
672#   $self      - blessed object with {geonames} set
673#   $place     - full place string, used as the search query for
674#                maximum context
675#   $component - original component string, used in warning text
676#   $warnings  - arrayref to push warnings onto
677#
678# Exit status:
679#   Returns the country name string on success, undef on failure.
680#
681# Side effects:
682#   Pushes a warning onto $warnings if a country is found via GeoNames.
683#
684# Notes:
685#   We search on the full place string rather than just the component
686#   to give GeoNames maximum context and improve result accuracy.
687#   The first result is used.
688
689sub _geonames_lookup {
690
22
58
        my ($self, $place, $component, $warnings) = @_;
691
692        my $result = $self->{geonames}->search(
693
22
36
                q     => $place,
694                style => 'FULL',
695        );
696
697        # Normalise to the first element if an arrayref was returned.
698        # Guard against a bare scalar (e.g. an error string) being returned.
699
21
95
        if(ref($result) eq 'ARRAY') {
700
18
24
                $result = $result->[0];
701        } elsif(!ref($result)) {
702
2
2
                return;         # scalar return is not a hashref — bail out cleanly
703        }
704
705        # Must be a plain hashref at this point
706
19
30
        return unless ref($result) eq 'HASH';
707
708        # Extract country name; must be a defined, non-ref, non-empty string
709
12
14
        my $country = $result->{countryName};
710
12
43
        return unless defined($country)
711                           && !ref($country)
712                           && length($country);
713
714
7
7
8
10
        push @{$warnings}, "$component: assuming country is $country";
715
7
12
        return $country;
716}
717
718 - 795
=head1 AUTHOR

Nigel Horne C<< <njh@nigelhorne.com> >>

=head1 REPOSITORY

L<https://github.com/nigelhorne/Geo-Address-Parser-Country>

=head1 SUPPORT

This module is provided as-is without any warranty.

Please report any bugs or feature requests to C<bug-geo-address-parser at rt.cpan.org>,
or through the web interface at
L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Geo-Address-Parser-Country>.
I will be notified, and then you'll
automatically be notified of progress on your bug as I make changes.

=head1 BUGS

=over 4

=item * C<normalise_place()> is not yet implemented. It currently
returns the place string unchanged. See L</normalise_place> for
details of the planned behaviour.

=item * The step 6 Australian state code lookup uses the raw,
un-normalised component as the hash key, making it case-sensitive
unlike steps 2-5. Lowercase codes such as C<nsw> will not match.
A fix to apply C<uc($component)> consistently is pending.

=item * C<Geo::GeoNames> generates its query methods via C<AUTOLOAD>,
so C<can('search')> returns false at the Perl level even though
C<$geonames-E<gt>search(...)> works correctly at runtime. The
C<can =E<gt> 'search'> schema check has been commented out as a
temporary workaround pending a fix to C<Geo::GeoNames> itself.

=back

Please report additional bugs via the GitHub issue tracker:
L<https://github.com/nigelhorne/Geo-Address-Parser-Country/issues>

=head1 SEE ALSO

=over 4

=item * L<Test Dashboard|https://nigelhorne.github.io/Geo-Address-Parser-Country/coverage/>

=item * L<Geo::Address::Parser>

=item * L<Locale::US>

=item * L<Locale::CA>

=item * L<Locale::AU>

=item * L<Locale::Object::Country>

=item * L<Geo::GeoNames>

=item * L<Object::Configure>

=item * L<Params::Get>

=item * L<Params::Validate::Strict>

=item * L<Return::Set>

=back

=head1 LICENCE AND COPYRIGHT

Copyright 2026 Nigel Horne.

Usage is subject to GPL2 licence terms.
If you use it, please let me know.

=cut
796
7971;