File Coverage

File:blib/lib/App/FileSync/I18N.pm
Coverage:89.7%

linestmtbrancondsubtimecode
1package App::FileSync::I18N;
2
3# Message catalogue lookup, interpolation and error raising for App::FileSync.
4# Every user-facing string in the distribution is a key here, so a translation
5# is one new catalogue rather than a hunt through the code.
6
7
328
328
328
77603
247
4520
use App::FileSync::Platform;
8
9
328
328
328
526
220
2494
use strict;
10
328
328
328
419
265
5749
use warnings;
11
328
328
328
722
6023
803
use autodie qw(:all);
12
13
328
328
328
707600
285
9164
use Carp qw(croak carp confess);
14
328
328
328
605
250
4444
use Exporter qw(import);
15
328
328
328
1307
2223
7351
use Readonly;
16
328
328
328
926
28349
1015
use Sub::Private { mode => 'enforce' };
17
328
328
328
32867
277
7408
use Scalar::Util qw(blessed);
18
19
328
328
328
64087
349
6714
use App::FileSync::I18N::en;
20
21
328
328
328
658
224
501
use version; our $VERSION = version->declare('0.001.1');
22
23our @EXPORT_OK = qw(message throw warning os_error strip_location);
24
25# Croak and carp report the first caller outside the message machinery, not
26# the line inside it that raised the error.
27$Carp::Internal{ (__PACKAGE__) }++;
28
29# The language every lookup falls back to, and the catalogue that must hold
30# every key.
31Readonly my $FALLBACK => 'en';
32
33# Selector keys inside a catalogue entry, see "CATALOGUE FORMAT" below.
34Readonly my $SELECT_KEY    => '_by';
35Readonly my $SELECT_COUNT  => 'count';
36Readonly my $SELECT_GENDER => 'gender';
37Readonly my $OTHER         => 'other';
38
39# Environment variables consulted, in order, when no language is configured.
40# FILESYNC_LANG lets a cron job choose a language without touching the
41# C library's locale.
42Readonly my @LOCALE_VARIABLES => qw(FILESYNC_LANG LC_ALL LC_MESSAGES LANG);
43
44# Territory to language, for a locale whose language has no catalogue but whose
45# country does (for example "xx_DE" or a bare "DE").  Keys are upper case; the
46# lookup upper-cases its input, so "gb" and "Gb" work too.
47Readonly my %COUNTRY_LANGUAGE => (
48        AT => 'de', AU => 'en', BE => 'fr', CA => 'en', CH => 'de', CN => 'zh',
49        DE => 'de', ES => 'es', FR => 'fr', GB => 'en', HK => 'zh', IE => 'en',
50        IN => 'en', IT => 'it', JP => 'ja', KR => 'ko', NL => 'nl', NZ => 'en',
51        PT => 'pt', SG => 'zh', TW => 'zh', UK => 'en', US => 'en',
52);
53
54# Plural category for a count, per language (a small subset of CLDR).  Anything
55# not listed uses the English rule.
56Readonly my %PLURAL_RULE => (
57        en => sub { $_[0] == 1 ? 'one' : $OTHER },
58        de => sub { $_[0] == 1 ? 'one' : $OTHER },
59        fr => sub { ($_[0] == 0 || $_[0] == 1) ? 'one' : $OTHER },
60        ja => sub { $OTHER },
61        ko => sub { $OTHER },
62        zh => sub { $OTHER },
63);
64
65# Settings, in the shape Object::Configure fills: "language" overrides the
66# environment, and "trace" makes throw() confess with a stack trace.
67our %config = (
68        language => undef,
69        trace    => $ENV{FILESYNC_TRACE} ? 1 : 0,
70);
71
72# Registered catalogues by language.  English is always present.
73my %CATALOG = ($FALLBACK => \%App::FileSync::I18N::en::MESSAGES);
74
75=encoding utf8
76
77 - 122
=head1 NAME

App::FileSync::I18N - message catalogue and error raising for App::FileSync

=head1 SYNOPSIS

        use App::FileSync::I18N qw(message throw);

        print message('cli.sync.summary', { repo => 'docs', dry => '', pulled => 2, pushed => 0, deleted => 0, conflicts => 0 }), "\n";
        throw('path.unsafe', { path => $path, reason => $why });

