4
\$\begingroup\$

I've attempted to make a small PHP library for styling terminal output using ANSI escape sequences. The repo is located here.

To be honest, I am not really satisfied with the current structure of my code. It seems to be "not good". But I don't know any better ways to structure it.

Chalk.php

<?php
namespace TdTrung\Chalk;
use TdTrung\OSRecognizer\OSRecognizer;
class Chalk
{
    const RESET = "\033[0m";
    private $styles = [
        'reset' => 0,
        'bold' => 1,
        'dim' => 2,
        'italic' => 3,
        'underscore' => 4,
        'blink' => 5,
        'inverse' => 7,
        'strikethrough' => 9,
        'black' => 30,
        'red' => 31,
        'green' => 32,
        'yellow' => 33,
        'blue' => 34,
        'magenta' => 35,
        'cyan' => 36,
        'lightGray' => 37,
        'darkGray' => 90,
        'lightRed' => 91,
        'lightGreen' => 92,
        'lightYellow' => 93,
        'lightBlue' => 94,
        'lightMagenta' => 95,
        'lightCyan' => 96,
        'white' => 97
    ];
    private $twoStageFns = ["rgb"];
    private $osRecognizer;
    private $supportLevel = 0;
    private $enableColor = true;
    public function __construct()
    {
        $this->initSeqBuilders();
        $this->osRecognizer = new OSRecognizer;
        $this->checkColorSupport();
    }
    private function initSeqBuilders()
    {
        foreach ($this->styles as $name => $code) {
            $this->styles[$name] = function ($offset) use ($code) {
                if ($code > 0)
                    $code = $offset + $code;
                return "\033[{$code}m";
            };
        }
        $this->styles["rgb"] = function ($r, $g, $b, $offset) {
            // TODO: Fallback to ANSI 256 if possible
            if (!$this->has16mSupport()) return "";
            $type = 38 + $offset;
            return "\033[{$type};2;{$r};{$g};{$b}m";
        };
    }
    private function checkColorSupport()
    {
        if (getenv('TERM') === 'dumb') {
            return 0;
        } else if (strpos($this->osRecognizer->getPlatform(), 'win') !== false) {
            // get os version and build
            $release = explode('.', $this->osRecognizer->getRelease());
            if (intval($release[0]) >= 10 && intval($release[1]) >= 10586) {
                $this->supportLevel = intval($release[2]) >= 14931 ? 3 : 2;
                return;
            }
            $this->supportLevel = 1;
        } else if (strpos(getenv('COLORTERM'), 'truecolor') !== false) {
            $this->supportLevel = 3;
        } else if (function_exists('posix_isatty') && @!posix_isatty(STDOUT)) {
            $this->supportLevel = 1;
        } else if (preg_match('/-256(color)?$/i', getenv('TERM'))) {
            $this->supportLevel = 2;
        } else if (preg_match('/^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i', getenv('TERM'))) {
            $this->supportLevel = 1;
        } else {
            $this->supportLevel = 0;
        }
    }
    private function is256Color($styleName)
    {
        return preg_match('/^color\d+/i', $styleName);
    }
    private function isValidStyle($styleName)
    {
        if (!(strpos($styleName, 'bg') === false)) {
            preg_match('/^bg(\w+)$/', $styleName, $match);
            $styleName = lcfirst($match[1]);
        }
        return array_key_exists($styleName, $this->styles) || $this->is256Color($styleName);
    }
    private function parseStyleName($styleName)
    {
        $offset = 0;
        if (!(strpos($styleName, 'bg') === false)) {
            $offset = 10;
            preg_match('/^bg(\w+)$/', $styleName, $match);
            $styleName = lcfirst($match[1]);
        }
        return [$offset, $styleName];
    }
    private function get256Sequence($styleName, $offset)
    {
        preg_match('/^color(\d+)/i', $styleName, $match);
        $offset += 38;
        return "\033[{$offset};5;{$match[1]}m";
    }
    public function isTwoStageFns($styleName)
    {
        return array_search($styleName, $this->twoStageFns) !== false;
    }
    public function disableColor()
    {
        $this->enableColor = false;
    }
    public function hasColorSupport()
    {
        return $this->supportLevel >= 1;
    }
    public function has256Support()
    {
        return $this->supportLevel >= 2;
    }
    public function has16mSupport()
    {
        return $this->supportLevel >= 3;
    }
    public function __get($styleName)
    {
        if (!$this->isValidStyle($styleName)) {
            throw new InvalidStyleException($styleName);
        }
        list($offset, $styleName) = $this->parseStyleName($styleName);
        if ($this->is256Color($styleName)) {
            $style = $this->get256Sequence($styleName, $offset);
        } else {
            $style = $this->styles[$styleName]($offset);
        }
        return new StyleChain($style, $this);
    }
    public function __call($styleName, $arguments)
    {
        if (!$this->isValidStyle($styleName)) {
            throw InvalidStyleException($styleName);
        }
        list($offset, $styleName) = $this->parseStyleName($styleName);
        if ($this->isTwoStageFns($styleName)) {
            array_push($arguments, $offset);
            return new StyleChain(
                call_user_func_array($this->styles[$styleName], $arguments),
                $this
            );
        } else if ($this->is256Color($styleName)) {
            $style = $this->get256Sequence($styleName, $offset);
        } else {
            $style = $this->styles[$styleName]($offset);
        }
        array_unshift($arguments, [$style]);
        return call_user_func_array([$this, 'apply'], $arguments);
    }
    public function apply()
    {
        if (func_num_args() < 2) throw new InvalidArgumentException('Insufficient arguments (at least 2 are required)');
        $styles = func_get_arg(0);
        $strings = func_get_args();
        array_shift($strings);
        $text = implode(" ", $strings);
        if (!$this->enableColor || !$this->hasColorSupport()) return $text;
        return array_reduce($styles, function ($carry, $style) {
            return "{$style}{$carry}" . Chalk::RESET;
        }, $text);
    }
}

