The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
#!/usr/bin/perl

=head1 NAME

find_mod_deps.pl - inspect B<only hard-coded> dependencies of sets of perl files

=head1 DESCRIPTION

Inspects the hard-coded dependencies of a set of perl files and prints
a summary of which modules they use (by default not including
inter-dependencies between the modules being inspected).

=head1 USAGE

find_mod_deps.pl [options]  [ path ... ]

If given any paths, inspects only the files in those paths.  Defaults
to inspecting all perl files in the current directory.

=head2 Options

=over 4

=item -i

If set, also print internal dependencies, i.e. the inter-dependencies
between the files we are inspecting.

=item -B

If set, print the dependencies in a format suitable for cutting and
pasting directly into a Build.PL (i.e. Module::Build)

=item -M

If set, print the dependencies in a format suitable for cutting and
pasting directly into a Makefile.PL (i.e. Module::Install)

=item -Z

If set, print the dependencies in a format suitable for cutting and
pasting directly into a dist.ini (i.e. Dist::Zilla).  Although, if
you're using Dist::Zilla, you probably have it configured to be
auto-discovering deps, and it will find the same deps as this script.

=back

=head1 AUTHOR

Robert Buels, rbuels@cpan.org

=cut

use strict;
use warnings;

use File::Find;
use Getopt::Std;
use IO::String;
use List::MoreUtils qw/ first_value all /;
use Module::CoreList;
use Pod::Strip;
use Pod::Usage;

use Data::Dump 'dump';
use Hash::Merge;

my %opt;
getopts('iBMZ', \%opt) or pod2usage();

-d './lib' or -d './bin' or -d './scripts' or die "run this script from the root dir of a distribution\n";

my @paths = @ARGV;

@paths = qw( t lib scripts bin cgi-bin Bio )
   unless @paths;

# expand any dirs into the perl files they contain
my @perl_files = map {
    if( -d ) {
        my @f;
        find( sub { push @f, $File::Find::name if is_perl_file($_) },
              $_,
            );
        @f
    } elsif( -e ) {
        if( is_perl_file($_) ) {
            $_
        } else {
            warn "WARNING: skipping user-specified file $_, since it is not a perl file.\n";
            ()
        }
    } else {
        ()
    }
} @paths;

my %perl_files = map { $_ => 1 } @perl_files;

my %deps;
my $merger = Hash::Merge->new('RETAINMENT_PRECEDENT');
for my $file ( @perl_files ) {
    my $deps = find_deps( $file );
    %deps = %{ $merger->merge( \%deps, $deps ) };
}

# classify the deps
my %classified;
for my $modname ( keys %deps ) {
    if( all { m|^(./)?t/| } @{$deps{$modname}} ) {
        $classified{build_requires}{$modname} = $deps{$modname};
    }
    else {
        $classified{requires}{$modname} = $deps{$modname};
    }
}

# decide which format to print in
if( $opt{B} ) {
    for ( values %classified ) {
        $_ = 0 for values %$_;
    }
    print dump \%classified;
} elsif( $opt{M} ) {
    print "requires      '$_' => 0;\n"
      for sort { lc $a cmp lc $b } keys %{$classified{requires}};
    print "test_requires '$_' => 0;\n"
      for sort { lc $a cmp lc $b } keys %{$classified{build_requires}};
} elsif( $opt{Z} ) {
    print "[Prereqs]\n";
    print "$_  =  0\n"
      for sort { lc $a cmp lc $b } keys %{$classified{requires}};

    print "\n[Prereqs / TestRequires]\n";
    print "$_  =  0\n"
      for sort { lc $a cmp lc $b } keys %{$classified{build_requires}};

} else {
    print dump \%classified;
}

exit;

################## helpers #####################

sub modfile {
    my $modname = shift;
    my $modfile = "$modname.pm";
    $modfile =~ s|::|/|g;
    return first_value {
        $_ =~ /$modfile$/;
    } @perl_files;
}

sub namespace_parent {
    my $modname = shift;
    $modname =~ s/(?:::)?[^:]+$//;
    return $modname;
}

sub find_deps {
    my ( $file ) = @_;

    my $nopod;
    { open my $p, '<', $file or die "Could not read file '$file': $!\n";
      local $/;
      my $code = <$p>;
      my $strip = Pod::Strip->new;
      $strip->output_string(\$nopod);
      $strip->parse_string_document( $code );
    }
    my $f = IO::String->new( \$nopod );

    my %deps;
    while( my $depline = <$f> ) {
        $depline =~ s/#.+//; #remove comments
        next unless $depline =~ /^\s*(use|require|extends|with)\s+.+;/;
        next unless $depline && $depline =~ /\S/;

        my @toks = $depline =~ /([\w:]{3,})/ig
            or die 'cannot parse: '.$depline;

        #warn "    adding to $k->{name}\n";
        shift @toks;
        if( @toks ) {
            if ( $toks[0] eq 'base' ) {
                shift @toks;
                shift @toks if $toks[0] eq 'qw';
            } else {
                @toks = ($toks[0]);
            }
        }

      MODNAME:
        foreach my $modname (@toks) {

            chomp $depline;
            #warn "'$depline' goes to $modname\n";

            #skip if the module is in the distribution
            my $modfile = modfile($modname);
            next if !$opt{i} && $modfile && -f $modfile;

            #skip if the module is in core before 5.6
            my $rl = Module::CoreList->first_release($modname);
            next if $rl && $rl <= 5.006;

            #skip if the module is actually defined in a parent file
            my $p = $modname;
            while( $p = namespace_parent($p) ) {
                my $p_modfile = modfile($p);
                #warn  "checking $p / $p_modfile\n";

                next unless $p_modfile && -f $p_modfile;

                open my $p, '<', $p_modfile or die "Could not read file '$p_modfile': $!\n";
                while( <$p> ) {
                    next MODNAME if /^\s*package\s+$p\b/;
                }
            }

            push @{$deps{$modname} ||= []}, $file;
        }
    }

    return \%deps;
}

sub is_perl_file {
    local $_ = shift;
    return -f && ( -x || /\.(pm|t|pl)$/ );
}