File Coverage

File:blib/lib/App/Access2CSV/I18N.pm
Coverage:96.0%

linestmtbrancondsubtimecode
1package App::Access2CSV::I18N;
2
3
43
43
43
143175
29
575
use strict;
4
43
43
43
71
26
757
use warnings;
5
43
43
43
524
12719
87
use autodie qw(:all);
6
7# Sub::Private must be switched to enforce mode before it is loaded,
8# otherwise it falls back to namespace mode, which breaks OO dispatch
9
43
106956
BEGIN { $Sub::Private::config{mode} = 'enforce' }
10
11
43
43
43
101
34
1049
use Carp qw(carp confess croak);
12
43
43
43
93
29
571
use Config;
13
43
41
41
6975
150209
1103
use Params::Get qw(get_params);
14
41
40
40
10132
340375
1049
use Params::Validate::Strict qw(validate_strict);
15
40
40
40
96
30
671
use Readonly;
16
40
39
39
5524
9896
963
use Return::Set qw(set_return);
17
39
38
38
7886
499521
102
use Sub::Private;
18
38
37
37
10768
131450
87
use Sub::Protected;
19
20our $VERSION = '0.001.0';
21
22# The wrappers installed by Sub::Private/Sub::Protected add stack frames;
23# listing them here stops Carp from blaming the wrapper for our errors
24our @CARP_NOT = qw(Sub::Private Sub::Protected);
25
26# Catalog used when no better match for the user's locale exists
27Readonly::Scalar my $DEFAULT_LANGUAGE => 'en';
28
29# Locale values that mean "no preference" rather than a real language
30Readonly::Hash my %NEUTRAL_LOCALES => (C => 1, POSIX => 1);
31
32# Environment variables consulted, most specific first (GNU gettext order)
33Readonly::Array my @LOCALE_VARIABLES => qw(LANGUAGE LC_ALL LC_MESSAGES LANG);
34
35# Characters that a terminal or log viewer would act on rather than show:
36# C0 controls except tab (ESC starts escape sequences, CR overwrites the
37# line, LF forges new log lines, BEL rings), DEL, and the invisible
38# text-direction controls.  C1 controls are matched as characters in
39# Perl character strings and as their UTF-8 bytes in byte strings.
40#
41# One character class (faster than alternatives), made of these ranges:
42#       \x00-\x08 \x0A-\x1F   C0 controls, except tab (\x09)
43#       \x7F                  DEL
44#       \x{80}-\x{9F}         C1 controls (U+009B works like ESC [)
45#       \x{200E}\x{200F}      left-to-right and right-to-left marks
46#       \x{202A}-\x{202E}     embeddings and overrides (U+202E is RLO)
47#       \x{2066}-\x{2069}     isolates
48# (No spaces or comments inside the brackets: /x does not apply there.)
49Readonly::Scalar my $UNPRINTABLE_RE =>
50        qr/[\x00-\x08\x0A-\x1F\x7F\x{80}-\x{9F}\x{200E}\x{200F}\x{202A}-\x{202E}\x{2066}-\x{2069}]/;
51Readonly::Scalar my $UNPRINTABLE_BYTES_RE => qr/
52          [\x00-\x08\x0A-\x1F\x7F]      # C0 controls except tab, and DEL
53        | \xC2 [\x80-\x9F]              # C1 controls, UTF-8 encoded
54        | \xE2 \x80 [\x8E\x8F\xAA-\xAE]  # LRM, RLM, embeddings, overrides
55        | \xE2 \x81 [\xA6-\xA9]          # isolates
56/x;
57
58# Coverage: Devel::Cover finds code through the symbol table, but in
59# enforce mode Sub::Private and Sub::Protected replace every private and
60# protected sub there with a wrapper (at CHECK time), so the real subs
61# would never appear in coverage reports.  Only when Devel::Cover is
62# loaded, give each sub of this distribution a second name, in a package
63# nothing calls, before the wrapping happens.  CHECK blocks run
64# last-defined first, so this one runs before the attribute handlers'.
65Readonly::Array my @OWN_PACKAGES => qw(App::Access2CSV App::Access2CSV::Exporter App::Access2CSV::I18N);
66Readonly::Scalar my $UNWRAPPED => 'App::Access2CSV::_Unwrapped';
67
68CHECK {
69
33
72960
        if($INC{'Devel/Cover.pm'}) {
70
33
94
                require B;
71
37
37
37
6672
60
14727
                no strict 'refs';
72
33
126
                foreach my $package (@OWN_PACKAGES) {
73
99
99
299
146
                        foreach my $name (keys %{"${package}::"}) {
74
9025
9025
4985
9499
                                my $code = *{"${package}::$name"}{CODE} or next;
75                                # Only subs written in this package, not imported ones
76
2747
4640
                                next unless B::svref_2object($code)->GV->STASH->NAME eq $package;
77
1644
1644
1362
2683
                                *{"${UNWRAPPED}::${package}::$name"} = $code;
78                        }
79                }
80        }
81}
82
83# Signals that mean "stop now": Ctrl-C is INT, Ctrl-\ is QUIT; TERM and
84# HUP come from kill, service managers and closed terminals
85Readonly::Array my @INTERRUPT_SIGNALS => qw(INT QUIT TERM HUP);
86
87# Plural category used when a language has no rule of its own
88Readonly::Scalar my $PLURAL_OTHER => 'other';
89
90# CLDR-style plural rules.  Each returns the category name for a count.
91# Only languages whose rule differs from English need an entry here.
92Readonly::Hash my %PLURAL_RULES => (
93        en => sub { $_[0] == 1 ? 'one' : 'other' },
94        de => sub { $_[0] == 1 ? 'one' : 'other' },
95        fr => sub { ($_[0] == 0 || $_[0] == 1) ? 'one' : 'other' },
96        ja => sub { 'other' },
97        ko => sub { 'other' },
98        zh => sub { 'other' },
99);
100
101# Message catalog: language => key => template.
102# A template is either a sprintf() format, or a hashref whose keys are
103# contexts (for example 'male'/'female') and/or plural categories
104# ('zero', 'one', 'two', 'few', 'many', 'other').
105# It is a package variable, not Readonly, so that applications and
106# Object::Configure style configuration can add languages or override text.
107our %MESSAGES = (
108        en => {
109                column_output      => 'OUTPUT FILE',
110                column_rows        => 'ROWS',
111                column_table       => 'TABLE',
112                count_unreadable   => 'mdb-count printed no number: "%s"',
113                count_failed       => 'Cannot count the rows of %s: %s',
114                database_not_file  => 'Database %s is not a regular file',
115                database_not_found => 'Cannot read database %s: %s',
116                database_unreadable => 'Database %s is not readable',
117                dry_run_title      => 'DRY RUN',
118                export_failed      => 'FAILED: %s: %s',
119                exported           => 'Exported %s => %s',
120                exported_rows      => {
121                        one   => 'Exported %s => %s (%d row)',
122                        other => 'Exported %s => %s (%d rows)',
123                },
124                fatal              => 'access2csv: %s',
125                invalid_name       => 'the name contains a NUL byte',
126                interrupted_reading => 'Interrupted by SIG%s while reading the database from standard input',
127                interrupted        => 'Interrupted by SIG%s: stopped, and the table being exported was discarded',
128                invalid_setting    => 'Invalid setting: %s',
129                invalid_utf8       => 'Table %s, line %d: output of mdb-export is not valid UTF-8',
130                log_failed         => 'Cannot write to the log: %s',
131                log_is_symlink     => 'it is a symbolic link',
132                log_open_failed    => 'Cannot open log file %s: %s',
133                logger_unavailable => 'no logger was created',
134                missing_database   => 'Missing database filename',
135                needs_object       => 'run() must be called on an object created by new()',
136                mkdir_failed       => 'Cannot create output directory %s: %s',
137                no_row_counter     => 'mdb-count not found in PATH; row counts are unavailable',
138                output_exists      => 'Output file already exists: %s (use --overwrite to replace it)',
139                program_failed     => '%s failed with exit status %d: %s',
140                program_found      => 'Found %s at %s',
141                program_missing    => 'Required program not found in PATH: %s',
142                program_not_run    => '%s could not be run: %s',
143                program_signalled  => '%s was killed by signal %d',
144                progress           => '[%d/%d] %s',
145                summary            => {
146                        one   => 'Processed %d table, %d failed',
147                        other => 'Processed %d tables, %d failed',
148                },
149                stdin_empty        => 'Standard input is empty: no database was piped in',
150                stdin_is_terminal  => 'Standard input is a terminal: pipe the database in, or give its file name',
151                stdin_read_failed  => 'Cannot read standard input: %s',
152                version            => 'access2csv version %s',
153                unknown_message    => 'Unknown message key: %s',
154                unknown_tables     => {
155                        one   => 'Table not found in database: %s',
156                        other => 'Tables not found in database: %s',
157                },
158                unmappable         => 'Table %s, line %d: cannot be represented in %s',
159                write_failed       => 'Cannot write %s: %s',
160        },
161);
162
163=encoding utf8
164
165 - 494
=head1 NAME

