File Coverage

blib/lib/Locale/Maketext/Test.pm
Criterion Covered Total %
statement 142 155 91.6
branch 46 66 69.7
condition 12 20 60.0
subroutine 22 23 95.6
pod 2 2 100.0
total 224 266 84.2


line stmt bran cond sub pod time code
1             use strict;
2 6     6   461269 use warnings;
  6         78  
  6         169  
3 6     6   36  
  6         12  
  6         153  
4             use 5.014;
5 6     6   120 use utf8;
  6         21  
6 6     6   3522  
  6         88  
  6         34  
7             use Syntax::Keyword::Try;
8 6     6   3120 use File::Spec;
  6         13305  
  6         35  
9 6     6   480 use Test::MockModule;
  6         12  
  6         122  
10 6     6   2785 use Locale::Maketext::ManyPluralForms;
  6         32194  
  6         43  
11 6     6   2815  
  6         101637  
  6         47  
12             # ABSTRACT: Locale::Maketext::Test
13              
14             =encoding UTF-8
15              
16             =head1 NAME
17              
18             Locale::Maketext::Test
19              
20             =cut
21              
22             our $VERSION = '0.06';
23              
24             =head1 SYNOPSIS
25              
26             use Locale::Maketext::Test;
27              
28             my $foo = Locale::Maketext::Test->new(directory => '/tmp/locales'); # it will look for files like id.po, ru.po
29              
30             ### optional parameters
31             # languages => ['en', 'de'] - to test specific languages in directory, else it will pick all po files in directory
32             # ideally these languages should be as per https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes format
33             # debug => 1 - if you want to check warnings add debug flag else it will output errors only
34             # auto => 1 set only when you want to fallback in case a key is missing from lexicons
35              
36             # start test
37             my $response = $foo->testlocales();
38              
39             # no errors or warnings if
40             $response->{status} eq 1;
41              
42             # something is wrong when
43             $response->{status} eq 0;
44              
45             # check for errors and warnings in case status is 0
46             $response->{errors} = {id => [qw(error1 error2)], ru => [qw(error1 error2)]};
47             # warnings are only present when debug is set to 1
48             $response->{warnings} = {id => [qw(warn1 warn2)], ru => [qw(warn1 warn2)]};
49              
50             =head1 DESCRIPTION
51              
52             This reads all message ids from the specified PO files and tries to
53             translate them into the destination language. PO files can be specified either
54             as file name (extension .po) or by providing the language. In the latter case
55             the PO file is found in the directory given by the directory option.
56              
57             =head2 TYPES OF ERRORS FOUND
58              
59             =head3 unknown %func() calls
60              
61             Translations can contain function calls in the form of %func(parameters).
62             These functions must be defined in our code. Sometimes translators try
63             to translate the function name which then calls an undefined function.
64              
65             =head3 incorrect number of %plural() parameters
66              
67             Different languages have different numbers of plural forms.
68             Some, like Malay, don't have any plural forms. Some, like English or French,
69             have just 2 forms, singular and one plural. Others like Arabic or Russian have
70             more forms. Whenever a translator uses the %plural() function, he must specify
71             the correct number of plural forms as parameters.
72              
73             =head3 incorrect usage of %d in %plural() parameters
74              
75             In some languages, like English or German, singular is applicable only to the
76             quantity of 1. That means the German translator could come up for instance
77             with the following valid %plural call:
78              
79             %plural(%5,ein Stein,%d Steine)
80              
81             In other languages, like French or Russian, this would be an error. French uses
82             singular also for 0 quantities. So, if the French translator calls:
83              
84             %plural(%5,une porte,%d portes)
85              
86             and in the actual call the quantity of 0 is passed the output is still "une porte".
87             In Russian the problem is even more critical because singular is used for instance
88             also for the quantity of 121.
89              
90             Thus, this test checks if a) the target language is similar to English in having only
91             2 plural forms, singular and one plural, and in applying singular only to the quantity
92             of 1. If both of these conditions are met %plural calls like the above are allowed.
93             Otherwise, if at least one of the parameters passed to %plural contains a %d,
94             all of the parameters must contain the %d as well.
95              
96             That means the following 2 %plural calls are allowed in Russian:
97              
98             %plural(%3,%d книга,%d книги,%d книг)
99             %3 %plural(%3,книга,книги,книг)
100              
101             while this is forbidden:
102              
103             %plural(%3,одна книга,%d книги,%d книг)
104              
105             =head1 SUBROUTINES/METHODS
106              
107             =head2 debug
108              
109             set this if you need to check warnings along with errors
110              
111             =cut
112              
113             use Moose;
114 6     6   3716 use namespace::autoclean;
  6         2838504  
  6         41  
