Verschärfte Suche (Linux-Magazin, Juli 2000)

Ein kleiner Altavista für die Festplatte gefällig? Das Modul Text::Query sucht in Text-Dokumenten nach vorgegebenen Ausdrücken.

Irgendwo in den 3000 Unterverzeichnissen auf meiner Festplatte habe ich doch vor drei Wochen mal ein C-Programm angefangen ... bloß -- wo war das gleich wieder? Wie hieß die Datei noch? Wieder test.c? t.c? Keine Ahnung mehr, Hilfe! Wer wie ich öfter vor solchen Problemen steht, wird das heutige Skript zu schätzen wissen: Es wühlt sich durch alle Textdateien auf der Festplatte und prüft, ob deren Inhalt komplexen Bedingungen wie ``Soll push_back enthalten, aber nicht printf'' oder ``printf soll nicht mehr als 10 Worte von "Hello World" entfernt stehen'' genügt.

In Unterverzeichnisse abzusteigen und in die Tiefe vorzudringen geht dank Perls File::Find-Modul problemlos, nur Pattern Matching, das über reguläre Ausdrücke hinausgeht, war bislang noch mit Aufwand verbunden. Das Text::Query-Modul von Eric Bohlman und Loic Dachary vom CPAN schafft da Abhilfe, denn es implementiert einen Query-Prozessor -- der sich mit simple_text und advanced_text in zwei verschiedenen Modi betreiben lässt.

Für Anfänger und Profis

Im simple_text-Moduls reagiert Text::Query wie ein Simple Query auf AltaVista.com. Ein Query besteht aus mehreren Worten oder in doppelte Anführungszeichen eingeschlossenen Ausdrücken. So sucht die Vorgabe hello world nach Dokumenten, die entweder hello oder world enthalten -- nicht notwendigerweise direkt hintereinander oder in dieser Reihenfolge. +hello -world hingegen stellt zur Bedingung, dass hello vorkommt, world jedoch nicht. Und "hello world" (in doppelten Anführungszeichen) passt nur auf Dokumente, in denen wörtlich irgendwo hello world steht. Standardmäßig achtet der Matcher nicht auf Groß- und Kleinschreibung, dies kann aber, wie später gezeigt wird, aktiviert werden.

advanced_text hingegen bietet logische Operatoren, ähnlich dem Advanced Search auf AltaVista.com. hello AND world passt auf Dokumente, die hello und world irgendwo enthalten, hello OR world ist zufrieden, wenn auch nur eines dieser Worte vorkommt. hello NEAR world fordert, dass beide Worte in einem einstellbaren Abstand stehen, üblicherweise dürfen nicht mehr als zehn Worte dazwischen liegen. Worte, die nicht auftauchen dürfen, schließt NOT aus und Phrasen, die Leerzeichen enthalten, halten doppelte Anführungszeichen zusammen: "hello world" AND NOT programming suchte nach Dateien, die zwar den Ausdruck hello world enthalten, in denen aber nicht von programming die Rede ist. Eine wörtliche Suche nach den Schlüsselworten AND, OR, NEAR oder NOT lässt sich bewerkstelligen, indem man sie in doppelte Anführungszeichen einschließt: "and" OR "or" sucht nach Dokumenten die entweder and oder or enthalten.

Ein neues Query-Objekt entsteht mit

    $query = Text::Query->new($query_string, 
                              -mode => $mode);

wobei $query_string den Query-String enthält (z. B. "hello AND world") und $mode entweder auf "simple_text" oder "advanced_text" gesetzt ist. Anschließend stellt

    $yesno = $query->match($text);

fest, ob der Text $text auf den eingestellten Query passt oder nicht und liefert dementsprechend einen wahren oder falschen Wert zurück. Einmal aufgesetzt, speichert das Text::Query-Objekt den Query intern optimiert und lässt beliebig viele Aufrufe der match-Methode auf verschiedene Textstücke zu.


Außer dem C<simple_text>- oder C<advanced_text>-Modus nimmt der Matcher
noch weitere Optionen entgegen:

-case => 1 macht den Matcher für Groß- und Kleinschreibung empfänglich. Normalerweise spielt es keine Rolle, ob Hello oder hello im Text steht -- der Query-String hello passt auf beide Stellen. Steht der -case-Schalter auf 1, spricht der Parser nur auf die zweite Textstelle an.

Außer den boolschen Konstrukten, die einen Query zusammenstricken, erlaubt Text::Query noch reguläre Perl-5-Ausdrücke, falls der -regexp-Schalter auf 1 gesetzt ist. Das Query-Objekt

    $query = Text::Query->new('\\bprint\\b',
                              -regexp => 1,
                              -mode   => "advanced_text");

würde wegen der mit \b festgelegten Wortgrenzen zwar auf print anschlagen, nicht jedoch auf printf.

