chiark / gitweb /
363103fccf01e7a4f7bfc47894330d6c56fe2066
[dgit.git] / i18n-diff-auditor
1 #!/usr/bin/perl -w
2 #
3 # i18n-diff-auditor
4 # Copyright (C)2018 Ian Jackson
5 # GPLv3+, NO WARRANTY, see below.
6 #
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU General Public License for more details.
16 #
17 # You should have received a copy of the GNU General Public License
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 use strict;
21 use Carp;
22 use Data::Dumper;
23 use Getopt::Long;
24
25 our $debug = 0;
26 GetOptions("debug|D+" => \$debug
27            );
28
29 our @debug;
30 sub debug ($$) {
31     my ($i,$s) = @_;
32     push @{ $debug[$i] }, $s if $debug;
33 }
34
35 my @d = <>;
36 unshift @d, "# dummy line to make line 1 index 1 in \@d\n";
37
38 our $i_last_l_ok = -1;
39 our $count_i_last_l_ok;
40
41 sub l_ok ($) {
42     my ($i) = @_;
43
44     if ($i == $i_last_l_ok) {
45         confess $i if $count_i_last_l_ok++ > 50;
46     } else {
47         $count_i_last_l_ok = 0;
48         $i_last_l_ok = $i;
49     }
50
51     return unless $i < @d;
52     $_ = $d[$i];
53     #print STDERR "L $i\n";
54     1;
55 }
56
57 sub l ($) {
58     my ($i) = @_;
59     confess $i unless l_ok $i;
60 };
61
62 our $perlop_text = <<'END'; # c&p from man perlop
63            left        terms and list operators (leftward)
64            left        ->
65            nonassoc    ++ --
66            right       **
67            right       ! ~ \ and unary + and -
68            left        =~ !~
69            left        * / % x
70            left        + - .
71            left        << >>
72            nonassoc    named unary operators
73            nonassoc    < > <= >= lt gt le ge
74            nonassoc    == != <=> eq ne cmp ~~
75            left        &
76            left        | ^
77            left        &&
78            left        || //
79            nonassoc    ..  ...
80            right       ?:
81            right       = += -= *= etc. goto last next redo dump
82            left        , =>
83            nonassoc    list operators (rightward)
84            right       not
85            left        and
86            left        or xor
87
88            **=    +=    *=    &=    &.=    <<=    &&=
89                   -=    /=    |=    |.=    >>=    ||=
90                   .=    %=    ^=    ^.=           //=
91                         x=
92 END
93
94 our $perlop_re;
95
96 sub prep_perlop () {
97     my @ops;
98     foreach (split /\n/, $perlop_text) {
99         next unless m{\S};
100         s{\s+$}{};
101         s{^\s+}{};
102         s{^(?: left | right | nonassoc ) \s+}{}x;
103         next if m{^terms and list operators};
104         next if m{^named unary};
105         next if m{^list operators};
106         s{ and unary.*}{};
107         s{ etc\. }{ };
108         s{\?\:}{ ? : };
109         foreach my $op (split /\s+/) {
110             next unless length $op;
111             next if $op =~ m{^\w+$};
112             $op =~ s/\W/\\$&/g;
113             push @ops, $op;
114         }
115     }
116     $perlop_re = '(?: '.(join ' | ', @ops).' )';
117     $perlop_re = qr{$perlop_re}x;
118     #print STDERR "$perlop_re\n";
119 }
120
121 prep_perlop();
122
123 our ($ifilehead, $ifirsthunkhead);
124 our ($ihunkhead, $ihunkend);
125 our ($ichunkstart, $ichunkend);
126 our ($before, $after);
127
128 sub is_string ($) { $_[0]{T} =~ m/heredoc|string/; }
129 sub is_trans ($) { grep { $_[0]{E} eq $_ } qw(__ f_ i_); }
130
131 sub qp ($) {
132     my ($p) = @_;
133     $p =~ s{\\}{\\\\}g;
134     $p =~ s{\'}{\\'}g;
135     $p =~ s{\n}{\\n}g;
136     $p =~ s{\t}{\\t}g;
137     return "'$p'";
138 };
139
140 sub semiparse ($) {
141     ($_) = @_;
142     my @o;
143     #my $in = $_;
144     # entries contain
145     #   T     type
146     #   E     exact input text (does not contain here doc contents)
147     #   P     something to print in messages
148     #   V     value, only for: heredoc string
149     #   Q     quote characcter, only for: heredoc string
150     for (;;) {
151         s{^\s+}{};
152         if (s{^[\$\@\%]?[_0-9a-zA-Z]+}{}) {
153             push @o, { T => 'ident', E => $&, P => $& };
154         } elsif (s{^\<\<(['"]?)([A-Z_]+)\1}{}) {
155             my ($q,$d) = ($1,$2);
156             $q ||= '"';
157             push @o, { T => 'heredoc', Q => $q, Delim => $d,
158                        E => $&, P => "<<$q$d$q" };
159             if (s{^
160                     (                 .* \n     )
161                     ( (?: (?! $d \n ) .* \n )*? )
162                               $d         \n
163                  }{ $1 }xe) {
164                 $o[$#o]{V} = $2;
165             } else {
166                 m{^.*\n} or confess;
167                 $_ = $&;
168                 $o[$#o]{V} = $';
169                 $o[$#o]{Invented} = 1;
170             }
171         } elsif (s{^ (["'])( (?: [^\\'"]
172                                | \\ [^"']
173                                | (?! \1 ) [^"]
174                               )*
175                        ) \1 }{}x) {
176             my ($q,$v) = ($1,$2);
177             push @o, { T => 'string', E => $&, P => "$q$q",
178                        Q => $q, V => $v};
179         } elsif (s{^$perlop_re|^\;}{}) {
180             push @o, { T => 'op', E => $&, P => $& };
181         } elsif (s/^[[{(]//) {
182             push @o, { T => 'bra', E => $&, P => $& };
183         } elsif (s/^[]})]//) {
184             push @o, { T => 'ket', E => $&, P => $& };
185         } elsif (s/^( [\$\@\%] )( \{ )//x) {
186             push @o, { T => 'deref', E => $1, P => $1 },
187                      { T => 'bra',   E => $2, P => $2 };
188         } elsif (s/^ [\$\@\%] [^[^{] //x) {
189             push @o, { T => 'specvar', E => $&, P => $& };
190         } elsif (!length) {
191             last;
192         } else {
193             m{^.{0,10}};
194             die "cannot tokenise \`$&'";
195         }
196     }
197     for (my $i=0; $i+2 < @o; $i++) {
198         next unless $o[$i+1]{E} eq '.';
199         my @inputs = @o[$i, $i+2];
200         #print STDERR Dumper(\@inputs);
201         next if grep { !is_string($_) } @inputs;
202         my $q = $inputs[0]{Q};
203         next if grep { $_->{Q} ne $q } @inputs;
204         next if grep { $_->{Invented} } @inputs;
205         my $new = { T => 'joinedstrings',
206                     E => (join '.', map { $_->{E} } @inputs),
207                     P => (join '.', map { $_->{P} } @inputs),
208                     V => (join '',  map { $_->{V} } @inputs),
209                     Q => $q,
210                   };
211         @o = (@o[0..$i-1], $new, @o[$i+3..$#o]);
212         $i--; # counteracts $i++
213     }
214     debug $ichunkstart, "semiparsed: ".join ' ', map { $_->{P} } @o;
215     # debug $ichunkstart, "semiparsed V: ".join ' ', map { defined $_->{V} ? ">$_->{V}<" : '-' } @o;
216     return @o;
217 }           
218
219 our @analysed_x;
220 our @analysed_y;
221
222 sub analyse_chunk_core () {
223     die "plain deletion\n" unless defined $after;
224     die "plain insertion\n" unless defined $before;
225     my @xs = semiparse $before;
226     my @ys = semiparse $after;
227     @analysed_x = @analysed_y = ();
228     my $next_something = sub {
229         my ($ary,$anal,$var,$what) = @_;
230         die "ran out of $what\n" unless @$ary;
231         my $r = shift @$ary;
232         push @$anal, $r->{P};
233         $$var = $r;
234     };
235     my ($x,$y);
236     my $next_x = sub { $next_something->(\@xs, \@analysed_x, \$x, 'before'); };
237     my $next_y = sub { $next_something->(\@ys, \@analysed_y, \$y, 'after' ); };
238     our @y_expect_suffix = ();
239     for (;;) {
240         while (my $e = shift @y_expect_suffix) {
241             $next_y->();
242             $y->{E} eq $e
243                 or die "suffix mismatch, expected $e got $y->{E}\n";
244         }
245         last unless @xs or @ys;
246         $next_x->();
247         $next_y->();
248         next if $x->{E} eq $y->{E};
249         next if $x->{E} eq 'sprintf' and $y->{E} eq 'f_';
250         next if $x->{E} eq 'die'     and $y->{E} eq 'confess';
251         next if $x->{E} eq 'die'     and $y->{E} eq 'fail';
252         if ($y->{E} eq '+'
253             and @ys >= 3
254             and $ys[0]{E} eq '('
255             and is_trans($ys[1])) {
256             $next_y->(); # (
257             $next_y->(); # __ f_ i_
258             @y_expect_suffix = ')';
259         } elsif ($y->{E} eq '('
260             and @ys > 2
261             and is_trans($ys[0])
262             and @analysed_y
263             and (grep { $_ eq $analysed_y[-1] } (qw( => [ { ? : . ),
264                                                  '(', ',') )) {
265             $next_y->(); # __ f_ i_
266             @y_expect_suffix = ')';
267         }
268         my $string_changed;
269         my $ye = $y->{E};
270         if (is_trans($y)) {
271             $next_y->();
272             die "__ on non-string $y->{P}\n"     unless is_string($y);
273             die "__ on was non-string $x->{P}\n" unless is_string($x);
274             if ($y->{Q} ne "'") {
275                 die "var subst in new string\n"
276                     if $y->{V} =~ m{(?<!\\) [\$\@]};
277             }
278             eval {
279                 die "__ string changed\n"       unless $y->{V} eq $x->{V};
280                 die "__ string quote changed\n" unless $y->{Q} eq $x->{Q};
281             };
282             $string_changed = $@;
283         }
284         if ($ye eq '__') {
285             $_ = $y->{V};
286             die "percent $& in __ ' string\n" if m{\%};
287             die $string_changed if length $string_changed;
288             next;
289         }
290         if ($ye eq 'i_') {
291             die $string_changed if length $string_changed;
292             next;
293         }
294         if ($ye eq 'f_') {
295             my $fmt = $y->{V};
296             die "no percent in f_ string\n" unless $fmt =~ m{\%};
297             next unless $string_changed;
298             die "f_ old string '-quoted\n" if $x->{Q} ne '"';
299             my $xs = $x->{V};
300             my $exactly = sub {
301                 my ($lit, $what) = @_;
302                 my $xl = substr($xs, 0, length($lit));
303                 if ($xl ne $lit) {
304                     debug $ichunkstart, "not exactly x: ..".qp($xs);
305                     debug $ichunkstart, "not exactly y:   ".qp($lit);
306                     my $next = @ys ? $ys[0]{P} : '(end)';
307                     die "string contents mismatch near $what before $next\n";
308                 }
309                 $xs = substr($xs, length($lit));
310             };
311             for (;;) {
312                 #print STDERR Dumper($fmt, $xs, \@xs, @ys);
313                 if ($fmt !~ m{\%[^\%]}) {
314                     $exactly->($fmt, '(tail)');
315                     $fmt = '';
316                     die "text deleted from end of string: ".qp($xs)."\n"
317                         if length $xs;
318                     last;
319                 }
320                 $exactly->($`, '(literal)');
321                 $fmt = $';
322                 if ($& eq '%%') { $exactly->('%', '%%'); next; }
323                 elsif ($& ne '%s') { die "unhandled %-subst $&\n"; }
324                 $next_y->();
325                 die "expected comma, got $y->{P}\n" unless $y->{E} eq ',';
326                 if (!length $fmt and
327                     !length $xs and
328                     @xs and
329                     $xs[0]{E} eq '.') {
330                     # X has   "<earlier>" .                <something>
331                     # Y has   "<earlier>%s" [other args] , <something>
332                     $next_x->(); # eat the '.'
333                     next;
334                 }
335                 if ($xs =~ m{^\@}) {
336                     $next_y->();
337                     die "\@... => not string" unless is_string($y);
338                     die "\@... => $y->{P}" if $y->{Q} ne '"';
339                     $exactly->($y->{V}, $y->{P});
340                     next;
341                 }
342                 my $bras = 0;
343                 for (;;) {
344                     if (!$bras and !@ys) {
345                         last;
346                     }
347                     $next_y->();
348                     if (!$bras and
349                         (grep { $y->{E} eq $_ } qw( or xor and not ; :
350                                                     if unless while when )
351                          or $y->{E} eq ','
352                          or $y->{T} eq 'ket'
353                         )) {
354                         # lookahead shows close of containing scope
355                         # or lower precedence operator
356                         unshift @ys, $y;
357                         pop @analysed_y;
358                         last;
359                     }
360                     $xs =~ s{^\s+}{} if $bras;
361                     if (is_string($y) and $y->{Q} eq '"') {
362                         $exactly->($y->{V}, $y->{P});
363                         next;
364                     }
365                     $exactly->($y->{E}, $y->{P});
366                     if ($y->{T} eq 'bra' or $y->{E} eq '?') {
367                         $bras++;
368                     } elsif ($y->{T} eq 'ket' or $y->{E} eq ':') {
369                         die "too many kets at $y->{E}\n" unless $bras;
370                         $bras--;
371                     }
372                 }
373             }
374             next;
375         }
376         die "mismatch $x->{P} => $y->{P}\n";
377     }
378 }
379
380 sub analyse_chunk () {
381     for (;;) {
382         eval { analyse_chunk_core(); };
383         return unless length $@;
384         if ($@ =~ m{^missing end of here doc (\S+)\n}) {
385             # fudge this
386             # (this never happens now, but in the future we might
387             # want this code again eg to try adding to the chunk)
388             $before .= "\n$1\n";
389             $after .= "\n$1\n";
390             next;
391         } else {
392             die $@;
393         }
394     }
395 }
396
397 our @report;
398 our $last_filehead = -1;
399
400 sub report_on_hunk () {
401     return unless @report;
402     if ($last_filehead != $ifilehead) {
403         foreach (my $i=$ifilehead; $i<$ifirsthunkhead; $i++) {
404             print $d[$i];
405         }
406         $last_filehead = $ifilehead;
407     }
408     my $dummy_r = { S => (scalar @d)+1, E => (scalar @d)+1 };
409     my $r;
410     for (my $i=$ihunkhead; ; $i++) {
411         for (;;) {
412             $r //= shift @report;
413             $r //= $dummy_r;
414             last if $i < $r->{E};
415             confess unless $r->{Done} == 03;
416             $r = undef;
417         }
418
419         last unless $i<$ihunkend;
420
421         foreach my $ds (@{ $debug[$i] }) {
422             print "# $ds\n";
423         }
424
425         if ($i == $r->{S}) {
426             print "!! $r->{M}";
427             $r->{Done} |= 01;
428         }
429         if ($i >= $r->{S}) {
430             print "!";
431             $r->{Done} |= 02;
432         } else {
433             print " ";
434         }
435         print $d[$i];
436     }
437     confess unless $r = $dummy_r;
438 }
439
440 for ($ifilehead = 0; l_ok $ifilehead; $ifilehead++) {
441     m{^diff} or next;
442     $ifirsthunkhead = $ifilehead;
443     while (l_ok $ifirsthunkhead and
444            m{^diff|^index|^---|^\Q+++\E}) {
445         $ifirsthunkhead++
446     }
447     $ihunkhead = $ifirsthunkhead;
448     while (l_ok $ihunkhead) {
449         m{^\@\@} or confess "$ihunkhead $_ ?";
450         my $i = $ihunkhead + 1;
451         for (; ; $i++) {
452             if (!l_ok $i or m{^ } or m{^\@\@}) {
453                 if (defined $ichunkstart) {
454                     $ichunkend = $i;
455                     eval { analyse_chunk(); 1; };
456                     if (length $@) {
457                         debug $ichunkstart, "done x: @analysed_x";
458                         debug $ichunkstart, "done y: @analysed_y";
459                         push @report, { M => $@,
460                                         S => $ichunkstart,
461                                         E => $ichunkend };
462                     }
463                     $ichunkstart = $ichunkend = $before = $after = undef;
464                 }
465                 l_ok $i or last;
466                 m{^\@\@} and last;
467             } elsif (m{^[-+]}) {
468                 my $which = $& eq '-' ? \$before : \$after;
469                 $ichunkstart //= $i;
470                 $$which //= '';
471                 $$which .= $';
472             } else {
473                 confess "$i $_ ?";
474             }
475         }
476         $ihunkend = $i;
477         report_on_hunk();
478         $ichunkend = $i;
479         $ihunkhead = $i;
480     }
481 }