StyleChain.php

<?php
namespace TdTrung\Chalk;
class StyleChain
{
    public $styles = [];
    private $colorInstance;
    public function __construct($style, Chalk $colorInstance)
    {
        array_push($this->styles, $style);
        $this->colorInstance = $colorInstance;
    }
    public function __invoke()
    {
        $arguments = func_get_args();
        array_unshift($arguments, $this->styles);
        return call_user_func_array(
            [$this->colorInstance, 'apply'],
            $arguments
        );
    }
    public function __get($prop)
    {
        $other = $this->colorInstance->{$prop};
        $this->merge($other);
        return $this;
    }
    public function __call($method, $arguments)
    {
        if ($this->colorInstance->isTwoStageFns($method)) {
            $result = call_user_func_array(
                [$this->colorInstance, $method],
                $arguments
            );
            $this->merge($result);
            return $this;
        }
        $other = $this->colorInstance->{$method};
        $this->merge($other);
        return call_user_func_array([$this, '__invoke'], $arguments);
    }
    private function merge(StyleChain $other)
    {
        $this->styles = array_merge($this->styles, $other->styles);
    }
}
\$\endgroup\$
2
\$\begingroup\$

General Feedback

The library looks useful and the code appears to adhere to style guides like PSR-2 for the most part.

I don't see docblocks in the code. Each method should have a description of what it does, along with any parameters and the return type. Then anyone reading or contributing will be able to know how methods are implemented without having to read through them.

Are there any unit tests for the library? It would be wise to have those defined, especially since you invite others to fork the repository and contribute to it. Minimum tests would likely include testing all styles supported, as well as chaining calls and cases where an exception is expected to be thrown.

The splat operator (i.e. ...) could likely be used to simplify some of the calls that utilize call_user_func_array(), unless PHP versions prior to 5.6 need to be supported.

Suggestion about the styles array

Instead of making dynamic functions for each style in $style, use a method that accepts the color and offset. This would mean that the __get() method would need to be updated to conditionally handle rgb with that method.

Then the array stored in $style could be declared as a constant.

class Chalk
{  
    const RESET = "\033[0m";

    const STYLES = [
        'reset' => 0,
        'bold' => 1,
        'dim' => 2,

That way each instance of the class wouldn't need to have that data tied to it, and there wouldn't be a need to call the initSeqBuilders() method.

And then define new methods for getting the styles from that array as well as the RGB colors:

public function getStyle($name, $offset) {
    $code = self::STYLES[$name];
    if ($code > 0)
        $code = $offset + $code;
    return "\033[{$code}m";
}
public function getRgbStyle ($r, $g, $b, $offset) {
    // TODO: Fallback to ANSI 256 if possible
    if (!$this->has16mSupport()) return "";

    $type = 38 + $offset;
    return "\033[{$type};2;{$r};{$g};{$b}m";
}

There would obviously need to be updates to any place that $this->styles is used.

Error when throwing exception

In the __call() method of Chalk there is a possibility that an exception will be thrown:

public function __call($styleName, $arguments)
{
    if (!$this->isValidStyle($styleName)) {
        throw InvalidStyleException($styleName);
    }

Instead of instantiating a InvalidStyleException object, that code calls InvalidStyleException() as if it is a function, which causes an error because that is not a function. Add the new operator to instantiate the exception:

public function __call($styleName, $arguments)
{
    if (!$this->isValidStyle($styleName)) {
        throw new InvalidStyleException($styleName);
    }
| improve this answer | |
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.