Fleshgrinder/php-format

View on GitHub
src/Formatter.php

Summary

Maintainability
C
1 day
Test Coverage
<?php
/**
 * @author Richard Fussenegger <fleshgrinder@users.noreply.github.com>
 * @copyright 2017 Richard Fussenegger
 * @license http://unlicense.org/ Unlicense
 */

declare(strict_types = 1);

namespace Fleshgrinder\Core;

use Fleshgrinder\Core\Formatter\InvalidArgumentException;
use Fleshgrinder\Core\Formatter\MissingPlaceholderException;

/**
 * The **pattern** utility class can be used to format special pattern, refer
 * to the documentation for more information.
 *
 * @see https://github.com/fleshgrinder/php-format/blob/master/README.md
 *     Documentation
 */
final class Formatter {
    use Unconstructable;

    /**
     * Format the given pattern by replacing positional and named placeholders
     * with their corresponding argument values. Refer to the library
     * documentation for more information.
     *
     * @see https://github.com/fleshgrinder/php-format/blob/master/README.md
     *     Documentation
     * @param $pattern
     *     The pattern that specifies the formatting which should be applied,
     *     see examples for more information.
     * @param $arguments
     *     Arguments (positional and named) to replace in the pattern.
     * @throws \Exception
     *     if any of the values inside `$arguments` throw something while being
     *     converted into a string.
     * @throws \Fleshgrinder\Core\Formatter\InvalidArgumentException
     *     if a value cannot be converted to a string, this includes all kinds
     *     or resources and classes without any of the following methods:
     *     `__toString`, `toString`, `toInt`, or `toFloat`.
     * @throws \Fleshgrinder\Core\Formatter\MissingPlaceholderException
     *     if a placeholder in the pattern is missing from the arguments.
     */
    public static function format(string $pattern, array $arguments): string {
        if (\strpos($pattern, '[') !== \false) {
            $pattern = \preg_replace_callback(
            /* @lang RegExp */
                '/(?<!\[)\[((?:[^\[\]]|(?R))+)](?!])/',
                static function ($matches) use ($arguments) {
                    if (\preg_match_all(/* @lang RegExp */
                        '@(?<!\{)\{\+?([a-z\d_-]+)(?:}}|[^}])*}\?(?!})@i', $matches[1], $placeholders)) {
                        /** @noinspection ForeachSourceInspection */
                        foreach ($placeholders[1] as $placeholder) {
                            if (empty($arguments[$placeholder])) {
                                return '';
                            }
                        }

                        return $matches[1];
                    }

                    return '';
                },
                $pattern
            );
        }

        $pattern = \preg_replace_callback(
        /* @lang RegExp */
            '@(?<!\{)\{(\+)?([A-Za-z\d_-]*)(?:\.([1-9]\d*))?(?:#([bceopx]))?(?::((?:}}|[^}])+))?}\??(?!})@',
            static function ($matches) use ($arguments) {
                static $counter = 0;

                $placeholder = $matches[2] === '' ? $counter++ : $matches[2];

                if (\array_key_exists($placeholder, $arguments)) {
                    $sign        = $matches[1] === '+';
                    $decimals    = $matches[3] ?? 0;
                    $format      = $matches[4] ?? '';
                    $conjunction = $matches[5] ?? '';

                    return self::formatArg($arguments[$placeholder], $sign, (int) $decimals, $format, $conjunction);
                }

                if (isset($matches[5]) && $matches[5] === '?') {
                    return 'void';
                }

                throw MissingPlaceholderException::new($placeholder, $arguments);
            },
            $pattern
        );

        return \str_replace(['{{', '}}', '[[', ']]'], ['{', '}', '[', ']'], $pattern);
    }

