chainlint.pl: add parser to identify test definitions

Finish fleshing out chainlint.pl by adding ScriptParser, a parser which scans shell scripts for tests defined by test_expect_success() and test_expect_failure(), plucks the test body from each definition, and passes it to TestParser for validation. It recognizes test definitions not only at the top-level of test scripts but also tests synthesized within compound commands such as loops and function. Signed-off-by: Eric Sunshine <sunshine@sunshineco.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Eric Sunshine committed Sep 1, 2022 at 00:29 UTC d99ebd6d2e57baa3ec45b939d40cf939b85301a3
1 file changed +60 -3
t/chainlint.pl
+60 -3
@@ -487,18 +487,75 @@ DONE:
487 $self->SUPER::accumulate($tokens, $cmd);
488 }
489
490 +# ScriptParser is a subclass of ShellParser which identifies individual test
491 +# definitions within test scripts, and passes each test body through TestParser
492 +# to identify possible problems. ShellParser detects test definitions not only
493 +# at the top-level of test scripts but also within compound commands such as
494 +# loops and function definitions.
495 package ScriptParser;
496
497 +use base 'ShellParser';
498 +
499 sub new {
500 my $class = shift @_;
494 - my $self = bless {} => $class;
495 - $self->{output} = [];
501 + my $self = $class->SUPER::new(@_);
502 $self->{ntests} = 0;
503 return $self;
504 }
505
506 +# extract the raw content of a token, which may be a single string or a
507 +# composition of multiple strings and non-string character runs; for instance,
508 +# `"test body"` unwraps to `test body`; `word"a b"42'c d'` to `worda b42c d`
509 +sub unwrap {
510 + my $token = @_ ? shift @_ : $_;
511 + # simple case: 'sqstring' or "dqstring"
512 + return $token if $token =~ s/^'([^']*)'$/$1/;
513 + return $token if $token =~ s/^"([^"]*)"$/$1/;
514 +
515 + # composite case
516 + my ($s, $q, $escaped);
517 + while (1) {
518 + # slurp up non-special characters
519 + $s .= $1 if $token =~ /\G([^\\'"]*)/gc;
520 + # handle special characters
521 + last unless $token =~ /\G(.)/sgc;
522 + my $c = $1;
523 + $q = undef, next if defined($q) && $c eq $q;
524 + $q = $c, next if !defined($q) && $c =~ /^['"]$/;
525 + if ($c eq '\\') {
526 + last unless $token =~ /\G(.)/sgc;
527 + $c = $1;
528 + $s .= '\\' if $c eq "\n"; # preserve line splice
529 + }
530 + $s .= $c;
531 + }
532 + return $s
533 +}
534 +
535 +sub check_test {
536 + my $self = shift @_;
537 + my ($title, $body) = map(unwrap, @_);
538 + $self->{ntests}++;
539 + my $parser = TestParser->new(\$body);
540 + my @tokens = $parser->parse();
541 + return unless $emit_all || grep(/\?![^?]+\?!/, @tokens);
542 + my $checked = join(' ', @tokens);
543 + $checked =~ s/^\n//;
544 + $checked =~ s/^ //mg;
545 + $checked =~ s/ $//mg;
546 + $checked .= "\n" unless $checked =~ /\n$/;
547 + push(@{$self->{output}}, "# chainlint: $title\n$checked");
548 +}
549 +
550 sub parse_cmd {
501 - return undef;
551 + my $self = shift @_;
552 + my @tokens = $self->SUPER::parse_cmd();
553 + return @tokens unless @tokens && $tokens[0] =~ /^test_expect_(?:success|failure)$/;
554 + my $n = $#tokens;
555 + $n-- while $n >= 0 && $tokens[$n] =~ /^(?:[;&\n|]|&&|\|\|)$/;
556 + $self->check_test($tokens[1], $tokens[2]) if $n == 2; # title body
557 + $self->check_test($tokens[2], $tokens[3]) if $n > 2; # prereq title body
558 + return @tokens;
559 }
560
561 # main contains high-level functionality for processing command-line switches,