#!perl

use strict;
use warnings;
use v5.14;

use App::perlvars             ();
use Getopt::Long::Descriptive qw( describe_options );

my ( $opt, $usage ) = describe_options(
    'perlvars %o file',
    [ 'ignore-file|i=s', 'A file containing an ignore list', ],
    [
        'scripts',
        'Also lint package-less files (e.g. .t and .pl scripts) by wrapping them in a synthetic package',
    ],
    [],
    [ 'help', 'print usage message and exit', { shortcircuit => 1 } ],
    [
        'verbose-help', 'print verbose usage message and exit',
        { shortcircuit => 1 }
    ],
    [ 'version', 'print version and exit', { shortcircuit => 1 } ],
);

if ( $opt->help ) {
    say( $usage->text );
    exit;
}

if ( $opt->version ) {
    say "perlvars $App::perlvars::VERSION";
    exit;
}

my $exit_code = 0;

my @files = @ARGV;

unless (@files) {
    require Pod::Usage;
    say $usage->text;
    Pod::Usage::pod2usage();
}

my $vars = App::perlvars->new(
    ( $opt->ignore_file ? ( ignore_file  => $opt->ignore_file ) : () ),
    ( $opt->scripts     ? ( lint_scripts => 1 )                 : () ),
);

for my $file (@files) {
    say $file;
    my ( $code, $error_message, @notes ) = $vars->validate_file($file);

    if ($error_message) {
        say STDERR $error_message;

        # A per-file problem (not found, unparseable, no package) must not
        # abort the run: record any failure and carry on to the next file.
        $exit_code = $code if $code;
        next;
    }

    if ( $code > 0 ) {
        $exit_code = $code;
        say STDERR $_ for @notes;
    }
}

say 'All files ok' unless $exit_code;

exit($exit_code);

# PODNAME: perlvars
# ABSTRACT: A command line utility for detecting unused Perl variables

__END__

=pod

=encoding UTF-8

=head1 NAME

perlvars - A command line utility for detecting unused Perl variables

=head1 VERSION

version 0.000009

=head1 SYNOPSIS

Detect unused variables in Perl code.

    perlvars lib/Foo.pm lib/Foo/Bar.pm

    PERL5OPT="-I." perlvars Foo.pm Baz.pm

You can also ignore arbitrary variables on a per-package basis, using an ignore
file.

    perlvars --ignore-file ignore-list.txt lib/Foo.pm lib/Foo/Bar.pm

See the documentation for L<App::perlvars> for the format of the ignore file.

By default, files with no C<package> declaration (most C<.t> and C<.pl>
scripts) are skipped. Pass C<--scripts> to lint those too.

    perlvars --scripts t/some-test.t script/some-tool.pl

If you'd like to check every .pm file in your lib directory, you can try
something like:

    find lib | grep pm$ | xargs perlvars

=head1 DESCRIPTION

This script (which is based heavily on the code in
L<Code::TidyAll::Plugin::Test::Vars>) is a wrapper around L<Test::Vars>, which
tries to find unused variables in your Perl code. Because L<Test::Vars> only
finds unused variables contained within packages, code without an explicit
C<package> declaration is skipped by default.

Pass C<--scripts> to also lint package-less files (such as most C<.t> and
C<.pl> scripts). With that flag, C<perlvars> wraps their contents in a
synthetic package and subroutine before analysis, so their variables are
checked too; reported line numbers refer to the original file. This is opt-in
because it turns on linting for files that were never linted before, which can
surface pre-existing findings, and because wrapping executes the file's
compile-time code (see below).

