Permalink
Switch branches/tags
Nothing to show
Find file
Fetching contributors…
Cannot retrieve contributors at this time
653 lines (520 sloc) 16.1 KB
#!/usr/bin/perl
use 5.10.0;
# Tsh -- non interactive Testing SHell in perl
# TODO items:
# - allow an RC file to be used to add basic and extended commands
# - convert internal defaults to additions to the RC file
# - implement shell commands as you go
# - solve the "pass/fail" inconsistency between shell and perl
# - solve the pipes problem (use 'overload'?)
# ----------------------------------------------------------------------
# modules
package Tsh;
use Cwd qw(chdir);
use Exporter 'import';
@EXPORT = qw(
try run cmp AUTOLOAD
rc error_count text lines error_list put
cd tsh_tempdir
$HOME $PWD $USER
);
@EXPORT_OK = qw();
use Env qw(@PATH HOME PWD USER TSH_VERBOSE);
# other candidates:
# GL_ADMINDIR GL_BINDIR GL_RC GL_REPO_BASE_ABS GL_REPO GL_USER
use strict;
use warnings;
use Text::Tabs; # only used for formatting the usage() message
use Text::ParseWords;
use File::Temp qw(tempdir);
END { chdir( $ENV{HOME} ); }
# we need this END handler *after* the 'use File::Temp' above. Without
# this, if $PWD at exit was $tempdir, you get errors like "cannot remove
# path when cwd is [...] at /usr/share/perl5/File/Temp.pm line 902".
use Data::Dumper;
# ----------------------------------------------------------------------
# globals
my $rc; # return code from backticked (external) programs
my $text; # STDOUT+STDERR of backticked (external) programs
my $lec; # the last external command (the rc and text are from this)
my $cmd; # the current command
my $testnum; # current test number, for info in TAP output
my $testname; # current test name, for error info to user
my $line; # current line number and text
my $err_count; # count of test failures
my @errors_in; # list of testnames that errored
my $tick; # timestamp for git commits
my %autoloaded;
my $tempdir = '';
my $prevdir = '';
# ----------------------------------------------------------------------
# setup
# unbuffer STDOUT and STDERR
select(STDERR); $|++;
select(STDOUT); $|++;
# set the timestamp (needed only under harness)
test_tick() if $ENV{HARNESS_ACTIVE};
# ----------------------------------------------------------------------
# this is for one-liner access from outside, using @ARGV, as in:
# perl -MTsh -e 'tsh()' 'tsh command list'
# or via STDIN
# perl -MTsh -e 'tsh()' < file-containing-tsh-commands
# NOTE: it **exits**!
sub tsh {
my @lines;
if (@ARGV) {
# simple, single argument which is a readable filename
if ( @ARGV == 1 and $ARGV[0] !~ /\s/ and -r $ARGV[0] ) {
# take the contents of the file
@lines = <>;
} else {
# more than one argument *or* not readable filename
# just take the arguments themselves as the command list
@lines = @ARGV;
@ARGV = ();
}
} else {
# no arguments given, take STDIN
usage() if -t;
@lines = <>;
}
# and process them
try(@lines);
# print error summary by default
if ( not defined $TSH_VERBOSE ) {
say STDERR "$err_count error(s)" if $err_count;
}
exit $err_count;
}
# these two get called with series of tsh commands, while the autoload,
# (later) handles single commands
sub try {
$line = $rc = $err_count = 0;
@errors_in = ();
# break up multiline arguments into separate lines
my @lines = map { split /\n/ } @_;
# and process them
rc_lines(@lines);
# bump err_count if the last command had a non-0 rc (that was apparently not checked).
$err_count++ if $rc;
# finish up...
dbg( 1, "$err_count error(s)" ) if $err_count;
return ( not $err_count );
}
# run() differs from try() in that
# - uses open(), not backticks
# - takes only one command, not tsh-things like ok, /patt/ etc
# - - if you pass it an array it uses the list form!
sub run {
open( my $fh, "-|", @_ ) or die "tell sitaram $!";
local $/ = undef; $text = <$fh>;
close $fh; warn "tell sitaram $!" if $!;
$rc = ( $? >> 8 );
return $text;
}
sub put {
my ( $file, $data ) = @_;
die "probable quoting error in arguments to put: $file\n" if $file =~ /^\s*['"]/;
my $mode = ">";
$mode = "|-" if $file =~ s/^\s*\|\s*//;
$rc = 0;
my $fh;
open( $fh, $mode, $file )
and print $fh $data
and close $fh
and return 1;
$rc = 1;
dbg( 1, "put $file: $!" );
return '';
}
# ----------------------------------------------------------------------
# TODO: AUTOLOAD and exportable convenience subs for common shell commands
sub cd {
my $dir = shift || '';
_cd($dir);
dbg( 1, "cd $dir: $!" ) if $rc;
return ( not $rc );
}
# this is classic AUTOLOAD, almost from the perlsub manpage. Although, if
# instead of `ls('bin');` you want to be able to say `ls 'bin';` you will need
# to predeclare ls, with `sub ls;`.
sub AUTOLOAD {
my $program = $Tsh::AUTOLOAD;
dbg( 4, "program = $program, arg=$_[0]" );
$program =~ s/.*:://;
$autoloaded{$program}++;
die "tsh's autoload support expects only one arg\n" if @_ > 1;
_sh("$program $_[0]");
return ( not $rc ); # perl truth
}
# ----------------------------------------------------------------------
# exportable service subs
sub rc {
return $rc || 0;
}
sub text {
return $text || '';
}
sub lines {
return split /\n/, $text;
}
sub error_count {
return $err_count;
}
sub error_list {
return (
wantarray
? @errors_in
: join( "\n", @errors_in )
);
}
sub tsh_tempdir {
# create tempdir if not already done
$tempdir = tempdir( "tsh_tempdir.XXXXXXXXXX", TMPDIR => 1, CLEANUP => 1 ) unless $tempdir;
# XXX TODO that 'UNLINK' doesn't work for Ctrl_C
return $tempdir;
}
# ----------------------------------------------------------------------
# internal (non-exportable) service subs
sub print_plan {
return unless $ENV{HARNESS_ACTIVE};
local $_ = shift;
say "1..$_";
}
sub rc_lines {
my @lines = @_;
while (@lines) {
local $_ = shift @lines;
chomp; $_ = trim_ws($_);
no warnings;
$line++;
use warnings;
# this also sets $testname
next if is_comment_or_empty($_);
dbg( 2, "L: $_" );
$line .= ": $_"; # save line for printing with 'FAIL:'
# a DEF has to be on a line by itself
if (/^DEF\s+([-.\w]+)\s*=\s*(\S.*)$/) {
def( $1, $2 );
next;
}
my @cmds = cmds($_);
# process each command
# (note: some of the commands may put stuff back into @lines)
while (@cmds) {
# this needs to be the 'global' one, since fail() prints it
$cmd = shift @cmds;
# is the current command a "testing" command?
my $testing_cmd = (
$cmd =~ m(^ok(?:\s+or\s+(.*))?$)
or $cmd =~ m(^!ok(?:\s+or\s+(.*))?$)
or $cmd =~ m(^/(.*?)/(?:\s+or\s+(.*))?$)
or $cmd =~ m(^!/(.*?)/(?:\s+or\s+(.*))?$)
);
# warn if the previous command failed but rc is not being checked
if ( $rc and not $testing_cmd ) {
dbg( 1, "rc: $rc from cmd prior to '$cmd'\n" );
# count this as a failure, for exit status purposes
$err_count++;
# and reset the rc, otherwise for example 'ls foo; tt; tt; tt'
# will tell you there are 3 errors!
$rc = 0;
push @errors_in, $testname if $testname;
}
# prepare to run the command
dbg( 3, "C: $cmd" );
if ( def($cmd) ) {
# expand macro and replace head of @cmds (unshift)
dbg( 2, "DEF: $cmd" );
unshift @cmds, cmds( def($cmd) );
} else {
parse($cmd);
}
# reset rc if checking is done
$rc = 0 if $testing_cmd;
# assumes you will (a) never have *both* 'ok' and '!ok' after
# an action command, and (b) one of them will come immediately
# after the action command, with /patt/ only after it.
}
}
}
sub def {
my ( $cmd, $list ) = @_;
state %def;
%def = read_rc_file() unless %def;
if ($list) {
# set mode
die "attempt to redefine macro $cmd\n" if $def{$cmd};
$def{$cmd} = $list;
return;
}
# get mode: split the $cmd at spaces, see if there is a definition
# available, substitute any %1, %2, etc., in it and send it back
my ( $c, @d ) = shellwords($cmd);
my $e; # the expanded value
if ( $e = $def{$c} ) { # starting value
for my $i ( 1 .. 9 ) {
last unless $e =~ /%$i/; # no more %N's (we assume sanity)
die "$def{$c} requires more arguments\n" unless @d;
my $f = shift @d; # get the next datum
$e =~ s/%$i/$f/g; # and substitute %N all over
}
return join( " ", $e, @d ); # join up any remaining data
}
return '';
}
sub _cd {
my $dir = shift || $HOME;
# a directory name of 'tsh_tempdir' is special
$dir = tsh_tempdir() if $dir eq 'tsh_tempdir';
if ($dir eq '-') {
die 'Cannot `cd -`without a previous call to cd' unless $prevdir;
$dir = $prevdir;
}
$prevdir = $ENV{'PWD'};
$rc = 0;
chdir($dir) or $rc = 1;
}
sub _sh {
my $cmd = shift;
# TODO: switch to IPC::Open3 or something...?
dbg( 4, " running: ( $cmd ) 2>&1" );
$text = `( $cmd ) 2>&1; /bin/echo -n RC=\$?`;
$lec = $cmd;
dbg( 4, " results:\n$text" );
if ( $text =~ /RC=(\d+)$/ ) {
$rc = $1;
$text =~ s/RC=\d+$//;
} else {
die "couldnt find RC= in result; this should not happen:\n$text\n\n...\n";
}
}
sub _perl {
my $perl = shift;
local $_;
$_ = $text;
dbg( 4, " eval: $perl" );
my $evrc = eval $perl;
if ($@) {
$rc = 1; # shell truth
dbg( 1, $@ );
# leave $text unchanged
} else {
$rc = not $evrc;
# $rc is always shell truth, so we need to cover the case where
# there was no error but it still returned a perl false
$text = $_;
}
dbg( 4, " eval-rc=$evrc, results:\n$text" );
}
sub parse {
my $cmd = shift;
if ( $cmd =~ /^sh (.*)/ ) {
_sh($1);
} elsif ( $cmd =~ /^perl (.*)/ ) {
_perl($1);
} elsif ( $cmd eq 'tt' or $cmd eq 'test-tick' ) {
test_tick();
} elsif ( $cmd =~ /^plan ?(\d+)$/ ) {
print_plan($1);
} elsif ( $cmd =~ /^cd ?(\S*)$/ ) {
_cd($1);
} elsif ( $cmd =~ /^ENV (\w+)=['"]?(.+?)['"]?$/ ) {
$ENV{$1} = $2;
} elsif ( $cmd =~ /^(?:tc|test-commit)\s+(\S.*)$/ ) {
# this is the only "git special" really; the default expansions are
# just that -- defaults. But this one is hardwired!
dummy_commits($1);
} elsif ( $cmd =~ '^put(?:\s+(\S.*))?$' ) {
if ($1) {
put( $1, $text );
} else {
print $text if defined $text;
}
} elsif ( $cmd =~ m(^ok(?:\s+or\s+(.*))?$) ) {
$rc ? fail( "ok, rc=$rc from $lec", $1 || '' ) : ok();
} elsif ( $cmd =~ m(^!ok(?:\s+or\s+(.*))?$) ) {
$rc ? ok() : fail( "!ok, rc=0 from $lec", $1 || '' );
} elsif ( $cmd =~ m(^/(.*?)/(?:\s+or\s+(.*))?$) ) {
expect( $1, $2 );
} elsif ( $cmd =~ m(^!/(.*?)/(?:\s+or\s+(.*))?$) ) {
not_expect( $1, $2 );
} else {
_sh($cmd);
}
}
# currently unused
sub executable {
my $cmd = shift;
# path supplied
$cmd =~ m(/) and -x $cmd and return 1;
# barename; look up in $PATH
for my $p (@PATH) {
-x "$p/$cmd" and return 1;
}
return 0;
}
sub ok {
$testnum++;
say "ok ($testnum)" if $ENV{HARNESS_ACTIVE};
}
sub fail {
$testnum++;
say "not ok ($testnum)" if $ENV{HARNESS_ACTIVE};
my $die = 0;
my ( $msg1, $msg2 ) = @_;
if ($msg2) {
# if arg2 is non-empty, print it regardless of debug level
$die = 1 if $msg2 =~ s/^die //;
say STDERR "# $msg2";
}
local $TSH_VERBOSE = 1 if $ENV{TSH_ERREXIT};
dbg( 1, "FAIL: $msg1", $testname || '', "test number $testnum", "L: $line", "results:\n$text" );
# count the error and add the testname to the list if it is set
$err_count++;
push @errors_in, $testname if $testname;
return unless $die or $ENV{TSH_ERREXIT};
dbg( 1, "exiting at cmd $cmd\n" );
exit( $rc || 74 );
}
sub cmp {
# compare input string with second input string or text()
my $in = shift;
my $text = ( @_ ? +shift : text() );
if ( $text eq $in ) {
ok();
} else {
fail( 'cmp failed', '' );
dbg( 4, "\n\ntext = <<<$text>>>, in = <<<$in>>>\n\n" );
}
}
sub expect {
my ( $patt, $msg ) = @_;
$msg =~ s/^\s+// if $msg;
my $sm;
if ( $sm = sm($patt) ) {
dbg( 4, " M: $sm" );
ok();
} else {
fail( "/$patt/", $msg || '' );
}
}
sub not_expect {
my ( $patt, $msg ) = @_;
$msg =~ s/^\s+// if $msg;
my $sm;
if ( $sm = sm($patt) ) {
dbg( 4, " M: $sm" );
fail( "!/$patt/", $msg || '' );
} else {
ok();
}
}
sub sm {
# smart match? for now we just do regex match
my $patt = shift;
return ( $text =~ qr($patt) ? $& : "" );
}
sub trim_ws {
local $_ = shift;
s/^\s+//; s/\s+$//;
return $_;
}
sub is_comment_or_empty {
local $_ = shift;
chomp; $_ = trim_ws($_);
if (/^##\s(.*)/) {
$testname = $1;
say "# $1";
}
return ( /^#/ or /^$/ );
}
sub cmds {
local $_ = shift;
chomp; $_ = trim_ws($_);
# split on unescaped ';'s, then unescape the ';' in the results
my @cmds = map { s/\\;/;/g; $_ } split /(?<!\\);/;
@cmds = grep { $_ = trim_ws($_); /\S/; } @cmds;
return @cmds;
}
sub dbg {
return unless $TSH_VERBOSE;
my $level = shift;
return unless $TSH_VERBOSE >= $level;
my $all = join( "\n", grep( /./, @_ ) );
chomp($all);
$all =~ s/\n/\n\t/g;
say STDERR "# $all";
}
sub ddump {
for my $i (@_) {
print STDERR "DBG: " . Dumper($i);
}
}
sub usage {
# TODO
print "Please see documentation at:
https://github.com/sitaramc/tsh/blob/master/README.mkd
Meanwhile, here are your local 'macro' definitions:
";
my %m = read_rc_file();
my @m = map { "$_\t$m{$_}\n" } sort keys %m;
$tabstop = 16;
print join( "", expand(@m) );
exit 1;
}
# ----------------------------------------------------------------------
# git-specific internal service subs
sub dummy_commits {
for my $f ( split ' ', shift ) {
if ( $f eq 'tt' or $f eq 'test-tick' ) {
test_tick();
next;
}
my $ts = ( $tick ? gmtime( $tick + 19800 ) : gmtime() );
_sh("echo $f at $ts >> $f && git add $f && git commit -m '$f at $ts'");
}
}
sub test_tick {
unless ( $ENV{HARNESS_ACTIVE} ) {
sleep 1;
return;
}
$tick += 60 if $tick;
$tick ||= 1310000000;
$ENV{GIT_COMMITTER_DATE} = "$tick +0530";
$ENV{GIT_AUTHOR_DATE} = "$tick +0530";
}
# ----------------------------------------------------------------------
# the internal macros, for easy reference and reading
sub read_rc_file {
my $rcfile = "$HOME/.tshrc";
my $rctext;
if ( -r $rcfile ) {
local $/ = undef;
open( my $rcfh, "<", $rcfile ) or die "this should not happen: $!\n";
$rctext = <$rcfh>;
} else {
# this is the default "rc" content
$rctext = "
add = git add
branch = git branch
clone = git clone
checkout = git checkout
commit = git commit
fetch = git fetch
init = git init
push = git push
reset = git reset
tag = git tag
empty = git commit --allow-empty -m empty
push-om = git push origin master
reset-h = git reset --hard
reset-hu = git reset --hard \@{u}
"
}
# ignore everything except lines of the form "aa = bb cc dd"
my %commands = ( $rctext =~ /^\s*([-.\w]+)\s*=\s*(\S.*)$/gm );
return %commands;
}
1;