#+##############################################################################
#                                                                              #
# File: No/Worries/String.pm                                                   #
#                                                                              #
# Description: string handling without worries                                 #
#                                                                              #
#-##############################################################################

#
# module definition
#

package No::Worries::String;
use strict;
use warnings;
our $VERSION  = "1.6";
our $REVISION = sprintf("%d.%02d", q$Revision: 1.14 $ =~ /(\d+)\.(\d+)/);

#
# used modules
#

use No::Worries::Export qw(export_control);
use Params::Validate qw(validate validate_pos :types);

#
# global variables
#

our(
    @_ByteSuffix,  # byte suffixes used by bytefmt
    @_Map,         # mapping of characters to escaped strings
    %_Plural,      # pluralization cache
);

#
# format a number of bytes
#

sub string_bytefmt ($;$) {
    my($number, $precision) = @_;
    my($index);

    $precision = 2 unless defined($precision);
    $index = 0;
    while ($_ByteSuffix[$index] and $number > 1024) {
        $index++;
        $number /= 1024.0;
    }
    return("$number $_ByteSuffix[$index]") if $number =~ /^\d+$/;
    return(sprintf("%.${precision}f %s", $number, $_ByteSuffix[$index]));
}

#
# escape a string (quite compact, human friendly but not Perl eval()'able)
#