=head1 DESCRIPTION

Every user-facing string is looked up by key in a catalogue.  English is built
in (L<App::FileSync::I18N::en>); other languages are added with
L</register>.  The language is chosen from C<$config{language}>, then
C<FILESYNC_LANG>, C<LC_ALL>, C<LC_MESSAGES> and C<LANG>, falling back to
English for any language or key that has no catalogue entry.

=head2 CATALOGUE FORMAT

An entry is either a string or a selector hash.

A string may contain C<%{name}FMT> placeholders, where FMT is any C<sprintf>
conversion (C<%{path}s>, C<%{count}d>, C<%{ratio}.1f>).  C<%%> is a literal
percent sign.  An undefined argument renders as the empty string (or 0 for a
numeric conversion).

A selector hash chooses a form by count or by gender, and may nest:

        'trash.pruned' => {
                _by   => 'count',
                one   => 'removed %{count}d day of trash',
                other => 'removed %{count}d days of trash',
        },
        'admin.owner' => {
                _by    => 'gender',
                female => '%{name}s owns her repo',
                male   => '%{name}s owns his repo',
                other  => '%{name}s owns their repo',
        },

The count form is chosen by the language's plural rule from C<< $args->{count} >>,
and the gender form from C<< $args->{gender} >>; C<other> is the fallback in both.

=cut
123
124 - 171
=head1 FUNCTIONS

=head2 register

Purpose: add or replace the catalogue for a language.

Args: C<$language> (two or three lower-case letters), C<\%entries>.

Returns: the language code.

Side Effects: later lookups in that language use the new entries.  Keys the
catalogue does not hold still fall back to English.

Usage:

        App::FileSync::I18N::register(de => { 'path.empty' => 'leerer Pfad' });

=head3 EXAMPLE

        App::FileSync::I18N::register(fr => { 'cli.no_repos' => 'Aucun depot configure' });
        print App::FileSync::I18N::message('cli.no_repos', {}, 'fr'), "\n";

=head3 API SPECIFICATION

=head4 INPUT

        [
                { name => 'language', type => 'string', matches => qr/\A[a-z]{2,3}\z/ },
                { name => 'entries',  type => 'hashref' },
        ]

=head4 OUTPUT

        { type => 'string', matches => qr/\A[a-z]{2,3}\z/ }

=head3 MESSAGES

        Key                  Meaning                         Resolution
        i18n.bad_language    language code is malformed      pass "de", not "de_DE"
        i18n.bad_catalogue   entries is not a hash           pass a hashref

=head3 FORMAL SPECIFICATION

        Î”Catalog
        lang? : LANG ; entries? : KEY ⇸ ENTRY
        Catalog' = Catalog ⊕ {lang? ↦ entries?}

=cut
172
173sub register {
174
4
86886
        my ($language, $entries) = @_;
175
176
4
14
        throw('i18n.bad_language', { language => $language })
177                unless defined $language && $language =~ /\A[a-z]{2,3}\z/;
178
4
7
        throw('i18n.bad_catalogue', { language => $language })
179                unless ref($entries) eq 'HASH';
180
181
4
4
        $CATALOG{$language} = $entries;
182
4
5
        return $language;
183}
184
185 - 225
=head2 unregister

Purpose: remove a registered catalogue (English cannot be removed).

Args: C<$language>.

Returns: 1 when a catalogue was removed, otherwise 0.

Side Effects: later lookups in that language fall back to English.

Usage:

        App::FileSync::I18N::unregister('de');

=head3 EXAMPLE

        App::FileSync::I18N::register(de => {});
        App::FileSync::I18N::unregister('de');

=head3 API SPECIFICATION

=head4 INPUT

        [ { name => 'language', type => 'string' } ]

=head4 OUTPUT

        { type => 'integer', min => 0, max => 1 }

=head3 MESSAGES

None.

=head3 FORMAL SPECIFICATION

        Î”Catalog
        lang? : LANG ; r! : 𝔹
        lang? ≠ en ⇒ Catalog' = {lang?} ⩤ Catalog
        r! ⇔ lang? ≠ en ∧ lang? ∈ dom Catalog

=cut
226
227sub unregister {
228
1
2
        my ($language) = @_;
229
230        my $removed = (defined $language && $language ne $FALLBACK
231
1
5
                && delete $CATALOG{$language}) ? 1 : 0;
232
1
6
        return $removed;
233}
234
235 - 284
=head2 parse_locale

