| File: | lib/App/Project/Doctor/Check/Pod.pm |
| Coverage: | 94.5% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package App::Project::Doctor::Check::Pod; | |||||
| 2 | ||||||
| 3 | # This check verifies that every .pm file under lib/ contains valid POD | |||||
| 4 | # documentation. It uses Pod::Checker to detect syntax errors. | |||||
| 5 | # Modules with no POD at all get a fixable finding that writes a skeleton. | |||||
| 6 | ||||||
| 7 | 3 3 3 | 2574 3 38 | use strict; | |||
| 8 | 3 3 3 | 5 2 115 | use warnings; | |||
| 9 | 3 3 3 | 12 36 9 | use autodie qw(:all); | |||
| 10 | ||||||
| 11 | # Inherit the standard check interface from Check::Base. | |||||
| 12 | 3 3 3 | 6562 4 11 | use parent -norequire, 'App::Project::Doctor::Check::Base'; | |||
| 13 | ||||||
| 14 | # croak dies with the caller's location; carp warns there. | |||||
| 15 | 3 3 3 | 107 2 1592 | use Carp qw(croak carp); | |||
| 16 | ||||||
| 17 | our $VERSION = '0.02'; | |||||
| 18 | ||||||
| 19 | # Short name used in Finding.check_name and text-report columns. | |||||
| 20 | 2 | 198 | sub name { 'POD' } | |||
| 21 | # One-line description for --help and verbose output. | |||||
| 22 | 1 | 3 | sub description { 'Every .pm file contains valid, parseable POD documentation.' } | |||
| 23 | # This check can offer a fix (append a POD skeleton). | |||||
| 24 | 2 | 4 | sub can_fix { 1 } | |||
| 25 | # Run after Dependencies (50) but before Security (60). | |||||
| 26 | 2 | 3 | sub order { 40 } | |||
| 27 | ||||||
| 28 | sub check { | |||||
| 29 | 10 | 14 | my ($self, $ctx) = @_; | |||
| 30 | # Guard: require a proper Context object. | |||||
| 31 | 10 | 17 | croak 'check requires an App::Project::Doctor::Context' unless ref $ctx; | |||
| 32 | ||||||
| 33 | 10 | 9 | my @findings; | |||
| 34 | # lib_modules() returns an arrayref of .pm paths relative to the distro root. | |||||
| 35 | 10 | 21 | my $modules = $ctx->lib_modules; | |||
| 36 | ||||||
| 37 | # If there are no .pm files at all there is nothing to check. | |||||
| 38 | 10 10 | 10 25 | unless (@{$modules}) { | |||
| 39 | 3 | 7 | return _f( | |||
| 40 | severity => 'info', | |||||
| 41 | message => 'No .pm files under lib/ -- nothing to check.', | |||||
| 42 | ); | |||||
| 43 | } | |||||
| 44 | ||||||
| 45 | 7 7 | 9 10 | for my $mod (@{$modules}) { | |||
| 46 | # Try to read the module source; skip it with a carp if reading fails. | |||||
| 47 | 8 8 1 1 | 14 15 18 174 | my $content = eval { $ctx->slurp($mod) } // do { carp "Cannot slurp $mod: $@"; next }; | |||
| 48 | ||||||
| 49 | # Quick check: does the file contain any POD at all? | |||||
| 50 | # A line beginning with '=' followed by a word character starts a POD block. | |||||
| 51 | 7 | 68 | unless ($content =~ /^=\w/m) { | |||
| 52 | # No POD found -- offer to append a skeleton. | |||||
| 53 | 3 | 7 | push @findings, _f( | |||
| 54 | severity => 'error', | |||||
| 55 | message => "No POD found in $mod.", | |||||
| 56 | file => $mod, | |||||
| 57 | fix => _fix_scaffold_pod($ctx, $mod), | |||||
| 58 | ); | |||||
| 59 | # No point running Pod::Checker on a file with no POD at all. | |||||
| 60 | 3 | 5 | next; | |||
| 61 | } | |||||
| 62 | ||||||
| 63 | # Validate the existing POD with Pod::Checker and collect any errors. | |||||
| 64 | 4 | 14 | for my $err (_check_pod($ctx->abs_path($mod))) { | |||
| 65 | push @findings, _f( | |||||
| 66 | severity => 'error', | |||||
| 67 | message => "POD error in $mod: $err->{message}", | |||||
| 68 | file => $mod, | |||||
| 69 | # Only include a line number when Pod::Checker provided one. | |||||
| 70 | 1 | 3 | defined $err->{line} ? (line => $err->{line}) : (), | |||
| 71 | ); | |||||
| 72 | } | |||||
| 73 | } | |||||
| 74 | ||||||
| 75 | # If we collected no error findings, all modules have valid POD. | |||||
| 76 | 7 | 39 | unless (@findings) { | |||
| 77 | push @findings, _f( | |||||
| 78 | severity => 'pass', | |||||
| 79 | 3 3 | 3 11 | message => sprintf('%d module(s) checked -- all have valid POD.', scalar @{$modules}), | |||
| 80 | ); | |||||
| 81 | } | |||||
| 82 | ||||||
| 83 | 7 | 21 | return @findings; | |||
| 84 | } | |||||
| 85 | ||||||
| 86 | # --------------------------------------------------------------------------- | |||||
| 87 | # Private helpers | |||||
| 88 | # --------------------------------------------------------------------------- | |||||
| 89 | ||||||
| 90 | # Purpose: Create a Finding with check_name pre-filled to 'POD'. | |||||
| 91 | # Entry: %args is a valid Finding constructor argument list. | |||||
| 92 | # Exit: App::Project::Doctor::Finding object. | |||||
| 93 | # Side effects: None. | |||||
| 94 | sub _f { | |||||
| 95 | 10 | 24 | require App::Project::Doctor::Finding; | |||
| 96 | 10 | 40 | return App::Project::Doctor::Finding->new(check_name => 'POD', @_); | |||
| 97 | } | |||||
| 98 | ||||||
| 99 | # Purpose: Run Pod::Checker on a single file and return a list of errors. | |||||
| 100 | # Entry: $abs_path is the absolute path to the .pm file to check. | |||||
| 101 | # Exit: List of hashrefs with 'message' string and optional 'line' int. | |||||
| 102 | # Side effects: Loads Pod::Checker if not already in memory; writes to an in-memory | |||||
| 103 | # filehandle (no disk I/O). | |||||
| 104 | sub _check_pod { | |||||
| 105 | 5 | 2363 | my $abs_path = shift; | |||
| 106 | 5 | 578 | require Pod::Checker; | |||
| 107 | ||||||
| 108 | # Capture Pod::Checker's diagnostic output into a scalar instead of STDERR. | |||||
| 109 | 5 | 33655 | my $captured = ''; | |||
| 110 | 5 | 15 | open my $out_fh, '>', \$captured; | |||
| 111 | 5 | 1809 | my $checker = Pod::Checker->new; | |||
| 112 | 5 | 317 | $checker->parse_from_file($abs_path, $out_fh); | |||
| 113 | 5 | 2766 | close $out_fh; | |||
| 114 | ||||||
| 115 | # If Pod::Checker reported no errors, return an empty list immediately. | |||||
| 116 | 5 | 949 | return () if ($checker->num_errors // 0) == 0; | |||
| 117 | ||||||
| 118 | # Parse the captured text output to extract individual error messages. | |||||
| 119 | 1 | 3 | my @errors; | |||
| 120 | 1 | 3 | for my $line (split /\n/, $captured) { | |||
| 121 | # Skip blank lines in the checker output. | |||||
| 122 | 1 | 1 | next unless $line =~ /\S/; | |||
| 123 | # Try to extract the line number from the Pod::Checker message. | |||||
| 124 | 1 | 3 | my ($lineno) = $line =~ /line\s+(\d+)/i; | |||
| 125 | 1 | 4 | push @errors, { | |||
| 126 | message => $line, | |||||
| 127 | # Only include 'line' in the hashref when we actually found one. | |||||
| 128 | defined $lineno ? (line => $lineno) : (), | |||||
| 129 | }; | |||||
| 130 | } | |||||
| 131 | 1 | 6 | return @errors; | |||
| 132 | } | |||||
| 133 | ||||||
| 134 | # Purpose: Return a coderef that rewrites a module file with a POD skeleton. | |||||
| 135 | # Entry: $ctx is the Context; $rel_path is the module path relative to root. | |||||
| 136 | # Exit: Coderef ($ctx) -> void; rewrites the module file with POD appended. | |||||
| 137 | # Side effects: Modifies the module file on disk when the coderef is called. | |||||
| 138 | sub _fix_scaffold_pod { | |||||
| 139 | 4 | 8 | my ($ctx, $rel_path) = @_; | |||
| 140 | # Return the fix as a closure so it runs only when the user accepts it. | |||||
| 141 | return sub { | |||||
| 142 | # Protect caller's $@ from autodie's internal eval inside open(). | |||||
| 143 | 3 | 5 | local $@; | |||
| 144 | 3 | 7 | my $abs = $ctx->abs_path($rel_path); | |||
| 145 | # Convert the relative file path to a Perl package name. | |||||
| 146 | # e.g. lib/My/Module.pm -> My::Module | |||||
| 147 | 3 3 3 | 7 5 10 | (my $pkg = $rel_path) =~ s{^lib/}{}; $pkg =~ s{/}{::}g; $pkg =~ s{\.pm$}{}; | |||
| 148 | ||||||
| 149 | # Read the existing file so we can rewrite it (not just append). | |||||
| 150 | 3 | 7 | open my $rfh, '<', $abs; | |||
| 151 | 3 3 3 | 216 5 27 | my $content = do { local $/; <$rfh> }; | |||
| 152 | 3 | 5 | close $rfh; | |||
| 153 | ||||||
| 154 | # Remove any trailing `1;` so the rewritten file has exactly one. | |||||
| 155 | # Without this, the original `1;` and the skeleton's `1;` would both appear. | |||||
| 156 | 3 | 74 | $content =~ s/\s*\n?1;\s*\z//s; | |||
| 157 | ||||||
| 158 | # Write the existing content back followed by the POD skeleton. | |||||
| 159 | 3 | 4 | open my $wfh, '>', $abs; | |||
| 160 | 3 3 | 195 13 | print {$wfh} $content, <<"END_POD"; | |||
| 161 | ||||||
| 162 | 1; | |||||
| 163 | ||||||