#!/usr/bin/perl

=begin metadata

Name: test
Description: condition evaluation utility
Author: Brad Appleton
License: perl

=end metadata

=cut


###########################################################################
# test -- Perl script to emulate BSD Unix test(1) functionality
#         (with slightly more informative error messages).
#
# Copyright (c) 1999 Brad Appleton. All rights reserved.
#
# This file is part of "Perl Power Tools: the Unix Reconstruction
# Project" (PPT) which is free software. You can redistribute it
# and/or modify it under the same terms as PPT itself.
#
###########################################################################

package PerlPowerTools::test;

use strict;
#use diagnostics;
use vars qw($VERSION);

$VERSION = 1.01;

## Set to 0 to disable debug output; non-zero to enable it
my $DEBUG = $ENV{DEBUG_PPT_TEST} || 0;
sub dbval($) {
    local $_ = shift;
    return '<undef>' unless defined;
    return ($_ ? "true" : "false")  if /^\d+$/;
    return (/^-\w/ ? $_ : "'$_'");
}

## Keep track of errors/warnings
my $ERRORS = 0;
$SIG{__WARN__} = sub { print STDERR "test: $_[0]"; ++$ERRORS; };
$SIG{__DIE__}  = sub { print STDERR "test: $_[0]"; exit 2; };

## Complain about an invalid argument
sub bad_arg(@) { warn "invalid argument @_\n"; }

## Make sure we have a numeric value
sub number($) {
    local $_ = shift;
    return $_ if (defined && /^[-+]?\d+$/);
    bad_arg "'$_[0]' - expecting a number";
    return undef;
}

## %TEST_OPS maps test(1) operators or operations to Perl operations
## If a value is a string, the string is returned. If it is a reference,
## then its assumed to be a reference to a subroutine, and the value
## is whatever the subroutine returns.
##
## The code doing the lookup is responsible for passing the correct
## number of parameters and interpreting the result.
##
my %TEST_OPS = (
    ## Logical/grouping operators ('-a' has higher precedence than '-o')
    '('    =>    '(',
    ')'    =>    ')',
    '!'    =>    '!',
    '-a'   =>    '&&',
    '-o'   =>    '||',
       ## NOTE: are && and || 'compatibility' or feature creep?
    '&&'   =>    '&&',
    '||'   =>    '||',

    ## File test operators
    '-b'   =>    sub { -b $_[0] },
    '-c'   =>    sub { -c $_[0] },
    '-d'   =>    sub { -d $_[0] },
    '-e'   =>    sub { -e $_[0] },
    '-f'   =>    sub { -f $_[0] },
    '-g'   =>    sub { -g $_[0] },
    '-h'   =>    sub { -l $_[0] },
    '-k'   =>    sub { -k $_[0] },
    '-l'   =>    sub { -l $_[0] },
    '-p'   =>    sub { -p $_[0] },
    '-r'   =>    sub { -r $_[0] },
    '-s'   =>    sub { -s $_[0] },
    '-t'   =>    sub { -t (number($_[0])||0) },
    '-u'   =>    sub { -u $_[0] },
    '-w'   =>    sub { -w $_[0] },
    '-x'   =>    sub { -x $_[0] },
    '-B'   =>    sub { -B $_[0] },
    '-L'   =>    sub { -l $_[0] },
    '-O'   =>    sub { -O $_[0] },
    '-G'   =>    sub { bad_arg("'-G' - operator not supported") },
    '-R'   =>    sub { -R $_[0] },
    '-S'   =>    sub { -S $_[0] },
    '-T'   =>    sub { -T $_[0] },
    '-W'   =>    sub { -W $_[0] },
    '-X'   =>    sub { -X $_[0] },

    ## String comparisons
    '-n'   =>    sub { length $_[0] },
    '-z'   =>    sub { ! length $_[0] },
    '='    =>    sub { $_[0] eq $_[1] },
    '!='   =>    sub { $_[0] ne $_[1] },
    '<'    =>    sub { $_[0] lt $_[1] },
    '>'    =>    sub { $_[0] gt $_[1] },
       ## NOTE: are ==, <=, and >= 'compatibility' or feature creep?
    '=='   =>    sub { $_[0] eq $_[1] },
    '<='   =>    sub { $_[0] le $_[1] },
    '>='   =>    sub { $_[0] ge $_[1] },

    ## Numeric comparisons
    '-eq'  =>    sub { (number($_[0])||0) == (number($_[1])||0) },
    '-ne'  =>    sub { (number($_[0])||0) != (number($_[1])||0) },
    '-lt'  =>    sub { (number($_[0])||0) <  (number($_[1])||0) },
    '-le'  =>    sub { (number($_[0])||0) <= (number($_[1])||0) },
    '-gt'  =>    sub { (number($_[0])||0) >  (number($_[1])||0) },
    '-ge'  =>    sub { (number($_[0])||0) >= (number($_[1])||0) },

    ## File comparisons
    '-nt'  =>    sub { -M $_[0] < -M $_[1] },
    '-ot'  =>    sub { -M $_[0] > -M $_[1] },
    '-ef'  =>    sub { bad_arg("'-ef' - operator not supported") },
);