Purpose: split a POSIX locale name into language and country.

Args: C<$locale>, such as C<de_DE.UTF-8>, C<en_GB>, C<fr>, C<DE> or C<C>.

Returns: a hashref C<{ language =E<gt> 'de', country =E<gt> 'DE' }>; either
value is C<undef> when the name does not carry it.  Matching is
case-insensitive; the language is returned in lower case and the country in
upper case.

Side Effects: none.

Usage:

        my $parts = App::FileSync::I18N::parse_locale('zh_CN.UTF-8');

=head3 EXAMPLE

        my $parts = App::FileSync::I18N::parse_locale('EN_gb.utf8');
        # { language => 'en', country => 'GB' }

=head3 API SPECIFICATION

=head4 INPUT

        [ { name => 'locale', type => 'string', optional => 1 } ]

=head4 OUTPUT

        {
                type => 'hashref',
                schema => {
                        language => { type => 'string', optional => 1 },
                        country  => { type => 'string', optional => 1 },
                },
        }

=head3 MESSAGES

None.

=head3 FORMAL SPECIFICATION

        parse : LOCALE → (LANG ∪ {⊥}) × (COUNTRY ∪ {⊥})
        parse("ll_CC.enc@mod") = (lower ll, upper CC)
        parse("CC") = (⊥, upper CC)    where CC is two letters in upper case
        parse("C") = parse("POSIX") = (⊥, ⊥)

=cut
285
286sub parse_locale {
287
1162
1089
        my ($locale) = @_;
288
289
1162
1671
        my %parts = (language => undef, country => undef);
290
291        # "C" and "POSIX" name no language at all; an empty value is not a locale.
292
1162
3465
        if (defined $locale && length $locale && $locale !~ /\A(?:C|POSIX)(?:\.|\z)/i) {
293
36
87
                if ($locale =~ /\A([A-Za-z]{2,3})(?:[_-]([A-Za-z]{2}))?(?:[.@].*)?\z/) {
294
36
46
                        my ($first, $second) = ($1, $2);
295
296                        # A bare upper-case pair is a country code ("DE"), as a GeoIP
297                        # lookup or a web form would supply; anything else leads with a
298                        # language.
299
36
61
                        if (!defined $second && $first =~ /\A[A-Z]{2}\z/) {
300
7
8
                                $parts{country} = $first;
301                        } else {
302
29
32
                                $parts{language} = lc $first;
303
29
34
                                $parts{country}  = uc $second if defined $second;
304                        }
305                }
306        }
307
308
1162
1133
        return \%parts;
309}
310
311 - 348
=head2 country_language

Purpose: the language usually spoken in a country.

Args: C<$country>, an ISO 3166-1 alpha-2 code in any case.

Returns: a lower-case language code, or C<undef> for an unknown country.

Side Effects: none.

Usage:

        my $language = App::FileSync::I18N::country_language('fr');     # 'fr'

=head3 EXAMPLE

        print App::FileSync::I18N::country_language('CN'), "\n";      # zh

=head3 API SPECIFICATION

=head4 INPUT

        [ { name => 'country', type => 'string', optional => 1 } ]

=head4 OUTPUT

        { type => 'string', optional => 1, matches => qr/\A[a-z]{2}\z/ }

=head3 MESSAGES

None.

=head3 FORMAL SPECIFICATION

        country_language : COUNTRY ⇸ LANG
        country_language(c) = CountryLanguage(upper c)   if upper c ∈ dom CountryLanguage

=cut
349
350sub country_language {
351
1173
6952
        my ($country) = @_;
352
353
1173
1421
        return defined $country ? $COUNTRY_LANGUAGE{ uc $country } : undef;
354}
355
356 - 409
=head2 language

Purpose: the catalogue language a lookup will use.

Args: optional C<$wanted>, a language code or locale name that overrides the
configuration and the environment.

Returns: a language code that has a registered catalogue; English when nothing
better matches.

Side Effects: none.

Usage:

        my $language = App::FileSync::I18N::language();
        my $german   = App::FileSync::I18N::language('de_DE.UTF-8');