115 6     6   48524  
  6         48996  
  6         28  
116             has debug => (
117             is => 'ro',
118             isa => 'Bool',
119             default => 0
120             );
121              
122             =head2 directory
123              
124             directory where locales files are located
125              
126             =cut
127              
128             has directory => (
129             is => 'ro',
130             isa => 'Str',
131             required => 1
132             );
133              
134             =head2 languages
135              
136             language array, set this if you want to test specific language only in specified directory
137              
138             =cut
139              
140             has languages => (
141             is => 'rw',
142             isa => 'ArrayRef[Str]',
143             default => sub { [] });
144              
145             =head2 auto
146              
147             flag to fallback when a key is missing from lexicons
148              
149             # if this is not set then maketext will output errors if
150             # translations is marked as fuzzy or is missing
151             # read more about it here https://metacpan.org/pod/Locale::Maketext::Lexicon
152             Locale::Maketext::Lexicon->import({ _auto => 1 })
153              
154             =cut
155              
156             has auto => (
157             is => 'ro',
158             isa => 'Bool',
159             default => 0
160             );
161              
162             has _status => (
163             is => 'rw',
164             isa => 'HashRef',
165             lazy => 1,
166             init_arg => undef,
167             default => sub {
168             {
169             status => 1,
170             errors => {},
171             warnings => {}};
172             });
173              
174             =head2 BUILD
175              
176             =cut
177              
178             my $self = shift;
179              
180 6     6 1 16 unless (scalar @{$self->languages}) {
181             my @lang = ();
182 6 100       12 if (opendir my $dh, $self->directory) {
  6         247  
183 1         4 while (my $dir = readdir($dh)) {
184 1 50       33 if (my ($x) = ($dir =~ /^(\w+)\.po$/)) {
185 1         43 push @lang, $x;
186 5 100       24 }
187 3         18 }
188             @lang = sort @lang;
189             }
190 1         5 $self->languages(\@lang);
191             }
192 1         39  
193             return Locale::Maketext::ManyPluralForms->import({
194             '_decode' => 1,
195 6         203 '_auto' => $self->auto,
196             '*' => ['Gettext' => File::Spec->rel2abs($self->directory) . '/*.po']});
197             }
198              
199             =head2 testlocales
200              
201             test po files in directory specified
202              
203             =cut
204              
205             my $self = shift;
206              
207             foreach my $lang (@{$self->languages}) {
208 6     6 1 101 my $po = $self->_get_po($lang);
209             my $lg = $po->{header}->{language};
210 6         17 my $hnd = Locale::Maketext::ManyPluralForms->get_handle($lg);
  6         190  
211 8         1073 $hnd->plural(1, 'test %d');
212 7         33  
213 7         126 my $plural_sub = $hnd->{_plural};
214 7         17990  
215             my $nplurals = 2; # default
216 7         1379 $nplurals = $1 if $po->{header}->{'plural-forms'} =~ /\bnplurals=(\d+);/;
217             my @plural;
218 7         21  
219 7 50       103 for (my ($i, $j) = (0, $nplurals); $i < 10000 && $j > 0; $i++) {
220 7         16 my $pos = $plural_sub->($i);
221             unless (defined $plural[$pos]) {
222 7   66     86 $plural[$pos] = $i;
223 13         326 $j--;
224 13 50       93 }
225 13         34 }
226 13         93  
227             # $lang_plural_is_like_english==1 means the language has exactly 2 plural forms
228             # and singular is applied only to the quantity of 1. That means something like
229             # %plural(%d,ein Stern,%d Sterne) is allowed. In French for instance, singular is
230             # also applied to the quantity of 0. In that case the singular form should also
231             # contain a %d sequence.
232             my $lang_plural_is_like_english = ($nplurals == 2);
233             if ($lang_plural_is_like_english) {
234             for (my $i = 0; $i <= 100_000; $i++) {
235 7         21 next if $i == 1;
236 7 100       23 if ($plural_sub->($i) == 0) {
237 4         16 $lang_plural_is_like_english = 0;
238 400004 100       2073844 last;
239 400000 50       7042547 }
240 0         0 }
241 0         0 }
242              
243             my $ln;
244              
245             $plural_sub = $hnd->can('plural');
246 7         38 my $mock = Test::MockModule->new(ref($hnd), no_auto => 1);
247             $mock->mock(
248 7         117 plural => sub {
249 7         111 # The plural call should provide exactly the number of forms required by the language
250             push @{$self->_status->{errors}->{$lg}},
251             $self->_format_message($ln, "\%plural() requires $nplurals parameters for this language (provided: @{[@_ - 2]})")
252             unless @_ == $nplurals + 2;
253 1 50   1   379  
  1         33  
254 1         8 # %plural() can be used like
255             #
256             # %plural(%3,word,words)
257             #
258             # or like
259             #
260             # %plural(%3,%d word,%d words)
261             #
262             # In the first case we are only looking for the correct plural form
263             # providing the actual quantity elsewhere.
264             #
265             # The code below checks that either all parameters of the current call contain %d
266             # or none of them. That means something like %plural(%15,one word,%d words) is an
267             # error as singular is in many languages also applied to other quantities than 1.
268              
269             my $found_percent_d = 0;
270             my @no_percent_d;
271             for (my $i = 2; $i < @_; $i++) {
272 1         3 if ($_[$i] =~ /%d/) {
273 1         2 $found_percent_d++;
274 1         4 } else {
275 2 50       9 # $i==2 means it's the singular parameter. This one is allowed to not contain
276 2         7 # %d if the language is like English
277             push @no_percent_d, $i - 1 unless ($i == 2 and $lang_plural_is_like_english);
278             }
279             }
280 0 0 0     0 if ($found_percent_d) {
281             if (@no_percent_d > 1) {
282             my $s = join(', ', @no_percent_d[0 .. $#no_percent_d - 1]) . ' and ' . $no_percent_d[-1];
283 1 50       3 push @{$self->_status->{errors}->{$lg}}, $self->_format_message($ln, "\%plural() parameters $s miss %d");
284 1 50       7 } elsif (@no_percent_d == 1) {
    50          
285 0         0 push @{$self->_status->{errors}->{$lg}}, $self->_format_message($ln, "\%plural() parameter $no_percent_d[0] misses %d");
286 0         0 }
  0         0  
287             }
288 0         0  
  0         0  
289             goto $plural_sub;
290             });
291              
292 1         11 for my $test (@{$po->{ids}}) {
293 7         473 $ln = $test->[3];
294             my $i = 0;
295 7         1036 my $j = 0;
  7         33  
296 21         746 my @param = map {
297 21         38 $j++;
298 21         36 push @{$self->_status->{warnings}->{$lg}}, $self->_format_message($ln, "unused parameter \%$j") if (not defined $_ and $self->debug);
299             defined $_ && $_ eq 'text' ? 'text' . $i++ : 1;
300 29         47 } @{$test->[1]};
301 29 100 100     443 try {
  2         55  
302 29 100 100     171 local $SIG{__WARN__} = sub { die $_[0] };
303 21         32 $hnd->maketext($test->[0], @param);
  21         63  
304             } catch ($e) {
305 0     0   0 if ($e =~ /Can't locate object method "([^"]+)" via package/) {
306             push @{$self->_status->{errors}->{$lg}}, $self->_format_message($ln, "Unknown directive \%$1()");
307 21         60 } else {
308             push @{$self->_status->{errors}->{$lg}}, $self->_format_message($ln, "Unexpected error:\n$e");
309             }
310             }
311             }
312             }
313              
314             return $self->_status;
315             }
316              
317 5         2963 =pod
318              
319             This returns hash with status, errors and warnings
320              
321             {
322             status => 1/0, # 1 is success, 0 failure
323             errors => { id => [error1, error2], ru => [error1, error2] },
324             warnings => { id => [warn1, warn2] }
325             }
326              
327             =cut
328              
329             my %map = (
330             'a' => "\007",
331             'b' => "\010",
332             't' => "\011",
333 49     49   216 'n' => "\012",
334             'v' => "\013",
335             'f' => "\014",
336             'r' => "\015",
337             );
338             return $_[0] =~ s/
339             \\
340             (?:
341             ([0-7]{1,3})
342 49         309 |
343             x([0-9a-fA-F]{1,2})
344             |
345             ([\\'"?abfvntr])
346             )
347             /$1 ? chr(oct($1)) : $2 ? chr(hex($2)) : ($map{$3} || $3)/regx;
348             }
349              
350             my @params;
351 84 50 33     559 return $_[0] =~ s!
    50          
352             (?> # matches %func(%N,parameters...)
353             %
354             (?<func>\w+)
355 21     21   60 \(
356 21         192 %
357             (?<p0>[0-9]+)
358             (?<prest>[^\)]*)
359             \)
360             )
361             |
362             (?> # matches %func(parameters)
363             %
364             (?<simplefunc>\w+)
365             \(
366             (?<simpleparm>[^\)]*)
367             \)
368             )
369             | # matches %N
370             %
371             (?<simple>[0-9]+)
372             | # [, ] and ~ should be escaped as ~[, ~] and ~~
373             (?<esc>[\[\]~])
374             !
375             if ($+{esc}) {
376             "~$+{esc}";
377             } elsif ($+{simplefunc}) {
378             "[$+{simplefunc},$+{simpleparm}]";
379             } else {
380 6 50   6   13429 my $pos = ($+{func} ? $+{p0} : $+{simple}) - 1;
  6 50       2203  
  6         6892  
  17         139  
381 0         0 $params[$pos] = $+{func} && $+{func} eq 'plural' ? 'plural' : ($params[$pos] // 'text');
382             $+{func} ? "[$+{func},_$+{p0}$+{prest}]" : "[_$+{simple}]";
383 0         0 }
384             !regx, \@params;
385 17 100       122 }
386 17 100 66     135  
      50        
