Blog

Modul des Monats: PPI

21.10.2020 // Renée Bäcker

Only perl can parse Perl

Dieses Zitat von Tom Christiansen gilt in Teilen immer noch. Perl (die Sprache) ist eigentlich alles das, was perl (der Interpreter) sauber ausführen kann. Dass das nicht nur der Code mit der Syntax ist, die man so hinlänglich sieht, kann man an etlichen Acme::-Modulen sehen. So kann man wirklich sauberen Code mit Acme::Bleach schreiben. Das Modul wandelt normalen Perl-Code in Whitespaces um – und das Programm läuft immer noch. Ein weiteres Beispiel: Zu meinen Ausbildungszeiten in der Bioinformatik habe ich (nicht produktiven) Code mit Acme::DoubleHelix in eine Doppelhelix-Darstellung umgewandelt.

Um diese Darstellung von Code geht es aber nicht, wenn wir etwas mit PPI machen. Das Modul dient der statischen Analyse von Perl-Code. Zum Beispiel als Backend für Perl::Critic und das von Gregor letztens vorgestellte Test::Perl::Critic::Progressive. Da PPI eine statische Analyse durchführt, wird der Code nicht ausgeführt. Deshalb können manche Konstrukte aber nicht tiefergehend betrachtet werden:

require $module;

Welches Modul hier geladen wird, kann PPI nicht feststellen. Für viele Anwendungsbereiche ist das aber auch irrelevant. Es ermöglicht aber viele Anwendungen, ohne dass man Perl-Code ausführen muss oder selbst mit mehr oder weniger guten regulären Ausdrücken im Code etwas suchen muss.

Aber nicht nur wegen Perl::Critic ist PPI unser Modul des Monats. Es vereinfacht auch unsere Arbeit bei einigen Anwendungen.

Zum einen bei der Bereitstellung der Perl-API-Dokumentation der ((OTRS)) Community Edition unter https://otrs.perl-services.de aber auch bei der Anwendung zur Messung der Modul-Verbreitung unter https://usage.perl-academy.de .

Bei der OTRS-Dokumentation schauen wir im Code nach, ob es überhaupt POD in der Datei gibt und schmeißt den Rest quasi weg. Damit wird es einfacher, Teile der Dokumentation auseinanderzunehmen, ohne dass der restliche Code in die Quere kommt.

So sieht der entsprechende Code aus:

my @perl_files = $archive->membersMatching('\.(?:pm|pod)$');

FILE:
for my $file ( @perl_files ) {
    my $path = $file->fileName;

    next FILE if $path =~ m{cpan-lib};
    next FILE if $path !~ m{Kernel};

    my $code = $file->contents;
    my $doc  = PPI::Document->new( \$code );
    my $pod  = $doc->find( 'PPI::Token::Pod' );

    next FILE if !$pod;

    my $pod_string = join "\n\n", map{ $_->content }@{ $pod };
    my $package    = $doc->find_first('PPI::Statement::Package');

    $pod_string =~ m{
         ^=head\d+ \s+ DESCRIPTION \s+
         (?<description>.*?) \s+
         ^=(?:head|cut)
    }xms;

    my $description = $+{description} || '';
}

In $archive steckt ein Objekt von Archive::Zip und über das holen wir alle Perl-Module und POD-Dateien, die in der Zip-Datei stecken. Wir holen immer die Zip-Datei, damit wir nicht zig Git-Klone bei uns rumfliegen haben und wir die vielleicht gar nicht mehr brauchen.

Von jeder dieser Dateien holen wir mit ->contents den Inhalt der Datei. Mit PPI::Document->new( \\$code ) erzeugen wir dann das Objekt von PPI::Document und durch die Skalarreferenz weiß PPI, dass nicht eine Datei geparst werden soll, sondern der Inhalt der Skalarvariablen. Soll eine Datei geparst werden, nimmt man einfach PPI::Document->new( $path_to_file ) .

Anschließend suchen wir alle POD-Teile in der Datei (find), und wenn gar kein POD enthalten ist, brauchen wir gar nicht weiterzumachen. Der Methode find muss man den Paketnamen übergeben, nach dessen Objekten man sucht. Welche Klassen es gibt ist, wird in der PPI-Dokumentation gezeigt.

Da wir auch noch den Paketnamen brauchen, der im Perl-Code zu finden ist, suchen wir das erste Element der Klasse PPI::Statement::Package.

Somit haben wir dann alles, um das HTML aus POD zu generieren.

Die zweite Anwendung nutzt PPI, um cpanfiles zu parsen. Warum wir nicht das Modul Module::CPANfile nutzen? Wenn man sich das anschaut, lädt es den Perl-Code mit eval. Und da wir keine Kontrolle darüber haben, was die Besucher in die Dateien schreiben, die hochgeladen werden, nehmen wir lieber einen Parser zur statischen Analyse.

Schauen wir uns also mal an, was PPI aus einem cpanfile macht. Das cpanfile hat diesen Inhalt:

on 'test' => sub {
    requires 'Perl::Critic::RENEEB' => '2.01';
    requires "Proc::Background"     => 0;
    requires 'Test::BDD::Cucumber';
};

on 'runtime' => sub {
    requires 'Class::Unload';
};

on 'develop' => sub {
    requires 'CryptX' => '0.64';
    requires 'DBIx::Class::DeploymentHandler';
    requires q~Dist::Zilla~;
    requires MySQL::Workbench::DBIC => '1.13';
};

Um uns anzuschauen, wie das Perl Document Object Tree dazu aussieht, nutzen wir das folgende Skript (Perl::Critichat ein etwas mächtigeres Skript im Repository, das aber leider nicht installiert wird):

