domingo, 28 de outubro de 2007

Diff recursivo com filtro

Às vezes nos deparamos com problemas aparentemente simples mas que nos dão trabalho pra resolver. Não estou reclamando... esses são, normalmente, os problemas mais divertidos e instrutivos.

Ontem um colega mandou o seguinte problema numa lista de discussão:

Srs,

Estou fazendo um diff recursivo, comparando dois diretórios de arquivos de códigos, eu gostaria que neste diff não fosse considerado as linhas com comentários.

Ex:

//linha de comentário

/*
* comentário
*/

Alguém conhece uma maneira de fazer isso?

Ele quer comparar código fonte Java desconsiderando as diferenças dentro dos comentários.

Eu sabia que o comando GNU diff, presente em todas as distribuições Linux, tem algumas opções para desprezar espaços em branco. Dei uma olhada no manpage pra ver se por acaso haveria alguma opção pra desprezar comentários... não custava nada, né?

-E --ignore-tab-expansion
Ignore changes due to tab expansion.

-b --ignore-space-change
Ignore changes in the amount of white space.

-w --ignore-all-space
Ignore all white space.

-B --ignore-blank-lines
Ignore changes whose lines are all blank.

--strip-trailing-cr
Strip trailing carriage return on input.

É um bocado de opção pra ignorar espaço em branco, não é? Tantas opções e, ainda assim, nenhuma me servia. Aliás, isso me fez pensar se essa proliferação de opções específicas não poderia ser evitada com a implementação de uma opção de filtro mais genérica. Quero dizer, o diff poderia suportar uma opção pra qual pudéssemos passar uma expressão regular especificando strings que deveriam ser ignoradas. Ou, talvez, outra opção passando um comando que o diff pudesse chamar para filtrar os arquivos que fosse comparar.