Während mehrere Leerzeichen, Tabulatoren und Zeilenumbrüche normalerweise gleichermaßen, nämlich als ein Leerzeichen, behandelt werden, weist der auf 1 gesetzter Schalter -litspace den Matcher an, Leerzeichen im Query-String wörtlich zu nehmen.

Und während das NEAR-Konstrukt im advanced_text-Modus üblicherweise Übereinstimmungen meldet, falls die kombinierten Worte nicht mehr als 10 Worte auseinander liegen, stellt die -near-Option beliebige Zahlenwerte ein.

Soviel zur Syntax von Text::Query -- Zeit für eine richtige Anwendung: findsearch.pl findet passende Textdateien unterhalb eines eingestellten Verzeichnisses.

Der Wühler

Das Textstück in Listing test.dat, das aus der Text::Query-Dokumentation stammt, soll für die nachfolgenden Untersuchungen herhalten.

findsearch.pl erwartet als Parameter das Verzeichnis, in dem es die Suche nach passenden Dateien starten soll und den Query-Ausdruck, der entscheidet, ob eine Datei passt oder nicht. Mit der Option -a schaltet findsearch.pl vom simple_text in den advanced_text-Modus, verträgt dann also auch boolsche Operationen wie AND und OR wie oben beschrieben. Ist der Beispieltext von Listing test.dat zum Beispiel irgendwo in testdir/test.dat begraben, gibt

    findsearch.pl -a testdir 'object AND NOT cowboy'

schnell

    testdir/test.dat

aus, denn testdir/test.dat erfüllt den angegebenen Query. Es enthält den Ausdruck object in der ersten Zeile und nirgendwo den Ausdruck cowboy. Tabelle 1 zeigt einige Kommandozeilenoptionen im Einsatz und deckt in der zweiten Spalte auf, ob der Matcher das Textstück aus Listing test.dat unter diesen Bedingungen als passend ansah. Tabelle 1 startet im simple_text-Modus und arbeitet sich dann über den advanced_text-Modus zu komplizierteren Queries weiter.

Kommandozeilenparameter Ausdruck passt auf Listing test.dat oder nicht?
'this module' Match, da Groß- oder Kleinschreibung irrelevant
'object cowboy' Match, da #1 vorkommt und im simple-Modus die oder-Verknüpfung zählt
'object +cowboy' Kein Match, da 'cowboy' zwingend vorgeschrieben
'+object -cowboy' Match, da 'object' vorkommt und 'cowboy' nicht
'"module provides"' Match, da wörtlich so vorkommend
'"provides object"' Kein Match, da nicht wörtlich so vorkommend
-a 'object AND cowboy' Kein Match, da der advanced-Modus eingestellt ist und nicht beide Ausdrücke vorkommen
-a 'object AND NOT cowboy' Match, da 'object' vorkommt und 'cowboy' nicht
-a 'object OR cowboy' Match, da object vorkommt und das genügt
-a 'module NEAR expression' Kein Match, da elf Worte dazwischen liegen
-a 'module NEAR object' Match, da zwei Worte dazwischen liegen
-a '"module provides" Match, da wörtlich so vorkommend
-a '"provides object" Kein Match, da nicht wörtlich so vorkommend
Tabelle 1: Reaktionen von findsearch.pl auf die Testdatei test.dat mit verschiedenen Kommandozeilenoptionen

Installation

findsearch.pl setzt das korrekt installierte Modul Text::Query vom CPAN voraus. Es wird am einfachsten mit der CPAN-Shell und

    perl -MCPAN -eshell 
    cpan> install Text::Query

von dort abgeholt und auf die heimische Festplatte kopiert.


Zeile 3 in C<findsearch.pl> weist das Skript mit C<use strict> an, keine
schlampigen Konstruktionen durchgehen zu lassen und alle Variablen
ordentlich mit C<my> zu lokalisieren. Anschließend kommen wichtige
Module hinzu: C<Getopt::Std> zum Erkennen von Kommandozeilenoptionen,
C<IO::File> für moderne Filehandles, C<File::Find> zum Durchsuchen
von Unterverzeichnissen und das frisch vom CPAN geholte
C<Text::Query> zum Erkennen von AltaVista-ähnlichen Patterns in 
Textstücken.

Zeile 10 legt -a als gültigen Kommandozeilenschalter fest und setzt den Eintrag 'a' im Hash %opts auf 1, falls -a gesetzt wurde. Anschließend entfernt es den Schalter auch noch aus @ARGV, um dem restlichen Programm die Arbeit zu erleichtern. Zeile 14 muss danach nur noch die verbliebenen Parameter abholen und diese als das Startverzeichnis und den Query-Ausdruck interpretieren.

Falls das Startverzeichnis oder der Query-Ausdruck fehlen, verzweigen die Zeilen 17 oder 21 zur Funktion usage, die ab Zeile 58 definiert ist, eine kurze Bedienungsanleitung ausgibt und das Programm daraufhin terminiert.

