File Coverage

blib/lib/AppBase/Grep.pm
Criterion Covered Total %
statement 11 111 9.9
branch 0 76 0.0
condition 0 41 0.0
subroutine 4 6 66.6
pod 1 1 100.0
total 16 235 6.8


line stmt bran cond sub pod time code
1             package AppBase::Grep;
2              
3 1     1   252344 use 5.010001;
  1         3  
4 1     1   5 use strict;
  1         2  
  1         25  
5 1     1   4 use warnings;
  1         2  
  1         67  
6 1     1   1516 use Log::ger;
  1         45  
  1         4  
7              
8             our $AUTHORITY = 'cpan:PERLANCAR'; # AUTHORITY
9             our $DATE = '2024-11-20'; # DATE
10             our $DIST = 'AppBase-Grep'; # DIST
11             our $VERSION = '0.014'; # VERSION
12              
13             our %SPEC;
14              
15             $SPEC{grep} = {
16             v => 1.1,
17             summary => 'A base for grep-like CLI utilities',
18             description => <<'MARKDOWN',
19              
20             This routine provides a base for grep-like CLI utilities. It accepts coderef as
21             source of lines, which in the actual utilities can be from files or other
22             sources. It provides common options like `-i`, `-v`, `-c`, color highlighting,
23             and so on.
24              
25             Examples of CLI utilities that are based on this: ,
26             (from ).
27              
28             Why? For grepping lines from files or stdin, is no match for the
29             standard grep (or its many alternatives): it's orders of magnitude slower and
30             currently has fewer options. But AppBase::Grep is a quick way to create
31             grep-like utilities that grep from a custom sources but have common/standard
32             grep features.
33              
34             Compared to the standard grep, AppBase::Grep also has these unique features:
35              
36             * `--all` option to match all patterns instead of just one;
37             * observe the `COLOR` environment variable to set `--color` default;
38              
39             MARKDOWN
40             args => {
41             pattern => {
42             summary => 'Specify *string* to search for',
43             schema => 'str*',
44             pos => 0,
45             },
46             regexps => {
47             summary => 'Specify additional *regexp pattern* to search for',
48             'x.name.is_plural' => 1,
49             'x.name.singular' => 'regexp',
50             schema => ['array*', of=>'str*'],
51             cmdline_aliases => {e=>{code=>sub { $_[0]{regexps} //= []; push @{$_[0]{regexps}}, $_[1] }}},
52             },
53              
54             ignore_case => {
55             summary => 'If set to true, will search case-insensitively',
56             schema => 'bool*',
57             cmdline_aliases => {i=>{}},
58             tags => ['category:matching-control'],
59             },
60             invert_match => {
61             summary => 'Invert the sense of matching',
62             schema => 'bool*',
63             cmdline_aliases => {v=>{}},
64             tags => ['category:matching-control'],
65             },
66             dash_prefix_inverts => { # not in grep
67             summary => 'When given pattern that starts with dash "-FOO", make it to mean "^(?!.*FOO)"',
68             schema => 'bool*',
69             description => <<'MARKDOWN',
70              
71             This is a convenient way to search for lines that do not match a pattern.
72             Instead of using `-v` to invert the meaning of all patterns, this option allows
73             you to invert individual pattern using the dash prefix, which is also used by
74             Google search and a few other search engines.
75              
76             MARKDOWN
77             tags => ['category:matching-control'],
78             },
79             all => { # not in grep
80             summary => 'Require all patterns to match, instead of just one',
81             schema => 'true*',
82             tags => ['category:matching-control'],
83             },
84             count => {
85             summary => 'Supress normal output; instead return a count of matching lines',
86             schema => 'true*',
87             cmdline_aliases => {c=>{}},
88             tags => ['category:general-output-control'],
89             },
90             files_with_matches => {
91             summary => 'Supress normal output; instead return filenames with matching lines; scanning for each file will stop on the first match',
92             schema => 'true*',
93             cmdline_aliases => {l=>{}},
94             tags => ['category:general-output-control'],
95             },
96             files_without_match => {
97             summary => 'Supress normal output; instead return filenames without matching lines',
98             schema => 'true*',
99             cmdline_aliases => {L=>{}},
100             tags => ['category:general-output-control'],
101             },
102             color => {
103             summary => 'Specify when to show color (never, always, or auto/when interactive)',
104             schema => ['str*', in=>[qw/never always auto/]],
105             default => 'auto',
106             tags => ['category:general-output-control'],
107             },
108             quiet => {
109             summary => 'Do not print matches, only return appropriate exit code',
110             schema => ['true*'],
111             cmdline_aliases => {silent=>{}, q=>{}},
112             tags => ['category:general-output-control'],
113             },
114              
115             line_number => {
116             summary => 'Show line number along with matches',
117             schema => ['true*'],
118             cmdline_aliases => {n=>{}},
119             tags => ['category:output-line-prefix-control'],
120             },
121             # XXX max_count
122             # word_regexp (-w) ?
123             # line_regexp (-x) ?
124             # --after-context (-A)
125             # --before-context (-B)
126             # --context (-C)
127              
128             _source => {
129             schema => 'code*',
130             tags => ['hidden'],
131             description => <<'MARKDOWN',
132              
133             Code to produce lines of text to grep form. Required.
134              
135             Will be called with these arguments:
136              
137             ($instruction*)
138              
139             where `$instruction` can be 1 to instruct the source to skip to the next "file"
140             (or source) before retrieving the next line.
141              
142             Should return the following:
143              
144             ($line, $label, $chomp)
145              
146             Where `$line` is the line (with newline ending, unless `$chomp` is true),
147             `$label` is source label (e.g. filename without line number if text source is
148             from files), and `$chomp` is boolean that can be set to true to indicate that
149             line is already chomped and should not be chomped again.
150              
151             MARKDOWN
152             },
153             _highlight_regexp => {
154             schema => 're*',
155             tags => ['hidden'],
156             description => <<'MARKDOWN',
157              
158             Regexp pattern to capture each pattern for highlighting. Optional.
159              
160             MARKDOWN
161             },
162             _filter_code => {
163             schema => 'code*',
164             tags => ['hidden'],
165             description => <<'MARKDOWN',
166              
167             Custom filtering. If set, then `pattern` and `regexps` arguments are not
168             required and lines of text will be filtered by this code. Used e.g. for grepping
169             custom stuffs, e.g. email address or URL from lines of text instead of plain
170             string or regexp patterns.
171              
172             Will be called for each line of text with these arguments:
173              
174             ($line, \%args, $ansi_highlight_seqe)
175              
176             where `$line` is the line of text and `%args` are the arguments given to the
177             `grep()` function.
178              
179             Should return either: 1) a simple scalar boolean value reflecting whether the
180             line matches, true when it does and false otherwise; 2) an arrayref containing
181             this information:
182              
183             [
184             $is_match, # bool, required
185             $highlighted_line, # str , optional
186             $number_of_matches, # bool, optional
187             ]
188              
189             MARKDOWN
190             },
191              
192             },
193             args_rels => {
194             'choose_one&' => [
195             [qw/quiet count files_with_matches files_without_match/],
196             [qw/invert_match files_with_matches/],
197             [qw/invert_match files_without_match/],
198             ],
199             },
200             };
201             sub grep {
202 0     0 1   require ColorThemeUtil::ANSI;
203 0           require Module::Load::Util;
204              
205 0           my %args = @_;
206              
207 0           my $opt_ci = $args{ignore_case};
208 0           my $opt_invert = $args{invert_match};
209 0           my $opt_count = $args{count};
210 0           my $opt_files_with_matches = $args{files_with_matches};
211 0           my $opt_files_without_match = $args{files_without_match};
212 0           my $opt_quiet = $args{quiet};
213 0           my $opt_linum = $args{line_number};
214              
215 0   0       my $ct = $ENV{APPBASE_GREP_COLOR_THEME} // 'Light';
216              
217 0           require Module::Load::Util;
218 0           my $ct_obj = Module::Load::Util::instantiate_class_with_optional_args(
219             {ns_prefixes=>['ColorTheme::Search','ColorTheme','']}, $ct);
220              
221 0           my (@str_patterns, @re_patterns);
222 0   0       for my $p ( grep {defined} $args{pattern}, @{ $args{regexps} // [] }) {
  0            
  0            
223 0 0 0       if ($args{dash_prefix_inverts} && $p =~ s/\A-//) {
224 0           $p = "^(?!.*$p)";
225             }
226 0           push @str_patterns, $p;
227 0 0         push @re_patterns , $opt_ci ? qr/$p/i : qr/$p/;
228             }
229 0 0 0       return [400, "Please specify at least one pattern"] unless $args{_filter_code} || @re_patterns;
230              
231 0   0       my $re_highlight = $args{_highlight_regexp} // join('|', @str_patterns);
232 0 0         $re_highlight = $opt_ci ? qr/$re_highlight/i : qr/$re_highlight/;
233              
234 0   0       my $color = $args{color} // 'auto';
235             my $use_color =
236             ($color eq 'always' ? 1 : $color eq 'never' ? 0 : undef) //
237             (defined $ENV{NO_COLOR} ? 0 : undef) //
238 0 0 0       ($ENV{COLOR} ? 1 : defined($ENV{COLOR}) ? 0 : undef) //
    0 0        
    0 0        
    0          
    0          
239             (-t STDOUT); ## no critic: InputOutput::ProhibitInteractiveTest
240              
241 0           my $source = $args{_source};
242              
243 0           my $logic = 'or';
244 0 0         $logic = 'and' if $args{all};
245              
246 0           my $num_matches = 0;
247 0           my ($line, $label, $linum, $chomp, $highlighted_line);
248              
249 0           my $ansi_highlight_seq = ColorThemeUtil::ANSI::item_color_to_ansi($ct_obj->get_item_color('highlight'));
250             my $code_print = sub {
251 0 0 0 0     if (defined $label && length $label) {
252 0 0         if ($use_color) {
253 0           print ColorThemeUtil::ANSI::item_color_to_ansi($ct_obj->get_item_color('location')) . $label . "\e[0m:"; # XXX separator color?
254             } else {
255 0           print $label, ":";
256             }
257             }
258              
259 0 0         if ($opt_linum) {
260 0 0         if ($use_color) {
261 0           print ColorThemeUtil::ANSI::item_color_to_ansi($ct_obj->get_item_color('location')) . $linum . "\e[0m:";
262             } else {
263 0           print $linum, ":";
264             }
265             }
266              
267 0 0         if ($use_color) {
268 0 0         if (defined $highlighted_line) {
269 0           print $highlighted_line;
270             } else {
271 0           $line =~ s/($re_highlight)/$ansi_highlight_seq$1\e[0m/g;
272 0           print $line;
273             }
274             } else {
275 0           print $line;
276             }
277 0 0         print "\n" if $chomp;
278 0           };
279              
280 0           my ($prevlabel, $is_file_match, $instruction, $has_print_files_without_match);
281             LINE:
282 0           while (1) {
283 0           ($line, $label, $chomp) = $source->($instruction);
284 0 0         last unless defined $line;
285 0           undef $instruction;
286              
287 0 0         chomp($line) if $chomp;
288              
289 0   0       $label //= '';
290              
291 0 0         if (!defined $prevlabel) {
292 0           $prevlabel = $label;
293 0           $linum = 1;
294             } else {
295 0 0         if ($label ne $prevlabel) {
296 0 0 0       if ($opt_files_without_match && !$is_file_match) {
297 0           print $label, "\n";
298             }
299 0           undef $is_file_match;
300 0           $prevlabel = $label;
301 0           $linum = 1;
302             } else {
303 0           $linum++;
304             }
305             }
306              
307 0           my $is_line_match;
308 0 0         if ($args{_filter_code}) {
    0          
309 0           my $res = $args{_filter_code}->($line, \%args, $ansi_highlight_seq);
310             #log_trace "Result from _filter_code: %s", $res;
311 0           my $ref = ref $res;
312 0 0         if (!$ref) {
313 0           $is_line_match = $res;
314             } else {
315 0 0         die "BUG: _filter_code must return an arrayref" unless $ref eq 'ARRAY';
316 0           ($is_line_match, $highlighted_line, undef) = @$res; # num_of_matches still unused
317             }
318             } elsif ($logic eq 'or') {
319 0           $is_line_match = 0;
320 0           for my $re (@re_patterns) {
321 0 0         if ($line =~ $re) {
322 0           $is_line_match = 1;
323 0           last;
324             }
325             }
326             } else {
327 0           $is_line_match = 1;
328 0           for my $re (@re_patterns) {
329 0 0         unless ($line =~ $re) {
330 0           $is_line_match = 0;
331 0           last;
332             }
333             }
334             }
335              
336 0 0         if ($is_line_match) {
337 0           $is_file_match = 1;
338 0 0         if ($opt_files_with_matches) {
339 0           print $label, "\n";
340 0           $instruction = 1;
341 0           next LINE;
342             }
343              
344 0 0         next if $opt_invert;
345 0 0 0       if ($opt_quiet || $opt_count) {
    0          
346 0           $num_matches++;
347             } elsif (!$opt_files_without_match) {
348 0           $code_print->();
349             }
350             } else {
351 0 0         next unless $opt_invert;
352 0 0 0       if ($opt_quiet || $opt_count) {
353 0           $num_matches++;
354             } else {
355 0           $code_print->();
356             }
357             }
358             }
359              
360 0 0 0       if ($opt_files_without_match && !$is_file_match) {
361 0           print $prevlabel, "\n";
362             }
363              
364             return [
365 0 0         200,
    0          
366             "OK",
367             $opt_count ? $num_matches : "",
368             {"cmdline.exit_code"=>$num_matches ? 0:1},
369             ];
370             }
371              
372             1;
373             # ABSTRACT: A base for grep-like CLI utilities
374              
375             __END__