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

## no critic: Subroutines::ProhibitSubroutinePrototypes
use 5.010001;
use strict;
#IFUNBUILT
# use warnings;
#END IFUNBUILT
use Exporter qw(import);
use List::Util qw(first max);
our $AUTHORITY = 'cpan:PERLANCAR'; # AUTHORITY
our $DATE = '2023-07-29'; # DATE
our $DIST = 'Perinci-Result-Format-Lite'; # DIST
our $VERSION = '0.288'; # VERSION
our @EXPORT_OK = qw(format);
# copy-pasted from List::MoreUtils::PP
sub firstidx (&@) {
my $f = shift;
foreach my $i ( 0 .. $#_ )
{
local *_ = \$_[$i];
return $i if $f->();
}
return -1;
}
sub _json {
state $json = do {
if (eval { require Cpanel::JSON::XS; 1 }) { Cpanel::JSON::XS->new->canonical(1)->convert_blessed->allow_nonref }
elsif (eval { require JSON::Tiny::Subclassable; 1 }) { JSON::Tiny::Subclassable->new }
elsif (eval { require JSON::PP; 1 }) { JSON::PP->new->canonical(1)->convert_blessed->allow_nonref }
else { die "Can't find any JSON module" }
};
$json;
};
sub __cleanse {
state $cleanser = do {
eval { require Data::Clean::JSON; 1 };
if ($@) {
undef;
} else {
Data::Clean::JSON->get_cleanser;
}
};
if ($cleanser) {
$cleanser->clean_in_place($_[0]);
} else {
$_[0];
}
}
sub __gen_table {
my ($data, $header_row, $resmeta, $format) = @_;
$resmeta //= {};
# column names
my @columns;
if ($header_row) {
@columns = @{$data->[0]};
} else {
@columns = map {"col$_"} 0..@{$data->[0]}-1;
}
my $column_orders; # e.g. [col2, col1, col3, ...]
SET_COLUMN_ORDERS: {
# find column orders from 'table_column_orders' in result metadata (or
# from env)
my $tcos;
if ($ENV{FORMAT_PRETTY_TABLE_COLUMN_ORDERS}) {
$tcos = _json->encode($ENV{FORMAT_PRETTY_TABLE_COLUMN_ORDERS});
} elsif (my $rfos = ($resmeta->{'cmdline.format_options'} //
$resmeta->{format_options})) {
my $rfo = $rfos->{'text-pretty'} // $rfos->{text} // $rfos->{any};
if ($rfo) {
$tcos = $rfo->{table_column_orders};
}
}
if ($tcos) {
# find an entry in tcos that @columns contains all the columns of
COLS:
for my $cols (@$tcos) {
for my $col (@$cols) {
next COLS unless first {$_ eq $col} @columns;
}
$column_orders = $cols;
last SET_COLUMN_ORDERS;
}
}
if ($resmeta->{'table.field_orders'}) {
$column_orders = $resmeta->{'table.field_orders'};
last SET_COLUMN_ORDERS;
}
# find column orders from table spec
$column_orders = $resmeta->{'table.fields'};
}
# reorder each row according to requested column order
if ($column_orders) {
require Sort::BySpec;
my $cmp = Sort::BySpec::cmp_by_spec(spec => $column_orders);
# 0->2, 1->0, ... (map column position from unordered to ordered)
my @map0 = sort { $cmp->($a->[1], $b->[1]) }
map {[$_, $columns[$_]]} 0..$#columns;
#use DD; dd \@map0;
my @map;
for (0..$#map0) {
$map[$_] = $map0[$_][0];
}
#use DD; dd \@map;
my $newdata = [];
for my $row (@$data) {
my @newrow;
for (0..$#map) { $newrow[$_] = $row->[$map[$_]] }
push @$newdata, \@newrow;
}
$data = $newdata;
my @newcolumns;
for (@map) { push @newcolumns, $columns[$_] }
@columns = @newcolumns;
}
my @field_idxs; # map column to index in table.fields
{
my $tff = $resmeta->{'table.fields'} or last;
for my $i (0..$#columns) {
$field_idxs[$i] = firstidx { $_ eq $columns[$i] } @$tff;
}
}
# determine field labels
{
last unless $header_row && @$data;
my $tff = $resmeta->{'table.fields'} or last;
my $tfl = $resmeta->{'table.field_labels'};
my $tfu = $resmeta->{'table.field_units'};
for my $i (0..$#columns) {
my $field_idx = $field_idxs[$i];
next unless $field_idx >= 0;
if ($tfl && defined $tfl->[$field_idx]) {
$data->[0][$i] = $tfl->[$field_idx];
} elsif ($tfu && defined $tfu->[$field_idx]) {
# add field units as label suffix to header (" (UNIT)")
$data->[0][$i] .= " ($tfu->[$field_idx])";
}
}
}
FORMAT_CELLS:
{
my $tffmt = $resmeta->{'table.field_formats'};
my $tffmt_code = $resmeta->{'table.field_format_code'};
my $tffmt_default = $resmeta->{'table.default_field_format'};
last unless $tffmt || $tffmt_code || $tffmt_default;
my (@fmt_names, @fmt_opts); # key: column index
for my $i (0..$#columns) {
my $field_idx = $field_idxs[$i];
my $fmt = $tffmt_code ? $tffmt_code->($columns[$i]) : undef;
$fmt //= $tffmt->[$field_idx] if $field_idx >= 0;
$fmt //= $tffmt_default;
if (ref $fmt eq 'ARRAY') {
$fmt_names[$i] = $fmt->[0];
$fmt_opts [$i] = $fmt->[1] // {};
} else {
$fmt_names[$i] = $fmt;
$fmt_opts [$i] = {};
}
}
my $nf;
for my $i (0..$#{$data}) {
next if $i==0 && $header_row;
my $row = $data->[$i];
for my $j (0..$#columns) {
next unless defined $row->[$j];
my $fmt_name = $fmt_names[$j];
#say "D:j=$j fmt_name=$fmt_name";
next unless $fmt_name;
my $fmt_opts = $fmt_opts [$j];
if ($fmt_name eq 'iso8601_datetime' || $fmt_name eq 'iso8601_date' || $fmt_name eq 'datetime' || $fmt_name eq 'date') {
if ($row->[$j] =~ /\A[0-9]+(\.[0-9]*)?\z/) {
my $frac = $1 ? "0$1"+0 : 0;
my @t = gmtime($row->[$j]);
if ($fmt_name eq 'iso8601_datetime' || $fmt_name eq 'datetime') {
$row->[$j] = sprintf(
"%04d-%02d-%02dT%02d:%02d:".($frac ? "%06.3f" : "%02d")."Z",
$t[5]+1900, $t[4]+1, $t[3], $t[2], $t[1], $t[0]+$frac);
} else {
$row->[$j] = sprintf(
"%04d-%02d-%02d",
$t[5]+1900, $t[4]+1, $t[3]);
}
}
} elsif ($fmt_name eq 'boolstr') {
$row->[$j] = $row->[$j] ? "yes" : "no";
} elsif ($fmt_name eq 'filesize') {
$row->[$j] = Format::Human::Bytes::base2($row->[$j], 0);
} elsif ($fmt_name eq 'sci2dec') {
if ($row->[$j] =~ /\A(?:[+-]?)(?:\d+\.|\d*\.(\d+))[eE]([+-]?\d+)\z/) {
my $n = length($1 || "") - $2; $n = 0 if $n < 0;
$row->[$j] = sprintf("%.${n}f", $row->[$j]);
}
} elsif ($fmt_name eq 'percent') {
my $fmt = $fmt_opts->{sprintf} // '%.2f%%';
$row->[$j] = sprintf($fmt, $row->[$j] * 100);
} elsif ($fmt_name eq 'number') {
$row->[$j] = Number::Format::BigFloat::format_number(
$row->[$j], {
thousands_sep => $fmt_opts->{thousands_sep} // ',',
decimal_point => $fmt_opts->{decimal_point} // '.',
decimal_digits => $fmt_opts->{precision} // 0,
# XXX decimal_fill
});
}
}
}
}
if ($format eq 'text-pretty') {
ALIGN_COLUMNS:
{
last unless @$data;
# note: the logic of this block of code now also put in Number::Pad
# XXX we just want to turn off 'uninitialized' and 'negative repeat
# count does nothing' from the operator x
no warnings;
my $tfa = $resmeta->{'table.field_aligns'};
my $tfa_code = $resmeta->{'table.field_align_code'};
my $tfa_default = $resmeta->{'table.default_field_align'};
# align numbers by default, with 'right' currently as 'number' is too slow
unless ($tfa || $tfa_code || $tfa_default) {
$tfa = [map { undef } 0 .. $#columns];
COLUMN:
for my $colidx (0 .. $#columns) {
for my $i (0 .. $#{$data}) {
next if $header_row && $i == 0;
my $cell = $data->[$i][$colidx];
next unless defined $cell;
next COLUMN unless $cell =~ /\A[+-]?[0-9]+(?:\.[0-9]*)?(?:[Ee][+-]?[0-9]+)?(?:%)?\z/;
}
$tfa->[$colidx] = 'right';
}
}
#use DD; dd $tfa;
#say "D1";
for my $colidx (0..$#columns) {
my $field_idx = $field_idxs[$colidx];
my $align = $tfa_code ? $tfa_code->($columns[$colidx]) : undef;
$align //= $tfa->[$field_idx] if $field_idx >= 0;
$align //= $tfa_default;
next unless $align;
# determine max widths
my $maxw;
my ($maxw_bd, $maxw_d, $maxw_ad); # before digit, digit, after d
if ($align eq 'number') {
my (@w_bd, @w_d, @w_ad);
for my $i (0..$#{$data}) {
my $row = $data->[$i];
if (@$row > $colidx) {
my $cell = $row->[$colidx];
if ($header_row && $i == 0) {
my $w = length($cell);
push @w_bd, 0;
push @w_bd, 0;
push @w_ad, 0;
} elsif ($cell =~ /\A([+-]?\d+)(\.?)(\d*)[%]?\z/) {
# decimal notation number (with optional percent sign). TODO: allow arbitraty units after number, e.g. ml, mcg, etc? but should we align the unit too?
push @w_bd, length($1);
push @w_d , length($2);
push @w_ad, length($3);
} elsif ($cell =~ /\A([+-]?\d+\.?\d*)([eE])([+-]?\d+)\z/) {
# scientific notation number
push @w_bd, length($1);
push @w_d , length($2);
push @w_ad, length($3);
} else {
# not a number
push @w_bd, length($cell);
push @w_bd, 0;
push @w_ad, 0;
}
} else {
push @w_bd, 0;
push @w_d , 0;
push @w_ad, 0;
}
}
$maxw_bd = max(@w_bd);
$maxw_d = max(@w_d);
$maxw_ad = max(@w_ad);
if ($header_row) {
my $w = length($data->[0][$colidx]);
if ($maxw_d == 0 && $maxw_ad == 0) {
$maxw_bd = $w;
}
}
}
$maxw = max(map {
@$_ > $colidx ? length($_->[$colidx]) : 0
} @$data);
# do the alignment
for my $i (0..$#{$data}) {
my $row = $data->[$i];
for my $i (0..$#{$data}) {
my $row = $data->[$i];
next unless @$row > $colidx;
my $cell = $row->[$colidx];
next unless defined($cell);
if ($align eq 'number') {
my ($bd, $d, $ad);
if ($header_row && $i == 0) {
} elsif (($bd, $d, $ad) = $cell =~ /\A([+-]?\d+)(\.?)(\d*)\z/) {
$cell = join(
'',
(' ' x ($maxw_bd - length($bd))), $bd,
$d , (' ' x ($maxw_d - length($d ))),
$ad, (' ' x ($maxw_ad - length($ad))),
);
} elsif (($bd, $d, $ad) = $cell =~ /\A([+-]?\d+\.?\d*)([eE])([+-]?\d+)\z/) {
$cell = join(
'',
(' ' x ($maxw_bd - length($bd))), $bd,
$d , (' ' x ($maxw_d - length($d ))),
$ad, (' ' x ($maxw_ad - length($ad))),
);
}
my $w = length($cell);
$cell = (' ' x ($maxw - $w)) . $cell
if $maxw > $w;
} elsif ($align eq 'right') {
$cell = (' ' x ($maxw - length($cell))) . $cell;
} elsif ($align eq 'middle' || $align eq 'center') {
my $w = length($cell);
my $n = int(($maxw-$w)/2);
$cell = (' ' x $n) . $cell . (' ' x ($maxw-$w-$n));
} else {
# assumed left
$cell .= (' ' x ($maxw - length($cell)));
}
$row->[$colidx] = $cell;
}
}
} # for $colidx
} # END align columns
#say "D2";
my $fres;
my $backend = $ENV{FORMAT_PRETTY_TABLE_BACKEND};
$backend //= "Text::Table::Org" if $ENV{INSIDE_EMACS};
my $backend_opts = $ENV{FORMAT_PRETTY_TABLE_BACKEND_OPTS};
if (defined $backend_opts) {
$backend_opts = eval { _json->decode($backend_opts) };
die "Invalid JSON in FORMAT_PRETTY_TABLE_BACKEND_OPTS: $@" if $@;
} else {
$backend_opts = {};
}
if ($backend) {
$fres = Text::Table::Any::table(
rows => $data,
header_row => $header_row,
backend => $backend,
backend_opts => $backend_opts,
(caption => $resmeta->{caption}) x !!defined($resmeta->{caption}),
);
} else {
$fres = Text::Table::Sprintf::table(rows=>$data, header_row=>$header_row);
}
$fres .= "\n" unless $fres =~ /\R\z/ || !length($fres);
$fres;
} elsif ($format eq 'csv') {
no warnings 'uninitialized';
join(
"",
map {
my $row = $_;
join(
",",
map {
my $cell = $_;
$cell =~ s/"/""/g;
qq("$cell");
} @$row)."\n";
} @$data
);
} elsif ($format eq 'tsv') {
no warnings 'uninitialized';
join("", map { my $row = $_; join("\t", @$row)."\n" } @$data);
} elsif ($format eq 'ltsv') {
no warnings 'uninitialized';
join("", map { my $row = $_; join("\t", map { "$columns[$_]:$row->[$_]" } 0 .. $#{$row})."\n" } @$data);
} elsif ($format eq 'html') {
no warnings 'uninitialized';
require HTML::Entities;
my $tfa = $resmeta->{'table.field_aligns'};
my @res;
push @res, "<table".($resmeta->{'table.html_class'} ?
" class=\"".HTML::Entities::encode_entities(
$resmeta->{'table.html_class'})."\"" : "").
">\n";
for my $i (0..$#{$data}) {
my $data_elem = $i == 0 ? "th" : "td";
push @res, "<thead>\n" if $i == 0;
push @res, "<tbody>\n" if $i == 1;
push @res, " <tr>\n";
my $row = $data->[$i];
for my $j (0..$#{$row}) {
my $field_idx = $field_idxs[$j];
my $align;
if ($field_idx >= 0 && $tfa->[$field_idx]) {
$align = $tfa->[$field_idx];
$align = "right" if $align eq 'number';
$align = "middle" if $align eq 'center';
}
push @res, " <$data_elem",
($align ? " align=\"$align\"" : ""),
">", HTML::Entities::encode_entities($row->[$j]),
"</$data_elem>\n";
}
push @res, " </tr>\n";
push @res, "</thead>\n" if $i == 0;
}
push @res, "</tbody>\n";
push @res, "</table>\n";
join '', @res;
} else {
no warnings 'uninitialized';
shift @$data if $header_row;
join("", map {join("\t", @$_)."\n"} @$data);
}
}
sub format {
my ($res, $format, $is_naked, $cleanse) = @_;
if ($format =~ /\A(text|text-simple|text-pretty|csv|tsv|ltsv|html)\z/) {
$format = $format eq 'text' ?
((-t STDOUT) ? 'text-pretty' : 'text-simple') : $format;
no warnings 'uninitialized';
if ($res->[0] !~ /^(2|304)/) {
my $fres = "ERROR $res->[0]: $res->[1]";
if (my $prev = $res->[3]{prev}) {
$fres .= " ($prev->[0]: $prev->[1])";
}
return "$fres\n";
} elsif ($res->[3] && $res->[3]{"x.hint.result_binary"}) {
return $res->[2];
} else {
my $data = $res->[2];
my $max = 1000;
if (!ref($data)) {
$data //= "";
$data .= "\n" unless !length($data) || $data =~ /\n\z/;
return $data;
} elsif (ref($data) eq 'ARRAY' && !@$data) {
return "";
} elsif (Data::Check::Structure::is_aos($data, {max=>$max})) {
return join("", map {"$_\n"} @$data);
} elsif (Data::Check::Structure::is_aoaos($data, {max=>$max})) {
my $header_row = 0;
my $data = $data;
if ($res->[3]{'table.fields'}) {
$data = [$res->[3]{'table.fields'}, @$data];
$header_row = 1;
}
return __gen_table($data, $header_row, $res->[3], $format);
} elsif (Data::Check::Structure::is_hos($data, {max=>$max})) {
$data = [map {[$_, $data->{$_}]} sort keys %$data];
unshift @$data, ["key", "value"];
return __gen_table($data, 1, $res->[3], $format);
} elsif (Data::Check::Structure::is_aohos($data, {max=>$max})) {
# collect all mentioned fields
my @fieldnames;
if ($res->[3] && $res->[3]{'table.fields'} &&
$res->[3]{'table.hide_unknown_fields'}) {
@fieldnames = @{ $res->[3]{'table.fields'} };
} else {
my %fieldnames;
for my $row (@$data) {
$fieldnames{$_}++ for keys %$row;
}
@fieldnames = sort keys %fieldnames;
}
my $newdata = [];
for my $row (@$data) {
push @$newdata, [map {$row->{$_}} @fieldnames];
}
unshift @$newdata, \@fieldnames;
return __gen_table($newdata, 1, $res->[3], $format);
} else {
$format = 'json-pretty';
}
}
}
my $tff = $res->[3]{'table.fields'};
$res = $res->[2] if $is_naked;
if ($format eq 'perl') {
my $use_color = $ENV{COLOR} // (-t STDOUT);
if ($use_color && eval { require Data::Dump::Color; 1 }) {
return Data::Dump::Color::dump($res);
} elsif (eval { require Data::Dump; 1 }) {
return Data::Dump::dump($res);
} else {
no warnings 'once';
require Data::Dumper;
local $Data::Dumper::Terse = 1;
local $Data::Dumper::Indent = 1;
local $Data::Dumper::Useqq = 1;
local $Data::Dumper::Deparse = 1;
local $Data::Dumper::Quotekeys = 0;
local $Data::Dumper::Sortkeys = 1;
local $Data::Dumper::Trailingcomma = 1;
return Data::Dumper::Dumper($res);
}
}
unless ($format =~ /\Ajson(-pretty)?\z/) {
warn "Unknown format '$format', fallback to json-pretty";
$format = 'json-pretty';
}
__cleanse($res) if ($cleanse//1);
if ($format =~ /json/) {
if ($tff && _json->can("sort_by") &&
eval { require Sort::ByExample; 1}) {
my $cmp = Sort::ByExample->cmp($tff);
_json->sort_by(sub { $cmp->($JSON::PP::a, $JSON::PP::b) });
}
if ($format eq 'json') {
return _json->encode($res) . "\n";
} else {
_json->pretty(1);
return _json->encode($res);
}
}
}
1;
# ABSTRACT: Format enveloped result
__END__
=pod
=encoding UTF-8
=head1 NAME
Perinci::Result::Format::Lite - Format enveloped result
=head1 VERSION
This document describes version 0.288 of Perinci::Result::Format::Lite (from Perl distribution Perinci-Result-Format-Lite), released on 2023-07-29.
=head1 SYNOPSIS
=head1 DESCRIPTION
This module formats L<enveloped result structure|Rinci::function/"Enveloped
result"> to "pretty text" if it can do so, e.g. the structure can be represented
as a 2-dimensional table. Otherwise, it falls back to JSON or Perl. The table
formats supported include CSV, TSV, LTSV, or HTML. More table formats (e.g. Org,
Markdown) are supported via L<Text::Table::Any> when you set
L</"FORMAT_PRETTY_TABLE_BACKEND">.
This module is a more lightweight version of L<Perinci::Result::Format> but the
long-term goal is to reunite the two formatting modules back to a
modular/pluggable module.
=for Pod::Coverage ^(firstidx)$
=head1 SUPPORTED RESULT METADATA PROPERTIES/ATTRIBUTES
The L<enveloped result specification|Rinci::function/"Enveloped result">
specifies various properties/attributes that can be used as formatting hints.
Below are the list of properties/attributes supported by this module, including
those that are not in the specification:
=over
=item * table.html_class
Str. Used when formatting result as HTML table.
=item * table.fields
Array of str. Define fields in order. Used when formatting result as text table.
Fields that are not defined in this array will be displayed after the defined
fields (or hidden, if you set C<table.hide_unknown_fields>).
=item * table.hide_unknown_fields
Bool. If set to true, then unknown fields (those not defined in C<table.fields>)
will not be shown. Used when formatting result as text table.
=item * table.field_orders
Array of str. Like C<table.fields>, but with higher precedence.
=item * table.field_labels
Array of str. Define labels for each field (each element correspond to the field
of the same element as defined in C<table.fields>). Used when formatting result
as text table. Will show this in header for fields instead the actual field
name.
=item * table.field_units
Array of str. Define units for each field (each element correspond to the field
of the same element as defined in C<table.fields>). Used when formatting result
as text table. Will show this along with field name/label. For example if a
field's unit is defined as `cm` and field name is `length`, then the field
header will show as `length (cm)`.
=item * table.field_formats
Array of str. Define format for each field (each element correspond to the field
of the same element as defined in C<table.fields>). Used when formatting result
as text table. Known formats: `iso8601_datetime`, `iso8601_date`, `datetime`,
`date`, `boolstr`, `filesize`, `sci2dec`, `percent`, `number`.
=item * table.field_format_code
Coderef. Will be called with argument of field name. Expected to return format
name (see C<table.field_formats>). Used when formatting result as text table.
This option can be used when you want to dynamically determine a suitable format
based on field name.
=item * table.default_field_format
Str. Instead of defining format for each field using `table.field_formats`, you
can also specify default format for all fields.
=item * table.field_aligns
Array of str. Define alignment for each field (each element correspond to the
field of the same element as defined in C<table.fields>). Used when formatting
result as text table. Known alignment value for each field: `number` (special
rule to align on decimal point or `E`), `right`, `middle`|`center`, `right`.
=item * table.field_align_code
Coderef. Will be called with argument of field name. Expected to return
alignment name (see C<table.field_aligns>). Used when formatting result as text
table. This option can be used when you want to dynamically determine a suitable
alignment based on field name.
=item * table.default_field_align
Str. Instead of defining alignment for each field using `table.field_aligns`,
you can also specify default alignment for all fields.
=back
=head1 FUNCTIONS
=head2 format($res, $format[ , $is_naked=0, $cleanse=1 ]) => str
=head1 ENVIRONMENT
=head2 FORMAT_PRETTY_TABLE_BACKEND
Str, optional. If this is set, will render text table using L<Text::Table::Any>
(with C<backend> set to the value of this environment variable) instead of the
default L<Text::Table::Sprintf>. This is useful if you want to output text table
in a different format, for example to generate Org tables (make sure
L<Text::Table::Org> backend is already installed):
% FORMAT_PRETTY_TABLE_BACKEND=Text::Table::Org lcpan rdeps Getopt::Lucid
For convenience, a default is chosen for you under certain condition. When
inside Emacs (environment C<INSIDE_EMACS> is set), C<Text::Table::Org> is used
as default.
=head2 FORMAT_PRETTY_TABLE_BACKEND_OPTS
Str, JSON-encoding expected. This setting is to accompany
L</FORMAT_PRETTY_TABLE_BACKEND>, to be passed to
L<Text::Table::Any>C<::table()>'s C<backend_opts> argument. It should be a hash
encoded in JSON, e.g.:
# keep table aligned in the presence of wide Unicode characters
% FORMAT_PRETTY_TABLE_BACKEND=Text::Table::More FORMAT_PRETTY_TABLE_BACKEND_OPTS='{"wide_char":1}' tabledata locale::JP::City::MIC --page
=head2 FORMAT_PRETTY_TABLE_COLUMN_ORDERS => array (json)
Set the default of C<table_column_orders> in C<format_options> in result
metadata, similar to what's implemented in L<Perinci::Result::Format> and
L<Data::Format::Pretty::Console>.
=head2 COLOR => bool
=head1 HOMEPAGE
Please visit the project's homepage at L<https://metacpan.org/release/Perinci-Result-Format-Lite>.
=head1 SOURCE
=head1 SEE ALSO
L<Perinci::Result::Format>, a more heavyweight version of this module.
L<Perinci::CmdLine::Lite> uses this module to format enveloped result.
=head1 AUTHOR
perlancar <perlancar@cpan.org>
=head1 CONTRIBUTING
To contribute, you can send patches by email/via RT, or send pull requests on
GitHub.
Most of the time, you don't need to build the distribution yourself. You can
simply modify the code, then test via:
% prove -l
If you want to build the distribution (e.g. to try to install it locally on your
system), you can install L<Dist::Zilla>,
L<Dist::Zilla::PluginBundle::Author::PERLANCAR>,
L<Pod::Weaver::PluginBundle::Author::PERLANCAR>, and sometimes one or two other
Dist::Zilla- and/or Pod::Weaver plugins. Any additional steps required beyond
that are considered a bug and can be reported to me.
=head1 COPYRIGHT AND LICENSE
This software is copyright (c) 2023, 2022, 2021, 2020, 2018, 2017, 2016, 2015 by perlancar <perlancar@cpan.org>.
This is free software; you can redistribute it and/or modify it under
the same terms as the Perl 5 programming language system itself.
=head1 BUGS
Please report any bugs or feature requests on the bugtracker website L<https://rt.cpan.org/Public/Dist/Display.html?Name=Perinci-Result-Format-Lite>
When submitting a bug or request, please include a test-file or a
patch to an existing test-file that illustrates the bug or desired
feature.
=cut