src/Helpers/Func.php

Summary

Maintainability
B
5 hrs
Test Coverage
A
91%
<?php

declare(strict_types=1);

namespace Smuuf\Primi\Helpers;

use \Smuuf\Primi\Context;
use \Smuuf\Primi\StackFrame;
use \Smuuf\Primi\Ex\TypeError;
use \Smuuf\Primi\Ex\EngineError;
use \Smuuf\Primi\Ex\BaseException;
use \Smuuf\Primi\Parser\GrammarHelpers;
use \Smuuf\Primi\Values\StringValue;
use \Smuuf\Primi\Values\NumberValue;
use \Smuuf\Primi\Values\AbstractValue;
use \Smuuf\Primi\Handlers\HandlerFactory;
use \Smuuf\Primi\Structures\MapContainer;
use \Smuuf\Primi\Values\TypeValue;

abstract class Func {

    /**
     * Pair of regexes to match zeroes at the beginning and at the end of a
     * string, if they're not the last zeroes on that side of decimal point.
     *
     * @const string[][]
     */
    private const DECIMAL_TRIMMING_REGEXES = [
        ['#^(-?)0+(\d)#S', '#(\.0+$)|((\.\d+?[1-9]?)0+$)#S'],
        ['\1\2', '\3']
    ];

    /**
     * Returns a generator yielding `[primi key, primi value]` tuples from some
     * PHP array. If the value is not an instance of `AbstractValue`
     * object, it will be converted automatically to a `AbstractValue` object.
     *
     * @param array<mixed, mixed> $array
     * @return TypeDef_PrimiObjectCouples
     */
    public static function array_to_couples(array $array): iterable {

        foreach ($array as $key => $value) {
            yield [
                AbstractValue::buildAuto($key),
                AbstractValue::buildAuto($value)
            ];
        }

    }

    /**
     * Convert iterable of couples _(PHP 2-tuple arrays with two items
     * containing Primi objects, where first item must a string object
     * representing a valid Primi variable name)_ to PHP dict array mapping
     * pairs of `['variable_name' => Some Primi object]`.
     *
     * @param TypeDef_PrimiObjectCouples $couples
     * @return array<string, AbstractValue> PHP dict array mapping of variables.
     * @throws TypeError
     */
    public static function couples_to_variables_array(
        iterable $couples,
        string $intendedTarget
    ): array {

        $attrs = [];
        foreach ($couples as [$k, $v]) {

            if (!$k instanceof StringValue) {
                throw new TypeError(
                    "$intendedTarget is not a string but '{$k->getTypeName()}'");
            }

            $varName = $k->getStringValue();
            if (!GrammarHelpers::isValidName($varName)) {
                throw new TypeError(
                    "$intendedTarget '$varName' is not a valid name");
            }

            $attrs[$varName] = $v;

        }

        return $attrs;

    }

    /**
     * Returns PHP iterable returning couples (2-tuples) of `[key, value]` from
     * a iterable Primi object that can be interpreted as a mapping.
     * Best-effort-style.
     *
     * @return TypeDef_PrimiObjectCouples
     * @throws TypeError
     */
    public static function mapping_to_couples(AbstractValue $value) {

        $internalValue = $value->getInternalValue();
        if ($internalValue instanceof MapContainer) {

            // If the internal value already is a mapping represented by
            // MapContainer, just return its items-iterator.
            return $internalValue->getItemsIterator();

        } else {

            // We can also try to extract mapping from Primi iterable objects.
            // If the Primi object provides an iterator, we're going to iterate
            // over its items AND if each of these items is an iterable with
            // two items in it, we can extract mapping from it - and convert
            // it into Primi object couples.

            // First, we try if the passed Primi object supports iteration.
            $items = $value->getIterator();
            if ($items === \null) {
                throw new TypeError("Unable to create mapping from non-iterable");
            }

            // We prepare the result container for couples, which will be
            // discarded if we encounter any errors when putting results in it.
            $couples = [];
            $i = -1;

            foreach ($items as $item) {

                $couple = [];
                $i++;
                $j = 0;

                // Second, for each of the item of the top-iterator we check
                // if the item also supports iteration.
                $subitems = $item->getIterator();
                if ($subitems === \null) {
                    throw new TypeError(
                        "Unable to create mapping from iterable: "
                        . "item #$i is not iterable"
                    );
                }

                foreach ($subitems as $subitem) {

                    $j++;

                    // Third, since we want to build and return iterable
                    // containing couples, the item needs to contain
                    // exactly two sub-items.
                    if ($j === 3) {
                        throw new TypeError(
                            "Unable to create mapping from iterable: "
                            . "item #$i contains more than two items ($j)"
                        );
                    }

                    $couple[] = $subitem;

                }

                if ($j < 2) {
                    throw new TypeError(
                        "Unable to create mapping from iterable: "
                        . "item #$i contains less than two items ($j)"
                    );
                }

                $couples[] = $couple;

            }

            // All went well, return iterable (list array) with all gathered
            // couples.
            return $couples;

        }

    }

