| File: | bin/pod-example-tester |
| Coverage: | 73.0% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | #!/usr/bin/env perl | |||||
| 2 | ||||||
| 3 | 1 1 1 | 2274 0 18 | use strict; | |||
| 4 | 1 1 1 | 1 0 24 | use warnings; | |||
| 5 | ||||||
| 6 | 1 1 1 | 1 0 29 | use File::Path qw(make_path); | |||
| 7 | 1 1 1 | 1 1 6 | use File::Spec; | |||
| 8 | 1 1 1 | 189 492 17 | use FindBin; | |||
| 9 | 1 1 1 | 371 4504 2 | use Getopt::Long; | |||
| 10 | 1 1 1 | 339 25997 85 | use Pod::Usage; | |||
| 11 | 1 1 1 | 239 272 3 | use lib "$FindBin::Bin/../lib"; | |||
| 12 | ||||||
| 13 | 1 1 1 | 237 2 30621 | use App::Test::Generator::PodExampleExtractor; | |||
| 14 | ||||||
| 15 - 66 | =head1 NAME
pod-example-tester - Generate round-trip tests from POD code examples
=head1 SYNOPSIS
pod-example-tester [options] <module.pm>
Options:
--output FILE Write test file to FILE (default: t/pod_examples.t)
--no-annotated Skip annotated single-line examples; only use verbatim blocks
--help Show this help message
--man Show full documentation
Examples:
pod-example-tester lib/My/Module.pm
pod-example-tester --output t/synopsis.t lib/My/Module.pm
=head1 DESCRIPTION
Reads a Perl module, extracts code examples from its POD (from
C<=head1 SYNOPSIS>, C<=head2 SYNOPSIS>, C<=for example begin>/C<=for example end>
blocks, and annotated inline examples), and generates a Test::Most-based
C<.t> file that runs every example as a subtest.
Each subtest:
=over 4
=item * Wraps the example code in C<eval { ... }>
=item * Asserts the example does not die
=item * If the example has a C<# returns value> or C<< # => value >> annotation,
additionally asserts the return value with C<is()>
=back
Running this test suite after any code change ensures that all documented
examples still work â the "round-trip" between documentation and code.
=head1 ANNOTATION FORMAT
Append C<# => value> or C<# returns value> to any indented line in the POD
to document the expected return value:
my $status = $obj->validate_score(75.5); # returns 'Pass'
my $x = add(2, 3); # => 5
The generated test will assert C<is($result, 'Pass', ...)> etc.
=cut | |||||
| 67 | ||||||
| 68 | 1 | 36746 | my %opts = ( | |||
| 69 | output => 't/pod_examples.t', | |||||
| 70 | annotated => 1, | |||||
| 71 | help => 0, | |||||
| 72 | man => 0, | |||||
| 73 | ); | |||||
| 74 | ||||||
| 75 | GetOptions( | |||||
| 76 | 'output|o=s' => \$opts{output}, | |||||
| 77 | 'annotated!' => \$opts{annotated}, | |||||
| 78 | 'help|h' => \$opts{help}, | |||||
| 79 | 'man|m' => \$opts{man}, | |||||
| 80 | 1 | 6 | ) or pod2usage(2); | |||
| 81 | ||||||
| 82 | 1 | 446 | pod2usage(-exitval => 0, -verbose => 1) if $opts{help}; | |||
| 83 | 1 | 1 | pod2usage(-exitval => 0, -verbose => 2) if $opts{man}; | |||
| 84 | ||||||
| 85 | 1 | 2 | my $input_file = shift @ARGV or pod2usage('Error: No input file specified'); | |||
| 86 | 1 | 17 | die "Error: File not found: $input_file\n" unless -f $input_file; | |||
| 87 | ||||||
| 88 | # Derive the package name from the source file | |||||
| 89 | 1 | 3 | my $package = _detect_package($input_file) | |||
| 90 | or die "Error: Could not determine package name from $input_file\n"; | |||||
| 91 | ||||||
| 92 | 1 | 7 | my $extractor = App::Test::Generator::PodExampleExtractor->new(file => $input_file); | |||
| 93 | 1 | 3 | my $examples = $extractor->extract(); | |||
| 94 | ||||||
| 95 | 1 | 1 | unless(@$examples) { | |||
| 96 | 0 | 0 | warn "No POD examples found in $input_file\n"; | |||
| 97 | 0 | 0 | exit 0; | |||
| 98 | } | |||||
| 99 | ||||||
| 100 | # Apply --no-annotated filter | |||||
| 101 | 1 | 2 | if(!$opts{annotated}) { | |||
| 102 | 0 0 | 0 0 | $examples = [ grep { !defined $_->{annotated_line} } @$examples ]; | |||
| 103 | } | |||||
| 104 | ||||||
| 105 | 1 | 2 | unless(@$examples) { | |||
| 106 | 0 | 0 | warn "No examples remaining after filtering\n"; | |||
| 107 | 0 | 0 | exit 0; | |||
| 108 | } | |||||
| 109 | ||||||
| 110 | # Ensure output directory exists | |||||
| 111 | 1 | 15 | my $out_dir = (File::Spec->splitpath($opts{output}))[1]; | |||
| 112 | 1 | 6 | make_path($out_dir) if $out_dir && !-d $out_dir; | |||
| 113 | ||||||
| 114 | open my $fh, '>', $opts{output} | |||||
| 115 | 1 | 31 | or die "Error: Cannot write to $opts{output}: $!\n"; | |||
| 116 | ||||||
| 117 | 1 | 2 | _emit_test_file($fh, $package, $input_file, $examples); | |||
| 118 | ||||||
| 119 | 1 | 82 | close $fh; | |||
| 120 | ||||||
| 121 | 1 | 7 | printf "Wrote %d example test(s) to %s\n", scalar(@$examples), $opts{output}; | |||
| 122 | ||||||
| 123 | # --------------------------------------------------------------------------- | |||||
| 124 | # _emit_test_file | |||||
| 125 | # --------------------------------------------------------------------------- | |||||
| 126 | sub _emit_test_file { | |||||
| 127 | 1 | 6 | my ($fh, $package, $source, $examples) = @_; | |||
| 128 | ||||||
| 129 | 1 | 4 | print $fh "#!/usr/bin/env perl\n"; | |||
| 130 | 1 | 1 | print $fh "#\n"; | |||
| 131 | 1 | 1 | print $fh "# POD round-trip tests for $package\n"; | |||
| 132 | 1 | 2 | print $fh "# Generated from $source by pod-example-tester\n"; | |||
| 133 | 1 | 1 | print $fh "# DO NOT EDIT â regenerate with: pod-example-tester $source\n"; | |||
| 134 | 1 | 2 | print $fh "#\n\n"; | |||
| 135 | 1 | 1 | print $fh "use strict;\n"; | |||
| 136 | 1 | 1 | print $fh "use warnings;\n"; | |||
| 137 | 1 | 1 | print $fh "use Test::Most;\n\n"; | |||
| 138 | 1 | 1 | print $fh "use_ok('$package') or BAIL_OUT(\"Cannot load $package\");\n\n"; | |||
| 139 | ||||||
| 140 | 1 | 1 | my $ctor = _detect_constructor($package); | |||
| 141 | ||||||
| 142 | 1 | 1 | for my $ex (@$examples) { | |||
| 143 | 1 | 1 | _emit_subtest($fh, $ex, $package, $ctor); | |||
| 144 | } | |||||
| 145 | ||||||
| 146 | 1 | 1 | print $fh "\ndone_testing();\n"; | |||
| 147 | } | |||||
| 148 | ||||||
| 149 | # --------------------------------------------------------------------------- | |||||
| 150 | # _emit_subtest â write one subtest block | |||||
| 151 | # --------------------------------------------------------------------------- | |||||
| 152 | sub _emit_subtest { | |||||
| 153 | 1 | 1 | my ($fh, $ex, $package, $ctor) = @_; | |||
| 154 | ||||||
| 155 | 1 | 2 | my $label = $ex->{label}; | |||
| 156 | 1 | 1 | my $code = $ex->{code}; | |||
| 157 | 1 | 1 | my $expected = $ex->{expected}; | |||
| 158 | 1 | 1 | my $is_annotated = defined $ex->{annotated_line}; | |||
| 159 | ||||||
| 160 | # Escape label for Perl string | |||||
| 161 | 1 | 1 | (my $safe_label = $label) =~ s/'/\\'/g; | |||
| 162 | ||||||
| 163 | 1 | 1 | print $fh "subtest '$safe_label' => sub {\n"; | |||
| 164 | ||||||
| 165 | 1 | 1 | if($is_annotated) { | |||
| 166 | # Single annotated call: wrap in eval, capture return value, compare. | |||||
| 167 | # Strip 'my $var =' prefix so the eval captures the bare expression | |||||
| 168 | # value directly rather than shadowing with an inner variable. | |||||
| 169 | 0 | 0 | my $expr = $code; | |||
| 170 | 0 | 0 | $expr =~ s/^\s*my\s+\$\w+\s*=\s*//; | |||
| 171 | 0 | 0 | $expr =~ s/\s*;\s*$//; | |||
| 172 | ||||||
| 173 | 0 | 0 | my $n_tests = defined($expected) ? 2 : 1; | |||
| 174 | 0 | 0 | print $fh "\tplan tests => $n_tests;\n"; | |||
| 175 | ||||||
| 176 | 0 | 0 | if($ctor && $expr =~ /\$obj\s*->/) { | |||
| 177 | 0 | 0 | print $fh "\tmy \$obj = $ctor;\n"; | |||
| 178 | } | |||||
| 179 | ||||||
| 180 | 0 | 0 | print $fh "\tmy \$result = eval { $expr };\n"; | |||
| 181 | 0 | 0 | print $fh "\tok(!\$\@, 'example runs without error') or diag \$\@;\n"; | |||
| 182 | ||||||
| 183 | 0 | 0 | if(defined $expected) { | |||
| 184 | 0 | 0 | print $fh "\tis(\$result, $expected, 'returns expected value from POD annotation');\n"; | |||
| 185 | } | |||||
| 186 | } else { | |||||
| 187 | # Verbatim block: run whole block in eval, check no exception. | |||||
| 188 | # Stub any variables the snippet uses but doesn't declare so the | |||||
| 189 | # generated file compiles cleanly under "use strict". | |||||
| 190 | 1 | 1 | print $fh "\tplan tests => 1;\n"; | |||
| 191 | 1 | 2 | print $fh "\tmy \$ok = eval {\n"; | |||
| 192 | 1 | 2 | my @stubs = _stub_undeclared_vars($code); | |||
| 193 | 1 | 1 | if(@stubs) { | |||
| 194 | 0 | 0 | print $fh "\t\tmy (", join(', ', @stubs), ");\n"; | |||
| 195 | } | |||||
| 196 | 1 | 3 | for my $line (split /\n/, $code) { | |||
| 197 | 2 | 11 | if(my $neutralized = _neutralize_exec($line)) { | |||
| 198 | 1 | 1 | print $fh "\t\t$neutralized\n"; | |||
| 199 | } else { | |||||
| 200 | 1 | 1 | print $fh "\t\t$line\n"; | |||
| 201 | } | |||||
| 202 | } | |||||
| 203 | 1 | 1 | print $fh "\t\t1;\n"; | |||
| 204 | 1 | 1 | print $fh "\t};\n"; | |||
| 205 | 1 | 1 | print $fh "\tok(\$ok && !\$\@, 'example runs without error') or diag \$\@;\n"; | |||
| 206 | } | |||||
| 207 | ||||||
| 208 | 1 | 1 | print $fh "};\n\n"; | |||
| 209 | } | |||||
| 210 | ||||||
| 211 | # --------------------------------------------------------------------------- | |||||
| 212 | # _neutralize_exec â if a line from a verbatim SYNOPSIS block would invoke | |||||
| 213 | # a shell command (system, exec, backticks, qx), return a replacement | |||||
| 214 | # note() call that describes what would have run instead of executing it. | |||||
| 215 | # Returns the empty string (false) for safe lines so the caller can pass | |||||
| 216 | # them through unchanged. | |||||
| 217 | # | |||||
| 218 | # Lines that are already comments are left alone â they can't execute. | |||||
| 219 | # --------------------------------------------------------------------------- | |||||
| 220 | sub _neutralize_exec { | |||||
| 221 | 2 | 2 | my ($line) = @_; | |||
| 222 | 2 | 3 | return '' if $line =~ /^\s*#/; # pure comment â harmless | |||
| 223 | 2 | 26 | return '' unless $line =~ /\b(?:system|exec)\s*\(|`|\bqx\s*[{(\[\/|]/; | |||
| 224 | ||||||
| 225 | 1 | 1 | (my $msg = $line) =~ s/^\s+//; # strip leading whitespace for display | |||
| 226 | 1 | 1 | $msg =~ s/'/\\'/g; # escape single quotes for q{} | |||
| 227 | 1 | 2 | $msg =~ s/\s+$//; | |||
| 228 | ||||||
| 229 | 1 | 2 | return "note('pod-example-tester: skipped shell call: $msg');"; | |||
| 230 | } | |||||
| 231 | ||||||
| 232 | # --------------------------------------------------------------------------- | |||||
| 233 | # _stub_undeclared_vars â return sorted list of sigil+name variables (e.g. | |||||
| 234 | # '$dir', '@items') that appear in the code block but are not declared with | |||||
| 235 | # my/our/local. Used to inject stub declarations so generated eval blocks | |||||
| 236 | # compile cleanly under "use strict" even when SYNOPSIS snippets assume | |||||
| 237 | # surrounding context (e.g. a $dir variable set up by the caller). | |||||
| 238 | # | |||||
| 239 | # Well-known Perl builtins (@_, @INC, @ARGV, %ENV, %INC, %SIG, $_) are | |||||
| 240 | # excluded because they exist without explicit declaration. | |||||
| 241 | # --------------------------------------------------------------------------- | |||||
| 242 | { | |||||
| 243 | 1 7 0 | 1 59 0 | my %PERL_BUILTINS = map { $_ => 1 } qw( | |||
| 244 | $_ @_ @INC @ARGV %ENV %INC %SIG | |||||
| 245 | ); | |||||
| 246 | ||||||
| 247 | sub _stub_undeclared_vars { | |||||
| 248 | 1 | 1 | my ($code) = @_; | |||
| 249 | ||||||
| 250 | 1 | 1 | my %declared; | |||
| 251 | # Capture every variable following a my/our/local keyword, including | |||||
| 252 | # list forms: my ($a, $b) and foreach my $x(...) | |||||
| 253 | 1 | 4 | while($code =~ /\b(?:my|our|local)\b[^;{]*?([\$\@\%]\w+)/g) { | |||
| 254 | 1 | 3 | $declared{$1} = 1; | |||
| 255 | } | |||||
| 256 | ||||||
| 257 | 1 | 0 | my %used; | |||
| 258 | 1 | 3 | while($code =~ /([\$\@\%])([a-zA-Z_]\w*)/g) { | |||
| 259 | 1 | 2 | $used{"$1$2"} = 1; | |||
| 260 | } | |||||
| 261 | ||||||
| 262 | 1 1 | 1 4 | return sort grep { !$declared{$_} && !$PERL_BUILTINS{$_} } keys %used; | |||
| 263 | } | |||||
| 264 | } | |||||
| 265 | ||||||
| 266 | # --------------------------------------------------------------------------- | |||||
| 267 | # _detect_package â extract 'package Foo::Bar' from source | |||||
| 268 | # --------------------------------------------------------------------------- | |||||
| 269 | 1 | 1 | sub _detect_package { | |||
| 270 | 1 | 1 | my ($file) = @_; | |||
| 271 | 1 | 30 | open my $fh, '<', $file or return; | |||
| 272 | 1 | 19 | while(<$fh>) { | |||
| 273 | 1 | 12 | return $1 if /^package\s+([\w:]+)\s*[;{]/; | |||
| 274 | } | |||||
| 275 | 0 | 0 | return; | |||
| 276 | } | |||||
| 277 | ||||||
| 278 | # --------------------------------------------------------------------------- | |||||
| 279 | # _detect_constructor â return a string like 'Foo::Bar->new()' if the | |||||
| 280 | # package has a new() method, else undef | |||||
| 281 | # --------------------------------------------------------------------------- | |||||
| 282 | sub _detect_constructor { | |||||
| 283 | 1 | 1 | my ($package) = @_; | |||
| 284 | ||||||
| 285 | # Try loading and querying can() | |||||
| 286 | 1 1 1 | 1 1 139 | eval { (my $mod = $package) =~ s{::}{/}g; require "$mod.pm" }; | |||
| 287 | 1 | 2 | return unless !$@; | |||
| 288 | 0 | return "${package}->new()" if $package->can('new'); | ||||
| 289 | 0 | return; | ||||
| 290 | } | |||||
| 291 | ||||||