Andernfalls erzeugt Zeile 24 ein neues Text::Query-Objekt und gibt diesem den in Zeile 12 gesetzten $mode mit, der entweder auf simple_text oder advanced_text steht.

Zeile 28 startet die Suche im angegebenen Unterverzeichnis und in beliebigen Stockwerken darunter. Die find-Funktion aus dem File::Find-Modul nimmt als Parameter eine Referenz auf eine Funktion und das Startverzeichnis entgegen, ruft für jeden gefundenen Eintrag (also nicht nur für Dateien, sondern auch Verzeichnisse) die angegebene Funktion auf und setzt dort die Spezial-Variable $_ auf den Namen des Eintrags und $File::Find::dir auf den Verzeichnispfad dorthin. Damit der Finder an die Callback-Funktion auch noch das Query-Objekt übergibt, definiert Zeile 24 einfach eine Subroutine um den Aufruf von search_file, die jedesmal schnell $q dazuschmuggelt.

search_file ab Zeile 31 holt das Query-Objekt ab, setzt $file auf den auf dem $_-Weg hereingereichten Eintrag und, falls es sich bei diesem nicht um eine Textdatei handelt, bricht es die Funktion in Zeile 37 ab und kehrt zurück, damit der Finder mit dem nächsten gefundenen Eintrag fortfahren kann.

Wurde tatsächlich eine Textdatei gefunden, öffnet Zeile 39 diese und Zeile 46 liest deren Inhalt auf einen Schlag in den Skalar $data ein. Falls im System irgendwelche 10-Gigabyte-Textdateien herumlungern, kann dies zu Problemen führen und, falls dies der Fall ist, sollte man noch einen Test der Art

    return if -s $file > 1_000_000;

anbringen, um Textdateien, die größer als ein Megabyte sind, zu ignorieren. Zeile 50 prüft, ob der Matcher den Daumen für die Datei nach oben oder unten hält -- und falls der Ausdruck passt, gibt Zeile 51 schlicht den Dateinamen mitsamt dem Pfad aus, und der Anwender weiß, dass dies eventuell die seit Ewigkeiten verschollene Datei ist -- endlich gefunden! Zeile 54 setzt $_ wieder auf den ursprünglichen Wert zurück, worauf das File::Find-Modul besteht, sonst kracht's.

Und fertig -- Dank raffinierter Modultechnik vom CPAN! Viel Spass beim Stöbern, bis zum nächsten Mal!

Listing test.dat

    This module provides an object that parses a 
    string containing a Boolean query expression 
    similar to an AltaVista "simple query".

Listing findsearch.pl

    01 #!/usr/bin/perl -w
    02 
    03 use strict;
    04 use Getopt::Std;
    05 use IO::File;
    06 use File::Find;
    07 use Text::Query;
    08 
    09 my %opts;
    10 getopts('a', \%opts);
    11 
    12 my $mode = $opts{a} ? "advanced_text" : "simple_text";
    13 
    14 my ($dir, $query) = @ARGV;
    15 
    16 if(! defined $dir or ! -d $dir) {
    17     usage("Directory not specified or unreadable");
    18 }
    19 
    20 if(! defined $query) {
    21     usage("No query given");
    22 }
    23 
    24 my $q=Text::Query->new($query, 
    25                        -mode   => $mode,
    26                       );
    27 
    28 find(sub { search_file($q) }, $dir);
    29 
    30 ##################################################
    31 sub search_file {
    32 ##################################################
    33     my ($q) = @_;
    34 
    35     my $file = $_;
    36 
    37     return unless -T $file;
    38 
    39     my $fh = IO::File->new("< $file");
    40 
    41     if(! $fh) {
    42         warn "Cannot open '$File::Find::dir/$file'";
    43         return 1;
    44     }
    45 
    46     my $data = join '', <$fh>;
    47 
    48     $fh->close;
    49 
    50     if($q->match($data)) {
    51         print "$File::Find::dir/$file\n";
    52     }
    53 
    54     $_ = $file;
    55 }
    56 
    57 ##################################################
    58 sub usage {
    59 ##################################################
    60     my ($message) = @_;
    61     (my $prog = $0) =~ s#.*/##g;
    62 
    63     print <<EOT;
    64 $message
    65 usage: $prog [-a] dir query
    66   dir:   Directory to start search in
    67   query: Query string
    68     simple: [+-]ausdruck [+-]ausdruck ...
    69     -a: ausdruck AND|OR|NEAR [NOT] ausdruck ...
    70 EOT
    71 
    72     exit 1;
    73 }

Michael Schilli

arbeitet als Software-Engineer bei Yahoo! in Sunnyvale, Kalifornien. Er hat "Goto Perl 5" (deutsch) und "Perl Power" (englisch) für Addison-Wesley geschrieben und ist unter mschilli@perlmeister.com zu erreichen. Seine Homepage: http://perlmeister.com.