| File: | blib/lib/App/FileSync/I18N.pm |
| Coverage: | 89.7% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package 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 | ||||||
| 23 | our @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. | |||||
| 31 | Readonly my $FALLBACK => 'en'; | |||||
| 32 | ||||||
| 33 | # Selector keys inside a catalogue entry, see "CATALOGUE FORMAT" below. | |||||
| 34 | Readonly my $SELECT_KEY => '_by'; | |||||
| 35 | Readonly my $SELECT_COUNT => 'count'; | |||||
| 36 | Readonly my $SELECT_GENDER => 'gender'; | |||||
| 37 | Readonly 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. | |||||
| 42 | Readonly 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. | |||||
| 47 | Readonly 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. | |||||
| 56 | Readonly 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. | |||||
| 67 | our %config = ( | |||||
| 68 | language => undef, | |||||
| 69 | trace => $ENV{FILESYNC_TRACE} ? 1 : 0, | |||||
| 70 | ); | |||||
| 71 | ||||||
| 72 | # Registered catalogues by language. English is always present. | |||||
| 73 | my %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 | ||||||
| 173 | sub 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 | ||||||
| 227 | sub 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 | ||||||
| 286 | sub 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 | ||||||
| 350 | sub 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 | ||||||
| 411 | sub 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 | ||||||
| 491 | sub 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 | ||||||
| 553 | sub 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 | ||||||
| 605 | sub 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 | ||||||
| 653 | sub 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 | ||||||
| 702 | sub 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. | |||||
| 725 | sub _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. | |||||
| 753 | sub _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. | |||||
| 768 | sub _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. | |||||
| 783 | sub _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 | ||||||
| 790 | 1; | |||||
| 791 | ||||||