Wrapping the body in a subroutine means the file's runtime statements are not
executed during analysis (a C<.t> script's tests do not actually run), but
B<compile-time> code still does: L<Test::Vars> C<require>s the wrapped file, so
its C<use> statements and C<BEGIN> blocks are executed as the file is compiled.
Only pass C<--scripts> for files you trust, exactly as you would before running
them.

A package-less file that cannot be analyzed is skipped silently (no analysis,
no error) even when C<--scripts> is given. This includes files that locate a
sibling library at runtime (for example via L<FindBin> and C<use lib>), files
that C<use> a module which is not installed in the environment C<perlvars> runs
in, and files that cannot be parsed as Perl. A file that contains a C<#line>
directive is likewise not analyzed, because the directive changes the filename
L<Test::Vars> attributes subroutines to.

Analyzing any file C<require>s it, so its compile-time code runs and its own
output is suppressed so it does not leak through C<perlvars>. A file that exits
or dies at compile time -- for example one that declares a package and does
C<use Test::More skip_all> -- cannot be analyzed and is skipped silently too,
rather than aborting the run.

When several files are given, a problem with one file (it cannot be found or
cannot be parsed) is reported to C<STDERR> and C<perlvars> moves on to the next
file rather than aborting the whole run; the final exit code is still non-zero.

=head1 USING perlvars WITH precious

L<precious|https://github.com/houseabsolute/precious> is a code quality tool
that runs multiple linters and tidiers from a single configuration. You can add
C<perlvars> as a lint command in your C<precious.toml>:

    [commands.perlvars]
    type    = "lint"
    include = ["**/*.pm"]
    cmd     = ["perlvars"]
    ok-exit-codes = [0]
    # perlvars exits 255 (not 1) when it finds unused/single-use variables.
    lint-failure-exit-codes = [255]

The C<lint-failure-exit-codes> setting is important: C<perlvars> exits with
C<255> (not C<1>) when it finds unused or single-use variables, so precious
needs to be told to treat C<255> as a lint failure rather than an error.

To also lint your C<.t> and C<.pl> scripts, add C<--scripts> to C<cmd> and
widen C<include> to match them:

    [commands.perlvars]
    type    = "lint"
    include = ["**/*.pm", "**/*.t", "**/*.pl"]
    cmd     = ["perlvars", "--scripts"]
    ok-exit-codes = [0]
    lint-failure-exit-codes = [255]

=head1 CAVEATS

As noted above, there are some serious limitations to this script, due to the
way that L<Test::Vars> works. You're strongly encouraged to consider using
L<Perl::Critic::Policy::Variables::ProhibitUnusedVarsStricter> if that's a
possibility for you.

Package-less files (such as most C<.t> and C<.pl> scripts) are supported when
you pass C<--scripts>: C<perlvars> wraps their contents in a synthetic package
and subroutine before analysis. Only variables that L<Test::Vars> can detect
are reported -- in practice, lexicals inside named or anonymous subroutines
(including the anonymous subs passed to C<subtest> and similar). File-scope
(top-level) lexicals are B<not> reported, matching how L<Test::Vars> already
ignores file-scope lexicals in a file that declares a package. Because these
findings are reported against the C<main> package, an ignore file uses C<main>
as the package name to suppress a variable in a package-less file (for example,
C<< main = $wanted >>). The caveats below apply to files that B<do> declare a
package.

Your code needs an explicit package name.

    package Foo::Bar;
    ...
    1;

The package name needs to match the file name, so the package above needs to be in a file named Foo/Bar.pm.

The package needs be in C<@INC> or in a C<./lib> folder. This means that for the example above, either of these should work:

    perlvars lib/Foo/Bar.pm

    cd lib && PERL5OPT="-I." perlvars Foo/Bar.pm

=head2 False positives from constant-folded code

L<Test::Vars> inspects your code's compiled optree and reports a lexical as
"used once" when it finds only a single reference to it. A variable that B<is>
used in the source can therefore still be reported if the Perl compiler removed
its uses before L<Test::Vars> looked at them. The usual cause is a block guarded
by a compile-time constant that is false in the environment where C<perlvars>
runs:

    use constant HAVE_XS => $INC{'Some/XS.pm'} ? 1 : 0;

    sub generate {
        my @xsub;
        if (HAVE_XS) {               # folded away when HAVE_XS is false
            my @known = ...;         # declared and used only inside the block
            my $xsub  = build(@known);
            push @xsub, $xsub if $xsub;
        }
        return ( sub {...}, @xsub );
    }

When C<HAVE_XS> is false, Perl constant-folds the C<if> away entirely, so the
uses of C<@known> and C<$xsub> never reach the optree and both are reported as
"used once" -- even though the source uses them correctly. Install the optional
dependency so the constant becomes true, and the findings disappear.

Several widely used distributions do exactly this to guard an optional XS fast
path or a version-specific workaround. For example, L<Type::Tiny> reports
C<@known>/C<$known>/C<$xsub> in C<Types::Standard::Map> and friends only when
L<Type::Tiny::XS> is B<not> installed, and L<Role::Tiny> reports C<$guard> in
C<_load_module> depending on a C<_WORK_AROUND_BROKEN_MODULE_STATE> constant.
Because the outcome depends on what is installed -- and, for constants resolved
at load time, even on module load order -- the same source file can be reported
in one environment and come back clean in another.

This cannot be worked around inside C<perlvars> or L<Test::Vars>: both inspect
the optree only after the compiler has already discarded the folded-out code, so
there is nothing left to count (see L</"Can this be fixed?"> below). To silence
such a false positive, list the variable in an C<--ignore-file> (see
L<App::perlvars> for the format), for example:

    Type::Tiny::Class = $xsub

=head2 Can this be fixed?

Not in C<perlvars>, and not cleanly in L<Test::Vars> either. L<Test::Vars>
counts variable uses by walking the compiled optree (via C<B::walkoptree>).
Perl's compiler folds a constant-false C<if> and frees the ops inside it
B<before> the optree is handed to L<Test::Vars>, so the folded-out uses simply
do not exist anymore -- the lexical's pad slot survives, but with no use ops to
count. There is no information left that distinguishes "declared, uses folded
away" from "declared and genuinely never used."

A real fix would require one of:

=over 4

=item *

Preventing Perl from constant-folding the dead branch during analysis. Perl
offers no supported way to turn off constant folding for a single compile, so
this is not practical.

=item *

Re-deriving uses from the source (for example with L<PPI>) instead of, or in
addition to, the optree. That is a different kind of analysis that would give up
L<Test::Vars>' main strength -- catching variables that are assigned but never
meaningfully read -- and would reintroduce its own false positives.

=back

So the folding is inherent to how L<Test::Vars> works, and the practical
response is to install the optional dependency (which makes the constant true
and the finding go away) or to suppress the variable via C<--ignore-file>.

=head1 ACKNOWLEDGEMENTS

The code in this module is largely copied directly from L<Code::TidyAll::Plugin::Test::Vars>.

=head1 SEE ALSO

You may also wish to use
L<Perl::Critic::Policy::Variables::ProhibitUnusedVarsStricter> which can find
some cases which L<Test::Vars> is not able to detect. It also does not require
the code to be inside a package.

=head1 AUTHOR

Olaf Alders <olaf@wundercounter.com>

=head1 COPYRIGHT AND LICENSE

This software is copyright (c) 2022 by MaxMind, Inc.

This is free software; you can redistribute it and/or modify it under
the same terms as the Perl 5 programming language system itself.

=cut