App::Access2CSV::I18N - Message catalog and translated error messages for App::Access2CSV

=head1 VERSION

Version 0.001.0

=head1 SYNOPSIS

        use App::Access2CSV::I18N;

        # 1. Get a message as text
        my $text = App::Access2CSV::I18N->i18n('missing_database');
        # "Missing database filename"

        # 2. Fill in values (the message has %s and %d placeholders)
        print App::Access2CSV::I18N->i18n('progress', { params => [1, 3, 'Customers'] }), "\n";
        # "[1/3] Customers"

        # 3. Let the count choose between singular and plural
        print App::Access2CSV::I18N->i18n('summary', { params => [$n, 0], count => $n }), "\n";
        # $n == 1: "Processed 1 table, 0 failed"
        # $n == 4: "Processed 4 tables, 0 failed"

        # 4. Add a translation.  Keys you do not translate stay in English.
        $App::Access2CSV::I18N::MESSAGES{de}{missing_database} =
                'Name der Datenbankdatei fehlt';
        $ENV{LANG} = 'de_DE.UTF-8';

        # 5. Use it as a base class, to get i18n() and the error helpers
        package My::Tool;
        use parent 'App::Access2CSV::I18N';

        sub check {
                my ($self, $file) = @_;
                $self->_croak_i18n('database_not_file', { params => [$file] }) unless -f $file;
                return $self;
        }

