File Coverage

File:blib/lib/App/Test/Generator/Mutation/BooleanNegation.pm
Coverage:82.0%

linestmtbrancondsubtimecode
1package App::Test::Generator::Mutation::BooleanNegation;
2
3
10
10
10
204507
7
137
use strict;
4
10
10
10
13
7
202
use warnings;
5
6
10
10
10
189
120
27
use parent 'App::Test::Generator::Mutation::Base';
7
10
10
10
1519
11
129
use App::Test::Generator::Mutant;
8
10
10
10
408
137183
3017
use PPI;
9
10our $VERSION = '0.36';
11
12 - 58
=head1 NAME

App::Test::Generator::Mutation::BooleanNegation - Negate boolean return
expressions to expose missing assertion coverage

=head1 VERSION

Version 0.36

=head1 METHODS

=head2 applies_to

Return true if this mutation strategy applies to the given PPI node.
Used by the mutation framework to pre-filter nodes before calling
C<mutate>.

    my $applies = $mutation->applies_to($node);

=head3 Arguments

=over 4

=item * C<$node>

A L<PPI::Element> node to test.

=back

=head3 Returns

True if the node is a C<PPI::Statement::Return>, false otherwise.

=head3 API specification

=head4 input

    {
        self => { type => OBJECT, isa => 'App::Test::Generator::Mutation::BooleanNegation' },
        node => { type => OBJECT, isa => 'PPI::Element' },
    }

=head4 output

    { type => SCALAR }

=cut
59
60sub applies_to {
61
9
11592
        my ($self, $node) = @_;
62
63        # PPI >= 1.270 classifies return as PPI::Statement::Break
64        # rather than PPI::Statement::Return
65
9
23
        return 0 unless $node->isa('PPI::Statement::Break');
66
67        # Must specifically be a return statement, not last/next/redo
68
4
6
        my $first = $node->schild(0) or return 0;
69
4
27
        return $first->content eq 'return';
70}
71
72 - 145
=head2 mutate

Walk a PPI document and generate one mutant for each return statement
whose expression can be negated. For example, C<return $ok> becomes
C<return !($ok)>.

    my $mutation = App::Test::Generator::Mutation::BooleanNegation->new;
    my $doc      = PPI::Document->new(\$source);
    my @mutants  = $mutation->mutate($doc);

    for my $m (@mutants) {
        print $m->id, ': ', $m->description, "\n";
    }

=head3 Arguments

=over 4

=item * C<$self>

An instance of C<App::Test::Generator::Mutation::BooleanNegation>.

=item * C<$doc>

A L<PPI::Document> object representing the parsed source to mutate.
The document is not modified by this method.

=back

=head3 Returns

A list of L<App::Test::Generator::Mutant> objects, one per qualifying
return statement found in the document. Returns an empty list if no
return statements with expressions are found.

Each mutant carries a C<transform> closure that when called with a
fresh L<PPI::Document> copy will wrap the targeted return expression
in C<!( )>, negating its boolean value.

=head3 Notes

Mutant IDs include both line and column number to ensure uniqueness
when multiple return statements appear on different lines of the same
source file.

Only return statements that have an expression child (i.e. not bare
C<return;> statements) are mutated.

=head3 API specification

=head4 input

    {
        self => {
            type => OBJECT,
            isa  => 'App::Test::Generator::Mutation::BooleanNegation',
        },
        doc => {
            type => OBJECT,
            isa  => 'PPI::Document',
        },
    }

=head4 output

    {
        type     => ARRAYREF,
        elements => {
            type => OBJECT,
            isa  => 'App::Test::Generator::Mutant',
        },
    }