#!/usr/bin/perl
  
use strict;
use warnings;
use PPI;
use PPI::Dumper;
use Mojo::File qw(curfile);

curfile->sibling('examples')->list->first( sub {
    my $code = $_->slurp;
    my $doc  = PPI::Document->new( \$code );
    PPI::Dumper->new( $doc )->print and return;
});

Das erzeugt dann folgende Ausgabe:

PPI::Document
  PPI::Statement
    PPI::Token::Word    'on'
    PPI::Token::Whitespace      ' '
    PPI::Token::Quote::Single   ''test''
    PPI::Token::Whitespace      ' '
    PPI::Token::Operator    '=>'
    PPI::Token::Whitespace      ' '
    PPI::Token::Word    'sub'
    PPI::Token::Whitespace      ' '
    PPI::Structure::Block   { ... }
      PPI::Token::Whitespace    '\n'
      PPI::Token::Whitespace    '    '
      PPI::Statement
        PPI::Token::Word    'requires'
        PPI::Token::Whitespace      ' '
        PPI::Token::Quote::Single   ''Perl::Critic::RENEEB''
        PPI::Token::Whitespace      ' '
        PPI::Token::Operator    '=>'
        PPI::Token::Whitespace      ' '
        PPI::Token::Quote::Single   ''2.01''
        PPI::Token::Structure   ';'
      PPI::Token::Whitespace    '\n'
      PPI::Token::Whitespace    '    '
      PPI::Statement
        PPI::Token::Word    'requires'
        PPI::Token::Whitespace      ' '
        PPI::Token::Quote::Double   '"Proc::Background"'
        PPI::Token::Whitespace      '     '
        PPI::Token::Operator    '=>'
        PPI::Token::Whitespace      ' '
        PPI::Token::Number      '0'
        PPI::Token::Structure   ';'
      PPI::Token::Whitespace    '\n'
[...]

Wir wollen einfach die Modulnamen daraus haben, also der erste String nach dem Literal requires. An dem Dump kann man gut sehen, dass das Literal immer ein Objekt der Klasse PPI::Token::Word ist. Aber nicht jedes Objekt der Klasse enthält das Literal requires. Wir sehen zum Beispiel auch sub. Das müssen wir berücksichtigen. Nach dem Literal kommen beliebige Whitespaces, die uns aber nicht interessieren. PPI kennt da das Prinzip der significant siblings/children. Da in dem Baum die Elemente von requires und der Modulname auf einer Ebene sind, interessieren wir uns für das nächste signifikate Geschwister.

So sieht der Code dann in der Anwendung aus:

            my $code = $file->slurp;
            my $doc  = PPI::Document->new( \$code );

            my $requires = $doc->find(
                sub { $_[1]->isa('PPI::Token::Word') and $_[1]->content eq 'requires' }
            );

            if ( !$requires ) {
                $file->remove;
                return;
            }

            REQUIRED:
            for my $required ( @{ $requires || [] } ) {
                my $value = $required->snext_sibling;
                
                my $can_string = $value->can('string') ? 1 : 0;
                my $prereq     = $can_string ?
                    $value->string :
                    $value->content;
                
                save_prereq( $prereq );
            }

Der Anfang ist ähnlich zu oben. Wir lesen die Datei ein und erzeugen ein Objekt von PPI::Document. Während wir oben der Methode find nur die Klasse angegeben haben, deren Objekte wir alle haben wollen, möchten wir hier die Suche etwas einschränken. Nämlich alle PPI::Token::Word-Objekte, deren Inhalt required ist. Übergibt man eine anonyme Subroutine an find, wird für jedes Element unterhalb des zu durchsuchenden Knoten diese Subroutine aufgerufen.

Als Parameter werden zwei Parameter übergeben. Zum einen das Element auf dem die Suche ausgeführt wird und zum anderen das Element, das geprüft wird.

Die Methode content liefert den Code 1:1.

Für alle gefundenen requires bekommen wir die Objekte der Klasse PPI::Token::Word. Für jedes dieser Objekte wollen wir das erste signifikante Geschwister. Mit next_sibling würde man die Whitespaces bekommen, die interessieren uns aber nicht. Aus diesem Grund müssen wir snext_sibling verwenden.

Schauen wir noch mal kurz auf die verschiedenen Varianten der Modulangaben:

requires 'Perl::Critic::RENEEB' => '2.01';
requires "Proc::Background"     => 0;
requires q~Dist::Zilla~;
requires MySQL::Workbench::DBIC => '1.13';

... und die dazugehörigen PPI-Elemente:

PPI::Token::Quote::Single   ''Perl::Critic::RENEEB''
PPI::Token::Quote::Double   '"Proc::Background"'
PPI::Token::Quote::Literal  'q~Dist::Zilla~'
PPI::Token::Word            'MySQL::Workbench::DBIC'

Da die Whitespace-Knoten übergangen werden bekommen wir einen der hier gezeigten Knoten. Mit der Methode content bekommen wir den Wert wie er hier zu sehen ist, also inklusive der Anführungszeichen bzw. des q\~\~.

Wir müssen uns also auf die unterschiedlichen Methoden stützen, die die Objekte mitliefern. Bei den ganzen Quote::\*-Klassen kann die Methode string genutzt werden, bei PPI::Token::Word die Methode content.

Noch kurz zu den s...-Methoden: Als significant werden alle Teile bezeichnet, die für die Ausführung des Codes wichtig sind. Kommentare, POD-Dokumenation, __END__-Bereiche und Whitespaces gehören nicht dazu.

Die Skripte und Beispiele habe ich auch wieder ins Git-Repository gepackt.


Permalink: