NAME
App::FileSync::I18N - message catalogue and error raising for App::FileSync
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 });
DESCRIPTION
Every user-facing string is looked up by key in a catalogue. English is built in (App::FileSync::I18N::en); other languages are added with "register". The language is chosen from $config{language}, then FILESYNC_LANG, LC_ALL, LC_MESSAGES and LANG, falling back to English for any language or key that has no catalogue entry.
CATALOGUE FORMAT
An entry is either a string or a selector hash.
A string may contain %{name}FMT placeholders, where FMT is any sprintf conversion (%{path}s, %{count}d, %{ratio}.1f). %% 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 $args->{count}, and the gender form from $args->{gender}; other is the fallback in both.
FUNCTIONS
register
Purpose: add or replace the catalogue for a language.
Args: $language (two or three lower-case letters), \%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' });
EXAMPLE
App::FileSync::I18N::register(fr => { 'cli.no_repos' => 'Aucun depot configure' });
print App::FileSync::I18N::message('cli.no_repos', {}, 'fr'), "\n";
API SPECIFICATION
INPUT
[
{ name => 'language', type => 'string', matches => qr/\A[a-z]{2,3}\z/ },
{ name => 'entries', type => 'hashref' },
]
OUTPUT
{ type => 'string', matches => qr/\A[a-z]{2,3}\z/ }
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
FORMAL SPECIFICATION
ΔCatalog
lang? : LANG ; entries? : KEY ⇸ ENTRY
Catalog' = Catalog ⊕ {lang? ↦ entries?}
unregister
Purpose: remove a registered catalogue (English cannot be removed).
Args: $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');
EXAMPLE
App::FileSync::I18N::register(de => {});
App::FileSync::I18N::unregister('de');
API SPECIFICATION
INPUT
[ { name => 'language', type => 'string' } ]
OUTPUT
{ type => 'integer', min => 0, max => 1 }
MESSAGES
None.
FORMAL SPECIFICATION
ΔCatalog
lang? : LANG ; r! : 𝔹
lang? ≠ en ⇒ Catalog' = {lang?} ⩤ Catalog
r! ⇔ lang? ≠ en ∧ lang? ∈ dom Catalog
parse_locale
Purpose: split a POSIX locale name into language and country.
Args: $locale, such as de_DE.UTF-8, en_GB, fr, DE or C.
Returns: a hashref { language => 'de', country => 'DE' }; either value is 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');
EXAMPLE
my $parts = App::FileSync::I18N::parse_locale('EN_gb.utf8');
# { language => 'en', country => 'GB' }
API SPECIFICATION
INPUT
[ { name => 'locale', type => 'string', optional => 1 } ]
OUTPUT
{
type => 'hashref',
schema => {
language => { type => 'string', optional => 1 },
country => { type => 'string', optional => 1 },
},
}
MESSAGES
None.
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") = (⊥, ⊥)
country_language
Purpose: the language usually spoken in a country.
Args: $country, an ISO 3166-1 alpha-2 code in any case.
Returns: a lower-case language code, or undef for an unknown country.
Side Effects: none.
Usage:
my $language = App::FileSync::I18N::country_language('fr'); # 'fr'
EXAMPLE
print App::FileSync::I18N::country_language('CN'), "\n"; # zh
API SPECIFICATION
INPUT
[ { name => 'country', type => 'string', optional => 1 } ]
OUTPUT
{ type => 'string', optional => 1, matches => qr/\A[a-z]{2}\z/ }
MESSAGES
None.
FORMAL SPECIFICATION
country_language : COUNTRY ⇸ LANG
country_language(c) = CountryLanguage(upper c) if upper c ∈ dom CountryLanguage
language
Purpose: the catalogue language a lookup will use.
Args: optional $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');
EXAMPLE
local $ENV{LC_ALL} = 'fr_FR.UTF-8';
print App::FileSync::I18N::language(), "\n"; # fr when registered, else en
API SPECIFICATION
INPUT
[ { name => 'wanted', type => 'string', optional => 1 } ]
OUTPUT
{ type => 'string', matches => qr/\A[a-z]{2,3}\z/ }
MESSAGES
None.
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
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
message
Purpose: render a catalogue entry.
Args: $key, optional \%args for the placeholders and selectors, and an optional $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' });
EXAMPLE
print message('word.file', { count => 3 }), "\n"; # 3 files
API SPECIFICATION
INPUT
[
{ name => 'key', type => 'string', min => 1 },
{ name => 'args', type => 'hashref', optional => 1 },
{ name => 'language', type => 'string', optional => 1 },
]
OUTPUT
{ type => 'string' }
MESSAGES
None; this is the function that renders them.
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
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 %
throw
Purpose: raise a catalogued error.
Args: $key, optional \%args, optional $language.
Returns: never returns.
Side Effects: croaks with the rendered message, which reports the caller's location; with $config{trace} (or FILESYNC_TRACE) set it confesses with a full stack trace instead. "strip_location" removes either for display.
Usage:
throw('snapshot.no_root') unless defined $root;
EXAMPLE
eval { throw('path.empty') };
print strip_location($@); # "empty path\n"
API SPECIFICATION
INPUT
[
{ name => 'key', type => 'string', min => 1 },
{ name => 'args', type => 'hashref', optional => 1 },
{ name => 'language', type => 'string', optional => 1 },
]
OUTPUT
{ type => 'void' }
MESSAGES
Whatever $key names.
FORMAL SPECIFICATION
throw : KEY × ARGS × LANG ↛ ∅
throw(k, a, l) raises message(k, a, l) ⁀ "\n" ⁀ location
warning
Purpose: emit a catalogued warning.
Args: as for "throw".
Returns: 1.
Side Effects: carps the rendered message to STDERR (or $SIG{__WARN__}).
Usage:
warning('client.record_failed', { path => $path, error => $reason });
EXAMPLE
local $SIG{__WARN__} = sub { print "warned: ", strip_location($_[0]) };
warning('path.empty');
API SPECIFICATION
INPUT
[
{ name => 'key', type => 'string', min => 1 },
{ name => 'args', type => 'hashref', optional => 1 },
{ name => 'language', type => 'string', optional => 1 },
]
OUTPUT
{ type => 'integer', min => 1, max => 1 }
MESSAGES
Whatever $key names.
FORMAL SPECIFICATION
warning(k, a, l) ⇒ STDERR' = STDERR ⁀ message(k, a, l) ⁀ location
os_error
Purpose: the operating-system reason behind a failed call.
Args: optional $error: an autodie::exception, or anything else.
Returns: the $! string the exception recorded, or the current $!.
Side Effects: none.
Usage:
eval { open my $fh, '<', $path; 1 } or throw('io.read', { path => $path, error => os_error($@) });
EXAMPLE
use autodie;
eval { opendir my $dh, '/nonexistent' };
print os_error($@), "\n"; # No such file or directory
API SPECIFICATION
INPUT
[ { name => 'error', type => 'scalar', optional => 1 } ]
OUTPUT
{ type => 'string' }
MESSAGES
None.
FORMAL SPECIFICATION
os_error(e) = e.errno if e ∈ AutodieException
= errno otherwise
strip_location
Purpose: turn a raised error into text for a person or a log line.
Args: $error as caught in $@.
Returns: the message without the 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";
EXAMPLE
eval { throw('path.empty') };
print strip_location($@), "\n"; # empty path
API SPECIFICATION
INPUT
[ { name => 'error', type => 'scalar', optional => 1 } ]
OUTPUT
{ type => 'string' }
MESSAGES
None.
FORMAL SPECIFICATION
strip(m ⁀ "\n at " ⁀ f ⁀ " line " ⁀ n ⁀ "." ⁀ trace) = m
strip(m ⁀ "\n") = m
strip(m) = m otherwise
LIMITATIONS
Only English ships. Other languages are registered at run time; the locale tests use stub catalogues.
The plural rules cover English, German, French, Chinese, Japanese and Korean. Other languages use the English rule until one is added to
%PLURAL_RULE.Operating-system reasons (
$!) come from the C library and follow the process locale set at start-up, not the catalogue language, so a German message can carry an English reason when the C library has no German locale.Country selection comes from the territory in a locale name, or a bare country code passed to "language". The distribution performs no GeoIP lookup: a sync tool has no business locating its user.
AUTHOR
Nigel Horne, <njh at nigelhorne.com>
LICENCE AND COPYRIGHT
Copyright 2026 Nigel Horne.
Usage is subject to the GPL2 licence terms. If you use it, please let me know.