387 17 100       219 {
388             my @stack;
389              
390             return pop @stack if @stack;
391             return scalar readline $_[0];
392             }
393              
394             push @stack, @_;
395             return;
396 242 100   242   533 }
397 193         894 }
398              
399             my $f = shift;
400              
401 49     49   95 while (defined(my $l = _nextline($f))) {
402 49         69 if ($l =~ /^\s*msgstr\s*"(.*)"/) {
403             my $line = $1;
404             while (defined($l = _nextline($f))) {
405             if ($l =~ /^\s*"(.*)"/) {
406             $line .= $1;
407 28     28   53 } else {
408             _unread($l);
409 28         59 return _cstring($line);
410 28 50       129 }
411 28         67 }
412 28         62 return _cstring($line);
413 105 100       356 }
414 84         219 }
415             return;
416 21         53 }
417 21         52  
418             my ($self, $lang, $header_only) = @_;
419              
420 7         35 unless ($lang =~ /\.po$/) {
421             $lang = $self->directory . '/' . $lang . '.po';
422             }
423 0         0  
424             my (%header, @ids, $ln);
425             my $first = 1;
426              
427 8     8   35 open my $f, '<:encoding(UTF-8)', $lang or die "Cannot open $lang: $!\n"; ## no critic (RequireBriefOpen)
428             READ:
429 8 50       36 while (defined(my $l = _nextline($f))) {
430 8         274 if ($l =~ /^\s*msgid\s*"(.*)"/) {
431             my $line = $1;
432             $ln = $.;
433 8         26 while (defined($l = _nextline($f))) {
434 8         19 if ($l =~ /^\s*"(.*)"/) {
435             $line .= $1;
436 6 100   6   57 } else {
  6         15  
  6         43  
  8         333  
437             _unread($l);
438 7         60271 if ($first) {
439 67 100       382 undef $first;
440 28         78 %header = map { split /\s*:\s*/, lc($_), 2 } split /\n/, _get_trans($f);
441 28         62 last READ if $header_only;
442 28         57 } elsif (length $line) {
443 28 50       115 push @ids, [_bstring(_cstring($line)), _get_trans($f), $ln];
444 0         0 }
445             last;
446 28         83 }
447 28 100       92 }
    50          
448 7         18 }
449 7         27 }
  84         429  
