Make sure we always call fileparse() in scalar context.
[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 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 =back
73
74 =head1 AUTHOR
75
76 E<AElig>var ArnfjE<ouml>rE<eth> Bjarmason <avar@f-prot.com>
77
78 =cut
79
80 # Get the command-line options
81 Getopt::Long::Parser->new(
82     config => [ qw< bundling no_ignore_case no_require_order pass_through > ],
83 )->getoptions(
84     'h|help' => \my $help,
85     'keys' => \my $keys,
86     'untranslated-values' => \my $untranslated_values,
87     'untranslated-values-all' => \my $untranslated_values_all,
88     'validate-variables' => \my $validate_variables,
89 ) or help();
90
91 # --keys is the default
92 $keys = 1 if not $untranslated_values_all and not $untranslated_values and not $validate_variables;
93
94 # On --help
95 help() if $help;
96
97 # If we're not given two .yml files
98 help() if @ARGV != 2 or (!-f $ARGV[0] or !-f $ARGV[1]);
99
100 my ($from, $to) = @ARGV;
101
102 my $from_data = LoadFile($from);
103 my $to_data   = LoadFile($to);
104
105 my $from_parsed = { iterate($from_data->{fileparse($from, qr/\.[^.]*/)}) };
106 my $to_parsed = { iterate($to_data->{fileparse($to, qr/\.[^.]*/)}) };
107
108 if ($keys)
109 {
110     print_key_differences($from_parsed, $to_parsed);
111 }
112 elsif ($untranslated_values or $untranslated_values_all)
113 {
114     my @untranslated = untranslated_keys($from_parsed, $to_parsed);
115
116     # Prune according to blacklist
117     if ($untranslated_values) {
118         @untranslated = prune_untranslated_with_blacklist(scalar(fileparse($to, qr/\.[^.]*/)), @untranslated);
119     }
120
121     say for @untranslated;
122 } elsif ($validate_variables)
123 {
124     print_validate_variables($from_parsed, $to_parsed);
125 }
126
127 exit 0;
128
129 sub print_key_differences
130 {
131     my ($f, $t) = @_;
132
133     # Hack around Test::Differences wanting a Test::* module loaded
134     $INC{"Test.pm"} = 1;
135     sub Test::ok { print shift }
136
137     # Diff the tree
138     eq_or_diff([ sort keys %$f ], [ sort keys %$t ]);
139 }
140
141 sub untranslated_keys
142 {
143     my ($from_parsed, $to_parsed) = @_;
144     sort grep { exists $to_parsed->{$_} and $from_parsed->{$_} eq $to_parsed->{$_} } keys %$from_parsed;
145 }
146
147 sub prune_untranslated_with_blacklist
148 {
149     my ($language, @keys) = @_;
150     my %keys;
151     @keys{@keys} = ();
152
153     my $end_yaml = LoadFile(*DATA);
154     my $untranslated_values = $end_yaml->{untranslated_values};
155     my $default = $untranslated_values->{default};
156     my $this_language = $untranslated_values->{$language} || {};
157
158     my %bw_list = (%$default, %$this_language);
159     
160     while (my ($key, $blacklisted) = each %bw_list)
161     {
162         # FIXME: Does syck actually support true/false booleans in yaml?
163         delete $keys{$key} if $blacklisted eq 'true'
164     }
165
166     sort keys %keys;
167 }
168
169 sub print_validate_variables
170 {
171     my ($f, $t) = @_;
172
173     while (my ($key, $val) = each %$f)
174     {
175         next if exists $f->{$key} and not exists $t->{$key};
176
177         my @from_var = parse_variables_from_string($f->{$key});
178         my @to_var   = parse_variables_from_string($t->{$key});
179
180         unless (@from_var ~~ @to_var) {
181             say "$key in $from has (@from_var) and $to has (@to_var)";
182         }
183
184     }
185 }
186
187 sub parse_variables_from_string
188 {
189     my ($string) = @_;
190
191     # This probably matches most of the variables
192     my $var = qr/ [a-z0-9_]+? /xs;
193
194     if (my @var = $string =~ m/ \{\{ ($var) \}\} | \[\[ ($var) \]\] /gsx) {
195         return sort grep { defined } @var;
196     } else {
197         return;
198     }
199 }
200
201 sub iterate
202 {
203     my ($hash, @path) = @_;
204     my @ret;
205         
206     while (my ($k, $v) = each %$hash)
207     {
208         if (ref $v eq 'HASH')
209         {
210              push @ret => iterate($v, @path, $k);
211         }
212         else
213         {
214             push @ret => join(".",@path, $k), $v;
215         }
216     }
217
218     return @ret;
219 }
220
221 sub help
222 {
223     my %arg = @_;
224
225     Pod::Usage::pod2usage(
226         -verbose => $arg{ verbose },
227         -exitval => $arg{ exitval } || 0,
228     );
229 }
230
231 __DATA__
232 untranslated_values:
233
234   # Default/Per language blacklist/whitelist for the
235   # --untranslated-values switch. "true" as a value indicates that the
236   # key is to be blacklisted, and "false" that it's to be
237   # whitelisted. "false" is only required to whitelist a key
238   # blacklisted by default on a per-language basis.
239
240   default:
241     html.dir: true
242     layouts.intro_3_bytemark: true
243     layouts.intro_3_ucl: true
244     layouts.project_name.h1: true
245     layouts.project_name.title: true
246     site.index.license.project_url: true
247     browse.relation_member.entry: true
248
249   de:
250     activerecord.attributes.message.sender: true
251     activerecord.attributes.trace.name: true
252     activerecord.models.changeset: true
253     activerecord.models.relation: true
254     browse.changeset.changeset: true
255     browse.changeset.changesetxml: true
256     browse.changeset.osmchangexml: true
257     browse.changeset.title: true
258     browse.common_details.version: true
259     browse.containing_relation.relation: true
260     browse.relation.relation: true
261     browse.relation.relation_title: true
262     browse.start_rjs.details: true
263     browse.start_rjs.object_list.details: true
264     browse.tag_details.tags: true
265     changeset.changesets.id: true
266     export.start.export_button: true
267     export.start.format: true
268     export.start.output: true
269     export.start.zoom: true
270     export.start_rjs.export: true
271     layouts.export: true
272     layouts.shop: true
273     site.edit.anon_edits: true
274     site.index.license.license_name: true
275     site.index.permalink: true
276     site.key.table.entry.park: true
277     site.search.submit_text: true
278     trace.edit.tags: true
279     trace.trace.in: true
280     trace.trace_form.tags: true
281     trace.trace_optionals.tags: true
282     trace.view.tags: true
283     user.account.public editing.enabled link: true
284
285   is:
286     # ({{link}})
287     site.edit.anon_edits: true
288
289     # Creative Commons Attribution-Share Alike 2.0
290     site.index.license.license_name: true
291
292     # http://creativecommons.org/licenses/by-sa/2.0/
293     site.index.license.license_url: true
294
295     # {{id}}
296     printable_name.with_id: true
297     
298     # {{name}} ({{id}})
299     printable_name.with_name: true