File Coverage

blib/lib/App/makefilepl2cpanfile.pm
Criterion Covered Total %
statement 68 72 94.4
branch 21 30 70.0
condition 16 28 57.1
subroutine 8 8 100.0
pod 1 1 100.0
total 114 139 82.0


line stmt bran cond sub pod time code
1             package App::makefilepl2cpanfile;
2              
3 4     4   625472 use strict;
  4         8  
  4         131  
4 4     4   16 use warnings;
  4         5  
  4         240  
5 4     4   1983 use autodie qw(:all);
  4         52617  
  4         14  
6              
7 4     4   65943 use Path::Tiny;
  4         47956  
  4         272  
8 4     4   2418 use YAML::Tiny;
  4         24946  
  4         286  
9 4     4   2059 use File::HomeDir;
  4         25160  
  4         5423  
10              
11             =head1 NAME
12              
13             App::makefilepl2cpanfile - Convert Makefile.PL to a cpanfile automatically
14              
15             =head1 SYNOPSIS
16              
17             use App::makefilepl2cpanfile;
18              
19             # Generate a cpanfile string
20             my $cpanfile_text = App::makefilepl2cpanfile::generate(
21             makefile => 'Makefile.PL',
22             existing => '', # optional, existing cpanfile content
23             with_develop => 1, # include developer dependencies
24             );
25              
26             # Write to disk
27             open my $fh, '>', 'cpanfile' or die $!;
28             print $fh $cpanfile_text;
29             close $fh;
30              
31             =head1 VERSION
32              
33             Version 0.02
34              
35             =cut
36              
37             our $VERSION = '0.02';
38              
39             =head1 DESCRIPTION
40              
41             This module parses a `Makefile.PL` and produces a `cpanfile` with:
42              
43             =over 4
44              
45             =item * Runtime dependencies (`PREREQ_PM`)
46              
47             =item * Build, test, and configure requirements (`BUILD_REQUIRES`, `TEST_REQUIRES`, `CONFIGURE_REQUIRES`)
48              
49             =item * Optional author/development dependencies in a `develop` block
50              
51             =back
52              
53             The parsing is done **safely**, without evaluating the Makefile.PL.
54              
55             =head1 CONFIGURATION
56              
57             You may create a YAML file in:
58              
59             ~/.config/makefilepl2cpanfile.yml
60              
61             with a structure like:
62              
63             develop:
64             Perl::Critic: 0
65             Devel::Cover: 0
66             Test::Pod: 0
67             Test::Pod::Coverage: 0
68              
69             This will override the default development tools.
70              
71             =head1 METHODS
72              
73             =head2 generate(%args)
74              
75             Generates a cpanfile string.
76              
77             Arguments:
78              
79             =over 4
80              
81             =item * makefile
82              
83             Path to `Makefile.PL`. Defaults to `'Makefile.PL'`.
84              
85             =item * existing
86              
87             Optional string containing an existing cpanfile. Existing `develop` blocks are merged.
88              
89             =item * with_develop
90              
91             Boolean. Include default or configured author tools. Defaults to true if not overridden.
92              
93             =back
94              
95             Returns the cpanfile as a string.
96              
97             =cut
98              
99             # ----------------------------
100             # Main generate sub
101             # ----------------------------
102             sub generate {
103 4     4 1 677213 my (%args) = @_;
104              
105 4   50     35 my $makefile = $args{makefile} || 'Makefile.PL';
106 4   100     29 my $existing = $args{existing} || '';
107 4 100       15 my $with_dev = exists $args{with_develop} ? $args{with_develop} : 1;
108              
109 4         8 my %deps;
110             my $min_perl;
111              
112 4 50       91 die "Cannot read '$makefile': $!" unless -r $makefile;
113              
114 4         29 my $content = path($makefile)->slurp_utf8;
115              
116             # MIN_PERL_VERSION
117 4 100       5470 if ($content =~ /MIN_PERL_VERSION\s*=>\s*['"]?([\d._]+)['"]?/) {
118 3         12 $min_perl = $1;
119             }
120              
121 4         33 my %map = (
122             PREREQ_PM => 'runtime',
123             BUILD_REQUIRES => 'build',
124             TEST_REQUIRES => 'test',
125             CONFIGURE_REQUIRES => 'configure',
126             );
127              
128             # Robust dependency hash extraction
129 4         16 for my $mf_key (keys %map) {
130 16         35 my $phase = $map{$mf_key};
131              
132 16         1158 while ($content =~ /
133             $mf_key \s*=>\s* \{
134             ( (?: [^{}] | \{[^}]*\} )*? )
135             \}
136             /gsx) {
137 8         33 my $block = $1;
138 8         27 $block =~ s/#[^\n]*//g; # strip comments
139              
140 8         58 while ($block =~ /
141             ['"]([^'"]+)['"]
142             \s*=>\s*
143             ['"]?([\d._]+)?['"]?
144             /gx) {
145 24   100     167 $deps{$phase}{$1} = $2 // 0;
146             }
147             }
148             }
149              
150             # Preserve existing develop block
151 4 100       20 if ($existing =~ /on\s+["']develop["']\s*=>\s*sub\s*\{(.*?)\};/s) {
152 1         7 while ($1 =~ /requires\s+['"]([^'"]+)['"](?:\s*,\s*['"]([^'"]+)['"])?/g) {
153 1   50     10 $deps{develop}{$1} //= $2 // 0;
      33        
154             }
155             }
156              
157             # Post-processing: develop block
158 4 100       13 if ($with_dev) {
159 3   100     16 $deps{develop} ||= {};
160              
161 3         19 my %default = (
162             'Perl::Critic' => 0,
163             'Devel::Cover' => 0,
164             'Test::Pod' => 0,
165             'Test::Pod::Coverage' => 0,
166             );
167              
168 3         26 my $cfg_file = File::HomeDir->my_home . '/.config/makefilepl2cpanfile.yml';
169 3 50       197 if (-r $cfg_file) {
170 0 0       0 my $yaml = YAML::Tiny->read($cfg_file) or die "Failed to parse $cfg_file: ", YAML::Tiny->errstr();
171 0         0 my $y = $yaml->[0];
172 0 0       0 %default = %{ $y->{develop} } if $y->{develop};
  0         0  
173             }
174              
175 3         10 for my $mod (keys %default) {
176 12   33     40 $deps{develop}{$mod} //= $default{$mod};
177             }
178             }
179              
180 4         25 return _emit(\%deps, $min_perl);
181             }
182              
183             # _emit - Render collected dependency data as a cpanfile-format string
184             #
185             # Converts a structured hash of phase-keyed dependencies and an optional
186             # minimum Perl version into a valid cpanfile string ready to be written
187             # to disk.
188             #
189             # Entry:
190             # $_[0] - hashref of dependency data, keyed by phase name:
191             # 'runtime', 'configure', 'build', 'test', 'develop'
192             # Each value is a hashref of Module::Name => version_string,
193             # where version_string may be 0 or '' to indicate no minimum.
194             # $_[1] - optional scalar containing the minimum Perl version string
195             # (e.g. '5.010'), or undef if none was declared.
196             #
197             # Exit:
198             # Returns a scalar string containing the complete cpanfile content,
199             # always terminated with a single newline. Never returns undef.
200             #
201             # Side effects:
202             # None.
203             #
204             # Notes:
205             # - Dependencies with a version of 0 or '' are emitted without a version
206             # constraint, as cpanfile treats an absent version as "any version".
207             # - The 'runtime' block is emitted without an enclosing 'on' block, per
208             # cpanfile convention.
209             # - Phase blocks (configure, build, test, develop) are separated by a
210             # blank line; no trailing blank line is emitted after the final block.
211             # - Modules within each phase are sorted alphabetically for reproducibility.
212             sub _emit {
213 4     4   13 my ($deps, $min_perl) = @_;
214              
215 4         9 my $out = "# Generated from Makefile.PL using makefilepl2cpanfile\n\n";
216 4 100       25 $out .= "requires 'perl', '$min_perl';\n\n" if $min_perl;
217              
218 4 50       15 if (my $rt = $deps->{runtime}) {
219 4         38 for my $m (sort keys %$rt) {
220 9         32 $out .= "requires '$m'";
221 9 100 33     58 $out .= ", '$rt->{$m}'" if defined $rt->{$m} && $rt->{$m} ne '' && $rt->{$m} != 0;
      66        
222 9         16 $out .= ";\n";
223             }
224 4         8 $out .= "\n";
225             }
226              
227 4         13 my @blocks;
228              
229 4         9 for my $phase (qw(configure build test develop)) {
230 16 100       52 my $h = $deps->{$phase} or next;
231 7 50       19 next unless %$h;
232              
233 7         13 my $block = "on '$phase' => sub {\n";
234 7         39 for my $m (sort keys %$h) {
235 28         57 $block .= " requires '$m'";
236 28 100 33     110 $block .= ", '$h->{$m}'" if defined $h->{$m} && $h->{$m} ne '' && $h->{$m} != 0;
      66        
237 28         33 $block .= ";\n";
238             }
239 7         12 $block .= "};";
240 7         15 push @blocks, $block;
241             }
242              
243 4 50       21 $out .= join("\n\n", @blocks) . "\n" if @blocks;
244              
245 4         30 return $out;
246             }
247              
248             1;
249              
250             __END__