=head1 DESCRIPTION

Every message that App::Access2CSV prints, logs or throws is looked up
here, by a short name called a I<key> (for example C<output_exists>).
This keeps all the text in one place, so the program can be translated.

The text for each key is a I<template>.  A template is usually a
C<sprintf> format: C<%s> is replaced by a text value and C<%d> by a whole
number, in order.  A template can also have different forms:

=over 4

=item * B<Plural forms>, chosen by a count: C<one> for one item,
C<other> for any other number.  Some languages use more forms
(C<zero>, C<two>, C<few>, C<many>).

=item * B<Context forms>, chosen by a word you give, such as C<male> or
C<female>.  A context form can itself contain plural forms.

=back

The templates live in the hash C<%App::Access2CSV::I18N::MESSAGES>:

        %MESSAGES = (
                en => {
                        missing_database => 'Missing database filename',
                        summary => {
                                one   => 'Processed %d table, %d failed',
                                other => 'Processed %d tables, %d failed',
                        },
                        ...
                },
        );

Only English (C<en>) is included.

=head2 Which language is used

=over 4

=item 1. The C<language> field of the object, if you call C<i18n> on an
object that has one (for example
C<< App::Access2CSV::Exporter->new(language => 'en') >>).

=item 2. Otherwise the first of these environment variables that is set
and is not C<C> or C<POSIX>: C<LANGUAGE>, C<LC_ALL>, C<LC_MESSAGES>,
C<LANG>.  Only the language part is used: C<de_DE.UTF-8> means C<de>.
Upper or lower case does not matter.  C<LANGUAGE> can hold a list such as
C<fr:de>; only the first entry is used.  An empty value, or an empty
first entry (C<:de>), counts as "not set", so the next variable is tried.

=item 3. If there is no catalog for that language, English is used.

=back

Inside a language, a key that has no translation falls back to the
English text, one key at a time.

=head2 Helpers for subclasses

Subclasses get two protected methods.  They can be called only from this
class and its subclasses:

=over 4

=item * C<< $self->_croak_i18n($key, \%args) >> - throws an exception
(with L<Carp/croak>) with the translated message.  It never returns.

=item * C<< $self->_carp_i18n($key, \%args) >> - warns (with
L<Carp/carp>) with the translated message, with control characters
escaped.  It returns C<$self>.

=item * C<< $self->_printable($text) >> - returns C<$text> with control
characters (C0 except tab, DEL, C1, and text-direction controls) shown
as escapes such as C<\x1B>, so that text from an untrusted database is
safe to print or log.

=back

=head1 ENCODING

=over 4