    public static function is_round_int(string $input): bool {

        if (!\is_numeric($input)) {
            return \false;
        }

        return \round((float) $input) == $input; // Intentionally ==

    }

    public static function is_decimal(string $input): bool {
        return (bool) \preg_match('#^[+-]?\d+(\.\d+)?$#S', $input);
    }

    /**
     * Returns a 8 character long hash unique for any existing PHP object and
     * which is always the same for a specific PHP object instance.
     *
     * This is based on `spl_object_hash` but is visibly more "random" than what
     * `spl_object_hash`.
     *
     * As is the case with `spl_object_hash`, a hash can be reused by a new
     * object if the previous object with the same hash was destroyed during
     * the PHP runtime.
     */
    public static function object_hash(object $o): string {
        return \substr(\md5(\spl_object_hash($o)), 0, 8);
    }

    /**
     * Return a 8 character long hash for any string.
     *
     * This hash should be used for "information" purposes - for example
     * to help a quick by-human-eye comparison that two things are different.
     */
    public static function string_hash(string $o): string {
        return \substr(\md5($o), 0, 8);
    }

    /**
     * Normalize decimal number - trim zeroes from left and from right and
     * do it, like, smart-like.
     *
     * Examples:
     * - `00100.0` -> `100.0`
     * - `00100.000` -> `100.0`
     * - `00100.000100` -> `100.0001`
     * - `000.000100` -> `0.0001`
     * - `0100` -> `100`
     * - `+0100` -> `100`
     * - `-0100` -> `-100`
     */
    public static function normalize_decimal(string $decimal): string {

        return \preg_replace(
            self::DECIMAL_TRIMMING_REGEXES[0],
            self::DECIMAL_TRIMMING_REGEXES[1],
            \ltrim(\trim($decimal), '+')
        );

    }

    /**
     * Converts a number represented with scientific notation to a decimal
     * number which is returned as a string.
     *
     * If there's not a decimal point nor an exponent present in the
     * number, or even if the `$number` is not really a number, the original
     * value is returned.
     *
     * Examples:
     * `1.123E+6` -> `1123000`
     * `987654.123E-6` -> `0.98765412`
     * `987654.123` -> `987654.123`
     * `987654` -> `987654`
     * `not a number, bruh` -> `not a number, bruh`
     */
    public static function scientific_to_decimal(string $number): string {

        // If not even in correct scientific form point, just return the
        // original.
        if (!\preg_match(
                "#^([+-]?\d+\.\d+)(?:E([+-]\d+))?$#S",
                $number,
                $matches
            )
        ) {
            return $number;
        }

        // If there's no exponent, just return the original.
        if (!isset($matches[2])) {
            return $number;
        }

        // Otherwise, take the base and multiply it by the exponent.
        $decimal = $matches[1];
        $exp = $matches[2];
        return \bcmul(
            $decimal,
            \bcpow('10', $exp, NumberValue::PRECISION),
            NumberValue::PRECISION
        );

    }

    /**
     * Helper for easy type-checking inside Primi extensions.
     *
     * Given an argument index, its value as object, and allowed types (as class
     * names) as the rest of the arguments, this function either throws a
     * TypeError exception with a user-friendly message or doesn't do
     * anything.
     *
     * @param class-string|AbstractValue $allowedTypes
     * @throws TypeError
     */
    public static function allow_argument_types(
        int $pos,
        AbstractValue $arg,
        ...$allowedTypes
    ): void {

        // If any of the "instanceof" checks is true,
        // the type is allowed - return without throwing exception.
        foreach ($allowedTypes as $type) {

            if (\is_string($type) && $arg instanceof $type) {
                return;
            } elseif (
                $type instanceof TypeValue
                && $arg->getType() === $type
            ) {
                return;
            }

        }

        throw new TypeError(\sprintf(
            "Expected '%s' but got '%s' as argument %d",
            Types::php_classes_to_primi_types($allowedTypes),
            $arg->getTypeName(),
            $pos
        ));

    }

    /**
     * @param array<string, AbstractValue|null> $current
     * @param array<string, TypeDef_AstNode> $defaults
     * @return array<string, AbstractValue>
     */
    public static function resolve_default_args(
        array $current,
        array $defaults,
        Context $ctx
    ): array {

        // Go through each of the known "defaults" for parameters and if its
        // corresponding current argument is not yet defined, use that
        // default's value definition (here presented as a AST node which we
        // can execute - which
        // is done at call-time) to fetch the argument's value.
        foreach ($defaults as $name => $astNode) {
            if (empty($current[$name])) {
                $current[$name] = HandlerFactory::runNode($astNode, $ctx);
            }
        }

        return $current;

    }

    /**
     * Takes array representing AST node and makes sure that its contents are
     * represented in a form of indexed sub-arrays. This comes handy if we want
     * to be sure that multiple AST sub-nodes (which PHP-PEG parser returns) are
     * universally iterable.
     *
     * @param TypeDef_AstNode $node
     * @return TypeDef_AstNode
     */
    public static function ensure_indexed(array $node): array {
        return !isset($node[0]) ? [$node] : $node;
    }

