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