## Apply a test operator to the given arguments
sub apply_op ($;@) {
    my $test_op = shift;
    my $perl_op = $TEST_OPS{$test_op};
    my $result  = undef;
    if (defined $perl_op) {
        $result = (ref $perl_op) ? 0+&{$perl_op}(@_) : $perl_op;
    }
    else {
        ## NOTE: should this be an error, or should it be the
        ##       same as saying "-n $test_op"?
        warn "invalid operator '$test_op'\n";
    }
    return $result;
}

## Evaluate test(1) operations and their operands.
## Returns 'undef' upon error, 0 false, !0 for true
sub test (@) {
    my @terms = ();
    local  $_ = "";
    my $grouping  = 0;
    my $need_expr = 1;
    while (@_ > 0) {
        $_ = shift;
        next if m/[\[;\]]/;  ## ignore '[', ']', and ';'

        if (/^[\(\)!]$/) {
            ## grouping and negation
            if ($_ eq ')') {
                --$grouping;
                bad_arg "'$_' - unbalanced parentheses" if ($grouping < 0);
                push @terms, 0  if ($need_expr);
                $need_expr = 0;
            }
            else {
                ++$grouping if ($_ eq '(');
                bad_arg "'$_' - expecting '-a' or '-o'" unless ($need_expr);
            }
            $DEBUG and printf ">>> %s", $_;
            push @terms, apply_op($_);
        }
        elsif (/^(?: -[ao] | \|\| | \&\& )$/x) {
            ## and/or operators
            bad_arg "'$_' - expression expected" if ($need_expr);
            $DEBUG and printf ">>> %s", $_;
            push @terms, apply_op($_);
            $need_expr = 1;
        }
        elsif ($_ eq '-t' and  (@_ == 0 or !defined(number $_[0]))) {
            ## '-t' with no argument
            bad_arg "'$_' - expecting '-a' or '-o'" unless ($need_expr);
            $DEBUG and printf ">>> %s", $_;
            push @terms, 0+(-t );
            $need_expr = 0;
        }
        elsif (/^-\w$/) {
            ## file tests and string length/existence operators
            bad_arg "'$_' - expecting '-a' or '-o'" unless ($need_expr);
            warn "argument expected after '$_'\n" if (@_ == 0);
            if (@_ > 0) {
                $DEBUG and printf ">>> %s %s", $_, $_[0];
                push @terms, apply_op($_, shift);
                $need_expr = 0;
            }
        }
        elsif (@_  and  $_[0] =~ /^(?: -[a-z]{2} | [=!]?= | [<>]=? )$/x) {
            ## We have a string or a filename that participates
            ## in a binary infix operation.
            bad_arg "'$_' - expecting '-a' or '-o'" unless ($need_expr);
            warn "argument expected after '${\shift()}'\n"  if (@_ < 2);
            if (@_ >= 2) {
                $DEBUG and printf ">>> %s %s %s", $_, @_[0..1];
                push @terms, apply_op(shift, $_, shift);
                $need_expr = 0;
            }
        }
        else {
            ## We have a lone string, so "-n string" is implied.
            bad_arg "'$_' - expecting '-a' or '-o'" unless ($need_expr);
            $DEBUG and printf ">>> %s", dbval $_;
            push @terms, length;
            $need_expr = 0;
        }
        $DEBUG and @terms and printf " ==> %s\n", dbval($terms[-1]);
    }

    ## Check for errors so far
    warn "too many open '(' parentheses\n" if ($grouping > 0);
    return undef if $ERRORS;

    ## Now we have all our primitive terms evaluated, compute the result.
    $DEBUG and print ">>>expression is: @terms\n";
    return $terms[0]  if (@terms == 1  and  $terms[0] =~ /^\d*$/);
    my $result = eval "@terms";
    warn "$@" if ($@ and !$ERRORS);
    return ($@) ? undef : 0+$result;
}

exit(1) if (@ARGV == 0);
my $rc = test @ARGV;

## For command-lines, zero is success and non-zero is false, so
## we need to interpret the return code into an exit status.
exit(defined($rc) ? !$rc : 2);

__END__

=head1 NAME

test - condition evaluation utility

=head1 SYNOPSIS

  test expression
  [ expression ]

=head1 DESCRIPTION

The B<test> utility evaluates the I<expression> and, if it evaluates
to true, returns a zero (true) exit status; otherwise it returns 1
(false). If no expression is given, B<test> also returns 1 (false).

All operators and flags are separate arguments to the test utility.

The following primaries are used to construct expression:

=over 14

=item B<-b> I<file>

True if file exists and is a block special file.

=item B<-c> I<file>

True if file exists and is a character special file.

=item B<-d> I<file>

True if file exists and is a directory.

=item B<-e> I<file>

True if file exists (regardless of type).

=item B<-f> I<file>

