Skip to content

Commit 1227fd3

Browse files
committed
t: add check-grep to detect bare grep assertions
Add check-grep.pl, a lint tool that detects bare 'grep' used as a test assertion where 'test_grep' should be used instead, and '! test_grep' where 'test_grep !' should be used (shell negation suppresses the diagnostic output). Wire it into the test-lint target as test-lint-grep. The checker reuses chainlint's shared shell parser to correctly identify command boundaries, so 'grep' appearing as an argument to another command, in heredoc bodies, command substitutions, or strings is not flagged. Signed-off-by: Michael Montalbo <mmontalbo@gmail.com>
1 parent 48edf00 commit 1227fd3

2 files changed

Lines changed: 136 additions & 1 deletion

File tree

t/Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ check-meson:
139139
test-lint: test-lint-duplicates test-lint-executable \
140140
test-lint-filenames
141141
ifneq ($(PERL_PATH),)
142-
test-lint: test-lint-shell-syntax
142+
test-lint: test-lint-shell-syntax test-lint-grep
143143
else
144144
GIT_TEST_CHAIN_LINT = 0
145145
endif
@@ -157,6 +157,9 @@ test-lint-executable:
157157
test -z "$$bad" || { \
158158
echo >&2 "non-executable tests:" $$bad; exit 1; }
159159

160+
test-lint-grep:
161+
@'$(PERL_PATH_SQ)' check-grep.pl $(T) $(TPERF)
162+
160163
test-lint-shell-syntax:
161164
@'$(PERL_PATH_SQ)' check-non-portable-shell.pl $(T) $(THELPERS) $(TPERF)
162165

t/check-grep.pl

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
#!/usr/bin/perl
2+
3+
# Detect bare 'grep' used as a test assertion where 'test_grep'
4+
# should be used, and '! test_grep' where 'test_grep !' should
5+
# be used. Uses the shared shell parser to correctly identify
6+
# command boundaries, avoiding false positives from 'grep' in
7+
# arguments, strings, heredocs, or command substitutions.
8+
9+
use strict;
10+
use warnings;
11+
use File::Basename;
12+
13+
my $_lib = dirname($0) . "/lib-shell-parser.pl";
14+
$_lib = "./$_lib" unless $_lib =~ m{^/};
15+
do $_lib or die "$0: failed to load $_lib: $@$!\n";
16+
17+
my $exit_code = 0;
18+
19+
package CheckGrepParser;
20+
our @ISA = ('ScriptParser');
21+
22+
sub check_test {
23+
my $self = shift @_;
24+
my $title = ScriptParser::unwrap(shift @_);
25+
my $body = shift @_;
26+
my $lineno = $body->[3];
27+
$body = ScriptParser::unwrap($body);
28+
if ($body eq '-') {
29+
my $herebody = shift @_;
30+
if ($herebody) {
31+
$body = $herebody->{content};
32+
$lineno = $herebody->{start_line};
33+
}
34+
}
35+
return unless $body;
36+
37+
my $parser = ShellParser->new(\$body);
38+
my @tokens = $parser->parse();
39+
40+
my $raw_lines = $self->{raw_lines};
41+
42+
# Check if the raw source line has a lint-ok annotation.
43+
sub _lint_ok {
44+
my ($raw_lines, $ln) = @_;
45+
return 0 unless $ln >= 1 && $ln <= @$raw_lines;
46+
return $raw_lines->[$ln - 1] =~ /lint-ok/;
47+
}
48+
49+
# Check if the grep at position $pos pipes its output
50+
# or redirects I/O (i.e. used as a filter, not assertion).
51+
sub _is_filter {
52+
my ($tokens, $pos) = @_;
53+
for (my $j = $pos + 1; $j < @$tokens; $j++) {
54+
my $t = $tokens->[$j]->[0];
55+
return 0 if $t =~ /^(?:&&|\|\||;;|\n)$/;
56+
return 1 if $t eq '|';
57+
return 1 if $t =~ /^>{1,2}$/;
58+
return 1 if $t eq '<';
59+
}
60+
return 0;
61+
}
62+
63+
# Walk the token stream. Track command boundaries to
64+
# identify 'grep' as a command word (not an argument).
65+
my $cmd_pos = 1;
66+
my $in_pipe = 0;
67+
my $file = $self->{file};
68+
69+
for (my $i = 0; $i < @tokens; $i++) {
70+
my $text = $tokens[$i]->[0];
71+
my $ln = ($tokens[$i]->[3] || 0) + ($lineno || 1) - 1;
72+
73+
# command separators: next token is a command word
74+
if ($text =~ /^(?:&&|\|\||;;|\n)$/ ||
75+
$text =~ /^[{(]$/ ||
76+
$text =~ /^(?:do|then|else|elif)$/) {
77+
$cmd_pos = 1;
78+
$in_pipe = 0 if $text =~ /^(?:&&|\|\||;;)$/;
79+
next;
80+
}
81+
if ($text eq '|') {
82+
$cmd_pos = 1;
83+
$in_pipe = 1;
84+
next;
85+
}
86+
if ($text =~ /^[})]$/) {
87+
$cmd_pos = 0;
88+
next;
89+
}
90+
91+
next unless $cmd_pos;
92+
93+
# '!' is a negation prefix, stay in command position
94+
if ($text eq '!') {
95+
if ($i + 1 < @tokens &&
96+
$tokens[$i + 1]->[0] eq 'test_grep' &&
97+
!_lint_ok($raw_lines, $ln)) {
98+
print "$file:$ln: error: ",
99+
'use "test_grep !" instead of ',
100+
'"! test_grep"', "\n";
101+
$exit_code = 1;
102+
}
103+
next;
104+
}
105+
106+
# bare grep at command position, not in a pipeline
107+
if ($text eq 'grep' && !$in_pipe &&
108+
!_is_filter(\@tokens, $i) &&
109+
!_lint_ok($raw_lines, $ln)) {
110+
print "$file:$ln: error: ",
111+
"bare grep outside pipeline ",
112+
"(use test_grep)\n";
113+
$exit_code = 1;
114+
}
115+
116+
$cmd_pos = 0;
117+
}
118+
}
119+
120+
package main;
121+
122+
for my $file (@ARGV) {
123+
open(my $fh, '<:unix:crlf', $file) or die "$0: $file: $!\n";
124+
my @raw_lines = <$fh>;
125+
close $fh;
126+
my $s = join('', @raw_lines);
127+
my $parser = CheckGrepParser->new(\$s);
128+
$parser->{file} = $file;
129+
$parser->{raw_lines} = \@raw_lines;
130+
$parser->parse();
131+
}
132+
exit $exit_code;

0 commit comments

Comments
 (0)