From Code to Community: Sponsoring The Perl and Raku Conference 2025 Learn more

use 5.024;
use utf8;
our $VERSION = "0.9909";
=encoding utf-8
=head1 NAME
App::Greple::annotate - greple module for generic annotation
=head1 SYNOPSIS
B<greple> B<-Mannotate> [ I<module option> ] -- [ I<command option> ] ...
=head1 VERSION
Version 0.9909
=head1 DESCRIPTION
C<App::Greple::annotate> module is made for C<App::Greple::charcode>
to display annotation for each matched text in the following style.
$ greple -Mcharcode '\P{ASCII}' charcode.pm
┌─── 12 \x{fe0e} \N{VARIATION SELECTOR-15}
│ ┌─ 14 \x{a9} \N{COPYRIGHT SIGN}
│ ├─ 14 \x{fe0e} \N{VARIATION SELECTOR-15}
Copyright︎ ©︎ 2025 Kazumasa Utashiro.
=for html <p>
</p>
=head1 COMMAND OPTIONS
=over 7
=item B<--annotate>, B<--no-annotate>
Print annotation or not. Enabled by default, so use C<--no-annotate>
to disable it.
=item B<--annotate::config>=I<params>
Set configuration paarameters.
=item B<-->[B<no->]B<align>
Align annotation or not.
Default true.
=item B<--align-all>
Align to the same column for all lines
=item B<--align-side>
Align to the longest line length, regardless of match position.
=item B<--alignto>=I<column>
Align to I<column> position.
=back
=head1 MODULE OPTIONS and PARAMS
=over 7
=item B<--config>=I<params>
Set configuration parameters.
=item B<--alignto>=I<column>
=item B<config>(B<alignto>=I<column>)
Align annotation messages. Defaults to C<1>, which aligns to the
rightmost column; C<0> means no align; if a value of C<2> or greater
is given, it aligns to that numbered column.
I<column> can be negative; if C<-1> is specified, align to the same
column for all lines. If C<-2> is specified, align to the longest
line length, regardless of match position.
=item B<--split>[=I<#>]
=item config(B<split>=[I<#>])
Defaults to C<0>. Use C<--split> or C<--split=1> to enable it. If a
pattern matching multiple characters is given, annotate each character
individually.
=back
=head1 VARIABLES
=over 7
=item B<$App::Greple::annotate::ANNOTATE>
Hold function reference to produce annotation text. Default function
is declared as this:
our $ANNOTATE //= sub {
my %param = @_;
my($column, $str) = @param{qw(column match)};
sprintf("%3d %s", $column, $str);
};
Parameter is passed by C<column> and C<match> labeled list.
=back
=head1 CONFIGURATION
Configuration parameters can be set in several ways.
=head2 MODULE START FUNCTION
The start function of a module can be specified at the same time as
the module declaration.
greple -Mannotate::config(alignto=0)
greple -Mannotate::config=alignto=80
=head2 PRIVATE MODULE OPTION
Module-specific options are specified between C<-Mannotate> and C<-->.
greple -Mannotate --config alignto=80 -- ...
greple -Mannotate --alignto=80 -- ...
=head2 GENERIC MODULE OPTION
Module-specific C<---config> option can be called by normal command
line option C<--annotate::config>.
greple -Mannotate --annotate::config alignto=80 ...
=head1 INSTALL
cpanm -n B<App::Greple::annotate>
=head1 SEE ALSO
L<App::Greple>
L<App::Greple::charcode>
=head1 LICENSE
Copyright︎ ©︎ 2025 Kazumasa Utashiro.
This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.
=head1 AUTHOR
Kazumasa Utashiro
=cut
use Hash::Util qw(lock_keys);
use List::Util qw(max);
our $config = Getopt::EX::Config->new(
annotate => \(our $opt_annotate = 1),
alignto => 1,
split => 0,
);
lock_keys %{$config};
my %type = ( '*' => ':1' );
sub optspec { $_[0] . ( $type{$_[0]} // $type{'*'} // '' ) }
sub finalize {
our($mod, $argv) = @_;
$config->deal_with($argv,
map(optspec($_), keys %{$config}));
}
use Text::ANSI::Fold::Util qw(ansi_width);
Text::ANSI::Fold->configure(expand => 1);
*vwidth = \&ansi_width;
package # no_index
Local::Annon {
use strict;
use warnings;
sub new {
my $class = shift;
@_ == 3 or die;
bless [ @_ ], $class;
}
sub start { shift->[0] }
sub end { shift->[1] }
sub annon :lvalue { shift->[2] }
}
package # no_index
Local::Annon::List {
use strict;
use warnings;
sub new {
my $class = shift;
bless {
Annotation => [],
Count => [],
}, $class;
}
sub annotation { $_[0]->{Annotation} }
sub count { $_[0]->{Count} }
sub push {
my $obj = CORE::shift;
push @{$obj->annotation}, @_;
push @{$obj->count}, int @_;
}
sub append {
my $obj = CORE::shift;
CORE::push @{$obj->annotation}, @_;
$obj->count->[-1] += int @_;
}
sub shift {
my $obj = CORE::shift;
my $count = CORE::shift @{$obj->count} or return ();
splice @{$obj->annotation}, 0, $count;
}
sub join {
my $obj = CORE::shift;
for (@_) {
CORE::push @{$obj->annotation}, @{$_->annotation};
CORE::push @{$obj->count}, @{$_->count};
}
}
sub total {
my $obj = CORE::shift;
List::Util::sum(@{$obj->count}) // 0;
}
sub last {
my $obj = CORE::shift;
$obj->annotation->[-1];
}
sub maxpos {
my $obj = CORE::shift;
List::Util::max map { $_->end - 1 } @{$obj->annotation};
}
}
sub code {
state $format = [ qw(\x{%02x} \x{%04x}) ];
my $ord = ord($_[0]);
sprintf($format->[$ord > 0xff], $ord);
}
my %cmap = (
"\t" => '\t',
"\n" => '\n',
"\r" => '\r',
"\f" => '\f',
"\b" => '\b',
"\a" => '\a',
"\e" => '\e',
);
sub control {
local $_ = @_ ? shift : $_;
if (s/\A([\t\n\r\f\b\a\e])/$cmap{$1}/e) {
$_;
} elsif (s/\A([\x00-\x1f])/sprintf "\\c%c", ord($1)+0x40/e) {
$_;
} else {
code($_);
}
}
sub visible {
local $_ = @_ ? shift : $_;
s{([^\pL\pN\pP\pS])}{control($1)}ger;
}
our $ANNOTATE //= sub {
my %param = @_;
my($column, $str) = @param{qw(column match)};
sprintf("%3d %s", $column, visible($str));
};
my $annotation;
sub prepare {
config('annotate') or return;
state $target;
if (defined $target and \$_ == $target) {
return;
} else {
$target = \$_;
}
$annotation = Local::Annon::List->new;
goto &_prepare;
}
sub _prepare {
my $grep = shift;
my @result = $grep->result or return;
for my $r (@result) {
my($b, @match) = @$r;
my @slice = $grep->slice_result($r);
my $start = 0;
my $progress = '';
my $indent = '';
my $current = Local::Annon::List->new;
while (my($i, $slice) = each @slice) {
if ($slice eq '') {
$current->push() if $i % 2;
next;
}
my $end = vwidth($progress . $slice);
my $gap = $end - $start;
my $indent_mark = '';
if ($i % 2) {
my $match = $match[$i / 2];
$indent_mark = '│';
my $head = '┌';
if ($gap == 0) {
if ($start == 0) {
$head = '╾';
$indent_mark = '';
} elsif ($current->total > 0 and $current->last->end == $start) {
$head = '├';
$start = $current->last->start;
substr($indent, $start) = '';
} elsif ($start > 0) {
$start = vwidth($progress =~ s/\X\z//r);
substr($indent, $start) = '';
}
}
my $marker = sub {
my($head, $match) = @_;
sprintf("%s%s%s\N{NBSP}%s",
$indent, $head, '─',
$ANNOTATE->(column => $start, match => $match));
};
$current->push( do {
if ($config->{split}) {
map {
my $out = $marker->($head, $_);
$head = '├';
Local::Annon->new($start, $end, $out);
} $slice =~ /./sg;
} else {
Local::Annon->new($start, $end, $marker->($head, $slice));
}
} );
}
$indent .= sprintf("%-*s", $end - $start, $indent_mark);
$progress .= $slice;
$start = $end;
}
@{$current->count} == 0 and next;
my $alignto = $config->{alignto};
if ($alignto > 0 and $current->total > 0) {
alignto($current, $alignto > 1 ? $alignto : $current->last->[0]);
}
$annotation->join($current);
}
if ($config->{alignto} == -1) {
alignto($annotation, $annotation->maxpos);
}
elsif ($config->{alignto} == -2) {
if (my $maxlen = max map { vwidth($grep->cut($_->[0]->@*)) } @result) {
alignto($annotation, $maxlen - 1);
}
}
}
sub alignto {
my($list, $pos) = @_;
for (@{$list->annotation}) {
if ((my $extend = $pos - $_->[0]) > 0) {
$_->annon =~ s/(?=([─]))/$1 x $extend/e;
}
}
}
sub annotate {
config('annotate') or return;
if (my @annon = $annotation->shift) {
say $_->annon for @annon;
}
undef;
}
1;
__DATA__
builtin annotate! $opt_annotate
option default \
--postgrep '&__PACKAGE__::prepare' \
--callback '&__PACKAGE__::annotate'
option --annotate::config \
--prologue &__PACKAGE__::config($<shift>)
option --config --annotate::config
option --split --annotate::config split=1
option --alignto --annotate::config alignto=$<shift>
option --align --annotate::config alignto=1
option --no-align --annotate::config alignto=0
option --align-all --annotate::config alignto=-1
option --align-side --annotate::config alignto=-2