True if file exists and is a regular file.

=item B<-g> I<file>

True if file exists and its set-group-ID flag is set.

=item B<-h> I<file>

True if file exists and is a symbolic link.  This operator is for
COMPATABILITY purposes, do not rely on its existence.  Use B<-L> instead.

=item B<-k> I<file>

True if file exists and its sticky bit is set.

=item B<-n> I<string>

True if the length of string is nonzero.

=item B<-p> I<file>

True if file is a named pipe (FIFO).

=item B<-r> I<file>

True if file exists and is readable by the effective user/group.

=item B<-s> I<file>

True if file exists and has a size greater than zero.

=item B<-t> I<file_descriptor>

True if the file whose file descriptor number is I<file_descriptor> is
open and is associated with a terminal.

=item B<-t>

Same as above with an implicit file descriptor number of "1"
(e.g.: B<-t> 1).

=item B<-u> I<file>

True if file exists and its set-user-ID flag is set.

=item B<-w> I<file>

True if file exists and is writable by the effective user/group.  True
indicates only that the write flag is on.  The file is not writable on
a read-only file system even if this test indicates true.

=item B<-x> I<file>

True if file exists and is executable by the effective user/group.
True indicates only that the execute flag is on.  If file is a
directory, true indicates that file can be searched.

=item B<-z> I<string>

True if the length of string is zero.

=item B<-B> I<file>

True if file exists and is a binary file.

=item B<-L> I<file>

True if file exists and is a symbolic link.

=item B<-O> I<file>

True if file exists and its owner matches the effective user ID of this
process.

=begin _NOT_IMPLEMENTED_

=item B<-G> I<file>

True if file exists and its group matches the effective
group ID of this process.

=end _NOT_IMPLEMENTED_

=item B<-R> I<file>

True if file exists and is readable by the real user/group.

=item B<-S> I<file>

True if file exists and is a socket.

=item B<-T> I<file>

True if file exists and is a text file.

=item B<-W> I<file>

True if file exists and is writable by the real user/group.  True
indicates only that the write flag is on.  The file is not writable on
a read-only file system even if this test indicates true.

=item B<-X> I<file>

True if file exists and is executable by the real user/group.
True indicates only that the execute flag is on.  If file is a
directory, true indicates that file can be searched.

=item I<file1> B<-nt> I<file2>

True if I<file1> exists and is newer than I<file2>.

=item I<file1> B<-ot> I<file2>

True if I<file1> exists and is older than I<file2>.

=begin _NOT_IMPLEMENTED_

=item I<file1> B<-ef> I<file2>

True if I<file1> and I<file2> exist and refer to the same file.

=end _NOT_IMPLEMENTED_

=item I<string>

True if I<string> is not the null string.

=item I<s1> = I<s2>

True if the strings I<s1> and I<s2> are identical.

=item I<s1> != I<s2>

True if the strings I<s1> and I<s2> are not identical.

=item I<s1> < I<s2>

True if string I<s1> comes before I<s2> based on the ASCII value
of their characters.

=item I<s1> > I<s2>

True if string I<s1> comes after I<s2> based on the ASCII value
of their characters.

=item I<s1>

True if I<s1> is not the null string.

=item I<n1> B<-eq> I<n2>

True if the integers I<n1> and I<n2> are algebraically equal.

=item I<n1> B<-ne> I<n2>

True if the integers I<n1> and I<n2> are not algebraically equal.

=item I<n1> B<-gt> I<n2>

True if the integer I<n1> is algebraically greater than the
integer I<n2>.

=item I<n1> B<-ge> I<n2>

True if the integer I<n1> is algebraically greater than or
equal to the integer I<n2>.

=item I<n1> B<-lt> I<n2>

True if the integer I<n1> is algebraically less than the integer I<n2>.

=item I<n1> B<-le> I<n2>

True if the integer I<n1> is algebraically less than or equal
to the integer I<n2>.

=back

These primaries can be combined with the following operators:

=over 14

=item ! I<expression>

True if I<expression> is false.

=item I<expression1> B<-a> I<expression2>

True if both I<expression1> and I<expression2> are true.

=item I<expression1> B<-o> I<expression2>

True if either I<expression1> or I<expression2> are true.

=item (I<expression>)

True if I<expression> is true (uses parentheses for grouping).

=back

Note that the B<-a> operator has higher precedence than the B<-o> operator.
Notice also that all the operators and flags are separate arguments to test.

=head1 RETURN VALUES

The test utility exits with one of the following values:

=over 8

=item $return_val == 0

Expression evaluated to true.

=item $return_val == 1

Expression evaluated to false or expression was missing.

=item $return_val > 1

An error occurred.

=back

=head1 CAVEATS

Command line arguments like parentheses and arithmetic operators
(e.g.: '(', ')', '!', '>', '<', etc.) I<may> be meaningful to the
command-line interpreter (shell) and therefore I<may> need to be
escaped from any special shell interpretation.

=head1 SEE ALSO

L<sh>, L<find>

=cut