=head3 EXAMPLE

        local $ENV{LC_ALL} = 'fr_FR.UTF-8';
        print App::FileSync::I18N::language(), "\n";  # fr when registered, else en

=head3 API SPECIFICATION

=head4 INPUT

        [ { name => 'wanted', type => 'string', optional => 1 } ]

=head4 OUTPUT

        { type => 'string', matches => qr/\A[a-z]{2,3}\z/ }

=head3 MESSAGES

None.

=head3 FORMAL SPECIFICATION

        language : LOCALE ∪ {⊥} → dom Catalog
        let (l, c) = parse(first defined of wanted, config, env)
        language = l          if l ∈ dom Catalog
                 = cl(c)      if cl(c) ∈ dom Catalog
                 = en         otherwise

=head3 PSEUDOCODE

        source := wanted, else config{language}, else the first set of
                  FILESYNC_LANG, LC_ALL, LC_MESSAGES, LANG
        (language, country) := parse_locale(source)
        if language has a catalogue: return language
        if country_language(country) has a catalogue: return it
        return en

=cut
410
411sub language {
412
1162
1300
        my ($wanted) = @_;
413
414
1162
841
        my $source = $wanted;
415
1162
1989
        $source = $config{language} unless defined $source && length $source;
416
1162
1993
        for my $variable (@LOCALE_VARIABLES) {
417
4546
16490
                last if defined $source && length $source;
418
4509
3729
                $source = $ENV{$variable};
419        }
420
421
1162
4981
        my $parts = parse_locale($source);
422
1162
1561
        my $by_country = country_language($parts->{country});
423
424        # The language named by the locale wins; the country is only a hint for
425        # a language that has no catalogue of its own.
426        my $chosen = (defined $parts->{language} && $CATALOG{ $parts->{language} })
427                ? $parts->{language}
428
1162
3271
                : (defined $by_country && $CATALOG{$by_country}) ? $by_country : $FALLBACK;
429
430
1162
3061
        return $chosen;
431}
432
433 - 489
=head2 message

Purpose: render a catalogue entry.

Args: C<$key>, optional C<\%args> for the placeholders and selectors, and an
optional C<$language> (code or locale name).

Returns: the rendered string, without a trailing newline.  A key that no
catalogue holds renders as the key itself followed by its arguments, so a
missing translation is visible rather than silent.

Side Effects: none.

Usage:

        my $text = message('path.unsafe', { path => 'a/../b', reason => 'parent path component' });

=head3 EXAMPLE

        print message('word.file', { count => 3 }), "\n";  # 3 files

=head3 API SPECIFICATION

=head4 INPUT

        [
                { name => 'key',      type => 'string', min => 1 },
                { name => 'args',     type => 'hashref', optional => 1 },
                { name => 'language', type => 'string', optional => 1 },
        ]

=head4 OUTPUT

        { type => 'string' }

=head3 MESSAGES

None; this is the function that renders them.

=head3 FORMAL SPECIFICATION

        message : KEY × ARGS × LANG → TEXT
        let l = language(lang?) ; e = Catalog(l)(key) if defined, else Catalog(en)(key)
        message(key, a, lang?) = interpolate(select(e, a, l), a)   if key ∈ dom Catalog(en) ∪ dom Catalog(l)
                               = key ⁀ describe(a)                otherwise

=head3 PSEUDOCODE

        language := language(requested)
        entry := catalogue(language){key}, else catalogue(en){key}
        if no entry: return key and its arguments
        while entry is a selector hash:
                pick the form named by the plural category of args{count}
                or by args{gender}; fall back to "other"
        replace each %{name}FMT with sprintf(FMT, args{name}); %% with %

=cut
490
491sub message {
492
1159
7263
        my ($key, $args, $wanted) = @_;
493
494
1159
1594
        $args = {} unless ref($args) eq 'HASH';
495
1159
1354
        my $language = language($wanted);
496
497
1159
1743
        my $entry = $CATALOG{$language}{$key};
498
1159
1227
        $entry = $CATALOG{$FALLBACK}{$key} unless defined $entry;
499
500        # An unknown key is a bug, but the person reading the error still
501        # deserves its arguments.
502
1159
1071
        return $key . _describe($args) unless defined $entry;
503
504
1158
1694
        return _interpolate(_select($entry, $args, $language), $args);
505}
506
507 - 551
=head2 throw