=item * B<The English catalog> is plain ASCII.

=item * B<Values in params> are copied into the message as they are.
Byte strings (such as UTF-8 file names from the command line) stay byte
strings, so non-ASCII text and emoji come out unchanged when printed.

=item * B<Translations with non-ASCII text> (for example German C<ue>
written as one letter, or Japanese) must be Perl character strings: write
them in a source file with C<use utf8;>.  Do not mix them with UTF-8
I<byte> strings in C<params>, or the bytes will be encoded a second time.
When you print such messages, give the output handle an encoding, for
example C<binmode(STDERR, ':encoding(UTF-8)')>, or Perl warns
"Wide character in print".

=back

=head1 COMMON PITFALLS

=over 4

=item * B<Percent signs.>  A template with no C<params> is returned
exactly as written, so C<100%> is safe.  But when you give C<params>, the
template goes through C<sprintf>, so a literal percent sign must be
written C<%%>.

=item * B<Number of values.>  Give exactly as many C<params> as the
template has placeholders.  Too few gives a Perl "Missing argument"
warning; C<undef> in C<params> gives a "Use of uninitialized value"
warning.

=item * B<No count means plural.>  If you do not give C<count>, the
C<other> form is used, even if the number in C<params> is 1.

=item * B<Replacing a key replaces all of its forms.>
C<%MESSAGES> is not merged in depth.  If you set
C<< $MESSAGES{en}{summary} = 'Done' >>, the C<one> and C<other> forms of
C<summary> are gone.  If a translation gives a plural hash, it should have
an C<other> form: when no form fits, the English text for that key is
used instead.

=item * B<Unknown context.>  A C<context> that the template does not have
is ignored; the plural forms (or the plain text) are used instead.

=item * B<Unknown keys are fatal.>  A key that is not in the English
catalog is a programming error: C<i18n> calls C<confess>, which stops the
program and prints a stack trace.

=item * B<undef arguments.>  C<undef> for C<args>, or for a field inside
the hashref form, is treated as "not given".  C<undef> as the key is
reported as a missing key.

=item * B<Load with use, not require.>  The protection of C<_croak_i18n>
and C<_carp_i18n> is set up at compile time.  After a run-time
C<require> it is missing, and Perl prints "Too late to run CHECK block".

=back

=head1 METHODS

=head2 i18n

=head3 Purpose

Turn a message key into text in the user's language.  Choose the right
context and plural form, then fill in the values.

=head3 Arguments

You can call C<i18n> on the class or on an object.  There are two ways
to give the arguments:

        $obj->i18n($key, \%args);
        $obj->i18n({ key => $key, args => \%args });

=over 4

=item C<key> (string, required)

The message key, for example C<'output_exists'>.

=item C<args> (hash reference, optional)

=over 4

=item C<params> - an array reference of the values for the placeholders,
in order.

=item C<count> - a whole number, 0 or more, that chooses the plural form.

=item C<context> - a word that chooses a context form, for example
C<'female'>.

=back

=back

=head3 Returns

The message as a string, without a newline at the end.

=head3 Side Effects

None.  It only reads C<%MESSAGES> and C<%ENV>.  Your C<$@>, C<$!> and
C<$_> are left as they were.

=head3 Usage

        my $text = $self->i18n('summary', { params => [3, 0], count => 3 });

=head3 EXAMPLE

        # "Output file already exists: out/Orders.csv (use --overwrite to replace it)"
        my $msg = App::Access2CSV::I18N->i18n('output_exists', { params => ['out/Orders.csv'] });

        # A message with context and plural forms
        $App::Access2CSV::I18N::MESSAGES{en}{greeting} = {
                female => { one => 'She sent %d letter', other => 'She sent %d letters' },
                other  => 'They sent %d letters',
        };
        print App::Access2CSV::I18N->i18n('greeting',
                { params => [2], count => 2, context => 'female' }), "\n";
        # "She sent 2 letters"

=head3 API SPECIFICATION

=head4 Input

        {
                key => {
                        type     => 'string',
                        min      => 1,
                        optional => 0,
                },
                args => {
                        type     => 'hashref',
                        optional => 1,
                        schema   => {
                                params  => { type => 'arrayref', optional => 1 },
                                count   => { type => 'integer', optional => 1, min => 0 },
                                context => { type => 'string', optional => 1 },
                        },
                },
        }