    /**
     * Return a `[line, pos]` tuple for given (probably multiline) string and
     * some offset.
     *
     * @return array{int, int}
     */
    public static function get_position_estimate(string $string, int $offset): array {

        $substring = \mb_substr($string, 0, $offset);

        // Current line number? Just count the newline characters up to the offset.
        $line = \substr_count($substring, "\n") + 1;

        // Position on the current line? Just count how many characters are there
        // from the substring's end back to the latest newline character. If there
        // were no newline characters (mb_strrchr() returns false), the source code
        // is a single line and in that case the position is determined simply by
        // our substring's length.
        $lastLine = \mb_strrchr($substring, "\n");
        $pos = $lastLine === \false
            ? \mb_strlen($substring)
            : \mb_strlen($lastLine);

        return [$line, $pos];

    }

    /**
     * Helper function for easier left-to-right evaluation of various abstract
     * trees representing logical/mathematical operations.
     *
     * Returns a generator yielding tuples of `[operator, operand]` with the
     * exception of first iteration, where the tuple `[null, first operand]` is
     * returned.
     *
     * For example when primi source code `1 and 2 and 3` is parsed and then
     * represented in a similar way to...
     *
     * ```php
     * [
     * 'operands' => ['Number 1', 'Number 2', 'Number 3']
     * 'ops' => ['Operator AND #1', 'Operator AND #2']
     * ]
     * ```
     *
     * ... the generator will yield (in this order):
     * - `[null, 'Number 1']`
     * - `['Operator AND #1', 'Number 2']`
     * - `['Operator AND #2', 'Number 3']`
     *
     * This way client code can, for example, implement short-circuiting by
     * using the result so-far and not processing the rest of what the generator
     * would yield.
     *
     * @param TypeDef_AstNode $node
     * @return \Generator<array{string|null, AbstractValue}>
     */
    public static function yield_left_to_right(array $node, Context $ctx) {

        foreach ($node['operands'] as $i => $operand) {

            // First operator will be null and the last one too.
            $operator = $node['ops'][$i - 1]['text'] ?? \null;
            $value = HandlerFactory::runNode($operand, $ctx);

            if (yield [$operator, $value]) {
                break;
            }

        }

    }

    /**
     * Return best available time for measuring things - as seconds.
     */
    public static function monotime(): float {
        return \hrtime(\true) / 1e9; // Nanoseconds to seconds.
    }

    /**
     * Return a random, hopefully quite unique string.
     */
    public static function unique_id(): string {
        return md5(random_bytes(128));
    }

    /**
     * @param array<StackFrame> $callstack Callstack.
     */
    public static function get_traceback_as_string(array $callstack): string {

        $result = [];
        $result[] = "Traceback:";

        foreach ($callstack as $level => $call)  {
            $call = $call->asString();
            $result[] = "[$level] {$call}";
        }

        return \implode("\n", $result);

    }

    public static function colorize_traceback(BaseException $ex): string {

        $result = \preg_replace_callback_array([
            '#^Traceback:$#m' => // "Traceback:" string.
                fn($m) => Colors::get("{green}$m[0]"),
            '#(@|in|from) (<.*?>)#m' => // E.g. "in <module: blahblah>"
                fn($m) => Colors::get("{$m[1]} {yellow}{$m[2]}{_}"),
            '#^(\[\d+\]) (.+) in #m' => // E.g. "[4] __main__.somefunc()"
                fn($m) => Colors::get("{darkgrey}{$m[1]}{_} {lightblue}{$m[2]}{_} in "),
            '#near (["\'])(.*?)\\1 @#' => // E.g. '... near "some code" @ ...'
                fn($m) => Colors::get("near {$m[1]}{lightcyan}{$m[2]}{_}{$m[1]}"),
        ], $ex->getMessage());

        return $result;

    }

    /**
     * Takes an array list of strings and returns array list of strings that are
     * guaranteed to represent a "realpath" to a directory in filesystem.
     *
     * If any of the passed strings is NOT a directory, `EngineError` is thrown.
     *
     * @param array<string> $paths
     * @return array<string>
     */
    public static function validate_dirs(array $paths): array {

        $result = [];
        foreach ($paths as &$path) {

            // Checked directory paths will be converted to "realpaths" -
            // ie. absolute paths.
            $rp = \realpath($path);
            if ($rp === \false || !is_dir($rp)) {
                throw new EngineError("Path '$path' is not a valid directory");
            }

            $result[] = rtrim($rp, '/');

        }

        return $result;

    }

    /**
     * Is string a "dunder" (double-underscored) name? Dunder starts and ends
     * with a double-underscore. For example `__init__` is a dunder.
     */
    public static function is_dunder_name(string $input): bool {
        return \str_starts_with($input, '__') && \str_ends_with($input, '__');
    }

    /**
     * Is string an "under" (underscored) name?
     */
    public static function is_under_name(string $input): bool {
        return \str_starts_with($input, '_');
    }

}