#!/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 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 = ''; # ---------------------------------------------------------------------- # 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'; $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 /(?= $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;