=cut
146
147sub mutate {
148
69
45379
        my ($self, $doc) = @_;
149
150        # PPI >= 1.270 classifies return statements as PPI::Statement::Break
151        # (alongside last/next/redo) rather than PPI::Statement::Return.
152        # Use a custom predicate to match only 'return' Break nodes.
153        my $returns = $doc->find(sub {
154
4722
18486
                my $node = $_[1];
155                # Must be a Break statement -- the parent class for return in
156                # newer PPI versions
157
4722
5551
                return 0 unless $node->isa('PPI::Statement::Break');
158                # Distinguish return from last/next/redo by checking the
159                # first significant child token
160
160
158
                my $first = $node->schild(0) or return 0;
161
160
869
                return $first->content eq 'return';
162
69
254
        }) || [];
163
164
69
459
        my @mutants;
165
166
69
69
65
80
        for my $ret (@{$returns}) {
167                # Skip bare return statements with no expression to negate.
168                # Also skip if the only child after 'return' is a semicolon —
169                # PPI may include the statement terminator as a significant child
170
160
162
                my $expr = $ret->schild(1) or next;
171
160
1204
                next if $expr->isa('PPI::Token::Structure') && $expr->content eq ';';
172
173                # Skip structure nodes (e.g. return ($x, $y) gives a
174                # PPI::Structure::List) — set_content only exists on tokens
175
157
178
                next unless $expr->isa('PPI::Token');
176
177                # Skip postfix conditionals — wrapping 'unless ...' in !() is invalid syntax
178
157
227
                next if $expr->isa('PPI::Token::Word') && $expr->content =~ /^(?:if|unless|while|until|for|foreach)$/;
179
180                # Capture location so the transform closure targets the
181                # exact statement rather than the first match on that line
182
153
205
                my $line = $ret->location->[0];
183
153
49840
                my $col  = $ret->location->[1];
184
185                # Build a unique ID from line and column so multiple return
186                # statements in the same file never collide
187
153
1005
                my $id = "BOOL_NEGATE_${line}_${col}";
188
189
153
107
                my $mutant = eval {
190                        App::Test::Generator::Mutant->new(
191                                id          => $id,
192                                group       => "BOOL_NEGATE:$line",
193                                description => 'Negate boolean return expression',
194                                original    => $ret->content,
195                                line        => $line,
196                                type        => 'boolean',
197
198                                # The transform closure captures line and col so it
199                                # targets precisely the right return statement in the
200                                # document copy it receives at test time
201                                transform => sub {
202
12
12
                                        my $doc  = $_[0];
203
204                                        # Locate all return statements in the fresh document copy using
205                                        # the same PPI::Statement::Break predicate as the outer find --
206                                        # PPI >= 1.270 no longer uses PPI::Statement::Return
207                                        my $rets = $doc->find(sub {
208                                                my $node = $_[1];
209                                                # Match Break nodes only -- covers return/last/next/redo
210                                                return 0 unless $node->isa('PPI::Statement::Break');
211                                                # Filter to return specifically by inspecting the first token
212                                                my $first = $node->schild(0) or return 0;
213                                                return $first->content eq 'return';
214
12
35
                                        }) || [];
215
216
12
12
70
13
                                        for my $ret (@{$rets}) {
217                                                # Match by line and column to avoid mutating
218                                                # the wrong return statement
219
14
1083
                                                next unless $ret->line_number   == $line;
220
12
5317
                                                next unless $ret->column_number == $col;
221
222                                                # Skip bare returns with no expression
223
12
109
                                                my $expr = $ret->schild(1) or last;
224
225                                                # Skip bare semicolon
226
12
115
                                                next if $expr->isa('PPI::Token::Structure') && $expr->content eq ';';
227
228                                                # Skip structure nodes — set_content only exists on tokens
229
12
19
                                                next unless $expr->isa('PPI::Token');
230
231                                                # Skip postfix conditionals — wrapping 'unless ...' in !() is invalid syntax
232
12
22
                                                next if $expr->isa('PPI::Token::Word') && $expr->content =~ /^(?:if|unless|while|until|for|foreach)$/;
233
234
12
18
                                                my $content = $expr->content();
235
12
50
                                                $expr->set_content("!($content)");
236
12
25
                                                last;
237                                        }
238                                },
239
153
182
                        );
240                };
241
242                # If the Mutant construction fails, report clearly rather than
243                # silently dropping the mutant from the results
244
153
278
                if($@ || !$mutant) {
245
0
0
                        warn "Failed to construct mutant $id: $@" if $@;
246
0
0
                        next;
247                }
248
249
153
153
                push @mutants, $mutant;
250        }
251
252
69
159
        return @mutants;
253}
254
255 - 277
=head1 AUTHOR

Nigel Horne, C<< <njh at nigelhorne.com> >>

=head1 LICENCE AND COPYRIGHT

Copyright 2026 Nigel Horne.

Usage is subject to licence terms.

The licence terms of this software are as follows:

=over 4

=item * Personal single user, single computer use: GPL2

=item * All other users (including Commercial, Charity, Educational,
Government) must apply in writing for a licence for use from Nigel Horne
at the above e-mail.

=back

=cut
278
2791;