Como não achei nenhuma solução direta no diff, passei a pensar em como remover os comentários do código Java. Se fosse apenas o comentário estilo C++ (//...) eu usaria um sed, mas como há também o comentário tipo C (/*...*/) não pensei duas vezes antes de usar Perl. O script mais conciso e elegante que obtive eu chamei de stripcomments.pl:

#!/usr/bin/perl
$/ = undef; # slurp mode
$_ = <>; # slurp file
s:/*.*?*/::sg; # strip C comments
s://.*::g; # strip C++ comments
print;

Ele lê todo o arquivo em memória e remove os comentários com expressões regulares simples. Com o tamanho da memória dos desktops atuais eu não creio que ler o arquivo todo seja problemático. Qualquer solução mais comedida no uso de memória seria muito mais complexa.

Pois bem, a idéia é passar pro diff o resultado da aplicação deste filtro a cada um dos seus dois argumentos. A melhor maneira que eu conheço pra fazer isso é usando a sintaxe de substituição de processos da bash. O comando seria o seguinte:

diff <(stripcomments.pl file1) <(stripcomments.pl file2)

Hmmm... há duas coisas no resultado deste diff de que eu não gosto. A primeira é que eu prefiro o resultado no formato unified, que eu obtenho com a opção -u. A segunda é que como o diff recebe o resultado do filtro ele não sabe o nome dos arquivos originais.

Descobri ontem que eu posso instruí-lo sobre o nome dos argumentos através da opção -L. O comando final ficou assim:

diff -u -L file1 <(stripcomments.pl file1) -L file2 <(stripcomments.pl file2)

OK, eu já tenho o filtro e já sei como fazer o diff usá-lo. Mas há um problema: meu colega quer um diff recursivo. Logo, eu não posso passar os nomes dos arquivos diretamente.

Estudei um pouco mais e descobri outra opção interessante do diff: a -q, ou --brief. (Só de curiosidade: o comando "man diff | grep '^ *-' | wc -l" mostra que o diff suporta 44 opções diferentes!) Com esta opção o diff não mostra as diferenças em si, apenas o nome dos arquivos diferentes.

Minha idéia final foi a seguinte. Primeiro eu executo um "diff -qr" nos dois diretórios pra descobrir quais arquivos são diferentes. Do resultado disso eu coleciono os pares de arquivos diferentes e comparo cada par com o comando diff acima, utilizando o filtro. O script final eu chamei de diff-java-stripped.pl:


01 #!/usr/bin/perl

02 BEGIN {
03 $ENV{PATH} = '/bin:/usr/bin';
04 $ENV{LANG} = 'C';
05 }

06 use strict;
07 use warnings;

08 my $usage = "$0 DIR1 DIR2\n";

09 my $dir1 = shift or die $usage;
10 my $dir2 = shift or die $usage;

11 my @pairs; # hold pairs of different files

12 open DIFF, "diff -rq $dir1 $dir2 |"
13 or die "Can't exec diff -rq: $!";
14 while (readline(*DIFF)) {
15 if (/^Files (.*) and (.*) differ$/) {
16 push @pairs, [$1, $2];
17 } else {
18 print;
19 }
20 }
21 close DIFF or die "closing DIFF";

22 open BASH, "| bash --norc"
23 or die "Can't exec bash --norc: $!";
24 for my $pair (@pairs) {
25 my ($l, $r) = @$pair;
26 print BASH "diff -u -L '$l' <(stripcomments.pl '$l') -L '$r' <(stripcomments.pl '$r')\n";
27 }
28 close BASH or die "closing BASH";

O primeiro diff é chamado na linha 12 e é processado no loop da linha 14. A saída do comando mostra três tipos de linhas, dependendo do tipo de diferença encontrada:

Only in dir1: file1
Only in dir2: file2
Files dir1/file3 and dir2/file3 differ

Quando um arquivo só existe debaixo de um dos dois diretórios o comando mostra a linha "Only in dir: file". Nesse caso, só estou interessado em mostrar este fato ao usuário.

Quando há dois arquivos diferentes com o mesmo nome dos dois lados, então eu guardo os caminhos de ambos em @pairs para compará-los depois.

O loop da linha 24 obtém cada par encontrado e constrói uma linha de comando para compará-los depois de devidamente filtrados. Cada linha de comando destas é submetida para execução por uma bash invocada a propósito na linha 22.

A definição da variável de ambiente LANG na linha 4 é para garantir que as mensagens do comando diff sejam em inglês independentemente do locale em que o comando esteja sendo executado.

Tá aí. Acho que ficou legal. E vocês?

(Obs: Foi difícil embutir os scripts acima neste post porque o editor do Blogger teimava eu não aceitar alguns caracteres e eu eliminar alguns newlines. Tive até que usar uma sintaxe alternativa na linha 14 porque não consegui fazer a sintaxe normal ser aceita.)

4 comentários:

  1. Aqui funcionou bem!
    Existem programas que fazem isso automaticamente ("ignore small changes"), mas a recursividade não existe - nem a diversão de se degladiar com o problema... ou, como diria um russo aí:

    $_="crAnh anmanagon o srwb o u-eln dt jdsmutahisalntloa adatovl otst oynmg ezat shieiinn dg e tho Zuiwtas to nflp wdsooheie oen.rpt uup.rey.roev lelarv sndiedt dy n i- t f ho ee agwdlociul ld dcb ttcohryueiea stpre p hcdlreo,s uteprl uddcirt iitiomcnnb h atZntidiu ee";$_.=$1,print$2while s/(..)(.)//;

    heh.

    ResponderExcluir
  2. @arthur: Provavelmente o blogger alterou o seu one-liner. Ao rodá-lo aqui eu vi que começa com "A man" mas daí pra frente parece que traduziu de russo pra aramaico.

    ResponderExcluir
  3. Fato. O blogger estragou minha assinatura de e-mail mais legal dos ultimos meses. :/

    Mas, a título de curiosidade, segue a citação... eu achei fenomenal o jeito que a frase se encaixa na descrição das noites perdidas em deep hack mode. :-)

    "A man would still do something out of sheer perversity - he would create destruction and chaos - just to gain his point.. and if all this could in turn be analyzed and prevented by predicting that it would occur, then man would deliberately go mad to prove his point." - Dostoyevsky, "Notes from the underground"

    ResponderExcluir
  4. Este comentário foi removido por um administrador do blog.

    ResponderExcluir