Purpose: raise a catalogued error.

Args: C<$key>, optional C<\%args>, optional C<$language>.

Returns: never returns.

Side Effects: croaks with the rendered message, which reports the caller's
location; with C<$config{trace}> (or C<FILESYNC_TRACE>) set it confesses with a
full stack trace instead.  L</strip_location> removes either for display.

Usage:

        throw('snapshot.no_root') unless defined $root;

=head3 EXAMPLE

        eval { throw('path.empty') };
        print strip_location($@);       # "empty path\n"

=head3 API SPECIFICATION

=head4 INPUT

        [
                { name => 'key',      type => 'string', min => 1 },
                { name => 'args',     type => 'hashref', optional => 1 },
                { name => 'language', type => 'string', optional => 1 },
        ]

=head4 OUTPUT

        { type => 'void' }

=head3 MESSAGES

Whatever C<$key> names.

=head3 FORMAL SPECIFICATION

        throw : KEY × ARGS × LANG ↛ ∅
        throw(k, a, l) raises message(k, a, l) ⁀ "\n" ⁀ location

=cut
552
553sub throw {
554
83
174
        my ($key, $args, $wanted) = @_;
555
556        # The newline keeps the location croak appends on a line of its own, which
557        # is what lets strip_location remove it without touching the message.
558
83
177
        my $text = message($key, $args, $wanted) . "\n";
559
83
6123
        $config{trace} ? confess($text) : croak($text);
560}
561
562 - 603
=head2 warning

Purpose: emit a catalogued warning.

Args: as for L</throw>.

Returns: 1.

Side Effects: carps the rendered message to STDERR (or C<$SIG{__WARN__}>).

Usage:

        warning('client.record_failed', { path => $path, error => $reason });

=head3 EXAMPLE

        local $SIG{__WARN__} = sub { print "warned: ", strip_location($_[0]) };
        warning('path.empty');

=head3 API SPECIFICATION

=head4 INPUT

        [
                { name => 'key',      type => 'string', min => 1 },
                { name => 'args',     type => 'hashref', optional => 1 },
                { name => 'language', type => 'string', optional => 1 },
        ]

=head4 OUTPUT

        { type => 'integer', min => 1, max => 1 }

=head3 MESSAGES

Whatever C<$key> names.

=head3 FORMAL SPECIFICATION

        warning(k, a, l) ⇒ STDERR' = STDERR ⁀ message(k, a, l) ⁀ location

=cut
604
605sub warning {
606
0
0
        my ($key, $args, $wanted) = @_;
607
608
0
0
        carp(message($key, $args, $wanted) . "\n");
609
0
0
        return 1;
610}
611
612 - 651
=head2 os_error

Purpose: the operating-system reason behind a failed call.

Args: optional C<$error>: an L<autodie::exception>, or anything else.

Returns: the C<$!> string the exception recorded, or the current C<$!>.

Side Effects: none.

Usage:

        eval { open my $fh, '<', $path; 1 } or throw('io.read', { path => $path, error => os_error($@) });

=head3 EXAMPLE

        use autodie;
        eval { opendir my $dh, '/nonexistent' };
        print os_error($@), "\n";     # No such file or directory

=head3 API SPECIFICATION

=head4 INPUT

        [ { name => 'error', type => 'scalar', optional => 1 } ]

=head4 OUTPUT

        { type => 'string' }

=head3 MESSAGES

None.

=head3 FORMAL SPECIFICATION

        os_error(e) = e.errno   if e ∈ AutodieException
                    = errno     otherwise

=cut
652
653sub os_error {
654
4
3275
        my ($error) = @_;
655
656
4
46
        my $text = (blessed($error) && $error->can('errno')) ? $error->errno : $!;
657
4
26
        return defined $text ? "$text" : '';
658}
659
660 - 700
=head2 strip_location

Purpose: turn a raised error into text for a person or a log line.

Args: C<$error> as caught in C<$@>.

Returns: the message without the C<at FILE line N.> suffix croak adds, without
a confess stack trace, and without a trailing newline.

Side Effects: none.

Usage:

        print STDERR strip_location($@), "\n";

=head3 EXAMPLE

        eval { throw('path.empty') };
        print strip_location($@), "\n";       # empty path

=head3 API SPECIFICATION

