File manager - Edit - /home/u816558632/domains/postills.com/public_html/public/nunomaduro.tar
Back
termwind/playground.php 0000644 00000000761 15002204065 0011270 0 ustar 00 <?php require_once __DIR__.'/vendor/autoload.php'; use function Termwind\render; render(<<<'HTML' <div class="mx-2 my-1"> <div class="flex space-x-1"> <span class="flex-1 truncate">Lorem ipsum dolor, sit amet consectetur adipisicing elit. Sunt illo et nisi omnis porro at, mollitia harum quas esse, aperiam dolorem ab recusandae fugiat nesciunt doloribus rem eaque nostrum itaque.</span> <span class="text-green">DONE</span> </div> </div> HTML); termwind/composer.json 0000644 00000003423 15002204065 0011113 0 ustar 00 { "name": "nunomaduro/termwind", "description": "Its like Tailwind CSS, but for the console.", "keywords": ["php", "cli", "package", "console", "css", "style"], "license": "MIT", "authors": [ { "name": "Nuno Maduro", "email": "enunomaduro@gmail.com" } ], "require": { "php": "^8.0", "ext-mbstring": "*", "symfony/console": "^5.3.0|^6.0.0" }, "require-dev": { "ergebnis/phpstan-rules": "^1.0.", "illuminate/console": "^8.0|^9.0", "illuminate/support": "^8.0|^9.0", "laravel/pint": "^1.0.0", "pestphp/pest": "^1.21.0", "pestphp/pest-plugin-mock": "^1.0", "phpstan/phpstan": "^1.4.6", "phpstan/phpstan-strict-rules": "^1.1.0", "symfony/var-dumper": "^5.2.7|^6.0.0", "thecodingmachine/phpstan-strict-rules": "^1.0.0" }, "autoload": { "psr-4": { "Termwind\\": "src/" }, "files": [ "src/Functions.php" ] }, "autoload-dev": { "psr-4": { "Tests\\": "tests/" } }, "minimum-stability": "dev", "prefer-stable": true, "config": { "sort-packages": true, "preferred-install": "dist", "allow-plugins": { "pestphp/pest-plugin": true } }, "scripts": { "lint": "pint -v", "test:lint": "pint --test -v", "test:types": "phpstan analyse --ansi", "test:unit": "pest --colors=always", "test": [ "@test:lint", "@test:types", "@test:unit" ] }, "extra": { "laravel": { "providers": [ "Termwind\\Laravel\\TermwindServiceProvider" ] } } } termwind/docker-compose.yml 0000644 00000000345 15002204065 0012026 0 ustar 00 version: '3' services: app: image: termwind-docker container_name: termwind-docker stdin_open: true tty: true build: context: . dockerfile: docker/Dockerfile volumes: - .:/usr/src/app termwind/Makefile 0000644 00000002201 15002204065 0010022 0 ustar 00 # Well documented Makefiles DEFAULT_GOAL := help help: @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n"} /^[a-zA-Z0-9_-]+:.*?##/ { printf " \033[36m%-40s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) ##@ [Docker] start: ## Spin up the container docker-compose up -d stop: ## Shut down the containers docker-compose down build: ## Build all docker images docker-compose build ##@ [Application] composer: ## Run composer commands. Specify the command e.g. via "make composer ARGS="install|update|require <dependency>" docker-compose run --rm app composer $(ARGS) lint: ## Run the Linter docker-compose run --rm app ./vendor/bin/pint -v test-lint: ## Run the Linter Test docker-compose run --rm app ./vendor/bin/pint --test -v test-types: ## Run the PHPStan analysis docker-compose run --rm app ./vendor/bin/phpstan analyse --ansi test-unit: ## Run the Pest Test Suite docker-compose run --rm app ./vendor/bin/pest --colors=always test: ## Run the tests. Apply arguments via make test ARGS="--init" make test-lint && make test-types && make test-unit termwind/src/Question.php 0000644 00000004765 15002204065 0011512 0 ustar 00 <?php declare(strict_types=1); namespace Termwind; use ReflectionClass; use Symfony\Component\Console\Helper\SymfonyQuestionHelper; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\StreamableInputInterface; use Symfony\Component\Console\Question\Question as SymfonyQuestion; use Symfony\Component\Console\Style\SymfonyStyle; use Termwind\Helpers\QuestionHelper; /** * @internal */ final class Question { /** * The streamable input to receive the input from the user. */ private static StreamableInputInterface|null $streamableInput; /** * An instance of Symfony's question helper. */ private SymfonyQuestionHelper $helper; public function __construct(SymfonyQuestionHelper $helper = null) { $this->helper = $helper ?? new QuestionHelper(); } /** * Sets the streamable input implementation. */ public static function setStreamableInput(StreamableInputInterface|null $streamableInput): void { self::$streamableInput = $streamableInput ?? new ArgvInput(); } /** * Gets the streamable input implementation. */ public static function getStreamableInput(): StreamableInputInterface { return self::$streamableInput ??= new ArgvInput(); } /** * Renders a prompt to the user. * * @param iterable<array-key, string>|null $autocomplete */ public function ask(string $question, iterable $autocomplete = null): mixed { $html = (new HtmlRenderer)->parse($question)->toString(); $question = new SymfonyQuestion($html); if ($autocomplete !== null) { $question->setAutocompleterValues($autocomplete); } $output = Termwind::getRenderer(); if ($output instanceof SymfonyStyle) { $property = (new ReflectionClass(SymfonyStyle::class)) ->getProperty('questionHelper'); $property->setAccessible(true); $currentHelper = $property->isInitialized($output) ? $property->getValue($output) : new SymfonyQuestionHelper(); $property->setValue($output, new QuestionHelper); try { return $output->askQuestion($question); } finally { $property->setValue($output, $currentHelper); } } return $this->helper->ask( self::getStreamableInput(), Termwind::getRenderer(), $question, ); } } termwind/src/Exceptions/InvalidStyle.php 0000644 00000000263 15002204065 0014420 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\Exceptions; use InvalidArgumentException; /** * @internal */ final class InvalidStyle extends InvalidArgumentException { } termwind/src/Exceptions/StyleNotFound.php 0000644 00000001117 15002204065 0014565 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\Exceptions; use InvalidArgumentException; /** * @internal */ final class StyleNotFound extends InvalidArgumentException { /** * Creates a new style not found instance. */ private function __construct(string $message) { parent::__construct($message, 0, $this->getPrevious()); } /** * Creates a new style not found instance from the given style. */ public static function fromStyle(string $style): self { return new self(sprintf('Style [%s] not found.', $style)); } } termwind/src/Exceptions/ColorNotFound.php 0000644 00000000264 15002204065 0014545 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\Exceptions; use InvalidArgumentException; /** * @internal */ final class ColorNotFound extends InvalidArgumentException { } termwind/src/Exceptions/InvalidChild.php 0000644 00000000263 15002204065 0014343 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\Exceptions; use InvalidArgumentException; /** * @internal */ final class InvalidChild extends InvalidArgumentException { } termwind/src/Exceptions/InvalidColor.php 0000644 00000000263 15002204065 0014376 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\Exceptions; use InvalidArgumentException; /** * @internal */ final class InvalidColor extends InvalidArgumentException { } termwind/src/HtmlRenderer.php 0000644 00000007467 15002204065 0012300 0 ustar 00 <?php declare(strict_types=1); namespace Termwind; use DOMDocument; use DOMNode; use Termwind\Html\CodeRenderer; use Termwind\Html\PreRenderer; use Termwind\Html\TableRenderer; use Termwind\ValueObjects\Node; /** * @internal */ final class HtmlRenderer { /** * Renders the given html. */ public function render(string $html, int $options): void { $this->parse($html)->render($options); } /** * Parses the given html. */ public function parse(string $html): Components\Element { $dom = new DOMDocument(); if (strip_tags($html) === $html) { return Termwind::span($html); } $html = '<?xml encoding="UTF-8">'.trim($html); $dom->loadHTML($html, LIBXML_NOERROR | LIBXML_COMPACT | LIBXML_HTML_NODEFDTD | LIBXML_NOBLANKS | LIBXML_NOXMLDECL); /** @var DOMNode $body */ $body = $dom->getElementsByTagName('body')->item(0); $el = $this->convert(new Node($body)); // @codeCoverageIgnoreStart return is_string($el) ? Termwind::span($el) : $el; // @codeCoverageIgnoreEnd } /** * Convert a tree of DOM nodes to a tree of termwind elements. */ private function convert(Node $node): Components\Element|string { $children = []; if ($node->isName('table')) { return (new TableRenderer)->toElement($node); } elseif ($node->isName('code')) { return (new CodeRenderer)->toElement($node); } elseif ($node->isName('pre')) { return (new PreRenderer)->toElement($node); } foreach ($node->getChildNodes() as $child) { $children[] = $this->convert($child); } $children = array_filter($children, fn ($child) => $child !== ''); return $this->toElement($node, $children); } /** * Convert a given DOM node to it's termwind element equivalent. * * @param array<int, Components\Element|string> $children */ private function toElement(Node $node, array $children): Components\Element|string { if ($node->isText() || $node->isComment()) { return (string) $node; } /** @var array<string, mixed> $properties */ $properties = [ 'isFirstChild' => $node->isFirstChild(), ]; $styles = $node->getClassAttribute(); return match ($node->getName()) { 'body' => $children[0], // Pick only the first element from the body node 'div' => Termwind::div($children, $styles, $properties), 'p' => Termwind::paragraph($children, $styles, $properties), 'ul' => Termwind::ul($children, $styles, $properties), 'ol' => Termwind::ol($children, $styles, $properties), 'li' => Termwind::li($children, $styles, $properties), 'dl' => Termwind::dl($children, $styles, $properties), 'dt' => Termwind::dt($children, $styles, $properties), 'dd' => Termwind::dd($children, $styles, $properties), 'span' => Termwind::span($children, $styles, $properties), 'br' => Termwind::breakLine($styles, $properties), 'strong' => Termwind::span($children, $styles, $properties)->strong(), 'b' => Termwind::span($children, $styles, $properties)->fontBold(), 'em', 'i' => Termwind::span($children, $styles, $properties)->italic(), 'u' => Termwind::span($children, $styles, $properties)->underline(), 's' => Termwind::span($children, $styles, $properties)->lineThrough(), 'a' => Termwind::anchor($children, $styles, $properties)->href($node->getAttribute('href')), 'hr' => Termwind::hr($styles, $properties), default => Termwind::div($children, $styles, $properties), }; } } termwind/src/Repositories/Styles.php 0000644 00000002341 15002204065 0013641 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\Repositories; use Closure; use Termwind\ValueObjects\Style; use Termwind\ValueObjects\Styles as StylesValueObject; /** * @internal */ final class Styles { /** * @var array<string, Style> */ private static array $storage = []; /** * Creates a new style from the given arguments. * * @param (Closure(StylesValueObject $element, string|int ...$arguments): StylesValueObject)|null $callback * @return Style */ public static function create(string $name, Closure $callback = null): Style { self::$storage[$name] = $style = new Style( $callback ?? static fn (StylesValueObject $styles) => $styles ); return $style; } /** * Removes all existing styles. */ public static function flush(): void { self::$storage = []; } /** * Checks a style with the given name exists. */ public static function has(string $name): bool { return array_key_exists($name, self::$storage); } /** * Gets the style with the given name. */ public static function get(string $name): Style { return self::$storage[$name]; } } termwind/src/Actions/StyleToMethod.php 0000644 00000007513 15002204065 0014041 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\Actions; use Termwind\Exceptions\StyleNotFound; use Termwind\Repositories\Styles as StyleRepository; use Termwind\Terminal; use Termwind\ValueObjects\Styles; /** * @internal */ final class StyleToMethod { /** * Finds if there is any media query on the style class. */ private const MEDIA_QUERIES_REGEX = "/^(sm|md|lg|xl|2xl)\:(.*)/"; /** * Defines the Media Query Breakpoints. */ public const MEDIA_QUERY_BREAKPOINTS = [ 'sm' => 64, 'md' => 76, 'lg' => 102, 'xl' => 128, '2xl' => 153, ]; /** * Creates a new action instance. */ public function __construct( private Styles $styles, private string $style, ) { // .. } /** * Applies multiple styles to the given styles. */ public static function multiple(Styles $styles, string $stylesString): Styles { $stylesString = self::sortStyles(array_merge( $styles->defaultStyles(), array_filter((array) preg_split('/(?![^\[]*\])\s/', $stylesString)) )); foreach ($stylesString as $style) { $styles = (new self($styles, $style))->__invoke(); } return $styles; } /** * Converts the given style to a method name. * * @return Styles */ public function __invoke(string|int ...$arguments): Styles { if (StyleRepository::has($this->style)) { return StyleRepository::get($this->style)($this->styles, ...$arguments); } $method = $this->applyMediaQuery($this->style); if ($method === '') { return $this->styles; } $method = array_filter( (array) preg_split('/(?![^\[]*\])-/', $method), fn ($item) => $item !== false ); $method = array_slice($method, 0, count($method) - count($arguments)); $methodName = implode(' ', $method); $methodName = ucwords($methodName); $methodName = lcfirst($methodName); $methodName = str_replace(' ', '', $methodName); if ($methodName === '') { throw StyleNotFound::fromStyle($this->style); } if (! method_exists($this->styles, $methodName)) { $argument = array_pop($method); $arguments[] = is_numeric($argument) ? (int) $argument : (string) $argument; return $this->__invoke(...$arguments); } return $this->styles ->setStyle($this->style) ->$methodName(...array_reverse($arguments)); } /** * Sorts all the styles based on the correct render order. * * @param string[] $styles * @return string[] */ private static function sortStyles(array $styles): array { $keys = array_keys(self::MEDIA_QUERY_BREAKPOINTS); usort($styles, function ($a, $b) use ($keys) { $existsA = (bool) preg_match(self::MEDIA_QUERIES_REGEX, $a, $matchesA); $existsB = (bool) preg_match(self::MEDIA_QUERIES_REGEX, $b, $matchesB); if ($existsA && ! $existsB) { return 1; } if ($existsA && array_search($matchesA[1], $keys, true) > array_search($matchesB[1], $keys, true)) { return 1; } return -1; }); return $styles; } /** * Applies the media query if exists. */ private function applyMediaQuery(string $method): string { $matches = []; preg_match(self::MEDIA_QUERIES_REGEX, $method, $matches); if (count($matches) < 1) { return $method; } [, $size, $method] = $matches; if ((new Terminal)->width() >= self::MEDIA_QUERY_BREAKPOINTS[$size]) { return $method; } return ''; } } termwind/src/Laravel/TermwindServiceProvider.php 0000644 00000000740 15002204065 0016103 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\Laravel; use Illuminate\Console\OutputStyle; use Illuminate\Support\ServiceProvider; use Termwind\Termwind; final class TermwindServiceProvider extends ServiceProvider { /** * Sets the correct renderer to be used. */ public function register(): void { $this->app->resolving(OutputStyle::class, function ($style): void { Termwind::renderUsing($style->getOutput()); }); } } termwind/src/Html/PreRenderer.php 0000644 00000001775 15002204065 0013022 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\Html; use Termwind\Components\Element; use Termwind\Termwind; use Termwind\ValueObjects\Node; /** * @internal */ final class PreRenderer { /** * Gets HTML content from a given node and converts to the content element. */ public function toElement(Node $node): Element { $lines = explode("\n", $node->getHtml()); if (reset($lines) === '') { array_shift($lines); } if (end($lines) === '') { array_pop($lines); } $maxStrLen = array_reduce( $lines, static fn (int $max, string $line) => ($max < strlen($line)) ? strlen($line) : $max, 0 ); $styles = $node->getClassAttribute(); $html = array_map( static fn (string $line) => (string) Termwind::div(str_pad($line, $maxStrLen + 3), $styles), $lines ); return Termwind::raw( implode('', $html) ); } } termwind/src/Html/InheritStyles.php 0000644 00000014270 15002204065 0013405 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\Html; use Termwind\Components\Element; use Termwind\Termwind; use Termwind\ValueObjects\Styles; /** * @internal */ final class InheritStyles { /** * Applies styles from parent element to child elements. * * @param array<int, Element|string> $elements * @return array<int, Element|string> */ public function __invoke(array $elements, Styles $styles): array { $elements = array_values($elements); foreach ($elements as &$element) { if (is_string($element)) { $element = Termwind::raw($element); } $element->inheritFromStyles($styles); } /** @var Element[] $elements */ if (($styles->getProperties()['styles']['display'] ?? 'inline') === 'flex') { $elements = $this->applyFlex($elements); } return match ($styles->getProperties()['styles']['justifyContent'] ?? false) { 'between' => $this->applyJustifyBetween($elements), 'evenly' => $this->applyJustifyEvenly($elements), 'around' => $this->applyJustifyAround($elements), 'center' => $this->applyJustifyCenter($elements), default => $elements, }; } /** * Applies flex-1 to child elements with the class. * * @param array<int, Element> $elements * @return array<int, Element> */ private function applyFlex(array $elements): array { [$totalWidth, $parentWidth] = $this->getWidthFromElements($elements); $width = max(0, array_reduce($elements, function ($carry, $element) { return $carry += $element->hasStyle('flex-1') ? $element->getInnerWidth() : 0; }, $parentWidth - $totalWidth)); $flexed = array_values(array_filter( $elements, fn ($element) => $element->hasStyle('flex-1') )); foreach ($flexed as $index => &$element) { if ($width === 0 && ! ($element->getProperties()['styles']['contentRepeat'] ?? false)) { continue; } $float = $width / count($flexed); $elementWidth = floor($float); if ($index === count($flexed) - 1) { $elementWidth += ($float - floor($float)) * count($flexed); } $element->addStyle("w-{$elementWidth}"); } return $elements; } /** * Applies the space between the elements. * * @param array<int, Element> $elements * @return array<int, Element|string> */ private function applyJustifyBetween(array $elements): array { if (count($elements) <= 1) { return $elements; } [$totalWidth, $parentWidth] = $this->getWidthFromElements($elements); $space = ($parentWidth - $totalWidth) / (count($elements) - 1); if ($space < 1) { return $elements; } $arr = []; foreach ($elements as $index => &$element) { if ($index !== 0) { // Since there is no float pixel, on the last one it should round up... $length = $index === count($elements) - 1 ? ceil($space) : floor($space); $arr[] = str_repeat(' ', (int) $length); } $arr[] = $element; } return $arr; } /** * Applies the space between and around the elements. * * @param array<int, Element> $elements * @return array<int, Element|string> */ private function applyJustifyEvenly(array $elements): array { [$totalWidth, $parentWidth] = $this->getWidthFromElements($elements); $space = ($parentWidth - $totalWidth) / (count($elements) + 1); if ($space < 1) { return $elements; } $arr = []; foreach ($elements as &$element) { $arr[] = str_repeat(' ', (int) floor($space)); $arr[] = $element; } $decimals = ceil(($space - floor($space)) * (count($elements) + 1)); $arr[] = str_repeat(' ', (int) (floor($space) + $decimals)); return $arr; } /** * Applies the space around the elements. * * @param array<int, Element> $elements * @return array<int, Element|string> */ private function applyJustifyAround(array $elements): array { if (count($elements) === 0) { return $elements; } [$totalWidth, $parentWidth] = $this->getWidthFromElements($elements); $space = ($parentWidth - $totalWidth) / count($elements); if ($space < 1) { return $elements; } $contentSize = $totalWidth; $arr = []; foreach ($elements as $index => &$element) { if ($index !== 0) { $arr[] = str_repeat(' ', (int) ceil($space)); $contentSize += ceil($space); } $arr[] = $element; } return [ str_repeat(' ', (int) floor(($parentWidth - $contentSize) / 2)), ...$arr, str_repeat(' ', (int) ceil(($parentWidth - $contentSize) / 2)), ]; } /** * Applies the space on before first element and after last element. * * @param array<int, Element> $elements * @return array<int, Element|string> */ private function applyJustifyCenter(array $elements): array { [$totalWidth, $parentWidth] = $this->getWidthFromElements($elements); $space = $parentWidth - $totalWidth; if ($space < 1) { return $elements; } return [ str_repeat(' ', (int) floor($space / 2)), ...$elements, str_repeat(' ', (int) ceil($space / 2)), ]; } /** * Gets the total width for the elements and their parent width. * * @param array<int, Element> $elements * @return int[] */ private function getWidthFromElements(array $elements) { $totalWidth = (int) array_reduce($elements, fn ($carry, $element) => $carry += $element->getLength(), 0); $parentWidth = Styles::getParentWidth($elements[0]->getProperties()['parentStyles'] ?? []); return [$totalWidth, $parentWidth]; } } termwind/src/Html/TableRenderer.php 0000644 00000017036 15002204065 0013320 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\Html; use Iterator; use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Helper\TableCell; use Symfony\Component\Console\Helper\TableCellStyle; use Symfony\Component\Console\Helper\TableSeparator; use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Console\Output\OutputInterface; use Termwind\Components\Element; use Termwind\HtmlRenderer; use Termwind\Termwind; use Termwind\ValueObjects\Node; use Termwind\ValueObjects\Styles; /** * @internal */ final class TableRenderer { /** * Symfony table object uses for table generation. */ private Table $table; /** * This object is used for accumulating output data from Symfony table object and return it as a string. */ private BufferedOutput $output; public function __construct() { $this->output = new BufferedOutput( // Content should output as is, without changes OutputInterface::VERBOSITY_NORMAL | OutputInterface::OUTPUT_RAW, true ); $this->table = new Table($this->output); } /** * Converts table output to the content element. */ public function toElement(Node $node): Element { $this->parseTable($node); $this->table->render(); $content = preg_replace('/\n$/', '', $this->output->fetch()) ?? ''; return Termwind::div($content, '', [ 'isFirstChild' => $node->isFirstChild(), ]); } /** * Looks for thead, tfoot, tbody, tr elements in a given DOM and appends rows from them to the Symfony table object. */ private function parseTable(Node $node): void { $style = $node->getAttribute('style'); if ($style !== '') { $this->table->setStyle($style); } foreach ($node->getChildNodes() as $child) { match ($child->getName()) { 'thead' => $this->parseHeader($child), 'tfoot' => $this->parseFoot($child), 'tbody' => $this->parseBody($child), default => $this->parseRows($child) }; } } /** * Looks for table header title and tr elements in a given thead DOM node and adds them to the Symfony table object. */ private function parseHeader(Node $node): void { $title = $node->getAttribute('title'); if ($title !== '') { $this->table->getStyle()->setHeaderTitleFormat( $this->parseTitleStyle($node) ); $this->table->setHeaderTitle($title); } foreach ($node->getChildNodes() as $child) { if ($child->isName('tr')) { foreach ($this->parseRow($child) as $row) { if (! is_array($row)) { continue; } $this->table->setHeaders($row); } } } } /** * Looks for table footer and tr elements in a given tfoot DOM node and adds them to the Symfony table object. */ private function parseFoot(Node $node): void { $title = $node->getAttribute('title'); if ($title !== '') { $this->table->getStyle()->setFooterTitleFormat( $this->parseTitleStyle($node) ); $this->table->setFooterTitle($title); } foreach ($node->getChildNodes() as $child) { if ($child->isName('tr')) { $rows = iterator_to_array($this->parseRow($child)); if (count($rows) > 0) { $this->table->addRow(new TableSeparator()); $this->table->addRows($rows); } } } } /** * Looks for tr elements in a given DOM node and adds them to the Symfony table object. */ private function parseBody(Node $node): void { foreach ($node->getChildNodes() as $child) { if ($child->isName('tr')) { $this->parseRows($child); } } } /** * Parses table tr elements. */ private function parseRows(Node $node): void { foreach ($this->parseRow($node) as $row) { $this->table->addRow($row); } } /** * Looks for th, td elements in a given DOM node and converts them to a table cells. * * @return Iterator<array<int, TableCell>|TableSeparator> */ private function parseRow(Node $node): Iterator { $row = []; foreach ($node->getChildNodes() as $child) { if ($child->isName('th') || $child->isName('td')) { $align = $child->getAttribute('align'); $class = $child->getClassAttribute(); if ($child->isName('th')) { $class .= ' strong'; } $text = (string) (new HtmlRenderer)->parse( trim(preg_replace('/<br\s?+\/?>/', "\n", $child->getHtml()) ?? '') ); if ((bool) preg_match(Styles::STYLING_REGEX, $text)) { $class .= ' font-normal'; } $row[] = new TableCell( // I need only spaces after applying margin, padding and width except tags. // There is no place for tags, they broke cell formatting. (string) Termwind::span($text, $class), [ // Gets rowspan and colspan from tr and td tag attributes 'colspan' => max((int) $child->getAttribute('colspan'), 1), 'rowspan' => max((int) $child->getAttribute('rowspan'), 1), // There are background and foreground and options 'style' => $this->parseCellStyle( $class, $align === '' ? TableCellStyle::DEFAULT_ALIGN : $align ), ] ); } } if ($row !== []) { yield $row; } $border = (int) $node->getAttribute('border'); for ($i = $border; $i--; $i > 0) { yield new TableSeparator(); } } /** * Parses tr, td tag class attribute and passes bg, fg and options to a table cell style. */ private function parseCellStyle(string $styles, string $align = TableCellStyle::DEFAULT_ALIGN): TableCellStyle { // I use this empty span for getting styles for bg, fg and options // It will be a good idea to get properties without element object and then pass them to an element object $element = Termwind::span('%s', $styles); $styles = []; $colors = $element->getProperties()['colors'] ?? []; foreach ($colors as $option => $content) { if (in_array($option, ['fg', 'bg'], true)) { $content = is_array($content) ? array_pop($content) : $content; $styles[] = "$option=$content"; } } // If there are no styles we don't need extra tags if ($styles === []) { $cellFormat = '%s'; } else { $cellFormat = '<'.implode(';', $styles).'>%s</>'; } return new TableCellStyle([ 'align' => $align, 'cellFormat' => $cellFormat, ]); } /** * Get styled representation of title. */ private function parseTitleStyle(Node $node): string { return (string) Termwind::span(' %s ', $node->getClassAttribute()); } } termwind/src/Html/CodeRenderer.php 0000644 00000020100 15002204065 0013125 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\Html; use Termwind\Components\Element; use Termwind\Termwind; use Termwind\ValueObjects\Node; /** * @internal */ final class CodeRenderer { public const TOKEN_DEFAULT = 'token_default'; public const TOKEN_COMMENT = 'token_comment'; public const TOKEN_STRING = 'token_string'; public const TOKEN_HTML = 'token_html'; public const TOKEN_KEYWORD = 'token_keyword'; public const ACTUAL_LINE_MARK = 'actual_line_mark'; public const LINE_NUMBER = 'line_number'; private const ARROW_SYMBOL_UTF8 = '➜'; private const DELIMITER_UTF8 = '▕ '; // '▶'; private const LINE_NUMBER_DIVIDER = 'line_divider'; private const MARKED_LINE_NUMBER = 'marked_line'; private const WIDTH = 3; /** * Holds the theme. * * @var array<string, string> */ private const THEME = [ self::TOKEN_STRING => 'text-gray', self::TOKEN_COMMENT => 'text-gray italic', self::TOKEN_KEYWORD => 'text-magenta strong', self::TOKEN_DEFAULT => 'strong', self::TOKEN_HTML => 'text-blue strong', self::ACTUAL_LINE_MARK => 'text-red strong', self::LINE_NUMBER => 'text-gray', self::MARKED_LINE_NUMBER => 'italic strong', self::LINE_NUMBER_DIVIDER => 'text-gray', ]; private string $delimiter = self::DELIMITER_UTF8; private string $arrow = self::ARROW_SYMBOL_UTF8; private const NO_MARK = ' '; /** * Highlights HTML content from a given node and converts to the content element. */ public function toElement(Node $node): Element { $line = max((int) $node->getAttribute('line'), 0); $startLine = max((int) $node->getAttribute('start-line'), 1); $html = $node->getHtml(); $lines = explode("\n", $html); $extraSpaces = $this->findExtraSpaces($lines); if ($extraSpaces !== '') { $lines = array_map(static function (string $line) use ($extraSpaces): string { return str_starts_with($line, $extraSpaces) ? substr($line, strlen($extraSpaces)) : $line; }, $lines); $html = implode("\n", $lines); } $tokenLines = $this->getHighlightedLines(trim($html, "\n"), $startLine); $lines = $this->colorLines($tokenLines); $lines = $this->lineNumbers($lines, $line); return Termwind::div(trim($lines, "\n")); } /** * Finds extra spaces which should be removed from HTML. * * @param array<int, string> $lines */ private function findExtraSpaces(array $lines): string { foreach ($lines as $line) { if ($line === '') { continue; } if (preg_replace('/\s+/', '', $line) === '') { return $line; } } return ''; } /** * Returns content split into lines with numbers. * * @return array<int, array<int, array{0: string, 1: non-empty-string}>> */ private function getHighlightedLines(string $source, int $startLine): array { $source = str_replace(["\r\n", "\r"], "\n", $source); $tokens = $this->tokenize($source); return $this->splitToLines($tokens, $startLine - 1); } /** * Splits content into tokens. * * @return array<int, array{0: string, 1: string}> */ private function tokenize(string $source): array { $tokens = token_get_all($source); $output = []; $currentType = null; $newType = self::TOKEN_KEYWORD; $buffer = ''; foreach ($tokens as $token) { if (is_array($token)) { if ($token[0] !== T_WHITESPACE) { $newType = match ($token[0]) { T_OPEN_TAG, T_OPEN_TAG_WITH_ECHO, T_CLOSE_TAG, T_STRING, T_VARIABLE, T_DIR, T_FILE, T_METHOD_C, T_DNUMBER, T_LNUMBER, T_NS_C, T_LINE, T_CLASS_C, T_FUNC_C, T_TRAIT_C => self::TOKEN_DEFAULT, T_COMMENT, T_DOC_COMMENT => self::TOKEN_COMMENT, T_ENCAPSED_AND_WHITESPACE, T_CONSTANT_ENCAPSED_STRING => self::TOKEN_STRING, T_INLINE_HTML => self::TOKEN_HTML, default => self::TOKEN_KEYWORD }; } } else { $newType = $token === '"' ? self::TOKEN_STRING : self::TOKEN_KEYWORD; } if ($currentType === null) { $currentType = $newType; } if ($currentType !== $newType) { $output[] = [$currentType, $buffer]; $buffer = ''; $currentType = $newType; } $buffer .= is_array($token) ? $token[1] : $token; } $output[] = [$newType, $buffer]; return $output; } /** * Splits tokens into lines. * * @param array<int, array{0: string, 1: string}> $tokens * @param int $startLine * @return array<int, array<int, array{0: string, 1: non-empty-string}>> */ private function splitToLines(array $tokens, int $startLine): array { $lines = []; $line = []; foreach ($tokens as $token) { foreach (explode("\n", $token[1]) as $count => $tokenLine) { if ($count > 0) { $lines[$startLine++] = $line; $line = []; } if ($tokenLine === '') { continue; } $line[] = [$token[0], $tokenLine]; } } $lines[$startLine++] = $line; return $lines; } /** * Applies colors to tokens according to a color schema. * * @param array<int, array<int, array{0: string, 1: non-empty-string}>> $tokenLines * @return array<int, string> */ private function colorLines(array $tokenLines): array { $lines = []; foreach ($tokenLines as $lineCount => $tokenLine) { $line = ''; foreach ($tokenLine as $token) { [$tokenType, $tokenValue] = $token; $line .= $this->styleToken($tokenType, $tokenValue); } $lines[$lineCount] = $line; } return $lines; } /** * Prepends line numbers into lines. * * @param array<int, string> $lines * @param int $markLine * @return string */ private function lineNumbers(array $lines, int $markLine): string { $lastLine = (int) array_key_last($lines); $lineLength = strlen((string) ($lastLine + 1)); $lineLength = $lineLength < self::WIDTH ? self::WIDTH : $lineLength; $snippet = ''; $mark = ' '.$this->arrow.' '; foreach ($lines as $i => $line) { $coloredLineNumber = $this->coloredLineNumber(self::LINE_NUMBER, $i, $lineLength); if (0 !== $markLine) { $snippet .= ($markLine === $i + 1 ? $this->styleToken(self::ACTUAL_LINE_MARK, $mark) : self::NO_MARK ); $coloredLineNumber = ($markLine === $i + 1 ? $this->coloredLineNumber(self::MARKED_LINE_NUMBER, $i, $lineLength) : $coloredLineNumber ); } $snippet .= $coloredLineNumber; $snippet .= $this->styleToken(self::LINE_NUMBER_DIVIDER, $this->delimiter); $snippet .= $line.PHP_EOL; } return $snippet; } /** * Formats line number and applies color according to a color schema. */ private function coloredLineNumber(string $token, int $lineNumber, int $length): string { return $this->styleToken( $token, str_pad((string) ($lineNumber + 1), $length, ' ', STR_PAD_LEFT) ); } /** * Formats string and applies color according to a color schema. */ private function styleToken(string $token, string $string): string { return (string) Termwind::span($string, self::THEME[$token]); } } termwind/src/Termwind.php 0000644 00000022052 15002204065 0011461 0 ustar 00 <?php declare(strict_types=1); namespace Termwind; use Closure; use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\OutputInterface; use Termwind\Components\Element; use Termwind\Exceptions\InvalidChild; /** * @internal */ final class Termwind { /** * The implementation of the output. */ private static OutputInterface|null $renderer; /** * Sets the renderer implementation. */ public static function renderUsing(OutputInterface|null $renderer): void { self::$renderer = $renderer ?? new ConsoleOutput(); } /** * Creates a div element instance. * * @param array<int, Element|string>|string $content * @param array<string, mixed> $properties */ public static function div(array|string $content = '', string $styles = '', array $properties = []): Components\Div { $content = self::prepareElements($content, $styles); return Components\Div::fromStyles( self::getRenderer(), $content, $styles, $properties ); } /** * Creates a paragraph element instance. * * @param array<int, Element|string>|string $content * @param array<string, mixed> $properties */ public static function paragraph(array|string $content = '', string $styles = '', array $properties = []): Components\Paragraph { $content = self::prepareElements($content, $styles); return Components\Paragraph::fromStyles( self::getRenderer(), $content, $styles, $properties ); } /** * Creates a span element instance with the given style. * * @param array<int, Element|string>|string $content * @param array<string, mixed> $properties */ public static function span(array|string $content = '', string $styles = '', array $properties = []): Components\Span { $content = self::prepareElements($content, $styles); return Components\Span::fromStyles( self::getRenderer(), $content, $styles, $properties ); } /** * Creates an element instance with raw content. * * @param array<int, Element|string>|string $content */ public static function raw(array|string $content = ''): Components\Raw { return Components\Raw::fromStyles( self::getRenderer(), $content ); } /** * Creates an anchor element instance with the given style. * * @param array<int, Element|string>|string $content * @param array<string, mixed> $properties */ public static function anchor(array|string $content = '', string $styles = '', array $properties = []): Components\Anchor { $content = self::prepareElements($content, $styles); return Components\Anchor::fromStyles( self::getRenderer(), $content, $styles, $properties ); } /** * Creates an unordered list instance. * * @param array<int, string|Element> $content * @param array<string, mixed> $properties */ public static function ul(array $content = [], string $styles = '', array $properties = []): Components\Ul { $ul = Components\Ul::fromStyles( self::getRenderer(), '', $styles, $properties ); $content = self::prepareElements( $content, $styles, static function ($li) use ($ul): string|Element { if (is_string($li)) { return $li; } if (! $li instanceof Components\Li) { throw new InvalidChild('Unordered lists only accept `li` as child'); } return match (true) { $li->hasStyle('list-none') => $li, $ul->hasStyle('list-none') => $li->addStyle('list-none'), $ul->hasStyle('list-square') => $li->addStyle('list-square'), $ul->hasStyle('list-disc') => $li->addStyle('list-disc'), default => $li->addStyle('list-none'), }; } ); return $ul->setContent($content); } /** * Creates an ordered list instance. * * @param array<int, string|Element> $content * @param array<string, mixed> $properties */ public static function ol(array $content = [], string $styles = '', array $properties = []): Components\Ol { $ol = Components\Ol::fromStyles( self::getRenderer(), '', $styles, $properties ); $index = 0; $content = self::prepareElements( $content, $styles, static function ($li) use ($ol, &$index): string|Element { if (is_string($li)) { return $li; } if (! $li instanceof Components\Li) { throw new InvalidChild('Ordered lists only accept `li` as child'); } return match (true) { $li->hasStyle('list-none') => $li->addStyle('list-none'), $ol->hasStyle('list-none') => $li->addStyle('list-none'), $ol->hasStyle('list-decimal') => $li->addStyle('list-decimal-'.(++$index)), default => $li->addStyle('list-none'), }; } ); return $ol->setContent($content); } /** * Creates a list item instance. * * @param array<int, Element|string>|string $content * @param array<string, mixed> $properties */ public static function li(array|string $content = '', string $styles = '', array $properties = []): Components\Li { $content = self::prepareElements($content, $styles); return Components\Li::fromStyles( self::getRenderer(), $content, $styles, $properties ); } /** * Creates a description list instance. * * @param array<int, string|Element> $content * @param array<string, mixed> $properties */ public static function dl(array $content = [], string $styles = '', array $properties = []): Components\Dl { $content = self::prepareElements( $content, $styles, static function ($element): string|Element { if (is_string($element)) { return $element; } if (! $element instanceof Components\Dt && ! $element instanceof Components\Dd) { throw new InvalidChild('Description lists only accept `dt` and `dd` as children'); } return $element; } ); return Components\Dl::fromStyles( self::getRenderer(), $content, $styles, $properties ); } /** * Creates a description term instance. * * @param array<int, Element|string>|string $content * @param array<string, mixed> $properties */ public static function dt(array|string $content = '', string $styles = '', array $properties = []): Components\Dt { $content = self::prepareElements($content, $styles); return Components\Dt::fromStyles( self::getRenderer(), $content, $styles, $properties ); } /** * Creates a description details instance. * * @param array<int, Element|string>|string $content * @param array<string, mixed> $properties */ public static function dd(array|string $content = '', string $styles = '', array $properties = []): Components\Dd { $content = self::prepareElements($content, $styles); return Components\Dd::fromStyles( self::getRenderer(), $content, $styles, $properties ); } /** * Creates a horizontal rule instance. * * @param array<string, mixed> $properties */ public static function hr(string $styles = '', array $properties = []): Components\Hr { return Components\Hr::fromStyles( self::getRenderer(), '', $styles, $properties ); } /** * Creates an break line element instance. * * @param array<string, mixed> $properties */ public static function breakLine(string $styles = '', array $properties = []): Components\BreakLine { return Components\BreakLine::fromStyles( self::getRenderer(), '', $styles, $properties ); } /** * Gets the current renderer instance. */ public static function getRenderer(): OutputInterface { return self::$renderer ??= new ConsoleOutput(); } /** * Convert child elements to a string. * * @param array<int, string|Element>|string $elements * @return array<int, string|Element> */ private static function prepareElements($elements, string $styles = '', Closure|null $callback = null): array { if ($callback === null) { $callback = static fn ($element): string|Element => $element; } $elements = is_array($elements) ? $elements : [$elements]; return array_map($callback, $elements); } } termwind/src/Functions.php 0000644 00000002772 15002204065 0011647 0 ustar 00 <?php declare(strict_types=1); namespace Termwind; use Closure; use Symfony\Component\Console\Output\OutputInterface; use Termwind\Repositories\Styles as StyleRepository; use Termwind\ValueObjects\Style; use Termwind\ValueObjects\Styles; if (! function_exists('Termwind\renderUsing')) { /** * Sets the renderer implementation. */ function renderUsing(OutputInterface|null $renderer): void { Termwind::renderUsing($renderer); } } if (! function_exists('Termwind\style')) { /** * Creates a new style. * * @param (Closure(Styles $renderable, string|int ...$arguments): Styles)|null $callback */ function style(string $name, Closure $callback = null): Style { return StyleRepository::create($name, $callback); } } if (! function_exists('Termwind\render')) { /** * Render HTML to a string. */ function render(string $html, int $options = OutputInterface::OUTPUT_NORMAL): void { (new HtmlRenderer)->render($html, $options); } } if (! function_exists('Termwind\terminal')) { /** * Returns a Terminal instance. */ function terminal(): Terminal { return new Terminal; } } if (! function_exists('Termwind\ask')) { /** * Renders a prompt to the user. * * @param iterable<array-key, string>|null $autocomplete */ function ask(string $question, iterable $autocomplete = null): mixed { return (new Question)->ask($question, $autocomplete); } } termwind/src/Components/Div.php 0000644 00000000234 15002204065 0012535 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\Components; final class Div extends Element { protected static array $defaultStyles = ['block']; } termwind/src/Components/Raw.php 0000644 00000000512 15002204065 0012543 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\Components; /** * @internal */ final class Raw extends Element { /** * Get the string representation of the element. */ public function toString(): string { return is_array($this->content) ? implode('', $this->content) : $this->content; } } termwind/src/Components/Span.php 0000644 00000000160 15002204065 0012712 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\Components; final class Span extends Element { // .. } termwind/src/Components/BreakLine.php 0000644 00000000775 15002204065 0013661 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\Components; final class BreakLine extends Element { /** * Get the string representation of the element. */ public function toString(): string { $display = $this->styles->getProperties()['styles']['display'] ?? 'inline'; if ($display === 'hidden') { return ''; } if ($display === 'block') { return parent::toString(); } return parent::toString()."\r"; } } termwind/src/Components/Dl.php 0000644 00000000233 15002204065 0012351 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\Components; final class Dl extends Element { protected static array $defaultStyles = ['block']; } termwind/src/Components/Anchor.php 0000644 00000000150 15002204065 0013222 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\Components; final class Anchor extends Element { } termwind/src/Components/Ul.php 0000644 00000000250 15002204065 0012371 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\Components; final class Ul extends Element { protected static array $defaultStyles = ['block', 'list-disc']; } termwind/src/Components/Ol.php 0000644 00000000253 15002204065 0012366 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\Components; final class Ol extends Element { protected static array $defaultStyles = ['block', 'list-decimal']; } termwind/src/Components/Dd.php 0000644 00000000243 15002204065 0012342 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\Components; final class Dd extends Element { protected static array $defaultStyles = ['block', 'ml-4']; } termwind/src/Components/Paragraph.php 0000644 00000000252 15002204065 0013720 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\Components; final class Paragraph extends Element { protected static array $defaultStyles = ['block', 'my-1']; } termwind/src/Components/Dt.php 0000644 00000000250 15002204065 0012360 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\Components; final class Dt extends Element { protected static array $defaultStyles = ['block', 'font-bold']; } termwind/src/Components/Li.php 0000644 00000000233 15002204065 0012356 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\Components; final class Li extends Element { protected static array $defaultStyles = ['block']; } termwind/src/Components/Element.php 0000644 00000006312 15002204065 0013407 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\Components; use Symfony\Component\Console\Output\OutputInterface; use Termwind\Actions\StyleToMethod; use Termwind\Html\InheritStyles; use Termwind\ValueObjects\Styles; /** * @internal * * @method Element inheritFromStyles(Styles $styles) * @method Element fontBold() * @method Element strong() * @method Element italic() * @method Element underline() * @method Element lineThrough() * @method int getLength() * @method int getInnerWidth() * @method array getProperties() * @method Element href(string $href) * @method bool hasStyle(string $style) * @method Element addStyle(string $style) */ abstract class Element { /** @var string[] */ protected static array $defaultStyles = []; protected Styles $styles; /** * Creates an element instance. * * @param array<int, Element|string>|string $content */ final public function __construct( protected OutputInterface $output, protected array|string $content, Styles|null $styles = null ) { $this->styles = $styles ?? new Styles(defaultStyles: static::$defaultStyles); $this->styles->setElement($this); } /** * Creates an element instance with the given styles. * * @param array<int, Element|string>|string $content * @param array<string, mixed> $properties */ final public static function fromStyles(OutputInterface $output, array|string $content, string $styles = '', array $properties = []): static { $element = new static($output, $content); if ($properties !== []) { $element->styles->setProperties($properties); } $elementStyles = StyleToMethod::multiple($element->styles, $styles); return new static($output, $content, $elementStyles); } /** * Get the string representation of the element. */ public function toString(): string { if (is_array($this->content)) { $inheritance = new InheritStyles(); $this->content = implode('', $inheritance($this->content, $this->styles)); } return $this->styles->format($this->content); } /** * @param array<int, mixed> $arguments */ public function __call(string $name, array $arguments): mixed { if (method_exists($this->styles, $name)) { $result = $this->styles->{$name}(...$arguments); if (str_starts_with($name, 'get') || str_starts_with($name, 'has')) { return $result; } } return $this; } /** * Sets the content of the element. * * @param array<int, Element|string>|string $content */ final public function setContent(array|string $content): static { return new static($this->output, $content, $this->styles); } /** * Renders the string representation of the element on the output. */ final public function render(int $options): void { $this->output->writeln($this->toString(), $options); } /** * Get the string representation of the element. */ final public function __toString(): string { return $this->toString(); } } termwind/src/Components/Hr.php 0000644 00000000247 15002204065 0012370 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\Components; final class Hr extends Element { protected static array $defaultStyles = ['block', 'border-t']; } termwind/src/ValueObjects/Styles.php 0000644 00000066756 15002204065 0013564 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\ValueObjects; use Closure; use Termwind\Actions\StyleToMethod; use Termwind\Components\Element; use Termwind\Components\Hr; use Termwind\Components\Li; use Termwind\Components\Ol; use Termwind\Components\Ul; use Termwind\Enums\Color; use Termwind\Exceptions\ColorNotFound; use Termwind\Exceptions\InvalidStyle; use Termwind\Repositories\Styles as StyleRepository; use function Termwind\terminal; /** * @internal */ final class Styles { /** * Finds all the styling on a string. */ public const STYLING_REGEX = "/\<[\w=#\/\;,:.&,%?-]+\>|\\e\[\d+m/"; /** @var array<int, string> */ private array $styles = []; private ?Element $element = null; /** * Creates a Style formatter instance. * * @param array<string, mixed> $properties * @param array<string, Closure(string, array<string, string|int>, array<string, int[]>): string> $textModifiers * @param array<string, Closure(string, array<string, string|int>): string> $styleModifiers * @param string[] $defaultStyles */ final public function __construct( private array $properties = [ 'colors' => [], 'options' => [], 'isFirstChild' => false, ], private array $textModifiers = [], private array $styleModifiers = [], private array $defaultStyles = [] ) { } /** * @param Element $element * @return $this */ public function setElement(Element $element): self { $this->element = $element; return $this; } /** * Gets default styles. * * @return string[] */ public function defaultStyles(): array { return $this->defaultStyles; } /** * Gets the element's style properties. * * @return array<string, mixed> */ final public function getProperties(): array { return $this->properties; } /** * Sets the element's style properties. * * @param array<string, mixed> $properties */ public function setProperties(array $properties): self { $this->properties = $properties; return $this; } /** * Sets the styles to the element. */ final public function setStyle(string $style): self { $this->styles = array_unique(array_merge($this->styles, [$style])); return $this; } /** * Checks if the element has the style. */ final public function hasStyle(string $style): bool { return in_array($style, $this->styles, true); } /** * Adds a style to the element. */ final public function addStyle(string $style): self { return StyleToMethod::multiple($this, $style); } /** * Inherit styles from given Styles object. */ final public function inheritFromStyles(self $styles): self { foreach (['ml', 'mr', 'pl', 'pr', 'width', 'minWidth', 'maxWidth', 'spaceY', 'spaceX'] as $style) { $this->properties['parentStyles'][$style] = array_merge( $this->properties['parentStyles'][$style] ?? [], $styles->properties['parentStyles'][$style] ?? [] ); $this->properties['parentStyles'][$style][] = $styles->properties['styles'][$style] ?? 0; } $this->properties['parentStyles']['justifyContent'] = $styles->properties['styles']['justifyContent'] ?? false; foreach (['bg', 'fg'] as $colorType) { $value = (array) ($this->properties['colors'][$colorType] ?? []); $parentValue = (array) ($styles->properties['colors'][$colorType] ?? []); if ($value === [] && $parentValue !== []) { $this->properties['colors'][$colorType] = $styles->properties['colors'][$colorType]; } } if (! is_null($this->properties['options']['bold'] ?? null) || ! is_null($styles->properties['options']['bold'] ?? null)) { $this->properties['options']['bold'] = $this->properties['options']['bold'] ?? $styles->properties['options']['bold'] ?? false; } return $this; } /** * Adds a background color to the element. */ final public function bg(string $color, int $variant = 0): self { return $this->with(['colors' => [ 'bg' => $this->getColorVariant($color, $variant), ]]); } /** * Adds a bold style to the element. */ final public function fontBold(): self { return $this->with(['options' => [ 'bold' => true, ]]); } /** * Removes the bold style on the element. */ final public function fontNormal(): self { return $this->with(['options' => [ 'bold' => false, ]]); } /** * Adds a bold style to the element. */ final public function strong(): self { $this->styleModifiers[__METHOD__] = static fn ($text): string => sprintf("\e[1m%s\e[0m", $text); return $this; } /** * Adds an italic style to the element. */ final public function italic(): self { $this->styleModifiers[__METHOD__] = static fn ($text): string => sprintf("\e[3m%s\e[0m", $text); return $this; } /** * Adds an underline style. */ final public function underline(): self { $this->styleModifiers[__METHOD__] = static fn ($text): string => sprintf("\e[4m%s\e[0m", $text); return $this; } /** * Adds the given margin left to the element. */ final public function ml(int $margin): self { return $this->with(['styles' => [ 'ml' => $margin, ]]); } /** * Adds the given margin right to the element. */ final public function mr(int $margin): self { return $this->with(['styles' => [ 'mr' => $margin, ]]); } /** * Adds the given margin bottom to the element. */ final public function mb(int $margin): self { return $this->with(['styles' => [ 'mb' => $margin, ]]); } /** * Adds the given margin top to the element. */ final public function mt(int $margin): self { return $this->with(['styles' => [ 'mt' => $margin, ]]); } /** * Adds the given horizontal margin to the element. */ final public function mx(int $margin): self { return $this->with(['styles' => [ 'ml' => $margin, 'mr' => $margin, ]]); } /** * Adds the given vertical margin to the element. */ final public function my(int $margin): self { return $this->with(['styles' => [ 'mt' => $margin, 'mb' => $margin, ]]); } /** * Adds the given margin to the element. */ final public function m(int $margin): self { return $this->my($margin)->mx($margin); } /** * Adds the given padding left to the element. */ final public function pl(int $padding): static { return $this->with(['styles' => [ 'pl' => $padding, ]]); } /** * Adds the given padding right. */ final public function pr(int $padding): static { return $this->with(['styles' => [ 'pr' => $padding, ]]); } /** * Adds the given horizontal padding. */ final public function px(int $padding): self { return $this->pl($padding)->pr($padding); } /** * Adds the given padding top. */ final public function pt(int $padding): static { return $this->with(['styles' => [ 'pt' => $padding, ]]); } /** * Adds the given padding bottom. */ final public function pb(int $padding): static { return $this->with(['styles' => [ 'pb' => $padding, ]]); } /** * Adds the given vertical padding. */ final public function py(int $padding): self { return $this->pt($padding)->pb($padding); } /** * Adds the given padding. */ final public function p(int $padding): self { return $this->pt($padding)->pr($padding)->pb($padding)->pl($padding); } /** * Adds the given vertical margin to the childs, ignoring the first child. */ final public function spaceY(int $space): self { return $this->with(['styles' => [ 'spaceY' => $space, ]]); } /** * Adds the given horizontal margin to the childs, ignoring the first child. */ final public function spaceX(int $space): self { return $this->with(['styles' => [ 'spaceX' => $space, ]]); } /** * Adds a border on top of each element. */ final public function borderT(int $width = 1): self { if (! $this->element instanceof Hr) { throw new InvalidStyle('`border-t` can only be used on an "hr" element.'); } $this->styleModifiers[__METHOD__] = function ($text, $styles): string { $length = $this->getLength($text); if ($length < 1) { $margins = (int) ($styles['ml'] ?? 0) + ($styles['mr'] ?? 0); return str_repeat('─', self::getParentWidth($this->properties['parentStyles'] ?? []) - $margins); } return str_repeat('─', $length); }; return $this; } /** * Adds a text alignment or color to the element. */ final public function text(string $value, int $variant = 0): self { if (in_array($value, ['left', 'right', 'center'], true)) { return $this->with(['styles' => [ 'text-align' => $value, ]]); } return $this->with(['colors' => [ 'fg' => $this->getColorVariant($value, $variant), ]]); } /** * Truncates the text of the element. */ final public function truncate(int $limit = 0, string $end = '…'): self { $this->textModifiers[__METHOD__] = function ($text, $styles) use ($limit, $end): string { $width = $styles['width'] ?? 0; if (is_string($width)) { $width = self::calcWidthFromFraction( $width, $styles, $this->properties['parentStyles'] ?? [] ); } [, $paddingRight, , $paddingLeft] = $this->getPaddings(); $width -= $paddingRight + $paddingLeft; $limit = $limit > 0 ? $limit : $width; if ($limit === 0) { return $text; } $limit -= mb_strwidth($end, 'UTF-8'); if ($this->getLength($text) <= $limit) { return $text; } return rtrim(self::trimText($text, $limit).$end); }; return $this; } /** * Forces the width of the element. */ final public function w(int|string $width): static { return $this->with(['styles' => [ 'width' => $width, ]]); } /** * Forces the element width to the full width of the terminal. */ final public function wFull(): static { return $this->w('1/1'); } /** * Removes the width set on the element. */ final public function wAuto(): static { return $this->with(['styles' => [ 'width' => null, ]]); } /** * Defines a minimum width of an element. */ final public function minW(int|string $width): static { return $this->with(['styles' => [ 'minWidth' => $width, ]]); } /** * Defines a maximum width of an element. */ final public function maxW(int|string $width): static { return $this->with(['styles' => [ 'maxWidth' => $width, ]]); } /** * Makes the element's content uppercase. */ final public function uppercase(): self { $this->textModifiers[__METHOD__] = static fn ($text): string => mb_strtoupper($text, 'UTF-8'); return $this; } /** * Makes the element's content lowercase. */ final public function lowercase(): self { $this->textModifiers[__METHOD__] = static fn ($text): string => mb_strtolower($text, 'UTF-8'); return $this; } /** * Makes the element's content capitalize. */ final public function capitalize(): self { $this->textModifiers[__METHOD__] = static fn ($text): string => mb_convert_case($text, MB_CASE_TITLE, 'UTF-8'); return $this; } /** * Makes the element's content in snakecase. */ final public function snakecase(): self { $this->textModifiers[__METHOD__] = static fn ($text): string => mb_strtolower( (string) preg_replace(['/([a-z\d])([A-Z])/', '/([^_])([A-Z][a-z])/'], '$1_$2', $text), 'UTF-8' ); return $this; } /** * Makes the element's content with a line through. */ final public function lineThrough(): self { $this->styleModifiers[__METHOD__] = static fn ($text): string => sprintf("\e[9m%s\e[0m", $text); return $this; } /** * Makes the element's content invisible. */ final public function invisible(): self { $this->styleModifiers[__METHOD__] = static fn ($text): string => sprintf("\e[8m%s\e[0m", $text); return $this; } /** * Do not display element's content. */ final public function hidden(): self { return $this->with(['styles' => [ 'display' => 'hidden', ]]); } /** * Makes a line break before the element's content. */ final public function block(): self { return $this->with(['styles' => [ 'display' => 'block', ]]); } /** * Makes an element eligible to work with flex-1 element's style. */ final public function flex(): self { return $this->with(['styles' => [ 'display' => 'flex', ]]); } /** * Makes an element grow and shrink as needed, ignoring the initial size. */ final public function flex1(): self { return $this->with(['styles' => [ 'flex-1' => true, ]]); } /** * Justifies childs along the element with an equal amount of space between. */ final public function justifyBetween(): self { return $this->with(['styles' => [ 'justifyContent' => 'between', ]]); } /** * Justifies childs along the element with an equal amount of space between * each item and half around. */ final public function justifyAround(): self { return $this->with(['styles' => [ 'justifyContent' => 'around', ]]); } /** * Justifies childs along the element with an equal amount of space around each item. */ final public function justifyEvenly(): self { return $this->with(['styles' => [ 'justifyContent' => 'evenly', ]]); } /** * Justifies childs along the center of the container’s main axis. */ final public function justifyCenter(): self { return $this->with(['styles' => [ 'justifyContent' => 'center', ]]); } /** * Repeats the string given until it fills all the content. */ final public function contentRepeat(string $string): self { $string = preg_replace("/\[?'?([^'|\]]+)'?\]?/", '$1', $string) ?? ''; $this->textModifiers[__METHOD__] = static fn (): string => str_repeat($string, (int) floor(terminal()->width() / mb_strlen($string, 'UTF-8'))); return $this->with(['styles' => [ 'contentRepeat' => true, ]]); } /** * Prepends text to the content. */ final public function prepend(string $string): self { $this->textModifiers[__METHOD__] = static fn ($text): string => $string.$text; return $this; } /** * Appends text to the content. */ final public function append(string $string): self { $this->textModifiers[__METHOD__] = static fn ($text): string => $text.$string; return $this; } /** * Prepends the list style type to the content. */ final public function list(string $type, int $index = 0): self { if (! $this->element instanceof Ul && ! $this->element instanceof Ol && ! $this->element instanceof Li) { throw new InvalidStyle(sprintf( 'Style list-none cannot be used with %s', $this->element !== null ? $this->element::class : 'unknown element' )); } if (! $this->element instanceof Li) { return $this; } return match ($type) { 'square' => $this->prepend('▪ '), 'disc' => $this->prepend('• '), 'decimal' => $this->prepend(sprintf('%d. ', $index)), default => $this, }; } /** * Adds the given properties to the element. * * @param array<string, mixed> $properties */ public function with(array $properties): self { $this->properties = array_replace_recursive($this->properties, $properties); return $this; } /** * Sets the href property to the element. */ final public function href(string $href): self { $href = str_replace('%', '%%', $href); return $this->with(['href' => array_filter([$href])]); } /** * Formats a given string. */ final public function format(string $content): string { foreach ($this->textModifiers as $modifier) { $content = $modifier( $content, $this->properties['styles'] ?? [], $this->properties['parentStyles'] ?? [] ); } $content = $this->applyWidth($content); foreach ($this->styleModifiers as $modifier) { $content = $modifier($content, $this->properties['styles'] ?? []); } return $this->applyStyling($content); } /** * Get the format string including required styles. */ private function getFormatString(): string { $styles = []; /** @var array<int, string> $href */ $href = $this->properties['href'] ?? []; if ($href !== []) { $styles[] = sprintf('href=%s', array_pop($href)); } $colors = $this->properties['colors'] ?? []; foreach ($colors as $option => $content) { if (in_array($option, ['fg', 'bg'], true)) { $content = is_array($content) ? array_pop($content) : $content; $styles[] = "$option=$content"; } } $options = $this->properties['options'] ?? []; if ($options !== []) { $options = array_keys(array_filter( $options, fn ($option) => $option === true )); $styles[] = count($options) > 0 ? 'options='.implode(',', $options) : 'options=,'; } // If there are no styles we don't need extra tags if ($styles === []) { return '%s%s%s%s%s'; } return '%s<'.implode(';', $styles).'>%s%s%s</>%s'; } /** * Get the margins applied to the element. * * @return array{0: int, 1: int, 2: int, 3: int} */ private function getMargins(): array { $isFirstChild = (bool) $this->properties['isFirstChild']; $spaceY = $this->properties['parentStyles']['spaceY'] ?? []; $spaceY = ! $isFirstChild ? end($spaceY) : 0; $spaceX = $this->properties['parentStyles']['spaceX'] ?? []; $spaceX = ! $isFirstChild ? end($spaceX) : 0; return [ $spaceY > 0 ? $spaceY : $this->properties['styles']['mt'] ?? 0, $this->properties['styles']['mr'] ?? 0, $this->properties['styles']['mb'] ?? 0, $spaceX > 0 ? $spaceX : $this->properties['styles']['ml'] ?? 0, ]; } /** * Get the paddings applied to the element. * * @return array{0: int, 1: int, 2: int, 3: int} */ private function getPaddings(): array { return [ $this->properties['styles']['pt'] ?? 0, $this->properties['styles']['pr'] ?? 0, $this->properties['styles']['pb'] ?? 0, $this->properties['styles']['pl'] ?? 0, ]; } /** * It applies the correct width for the content. */ private function applyWidth(string $content): string { $styles = $this->properties['styles'] ?? []; $minWidth = $styles['minWidth'] ?? -1; $width = max($styles['width'] ?? -1, $minWidth); $maxWidth = $styles['maxWidth'] ?? 0; if ($width < 0) { return $content; } if ($width === 0) { return ''; } if (is_string($width)) { $width = self::calcWidthFromFraction( $width, $styles, $this->properties['parentStyles'] ?? [] ); } if ($maxWidth > 0) { $width = min($styles['maxWidth'], $width); } $width -= ($styles['pl'] ?? 0) + ($styles['pr'] ?? 0); $length = $this->getLength($content); preg_match_all("/\n+/", $content, $matches); $width *= count($matches[0] ?? []) + 1; $width += mb_strlen($matches[0][0] ?? '', 'UTF-8'); if ($length <= $width) { $space = $width - $length; return match ($styles['text-align'] ?? '') { 'right' => str_repeat(' ', $space).$content, 'center' => str_repeat(' ', (int) floor($space / 2)).$content.str_repeat(' ', (int) ceil($space / 2)), default => $content.str_repeat(' ', $space), }; } return self::trimText($content, $width); } /** * It applies the styling for the content. */ private function applyStyling(string $content): string { $display = $this->properties['styles']['display'] ?? 'inline'; if ($display === 'hidden') { return ''; } $isFirstChild = (bool) $this->properties['isFirstChild']; [$marginTop, $marginRight, $marginBottom, $marginLeft] = $this->getMargins(); [$paddingTop, $paddingRight, $paddingBottom, $paddingLeft] = $this->getPaddings(); $content = (string) preg_replace('/\r[ \t]?/', "\n", (string) preg_replace( '/\n/', str_repeat(' ', $marginRight + $paddingRight) ."\n". str_repeat(' ', $marginLeft + $paddingLeft), $content) ); $formatted = sprintf( $this->getFormatString(), str_repeat(' ', $marginLeft), str_repeat(' ', $paddingLeft), $content, str_repeat(' ', $paddingRight), str_repeat(' ', $marginRight), ); $empty = str_replace( $content, str_repeat(' ', $this->getLength($content)), $formatted ); $items = []; if (in_array($display, ['block', 'flex'], true) && ! $isFirstChild) { $items[] = "\n"; } if ($marginTop > 0) { $items[] = str_repeat("\n", $marginTop); } if ($paddingTop > 0) { $items[] = $empty."\n"; } $items[] = $formatted; if ($paddingBottom > 0) { $items[] = "\n".$empty; } if ($marginBottom > 0) { $items[] = str_repeat("\n", $marginBottom); } return implode('', $items); } /** * Get the length of the text provided without the styling tags. */ public function getLength(string $text = null): int { return mb_strlen(preg_replace( self::STYLING_REGEX, '', $text ?? $this->element?->toString() ?? '' ) ?? '', 'UTF-8'); } /** * Get the length of the element without margins. */ public function getInnerWidth(): int { $innerLength = $this->getLength(); [, $marginRight, , $marginLeft] = $this->getMargins(); return $innerLength - $marginLeft - $marginRight; } /** * Get the constant variant color from Color class. */ private function getColorVariant(string $color, int $variant): string { if ($variant > 0) { $color .= '-'.$variant; } if (StyleRepository::has($color)) { return StyleRepository::get($color)->getColor(); } $colorConstant = mb_strtoupper(str_replace('-', '_', $color), 'UTF-8'); if (! defined(Color::class."::$colorConstant")) { throw new ColorNotFound($colorConstant); } return constant(Color::class."::$colorConstant"); } /** * Calculates the width based on the fraction provided. * * @param array<string, int> $styles * @param array<string, array<int, int|string>> $parentStyles */ private static function calcWidthFromFraction(string $fraction, array $styles, array $parentStyles): int { $width = self::getParentWidth($parentStyles); preg_match('/(\d+)\/(\d+)/', $fraction, $matches); if (count($matches) !== 3 || $matches[2] === '0') { throw new InvalidStyle(sprintf('Style [%s] is invalid.', "w-$fraction")); } /** @@phpstan-ignore-next-line */ $width = (int) floor($width * $matches[1] / $matches[2]); $width -= ($styles['ml'] ?? 0) + ($styles['mr'] ?? 0); return $width; } /** * Gets the width of the parent element. * * @param array<string, array<int|string>> $styles */ public static function getParentWidth(array $styles): int { $width = terminal()->width(); foreach ($styles['width'] ?? [] as $index => $parentWidth) { $minWidth = (int) $styles['minWidth'][$index]; $maxWidth = (int) $styles['maxWidth'][$index]; $margins = (int) $styles['ml'][$index] + (int) $styles['mr'][$index]; $parentWidth = max($parentWidth, $minWidth); if ($parentWidth < 1) { $parentWidth = $width; } elseif (is_int($parentWidth)) { $parentWidth += $margins; } preg_match('/(\d+)\/(\d+)/', (string) $parentWidth, $matches); $width = count($matches) !== 3 ? (int) $parentWidth : (int) floor($width * $matches[1] / $matches[2]); //@phpstan-ignore-line if ($maxWidth > 0) { $width = min($maxWidth, $width); } $width -= $margins; $width -= (int) $styles['pl'][$index] + (int) $styles['pr'][$index]; } return $width; } /** * It trims the text properly ignoring all escape codes and * `<bg;fg;options>` tags. */ private static function trimText(string $text, int $width): string { preg_match_all(self::STYLING_REGEX, $text, $matches, PREG_OFFSET_CAPTURE); $text = rtrim(mb_strimwidth(preg_replace(self::STYLING_REGEX, '', $text) ?? '', 0, $width, '', 'UTF-8')); foreach ($matches[0] ?? [] as [$part, $index]) { $text = substr($text, 0, $index).$part.substr($text, $index, null); } return $text; } } termwind/src/ValueObjects/Style.php 0000644 00000003054 15002204065 0013357 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\ValueObjects; use Closure; use Termwind\Actions\StyleToMethod; use Termwind\Exceptions\InvalidColor; /** * @internal */ final class Style { /** * Creates a new value object instance. * * @param Closure(Styles $styles, string|int ...$argument): Styles $callback */ public function __construct(private Closure $callback, private string $color = '') { // .. } /** * Apply the given set of styles to the styles. */ public function apply(string $styles): void { $callback = clone $this->callback; $this->callback = static function ( Styles $formatter, string|int ...$arguments ) use ($callback, $styles): Styles { $formatter = $callback($formatter, ...$arguments); return StyleToMethod::multiple($formatter, $styles); }; } /** * Sets the color to the style. */ public function color(string $color): void { if (preg_match('/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/', $color) < 1) { throw new InvalidColor(sprintf('The color %s is invalid.', $color)); } $this->color = $color; } /** * Gets the color. */ public function getColor(): string { return $this->color; } /** * Styles the given formatter with this style. */ public function __invoke(Styles $styles, string|int ...$arguments): Styles { return ($this->callback)($styles, ...$arguments); } } termwind/src/ValueObjects/Node.php 0000644 00000010157 15002204065 0013146 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\ValueObjects; use Generator; /** * @internal */ final class Node { /** * A value object with helper methods for working with DOM node. */ public function __construct(private \DOMNode $node) { } /** * Gets the value of the node. */ public function getValue(): string { return $this->node->nodeValue ?? ''; } /** * Gets child nodes of the node. * * @return Generator<Node> */ public function getChildNodes(): Generator { foreach ($this->node->childNodes as $node) { yield new static($node); } } /** * Checks if the node is a text. */ public function isText(): bool { return $this->node instanceof \DOMText; } /** * Checks if the node is a comment. */ public function isComment(): bool { return $this->node instanceof \DOMComment; } /** * Compares the current node name with a given name. */ public function isName(string $name): bool { return $this->getName() === $name; } /** * Returns the current node type name. */ public function getName(): string { return $this->node->nodeName; } /** * Returns value of [class] attribute. */ public function getClassAttribute(): string { return $this->getAttribute('class'); } /** * Returns value of attribute with a given name. */ public function getAttribute(string $name): string { if ($this->node instanceof \DOMElement) { return $this->node->getAttribute($name); } return ''; } /** * Checks if the node is empty. */ public function isEmpty(): bool { return $this->isText() && preg_replace('/\s+/', '', $this->getValue()) === ''; } /** * Gets the previous sibling from the node. */ public function getPreviousSibling(): static|null { $node = $this->node; while ($node = $node->previousSibling) { $node = new static($node); if ($node->isEmpty()) { $node = $node->node; continue; } if (! $node->isComment()) { return $node; } $node = $node->node; } return is_null($node) ? null : new static($node); } /** * Gets the next sibling from the node. */ public function getNextSibling(): static|null { $node = $this->node; while ($node = $node->nextSibling) { $node = new static($node); if ($node->isEmpty()) { $node = $node->node; continue; } if (! $node->isComment()) { return $node; } $node = $node->node; } return is_null($node) ? null : new static($node); } /** * Checks if the node is the first child. */ public function isFirstChild(): bool { return is_null($this->getPreviousSibling()); } /** * Gets the inner HTML representation of the node including child nodes. */ public function getHtml(): string { $html = ''; foreach ($this->node->childNodes as $child) { if ($child->ownerDocument instanceof \DOMDocument) { $html .= $child->ownerDocument->saveXML($child); } } return html_entity_decode($html); } /** * Converts the node to a string. */ public function __toString(): string { if ($this->isComment()) { return ''; } if ($this->getValue() === ' ') { return ' '; } if ($this->isEmpty()) { return ''; } $text = preg_replace('/\s+/', ' ', $this->getValue()) ?? ''; if (is_null($this->getPreviousSibling())) { $text = ltrim($text); } if (is_null($this->getNextSibling())) { $text = rtrim($text); } return $text; } } termwind/src/Terminal.php 0000644 00000001566 15002204065 0011452 0 ustar 00 <?php declare(strict_types=1); namespace Termwind; use Symfony\Component\Console\Terminal as ConsoleTerminal; /** * @internal */ final class Terminal { /** * An instance of Symfony's console terminal. */ private ConsoleTerminal $terminal; /** * Creates a new terminal instance. */ public function __construct(ConsoleTerminal $terminal = null) { $this->terminal = $terminal ?? new ConsoleTerminal(); } /** * Gets the terminal width. */ public function width(): int { return $this->terminal->getWidth(); } /** * Gets the terminal height. */ public function height(): int { return $this->terminal->getHeight(); } /** * Clears the terminal screen. */ public function clear(): void { Termwind::getRenderer()->write("\ec"); } } termwind/src/Enums/Color.php 0000644 00000023030 15002204065 0012032 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\Enums; final class Color { public const BLACK = 'black'; public const WHITE = 'white'; public const BRIGHTWHITE = 'bright-white'; public const SLATE_50 = '#f8fafc'; public const SLATE_100 = '#f1f5f9'; public const SLATE_200 = '#e2e8f0'; public const SLATE_300 = '#cbd5e1'; public const SLATE_400 = '#94a3b8'; public const SLATE_500 = '#64748b'; public const SLATE_600 = '#475569'; public const SLATE_700 = '#334155'; public const SLATE_800 = '#1e293b'; public const SLATE_900 = '#0f172a'; public const GRAY = 'gray'; public const GRAY_50 = '#f9fafb'; public const GRAY_100 = '#f3f4f6'; public const GRAY_200 = '#e5e7eb'; public const GRAY_300 = '#d1d5db'; public const GRAY_400 = '#9ca3af'; public const GRAY_500 = '#6b7280'; public const GRAY_600 = '#4b5563'; public const GRAY_700 = '#374151'; public const GRAY_800 = '#1f2937'; public const GRAY_900 = '#111827'; public const ZINC_50 = '#fafafa'; public const ZINC_100 = '#f4f4f5'; public const ZINC_200 = '#e4e4e7'; public const ZINC_300 = '#d4d4d8'; public const ZINC_400 = '#a1a1aa'; public const ZINC_500 = '#71717a'; public const ZINC_600 = '#52525b'; public const ZINC_700 = '#3f3f46'; public const ZINC_800 = '#27272a'; public const ZINC_900 = '#18181b'; public const NEUTRAL_50 = '#fafafa'; public const NEUTRAL_100 = '#f5f5f5'; public const NEUTRAL_200 = '#e5e5e5'; public const NEUTRAL_300 = '#d4d4d4'; public const NEUTRAL_400 = '#a3a3a3'; public const NEUTRAL_500 = '#737373'; public const NEUTRAL_600 = '#525252'; public const NEUTRAL_700 = '#404040'; public const NEUTRAL_800 = '#262626'; public const NEUTRAL_900 = '#171717'; public const STONE_50 = '#fafaf9'; public const STONE_100 = '#f5f5f4'; public const STONE_200 = '#e7e5e4'; public const STONE_300 = '#d6d3d1'; public const STONE_400 = '#a8a29e'; public const STONE_500 = '#78716c'; public const STONE_600 = '#57534e'; public const STONE_700 = '#44403c'; public const STONE_800 = '#292524'; public const STONE_900 = '#1c1917'; public const RED = 'red'; public const BRIGHTRED = 'bright-red'; public const RED_50 = '#fef2f2'; public const RED_100 = '#fee2e2'; public const RED_200 = '#fecaca'; public const RED_300 = '#fca5a5'; public const RED_400 = '#f87171'; public const RED_500 = '#ef4444'; public const RED_600 = '#dc2626'; public const RED_700 = '#b91c1c'; public const RED_800 = '#991b1b'; public const RED_900 = '#7f1d1d'; public const ORANGE = '#f97316'; public const ORANGE_50 = '#fff7ed'; public const ORANGE_100 = '#ffedd5'; public const ORANGE_200 = '#fed7aa'; public const ORANGE_300 = '#fdba74'; public const ORANGE_400 = '#fb923c'; public const ORANGE_500 = '#f97316'; public const ORANGE_600 = '#ea580c'; public const ORANGE_700 = '#c2410c'; public const ORANGE_800 = '#9a3412'; public const ORANGE_900 = '#7c2d12'; public const AMBER_50 = '#fffbeb'; public const AMBER_100 = '#fef3c7'; public const AMBER_200 = '#fde68a'; public const AMBER_300 = '#fcd34d'; public const AMBER_400 = '#fbbf24'; public const AMBER_500 = '#f59e0b'; public const AMBER_600 = '#d97706'; public const AMBER_700 = '#b45309'; public const AMBER_800 = '#92400e'; public const AMBER_900 = '#78350f'; public const YELLOW = 'yellow'; public const BRIGHTYELLOW = 'bright-yellow'; public const YELLOW_50 = '#fefce8'; public const YELLOW_100 = '#fef9c3'; public const YELLOW_200 = '#fef08a'; public const YELLOW_300 = '#fde047'; public const YELLOW_400 = '#facc15'; public const YELLOW_500 = '#eab308'; public const YELLOW_600 = '#ca8a04'; public const YELLOW_700 = '#a16207'; public const YELLOW_800 = '#854d0e'; public const YELLOW_900 = '#713f12'; public const LIME_50 = '#f7fee7'; public const LIME_100 = '#ecfccb'; public const LIME_200 = '#d9f99d'; public const LIME_300 = '#bef264'; public const LIME_400 = '#a3e635'; public const LIME_500 = '#84cc16'; public const LIME_600 = '#65a30d'; public const LIME_700 = '#4d7c0f'; public const LIME_800 = '#3f6212'; public const LIME_900 = '#365314'; public const GREEN = 'green'; public const BRIGHTGREEN = 'bright-green'; public const GREEN_50 = '#f0fdf4'; public const GREEN_100 = '#dcfce7'; public const GREEN_200 = '#bbf7d0'; public const GREEN_300 = '#86efac'; public const GREEN_400 = '#4ade80'; public const GREEN_500 = '#22c55e'; public const GREEN_600 = '#16a34a'; public const GREEN_700 = '#15803d'; public const GREEN_800 = '#166534'; public const GREEN_900 = '#14532d'; public const EMERALD_50 = '#ecfdf5'; public const EMERALD_100 = '#d1fae5'; public const EMERALD_200 = '#a7f3d0'; public const EMERALD_300 = '#6ee7b7'; public const EMERALD_400 = '#34d399'; public const EMERALD_500 = '#10b981'; public const EMERALD_600 = '#059669'; public const EMERALD_700 = '#047857'; public const EMERALD_800 = '#065f46'; public const EMERALD_900 = '#064e3b'; public const TEAL_50 = '#f0fdfa'; public const TEAL_100 = '#ccfbf1'; public const TEAL_200 = '#99f6e4'; public const TEAL_300 = '#5eead4'; public const TEAL_400 = '#2dd4bf'; public const TEAL_500 = '#14b8a6'; public const TEAL_600 = '#0d9488'; public const TEAL_700 = '#0f766e'; public const TEAL_800 = '#115e59'; public const TEAL_900 = '#134e4a'; public const CYAN = 'cyan'; public const BRIGHTCYAN = 'bright-cyan'; public const CYAN_50 = '#ecfeff'; public const CYAN_100 = '#cffafe'; public const CYAN_200 = '#a5f3fc'; public const CYAN_300 = '#67e8f9'; public const CYAN_400 = '#22d3ee'; public const CYAN_500 = '#06b6d4'; public const CYAN_600 = '#0891b2'; public const CYAN_700 = '#0e7490'; public const CYAN_800 = '#155e75'; public const CYAN_900 = '#164e63'; public const SKY_50 = '#f0f9ff'; public const SKY_100 = '#e0f2fe'; public const SKY_200 = '#bae6fd'; public const SKY_300 = '#7dd3fc'; public const SKY_400 = '#38bdf8'; public const SKY_500 = '#0ea5e9'; public const SKY_600 = '#0284c7'; public const SKY_700 = '#0369a1'; public const SKY_800 = '#075985'; public const SKY_900 = '#0c4a6e'; public const BLUE = 'blue'; public const BRIGHTBLUE = 'bright-blue'; public const BLUE_50 = '#eff6ff'; public const BLUE_100 = '#dbeafe'; public const BLUE_200 = '#bfdbfe'; public const BLUE_300 = '#93c5fd'; public const BLUE_400 = '#60a5fa'; public const BLUE_500 = '#3b82f6'; public const BLUE_600 = '#2563eb'; public const BLUE_700 = '#1d4ed8'; public const BLUE_800 = '#1e40af'; public const BLUE_900 = '#1e3a8a'; public const INDIGO_50 = '#eef2ff'; public const INDIGO_100 = '#e0e7ff'; public const INDIGO_200 = '#c7d2fe'; public const INDIGO_300 = '#a5b4fc'; public const INDIGO_400 = '#818cf8'; public const INDIGO_500 = '#6366f1'; public const INDIGO_600 = '#4f46e5'; public const INDIGO_700 = '#4338ca'; public const INDIGO_800 = '#3730a3'; public const INDIGO_900 = '#312e81'; public const VIOLET_50 = '#f5f3ff'; public const VIOLET_100 = '#ede9fe'; public const VIOLET_200 = '#ddd6fe'; public const VIOLET_300 = '#c4b5fd'; public const VIOLET_400 = '#a78bfa'; public const VIOLET_500 = '#8b5cf6'; public const VIOLET_600 = '#7c3aed'; public const VIOLET_700 = '#6d28d9'; public const VIOLET_800 = '#5b21b6'; public const VIOLET_900 = '#4c1d95'; public const PURPLE_50 = '#faf5ff'; public const PURPLE_100 = '#f3e8ff'; public const PURPLE_200 = '#e9d5ff'; public const PURPLE_300 = '#d8b4fe'; public const PURPLE_400 = '#c084fc'; public const PURPLE_500 = '#a855f7'; public const PURPLE_600 = '#9333ea'; public const PURPLE_700 = '#7e22ce'; public const PURPLE_800 = '#6b21a8'; public const PURPLE_900 = '#581c87'; public const FUCHSIA_50 = '#fdf4ff'; public const FUCHSIA_100 = '#fae8ff'; public const FUCHSIA_200 = '#f5d0fe'; public const FUCHSIA_300 = '#f0abfc'; public const FUCHSIA_400 = '#e879f9'; public const FUCHSIA_500 = '#d946ef'; public const FUCHSIA_600 = '#c026d3'; public const FUCHSIA_700 = '#a21caf'; public const FUCHSIA_800 = '#86198f'; public const FUCHSIA_900 = '#701a75'; public const PINK_50 = '#fdf2f8'; public const PINK_100 = '#fce7f3'; public const PINK_200 = '#fbcfe8'; public const PINK_300 = '#f9a8d4'; public const PINK_400 = '#f472b6'; public const PINK_500 = '#ec4899'; public const PINK_600 = '#db2777'; public const PINK_700 = '#be185d'; public const PINK_800 = '#9d174d'; public const PINK_900 = '#831843'; public const ROSE_50 = '#fff1f2'; public const ROSE_100 = '#ffe4e6'; public const ROSE_200 = '#fecdd3'; public const ROSE_300 = '#fda4af'; public const ROSE_400 = '#fb7185'; public const ROSE_500 = '#f43f5e'; public const ROSE_600 = '#e11d48'; public const ROSE_700 = '#be123c'; public const ROSE_800 = '#9f1239'; public const ROSE_900 = '#881337'; public const MAGENTA = 'magenta'; public const BRIGHTMAGENTA = 'bright-magenta'; } termwind/src/Helpers/QuestionHelper.php 0000644 00000001147 15002204065 0014243 0 ustar 00 <?php declare(strict_types=1); namespace Termwind\Helpers; use Symfony\Component\Console\Formatter\OutputFormatter; use Symfony\Component\Console\Helper\SymfonyQuestionHelper; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\Question; /** * @internal */ final class QuestionHelper extends SymfonyQuestionHelper { /** * {@inheritdoc} */ protected function writePrompt(OutputInterface $output, Question $question): void { $text = OutputFormatter::escapeTrailingBackslash($question->getQuestion()); $output->write($text); } } termwind/LICENSE.md 0000644 00000002111 15002204065 0007766 0 ustar 00 The MIT License (MIT) Copyright (c) Nuno Maduro <enunomaduro@gmail.com> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. termwind/docker/Dockerfile 0000644 00000000350 15002204065 0011626 0 ustar 00 FROM php:8.2-cli-alpine # INSTALL AND UPDATE COMPOSER COPY --from=composer /usr/bin/composer /usr/bin/composer RUN composer self-update WORKDIR /usr/src/app COPY . . # INSTALL YOUR DEPENDENCIES RUN composer install --prefer-dist collision/composer.json 0000644 00000004133 15002204065 0011254 0 ustar 00 { "name": "nunomaduro/collision", "description": "Cli error handling for console/command-line PHP applications.", "keywords": ["console", "command-line", "php", "cli", "error", "handling", "laravel-zero", "laravel", "artisan", "symfony"], "license": "MIT", "support": { "issues": "https://github.com/nunomaduro/collision/issues", "source": "https://github.com/nunomaduro/collision" }, "authors": [ { "name": "Nuno Maduro", "email": "enunomaduro@gmail.com" } ], "require": { "php": "^8.0.0", "filp/whoops": "^2.14.5", "symfony/console": "^6.0.2" }, "require-dev": { "brianium/paratest": "^6.4.1", "laravel/framework": "^9.26.1", "laravel/pint": "^1.1.1", "nunomaduro/larastan": "^1.0.3", "nunomaduro/mock-final-classes": "^1.1.0", "orchestra/testbench": "^7.7", "phpunit/phpunit": "^9.5.23", "spatie/ignition": "^1.4.1" }, "autoload-dev": { "psr-4": { "Tests\\Unit\\": "tests/Unit", "Tests\\FakeProgram\\": "tests/FakeProgram", "Tests\\": "tests/LaravelApp/tests", "App\\": "tests/LaravelApp/app/" } }, "minimum-stability": "dev", "prefer-stable": true, "autoload": { "psr-4": { "NunoMaduro\\Collision\\": "src/" } }, "config": { "preferred-install": "dist", "sort-packages": true, "allow-plugins": { "pestphp/pest-plugin": true } }, "extra": { "branch-alias": { "dev-develop": "6.x-dev" }, "laravel": { "providers": [ "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" ] } }, "scripts": { "lint": "pint -v", "test:lint": "pint --test -v", "test:types": "phpstan analyse --ansi", "test:unit": "phpunit --colors=always", "test": [ "@test:lint", "@test:types", "@test:unit" ] } } collision/.temp/.gitkeep 0000644 00000000010 15002204065 0011174 0 ustar 00 .gitkeep collision/README.md 0000644 00000006273 15002204065 0010020 0 ustar 00 <a href="https://supportukrainenow.org/"><img src="https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct.svg" width="100%"></a> ------ <p align="center"> <img src="https://raw.githubusercontent.com/nunomaduro/collision/stable/docs/logo.png" alt="Collision logo" width="480"> <br> <img src="https://raw.githubusercontent.com/nunomaduro/collision/stable/docs/example.png" alt="Collision code example" height="300"> </p> <p align="center"> <a href="https://github.com/nunomaduro/collision/actions"><img src="https://img.shields.io/github/workflow/status/nunomaduro/collision/Tests.svg" alt="Build Status"></img></a> <a href="https://scrutinizer-ci.com/g/nunomaduro/collision"><img src="https://img.shields.io/scrutinizer/g/nunomaduro/collision.svg" alt="Quality Score"></img></a> <a href="https://packagist.org/packages/nunomaduro/collision"><img src="https://poser.pugx.org/nunomaduro/collision/d/total.svg" alt="Total Downloads"></a> <a href="https://packagist.org/packages/nunomaduro/collision"><img src="https://poser.pugx.org/nunomaduro/collision/v/stable.svg" alt="Latest Stable Version"></a> <a href="https://packagist.org/packages/nunomaduro/collision"><img src="https://poser.pugx.org/nunomaduro/collision/license.svg" alt="License"></a> </p> --- Collision was created by, and is maintained by **[Nuno Maduro](https://github.com/nunomaduro)**, and is a package designed to give you beautiful error reporting when interacting with your app through the command line. * It's included on **[Laravel](https://laravel.com)**, the most popular free, open-source PHP framework in the world. * Built on top of the **[Whoops](https://github.com/filp/whoops)** error handler. * Supports [Laravel](https://github.com/laravel/laravel), [Symfony](https://symfony.com), [PHPUnit](https://github.com/sebastianbergmann/phpunit), and many other frameworks. ## Installation & Usage > **Requires [PHP 8.0+](https://php.net/releases/)** Require Collision using [Composer](https://getcomposer.org): ```bash composer require nunomaduro/collision --dev ``` ## Laravel Version Compatibility Laravel | Collision :---------|:---------- 6.x | 3.x 7.x | 4.x 8.x | 5.x 9.x | 6.x As an example, here is how to require Collision on Laravel 6.x: ```bash composer require nunomaduro/collision:^3.0 --dev ``` ## Phpunit adapter Phpunit must be 9.0 or higher. Add the Collision `printerClass` to your `phpunit.xml` in the `phpunit` section: ```xml <phpunit printerClass="NunoMaduro\Collision\Adapters\Phpunit\Printer"> ``` ## No adapter You need to register the handler in your code: ```php (new \NunoMaduro\Collision\Provider)->register(); ``` ## Contributing Thank you for considering to contribute to Collision. All the contribution guidelines are mentioned [here](CONTRIBUTING.md). You can have a look at the [CHANGELOG](CHANGELOG.md) for constant updates & detailed information about the changes. You can also follow the twitter account for latest announcements or just come say hi!: [@enunomaduro](https://twitter.com/enunomaduro) ## License Collision is an open-sourced software licensed under the [MIT license](LICENSE.md). Logo by [Caneco](https://twitter.com/caneco). collision/src/ArgumentFormatter.php 0000644 00000002515 15002204065 0013502 0 ustar 00 <?php declare(strict_types=1); namespace NunoMaduro\Collision; use NunoMaduro\Collision\Contracts\ArgumentFormatter as ArgumentFormatterContract; /** * @internal * * @see \Tests\Unit\ArgumentFormatterTest */ final class ArgumentFormatter implements ArgumentFormatterContract { private const MAX_STRING_LENGTH = 1000; /** * {@inheritdoc} */ public function format(array $arguments, bool $recursive = true): string { $result = []; foreach ($arguments as $argument) { switch (true) { case is_string($argument): $result[] = '"'.(mb_strlen($argument) > self::MAX_STRING_LENGTH ? mb_substr($argument, 0, self::MAX_STRING_LENGTH).'...' : $argument).'"'; break; case is_array($argument): $associative = array_keys($argument) !== range(0, count($argument) - 1); if ($recursive && $associative && count($argument) <= 5) { $result[] = '['.$this->format($argument, false).']'; } break; case is_object($argument): $class = get_class($argument); $result[] = "Object($class)"; break; } } return implode(', ', $result); } } collision/src/Exceptions/InvalidStyleException.php 0000644 00000000270 15002204065 0016437 0 ustar 00 <?php declare(strict_types=1); namespace NunoMaduro\Collision\Exceptions; use RuntimeException; /** * @internal */ final class InvalidStyleException extends RuntimeException { } collision/src/Exceptions/ShouldNotHappen.php 0000644 00000000727 15002204065 0015233 0 ustar 00 <?php declare(strict_types=1); namespace NunoMaduro\Collision\Exceptions; use RuntimeException; /** * @internal */ final class ShouldNotHappen extends RuntimeException { /** * @var string */ private const MESSAGE = 'This should not happen, please open an issue on collision repository: %s'; public function __construct() { parent::__construct(sprintf(self::MESSAGE, 'https://github.com/nunomaduro/collision/issues/new')); } } collision/src/Handler.php 0000644 00000002306 15002204065 0011407 0 ustar 00 <?php declare(strict_types=1); namespace NunoMaduro\Collision; use NunoMaduro\Collision\Contracts\Handler as HandlerContract; use NunoMaduro\Collision\Contracts\Writer as WriterContract; use Symfony\Component\Console\Output\OutputInterface; use Whoops\Handler\Handler as AbstractHandler; /** * @internal * * @see \Tests\Unit\HandlerTest */ final class Handler extends AbstractHandler implements HandlerContract { /** * Holds an instance of the writer. * * @var \NunoMaduro\Collision\Contracts\Writer */ protected $writer; /** * Creates an instance of the Handler. */ public function __construct(WriterContract $writer = null) { $this->writer = $writer ?: new Writer(); } /** * {@inheritdoc} */ public function handle() { $this->writer->write($this->getInspector()); return static::QUIT; } /** * {@inheritdoc} */ public function setOutput(OutputInterface $output): HandlerContract { $this->writer->setOutput($output); return $this; } /** * {@inheritdoc} */ public function getWriter(): WriterContract { return $this->writer; } } collision/src/Highlighter.php 0000644 00000021161 15002204065 0012270 0 ustar 00 <?php declare(strict_types=1); namespace NunoMaduro\Collision; use NunoMaduro\Collision\Contracts\Highlighter as HighlighterContract; /** * @internal */ final class Highlighter implements HighlighterContract { public const TOKEN_DEFAULT = 'token_default'; public const TOKEN_COMMENT = 'token_comment'; public const TOKEN_STRING = 'token_string'; public const TOKEN_HTML = 'token_html'; public const TOKEN_KEYWORD = 'token_keyword'; public const ACTUAL_LINE_MARK = 'actual_line_mark'; public const LINE_NUMBER = 'line_number'; private const ARROW_SYMBOL = '>'; private const DELIMITER = '|'; private const ARROW_SYMBOL_UTF8 = '➜'; private const DELIMITER_UTF8 = '▕'; // '▶'; private const LINE_NUMBER_DIVIDER = 'line_divider'; private const MARKED_LINE_NUMBER = 'marked_line'; private const WIDTH = 3; /** * Holds the theme. * * @var array */ private const THEME = [ self::TOKEN_STRING => ['light_gray'], self::TOKEN_COMMENT => ['dark_gray', 'italic'], self::TOKEN_KEYWORD => ['magenta', 'bold'], self::TOKEN_DEFAULT => ['default', 'bold'], self::TOKEN_HTML => ['blue', 'bold'], self::ACTUAL_LINE_MARK => ['red', 'bold'], self::LINE_NUMBER => ['dark_gray'], self::MARKED_LINE_NUMBER => ['italic', 'bold'], self::LINE_NUMBER_DIVIDER => ['dark_gray'], ]; /** @var ConsoleColor */ private $color; /** @var array */ private const DEFAULT_THEME = [ self::TOKEN_STRING => 'red', self::TOKEN_COMMENT => 'yellow', self::TOKEN_KEYWORD => 'green', self::TOKEN_DEFAULT => 'default', self::TOKEN_HTML => 'cyan', self::ACTUAL_LINE_MARK => 'dark_gray', self::LINE_NUMBER => 'dark_gray', self::MARKED_LINE_NUMBER => 'dark_gray', self::LINE_NUMBER_DIVIDER => 'dark_gray', ]; /** @var string */ private $delimiter = self::DELIMITER_UTF8; /** @var string */ private $arrow = self::ARROW_SYMBOL_UTF8; /** * @var string */ private const NO_MARK = ' '; /** * Creates an instance of the Highlighter. */ public function __construct(ConsoleColor $color = null, bool $UTF8 = true) { $this->color = $color ?: new ConsoleColor(); foreach (self::DEFAULT_THEME as $name => $styles) { if (! $this->color->hasTheme($name)) { $this->color->addTheme($name, $styles); } } foreach (self::THEME as $name => $styles) { $this->color->addTheme($name, $styles); } if (! $UTF8) { $this->delimiter = self::DELIMITER; $this->arrow = self::ARROW_SYMBOL; } $this->delimiter .= ' '; } /** * {@inheritdoc} */ public function highlight(string $content, int $line): string { return rtrim($this->getCodeSnippet($content, $line, 4, 4)); } /** * @param string $source * @param int $lineNumber * @param int $linesBefore * @param int $linesAfter */ public function getCodeSnippet($source, $lineNumber, $linesBefore = 2, $linesAfter = 2): string { $tokenLines = $this->getHighlightedLines($source); $offset = $lineNumber - $linesBefore - 1; $offset = max($offset, 0); $length = $linesAfter + $linesBefore + 1; $tokenLines = array_slice($tokenLines, $offset, $length, $preserveKeys = true); $lines = $this->colorLines($tokenLines); return $this->lineNumbers($lines, $lineNumber); } /** * @param string $source */ private function getHighlightedLines($source): array { $source = str_replace(["\r\n", "\r"], "\n", $source); $tokens = $this->tokenize($source); return $this->splitToLines($tokens); } /** * @param string $source */ private function tokenize($source): array { $tokens = token_get_all($source); $output = []; $currentType = null; $buffer = ''; foreach ($tokens as $token) { if (is_array($token)) { switch ($token[0]) { case T_WHITESPACE: break; case T_OPEN_TAG: case T_OPEN_TAG_WITH_ECHO: case T_CLOSE_TAG: case T_STRING: case T_VARIABLE: // Constants case T_DIR: case T_FILE: case T_METHOD_C: case T_DNUMBER: case T_LNUMBER: case T_NS_C: case T_LINE: case T_CLASS_C: case T_FUNC_C: case T_TRAIT_C: $newType = self::TOKEN_DEFAULT; break; case T_COMMENT: case T_DOC_COMMENT: $newType = self::TOKEN_COMMENT; break; case T_ENCAPSED_AND_WHITESPACE: case T_CONSTANT_ENCAPSED_STRING: $newType = self::TOKEN_STRING; break; case T_INLINE_HTML: $newType = self::TOKEN_HTML; break; default: $newType = self::TOKEN_KEYWORD; } } else { $newType = $token === '"' ? self::TOKEN_STRING : self::TOKEN_KEYWORD; } if ($currentType === null) { $currentType = $newType; } if ($currentType !== $newType) { $output[] = [$currentType, $buffer]; $buffer = ''; $currentType = $newType; } $buffer .= is_array($token) ? $token[1] : $token; } if (isset($newType)) { $output[] = [$newType, $buffer]; } return $output; } private function splitToLines(array $tokens): array { $lines = []; $line = []; foreach ($tokens as $token) { foreach (explode("\n", $token[1]) as $count => $tokenLine) { if ($count > 0) { $lines[] = $line; $line = []; } if ($tokenLine === '') { continue; } $line[] = [$token[0], $tokenLine]; } } $lines[] = $line; return $lines; } private function colorLines(array $tokenLines): array { $lines = []; foreach ($tokenLines as $lineCount => $tokenLine) { $line = ''; foreach ($tokenLine as $token) { [$tokenType, $tokenValue] = $token; if ($this->color->hasTheme($tokenType)) { $line .= $this->color->apply($tokenType, $tokenValue); } else { $line .= $tokenValue; } } $lines[$lineCount] = $line; } return $lines; } /** * @param int|null $markLine */ private function lineNumbers(array $lines, $markLine = null): string { $lineStrlen = strlen((string) (array_key_last($lines) + 1)); $lineStrlen = $lineStrlen < self::WIDTH ? self::WIDTH : $lineStrlen; $snippet = ''; $mark = ' '.$this->arrow.' '; foreach ($lines as $i => $line) { $coloredLineNumber = $this->coloredLineNumber(self::LINE_NUMBER, $i, $lineStrlen); if (null !== $markLine) { $snippet .= ($markLine === $i + 1 ? $this->color->apply(self::ACTUAL_LINE_MARK, $mark) : self::NO_MARK ); $coloredLineNumber = ($markLine === $i + 1 ? $this->coloredLineNumber(self::MARKED_LINE_NUMBER, $i, $lineStrlen) : $coloredLineNumber ); } $snippet .= $coloredLineNumber; $snippet .= $this->color->apply(self::LINE_NUMBER_DIVIDER, $this->delimiter); $snippet .= $line.PHP_EOL; } return $snippet; } /** * @param string $style * @param int $i * @param int $lineStrlen */ private function coloredLineNumber($style, $i, $lineStrlen): string { return $this->color->apply($style, str_pad((string) ($i + 1), $lineStrlen, ' ', STR_PAD_LEFT)); } } collision/src/Contracts/ArgumentFormatter.php 0000644 00000000471 15002204065 0015441 0 ustar 00 <?php declare(strict_types=1); namespace NunoMaduro\Collision\Contracts; /** * @internal */ interface ArgumentFormatter { /** * Formats the provided array of arguments into * an understandable description. */ public function format(array $arguments, bool $recursive = true): string; } collision/src/Contracts/RenderlessEditor.php 0000644 00000000200 15002204065 0015236 0 ustar 00 <?php declare(strict_types=1); namespace NunoMaduro\Collision\Contracts; /** * @internal */ interface RenderlessEditor { } collision/src/Contracts/Handler.php 0000644 00000001045 15002204065 0013346 0 ustar 00 <?php declare(strict_types=1); namespace NunoMaduro\Collision\Contracts; use Symfony\Component\Console\Output\OutputInterface; use Whoops\Handler\HandlerInterface; /** * @internal */ interface Handler extends HandlerInterface { /** * Sets the output. * * @return \NunoMaduro\Collision\Contracts\Handler */ public function setOutput(OutputInterface $output): Handler; /** * Returns the writer. * * @return \NunoMaduro\Collision\Contracts\Writer */ public function getWriter(): Writer; } collision/src/Contracts/Highlighter.php 0000644 00000000366 15002204065 0014234 0 ustar 00 <?php declare(strict_types=1); namespace NunoMaduro\Collision\Contracts; /** * @internal */ interface Highlighter { /** * Highlights the provided content. */ public function highlight(string $content, int $line): string; } collision/src/Contracts/SolutionsRepository.php 0000644 00000000560 15002204065 0016071 0 ustar 00 <?php declare(strict_types=1); namespace NunoMaduro\Collision\Contracts; use Spatie\Ignition\Contracts\Solution; use Throwable; /** * @internal */ interface SolutionsRepository { /** * Gets the solutions from the given `$throwable`. * * @return array<int, Solution> */ public function getFromThrowable(Throwable $throwable): array; } collision/src/Contracts/Provider.php 0000644 00000000676 15002204065 0013574 0 ustar 00 <?php declare(strict_types=1); namespace NunoMaduro\Collision\Contracts; /** * @internal */ interface Provider { /** * Registers the current Handler as Error Handler. * * @return \NunoMaduro\Collision\Contracts\Provider */ public function register(): Provider; /** * Returns the handler. * * @return \NunoMaduro\Collision\Contracts\Handler */ public function getHandler(): Handler; } collision/src/Contracts/Adapters/Phpunit/HasPrintableTestCaseName.php 0000644 00000000447 15002204065 0022001 0 ustar 00 <?php declare(strict_types=1); namespace NunoMaduro\Collision\Contracts\Adapters\Phpunit; /** * @internal */ interface HasPrintableTestCaseName { /** * Returns the test case name that should be used by the printer. */ public function getPrintableTestCaseName(): string; } collision/src/Contracts/Adapters/Phpunit/Listener.php 0000644 00000000572 15002204065 0016754 0 ustar 00 <?php declare(strict_types=1); namespace NunoMaduro\Collision\Contracts\Adapters\Phpunit; use PHPUnit\Framework\Test; use PHPUnit\Framework\TestListener; /** * @internal */ interface Listener extends TestListener { /** * Renders the provided error * on the console. * * @return void */ public function render(Test $test, \Throwable $t); } collision/src/Contracts/RenderlessTrace.php 0000644 00000000177 15002204065 0015063 0 ustar 00 <?php declare(strict_types=1); namespace NunoMaduro\Collision\Contracts; /** * @internal */ interface RenderlessTrace { } collision/src/Contracts/Writer.php 0000644 00000003227 15002204065 0013251 0 ustar 00 <?php declare(strict_types=1); /** * This file is part of Collision. * * (c) Nuno Maduro <enunomaduro@gmail.com> * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace NunoMaduro\Collision\Contracts; use Symfony\Component\Console\Output\OutputInterface; use Whoops\Exception\Inspector; /** * @internal */ interface Writer { /** * Ignores traces where the file string matches one * of the provided regex expressions. * * @param string[] $ignore the regex expressions * @return \NunoMaduro\Collision\Contracts\Writer */ public function ignoreFilesIn(array $ignore): Writer; /** * Declares whether or not the Writer should show the trace. * * @return \NunoMaduro\Collision\Contracts\Writer */ public function showTrace(bool $show): Writer; /** * Declares whether or not the Writer should show the title. * * @return \NunoMaduro\Collision\Contracts\Writer */ public function showTitle(bool $show): Writer; /** * Declares whether or not the Writer should show the editor. * * @return \NunoMaduro\Collision\Contracts\Writer */ public function showEditor(bool $show): Writer; /** * Writes the details of the exception on the console. */ public function write(Inspector $inspector): void; /** * Sets the output. * * @return \NunoMaduro\Collision\Contracts\Writer */ public function setOutput(OutputInterface $output): Writer; /** * Gets the output. */ public function getOutput(): OutputInterface; } collision/src/Coverage.php 0000644 00000013550 15002204065 0011570 0 ustar 00 <?php declare(strict_types=1); namespace NunoMaduro\Collision; use SebastianBergmann\CodeCoverage\CodeCoverage; use SebastianBergmann\CodeCoverage\Node\Directory; use SebastianBergmann\CodeCoverage\Node\File; use SebastianBergmann\Environment\Runtime; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Terminal; /** * @internal */ final class Coverage { /** * Returns the coverage path. */ public static function getPath(): string { return implode(DIRECTORY_SEPARATOR, [ dirname(__DIR__), '.temp', 'coverage', ]); } /** * Runs true there is any code coverage driver available. */ public static function isAvailable(): bool { if (! (new Runtime())->canCollectCodeCoverage()) { return false; } if (static::usingXdebug()) { $mode = getenv('XDEBUG_MODE') ?: ini_get('xdebug.mode'); return $mode && in_array('coverage', explode(',', $mode), true); } return true; } /** * If the user is using Xdebug. */ public static function usingXdebug(): bool { return (new Runtime())->hasXdebug(); } /** * Reports the code coverage report to the * console and returns the result in float. */ public static function report(OutputInterface $output): float { if (! file_exists($reportPath = self::getPath())) { if (self::usingXdebug()) { $output->writeln( " <fg=black;bg=yellow;options=bold> WARN </> Unable to get coverage using Xdebug. Did you set <href=https://xdebug.org/docs/code_coverage#mode>Xdebug's coverage mode</>?</>", ); return 0.0; } $output->writeln( ' <fg=black;bg=yellow;options=bold> WARN </> No coverage driver detected.</>', ); return 0.0; } /** @var CodeCoverage $codeCoverage */ $codeCoverage = require $reportPath; unlink($reportPath); $totalCoverage = $codeCoverage->getReport()->percentageOfExecutedLines(); $totalWidth = (new Terminal())->getWidth(); $dottedLineLength = $totalWidth; /** @var Directory<File|Directory> $report */ $report = $codeCoverage->getReport(); foreach ($report->getIterator() as $file) { if (! $file instanceof File) { continue; } $dirname = dirname($file->id()); $basename = basename($file->id(), '.php'); $name = $dirname === '.' ? $basename : implode(DIRECTORY_SEPARATOR, [ $dirname, $basename, ]); $rawName = $dirname === '.' ? $basename : implode(DIRECTORY_SEPARATOR, [ $dirname, $basename, ]); $linesExecutedTakenSize = 0; if ($file->percentageOfExecutedLines()->asString() != '0.00%') { $linesExecutedTakenSize = strlen($uncoveredLines = trim(implode(', ', self::getMissingCoverage($file)))) + 1; $name .= sprintf(' <fg=red>%s</>', $uncoveredLines); } $percentage = $file->numberOfExecutableLines() === 0 ? '100.0' : number_format($file->percentageOfExecutedLines()->asFloat(), 1, '.', ''); $takenSize = strlen($rawName.$percentage) + 8 + $linesExecutedTakenSize; // adding 3 space and percent sign $percentage = sprintf( '<fg=%s%s>%s</>', $percentage === '100.0' ? 'green' : ($percentage === '0.0' ? 'red' : 'yellow'), $percentage === '100.0' ? ';options=bold' : '', $percentage ); $output->writeln(sprintf( ' <fg=white>%s</> <fg=#6C7280>%s</> %s <fg=#6C7280>%%</>', $name, str_repeat('.', max($dottedLineLength - $takenSize, 1)), $percentage )); } $output->writeln(''); $rawName = 'Total Coverage'; $takenSize = strlen($rawName.$totalCoverage->asString()) + 6; $output->writeln(sprintf( ' <fg=white;options=bold>%s</> <fg=#6C7280>%s</> %s <fg=#6C7280>%%</>', $rawName, str_repeat('.', max($dottedLineLength - $takenSize, 1)), number_format($totalCoverage->asFloat(), 1, '.', '') )); return $totalCoverage->asFloat(); } /** * Generates an array of missing coverage on the following format:. * * ``` * ['11', '20..25', '50', '60..80']; * ``` * * @param File $file * @return array<int, string> */ public static function getMissingCoverage($file): array { $shouldBeNewLine = true; $eachLine = function (array $array, array $tests, int $line) use (&$shouldBeNewLine): array { if (count($tests) > 0) { $shouldBeNewLine = true; return $array; } if ($shouldBeNewLine) { $array[] = (string) $line; $shouldBeNewLine = false; return $array; } $lastKey = count($array) - 1; if (array_key_exists($lastKey, $array) && str_contains($array[$lastKey], '..')) { [$from] = explode('..', $array[$lastKey]); $array[$lastKey] = $line > $from ? sprintf('%s..%s', $from, $line) : sprintf('%s..%s', $line, $from); return $array; } $array[$lastKey] = sprintf('%s..%s', $array[$lastKey], $line); return $array; }; $array = []; foreach (array_filter($file->lineCoverageData(), 'is_array') as $line => $tests) { $array = $eachLine($array, $tests, $line); } return $array; } } collision/src/SolutionsRepositories/NullSolutionsRepository.php 0000644 00000000602 15002204065 0021370 0 ustar 00 <?php declare(strict_types=1); namespace NunoMaduro\Collision\SolutionsRepositories; use NunoMaduro\Collision\Contracts\SolutionsRepository; use Throwable; /** * @internal */ final class NullSolutionsRepository implements SolutionsRepository { /** * {@inheritdoc} */ public function getFromThrowable(Throwable $throwable): array { return []; } } collision/src/Provider.php 0000644 00000002242 15002204065 0011623 0 ustar 00 <?php declare(strict_types=1); namespace NunoMaduro\Collision; use NunoMaduro\Collision\Contracts\Handler as HandlerContract; use NunoMaduro\Collision\Contracts\Provider as ProviderContract; use Whoops\Run; use Whoops\RunInterface; /** * @internal * * @see \Tests\Unit\ProviderTest */ final class Provider implements ProviderContract { /** * Holds an instance of the Run. * * @var \Whoops\RunInterface */ protected $run; /** * Holds an instance of the handler. * * @var \NunoMaduro\Collision\Contracts\Handler */ protected $handler; /** * Creates a new instance of the Provider. */ public function __construct(RunInterface $run = null, HandlerContract $handler = null) { $this->run = $run ?: new Run(); $this->handler = $handler ?: new Handler(); } /** * {@inheritdoc} */ public function register(): ProviderContract { $this->run->pushHandler($this->handler) ->register(); return $this; } /** * {@inheritdoc} */ public function getHandler(): HandlerContract { return $this->handler; } } collision/src/ConsoleColor.php 0000644 00000014761 15002204065 0012443 0 ustar 00 <?php declare(strict_types=1); namespace NunoMaduro\Collision; use NunoMaduro\Collision\Exceptions\InvalidStyleException; use NunoMaduro\Collision\Exceptions\ShouldNotHappen; /** * @internal */ final class ConsoleColor { public const FOREGROUND = 38; public const BACKGROUND = 48; public const COLOR256_REGEXP = '~^(bg_)?color_(\d{1,3})$~'; public const RESET_STYLE = 0; /** @var bool */ private $isSupported; /** @var bool */ private $forceStyle = false; /** @var array */ private const STYLES = [ 'none' => null, 'bold' => '1', 'dark' => '2', 'italic' => '3', 'underline' => '4', 'blink' => '5', 'reverse' => '7', 'concealed' => '8', 'default' => '39', 'black' => '30', 'red' => '31', 'green' => '32', 'yellow' => '33', 'blue' => '34', 'magenta' => '35', 'cyan' => '36', 'light_gray' => '37', 'dark_gray' => '90', 'light_red' => '91', 'light_green' => '92', 'light_yellow' => '93', 'light_blue' => '94', 'light_magenta' => '95', 'light_cyan' => '96', 'white' => '97', 'bg_default' => '49', 'bg_black' => '40', 'bg_red' => '41', 'bg_green' => '42', 'bg_yellow' => '43', 'bg_blue' => '44', 'bg_magenta' => '45', 'bg_cyan' => '46', 'bg_light_gray' => '47', 'bg_dark_gray' => '100', 'bg_light_red' => '101', 'bg_light_green' => '102', 'bg_light_yellow' => '103', 'bg_light_blue' => '104', 'bg_light_magenta' => '105', 'bg_light_cyan' => '106', 'bg_white' => '107', ]; /** @var array */ private $themes = []; public function __construct() { $this->isSupported = $this->isSupported(); } /** * @param string|array $style * @param string $text * @return string * * @throws InvalidStyleException * @throws \InvalidArgumentException */ public function apply($style, $text) { if (! $this->isStyleForced() && ! $this->isSupported()) { return $text; } if (is_string($style)) { $style = [$style]; } if (! is_array($style)) { throw new \InvalidArgumentException('Style must be string or array.'); } $sequences = []; foreach ($style as $s) { if (isset($this->themes[$s])) { $sequences = array_merge($sequences, $this->themeSequence($s)); } elseif ($this->isValidStyle($s)) { $sequences[] = $this->styleSequence($s); } else { throw new ShouldNotHappen(); } } $sequences = array_filter($sequences, function ($val) { return $val !== null; }); if (empty($sequences)) { return $text; } return $this->escSequence(implode(';', $sequences)).$text.$this->escSequence(self::RESET_STYLE); } /** * @param bool $forceStyle */ public function setForceStyle($forceStyle) { $this->forceStyle = $forceStyle; } /** * @return bool */ public function isStyleForced() { return $this->forceStyle; } public function setThemes(array $themes) { $this->themes = []; foreach ($themes as $name => $styles) { $this->addTheme($name, $styles); } } /** * @param string $name * @param array|string $styles */ public function addTheme($name, $styles) { if (is_string($styles)) { $styles = [$styles]; } if (! is_array($styles)) { throw new \InvalidArgumentException('Style must be string or array.'); } foreach ($styles as $style) { if (! $this->isValidStyle($style)) { throw new InvalidStyleException($style); } } $this->themes[$name] = $styles; } /** * @return array */ public function getThemes() { return $this->themes; } /** * @param string $name * @return bool */ public function hasTheme($name) { return isset($this->themes[$name]); } /** * @param string $name */ public function removeTheme($name) { unset($this->themes[$name]); } /** * @return bool */ public function isSupported() { // The COLLISION_FORCE_COLORS variable is for internal purposes only if (getenv('COLLISION_FORCE_COLORS') !== false) { return true; } if (DIRECTORY_SEPARATOR === '\\') { return getenv('ANSICON') !== false || getenv('ConEmuANSI') === 'ON'; } return function_exists('posix_isatty') && @posix_isatty(STDOUT); } /** * @return bool */ public function are256ColorsSupported() { if (DIRECTORY_SEPARATOR === '\\') { return function_exists('sapi_windows_vt100_support') && @sapi_windows_vt100_support(STDOUT); } return strpos(getenv('TERM'), '256color') !== false; } /** * @return array */ public function getPossibleStyles() { return array_keys(self::STYLES); } /** * @param string $name * @return string[] */ private function themeSequence($name) { $sequences = []; foreach ($this->themes[$name] as $style) { $sequences[] = $this->styleSequence($style); } return $sequences; } /** * @param string $style * @return string */ private function styleSequence($style) { if (array_key_exists($style, self::STYLES)) { return self::STYLES[$style]; } if (! $this->are256ColorsSupported()) { return null; } preg_match(self::COLOR256_REGEXP, $style, $matches); $type = $matches[1] === 'bg_' ? self::BACKGROUND : self::FOREGROUND; $value = $matches[2]; return "$type;5;$value"; } /** * @param string $style * @return bool */ private function isValidStyle($style) { return array_key_exists($style, self::STYLES) || preg_match(self::COLOR256_REGEXP, $style); } /** * @param string|int $value * @return string */ private function escSequence($value) { return "\033[{$value}m"; } } collision/src/Adapters/Laravel/CollisionServiceProvider.php 0000644 00000004640 15002204065 0020155 0 ustar 00 <?php declare(strict_types=1); namespace NunoMaduro\Collision\Adapters\Laravel; use Illuminate\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; use Illuminate\Support\ServiceProvider; use NunoMaduro\Collision\Adapters\Laravel\Commands\TestCommand; use NunoMaduro\Collision\Contracts\Provider as ProviderContract; use NunoMaduro\Collision\Handler; use NunoMaduro\Collision\Provider; use NunoMaduro\Collision\SolutionsRepositories\NullSolutionsRepository; use NunoMaduro\Collision\Writer; /** * @internal * * @final */ class CollisionServiceProvider extends ServiceProvider { /** * {@inheritdoc} * * @var bool */ protected $defer = true; /** * Boots application services. * * @return void */ public function boot() { $this->commands([ TestCommand::class, ]); } /** * {@inheritdoc} */ public function register() { if ($this->app->runningInConsole() && ! $this->app->runningUnitTests()) { $this->app->bind(ProviderContract::class, function () { // @phpstan-ignore-next-line if ($this->app->has(\Spatie\Ignition\Contracts\SolutionProviderRepository::class)) { /** @var \Spatie\Ignition\Contracts\SolutionProviderRepository $solutionProviderRepository */ $solutionProviderRepository = $this->app->get(\Spatie\Ignition\Contracts\SolutionProviderRepository::class); $solutionsRepository = new IgnitionSolutionsRepository($solutionProviderRepository); } else { $solutionsRepository = new NullSolutionsRepository(); } $writer = new Writer($solutionsRepository); $handler = new Handler($writer); return new Provider(null, $handler); }); /** @var \Illuminate\Contracts\Debug\ExceptionHandler $appExceptionHandler */ $appExceptionHandler = $this->app->make(ExceptionHandlerContract::class); $this->app->singleton( ExceptionHandlerContract::class, function ($app) use ($appExceptionHandler) { return new ExceptionHandler($app, $appExceptionHandler); } ); } } /** * {@inheritdoc} */ public function provides() { return [ProviderContract::class]; } } collision/src/Adapters/Laravel/Exceptions/RequirementsException.php 0000644 00000000537 15002204065 0021652 0 ustar 00 <?php declare(strict_types=1); namespace NunoMaduro\Collision\Adapters\Laravel\Exceptions; use NunoMaduro\Collision\Contracts\RenderlessEditor; use NunoMaduro\Collision\Contracts\RenderlessTrace; use RuntimeException; /** * @internal */ final class RequirementsException extends RuntimeException implements RenderlessEditor, RenderlessTrace { } collision/src/Adapters/Laravel/Inspector.php 0000644 00000001034 15002204065 0015126 0 ustar 00 <?php declare(strict_types=1); /** * This file is part of Collision. * * (c) Nuno Maduro <enunomaduro@gmail.com> * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace NunoMaduro\Collision\Adapters\Laravel; use Whoops\Exception\Inspector as BaseInspector; /** * @internal */ final class Inspector extends BaseInspector { /** * {@inheritdoc} */ protected function getTrace($e) { return $e->getTrace(); } } collision/src/Adapters/Laravel/IgnitionSolutionsRepository.php 0000644 00000001667 15002204065 0020774 0 ustar 00 <?php declare(strict_types=1); namespace NunoMaduro\Collision\Adapters\Laravel; use NunoMaduro\Collision\Contracts\SolutionsRepository; use Spatie\Ignition\Contracts\SolutionProviderRepository; use Throwable; /** * @internal */ final class IgnitionSolutionsRepository implements SolutionsRepository { /** * Holds an instance of ignition solutions provider repository. * * @var \Spatie\Ignition\Contracts\SolutionProviderRepository */ protected $solutionProviderRepository; /** * IgnitionSolutionsRepository constructor. */ public function __construct(SolutionProviderRepository $solutionProviderRepository) { $this->solutionProviderRepository = $solutionProviderRepository; } /** * {@inheritdoc} */ public function getFromThrowable(Throwable $throwable): array { return $this->solutionProviderRepository->getSolutionsForThrowable($throwable); } } collision/src/Adapters/Laravel/Commands/TestCommand.php 0000644 00000026266 15002204065 0017155 0 ustar 00 <?php declare(strict_types=1); namespace NunoMaduro\Collision\Adapters\Laravel\Commands; use Dotenv\Exception\InvalidPathException; use Dotenv\Parser\Parser; use Dotenv\Store\StoreBuilder; use Illuminate\Console\Command; use Illuminate\Support\Env; use Illuminate\Support\Str; use NunoMaduro\Collision\Adapters\Laravel\Exceptions\RequirementsException; use NunoMaduro\Collision\Coverage; use RuntimeException; use Symfony\Component\Process\Exception\ProcessSignaledException; use Symfony\Component\Process\Process; /** * @internal * * @final */ class TestCommand extends Command { /** * The name and signature of the console command. * * @var string */ protected $signature = 'test {--without-tty : Disable output to TTY} {--coverage : Indicates whether code coverage information should be collected} {--min= : Indicates the minimum threshold enforcement for code coverage} {--p|parallel : Indicates if the tests should run in parallel} {--recreate-databases : Indicates if the test databases should be re-created} {--drop-databases : Indicates if the test databases should be dropped} '; /** * The console command description. * * @var string */ protected $description = 'Run the application tests'; /** * Create a new command instance. * * @return void */ public function __construct() { parent::__construct(); $this->ignoreValidationErrors(); } /** * Execute the console command. * * @return mixed */ public function handle() { $phpunitVersion = \PHPUnit\Runner\Version::id(); if ((int) $phpunitVersion[0] === 1) { throw new RequirementsException('Running PHPUnit 10.x or Pest 2.x requires Collision 7.x.'); } if ((int) $phpunitVersion[0] < 9) { throw new RequirementsException('Running Collision 6.x artisan test command requires at least PHPUnit 9.x.'); } $laravelVersion = (int) \Illuminate\Foundation\Application::VERSION; // @phpstan-ignore-next-line if ($laravelVersion < 9) { throw new RequirementsException('Running Collision 6.x artisan test command requires at least Laravel 9.x.'); } if ($this->option('coverage') && ! Coverage::isAvailable()) { $this->output->writeln(sprintf( "\n <fg=white;bg=red;options=bold> ERROR </> Code coverage driver not available.%s</>", Coverage::usingXdebug() ? " Did you set <href=https://xdebug.org/docs/code_coverage#mode>Xdebug's coverage mode</>?" : '' )); $this->newLine(); return 1; } if ($this->option('parallel') && ! $this->isParallelDependenciesInstalled()) { if (! $this->confirm('Running tests in parallel requires "brianium/paratest". Do you wish to install it as a dev dependency?')) { return 1; } $this->installParallelDependencies(); } $options = array_slice($_SERVER['argv'], $this->option('without-tty') ? 3 : 2); $this->clearEnv(); $parallel = $this->option('parallel'); $process = (new Process(array_merge( // Binary ... $this->binary(), // Arguments ... $parallel ? $this->paratestArguments($options) : $this->phpunitArguments($options) ), null, // Envs ... $parallel ? $this->paratestEnvironmentVariables() : $this->phpunitEnvironmentVariables(), ))->setTimeout(null); try { $process->setTty(! $this->option('without-tty')); } catch (RuntimeException $e) { $this->output->writeln('Warning: '.$e->getMessage()); } $exitCode = 1; try { $exitCode = $process->run(function ($type, $line) { $this->output->write($line); }); } catch (ProcessSignaledException $e) { if (extension_loaded('pcntl') && $e->getSignal() !== SIGINT) { throw $e; } } if ($exitCode === 0 && $this->option('coverage')) { if (! $this->usingPest() && $this->option('parallel')) { $this->newLine(); } $coverage = Coverage::report($this->output); $exitCode = (int) ($coverage < $this->option('min')); if ($exitCode === 1) { $this->output->writeln(sprintf( "\n <fg=white;bg=red;options=bold> FAIL </> Code coverage below expected:<fg=red;options=bold> %s %%</>. Minimum:<fg=white;options=bold> %s %%</>.", number_format($coverage, 1), number_format((float) $this->option('min'), 1) )); } } $this->newLine(); return $exitCode; } /** * Get the PHP binary to execute. * * @return array */ protected function binary() { if ($this->usingPest()) { $command = $this->option('parallel') ? ['vendor/pestphp/pest/bin/pest', '--parallel'] : ['vendor/pestphp/pest/bin/pest']; } else { $command = $this->option('parallel') ? ['vendor/brianium/paratest/bin/paratest'] : ['vendor/phpunit/phpunit/phpunit']; } if ('phpdbg' === PHP_SAPI) { return array_merge([PHP_BINARY, '-qrr'], $command); } return array_merge([PHP_BINARY], $command); } /** * Gets the common arguments of PHPUnit and Pest. * * @return array */ protected function commonArguments() { $arguments = []; if ($this->option('coverage')) { $arguments[] = '--coverage-php'; $arguments[] = Coverage::getPath(); } return $arguments; } /** * Determines if Pest is being used. * * @return bool */ protected function usingPest() { return class_exists(\Pest\Laravel\PestServiceProvider::class); } /** * Get the array of arguments for running PHPUnit. * * @param array $options * @return array */ protected function phpunitArguments($options) { $options = array_merge(['--printer=NunoMaduro\\Collision\\Adapters\\Phpunit\\Printer'], $options); $options = array_values(array_filter($options, function ($option) { return ! Str::startsWith($option, '--env=') && $option != '-q' && $option != '--quiet' && $option != '--coverage' && ! Str::startsWith($option, '--min'); })); if (! file_exists($file = base_path('phpunit.xml'))) { $file = base_path('phpunit.xml.dist'); } return array_merge($this->commonArguments(), ["--configuration=$file"], $options); } /** * Get the array of arguments for running Paratest. * * @param array $options * @return array */ protected function paratestArguments($options) { $options = array_values(array_filter($options, function ($option) { return ! Str::startsWith($option, '--env=') && $option != '--coverage' && $option != '-q' && $option != '--quiet' && ! Str::startsWith($option, '--min') && ! Str::startsWith($option, '-p') && ! Str::startsWith($option, '--parallel') && ! Str::startsWith($option, '--recreate-databases') && ! Str::startsWith($option, '--drop-databases'); })); if (! file_exists($file = base_path('phpunit.xml'))) { $file = base_path('phpunit.xml.dist'); } return array_merge($this->commonArguments(), [ "--configuration=$file", "--runner=\Illuminate\Testing\ParallelRunner", ], $options); } /** * Get the array of environment variables for running PHPUnit. * * @return array */ protected function phpunitEnvironmentVariables() { return []; } /** * Get the array of environment variables for running Paratest. * * @return array */ protected function paratestEnvironmentVariables() { return [ 'LARAVEL_PARALLEL_TESTING' => 1, 'LARAVEL_PARALLEL_TESTING_RECREATE_DATABASES' => $this->option('recreate-databases'), 'LARAVEL_PARALLEL_TESTING_DROP_DATABASES' => $this->option('drop-databases'), ]; } /** * Clears any set Environment variables set by Laravel if the --env option is empty. * * @return void */ protected function clearEnv() { if (! $this->option('env')) { $vars = self::getEnvironmentVariables( // @phpstan-ignore-next-line $this->laravel->environmentPath(), // @phpstan-ignore-next-line $this->laravel->environmentFile() ); $repository = Env::getRepository(); foreach ($vars as $name) { $repository->clear($name); } } } /** * @param string $path * @param string $file * @return array */ protected static function getEnvironmentVariables($path, $file) { try { $content = StoreBuilder::createWithNoNames() ->addPath($path) ->addName($file) ->make() ->read(); } catch (InvalidPathException $e) { return []; } $vars = []; foreach ((new Parser())->parse($content) as $entry) { $vars[] = $entry->getName(); } return $vars; } /** * Check if the parallel dependencies are installed. * * @return bool */ protected function isParallelDependenciesInstalled() { return class_exists(\ParaTest\Console\Commands\ParaTestCommand::class); } /** * Install parallel testing needed dependencies. * * @return void */ protected function installParallelDependencies() { $command = $this->findComposer().' require brianium/paratest --dev'; $process = Process::fromShellCommandline($command, null, null, null, null); if ('\\' !== DIRECTORY_SEPARATOR && file_exists('/dev/tty') && is_readable('/dev/tty')) { try { $process->setTty(true); } catch (RuntimeException $e) { $this->output->writeln('Warning: '.$e->getMessage()); } } try { $process->run(function ($type, $line) { $this->output->write($line); }); } catch (ProcessSignaledException $e) { if (extension_loaded('pcntl') && $e->getSignal() !== SIGINT) { throw $e; } } } /** * Get the composer command for the environment. * * @return string */ protected function findComposer() { $composerPath = getcwd().'/composer.phar'; if (file_exists($composerPath)) { return '"'.PHP_BINARY.'" '.$composerPath; } return 'composer'; } } collision/src/Adapters/Laravel/ExceptionHandler.php 0000644 00000004265 15002204065 0016425 0 ustar 00 <?php declare(strict_types=1); namespace NunoMaduro\Collision\Adapters\Laravel; use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; use NunoMaduro\Collision\Contracts\Provider as ProviderContract; use Symfony\Component\Console\Exception\ExceptionInterface as SymfonyConsoleExceptionInterface; use Throwable; /** * @internal */ final class ExceptionHandler implements ExceptionHandlerContract { /** * Holds an instance of the application exception handler. * * @var \Illuminate\Contracts\Debug\ExceptionHandler */ protected $appExceptionHandler; /** * Holds an instance of the container. * * @var \Illuminate\Contracts\Container\Container */ protected $container; /** * Creates a new instance of the ExceptionHandler. */ public function __construct(Container $container, ExceptionHandlerContract $appExceptionHandler) { $this->container = $container; $this->appExceptionHandler = $appExceptionHandler; } /** * {@inheritdoc} */ public function report(Throwable $e) { $this->appExceptionHandler->report($e); } /** * {@inheritdoc} */ public function render($request, Throwable $e) { return $this->appExceptionHandler->render($request, $e); } /** * {@inheritdoc} */ public function renderForConsole($output, Throwable $e) { if ($e instanceof SymfonyConsoleExceptionInterface) { $this->appExceptionHandler->renderForConsole($output, $e); } else { /** @var \NunoMaduro\Collision\Contracts\Provider $provider */ $provider = $this->container->make(ProviderContract::class); $handler = $provider->register() ->getHandler() ->setOutput($output); $handler->setInspector((new Inspector($e))); $handler->handle(); } } /** * Determine if the exception should be reported. * * @return bool */ public function shouldReport(Throwable $e) { return $this->appExceptionHandler->shouldReport($e); } } collision/src/Adapters/Phpunit/Timer.php 0000644 00000001144 15002204065 0014303 0 ustar 00 <?php declare(strict_types=1); namespace NunoMaduro\Collision\Adapters\Phpunit; /** * @internal */ final class Timer { /** * @var float */ private $start; /** * Timer constructor. */ private function __construct(float $start) { $this->start = $start; } /** * Starts the timer. */ public static function start(): Timer { return new self(microtime(true)); } /** * Returns the elapsed time in microseconds. */ public function result(): float { return microtime(true) - $this->start; } } collision/src/Adapters/Phpunit/Printer.php 0000644 00000014734 15002204065 0014657 0 ustar 00 <?php declare(strict_types=1); namespace NunoMaduro\Collision\Adapters\Phpunit; use NunoMaduro\Collision\Exceptions\ShouldNotHappen; use PHPUnit\Framework\AssertionFailedError; use PHPUnit\Framework\Test; use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestSuite; use PHPUnit\Framework\Warning; use ReflectionObject; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Output\ConsoleOutput; use Throwable; /** * @internal */ final class Printer implements \PHPUnit\TextUI\ResultPrinter { /** * Holds an instance of the style. * * Style is a class we use to interact with output. * * @var Style */ private $style; /** * Holds the duration time of the test suite. * * @var Timer */ private $timer; /** * Holds the state of the test * suite. The number of tests, etc. * * @var State */ private $state; /** * If the test suite has failed. * * @var bool */ private $failed = false; /** * Creates a new instance of the listener. * * @param ConsoleOutput $output * * @throws \ReflectionException */ public function __construct(\Symfony\Component\Console\Output\ConsoleOutputInterface $output = null, bool $verbose = false, string $colors = 'always') { $this->timer = Timer::start(); $decorated = $colors === 'always' || $colors === 'auto'; $output = $output ?? new ConsoleOutput(ConsoleOutput::VERBOSITY_NORMAL, $decorated); ConfigureIO::of(new ArgvInput(), $output); $this->style = new Style($output); $dummyTest = new class() extends TestCase { }; $this->state = State::from($dummyTest); } /** * {@inheritdoc} */ public function addError(Test $testCase, Throwable $throwable, float $time): void { $this->failed = true; $testCase = $this->testCaseFromTest($testCase); $this->state->add(TestResult::fromTestCase($testCase, TestResult::FAIL, $throwable)); } /** * {@inheritdoc} */ public function addWarning(Test $testCase, Warning $warning, float $time): void { $testCase = $this->testCaseFromTest($testCase); $this->state->add(TestResult::fromTestCase($testCase, TestResult::WARN, $warning)); } /** * {@inheritdoc} */ public function addFailure(Test $testCase, AssertionFailedError $error, float $time): void { $this->failed = true; $testCase = $this->testCaseFromTest($testCase); $reflector = new ReflectionObject($error); if ($reflector->hasProperty('message')) { $message = trim((string) preg_replace("/\r|\n/", "\n ", $error->getMessage())); $property = $reflector->getProperty('message'); $property->setAccessible(true); $property->setValue($error, $message); } $this->state->add(TestResult::fromTestCase($testCase, TestResult::FAIL, $error)); } /** * {@inheritdoc} */ public function addIncompleteTest(Test $testCase, Throwable $throwable, float $time): void { $testCase = $this->testCaseFromTest($testCase); $this->state->add(TestResult::fromTestCase($testCase, TestResult::INCOMPLETE, $throwable)); } /** * {@inheritdoc} */ public function addRiskyTest(Test $testCase, Throwable $throwable, float $time): void { $testCase = $this->testCaseFromTest($testCase); $this->state->add(TestResult::fromTestCase($testCase, TestResult::RISKY, $throwable)); } /** * {@inheritdoc} */ public function addSkippedTest(Test $testCase, Throwable $throwable, float $time): void { $testCase = $this->testCaseFromTest($testCase); $this->state->add(TestResult::fromTestCase($testCase, TestResult::SKIPPED, $throwable)); } /** * {@inheritdoc} */ public function startTestSuite(TestSuite $suite): void { if ($this->state->suiteTotalTests === null) { $this->state->suiteTotalTests = $suite->count(); } } /** * {@inheritdoc} */ public function endTestSuite(TestSuite $suite): void { // .. } /** * {@inheritdoc} */ public function startTest(Test $testCase): void { $testCase = $this->testCaseFromTest($testCase); // Let's check first if the testCase is over. if ($this->state->testCaseHasChanged($testCase)) { $this->style->writeCurrentTestCaseSummary($this->state); $this->state->moveTo($testCase); } } /** * {@inheritdoc} */ public function endTest(Test $testCase, float $time): void { $testCase = $this->testCaseFromTest($testCase); if (! $this->state->existsInTestCase($testCase)) { $this->state->add(TestResult::fromTestCase($testCase, TestResult::PASS)); } if ($testCase instanceof TestCase && $testCase->getTestResultObject() instanceof \PHPUnit\Framework\TestResult && ! $testCase->getTestResultObject()->isStrictAboutOutputDuringTests() && ! $testCase->hasExpectationOnOutput()) { $this->style->write($testCase->getActualOutput()); } } /** * Intentionally left blank as we output things on events of the listener. */ public function write(string $content): void { // .. } /** * Returns a test case from the given test. * * Note: This printer is do not work with normal Test classes - only * with Test Case classes. Please report an issue if you think * this should work any other way. */ private function testCaseFromTest(Test $test): TestCase { if (! $test instanceof TestCase) { throw new ShouldNotHappen(); } return $test; } /** * Intentionally left blank as we output things on events of the listener. */ public function printResult(\PHPUnit\Framework\TestResult $result): void { if ($result->count() === 0) { $this->style->writeWarning('No tests executed!'); } $this->style->writeCurrentTestCaseSummary($this->state); if ($this->failed) { $onFailure = $this->state->suiteTotalTests !== $this->state->testSuiteTestsCount(); $this->style->writeErrorsSummary($this->state, $onFailure); } $this->style->writeRecap($this->state, $this->timer); } } collision/src/Adapters/Phpunit/Style.php 0000644 00000017405 15002204065 0014332 0 ustar 00 <?php declare(strict_types=1); namespace NunoMaduro\Collision\Adapters\Phpunit; use NunoMaduro\Collision\Exceptions\ShouldNotHappen; use NunoMaduro\Collision\Writer; use PHPUnit\Framework\AssertionFailedError; use PHPUnit\Framework\ExceptionWrapper; use PHPUnit\Framework\ExpectationFailedException; use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\ConsoleOutputInterface; use Throwable; use Whoops\Exception\Inspector; /** * @internal */ final class Style { /** * @var ConsoleOutput */ private $output; /** * Style constructor. */ public function __construct(ConsoleOutputInterface $output) { if (! $output instanceof ConsoleOutput) { throw new ShouldNotHappen(); } $this->output = $output; } /** * Prints the content. */ public function write(string $content): void { $this->output->write($content); } /** * Prints the content similar too:. * * ``` * PASS Unit\ExampleTest * ✓ basic test * ``` */ public function writeCurrentTestCaseSummary(State $state): void { if ($state->testCaseTestsCount() === 0) { return; } if (! $state->headerPrinted) { $this->output->writeln($this->titleLineFrom( $state->getTestCaseTitle() === 'FAIL' ? 'white' : 'black', $state->getTestCaseTitleColor(), $state->getTestCaseTitle(), $state->testCaseName )); $state->headerPrinted = true; } $state->eachTestCaseTests(function (TestResult $testResult) { $this->output->writeln($this->testLineFrom( $testResult->color, $testResult->icon, $testResult->description, $testResult->warning )); }); } /** * Prints the content similar too:. * * ``` * PASS Unit\ExampleTest * ✓ basic test * ``` */ public function writeErrorsSummary(State $state, bool $onFailure): void { $errors = array_filter($state->suiteTests, function (TestResult $testResult) { return $testResult->type === TestResult::FAIL; }); if (! $onFailure) { $this->output->writeln(['', " \e[2m---\e[22m", '']); } array_map(function (TestResult $testResult) use ($onFailure) { if (! $onFailure) { $this->output->write(sprintf( ' <fg=red;options=bold>• %s </>> <fg=red;options=bold>%s</>', $testResult->testCaseName, $testResult->description )); } if (! $testResult->throwable instanceof Throwable) { throw new ShouldNotHappen(); } $this->writeError($testResult->throwable); }, $errors); } /** * Writes the final recap. */ public function writeRecap(State $state, Timer $timer = null): void { $types = [TestResult::FAIL, TestResult::WARN, TestResult::RISKY, TestResult::INCOMPLETE, TestResult::SKIPPED, TestResult::PASS]; foreach ($types as $type) { if (($countTests = $state->countTestsInTestSuiteBy($type)) !== 0) { $color = TestResult::makeColor($type); $tests[] = "<fg=$color;options=bold>$countTests $type</>"; } } $pending = $state->suiteTotalTests - $state->testSuiteTestsCount(); if ($pending !== 0) { $tests[] = "\e[2m$pending pending\e[22m"; } if (! empty($tests)) { $this->output->write([ "\n", sprintf( ' <fg=white;options=bold>Tests: </><fg=default>%s</>', implode(', ', $tests) ), ]); } if ($timer !== null) { $timeElapsed = number_format($timer->result(), 2, '.', ''); $this->output->writeln([ '', sprintf( ' <fg=white;options=bold>Time: </><fg=default>%ss</>', $timeElapsed ), ] ); } $this->output->writeln(''); } /** * Displays a warning message. */ public function writeWarning(string $message): void { $this->output->writeln($this->testLineFrom('yellow', $message, '')); } /** * Displays the error using Collision's writer * and terminates with exit code === 1. */ public function writeError(Throwable $throwable): void { $writer = (new Writer())->setOutput($this->output); if ($throwable instanceof AssertionFailedError) { $writer->showTitle(false); $this->output->write('', true); } $writer->ignoreFilesIn([ '/vendor\/bin\/pest/', '/bin\/pest/', '/vendor\/pestphp\/pest/', '/vendor\/phpspec\/prophecy-phpunit/', '/vendor\/phpspec\/prophecy/', '/vendor\/phpunit\/phpunit\/src/', '/vendor\/mockery\/mockery/', '/vendor\/laravel\/dusk/', '/vendor\/laravel\/framework\/src\/Illuminate\/Testing/', '/vendor\/laravel\/framework\/src\/Illuminate\/Foundation\/Testing/', '/vendor\/symfony\/framework-bundle\/Test/', '/vendor\/symfony\/phpunit-bridge/', '/vendor\/symfony\/dom-crawler/', '/vendor\/symfony\/browser-kit/', '/vendor\/symfony\/css-selector/', '/vendor\/bin\/.phpunit/', '/bin\/.phpunit/', '/vendor\/bin\/simple-phpunit/', '/bin\/phpunit/', '/vendor\/coduo\/php-matcher\/src\/PHPUnit/', '/vendor\/sulu\/sulu\/src\/Sulu\/Bundle\/TestBundle\/Testing/', '/vendor\/webmozart\/assert/', ]); if ($throwable instanceof ExceptionWrapper && $throwable->getOriginalException() !== null) { $throwable = $throwable->getOriginalException(); } $inspector = new Inspector($throwable); $writer->write($inspector); if ($throwable instanceof ExpectationFailedException && $comparisionFailure = $throwable->getComparisonFailure()) { $diff = $comparisionFailure->getDiff(); $lines = explode(PHP_EOL, $diff); $diff = ''; foreach ($lines as $line) { if (0 === strpos($line, '-')) { $line = '<fg=red>'.$line.'</>'; } elseif (0 === strpos($line, '+')) { $line = '<fg=green>'.$line.'</>'; } $diff .= $line.PHP_EOL; } $diff = trim((string) preg_replace("/\r|\n/", "\n ", $diff)); $this->output->write(" $diff"); } $this->output->writeln(''); } /** * Returns the title contents. */ private function titleLineFrom(string $fg, string $bg, string $title, string $testCaseName): string { return sprintf( "\n <fg=%s;bg=%s;options=bold> %s </><fg=default> %s</>", $fg, $bg, $title, $testCaseName ); } /** * Returns the test contents. */ private function testLineFrom(string $fg, string $icon, string $description, string $warning = null): string { if (! empty($warning)) { $warning = sprintf( ' → %s', $warning ); } return sprintf( " <fg=%s;options=bold>%s</><fg=default> \e[2m%s\e[22m</><fg=yellow>%s</>", $fg, $icon, $description, $warning ); } } collision/src/Adapters/Phpunit/ConfigureIO.php 0000644 00000001750 15002204065 0015377 0 ustar 00 <?php declare(strict_types=1); /** * This file is part of Collision. * * (c) Nuno Maduro <enunomaduro@gmail.com> * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace NunoMaduro\Collision\Adapters\Phpunit; use ReflectionObject; use Symfony\Component\Console\Application; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\Output; /** * @internal */ final class ConfigureIO { /** * Configures both given input and output with * options from the environment. * * @throws \ReflectionException */ public static function of(InputInterface $input, Output $output): void { $application = new Application(); $reflector = new ReflectionObject($application); $method = $reflector->getMethod('configureIO'); $method->setAccessible(true); $method->invoke($application, $input, $output); } } collision/src/Adapters/Phpunit/State.php 0000644 00000010655 15002204065 0014312 0 ustar 00 <?php declare(strict_types=1); namespace NunoMaduro\Collision\Adapters\Phpunit; use NunoMaduro\Collision\Contracts\Adapters\Phpunit\HasPrintableTestCaseName; use PHPUnit\Framework\TestCase; /** * @internal */ final class State { /** * The complete test suite number of tests. * * @var int|null */ public $suiteTotalTests; /** * The complete test suite tests. * * @var array<int, TestResult> */ public $suiteTests = []; /** * The current test case class. * * @var string */ public $testCaseName; /** * The current test case tests. * * @var array<int, TestResult> */ public $testCaseTests = []; /** * The current test case tests. * * @var array<int, TestResult> */ public $toBePrintedCaseTests = []; /** * Header printed. * * @var bool */ public $headerPrinted = false; /** * The state constructor. */ private function __construct(string $testCaseName) { $this->testCaseName = $testCaseName; } /** * Creates a new State starting from the given test case. */ public static function from(TestCase $test): self { return new self(self::getPrintableTestCaseName($test)); } /** * Adds the given test to the State. */ public function add(TestResult $test): void { $this->testCaseTests[] = $test; $this->toBePrintedCaseTests[] = $test; $this->suiteTests[] = $test; } /** * Gets the test case title. */ public function getTestCaseTitle(): string { foreach ($this->testCaseTests as $test) { if ($test->type === TestResult::FAIL) { return 'FAIL'; } } foreach ($this->testCaseTests as $test) { if ($test->type !== TestResult::PASS) { return 'WARN'; } } return 'PASS'; } /** * Gets the test case title color. */ public function getTestCaseTitleColor(): string { foreach ($this->testCaseTests as $test) { if ($test->type === TestResult::FAIL) { return 'red'; } } foreach ($this->testCaseTests as $test) { if ($test->type !== TestResult::PASS) { return 'yellow'; } } return 'green'; } /** * Returns the number of tests on the current test case. */ public function testCaseTestsCount(): int { return count($this->testCaseTests); } /** * Returns the number of tests on the complete test suite. */ public function testSuiteTestsCount(): int { return count($this->suiteTests); } /** * Checks if the given test case is different from the current one. */ public function testCaseHasChanged(TestCase $testCase): bool { return self::getPrintableTestCaseName($testCase) !== $this->testCaseName; } /** * Moves the a new test case. */ public function moveTo(TestCase $testCase): void { $this->testCaseName = self::getPrintableTestCaseName($testCase); $this->testCaseTests = []; $this->headerPrinted = false; } /** * Foreach test in the test case. */ public function eachTestCaseTests(callable $callback): void { foreach ($this->toBePrintedCaseTests as $test) { $callback($test); } $this->toBePrintedCaseTests = []; } public function countTestsInTestSuiteBy(string $type): int { return count(array_filter($this->suiteTests, function (TestResult $testResult) use ($type) { return $testResult->type === $type; })); } /** * Checks if the given test already contains a result. */ public function existsInTestCase(TestCase $test): bool { foreach ($this->testCaseTests as $testResult) { if (TestResult::makeDescription($test) === $testResult->description) { return true; } } return false; } /** * Returns the printable test case name from the given `TestCase`. */ public static function getPrintableTestCaseName(TestCase $test): string { return $test instanceof HasPrintableTestCaseName ? $test->getPrintableTestCaseName() : get_class($test); } } collision/src/Adapters/Phpunit/TestResult.php 0000644 00000010610 15002204065 0015337 0 ustar 00 <?php declare(strict_types=1); namespace NunoMaduro\Collision\Adapters\Phpunit; use NunoMaduro\Collision\Contracts\Adapters\Phpunit\HasPrintableTestCaseName; use PHPUnit\Framework\TestCase; use Throwable; /** * @internal */ final class TestResult { public const FAIL = 'failed'; public const SKIPPED = 'skipped'; public const INCOMPLETE = 'incomplete'; public const RISKY = 'risky'; public const WARN = 'warnings'; public const RUNS = 'pending'; public const PASS = 'passed'; /** * @readonly * * @var string */ public $testCaseName; /** * @readonly * * @var string */ public $description; /** * @readonly * * @var string */ public $type; /** * @readonly * * @var string */ public $icon; /** * @readonly * * @var string */ public $color; /** * @readonly * * @var Throwable|null */ public $throwable; /** * @readonly * * @var string */ public $warning = ''; /** * Test constructor. */ private function __construct(string $testCaseName, string $description, string $type, string $icon, string $color, Throwable $throwable = null) { $this->testCaseName = $testCaseName; $this->description = $description; $this->type = $type; $this->icon = $icon; $this->color = $color; $this->throwable = $throwable; $asWarning = $this->type === TestResult::WARN || $this->type === TestResult::RISKY || $this->type === TestResult::SKIPPED || $this->type === TestResult::INCOMPLETE; if ($throwable instanceof Throwable && $asWarning) { $this->warning = trim((string) preg_replace("/\r|\n/", ' ', $throwable->getMessage())); } } /** * Creates a new test from the given test case. */ public static function fromTestCase(TestCase $testCase, string $type, Throwable $throwable = null): self { $testCaseName = State::getPrintableTestCaseName($testCase); $description = self::makeDescription($testCase); $icon = self::makeIcon($type); $color = self::makeColor($type); return new self($testCaseName, $description, $type, $icon, $color, $throwable); } /** * Get the test case description. */ public static function makeDescription(TestCase $testCase): string { $name = $testCase->getName(false); if ($testCase instanceof HasPrintableTestCaseName) { return $name; } // First, lets replace underscore by spaces. $name = str_replace('_', ' ', $name); // Then, replace upper cases by spaces. $name = (string) preg_replace('/([A-Z])/', ' $1', $name); // Finally, if it starts with `test`, we remove it. $name = (string) preg_replace('/^test/', '', $name); // Removes spaces $name = trim($name); // Lower case everything $name = mb_strtolower($name); // Add the dataset name if it has one if ($dataName = $testCase->dataName()) { if (is_int($dataName)) { $name .= sprintf(' with data set #%d', $dataName); } else { $name .= sprintf(' with data set "%s"', $dataName); } } return $name; } /** * Get the test case icon. */ public static function makeIcon(string $type): string { switch ($type) { case self::FAIL: return '⨯'; case self::SKIPPED: return '-'; case self::RISKY: return '!'; case self::INCOMPLETE: return '…'; case self::WARN: return '!'; case self::RUNS: return '•'; default: return '✓'; } } /** * Get the test case color. */ public static function makeColor(string $type): string { switch ($type) { case self::FAIL: return 'red'; case self::SKIPPED: case self::INCOMPLETE: case self::RISKY: case self::WARN: case self::RUNS: return 'yellow'; default: return 'green'; } } } collision/src/Writer.php 0000644 00000023333 15002204065 0011311 0 ustar 00 <?php declare(strict_types=1); namespace NunoMaduro\Collision; use NunoMaduro\Collision\Contracts\ArgumentFormatter as ArgumentFormatterContract; use NunoMaduro\Collision\Contracts\Highlighter as HighlighterContract; use NunoMaduro\Collision\Contracts\RenderlessEditor; use NunoMaduro\Collision\Contracts\RenderlessTrace; use NunoMaduro\Collision\Contracts\SolutionsRepository; use NunoMaduro\Collision\Contracts\Writer as WriterContract; use NunoMaduro\Collision\SolutionsRepositories\NullSolutionsRepository; use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\OutputInterface; use Whoops\Exception\Frame; use Whoops\Exception\Inspector; /** * @internal * * @see \Tests\Unit\WriterTest */ final class Writer implements WriterContract { /** * The number of frames if no verbosity is specified. */ public const VERBOSITY_NORMAL_FRAMES = 1; /** * Holds an instance of the solutions repository. * * @var \NunoMaduro\Collision\Contracts\SolutionsRepository */ private $solutionsRepository; /** * Holds an instance of the Output. * * @var \Symfony\Component\Console\Output\OutputInterface */ protected $output; /** * Holds an instance of the Argument Formatter. * * @var \NunoMaduro\Collision\Contracts\ArgumentFormatter */ protected $argumentFormatter; /** * Holds an instance of the Highlighter. * * @var \NunoMaduro\Collision\Contracts\Highlighter */ protected $highlighter; /** * Ignores traces where the file string matches one * of the provided regex expressions. * * @var string[] */ protected $ignore = []; /** * Declares whether or not the trace should appear. * * @var bool */ protected $showTrace = true; /** * Declares whether or not the title should appear. * * @var bool */ protected $showTitle = true; /** * Declares whether or not the editor should appear. * * @var bool */ protected $showEditor = true; /** * Creates an instance of the writer. */ public function __construct( SolutionsRepository $solutionsRepository = null, OutputInterface $output = null, ArgumentFormatterContract $argumentFormatter = null, HighlighterContract $highlighter = null ) { $this->solutionsRepository = $solutionsRepository ?: new NullSolutionsRepository(); $this->output = $output ?: new ConsoleOutput(); $this->argumentFormatter = $argumentFormatter ?: new ArgumentFormatter(); $this->highlighter = $highlighter ?: new Highlighter(); } /** * {@inheritdoc} */ public function write(Inspector $inspector): void { $this->renderTitleAndDescription($inspector); $frames = $this->getFrames($inspector); $editorFrame = array_shift($frames); $exception = $inspector->getException(); if ($this->showEditor && $editorFrame !== null && ! $exception instanceof RenderlessEditor ) { $this->renderEditor($editorFrame); } $this->renderSolution($inspector); if ($this->showTrace && ! empty($frames) && ! $exception instanceof RenderlessTrace) { $this->renderTrace($frames); } elseif (! $exception instanceof RenderlessEditor) { $this->output->writeln(''); } } /** * {@inheritdoc} */ public function ignoreFilesIn(array $ignore): WriterContract { $this->ignore = $ignore; return $this; } /** * {@inheritdoc} */ public function showTrace(bool $show): WriterContract { $this->showTrace = $show; return $this; } /** * {@inheritdoc} */ public function showTitle(bool $show): WriterContract { $this->showTitle = $show; return $this; } /** * {@inheritdoc} */ public function showEditor(bool $show): WriterContract { $this->showEditor = $show; return $this; } /** * {@inheritdoc} */ public function setOutput(OutputInterface $output): WriterContract { $this->output = $output; return $this; } /** * {@inheritdoc} */ public function getOutput(): OutputInterface { return $this->output; } /** * Returns pertinent frames. */ protected function getFrames(Inspector $inspector): array { return $inspector->getFrames() ->filter( function ($frame) { // If we are in verbose mode, we always // display the full stack trace. if ($this->output->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE) { return true; } foreach ($this->ignore as $ignore) { // Ensure paths are linux-style (like the ones on $this->ignore) // @phpstan-ignore-next-line $sanitizedPath = (string) str_replace('\\', '/', $frame->getFile()); if (preg_match($ignore, $sanitizedPath)) { return false; } } return true; } ) ->getArray(); } /** * Renders the title of the exception. */ protected function renderTitleAndDescription(Inspector $inspector): WriterContract { $exception = $inspector->getException(); $message = rtrim($exception->getMessage()); $class = $inspector->getExceptionName(); if ($this->showTitle) { $this->render("<bg=red;options=bold> $class </>"); $this->output->writeln(''); } $this->output->writeln("<fg=default;options=bold> $message</>"); return $this; } /** * Renders the solution of the exception, if any. */ protected function renderSolution(Inspector $inspector): WriterContract { $throwable = $inspector->getException(); $solutions = $this->solutionsRepository->getFromThrowable($throwable); foreach ($solutions as $solution) { /** @var \Spatie\Ignition\Contracts\Solution $solution */ $title = $solution->getSolutionTitle(); $description = $solution->getSolutionDescription(); $links = $solution->getDocumentationLinks(); $description = trim((string) preg_replace("/\n/", "\n ", $description)); $this->render(sprintf( '<fg=cyan;options=bold>i</> <fg=default;options=bold>%s</>: %s %s', rtrim($title, '.'), $description, implode(', ', array_map(function (string $link) { return sprintf("\n <fg=gray>%s</>", $link); }, $links)) )); } return $this; } /** * Renders the editor containing the code that was the * origin of the exception. */ protected function renderEditor(Frame $frame): WriterContract { if ($frame->getFile() !== 'Unknown') { $file = $this->getFileRelativePath((string) $frame->getFile()); // getLine() might return null so cast to int to get 0 instead $line = (int) $frame->getLine(); $this->render('at <fg=green>'.$file.'</>'.':<fg=green>'.$line.'</>'); $content = $this->highlighter->highlight((string) $frame->getFileContents(), (int) $frame->getLine()); $this->output->writeln($content); } return $this; } /** * Renders the trace of the exception. */ protected function renderTrace(array $frames): WriterContract { $vendorFrames = 0; $userFrames = 0; foreach ($frames as $i => $frame) { if ($this->output->getVerbosity() < OutputInterface::VERBOSITY_VERBOSE && strpos($frame->getFile(), '/vendor/') !== false) { $vendorFrames++; continue; } if ($userFrames > static::VERBOSITY_NORMAL_FRAMES && $this->output->getVerbosity() < OutputInterface::VERBOSITY_VERBOSE) { break; } $userFrames++; $file = $this->getFileRelativePath($frame->getFile()); $line = $frame->getLine(); $class = empty($frame->getClass()) ? '' : $frame->getClass().'::'; $function = $frame->getFunction(); $args = $this->argumentFormatter->format($frame->getArgs()); $pos = str_pad((string) ((int) $i + 1), 4, ' '); if ($vendorFrames > 0) { $this->output->write( sprintf("\n \e[2m+%s vendor frames \e[22m", $vendorFrames) ); $vendorFrames = 0; } $this->render("<fg=yellow>$pos</><fg=default;options=bold>$file</>:<fg=default;options=bold>$line</>"); $this->render("<fg=gray> $class$function($args)</>", false); } return $this; } /** * Renders an message into the console. * * @return $this */ protected function render(string $message, bool $break = true): WriterContract { if ($break) { $this->output->writeln(''); } $this->output->writeln(" $message"); return $this; } /** * Returns the relative path of the given file path. */ protected function getFileRelativePath(string $filePath): string { $cwd = (string) getcwd(); if (! empty($cwd)) { return str_replace("$cwd/", '', $filePath); } return $filePath; } } collision/LICENSE.md 0000644 00000002111 15002204065 0010130 0 ustar 00 The MIT License (MIT) Copyright (c) Nuno Maduro <enunomaduro@gmail.com> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
| ver. 1.4 |
Github
|
.
| PHP 8.2.28 | Generation time: 0.01 |
proxy
|
phpinfo
|
Settings