sub string_escape ($) {
    my($string) = @_;
    my(@list);

    validate_pos(@_, { type => SCALAR });
    foreach my $ord (map(ord($_), split(//, $string))) {
        push(@list, $ord < 256 ? $_Map[$ord] : sprintf("\\x{%04x}", $ord));
    }
    return(join("", @list));
}

#
# return the plural form of the given noun
#

sub string_plural ($) {
    my($noun) = @_;

    unless ($_Plural{$noun}) {
        if ($noun =~ /(ch|s|sh|x|z)$/) {
            $_Plural{$noun} = $noun . "es";
        } elsif ($noun =~ /[bcdfghjklmnpqrstvwxz]y$/) {
            $_Plural{$noun} = substr($noun, 0, -1) . "ies";
        } elsif ($noun =~ /f$/) {
            $_Plural{$noun} = substr($noun, 0, -1) . "ves";
        } elsif ($noun =~ /fe$/) {
            $_Plural{$noun} = substr($noun, 0, -2) . "ves";
        } elsif ($noun =~ /[bcdfghjklmnpqrstvwxz]o$/) {
            $_Plural{$noun} = $noun . "es";
        } else {
            $_Plural{$noun} = $noun . "s";
        }
    }
    return($_Plural{$noun});
}

#
# quantify the given (count, noun) pair
#

sub string_quantify ($$) {
    my($count, $noun) = @_;

    return($count . " " . ($count == 1 ? $noun : string_plural($noun)));
}

#
# return the real length of a string (removing ANSI Escape sequences)
#

sub _strlen ($) {
    my($string) = @_;

    return(0) unless defined($string);
    $string =~ s/\x1b\[[0-9;]*[mGKH]//g;
    return(length($string));
}

#
# return an aligned and padded string
#

sub _strpad ($$$) {
    my($string, $length, $align) = @_;
    my($strlen, $before, $after);

    $string = "" unless defined($string);
    $strlen = _strlen($string);
    $align ||= "left";
    if ($align eq "left") {
        $before = 0;
        $after = $length - $strlen;
    } elsif ($align eq "right") {
        $before = $length - $strlen;
        $after = 0;
    } elsif ($align eq "center") {
        $before = ($length - $strlen) >> 1;
        $after = $length - $strlen - $before;
    } else {
        die("unexpected alignment: $align\n");
    }
    return((" " x $before) . $string . (" " x $after));
}

#
# return a string generated from a repeated pattern
#

sub _strgen ($$) {
    my($pattern, $length) = @_;

    return(substr($pattern x $length, 0, $length));
}

#
# return a formatted table line
#

sub _tblfmt ($$) {
    my($column, $option) = @_;
    my($line, $index);

    $line = $option->{indent};
    $line .= $option->{lsep};
    $index = 0;
    while ($index < @{ $option->{collen} }) {
        $line .= $option->{colsep} if $index;
        $line .= _strpad($column->[$index],
                         $option->{collen}[$index],
                         $option->{align}[$index]);
        $index++;
    }
    $line .= $option->{rsep};
    $line .= "\n";
    return($line);
}

#
# transform a table into a string
#

my %string_table_options = (
    align    => { optional => 1, type => ARRAYREF },
    colsep   => { optional => 1, type => SCALAR },
    header   => { optional => 1, type => ARRAYREF },
    headsep  => { optional => 1, type => SCALAR },
    indent   => { optional => 1, type => SCALAR },
    markdown => { optional => 1, type => BOOLEAN },
);

sub string_table ($@) {
    my($lines, %option, @collen, @headsep, $index, $length, $result);

    # handle options
    $lines = shift(@_);
    %option = validate(@_, \%string_table_options) if @_;
    $option{align} ||= [];
    $option{colsep} = " | "
        unless defined($option{colsep});
    $option{headsep} = $option{markdown} ? "-" : "="
        unless defined($option{headsep});
    $option{indent} = ""
        unless defined($option{indent});
    if ($option{markdown}) {
        $option{lsep} = $option{rsep} = $option{colsep};
        $option{lsep} =~ s/^\s+//;
        $option{rsep} =~ s/\s+$//;
    } else {
        $option{lsep} = "";
        $option{rsep} = "";
    }
    # compute column lengths
    foreach my $line ($option{header} ? ($option{header}) : (), @{ $lines }) {
        $index = 0;
        foreach my $entry (@{ $line }) {
            $length = _strlen($entry);
            $collen[$index] = $length
                unless defined($collen[$index]) and $collen[$index] >= $length;
            $index++;
        }
    }
    # compute total length
    $length = length($option{lsep}) + length($option{rsep});
    $length += length($option{colsep}) * (@collen - 1);
    foreach my $collen (@collen) {
        $length += $collen;
    }
    $option{collen} = \@collen;
    $result = "";
    # format header
    if ($option{header}) {
        $result .= _tblfmt($option{header}, \%option);
        if (length($option{headsep})) {
            if ($option{markdown}) {
                @headsep = map(_strgen($option{headsep}, $_), @collen);
                $result .= _tblfmt(\@headsep, \%option);
            } else {
                $result .= $option{indent};
                $result .= _strgen($option{headsep}, $length) . "\n";
            }
        }
    }
    # format lines
    foreach my $line (@{ $lines }) {
        $result .= _tblfmt($line, \%option);
    }
    return($result);
}

#
# remove leading and trailing spaces
#

sub string_trim ($) {
    my($string) = @_;

    validate_pos(@_, { type => SCALAR });
    $string =~ s/^\s+//;
    $string =~ s/\s+$//;
    return($string);
}

#
# module initialization
#

@_ByteSuffix = qw(B kB MB GB TB PB EB ZB YB);
foreach my $ord (0 .. 255) {
    $_Map[$ord] = 32 <= $ord && $ord < 127 ?
        chr($ord) : sprintf("\\x%02x", $ord);
}
$_Map[ord("\t")] = "\\t";
$_Map[ord("\n")] = "\\n";
$_Map[ord("\r")] = "\\r";
$_Map[ord("\e")] = "\\e";
$_Map[ord("\\")] = "\\\\";
%_Plural = (
    "child" => "children",
    "data"  => "data",
    "foot"  => "feet",
    "index" => "indices",
    "man"   => "men",
    "tooth" => "teeth",
    "woman" => "women",
);

#
# export control
#

sub import : method {
    my($pkg, %exported);

    $pkg = shift(@_);
    grep($exported{$_}++, map("string_$_",
        qw(bytefmt escape plural quantify table trim)));
    export_control(scalar(caller()), $pkg, \%exported, @_);
}

1;

__DATA__

=head1 NAME

No::Worries::String - string handling without worries

=head1 SYNOPSIS

  use No::Worries::String qw(*);

  # format a number of bytes
  printf("%s has %s\n", $path, string_bytefmt(-s $path));

  # escape a string
  printf("found %s\n", string_escape($data));

  # produce a nice output (e.g "1 file" or "3 files")
  printf("found %s\n", string_quantify($count, "file"));

  # format a table
  print(string_table([
      [1, 1,  1],
      [2, 4,  8],
      [3, 9, 27],
  ], header => [qw(x x^2 x^3)]));

  # trim a string
  $string = string_trim($input);

=head1 DESCRIPTION

This module eases string handling by providing convenient string manipulation
functions.

=head1 FUNCTIONS

This module provides the following functions (none of them being exported by
default):

=over

=item string_bytefmt(NUMBER[, PRECISION])

return the given NUMBER formatted as a number of bytes with a suffix such as
C<kB> or C<GB>; the default precision (i.e. number of digits after the decimal
dot) is 2

=item string_escape(STRING)

return a new string with all potentially non-printable characters escaped;
this includes ASCII control characters, non-7bit ASCII and Unicode characters

=item string_plural(STRING)

assuming that STRING is an English noun, returns its plural form

=item string_quantify(NUMBER, STRING)

assuming that STRING is an English noun, returns a string saying how much of
it there is; e.g. C<string_quantify(2, "foot")> is C<"2 feet">

=item string_table(TABLE[, OPTIONS])

transform the given table (a reference to an array of arrays of strings) into
a formatted multi-line string; supported options:

=over

=item * C<align>: array reference of alignment directions (default: "left");
possible values are "left", "center" and "right"

=item * C<colsep>: column separator string (default: " | ")

=item * C<header>: array reference of column headers (default: none)

=item * C<headsep>: header separator (default: "=" or "-" for MarkDown)

=item * C<indent>: string to prepend to each line (default: "")

=item * C<markdown>: return a MarkDown compatible table

=back

=item string_trim(STRING)

return a new string with leading and trailing spaces removed

=back

=head1 SEE ALSO

L<No::Worries>.

=head1 AUTHOR

Lionel Cons L<http://cern.ch/lionel.cons>

Copyright (C) CERN 2012-2019
