more SYNOPSIS instructions
[rails.git] / script / locale / diff
1 #!/usr/bin/env perl
2 use feature ':5.10';
3 use strict;
4 use warnings;
5 use YAML::Syck qw(Dump LoadFile);
6 use Test::Differences;
7 use Pod::Usage ();
8 use Getopt::Long ();
9 use File::Basename qw(fileparse);
10
11 =head1 NAME
12
13 locale-diff - Compare two YAML files and print how their datastructures differ
14
15 =head1 SYNOPSIS
16
17     # --keys is the default
18     diff en.yml is.yml
19     diff --keys en.yml is.yml
20
21     # --untranslated-values compares prints keys whose values don't differ
22     diff --untranslated-values en.yml is.yml
23
24     # --untranslated-values-all compares prints keys whose values
25     # don't differ. Ignoring the blacklist which prunes things
26     # unlikley to be translated
27     diff --untranslated-values-all en.yml is.yml
28
29     # Check that interpolated variables ({{var}} and [[var]]) are the same
30     diff --validate-variables en.yml is.yml
31
32 =head1 DESCRIPTION
33
34 This utility prints the differences between two YAML files using
35 L<Test::Differences>. The purpose of it is to diff the files is
36 F<config/locales> to find out what keys need to be added to the
37 translated files when F<en.yml> changes.
38
39 =head1 OPTIONS
40
41 =over
42
43 =item -h, --help
44
45 Print this help message.
46
47 =item --keys
48
49 Show the hash keys that differ between the two files, useful merging
50 new entries from F<en.yml> to a local file.
51
52 =item --untranslated-values
53
54 Show keys that B<exist in both the compared files> and whose values
55 are exactly the same. Use C<--keys> to a list of values that hasn't
56 been merged.
57
58 The values are pruned according to global and language specific
59 blacklists found in the C<__DATA__> section of this script.
60
61 This helps to find untranslated values.
62
63 =item --untranslated-values-all
64
65 Like C<--untranslated-values> but ignores blacklists.
66
67 =item --validate-variables
68
69 Check that interpolated Ruby i18n variables (C<{{foo}}> and
70 C<[[foo]]>) are equivalent in the two provided files.
71
72 =item --dump-flat
73
74 Dump a flat version of the translation hash in YAML format,
75 i.e. "foo.bar" instead of "{foo}->{bar}".
76
77 =back
78
79 =head1 AUTHOR
80
81 E<AElig>var ArnfjE<ouml>rE<eth> Bjarmason <avarab@gmail.com>
82
83 =cut
84
85 # Get the command-line options
86 Getopt::Long::Parser->new(
87     config => [ qw< bundling no_ignore_case no_require_order pass_through > ],
88 )->getoptions(
89     'h|help' => \my $help,
90     'keys' => \my $keys,
91     'dump-flat' => \my $dump_flat,
92     'untranslated-values' => \my $untranslated_values,
93     'untranslated-values-all' => \my $untranslated_values_all,
94     'validate-variables' => \my $validate_variables,
95 ) or help();
96
97 # --keys is the default
98 $keys = 1 if not $untranslated_values_all and not $untranslated_values and not $validate_variables and not $dump_flat;
99
100 # On --help
101 help() if $help;
102
103 # If we're not given two .yml files
104 help() if (@ARGV != 2 or (!-f $ARGV[0] or !-f $ARGV[1])) and not $dump_flat;
105
106 my ($from, $to) = @ARGV;
107
108 my $from_data = LoadFile($from);
109 my $from_parsed = { iterate($from_data->{fileparse($from, qr/\.[^.]*/)}) };
110
111 if ($dump_flat)
112 {
113     # Mark as UTF-8
114     map { if (ref $_ eq 'ARRAY') { map { utf8::decode($_) } @$_ } else {  utf8::decode($_) } } values %$from_parsed;
115
116     print Dump $from_parsed;
117
118     exit 0;
119 }
120
121 my $to_data   = LoadFile($to);
122 my $to_parsed = { iterate($to_data->{fileparse($to, qr/\.[^.]*/)}) };
123
124 if ($keys)
125 {
126     print_key_differences($from_parsed, $to_parsed);
127 }
128 elsif ($untranslated_values or $untranslated_values_all)
129 {
130     my @untranslated = untranslated_keys($from_parsed, $to_parsed);
131
132     # Prune according to blacklist
133     if ($untranslated_values) {
134         @untranslated = prune_untranslated_with_blacklist(scalar(fileparse($to, qr/\.[^.]*/)), @untranslated);
135     }
136
137     say for @untranslated;
138 } elsif ($validate_variables)
139 {
140     print_validate_variables($from_parsed, $to_parsed);
141 }
142
143 exit 0;
144
145 sub print_key_differences
146 {
147     my ($f, $t) = @_;
148
149     # Hack around Test::Differences wanting a Test::* module loaded
150     $INC{"Test.pm"} = 1;
151     sub Test::ok { print shift }
152
153     # Diff the tree
154     eq_or_diff([ sort keys %$f ], [ sort keys %$t ]);
155 }
156
157 sub untranslated_keys
158 {
159     my ($from_parsed, $to_parsed) = @_;
160     sort grep { exists $to_parsed->{$_} and $from_parsed->{$_} eq $to_parsed->{$_} } keys %$from_parsed;
161 }
162
163 sub prune_untranslated_with_blacklist
164 {
165     my ($language, @keys) = @_;
166     my %keys;
167     @keys{@keys} = ();
168
169     my $end_yaml = LoadFile(*DATA);
170     my $untranslated_values = $end_yaml->{untranslated_values};
171     my $default = $untranslated_values->{default};
172     my $this_language = $untranslated_values->{$language} || {};
173
174     my %bw_list = (%$default, %$this_language);
175     
176     while (my ($key, $blacklisted) = each %bw_list)
177     {
178         # FIXME: Does syck actually support true/false booleans in yaml?
179         delete $keys{$key} if $blacklisted eq 'true'
180     }
181
182     sort keys %keys;
183 }
184
185 sub print_validate_variables
186 {
187     my ($f, $t) = @_;
188
189     while (my ($key, $val) = each %$f)
190     {
191         next if exists $f->{$key} and not exists $t->{$key};
192
193         my @from_var = parse_variables_from_string($f->{$key});
194         my @to_var   = parse_variables_from_string($t->{$key});
195
196         unless (@from_var ~~ @to_var) {
197             say "$key in $from has (@from_var) and $to has (@to_var)";
198         }
199
200     }
201 }
202
203 sub parse_variables_from_string
204 {
205     my ($string) = @_;
206
207     # This probably matches most of the variables
208     my $var = qr/ [a-z0-9_]+? /xs;
209
210     if (my @var = $string =~ m/ \{\{ ($var) \}\} | \[\[ ($var) \]\] /gsx) {
211         return sort grep { defined } @var;
212     } else {
213         return;
214     }
215 }
216
217 sub iterate
218 {
219     my ($hash, @path) = @_;
220     my @ret;
221         
222     while (my ($k, $v) = each %$hash)
223     {
224         if (ref $v eq 'HASH')
225         {
226              push @ret => iterate($v, @path, $k);
227         }
228         else
229         {
230             push @ret => join(".",@path, $k), $v;
231         }
232     }
233
234     return @ret;
235 }
236
237 sub help
238 {
239     my %arg = @_;
240
241     Pod::Usage::pod2usage(
242         -verbose => $arg{ verbose },
243         -exitval => $arg{ exitval } || 0,
244     );
245 }
246
247 __DATA__
248 untranslated_values:
249
250   # Default/Per language blacklist/whitelist for the
251   # --untranslated-values switch. "true" as a value indicates that the
252   # key is to be blacklisted, and "false" that it's to be
253   # whitelisted. "false" is only required to whitelist a key
254   # blacklisted by default on a per-language basis.
255
256   default:
257     html.dir: true
258     layouts.intro_3_bytemark: true
259     layouts.intro_3_ucl: true
260     layouts.project_name.h1: true
261     layouts.project_name.title: true
262     site.index.license.project_url: true
263     browse.relation_member.entry: true
264
265     # #{{id}}
266     changeset.changeset.id: true
267
268   de:
269     activerecord.attributes.message.sender: true
270     activerecord.attributes.trace.name: true
271     activerecord.models.changeset: true
272     activerecord.models.relation: true
273     browse.changeset.changeset: true
274     browse.changeset.changesetxml: true
275     browse.changeset.osmchangexml: true
276     browse.changeset.title: true
277     browse.common_details.version: true
278     browse.containing_relation.relation: true
279     browse.relation.relation: true
280     browse.relation.relation_title: true
281     browse.start_rjs.details: true
282     browse.start_rjs.object_list.details: true
283     browse.tag_details.tags: true
284     changeset.changesets.id: true
285     export.start.export_button: true
286     export.start.format: true
287     export.start.output: true
288     export.start.zoom: true
289     export.start_rjs.export: true
290     layouts.export: true
291     layouts.shop: true
292     site.edit.anon_edits: true
293     site.index.license.license_name: true
294     site.index.permalink: true
295     site.key.table.entry.park: true
296     site.search.submit_text: true
297     trace.edit.tags: true
298     trace.trace.in: true
299     trace.trace_form.tags: true
300     trace.trace_optionals.tags: true
301     trace.view.tags: true
302     user.account.public editing.enabled link: true
303
304   is:
305     # ({{link}})
306     site.edit.anon_edits: true
307
308     # Creative Commons Attribution-Share Alike 2.0
309     site.index.license.license_name: true
310
311     # http://creativecommons.org/licenses/by-sa/2.0/
312     site.index.license.license_url: true
313
314     # {{id}}
315     printable_name.with_id: true
316     
317     # {{name}} ({{id}})
318     printable_name.with_name: true
319
320     # {{type}} 
321     geocoder.search_osm_namefinder.prefix: true
322
323     # {{suffix}}, {{parentname}}
324     geocoder.search_osm_namefinder.suffix_suburb: true