    /** @noinspection MoreThanThreeArgumentsInspection */
    /**
     * @throws \Exception
     * @throws \Fleshgrinder\Core\Formatter\InvalidArgumentException
     */
    private static function formatArg($arg, bool $sign, int $decimals, string $format, string $conjunction): string {
        if ($conjunction === '?') {
            $type = Value::getType($arg);

            if ($type === Value::TYPE_RESOURCE) {
                $type = \get_resource_type($arg) . " {$type}";
            }

            return $type;
        }

        if ($arg === \null) {
            return 'NULL';
        }

        if (\is_string($arg)) {
            return self::formatString(Value::TYPE_STRING, $arg, $format);
        }

        if (\is_array($arg)) {
            return self::formatArrayArg(Value::TYPE_ARRAY, \array_values($arg), $sign, $decimals, $format, $conjunction);
        }

        if (\is_bool($arg)) {
            return $arg ? 'TRUE' : 'FALSE';
        }

        if (\is_float($arg) || \is_int($arg)) {
            return self::formatNumber($arg, $sign, $decimals, $format);
        }

        if (\is_object($arg)) {
            if (\method_exists($arg, 'toString')) {
                return self::formatString(\get_class($arg), $arg->toString(), $format);
            }

            if (\method_exists($arg, 'toInt')) {
                return self::formatNumber($arg->toInt(), $sign, $decimals, $format);
            }

            if (\method_exists($arg, 'toFloat')) {
                return self::formatNumber($arg->toFloat(), $sign, $decimals, $format);
            }

            if (\method_exists($arg, '__toString')) {
                return self::formatString(\get_class($arg), (string) $arg, $format);
            }

            if ($arg instanceof \Traversable) {
                return self::formatArrayArg(\get_class($arg), \iterator_to_array($arg, \false), $sign, $decimals, $format, $conjunction);
            }
        }

        throw InvalidArgumentException::new($arg);
    }

    /** @noinspection MoreThanThreeArgumentsInspection */
    /**
     * @throws \Exception
     * @throws \Fleshgrinder\Core\Formatter\InvalidArgumentException
     */
    private static function formatArrayArg(string $type, array $arg, bool $sign, int $decimals, string $format, string $conjunction): string {
        $c = \count($arg);
        switch ($c) {
            case 0:
                return "empty {$type}";

            case 1:
                return self::formatArg($arg[0], $sign, $decimals, $format, $conjunction);

            case 2:
                $left  = self::formatArg($arg[0], $sign, $decimals, $format, $conjunction);
                $right = self::formatArg($arg[1], $sign, $decimals, $format, $conjunction);

                return $conjunction === ''
                    ? "{$left}, {$right}"
                    : "{$left} {$conjunction} {$right}";
        }

        $last = self::formatArg(\array_pop($arg), $sign, $decimals, $format, $conjunction);
        $list = '';
        foreach ($arg as $item) {
            $list .= self::formatArg($item, $sign, $decimals, $format, $conjunction) . ', ';
        }

        return $conjunction === ''
            ? "{$list}{$last}"
            : "{$list}{$conjunction} {$last}";
    }

    /** @noinspection MoreThanThreeArgumentsInspection */
    /** @param float|int $arg */
    private static function formatNumber($arg, bool $sign, int $decimals, string $format): string {
        if ($format) {
            if ($format === 'e') {
                $pattern = '%';
                $sign && $pattern .= '+';
                $decimals > 0 && $pattern .= ".{$decimals}";

                return \sprintf("{$pattern}e", $arg);
            }

            $formats = [
                'b' => '0b%b',
                'o' => '0o%o',
                'x' => '0x%X',
            ];

            if (isset($formats[$format])) {
                return \sprintf($formats[$format], $arg);
            }
        }

        return ($sign && $arg >= 0 ? '+' : '') . \number_format($arg, $decimals);
    }

    private static function formatString(string $type, string $arg, string $format): string {
        if ($arg === '') {
            return "empty {$type}";
        }

        if ($format) {
            if ($format === 'c') {
                return self::formatControlChars($arg, '^?', static function (int $ord): string {
                    return '^' . \chr($ord + 64);
                });
            }

            if ($format === 'p') {
                return self::formatControlChars($arg, '␡', static function (int $ord): string {
                    return \html_entity_decode('&#' . ($ord + 9216) . ';', 0, 'UTF-8');
                });
            }
        }

        return $arg;
    }

    private static function formatControlChars(string $string, string $del, callable $cc): string {
        $result = '';

        for ($i = 0, $bytes = \strlen($string); $i < $bytes; ++$i) {
            $ord = \ord($string{$i});

            if (0 <= $ord && $ord <= 31) {
                $result .= $cc($ord);
            }
            elseif ($ord === 127) {
                $result .= $del;
            }
            else {
                $result .= $string{$i};
            }
        }

        return $result;
    }
}