450 7 50       35  
451             return {
452 21         52 header => \%header,
453             ids => \@ids,
454 28         86 lang => $header{language},
455             file => $lang,
456             };
457             }
458              
459             my ($self, $line, $message) = @_;
460             $self->_status->{status} = 0;
461             return "(line=$line): $message";
462             }
463              
464 7         202 __PACKAGE__->meta->make_immutable;
465              
466             =head1 AUTHOR
467              
468             Binary.com, C<< <binary at cpan.org> >>
469 9     9   29  
470 9         265 =head1 BUGS
471 9         53  
472             Please report any bugs or feature requests to C<bug-locale-maketext-test at rt.cpan.org>, or through
473             the web interface at L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Locale-Maketext-Test>. I will be notified, and then you'll
474             automatically be notified of progress on your bug as I make changes.
475              
476              
477              
478              
479             =head1 SUPPORT
480              
481             You can find documentation for this module with the perldoc command.
482              
483             perldoc Locale::Maketext::Test
484              
485              
486             You can also look for information at:
487              
488             =over 4
489              
490             =item * RT: CPAN's request tracker (report bugs here)
491              
492             L<http://rt.cpan.org/NoAuth/Bugs.html?Dist=Locale-Maketext-Test>
493              
494             =item * AnnoCPAN: Annotated CPAN documentation
495              
496             L<http://annocpan.org/dist/Locale-Maketext-Test>
497              
498             =item * CPAN Ratings
499              
500             L<http://cpanratings.perl.org/d/Locale-Maketext-Test>
501              
502             =item * Search CPAN
503              
504             L<http://search.cpan.org/dist/Locale-Maketext-Test/>
505              
506             =back
507              
508              
509             =head1 ACKNOWLEDGEMENTS
510              
511              
512             =cut
513              
514             1; # End of Locale::Maketext::Test