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