File Coverage

blib/lib/ExtUtils/XSOne.pm
Criterion Covered Total %
statement 199 201 99.0
branch 70 82 85.3
condition 23 34 67.6
subroutine 22 22 100.0
pod 2 2 100.0
total 316 341 92.6


line stmt bran cond sub pod time code
1             package ExtUtils::XSOne;
2              
3 18     18   2922742 use 5.008003;
  18         72  
4 18     18   94 use strict;
  18         29  
  18         490  
5 18     18   91 use warnings;
  18         48  
  18         1099  
6              
7 18     18   96 use File::Spec;
  18         36  
  18         783  
8 18     18   90 use File::Basename qw(dirname basename);
  18         27  
  18         2057  
9 18     18   109 use File::Path qw(make_path);
  18         39  
  18         1285  
10 18     18   99 use File::Find qw(find);
  18         29  
  18         1155  
11 18     18   98 use Carp qw(croak);
  18         35  
  18         60002  
12              
13             our $VERSION = '0.03';
14              
15             sub combine {
16 37     37 1 11363955 my ($class, %opts) = @_;
17              
18 37 100       388 my $src_dir = $opts{src_dir} or croak "src_dir is required";
19 36 100       256 my $output = $opts{output} or croak "output is required";
20 35         84 my $order = $opts{order};
21 35   100     209 my $verbose = $opts{verbose} || 0;
22 35 100       117 my $dedup = exists $opts{deduplicate} ? $opts{deduplicate} : 1;
23 35   100     171 my $recursive = $opts{recursive} || 0;
24              
25 35         73 my @sorted;
26 35 100       103 if ($recursive) {
27 1         3 @sorted = $class->_find_xs_files_recursive($src_dir);
28             } else {
29 34         127 my @xs_files = $class->_find_xs_files($src_dir);
30 31         222 @sorted = $class->_sort_files(\@xs_files, $order);
31             }
32              
33 32 100       118 if ($verbose) {
34 2         66 warn "ExtUtils::XSOne: Processing files in order:\n";
35 2         36 warn " $_\n" for @sorted;
36             }
37              
38             # Read all files and extract/deduplicate C preamble
39 32         239 my @file_contents;
40             my %seen_includes;
41 32         0 my %seen_defines;
42 32         0 my @collected_includes;
43 32         0 my @collected_defines;
44 32         0 my @collected_c_code;
45 32         0 my @header_c_code; # C code from _header.xs files (processed first)
46              
47 32         82 for my $file (@sorted) {
48             # In recursive mode, $file is already a relative path from src_dir
49             # In non-recursive mode, $file is just the filename
50 100         1419 my $path = File::Spec->catfile($src_dir, $file);
51 100 100       3007 my $display_file = $recursive ? $file : basename($file);
52 100 100       440 warn "ExtUtils::XSOne: Reading $path\n" if $verbose;
53              
54 100         308 my $content = $class->_read_file($path);
55 100         2635 my $filename = basename($file);
56 100         243 my $is_header = ($filename eq '_header.xs');
57              
58 100 100       235 if ($dedup) {
59             # Parse and extract C preamble (before MODULE =)
60 98         524 my ($preamble, $xs_part) = $class->_split_preamble($content);
61              
62             # For _header.xs files without MODULE, the entire content is in xs_part
63             # We need to treat it as preamble C code
64 98         250 my $c_content = $preamble;
65 98 50 66     455 if ($is_header && $preamble eq '' && $xs_part =~ /\S/) {
      66        
66 17         45 $c_content = $xs_part;
67 17         59 $xs_part = '';
68             }
69              
70             # Extract includes, defines, and other C code from preamble
71 98         290 my ($includes, $defines, $other_c) = $class->_parse_preamble($c_content);
72              
73             # Deduplicate includes
74 98         235 for my $inc (@$includes) {
75 114         238 my $normalized = $class->_normalize_include($inc);
76 114 100       407 unless ($seen_includes{$normalized}++) {
77 88         266 push @collected_includes, $inc;
78             }
79             }
80              
81             # Deduplicate defines (by macro name)
82 98         215 for my $def (@$defines) {
83 31         90 my $name = $class->_extract_define_name($def);
84 31 100       123 unless ($seen_defines{$name}++) {
85 25         71 push @collected_defines, $def;
86             }
87             }
88              
89             # Collect other C code (functions, structs, etc.)
90             # _header.xs code goes first to ensure definitions are available
91 98 100       325 if ($other_c =~ /\S/) {
92 57 100       142 if ($is_header) {
93 17         109 push @header_c_code, { file => $file, path => $path, code => $other_c };
94             } else {
95 40         223 push @collected_c_code, { file => $file, path => $path, code => $other_c };
96             }
97             }
98              
99             # Store just the XS part (empty for pure _header.xs files)
100 98 100       728 push @file_contents, { file => $file, path => $path, content => $xs_part }
101             if $xs_part =~ /\S/;
102             } else {
103 2         13 push @file_contents, { file => $file, path => $path, content => $content };
104             }
105             }
106              
107             # Merge header C code first, then other C code
108 32         88 @collected_c_code = (@header_c_code, @collected_c_code);
109              
110             # Build combined content
111 32         115 my $combined = $class->_build_header($src_dir, \@sorted);
112              
113 32 100 100     284 if ($dedup && (@collected_includes || @collected_defines || @collected_c_code)) {
      100        
114             # Add deduplicated preamble
115 26         54 $combined .= "/* ========== COMBINED C PREAMBLE ========== */\n\n";
116              
117             # Includes first
118 26 100       83 if (@collected_includes) {
119 21         88 $combined .= join("\n", @collected_includes) . "\n\n";
120             }
121              
122             # Then defines
123 26 100       101 if (@collected_defines) {
124 17         50 $combined .= join("\n", @collected_defines) . "\n\n";
125             }
126              
127             # Then other C code with source markers
128 26         58 for my $c_block (@collected_c_code) {
129 57         173 $combined .= "/* C code from: $c_block->{file} */\n";
130 57         147 $combined .= "#line 1 \"$c_block->{path}\"\n";
131 57         139 $combined .= $c_block->{code} . "\n";
132             }
133              
134 26         52 $combined .= "/* ========== END COMBINED C PREAMBLE ========== */\n";
135             }
136              
137             # Add XS parts
138 32         98 for my $fc (@file_contents) {
139 83         267 $combined .= $class->_wrap_file($fc->{file}, $fc->{path}, $fc->{content});
140             }
141              
142             # Write output
143 32         123 $class->_write_file($output, $combined, $verbose);
144              
145 32 100       187 warn "ExtUtils::XSOne: Generated $output from " . scalar(@sorted) . " files\n"
146             if $verbose;
147              
148 32         418 return scalar(@sorted);
149             }
150              
151             sub files_in_order {
152 6     6 1 32504 my ($class, $src_dir, $order) = @_;
153              
154 6         23 my @xs_files = $class->_find_xs_files($src_dir);
155 6         47 return $class->_sort_files(\@xs_files, $order);
156             }
157              
158             #
159             # Internal methods
160             #
161              
162             sub _find_xs_files {
163 40     40   109 my ($class, $src_dir) = @_;
164              
165 40 100       1335 croak "Source directory '$src_dir' does not exist" unless -d $src_dir;
166              
167 38 50       1878 opendir(my $dh, $src_dir) or croak "Cannot open $src_dir: $!";
168 38         1235 my @xs_files = grep { /\.xs$/ } readdir($dh);
  195         756  
169 38         464 closedir($dh);
170              
171 38 100       354 croak "No .xs files found in $src_dir" unless @xs_files;
172              
173 37         239 return @xs_files;
174             }
175              
176             sub _find_xs_files_recursive {
177 2     2   6223 my ($class, $src_dir) = @_;
178              
179 2 50       62 croak "Source directory '$src_dir' does not exist" unless -d $src_dir;
180              
181 2         7 my @headers; # { path => relative_path, depth => N }
182             my @footers; # { path => relative_path, depth => N }
183 2         0 my @packages; # relative paths to package XS files
184              
185             # Normalize src_dir for consistent path handling
186 2         12 $src_dir = File::Spec->canonpath($src_dir);
187 2         5 my $src_dir_len = length($src_dir);
188              
189             find({
190             wanted => sub {
191 20 100 66 20   1221 return unless -f && /\.xs$/;
192              
193 14         73 my $full_path = $File::Find::name;
194 14         28 my $dir = $File::Find::dir;
195              
196             # Get path relative to src_dir
197 14         63 my $rel_path = substr($full_path, $src_dir_len);
198 14         59 $rel_path =~ s{^[/\\]}{}; # Remove leading separator
199              
200             # Calculate depth (number of directory separators)
201 14         35 my $depth = ($rel_path =~ tr!/\\!!);
202              
203 14         680 my $filename = basename($full_path);
204              
205 14 100       51 if ($filename eq '_header.xs') {
    100          
206 4         190 push @headers, { path => $rel_path, depth => $depth };
207             } elsif ($filename eq '_footer.xs') {
208 4         81 push @footers, { path => $rel_path, depth => $depth };
209             } else {
210 6         134 push @packages, $rel_path;
211             }
212             },
213 2         211 no_chdir => 1,
214             }, $src_dir);
215              
216 2 0 33     21 croak "No .xs files found in $src_dir" unless @headers || @footers || @packages;
      33        
217              
218             # Sort headers by depth (shallow first), then alphabetically
219 4         14 @headers = map { $_->{path} }
220 2 50       28 sort { $a->{depth} <=> $b->{depth} || $a->{path} cmp $b->{path} }
  2         14  
221             @headers;
222              
223             # Sort footers by depth (deep first - reverse of headers), then alphabetically
224 4         9 @footers = map { $_->{path} }
225 2 50       5 sort { $b->{depth} <=> $a->{depth} || $a->{path} cmp $b->{path} }
  2         8  
226             @footers;
227              
228             # Sort package files alphabetically
229 2         14 @packages = sort @packages;
230              
231 2         12 return (@headers, @packages, @footers);
232             }
233              
234             sub _sort_files {
235 37     37   150 my ($class, $files, $order) = @_;
236              
237 37 100 66     143 if ($order && @$order) {
238             # Use explicit order
239 5         13 my %available = map { $_ => 1 } @$files;
  23         70  
240 5         13 my @sorted;
241              
242 5         40 for my $name (@$order) {
243 19         38 my $file = "$name.xs";
244 19 50       50 if ($available{$file}) {
245 19         43 push @sorted, $file;
246 19         74 delete $available{$file};
247             }
248             }
249              
250             # Append any remaining files
251 5         19 push @sorted, sort keys %available;
252 5         36 return @sorted;
253             }
254              
255             # Default ordering: _header first, _footer last, others alphabetically
256 32         75 my @header = grep { /^_header\.xs$/ } @$files;
  96         274  
257 32         69 my @footer = grep { /^_footer\.xs$/ } @$files;
  96         218  
258 32         84 my @middle = sort grep { !/^_/ } @$files;
  96         267  
259 32 100       82 my @other_underscore = sort grep { /^_/ && !/^_(header|footer)\.xs$/ } @$files;
  96         425  
260              
261 32         162 return (@header, @middle, @other_underscore, @footer);
262             }
263              
264             sub _split_preamble {
265 98     98   260 my ($class, $content) = @_;
266              
267             # Find the first MODULE = declaration
268 98 100       1351 if ($content =~ /^(.*?)(^MODULE\s*=\s*.+)$/ms) {
269 71         499 return ($1, $2);
270             }
271              
272             # No MODULE found - entire content is C preamble (e.g., _header.xs)
273 27         102 return ('', $content);
274             }
275              
276             sub _parse_preamble {
277 98     98   194 my ($class, $preamble) = @_;
278              
279 98         234 my @includes;
280             my @defines;
281 98         0 my @other_lines;
282              
283 98         579 for my $line (split /\n/, $preamble) {
284 1055 100       2470 if ($line =~ /^\s*#\s*include\s/) {
    100          
285 114         272 push @includes, $line;
286             } elsif ($line =~ /^\s*#\s*define\s/) {
287 31         79 push @defines, $line;
288             } else {
289 910         1733 push @other_lines, $line;
290             }
291             }
292              
293 98         561 my $other_c = join("\n", @other_lines);
294              
295             # Remove leading/trailing whitespace
296 98         252 $other_c =~ s/^\s+//;
297 98         865 $other_c =~ s/\s+$//;
298              
299 98         414 return (\@includes, \@defines, $other_c);
300             }
301              
302             sub _normalize_include {
303 114     114   192 my ($class, $include) = @_;
304              
305             # Extract the actual include path/name
306 114 50       462 if ($include =~ /#\s*include\s*[<"]([^>"]+)[>"]/) {
307 114         338 return $1;
308             }
309 0         0 return $include;
310             }
311              
312             sub _extract_define_name {
313 31     31   63 my ($class, $define) = @_;
314              
315 31 50       155 if ($define =~ /#\s*define\s+(\w+)/) {
316 31         82 return $1;
317             }
318 0         0 return $define;
319             }
320              
321             sub _build_header {
322 32     32   80 my ($class, $src_dir, $files) = @_;
323              
324 32         87 my $header = <<"HEADER";
325             /*
326             * THIS FILE IS AUTO-GENERATED BY ExtUtils::XSOne
327             * DO NOT EDIT DIRECTLY - edit files in $src_dir/ instead
328             *
329             * Generated from:
330             HEADER
331              
332 32         138 $header .= " * $_\n" for @$files;
333 32         62 $header .= " */\n\n";
334              
335 32         102 return $header;
336             }
337              
338             # Note: xsubpp cannot handle comments or #line directives between MODULE
339             # declarations - they cause parsing errors. We only add markers for pure
340             # C code sections (like _header.xs without MODULE), not for XS sections.
341             sub _wrap_file {
342 83     83   196 my ($class, $file, $path, $content) = @_;
343              
344 83         131 my $wrapped = "\n";
345              
346             # Check if content starts with MODULE declaration (XS code)
347 83 100       334 if ($content =~ /^\s*MODULE\s*=/m) {
348             # For XS content, don't add any comments or #line directives
349             # xsubpp can't handle them between MODULE sections
350 73         181 $wrapped .= $content;
351             } else {
352             # For non-MODULE content (like _header.xs or pure C preamble),
353             # add a marker comment and #line directive for debugging
354 10         19 $wrapped .= "/* ========== BEGIN: $file ========== */\n";
355 10         18 $wrapped .= "#line 1 \"$path\"\n";
356 10         91 $wrapped .= $content;
357             }
358              
359 83         123 $wrapped .= "\n";
360              
361 83         458 return $wrapped;
362             }
363              
364             sub _read_file {
365 100     100   227 my ($class, $path) = @_;
366              
367 100 50       4613 open(my $fh, '<', $path) or croak "Cannot read $path: $!";
368 100         524 local $/;
369 100         2827 my $content = <$fh>;
370 100         1035 close($fh);
371              
372 100         669 return $content;
373             }
374              
375             sub _write_file {
376 32     32   155 my ($class, $path, $content, $verbose) = @_;
377              
378             # Create directory if needed
379 32         1340 my $dir = dirname($path);
380 32 100 33     989 if ($dir && $dir ne '.' && !-d $dir) {
      66        
381 1         464 make_path($dir); # throws on failure
382             }
383              
384 32 100       135 warn "ExtUtils::XSOne: Writing $path\n" if $verbose;
385              
386 32 50       6239 open(my $fh, '>', $path) or croak "Cannot write $path: $!";
387 32         598 print $fh $content;
388 32         2203 close($fh);
389             }
390              
391             1;
392              
393             __END__