Valid and invalid values (tested in F<t/domain.t>):

        key      valid:   a key in the English catalog
                 invalid: "" (1 character is the minimum), undef, a
                          reference, an unknown key (fatal: "Unknown message
                          key"), a known key with extra characters
        count    valid:   whole numbers from 0 up (0 is the minimum; 2**53
                          works); undef means "no count"
                 invalid: -1 and below, fractions (1.5), words
                 edges:   English and German: 1 is singular, 0 and 2 plural.
                          French: 0 and 1 singular.  Japanese, Korean,
                          Chinese: always the "other" form
        params   any number of values, including none; each value is
                 copied into the text exactly, whether it is a Perl
                 character string or UTF-8 bytes (non-ASCII letters,
                 emoji, joined emoji, combining marks, right-to-left text)
        context  any string; one the template does not have (including "")
                 is ignored; a reference is invalid
        language (from the environment) the first 2 or 3 letters, in any
                 case; 1 letter, non-ASCII letters, C and POSIX all mean
                 "no language"

=head4 Output

        {
                type => 'string',
        }

=head3 MESSAGES

        +-----------------------------+-------------------------------+---------------------------------+
        | Message                     | Meaning                       | What to do                      |
        +-----------------------------+-------------------------------+---------------------------------+
        | Unknown message key: KEY    | KEY is not in the English     | Programming error: add KEY to   |
        |  (fatal, with stack trace)  | catalog                       | $MESSAGES{en}                   |
        | Required parameter 'key' is | No key was given              | Give a key                      |
        |  missing (fatal)            |                               |                                 |
        | Unknown parameter 'X'       | args has a field that is not  | Use only params, count and      |
        |  (fatal)                    | params, count or context      | context                         |
        | Parameter 'count' (X) must  | count is negative or not a    | Give a whole number, 0 or more  |
        |  be ... (fatal)             | whole number                  |                                 |
        | Parameter 'params' must be  | params is not an array        | Give an array reference         |
        |  an arrayref (fatal)        | reference                     |                                 |
        +-----------------------------+-------------------------------+---------------------------------+

=head3 PSEUDOCODE

        check key and args
        lang := the object's language, or the language from the environment,
                or English if there is no catalog for it
        for try in (lang, then en if lang is not en):   # at most 2 tries, no recursion
                entry := catalog[try][key], or else catalog[en][key]
                if entry has forms and one matches args.context:
                        entry := that form
                if entry still has forms:
                        entry := the form for plural_category(try, count),
                                 or else the "other" form
                stop if entry is now a plain string
        if no try gave a plain string: confess (the catalog is broken)
        if there are params: return sprintf(entry, params)
        else: return entry unchanged

=cut
495
496sub i18n {
497
1235
906639
        my $self = shift;
498
499        # Validation uses eval internally; the caller's $@ must survive
500
1235
1012
        local $@;
501
502        # Accept both i18n('key', {...}) and i18n({ key => ..., args => {...} })
503        # Undefined values are dropped so that validation reports them as missing
504
1235
7
3401
11
        my $in = (ref($_[0]) eq 'HASH') ? { %{ $_[0] } } : { key => $_[0], args => $_[1] };
505
1235
1235
2468
1235
1164
1119
2544
1930
        delete @{$in}{ grep { !defined $in->{$_} } keys %{$in} };
506
507
1235
10064
        my $params = validate_strict(
508                schema => {
509                        key  => { type => 'string', min => 1 },
510                        args => {
511                                type     => 'hashref',
512                                optional => 1,
513                                schema   => {
514                                        params  => { type => 'arrayref', optional => 1 },
515                                        count   => { type => 'integer', optional => 1, min => 0 },
516                                        context => { type => 'string', optional => 1 },
517                                },
518                        },
519                },
520                input => $in,
521        );
522
1195
233339
        my $args = $params->{args} || {};
523
524        # Resolve the template in the user's language, falling back to English
525        # Try the chosen language, then English: a fixed list of at most two
526        # attempts.  There is deliberately no recursion here, so no mistake in
527        # choosing the language (a bug, or a mutation of _language) can ever
528        # make this loop forever.
529
1195
1098
        my $key = $params->{key};
530        # An undefined language (only possible through a bug) means English
531
1195
2464
        my $lang = $self->_language() // $DEFAULT_LANGUAGE;
532
1195
916
        my $entry;
533
1195
1583
        foreach my $try ($lang eq $DEFAULT_LANGUAGE ? ($lang) : ($lang, $DEFAULT_LANGUAGE)) {
534
1205
2058
                $entry = _narrow($self->_lookup($try, $key), $try, $args);
535
1198
1270
                last if defined $entry;
536        }
537
538        # Premise 1: every English template is complete.  Premise 2: not even
539        # the English one gave a usable form.  Conclusion: the catalog is
540        # broken, which is a programming error.
541
1188
1078
        _unknown_key($key) unless defined $entry;
542
543        # A literal message with no placeholders is returned untouched, which
544        # protects any '%' characters it contains from sprintf()
545        # Use the validated array reference directly rather than copying it
546
1178
884
        my $values = $args->{params};
547
1178
814
1491
2064
        my $text = ($values && @{$values}) ? sprintf($entry, @{$values}) : $entry;
548
549
1178
2733
        return set_return($text, { type => 'string' });
550}
551
552# _croak_i18n
553# Purpose:        Throw a localised exception from the caller's point of view.
554# Entry Criteria: $key is a catalog key; $args is an optional i18n() hashref.
555# Exit Status:    Never returns; always croaks.
556# Side Effects:   Unwinds the stack with a Carp exception.
557sub _croak_i18n :Protected {
558
358
7920
        my ($self, $key, $args) = @_;
559
560
358
617
        croak($self->i18n($key, $args));
561
37
37
37
107
28
442
}
562
563# _carp_i18n
564# Purpose:        Emit a localised warning from the caller's point of view.
565# Entry Criteria: $key is a catalog key; $args is an optional i18n() hashref.
566# Exit Status:    Returns $self for chaining.
567# Side Effects:   Writes a warning to STDERR (or $SIG{__WARN__}).
568sub _carp_i18n :Protected {
569
120
3668
        my ($self, $key, $args) = @_;
570
571
120
241
        carp($self->_printable($self->i18n($key, $args)));
572
120
19310
        return $self;
573
37
37
37
4379
31
276
}
574
575# _interrupt_signals
576# Purpose:        List the "stop now" signals that this program may take
577#                 over: those that exist on this system and that nobody
578#                 has set a handler for.  Used by code that must clean up
579#                 (delete temporary files) when stopped, since Perl's
580#                 default action for these signals skips all clean-up.
581# Entry Criteria: None.
582# Exit Status:    Returns an arrayref of signal names, e.g. ['INT', 'TERM'].
583#                 The caller localises %SIG for exactly these names.
584# Side Effects:   None.
585sub _interrupt_signals :Protected {
586
315
21420
6510
19713
        my %exists = map { $_ => 1 } split ' ', $Config{sig_name};
587
315
1260
2019
5511
        return [ grep { $exists{$_} && ($SIG{$_} // 'DEFAULT') eq 'DEFAULT' } @INTERRUPT_SIGNALS ];
588
37
37
37
4691
32
287
}
589
590# _printable
591# Purpose:        Make text safe to show on a terminal or write to a log,
592#                 by replacing control characters with visible escapes
593#                 such as \x1B.  Text may come from a hostile database
594#                 (table names, mdbtools error output).
595# Entry Criteria: $text is a string (Perl characters or UTF-8 bytes) or undef.
596# Exit Status:    Returns the escaped string ('' for undef).
597# Side Effects:   None.
598sub _printable :Protected {
599
584
27024
        my ($self, $text) = @_;
600
601
584
824
        $text //= '';
602
584
939
        if(utf8::is_utf8($text)) {
603
0
0
0
0
                $text =~ s/($UNPRINTABLE_RE)/sprintf(ord($1) > 0xFF ? '\\x{%X}' : '\\x%02X', ord $1)/ge;
604        } else {
605
584
43
52
4848
133
208
                $text =~ s{($UNPRINTABLE_BYTES_RE)}{join(q{}, map { sprintf(q{\\x%02X}, ord) } split(//, $1))}ge;
606        }
607
584
4945
        return $text;
608
37
37
37
5842
28
291
}
609
610# _language
611# Purpose:        Work out which catalog language to use.
612# Entry Criteria: $self is a class name or an object (optionally with {language}).
613# Exit Status:    Returns a lower-case language code, e.g. 'en' or 'de'.
614# Side Effects:   None; reads %ENV only.
615sub _language :Private {
616
1205
21748
        my $self = shift;
617
618        # An explicit per-object choice beats anything in the environment.
619        # Otherwise take the first meaningful locale variable; LANGUAGE may be
620        # a colon-separated preference list, so only its first entry counts.
621        # Empty values and C/POSIX mean "no preference", so they are skipped.
622
1205
2635
        my $wanted = ref($self) && $self->{language};
623
4752
10714
        ($wanted) = grep { length && !$NEUTRAL_LOCALES{$_} }
624
1205
4752
4678
24020
                map { (split /:/, $ENV{$_} // '')[0] // '' } @LOCALE_VARIABLES
625                unless $wanted;
626
627        # "de_DE.UTF-8@euro" -> "de"; anything unparseable means English
628
1205
4000
        my ($code) = ($wanted // '') =~ /\A([A-Za-z]{2,3})(?:[_\-.@]|\z)/;
629
1205
2445
        return (defined($code) && exists($MESSAGES{lc $code})) ? lc($code) : $DEFAULT_LANGUAGE;
630
37
37
37
6244
35
313
}
631
632# _lookup
633# Purpose:        Fetch the raw template for $key in $lang.
634# Entry Criteria: $lang is a catalog language; $key is a non-empty string.
635# Exit Status:    Returns a string or hashref template.
636# Side Effects:   confess()es if $key is missing from the default catalog,
637#                 because that can only be a programming error.
638sub _lookup :Private {
639
1212
10975
        my ($self, $lang, $key) = @_;
640
641        # A partial translation silently falls back to English per key
642
1212
1781
        foreach my $catalog ($MESSAGES{$lang}, $MESSAGES{$DEFAULT_LANGUAGE}) {
643
1243
4674
                return $catalog->{$key} if $catalog && exists($catalog->{$key});
644        }
645
646
11
24
        return _unknown_key($key);
647
37
37
37
4316
44
255
}
648
649# _narrow
650# Purpose:        Reduce a template to one sprintf() format: first the
651#                 form for the context (if the template has one), then
652#                 the plural form for the count, falling back to "other".
653# Entry Criteria: $entry is a string or hashref template; $lang is the
654#                 language whose plural rule applies; $args is the
655#                 validated i18n() args hashref.
656# Exit Status:    Returns a plain string, or undef if the template has no
657#                 usable form (a gap in a translation).
658# Side Effects:   None.  A plain function, not a method.
659sub _narrow :Private {
660
1217
107723
        my ($entry, $lang, $args) = @_;
661
662
1217
1961
        if(ref($entry) eq 'HASH' && defined($args->{context}) && exists($entry->{$args->{context}})) {
663
14
19
                $entry = $entry->{$args->{context}};
664        }
665
1217
1339
        if(ref($entry) eq 'HASH') {
666
180
404
                my $category = _plural_category($lang, $args->{count});
667
180
364
                $entry = exists($entry->{$category}) ? $entry->{$category} : $entry->{$PLURAL_OTHER};
668        }
669
1217
2306
        return (defined($entry) && !ref($entry)) ? $entry : undef;
670
37
37
37
5262
29
299
}
671
672# _unknown_key
673# Purpose:        Report a message key that the English catalog lacks.
674# Entry Criteria: $key is the key that could not be resolved.
675# Exit Status:    Never returns; always confesses (a stack trace helps,
676#                 because this can only be a programming error).
677# Side Effects:   Unwinds the stack.
678# The text is built directly from the catalog, not through i18n(), which
679# could recurse forever if 'unknown_message' itself were missing.
680sub _unknown_key :Private {
681
23
2159
        my $key = shift;
682
683
23
352
        confess(sprintf($MESSAGES{$DEFAULT_LANGUAGE}{unknown_message} || 'Unknown message key: %s', $key));
684
37
37
37
3547
57
264
}
685
686# _plural_category
687# Purpose:        Map a count to a CLDR plural category for a language.
688# Entry Criteria: $lang is a language code; $count is a non-negative
689#                 integer or undef (undef means "no plural choice").
690# Exit Status:    Returns a category name such as 'one' or 'other'.
691# Side Effects:   None.  A plain function, not a method.
692sub _plural_category :Private {
693
193
6129
        my ($lang, $count) = @_;
694
695
193
699
        my $rule = $PLURAL_RULES{$lang} || $PLURAL_RULES{$DEFAULT_LANGUAGE};
696
193
1228
        return defined($count) ? $rule->($count) : $PLURAL_OTHER;
697
37
37
37
3413
61
248
}
698
6991;
700