=head4 INPUT

        [ { name => 'error', type => 'scalar', optional => 1 } ]

=head4 OUTPUT

        { type => 'string' }

=head3 MESSAGES

None.

=head3 FORMAL SPECIFICATION

        strip(m ⁀ "\n at " ⁀ f ⁀ " line " ⁀ n ⁀ "." ⁀ trace) = m
        strip(m ⁀ "\n") = m
        strip(m) = m   otherwise

=cut
701
702sub strip_location {
703
267
1961
        my ($error) = @_;
704
705
267
713
        my $text = defined $error ? "$error" : '';
706
707        # throw() ends its message with a newline, so croak's location (and a
708        # confess trace) always starts on a fresh line and cannot be confused
709        # with a path inside the message.
710
267
712
        $text =~ s/\n\s*at [^\n]+ line \d+[^\n]*\.\n(?:\t[^\n]*\n?)*\z/\n/;
711
712        # autodie and plain die put the location on the same line instead.
713
267
358
        $text =~ s/ at \S+ line \d+\.?\s*\z//;
714
267
642
        $text =~ s/\s+\z//;
715
716
267
623
        return $text;
717}
718
719# _select
720# Purpose:     choose the plural or gender form of a catalogue entry.
721# Entry:       $entry is a string or a selector hash; $args holds count/gender;
722#              $language names the plural rule.
723# Exit:        returns a string (an empty one if a selector has no usable form).
724# Side effects: none.
725sub _select :Private {
726        my ($entry, $args, $language) = @_;
727
728        # Selectors may nest (gender, then count), so keep choosing until a
729        # plain string is left.
730        while (ref($entry) eq 'HASH') {
731                my $by = $entry->{$SELECT_KEY} || $SELECT_COUNT;
732                my $form;
733                if ($by eq $SELECT_GENDER) {
734                        $form = defined $args->{gender} ? $args->{gender} : $OTHER;
735                } else {
736                        my $rule = $PLURAL_RULE{$language} || $PLURAL_RULE{$FALLBACK};
737                        my $count = defined $args->{count} && $args->{count} =~ /\A-?\d+(?:\.\d+)?\z/
738                                ? $args->{count} : 0;
739                        $form = $rule->($count);
740                }
741                $entry = defined $entry->{$form} ? $entry->{$form} : $entry->{$OTHER};
742        }
743
744        return defined $entry ? $entry : '';
745
328
328
328
255392
270
6180
}
746
747# _interpolate
748# Purpose:     replace %{name}FMT placeholders and %% in a template.
749# Entry:       $template is a string; $args is a hashref.
750# Exit:        returns the rendered string.
751# Side effects: none.  sprintf runs with warnings off for the conversion only,
752#              so an argument of the wrong type renders rather than warns.
753sub _interpolate :Private {
754        my ($template, $args) = @_;
755
756        $template =~ s{%(?:(%)|\{(\w+)\}([-+ 0#]*\d*(?:\.\d+)?[a-zA-Z]))}{
757                defined $1 ? '%' : _format($3, $args->{$2})
758        }ge;
759
760        return $template;
761
328
328
328
122666
260
3149
}
762
763# _format
764# Purpose:     sprintf one value, treating undef as empty (or 0 for numbers).
765# Entry:       $conversion is a sprintf conversion without the leading %.
766# Exit:        returns the formatted value.
767# Side effects: none.
768sub _format :Private {
769        my ($conversion, $value) = @_;
770
771        my $numeric = $conversion =~ /[dioxXeEfgGbBu]\z/ ? 1 : 0;
772        $value = $numeric ? 0 : '' unless defined $value;
773
774
328
328
328
89446
278
10242
        no warnings 'numeric';
775        return sprintf('%' . $conversion, $value);
776
328
328
328
607
274
2540
}
777
778# _describe
779# Purpose:     show the arguments of a message whose key has no entry.
780# Entry:       $args is a hashref.
781# Exit:        returns " (k=v, ...)" or "" when there are none.
782# Side effects: none.
783sub _describe :Private {
784        my ($args) = @_;
785
786        my @pairs = map { "$_=" . (defined $args->{$_} ? $args->{$_} : '') } sort keys %{$args};
787        return @pairs ? ' (' . join(', ', @pairs) . ')' : '';
788
328
328
328
91372
288
2833
}
789
7901;
791