t: ensure failure if field gets fed an invalid regexp value
[barcat.git] / barcat
1 #!/usr/bin/perl -CA
2 use 5.014;
3 use warnings;
4 use utf8;
5 use List::Util qw( min max sum );
6 use open qw( :std :utf8 );
7 use re '/msx';
8
9 our $VERSION = '1.07';
10
11 use Getopt::Long '2.33', qw( :config gnu_getopt );
12 my %opt;
13 GetOptions(\%opt,
14         'ascii|a!',
15         'color|c!',
16         'C' => sub { $opt{color} = 0 },
17         'field|f=s' => sub {
18                 eval {
19                         local $_ = $_[1];
20                         $opt{anchor} = /\A[0-9]+\z/ ? qr/(?:\S*\h+){$_}\K/ : qr/$_/;
21                 } or die $@ =~ s/(?:\ at\ \N+)?\Z/ for option $_[0]/r;
22         },
23         'human-readable|H!',
24         'interval|t:i',
25         'trim|length|l=s' => sub {
26                 my ($optname, $optval) = @_;
27                 $optval =~ s/%$// and $opt{trimpct}++;
28                 $optval =~ m/\A-?[0-9]+\z/ or die(
29                         "Value \"$optval\" invalid for option $optname",
30                         " (number or percentage expected)\n"
31                 );
32                 $opt{trim} = $optval;
33         },
34         'value-length=i',
35         'hidemin=i',
36         'hidemax=i',
37         'minval=f',
38         'maxval=f',
39         'limit|L:s' => sub {
40                 my ($optname, $optval) = @_;
41                 $optval ||= 0;
42                 $optval =~ /\A-[0-9]+\z/ and $optval .= '-';  # tail shorthand
43                 ($opt{hidemin}, $opt{hidemax}) =
44                 $optval =~ m/\A (?: (-? [0-9]+)? - )? ([0-9]+)? \z/ or die(
45                         "Value \"$optval\" invalid for option limit",
46                         " (range expected)\n"
47                 );
48         },
49         'header!',
50         'markers|m=s',
51         'graph-format=s' => sub {
52                 $opt{'graph-format'} = substr $_[1], 0, 1;
53         },
54         'spark:s' => sub {
55                 $opt{spark} = [split //,
56                         $_[1] || ($opt{ascii} ? ' ..oOO' : ' ▁▂▃▄▅▆▇█')
57                 ];
58         },
59         'palette=s' => sub {
60                 $opt{palette} = {
61                         fire   => [qw( 90 31 91 33 93 97 96 )],
62                         fire88 => [map {"38;5;$_"} qw(
63                                 80  32 48 64  68 72 76  77 78 79  47
64                         )],
65                         fire256=> [map {"38;5;$_"} qw(
66                                 235  52 88 124 160 196
67                                 202 208 214 220 226  227 228 229 230 231  159
68                         )],
69                         ramp88 => [map {"38;5;$_"} qw(
70                                 64 65 66 67 51 35 39 23 22 26 25 28
71                         )],
72                         whites => [qw( 1;30 0;37 1;37 )],
73                         greys  => [map {"38;5;$_"} 52, 235..255, 47],
74                 }->{$_[1]} // [ split /[^0-9;]/, $_[1] ];
75         },
76         'stat|s!',
77         'signal-stat=s',
78         'unmodified|u!',
79         'width|w=i',
80         'version' => sub {
81                 say "barcat version $VERSION";
82                 exit;
83         },
84         'usage|h' => sub {
85                 local $/ = undef;  # slurp
86                 my $pod = readline *DATA;
87                 $pod =~ s/^=over\K/ 25/;  # indent options list
88                 $pod =~ s{
89                         ^=item \h \N*\n\n \N*\n \K  # first line
90                         (?: (?: ^=over .*? ^=back\n )? (?!=) \N*\n )*
91                 }{\n}g;  # abbreviate options
92                 $pod =~ s/[.,](?=\n)//g;  # trailing punctuation
93                 $pod =~ s/^=item\ \K(?=--)/____/g;  # align long options
94                 # abbreviate <variable> indicators
95                 $pod =~ s/\Q>.../s>/g;
96                 $pod =~ s/<(?:number|count|seconds)>/N/g;
97                 $pod =~ s/<character(s?)>/\Uchar$1/g;
98                 $pod =~ s/\Q | /|/g;
99                 $pod =~ s/(?<!\w)<([a-z]+)>/\U$1/g;  # uppercase
100
101                 require Pod::Usage;
102                 my $parser = Pod::Usage->new(USAGE_OPTIONS => {
103                         -indent => 2, -width => 78,
104                 });
105                 $parser->select('SYNOPSIS', 'OPTIONS');
106                 $parser->output_string(\my $contents);
107                 $parser->parse_string_document($pod);
108
109                 $contents =~ s/\n(?=\n\h)//msg;  # strip space between items
110                 $contents =~ s/^\ \ \K____/    /g;  # nbsp substitute
111                 print $contents;
112                 exit;
113         },
114         'help|?'  => sub {
115                 require Pod::Usage;
116                 Pod::Usage::pod2usage(
117                         -exitval => 0, -perldocopt => '-oman', -verbose => 2,
118                 );
119         },
120 ) or exit 64;  # EX_USAGE
121
122 $opt{width} ||= $ENV{COLUMNS} || qx(tput cols) || 80 unless $opt{spark};
123 $opt{color} //= -t *STDOUT;  # enable on tty
124 $opt{'graph-format'} //= '-';
125 $opt{trim}   *= $opt{width} / 100 if $opt{trimpct};
126 $opt{units}   = [split //, ' kMGTPEZYyzafpn'.($opt{ascii} ? 'u' : 'μ').'m']
127         if $opt{'human-readable'};
128 $opt{anchor} //= qr/\A/;
129 $opt{'value-length'} = 6 if $opt{units};
130 $opt{'value-length'} = 1 if $opt{unmodified};
131 $opt{'signal-stat'} //= exists $SIG{INFO} ? 'INFO' : 'QUIT';
132 $opt{markers} //= '=avg >31.73v <68.27v +50v |0';
133 $opt{palette} //= $opt{color} && [31, 90, 32];
134 $opt{hidemin} = ($opt{hidemin} || 1) - 1;
135 $opt{input} = (@ARGV && $ARGV[0] =~ m/\A[-0-9]/) ? \@ARGV : undef
136         and undef $opt{interval};
137
138 $opt{'sum-format'} = sub { sprintf '%.8g', $_[0] };
139 $opt{'calc-format'} = sub { sprintf '%*.*f', 0, 2, $_[0] };
140 $opt{'value-format'} = $opt{units} && sub {
141         my $unit = int(log(abs $_[0] || 1) / log(10) - 3*($_[0] < 1) + 1e-15);
142         my $float = $_[0] !~ /^0*[-0-9]{1,3}$/;
143         sprintf('%3.*f%1s',
144                 $float && ($unit % 3) == ($unit < 0),  # tenths
145                 $_[0] / 1000 ** int($unit/3),   # number
146                 $#{$opt{units}} * 1.5 < abs $unit ? "e$unit" : $opt{units}->[$unit/3]
147         );
148 };
149
150
151 my (@lines, @values, @order);
152
153 $SIG{$_} = \&show_stat for $opt{'signal-stat'} || ();
154 $SIG{ALRM} = sub {
155         show_lines();
156         alarm $opt{interval} if defined $opt{interval} and $opt{interval} > 0;
157 };
158 $SIG{INT} = \&show_exit;
159
160 if (defined $opt{interval}) {
161         $opt{interval} ||= 1;
162         alarm $opt{interval} if $opt{interval} > 0;
163
164         eval {
165                 require Tie::Array::Sorted;
166                 tie @order, 'Tie::Array::Sorted', sub { $_[1] <=> $_[0] };
167         } or warn $@, "Expect slowdown with large datasets!\n";
168 }
169
170 my $valmatch = qr<
171         $opt{anchor} ( \h* -? [0-9]* [.]? [0-9]+ (?: e[+-]?[0-9]+ )? |)
172 >x;
173 while (defined ($_ = $opt{input} ? shift @{ $opt{input} } : readline)) {
174         s/\r?\n\z//;
175         s/\A\h*// unless $opt{unmodified};
176         my $valnum = s/$valmatch/\n/ && $1;
177         push @values, $valnum;
178         push @order, $valnum if length $valnum;
179         if (defined $opt{trim} and defined $valnum) {
180                 my $trimpos = abs $opt{trim};
181                 $trimpos -= length $valnum if $opt{unmodified};
182                 if ($trimpos <= 1) {
183                         $_ = substr $_, 0, 2;
184                 }
185                 elsif (length > $trimpos) {
186                         # cut and replace (intentional lvalue for speed, contrary to PBP)
187                         substr($_, $trimpos - 1) = $opt{ascii} ? '>' : '…';
188                 }
189         }
190         push @lines, $_;
191         show_lines() if defined $opt{interval} and $opt{interval} < 0
192                 and $. % $opt{interval} == 0;
193 }
194
195 if ($opt{'zero-missing'}) {
196         push @values, (0) x 10;
197 }
198
199 $SIG{INT} = 'DEFAULT';
200
201 sub color {
202         $opt{color} and defined $_[0] or return '';
203         return "\e[$_[0]m" if defined wantarray;
204         $_ = color(@_) . $_ . color(0) if defined;
205 }
206
207 sub show_lines {
208
209 state $nr =
210         $opt{hidemin} < 0 ? @lines + $opt{hidemin} + 1 :
211         $opt{hidemin};
212 @lines > $nr or return;
213
214 my $limit = $#lines;
215 if (defined $opt{hidemax}) {
216         if ($opt{hidemin} and $opt{hidemin} < 0) {
217                 $limit -= $opt{hidemax} - 1;
218         }
219         else {
220                 $limit = $opt{hidemax} - 1;
221         }
222 }
223
224 @order = sort { $b <=> $a } @order unless tied @order;
225 my $maxval = $opt{maxval} // (
226         $opt{hidemax} ? max grep { length } @values[$nr .. $limit] :
227         $order[0]
228 ) // 0;
229 my $minval = $opt{minval} // min $order[-1] // (), 0;
230 my $range = $maxval - $minval;
231 my $lenval = $opt{'value-length'} // max map { length } @order;
232 my $len    = defined $opt{trim} && $opt{trim} <= 0 ? -$opt{trim} + 1 :
233         max map { length $values[$_] && length $lines[$_] }
234                 0 .. min $#lines, $opt{hidemax} || ();  # left padding
235 my $size   = defined $opt{width} && $range &&
236         ($opt{width} - $lenval - $len) / $range;  # bar multiplication
237
238 my @barmark;
239 if ($opt{markers} and $size > 0) {
240         for my $markspec (split /\h/, $opt{markers}) {
241                 my ($char, $func) = split //, $markspec, 2;
242                 my $pos = eval {
243                         if ($func eq 'avg') {
244                                 return sum(@order) / @order;
245                         }
246                         elsif ($func =~ /\A([0-9.]+)v\z/) {
247                                 die "Invalid marker $char: percentile $1 out of bounds\n" if $1 > 100;
248                                 my $index = $#order * $1 / 100;
249                                 return ($order[$index] + $order[$index + .5]) / 2;
250                         }
251                         elsif ($func =~ /\A-?[0-9.]+\z/) {
252                                 return $func;
253                         }
254                         else {
255                                 die "Unknown marker $char: $func\n";
256                         }
257                 };
258                 defined $pos or do {
259                         warn $@ if $@;
260                         next;
261                 };
262                 $pos -= $minval;
263                 $pos >= 0 or next;
264                 color(36) for $barmark[$pos * $size] = $char;
265         }
266
267         state $lastmax = $maxval;
268         if ($maxval > $lastmax) {
269                 print ' ' x ($lenval + $len);
270                 printf color(90);
271                 printf '%-*s',
272                         ($lastmax - $minval) * $size + .5,
273                         '-' x (($values[$nr - 1] - $minval) * $size);
274                 print color(92);
275                 say '+' x (($range - $lastmax) * $size + .5);
276                 print color(0);
277                 $lastmax = $maxval;
278         }
279 }
280
281 say(
282         color(31), sprintf('%*s', $lenval, $minval),
283         color(90), '-', color(36), '+',
284         color(32), sprintf('%*s', $size * $range - 3, $maxval),
285         color(90), '-', color(36), '+',
286         color(0),
287 ) if $opt{header};
288
289 while ($nr <= $limit) {
290         my $val = $values[$nr];
291         my $rel = length $val && $range && ($val - $minval) / $range;
292         my $color = !length $val || !$opt{palette} ? undef :
293                 $val == $order[0] ? $opt{palette}->[-1] : # max
294                 $val == $order[-1] ? $opt{palette}->[0] : # min
295                 $opt{palette}->[ $rel * ($#{$opt{palette}} - 1) + 1 ];
296
297         if ($opt{spark}) {
298                 say '' if $opt{width} and $nr and $nr % $opt{width} == 0;
299                 print color($color), $opt{spark}->[
300                         !$val || !$#{$opt{spark}} ? 0 : # blank
301                         $val == $order[0] ? -1 : # max
302                         $val == $order[-1] ? 1 : # min
303                         $#{$opt{spark}} < 3 ? 1 :
304                         $rel * ($#{$opt{spark}} - 3) + 2.5
305                 ];
306                 next;
307         }
308
309         if (length $val) {
310                 $val = $opt{'value-format'} ? $opt{'value-format'}->($val) :
311                         sprintf "%*s", $lenval, $val;
312                 color($color) for $val;
313         }
314         my $line = $lines[$nr] =~ s/\n/$val/r;
315         printf '%-*s', $len + length($val), $line;
316         print $barmark[$_] // $opt{'graph-format'}
317                 for 1 .. $size && (($values[$nr] || 0) - $minval) * $size + .5;
318         say '';
319 }
320 continue {
321         $nr++;
322 }
323 say $opt{palette} ? color(0) : '' if $opt{spark};
324
325         return $nr;
326 }
327
328 sub show_stat {
329         if ($opt{hidemin} or $opt{hidemax}) {
330                 my $linemin = $opt{hidemin};
331                 my $linemax = ($opt{hidemax} || @lines) - 1;
332                 if ($linemin < 0) {
333                         $linemin += @lines;
334                         $linemax = @lines - $linemax;
335                 }
336                 printf '%.8g of ', $opt{'sum-format'}->(
337                         sum(grep {length} @values[$linemin .. $linemax]) // 0
338                 );
339         }
340         if (@order) {
341                 my $total = sum @order;
342                 printf '%s total', color(1) . $opt{'sum-format'}->($total) . color(0);
343                 printf ' in %d values', scalar @order;
344                 printf ' over %d lines', scalar @lines if @order != @lines;
345                 printf(' (%s min, %s avg, %s max)',
346                         color(31) . ($opt{'value-format'} || sub {$_[0]})->($order[-1]) . color(0),
347                         color(36) . ($opt{'value-format'} || $opt{'calc-format'})->($total / @order) . color(0),
348                         color(32) . ($opt{'value-format'} || sub {$_[0]})->($order[0]) . color(0),
349                 );
350         }
351         say '';
352         return 1;
353 }
354
355 sub show_exit {
356         show_lines();
357         show_stat() if $opt{stat};
358         exit 130 if @_;  # 0x80+signo
359         exit;
360 }
361
362 show_exit();
363
364 __END__
365 =encoding utf8
366
367 =head1 NAME
368
369 barcat - graph to visualize input values
370
371 =head1 SYNOPSIS
372
373 B<barcat> [<options>] [<file>... | <numbers>]
374
375 =head1 DESCRIPTION
376
377 Visualizes relative sizes of values read from input
378 (parameters, file(s) or STDIN).
379 Contents are concatenated similar to I<cat>,
380 but numbers are reformatted and a bar graph is appended to each line.
381
382 Don't worry, barcat does not drink and divide.
383 It can has various options for input and output (re)formatting,
384 but remains limited to one-dimensional charts.
385 For more complex graphing needs
386 you'll need a larger animal like I<gnuplot>.
387
388 =head1 OPTIONS
389
390 =over
391
392 =item -a, --[no-]ascii
393
394 Restrict user interface to ASCII characters,
395 replacing default UTF-8 by their closest approximation.
396 Input is always interpreted as UTF-8 and shown as is.
397
398 =item -c, --[no-]color
399
400 Force colored output of values and bar markers.
401 Defaults on if output is a tty,
402 disabled otherwise such as when piped or redirected.
403
404 =item -f, --field=(<number> | <regexp>)
405
406 Compare values after a given number of whitespace separators,
407 or matching a regular expression.
408
409 Unspecified or I<-f0> means values are at the start of each line.
410 With I<-f1> the second word is taken instead.
411 A string can indicate the starting position of a value
412 (such as I<-f:> if preceded by colons),
413 or capture the numbers itself,
414 for example I<-f'(\d+)'> for the first digits anywhere.
415
416 =item --header
417
418 Prepend a chart axis with minimum and maximum values labeled.
419
420 =item -H, --human-readable
421
422 Format values using SI unit prefixes,
423 turning long numbers like I<12356789> into I<12.4M>.
424 Also changes an exponent I<1.602176634e-19> to I<160.2z>.
425 Short integers are aligned but kept without decimal point.
426
427 =item -t, --interval[=(<seconds> | -<lines>)]
428
429 Output partial progress every given number of seconds or input lines.
430 An update can also be forced by sending a I<SIGALRM> alarm signal.
431
432 =item -l, --length=[-]<size>[%]
433
434 Trim line contents (between number and bars)
435 to a maximum number of characters.
436 The exceeding part is replaced by an abbreviation sign,
437 unless C<--length=0>.
438
439 Prepend a dash (i.e. make negative) to enforce padding
440 regardless of encountered contents.
441
442 =item -L, --limit[=(<count> | -<last> | <start>-[<end>])]
443
444 Stop output after a number of lines.
445 A single value indicates the last line number (like C<head>),
446 or first line counting from the bottom if negative (like C<tail>).
447 A specific range can be given by two values.
448
449 All input is still counted and analyzed for statistics,
450 but disregarded for padding and bar size.
451
452 =item --graph-format=<character>
453
454 Glyph to repeat for the graph line.
455 Defaults to a dash C<->.
456
457 =item -m, --markers=<format>
458
459 Statistical positions to indicate on bars.
460 A single indicator glyph precedes each position:
461
462 =over 2
463
464 =item <number>
465
466 Exact value to match on the axis.
467 A vertical bar at the zero crossing is displayed by I<|0>
468 for negative values.
469 For example I<:3.14> would show a colon at pi.
470
471 =item <percentage>I<v>
472
473 Ranked value at the given percentile.
474 The default shows I<+> at I<50v> for the mean or median;
475 the middle value or average between middle values.
476 One standard deviation right of the mean is at about I<68.3v>.
477 The default includes I<< >31.73v <68.27v >>
478 to encompass all I<normal> results, or 68% of all entries, by B<< <--> >>.
479
480 =item I<avg>
481
482 Matches the average;
483 the sum of all values divided by the number of counted lines.
484 Indicated by default as I<=>.
485
486 =back
487
488 =item --min=<number>, --max=<number>
489
490 Bars extend from 0 or the minimum value if lower,
491 to the largest value encountered.
492 These options can be set to customize this range.
493
494 =item --palette=(<preset> | <color>...)
495
496 Override colors of parsed numbers.
497 Can be any CSI escape, such as I<90> for default dark grey,
498 or alternatively I<1;30> for bright black.
499
500 In case of additional colors,
501 the last is used for values equal to the maximum, the first for minima.
502 If unspecified, these are green and red respectively (I<31 90 32>).
503 Multiple intermediate colors will be distributed
504 relative to the size of values.
505
506 Predefined color schemes are named I<whites> and I<fire>,
507 or I<greys> and I<fire256> for 256-color variants.
508
509 =item --spark[=<characters>]
510
511 Replace lines by I<sparklines>,
512 single characters corresponding to input values.
513 A specified sequence of unicode characters will be used for
514 Of a specified sequence of unicode characters,
515 the first one will be used for non-values,
516 the last one for the maximum,
517 the second (if any) for the minimum,
518 and any remaining will be distributed over the range of values.
519 Unspecified, block fill glyphs U+2581-2588 will be used.
520
521 =item -s, --stat
522
523 Total statistics after all data.
524
525 =item -u, --unmodified
526
527 Do not reformat values, keeping leading whitespace.
528 Keep original value alignment, which may be significant in some programs.
529
530 =item --value-length=<size>
531
532 Reserved space for numbers.
533
534 =item -w, --width=<columns>
535
536 Override the maximum number of columns to use.
537 Appended graphics will extend to fill up the entire screen.
538
539 =item -h, --usage
540
541 Overview of available options.
542
543 =item --help
544
545 Full documentation
546 rendered by perldoc.
547
548 =item --version
549
550 Version information.
551
552 =back
553
554 =head1 EXAMPLES
555
556 Draw a sine wave:
557
558     seq 30 | awk '{print sin($1/10)}' | barcat
559
560 Compare file sizes (with human-readable numbers):
561
562     du -d0 -b * | barcat -H
563
564 Memory usage of user processes with long names truncated:
565
566     ps xo %mem,pid,cmd | barcat -l40
567
568 Monitor network latency from prefixed results:
569
570     ping google.com | barcat -f'time=\K' -t
571
572 Commonly used after counting, for example users on the current server:
573
574     users | tr ' ' '\n' | sort | uniq -c | barcat
575
576 Letter frequencies in text files:
577
578     cat /usr/share/games/fortunes/*.u8 |
579     perl -CS -nE 'say for grep length, split /\PL*/, uc' |
580     sort | uniq -c | barcat
581
582 Number of HTTP requests per day:
583
584     cat log/access.log | cut -d\  -f4 | cut -d: -f1 | uniq -c | barcat
585
586 Any kind of database query with counts, preserving returned alignment:
587
588     echo 'SELECT count(*),schemaname FROM pg_tables GROUP BY 2' |
589     psql -t | barcat -u
590
591 In PostgreSQL from within the client:
592
593         postgres=> SELECT sin(generate_series(0, 3, .1)) \g |barcat
594
595 Earthquakes worldwide magnitude 1+ in the last 24 hours:
596
597     curl https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/1.0_day.csv |
598     column -tns, | barcat -f4 -u -l80%
599
600 External datasets, like movies per year:
601
602     curl https://github.com/prust/wikipedia-movie-data/raw/master/movies.json -L |
603     perl -054 -nlE 'say if s/^"year"://' | uniq -c | barcat
604
605 But please get I<jq> to process JSON
606 and replace the manual selection by C<< jq '.[].year' >>.
607
608 Pokémon height comparison:
609
610     curl https://github.com/Biuni/PokemonGO-Pokedex/raw/master/pokedex.json -L |
611     jq -r '.pokemon[] | [.height,.num,.name] | join(" ")' | barcat
612
613 USD/EUR exchange rate from CSV provided by the ECB:
614
615     curl https://sdw.ecb.europa.eu/export.do \
616          -Gd 'node=SEARCHRESULTS&q=EXR.D.USD.EUR.SP00.A&exportType=csv' |
617     grep '^[12]' | barcat -f',\K' --value-length=7
618
619 Total population history in XML from the World Bank:
620
621     curl http://api.worldbank.org/v2/country/1W/indicator/SP.POP.TOTL -L |
622     xmllint --xpath '//*[local-name()="date" or local-name()="value"]' - |
623     sed -r 's,</wb:value>,\n,g; s,(<[^>]+>)+, ,g' | barcat -f1 -H
624
625 And of course various Git statistics, such commit count by year:
626
627     git log --pretty=%ci | cut -b-4 | uniq -c | barcat
628
629 Or the top 3 most frequent authors with statistics over all:
630
631     git shortlog -sn | barcat -L3 -s
632
633 Sparkline graphics of simple input given as inline parameters:
634
635         barcat --spark= 3 1 4 1 5 0 9 2 4
636
637 Activity graph of the last days (substitute date C<-v-{}d> on BSD):
638
639     ( git log --pretty=%ci --since=30day | cut -b-10
640       seq 0 30 | xargs -i date +%F -d-{}day ) |
641     sort | uniq -c | awk '$1--' | barcat --spark
642
643 =head1 AUTHOR
644
645 Mischa POSLAWSKY <perl@shiar.org>
646
647 =head1 LICENSE
648
649 GPL3+.