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.