How do I add new lines to a Perl script using PPI?

Viewed 217

What I'd ideally like to be able to do is scan a bunch of files that are implicitly importing a bunch of functions via Test::Most. I'd like to explicitly import the functions within the file. So basically I'll check the use statements to see if they already exist and, if they don't, I'd like to add an additional use statement for the function(s) in question. For example, I might add use Test::Differences qw( eq_or_diff ); if there's an eq_or_diff in the file, but no use Test::Differences. It'll get a little more complicated, but that's the basic idea.

As a proof of concept, I've tried to add just a single word into an existing script, but I can't figure it out. insert_after() returns true on success. I only ever get a false value, but I don't see any debugging info as to why the line could not be added.

use strict;
use warnings;

use PPI::Document ();
use PPI::Token::Word ();
use Test::More;

my $script = <<'EOF';
use strict;
use warnings;

use DateTime ();
use Git::Helpers qw( checkout_root );
use LWP::UserAgent ();

my $foo = 'bar';
EOF

my $doc = PPI::Document->new( \$script );

my $includes    = $doc->find('PPI::Statement::Include');
my @use         = grep { $_->type eq 'use' } @{$includes};
my $second_last = $use[-2];

diag 'Trying to insert after ' . $second_last->module;

my $word = PPI::Token::Word->new('use');
isa_ok( $word,        'PPI::Element', 'word is an Element' );
isa_ok( $second_last, 'PPI::Element', 'use is an Element' );

ok( $second_last->insert_after($word), 'word inserted' );

diag $doc->serialize;

done_testing();

The output of my script is as follows. You'll note that the document does not appear to have been altered:

# trying to insert after Git::Helpers
ok 1 - 'word is an Element' isa 'PPI::Element'
ok 2 - 'use is an Element' isa 'PPI::Element'
not ok 3 - word inserted
#   failed test 'word inserted'
#   at so.pl line 31.
# use strict;
# use warnings;
#
# use DateTime ();
# use Git::Helpers qw( checkout_root );
# use LWP::UserAgent ();
#
# my $foo = 'bar';
1..3
# looks like you failed 1 test of 3.
1 Answers

Looking at the source of PPI::Statement:

# As above, you can insert a statement, or a non-significant token
sub insert_after {
        my $self    = shift;
        my $Element = _INSTANCE(shift, 'PPI::Element') or return undef;
        if ( $Element->isa('PPI::Statement') ) {
                return $self->__insert_after($Element);
        } elsif ( $Element->isa('PPI::Token') and ! $Element->significant ) {
                return $self->__insert_after($Element);
        }
        '';
}

A "non-significant token" is something like whitespace or a comment.

You are trying to insert a single, significant token at the top level (after a statement). That's not allowed.

You'll have to build a full PPI::Statement::Include element.


Here's some (rather ugly) proof-of-concept code:

# ...
diag 'Trying to insert after ' . $second_last->module;

{
    my $insertion_point = $second_last;
    for my $new_element (
        do {
            my $synthetic_use = PPI::Statement::Include->new;
            for my $child (
                PPI::Token::Word->new('use'),
                PPI::Token::Whitespace->new(' '),
                PPI::Token::Word->new('Test::Differences'),
                PPI::Token::Whitespace->new(' '),
                PPI::Token::Quote::Single->new("'eq_or_diff'"),
                PPI::Token::Structure->new(';'),
            ) {
                ok $synthetic_use->add_element($child);
            }
            $synthetic_use
        },
        PPI::Token::Whitespace->new("\n"),
    ) {
        ok $insertion_point->insert_after($new_element);
    }
}

diag $doc->serialize;

But it's much easier to let PPI parse a given fragment and just use those objects:

diag 'Trying to insert after ' . $second_last->module;

{
    my $insertion_point = $second_last;
    for my $new_element (
        reverse PPI::Document->new(\ "\nuse Test::Differences qw( eq_or_diff );")->elements
    ) {
        ok $insertion_point->insert_after($new_element->remove);
    }
}

diag $doc->serialize;

Beware: Using $new_element->remove instead of just $new_element is crucial. You need to detach $new_element from its old containing document because otherwise the destruction of the temporary PPI::Document instance will wipe out all child elements, including those already added to $doc.

Related