JFIFxxC      C  " }!1AQa"q2#BR$3br %&'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz w!1AQaq"2B #3Rbrcollision/composer.json000064400000005202150364335730011271 0ustar00{ "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.1.0", "filp/whoops": "^2.15.3", "nunomaduro/termwind": "^1.15.1", "symfony/console": "^6.3.4" }, "conflict": { "laravel/framework": ">=11.0.0" }, "require-dev": { "brianium/paratest": "^7.3.0", "laravel/framework": "^10.28.0", "laravel/pint": "^1.13.3", "laravel/sail": "^1.25.0", "laravel/sanctum": "^3.3.1", "laravel/tinker": "^2.8.2", "nunomaduro/larastan": "^2.6.4", "orchestra/testbench-core": "^8.13.0", "pestphp/pest": "^2.23.2", "phpunit/phpunit": "^10.4.1", "sebastian/environment": "^6.0.1", "spatie/laravel-ignition": "^2.3.1" }, "autoload-dev": { "psr-4": { "Tests\\Printer\\": "tests/Printer", "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/" }, "files": [ "./src/Adapters/Phpunit/Autoload.php" ] }, "config": { "preferred-install": "dist", "sort-packages": true, "allow-plugins": { "pestphp/pest-plugin": true } }, "extra": { "laravel": { "providers": [ "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" ] } }, "scripts": { "lint": "pint -v", "test:lint": "pint --test -v", "test:types": "phpstan analyse --ansi", "test:unit:phpunit": [ "@putenv XDEBUG_MODE=coverage", "phpunit --colors=always" ], "test:unit:pest": [ "@putenv XDEBUG_MODE=coverage", "pest --colors=always -v" ], "test": [ "@test:lint", "@test:types", "@test:unit:phpunit", "@test:unit:pest" ] } } collision/src/Exceptions/TestOutcome.php000064400000000275150364335730014450 0ustar00throwable; } /** * @return class-string */ public function getClassName(): string { return $this->throwable->className(); } public function getMessage(): string { if ($this->throwable->className() === ExpectationFailedException::class) { $message = $this->throwable->description(); } else { $message = $this->throwable->message(); } $regexes = [ 'To contain' => '/Failed asserting that \'(.*)\' contains "(.*)"\./s', 'Not to contain' => '/Failed asserting that \'(.*)\' does not contain "(.*)"\./s', ]; foreach ($regexes as $key => $pattern) { preg_match($pattern, $message, $matches, PREG_OFFSET_CAPTURE, 0); if (count($matches) === 3) { $message = $this->shortenMessage($matches, $key); break; } } // Diffs... if (str_contains($message, self::DIFF_SEPARATOR)) { $diff = ''; $lines = explode(PHP_EOL, explode(self::DIFF_SEPARATOR, $message)[1]); foreach ($lines as $line) { $diff .= $this->colorizeLine($line, str_starts_with($line, '-') ? 'red' : 'green').PHP_EOL; } $message = str_replace(explode(self::DIFF_SEPARATOR, $message)[1], $diff, $message); $message = str_replace(self::DIFF_SEPARATOR, '', $message); } return $message; } private function shortenMessage(array $matches, string $key): string { $actual = $matches[1][0]; $expected = $matches[2][0]; $actualExploded = explode(PHP_EOL, $actual); $expectedExploded = explode(PHP_EOL, $expected); if (($countActual = count($actualExploded)) > 4 && ! $this->isVerbose) { $actualExploded = array_slice($actualExploded, 0, 3); } if (($countExpected = count($expectedExploded)) > 4 && ! $this->isVerbose) { $expectedExploded = array_slice($expectedExploded, 0, 3); } $actualAsString = ''; $expectedAsString = ''; foreach ($actualExploded as $line) { $actualAsString .= PHP_EOL.$this->colorizeLine($line, 'red'); } foreach ($expectedExploded as $line) { $expectedAsString .= PHP_EOL.$this->colorizeLine($line, 'green'); } if ($countActual > 4 && ! $this->isVerbose) { $actualAsString .= PHP_EOL.$this->colorizeLine(sprintf('... (%s more lines)', $countActual - 3), 'gray'); } if ($countExpected > 4 && ! $this->isVerbose) { $expectedAsString .= PHP_EOL.$this->colorizeLine(sprintf('... (%s more lines)', $countExpected - 3), 'gray'); } return implode(PHP_EOL, [ 'Expected: '.ltrim($actualAsString, PHP_EOL.' '), '', ' '.$key.': '.ltrim($expectedAsString, PHP_EOL.' '), '', ]); } public function getCode(): int { return 0; } /** * @throws \ReflectionException */ public function getFile(): string { if (! isset($this->getTrace()[0])) { return (string) (new ReflectionClass($this->getClassName()))->getFileName(); } return $this->getTrace()[0]['file']; } public function getLine(): int { if (! isset($this->getTrace()[0])) { return 0; } return (int) $this->getTrace()[0]['line']; } public function getTrace(): array { $frames = explode("\n", $this->getTraceAsString()); $frames = array_filter($frames, fn ($trace) => $trace !== ''); return array_map(function ($trace) { if (trim($trace) === '') { return null; } $parts = explode(':', $trace); $line = array_pop($parts); $file = implode(':', $parts); return [ 'file' => $file, 'line' => $line, ]; }, $frames); } public function getTraceAsString(): string { return $this->throwable->stackTrace(); } public function getPrevious(): ?self { if ($this->throwable->hasPrevious()) { return new self($this->throwable->previous(), $this->isVerbose); } return null; } public function __toString() { return $this->getMessage(); } private function colorizeLine(string $line, string $color): string { return sprintf(' %s', $color, $line); } } collision/src/Exceptions/InvalidStyleException.php000064400000000303150364335730016453 0ustar00run = $run ?: new Run(); $this->handler = $handler ?: new Handler(); } /** * Registers the current Handler as Error Handler. */ public function register(): self { $this->run->pushHandler($this->handler) ->register(); return $this; } /** * Returns the handler. */ public function getHandler(): Handler { return $this->handler; } } collision/src/Coverage.php000064400000013763150364335730011615 0ustar00canCollectCodeCoverage()) { return false; } if ($runtime->hasPCOV() || $runtime->hasPHPDBGCodeCoverage()) { return true; } if (self::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( " WARN Unable to get coverage using Xdebug. Did you set Xdebug's coverage mode?", ); return 0.0; } $output->writeln( ' WARN No coverage driver detected. Did you install Xdebug or PCOV?', ); return 0.0; } /** @var CodeCoverage $codeCoverage */ $codeCoverage = require $reportPath; unlink($reportPath); $totalCoverage = $codeCoverage->getReport()->percentageOfExecutedLines(); /** @var 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, ]); $percentage = $file->numberOfExecutableLines() === 0 ? '100.0' : number_format($file->percentageOfExecutedLines()->asFloat(), 1, '.', ''); $uncoveredLines = ''; $percentageOfExecutedLinesAsString = $file->percentageOfExecutedLines()->asString(); if (! in_array($percentageOfExecutedLinesAsString, ['0.00%', '100.00%', '100.0%', ''], true)) { $uncoveredLines = trim(implode(', ', self::getMissingCoverage($file))); $uncoveredLines = sprintf('%s', $uncoveredLines).' / '; } $color = $percentage === '100.0' ? 'green' : ($percentage === '0.0' ? 'red' : 'yellow'); $truncateAt = max(1, terminal()->width() - 12); renderUsing($output); render(<< {$name} $uncoveredLines {$percentage}% HTML); } $totalCoverageAsString = $totalCoverage->asFloat() === 0.0 ? '0.0' : number_format($totalCoverage->asFloat(), 1, '.', ''); renderUsing($output); render(<<
Total: {$totalCoverageAsString} %
HTML); return $totalCoverage->asFloat(); } /** * Generates an array of missing coverage on the following format:. * * ``` * ['11', '20..25', '50', '60..80']; * ``` * * @param File $file * @return array */ public static function getMissingCoverage($file): array { $shouldBeNewLine = true; $eachLine = function (array $array, array $tests, int $line) use (&$shouldBeNewLine): array { if ($tests !== []) { $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((string) $array[$lastKey], '..')) { [$from] = explode('..', (string) $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.php000064400000000602150364335730021407 0ustar00 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', ]; private array $themes = []; /** * @throws InvalidStyleException * @throws InvalidArgumentException */ public function apply(array|string $style, string $text): string { 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); } public function setForceStyle(bool $forceStyle): void { $this->forceStyle = $forceStyle; } public function isStyleForced(): bool { return $this->forceStyle; } public function setThemes(array $themes): void { $this->themes = []; foreach ($themes as $name => $styles) { $this->addTheme($name, $styles); } } public function addTheme(string $name, array|string $styles): void { 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; } public function getThemes(): array { return $this->themes; } public function hasTheme(string $name): bool { return isset($this->themes[$name]); } public function removeTheme(string $name): void { unset($this->themes[$name]); } public function isSupported(): bool { // 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); } public function are256ColorsSupported(): bool { if (DIRECTORY_SEPARATOR === '\\') { return function_exists('sapi_windows_vt100_support') && @sapi_windows_vt100_support(STDOUT); } return strpos((string) getenv('TERM'), '256color') !== false; } public function getPossibleStyles(): array { return array_keys(self::STYLES); } private function themeSequence(string $name): array { $sequences = []; foreach ($this->themes[$name] as $style) { $sequences[] = $this->styleSequence($style); } return $sequences; } private function styleSequence(string $style): ?string { 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"; } private function isValidStyle(string $style): bool { return array_key_exists($style, self::STYLES) || preg_match(self::COLOR256_REGEXP, $style); } private function escSequence(string|int $value): string { return "\033[{$value}m"; } } collision/src/Highlighter.php000064400000020230150364335730012303 0ustar00'; 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. */ 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'], ]; private ConsoleColor $color; 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', ]; private string $delimiter = self::DELIMITER_UTF8; private string $arrow = self::ARROW_SYMBOL_UTF8; 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 .= ' '; } /** * Highlights the provided content. */ public function highlight(string $content, int $line): string { return rtrim($this->getCodeSnippet($content, $line, 4, 4)); } /** * Highlights the provided content. */ public function getCodeSnippet(string $source, int $lineNumber, int $linesBefore = 2, int $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); } private function getHighlightedLines(string $source): array { $source = str_replace(["\r\n", "\r"], "\n", $source); $tokens = $this->tokenize($source); return $this->splitToLines($tokens); } private function tokenize(string $source): array { $tokens = token_get_all($source); $output = []; $currentType = null; $buffer = ''; $newType = null; 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; } private function lineNumbers(array $lines, int $markLine = null): string { $lineStrlen = strlen((string) ((int) 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 ($markLine !== null) { $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; } private function coloredLineNumber(string $style, int $i, int $length): string { return $this->color->apply($style, str_pad((string) ($i + 1), $length, ' ', STR_PAD_LEFT)); } } collision/src/Handler.php000064400000001772150364335730011434 0ustar00writer = $writer ?: new Writer(); } /** * {@inheritdoc} */ public function handle(): int { $this->writer->write($this->getInspector()); // @phpstan-ignore-line return self::QUIT; } /** * {@inheritdoc} */ public function setOutput(OutputInterface $output): self { $this->writer->setOutput($output); return $this; } /** * {@inheritdoc} */ public function getWriter(): Writer { return $this->writer; } } collision/src/Writer.php000064400000023601150364335730011326 0ustar00 */ private array $ignore = []; /** * Declares whether or not the trace should appear. */ private bool $showTrace = true; /** * Declares whether or not the title should appear. */ private bool $showTitle = true; /** * Declares whether the editor should appear. */ private bool $showEditor = true; /** * Creates an instance of the writer. */ public function __construct( SolutionsRepository $solutionsRepository = null, OutputInterface $output = null, ArgumentFormatter $argumentFormatter = null, Highlighter $highlighter = null ) { $this->solutionsRepository = $solutionsRepository ?: new NullSolutionsRepository(); $this->output = $output ?: new ConsoleOutput(); $this->argumentFormatter = $argumentFormatter ?: new ArgumentFormatter(); $this->highlighter = $highlighter ?: new Highlighter(); } public function write(Inspector $inspector): void { $this->renderTitleAndDescription($inspector); $frames = $this->getFrames($inspector); $exception = $inspector->getException(); if ($exception instanceof RenderableOnCollisionEditor) { $editorFrame = $exception->toCollisionEditor(); } else { $editorFrame = array_shift($frames); } 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(''); } } public function ignoreFilesIn(array $ignore): self { $this->ignore = $ignore; return $this; } public function showTrace(bool $show): self { $this->showTrace = $show; return $this; } public function showTitle(bool $show): self { $this->showTitle = $show; return $this; } public function showEditor(bool $show): self { $this->showEditor = $show; return $this; } public function setOutput(OutputInterface $output): self { $this->output = $output; return $this; } public function getOutput(): OutputInterface { return $this->output; } /** * Returns pertinent frames. * * @return array */ private 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) { if (is_string($ignore)) { // Ensure paths are linux-style (like the ones on $this->ignore) $sanitizedPath = (string) str_replace('\\', '/', $frame->getFile()); if (preg_match($ignore, $sanitizedPath)) { return false; } } if ($ignore instanceof Closure) { if ($ignore($frame)) { return false; } } } return true; } ) ->getArray(); } /** * Renders the title of the exception. */ private function renderTitleAndDescription(Inspector $inspector): self { /** @var Throwable|TestException $exception */ $exception = $inspector->getException(); $message = rtrim($exception->getMessage()); $class = $exception instanceof TestException ? $exception->getClassName() : $inspector->getExceptionName(); if ($this->showTitle) { $this->render(" $class "); $this->output->writeln(''); } $this->output->writeln(" $message"); return $this; } /** * Renders the solution of the exception, if any. */ private function renderSolution(Inspector $inspector): self { $throwable = $inspector->getException(); $solutions = $throwable instanceof Throwable ? $this->solutionsRepository->getFromThrowable($throwable) : []; // @phpstan-ignore-line 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( 'i %s: %s %s', rtrim($title, '.'), $description, implode(', ', array_map(function (string $link) { return sprintf("\n %s", $link); }, $links)) )); } return $this; } /** * Renders the editor containing the code that was the * origin of the exception. */ private function renderEditor(Frame $frame): self { 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 '.$file.''.':'.$line.''); $content = $this->highlighter->highlight((string) $frame->getFileContents(), (int) $frame->getLine()); $this->output->writeln($content); } return $this; } /** * Renders the trace of the exception. */ private function renderTrace(array $frames): self { $vendorFrames = 0; $userFrames = 0; if (! empty($frames)) { $this->output->writeln(['']); } foreach ($frames as $i => $frame) { if ($this->output->getVerbosity() < OutputInterface::VERBOSITY_VERBOSE && strpos($frame->getFile(), '/vendor/') !== false) { $vendorFrames++; continue; } if ($userFrames > self::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->writeln( sprintf(" \e[2m+%s vendor frames \e[22m", $vendorFrames) ); $vendorFrames = 0; } $this->render("$pos$file:$line", (bool) $class && $i > 0); if ($class) { $this->render(" $class$function($args)", false); } } if (! empty($frames)) { $this->output->writeln(['']); } return $this; } /** * Renders a message into the console. */ private function render(string $message, bool $break = true): self { if ($break) { $this->output->writeln(''); } $this->output->writeln(" $message"); return $this; } /** * Returns the relative path of the given file path. */ private function getFileRelativePath(string $filePath): string { $cwd = (string) getcwd(); if (! empty($cwd)) { return str_replace("$cwd".DIRECTORY_SEPARATOR, '', $filePath); } return $filePath; } } collision/src/Adapters/Phpunit/Support/ResultReflection.php000064400000000613150364335730020207 0ustar00 $this->numberOfTests)->call($testResult); } } collision/src/Adapters/Phpunit/TestResult.php000064400000022021150364335730015355 0ustar00id = $id; $this->testCaseName = $testCaseName; $this->description = $description; $this->type = $type; $this->icon = $icon; $this->compactIcon = $compactIcon; $this->color = $color; $this->compactColor = $compactColor; $this->throwable = $throwable; $this->duration = 0.0; $asWarning = $this->type === TestResult::WARN || $this->type === TestResult::RISKY || $this->type === TestResult::SKIPPED || $this->type === TestResult::DEPRECATED || $this->type === TestResult::NOTICE || $this->type === TestResult::INCOMPLETE; if ($throwable instanceof Throwable && $asWarning) { if (in_array($this->type, [TestResult::DEPRECATED, TestResult::NOTICE])) { foreach (explode("\n", $throwable->stackTrace()) as $line) { if (strpos($line, 'vendor/nunomaduro/collision') === false) { $this->warningSource = str_replace(getcwd().'/', '', $line); break; } } } $this->warning .= trim((string) preg_replace("/\r|\n/", ' ', $throwable->message())); // pest specific $this->warning = str_replace('__pest_evaluable_', '', $this->warning); $this->warning = str_replace('This test depends on "P\\', 'This test depends on "', $this->warning); } } /** * Sets the telemetry information. */ public function setDuration(float $duration): void { $this->duration = $duration; } /** * Creates a new test from the given test case. */ public static function fromTestCase(Test $test, string $type, Throwable $throwable = null): self { if (! $test instanceof TestMethod) { throw new ShouldNotHappen(); } if (is_subclass_of($test->className(), HasPrintableTestCaseName::class)) { $testCaseName = $test->className()::getPrintableTestCaseName(); } else { $testCaseName = $test->className(); } $description = self::makeDescription($test); $icon = self::makeIcon($type); $compactIcon = self::makeCompactIcon($type); $color = self::makeColor($type); $compactColor = self::makeCompactColor($type); return new self($test->id(), $testCaseName, $description, $type, $icon, $compactIcon, $color, $compactColor, $throwable); } /** * Creates a new test from the given Pest Parallel Test Case. */ public static function fromPestParallelTestCase(Test $test, string $type, Throwable $throwable = null): self { if (! $test instanceof TestMethod) { throw new ShouldNotHappen(); } if (is_subclass_of($test->className(), HasPrintableTestCaseName::class)) { $testCaseName = $test->className()::getPrintableTestCaseName(); } else { $testCaseName = $test->className(); } if (is_subclass_of($test->className(), HasPrintableTestCaseName::class)) { $description = $test->testDox()->prettifiedMethodName(); } else { $description = self::makeDescription($test); } $icon = self::makeIcon($type); $compactIcon = self::makeCompactIcon($type); $color = self::makeColor($type); $compactColor = self::makeCompactColor($type); return new self($test->id(), $testCaseName, $description, $type, $icon, $compactIcon, $color, $compactColor, $throwable); } /** * Creates a new test from the given test case. */ public static function fromBeforeFirstTestMethodErrored(BeforeFirstTestMethodErrored $event): self { if (is_subclass_of($event->testClassName(), HasPrintableTestCaseName::class)) { $testCaseName = $event->testClassName()::getPrintableTestCaseName(); } else { $testCaseName = $event->testClassName(); } $description = ''; $icon = self::makeIcon(self::FAIL); $compactIcon = self::makeCompactIcon(self::FAIL); $color = self::makeColor(self::FAIL); $compactColor = self::makeCompactColor(self::FAIL); return new self($testCaseName, $testCaseName, $description, self::FAIL, $icon, $compactIcon, $color, $compactColor, $event->throwable()); } /** * Get the test case description. */ public static function makeDescription(TestMethod $test): string { if (is_subclass_of($test->className(), HasPrintableTestCaseName::class)) { return $test->className()::getLatestPrintableTestCaseMethodName(); } $name = $test->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); 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::DEPRECATED: case self::WARN: case self::RISKY: case self::NOTICE: return '!'; case self::INCOMPLETE: return '…'; case self::TODO: return '↓'; case self::RUNS: return '•'; default: return '✓'; } } /** * Get the test case compact icon. */ public static function makeCompactIcon(string $type): string { switch ($type) { case self::FAIL: return '⨯'; case self::SKIPPED: return 's'; case self::DEPRECATED: case self::NOTICE: case self::WARN: case self::RISKY: return '!'; case self::INCOMPLETE: return 'i'; case self::TODO: return 't'; case self::RUNS: return '•'; default: return '.'; } } /** * Get the test case compact color. */ public static function makeCompactColor(string $type): string { switch ($type) { case self::FAIL: return 'red'; case self::DEPRECATED: case self::NOTICE: case self::SKIPPED: case self::INCOMPLETE: case self::RISKY: case self::WARN: case self::RUNS: return 'yellow'; case self::TODO: return 'cyan'; default: return 'gray'; } } /** * Get the test case color. */ public static function makeColor(string $type): string { switch ($type) { case self::TODO: return 'cyan'; case self::FAIL: return 'red'; case self::DEPRECATED: case self::NOTICE: case self::SKIPPED: case self::INCOMPLETE: case self::RISKY: case self::WARN: case self::RUNS: return 'yellow'; default: return 'green'; } } } collision/src/Adapters/Phpunit/Autoload.php000064400000000507150364335730015014 0ustar00= 10) { EnsurePrinterIsRegisteredSubscriber::register(); } collision/src/Adapters/Phpunit/Style.php000064400000044330150364335730014346 0ustar00terminal = terminal(); $this->output = $output; $this->compactSymbolsPerLine = $this->terminal->width() - 4; } /** * Prints the content similar too:. * * ``` * WARN Your XML configuration validates against a deprecated schema... * ``` */ public function writeWarning(string $message): void { $this->output->writeln(['', ' WARN '.$message]); } /** * Prints the content similar too:. * * ``` * WARN Your XML configuration validates against a deprecated schema... * ``` */ public function writeThrowable(\Throwable $throwable): void { $this->output->writeln(['', ' ERROR '.$throwable->getMessage()]); } /** * Prints the content similar too:. * * ``` * PASS Unit\ExampleTest * ✓ basic test * ``` */ public function writeCurrentTestCaseSummary(State $state): void { if ($state->testCaseTestsCount() === 0 || is_null($state->testCaseName)) { return; } if (! $state->headerPrinted && ! DefaultPrinter::compact()) { $this->output->writeln($this->titleLineFrom( $state->getTestCaseFontColor(), $state->getTestCaseTitleColor(), $state->getTestCaseTitle(), $state->testCaseName, $state->todosCount(), )); $state->headerPrinted = true; } $state->eachTestCaseTests(function (TestResult $testResult): void { if ($testResult->description !== '') { if (DefaultPrinter::compact()) { $this->writeCompactDescriptionLine($testResult); } else { $this->writeDescriptionLine($testResult); } } }); } /** * Prints the content similar too:. * * ``` * PASS Unit\ExampleTest * ✓ basic test * ``` */ public function writeErrorsSummary(State $state): void { $configuration = Registry::get(); $failTypes = [ TestResult::FAIL, ]; if ($configuration->displayDetailsOnTestsThatTriggerNotices()) { $failTypes[] = TestResult::NOTICE; } if ($configuration->displayDetailsOnTestsThatTriggerDeprecations()) { $failTypes[] = TestResult::DEPRECATED; } if ($configuration->failOnWarning() || $configuration->displayDetailsOnTestsThatTriggerWarnings()) { $failTypes[] = TestResult::WARN; } if ($configuration->failOnRisky()) { $failTypes[] = TestResult::RISKY; } if ($configuration->failOnIncomplete() || $configuration->displayDetailsOnIncompleteTests()) { $failTypes[] = TestResult::INCOMPLETE; } if ($configuration->failOnSkipped() || $configuration->displayDetailsOnSkippedTests()) { $failTypes[] = TestResult::SKIPPED; } $failTypes = array_unique($failTypes); $errors = array_values(array_filter($state->suiteTests, fn (TestResult $testResult) => in_array( $testResult->type, $failTypes, true ))); array_map(function (TestResult $testResult): void { if (! $testResult->throwable instanceof Throwable) { throw new ShouldNotHappen(); } renderUsing($this->output); render(<<<'HTML'

HTML ); $testCaseName = $testResult->testCaseName; $description = $testResult->description; /** @var class-string $throwableClassName */ $throwableClassName = $testResult->throwable->className(); $throwableClassName = ! in_array($throwableClassName, [ ExpectationFailedException::class, IncompleteTestError::class, SkippedWithMessageException::class, TestOutcome::class, ], true) ? sprintf('%s', (new ReflectionClass($throwableClassName))->getShortName()) : ''; $truncateClasses = $this->output->isVerbose() ? '' : 'flex-1 truncate'; renderUsing($this->output); render(sprintf(<<<'HTML'
%s %s>%s %s
HTML, $truncateClasses, $testResult->color === 'yellow' ? 'yellow-400' : $testResult->color, $testResult->color === 'yellow' ? 'text-black' : '', $testResult->type, $testCaseName, $description, $throwableClassName)); $this->writeError($testResult->throwable); }, $errors); } /** * Writes the final recap. */ public function writeRecap(State $state, Info $telemetry, PHPUnitTestResult $result): void { $tests = []; foreach (self::TYPES as $type) { if (($countTests = $state->countTestsInTestSuiteBy($type)) !== 0) { $color = TestResult::makeColor($type); if ($type === TestResult::WARN && $countTests < 2) { $type = 'warning'; } if ($type === TestResult::NOTICE && $countTests > 1) { $type = 'notices'; } if ($type === TestResult::TODO && $countTests > 1) { $type = 'todos'; } $tests[] = "$countTests $type"; } } $pending = ResultReflection::numberOfTests($result) - $result->numberOfTestsRun(); if ($pending > 0) { $tests[] = "\e[2m$pending pending\e[22m"; } $timeElapsed = number_format($telemetry->durationSinceStart()->asFloat(), 2, '.', ''); $this->output->writeln(['']); if (! empty($tests)) { $this->output->writeln([ sprintf( ' Tests: %s (%s assertions)', implode(', ', $tests), $result->numberOfAssertions() ), ]); } $this->output->writeln([ sprintf( ' Duration: %ss', $timeElapsed ), ]); $this->output->writeln(''); } /** * @param array $slowTests */ public function writeSlowTests(array $slowTests, Info $telemetry): void { $this->output->writeln(' Top 10 slowest tests:'); $timeElapsed = $telemetry->durationSinceStart()->asFloat(); foreach ($slowTests as $testResult) { $seconds = number_format($testResult->duration / 1000, 2, '.', ''); $color = ($testResult->duration / 1000) > $timeElapsed * 0.25 ? 'red' : ($testResult->duration > $timeElapsed * 0.1 ? 'yellow' : 'gray'); renderUsing($this->output); render(sprintf(<<<'HTML'
%s>%s %ss
HTML, $testResult->testCaseName, $testResult->description, $color, $seconds)); } $timeElapsedInSlowTests = array_sum(array_map(fn (TestResult $testResult) => $testResult->duration / 1000, $slowTests)); $timeElapsedAsString = number_format($timeElapsed, 2, '.', ''); $percentageInSlowTestsAsString = number_format($timeElapsedInSlowTests * 100 / $timeElapsed, 2, '.', ''); $timeElapsedInSlowTestsAsString = number_format($timeElapsedInSlowTests, 2, '.', ''); renderUsing($this->output); render(sprintf(<<<'HTML'

(%s%% of %ss) %ss
HTML, $percentageInSlowTestsAsString, $timeElapsedAsString, $timeElapsedInSlowTestsAsString)); } /** * 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); $throwable = new TestException($throwable, $this->output->isVerbose()); $writer->showTitle(false); $writer->ignoreFilesIn([ '/vendor\/nunomaduro\/collision/', '/vendor\/bin\/pest/', '/bin\/pest/', '/vendor\/pestphp\/pest/', '/vendor\/pestphp\/pest-plugin-arch/', '/vendor\/phpspec\/prophecy-phpunit/', '/vendor\/phpspec\/prophecy/', '/vendor\/phpunit\/phpunit\/src/', '/vendor\/mockery\/mockery/', '/vendor\/laravel\/dusk/', '/Illuminate\/Testing/', '/Illuminate\/Foundation\/Testing/', '/Illuminate\/Foundation\/Bootstrap\/HandleExceptions/', '/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/', $this->ignorePestPipes(...), $this->ignorePestExtends(...), $this->ignorePestInterceptors(...), ]); /** @var \Throwable $throwable */ $inspector = new Inspector($throwable); $writer->write($inspector); } /** * Returns the title contents. */ private function titleLineFrom(string $fg, string $bg, string $title, string $testCaseName, int $todos): string { return sprintf( "\n %s %s%s", $fg, $bg, $title, $testCaseName, $todos > 0 ? sprintf(' - %s todo%s', $todos, $todos > 1 ? 's' : '') : '', ); } /** * Writes a description line. */ private function writeCompactDescriptionLine(TestResult $result): void { $symbolsOnCurrentLine = $this->compactProcessed % $this->compactSymbolsPerLine; if ($symbolsOnCurrentLine >= $this->terminal->width() - 4) { $symbolsOnCurrentLine = 0; } if ($symbolsOnCurrentLine === 0) { $this->output->writeln(''); $this->output->write(' '); } $this->output->write(sprintf('%s', $result->compactColor, $result->compactIcon)); $this->compactProcessed++; } /** * Writes a description line. */ private function writeDescriptionLine(TestResult $result): void { if (! empty($warning = $result->warning)) { if (! str_contains($warning, "\n")) { $warning = sprintf( ' → %s', $warning ); } else { $warningLines = explode("\n", $warning); $warning = ''; foreach ($warningLines as $w) { $warning .= sprintf( "\n ⇂ %s", trim($w) ); } } } $seconds = ''; if (($result->duration / 1000) > 0.0) { $seconds = number_format($result->duration / 1000, 2, '.', ''); $seconds = $seconds !== '0.00' ? sprintf('%ss', $seconds) : ''; } if (isset($_SERVER['REBUILD_SNAPSHOTS']) || (isset($_SERVER['COLLISION_IGNORE_DURATION']) && $_SERVER['COLLISION_IGNORE_DURATION'] === 'true')) { $seconds = ''; } $truncateClasses = $this->output->isVerbose() ? '' : 'flex-1 truncate'; if ($warning !== '') { $warning = sprintf('%s', $warning); if (! empty($result->warningSource)) { $warning .= ' // '.$result->warningSource; } } $description = preg_replace('/`([^`]+)`/', '$1', $result->description); renderUsing($this->output); render(sprintf(<<<'HTML'
%s%s%s %s
HTML, $seconds === '' ? '' : 'flex space-x-1 justify-between', $truncateClasses, $result->color, $result->icon, $description, $warning, $seconds)); } /** * @param Frame $frame */ private function ignorePestPipes($frame): bool { if (class_exists(Expectation::class)) { $reflection = new ReflectionClass(Expectation::class); /** @var array> $expectationPipes */ $expectationPipes = $reflection->getStaticPropertyValue('pipes', []); foreach ($expectationPipes as $pipes) { foreach ($pipes as $pipeClosure) { if ($this->isFrameInClosure($frame, $pipeClosure)) { return true; } } } } return false; } /** * @param Frame $frame */ private function ignorePestExtends($frame): bool { if (class_exists(Expectation::class)) { $reflection = new ReflectionClass(Expectation::class); /** @var array $extends */ $extends = $reflection->getStaticPropertyValue('extends', []); foreach ($extends as $extendClosure) { if ($this->isFrameInClosure($frame, $extendClosure)) { return true; } } } return false; } /** * @param Frame $frame */ private function ignorePestInterceptors($frame): bool { if (class_exists(Expectation::class)) { $reflection = new ReflectionClass(Expectation::class); /** @var array> $expectationInterceptors */ $expectationInterceptors = $reflection->getStaticPropertyValue('interceptors', []); foreach ($expectationInterceptors as $pipes) { foreach ($pipes as $pipeClosure) { if ($this->isFrameInClosure($frame, $pipeClosure)) { return true; } } } } return false; } /** * @param Frame $frame */ private function isFrameInClosure($frame, Closure $closure): bool { $reflection = new ReflectionFunction($closure); $sanitizedPath = (string) str_replace('\\', '/', (string) $frame->getFile()); /** @phpstan-ignore-next-line */ $sanitizedClosurePath = (string) str_replace('\\', '/', $reflection->getFileName()); if ($sanitizedPath === $sanitizedClosurePath) { if ($reflection->getStartLine() <= $frame->getLine() && $frame->getLine() <= $reflection->getEndLine()) { return true; } } return false; } } collision/src/Adapters/Phpunit/Subscribers/EnsurePrinterIsRegisteredSubscriber.php000064400000027755150364335730024713 0ustar00= 10) { /** * @internal */ final class EnsurePrinterIsRegisteredSubscriber implements StartedSubscriber { /** * If this subscriber has been registered on PHPUnit's facade. */ private static bool $registered = false; /** * Runs the subscriber. */ public function notify(Started $event): void { $printer = new ReportablePrinter(new DefaultPrinter(true)); if (isset($_SERVER['COLLISION_PRINTER_COMPACT'])) { DefaultPrinter::compact(true); } if (isset($_SERVER['COLLISION_PRINTER_PROFILE'])) { DefaultPrinter::profile(true); } $subscribers = [ // Configured new class($printer) extends Subscriber implements ConfiguredSubscriber { public function notify(Configured $event): void { $this->printer()->setDecorated( $event->configuration()->colors() ); } }, // Test new class($printer) extends Subscriber implements PrintedUnexpectedOutputSubscriber { public function notify(PrintedUnexpectedOutput $event): void { $this->printer()->testPrintedUnexpectedOutput($event); } }, // Test Runner new class($printer) extends Subscriber implements ExecutionStartedSubscriber { public function notify(ExecutionStarted $event): void { $this->printer()->testRunnerExecutionStarted($event); } }, new class($printer) extends Subscriber implements ExecutionFinishedSubscriber { public function notify(ExecutionFinished $event): void { $this->printer()->testRunnerExecutionFinished($event); } }, // Test > Hook Methods new class($printer) extends Subscriber implements BeforeFirstTestMethodErroredSubscriber { public function notify(BeforeFirstTestMethodErrored $event): void { $this->printer()->testBeforeFirstTestMethodErrored($event); } }, // Test > Lifecycle ... new class($printer) extends Subscriber implements FinishedSubscriber { public function notify(Finished $event): void { $this->printer()->testFinished($event); } }, new class($printer) extends Subscriber implements PreparationStartedSubscriber { public function notify(PreparationStarted $event): void { $this->printer()->testPreparationStarted($event); } }, // Test > Issues ... new class($printer) extends Subscriber implements ConsideredRiskySubscriber { public function notify(ConsideredRisky $event): void { $this->printer()->testConsideredRisky($event); } }, new class($printer) extends Subscriber implements DeprecationTriggeredSubscriber { public function notify(DeprecationTriggered $event): void { $this->printer()->testDeprecationTriggered($event); } }, new class($printer) extends Subscriber implements TestRunnerDeprecationTriggeredSubscriber { public function notify(TestRunnerDeprecationTriggered $event): void { $this->printer()->testRunnerDeprecationTriggered($event); } }, new class($printer) extends Subscriber implements TestRunnerWarningTriggeredSubscriber { public function notify(TestRunnerWarningTriggered $event): void { $this->printer()->testRunnerWarningTriggered($event); } }, new class($printer) extends Subscriber implements PhpDeprecationTriggeredSubscriber { public function notify(PhpDeprecationTriggered $event): void { $this->printer()->testPhpDeprecationTriggered($event); } }, new class($printer) extends Subscriber implements PhpunitDeprecationTriggeredSubscriber { public function notify(PhpunitDeprecationTriggered $event): void { $this->printer()->testPhpunitDeprecationTriggered($event); } }, new class($printer) extends Subscriber implements PhpNoticeTriggeredSubscriber { public function notify(PhpNoticeTriggered $event): void { $this->printer()->testPhpNoticeTriggered($event); } }, new class($printer) extends Subscriber implements PhpWarningTriggeredSubscriber { public function notify(PhpWarningTriggered $event): void { $this->printer()->testPhpWarningTriggered($event); } }, new class($printer) extends Subscriber implements PhpunitWarningTriggeredSubscriber { public function notify(PhpunitWarningTriggered $event): void { $this->printer()->testPhpunitWarningTriggered($event); } }, new class($printer) extends Subscriber implements PhpunitErrorTriggeredSubscriber { public function notify(PhpunitErrorTriggered $event): void { $this->printer()->testPhpunitErrorTriggered($event); } }, // Test > Outcome ... new class($printer) extends Subscriber implements ErroredSubscriber { public function notify(Errored $event): void { $this->printer()->testErrored($event); } }, new class($printer) extends Subscriber implements FailedSubscriber { public function notify(Failed $event): void { $this->printer()->testFailed($event); } }, new class($printer) extends Subscriber implements MarkedIncompleteSubscriber { public function notify(MarkedIncomplete $event): void { $this->printer()->testMarkedIncomplete($event); } }, new class($printer) extends Subscriber implements NoticeTriggeredSubscriber { public function notify(NoticeTriggered $event): void { $this->printer()->testNoticeTriggered($event); } }, new class($printer) extends Subscriber implements PassedSubscriber { public function notify(Passed $event): void { $this->printer()->testPassed($event); } }, new class($printer) extends Subscriber implements SkippedSubscriber { public function notify(Skipped $event): void { $this->printer()->testSkipped($event); } }, new class($printer) extends Subscriber implements WarningTriggeredSubscriber { public function notify(WarningTriggered $event): void { $this->printer()->testWarningTriggered($event); } }, ]; Facade::instance()->registerSubscribers(...$subscribers); } /** * Registers the subscriber on PHPUnit's facade. */ public static function register(): void { $shouldRegister = self::$registered === false && isset($_SERVER['COLLISION_PRINTER']); if ($shouldRegister) { self::$registered = true; Facade::instance()->registerSubscriber(new self()); } } } } collision/src/Adapters/Phpunit/Subscribers/Subscriber.php000064400000001476150364335730017643 0ustar00 * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace NunoMaduro\Collision\Adapters\Phpunit\Subscribers; use NunoMaduro\Collision\Adapters\Phpunit\Printers\ReportablePrinter; /** * @internal */ abstract class Subscriber { /** * The printer instance. */ private ReportablePrinter $printer; /** * Creates a new subscriber. */ public function __construct(ReportablePrinter $printer) { $this->printer = $printer; } /** * Returns the printer instance. */ protected function printer(): ReportablePrinter { return $this->printer; } } collision/src/Adapters/Phpunit/Printers/DefaultPrinter.php000064400000031144150364335730020003 0ustar00output = new ConsoleOutput(OutputInterface::VERBOSITY_NORMAL, $colors); ConfigureIO::of(new ArgvInput(), $this->output); self::$verbose = $this->output->isVerbose(); $this->style = new Style($this->output); $this->state = new State(); } /** * If the printer instances should be compact. */ public static function compact(bool $value = null): bool { if (! is_null($value)) { self::$compact = $value; } return ! self::$verbose && self::$compact; } /** * If the printer instances should profile. */ public static function profile(bool $value = null): bool { if (! is_null($value)) { self::$profile = $value; } return self::$profile; } /** * Defines if the output should be decorated or not. */ public function setDecorated(bool $decorated): void { $this->output->setDecorated($decorated); } /** * Listen to the runner execution started event. */ public function testPrintedUnexpectedOutput(PrintedUnexpectedOutput $printedUnexpectedOutput): void { $this->output->write($printedUnexpectedOutput->output()); } /** * Listen to the runner execution started event. */ public function testRunnerExecutionStarted(ExecutionStarted $executionStarted): void { // .. } /** * Listen to the test finished event. */ public function testFinished(Finished $event): void { $duration = (hrtime(true) - $this->testStartedAt) / 1_000_000; $test = $event->test(); if (! $test instanceof TestMethod) { throw new ShouldNotHappen(); } if (! $this->state->existsInTestCase($event->test())) { $this->state->add(TestResult::fromTestCase($event->test(), TestResult::PASS)); } $result = $this->state->setDuration($test, $duration); if (self::$profile) { $this->profileSlowTests[$event->test()->id()] = $result; // Sort the slow tests by time, and keep only 10 of them. uasort($this->profileSlowTests, static function (TestResult $a, TestResult $b) { return $b->duration <=> $a->duration; }); $this->profileSlowTests = array_slice($this->profileSlowTests, 0, 10); } } /** * Listen to the test prepared event. */ public function testPreparationStarted(PreparationStarted $event): void { $this->testStartedAt = hrtime(true); $test = $event->test(); if (! $test instanceof TestMethod) { throw new ShouldNotHappen(); } if ($this->state->testCaseHasChanged($test)) { $this->style->writeCurrentTestCaseSummary($this->state); $this->state->moveTo($test); } } /** * Listen to the test errored event. */ public function testBeforeFirstTestMethodErrored(BeforeFirstTestMethodErrored $event): void { $this->state->add(TestResult::fromBeforeFirstTestMethodErrored($event)); } /** * Listen to the test errored event. */ public function testErrored(Errored $event): void { $this->state->add(TestResult::fromTestCase($event->test(), TestResult::FAIL, $event->throwable())); } /** * Listen to the test failed event. */ public function testFailed(Failed $event): void { $throwable = $event->throwable(); $this->state->add(TestResult::fromTestCase($event->test(), TestResult::FAIL, $throwable)); } /** * Listen to the test marked incomplete event. */ public function testMarkedIncomplete(MarkedIncomplete $event): void { $this->state->add(TestResult::fromTestCase($event->test(), TestResult::INCOMPLETE, $event->throwable())); } /** * Listen to the test considered risky event. */ public function testConsideredRisky(ConsideredRisky $event): void { $throwable = ThrowableBuilder::from(new IncompleteTestError($event->message())); $this->state->add(TestResult::fromTestCase($event->test(), TestResult::RISKY, $throwable)); } /** * Listen to the test runner deprecation triggered. */ public function testRunnerDeprecationTriggered(TestRunnerDeprecationTriggered $event): void { $this->style->writeWarning($event->message()); } /** * Listen to the test runner warning triggered. */ public function testRunnerWarningTriggered(TestRunnerWarningTriggered $event): void { if (! str_starts_with($event->message(), 'No tests found in class')) { $this->style->writeWarning($event->message()); } } /** * Listen to the test runner warning triggered. */ public function testPhpDeprecationTriggered(PhpDeprecationTriggered $event): void { $throwable = ThrowableBuilder::from(new TestOutcome($event->message())); $this->state->add(TestResult::fromTestCase($event->test(), TestResult::DEPRECATED, $throwable)); } /** * Listen to the test runner notice triggered. */ public function testPhpNoticeTriggered(PhpNoticeTriggered $event): void { $throwable = ThrowableBuilder::from(new TestOutcome($event->message())); $this->state->add(TestResult::fromTestCase($event->test(), TestResult::NOTICE, $throwable)); } /** * Listen to the test php warning triggered event. */ public function testPhpWarningTriggered(PhpWarningTriggered $event): void { $throwable = ThrowableBuilder::from(new TestOutcome($event->message())); $this->state->add(TestResult::fromTestCase($event->test(), TestResult::WARN, $throwable)); } /** * Listen to the test runner warning triggered. */ public function testPhpunitWarningTriggered(PhpunitWarningTriggered $event): void { $throwable = ThrowableBuilder::from(new TestOutcome($event->message())); $this->state->add(TestResult::fromTestCase($event->test(), TestResult::WARN, $throwable)); } /** * Listen to the test deprecation triggered event. */ public function testDeprecationTriggered(DeprecationTriggered $event): void { $throwable = ThrowableBuilder::from(new TestOutcome($event->message())); $this->state->add(TestResult::fromTestCase($event->test(), TestResult::DEPRECATED, $throwable)); } /** * Listen to the test phpunit deprecation triggered event. */ public function testPhpunitDeprecationTriggered(PhpunitDeprecationTriggered $event): void { $throwable = ThrowableBuilder::from(new TestOutcome($event->message())); $this->state->add(TestResult::fromTestCase($event->test(), TestResult::DEPRECATED, $throwable)); } /** * Listen to the test phpunit error triggered event. */ public function testPhpunitErrorTriggered(PhpunitErrorTriggered $event): void { $throwable = ThrowableBuilder::from(new TestOutcome($event->message())); $this->state->add(TestResult::fromTestCase($event->test(), TestResult::FAIL, $throwable)); } /** * Listen to the test warning triggered event. */ public function testNoticeTriggered(NoticeTriggered $event): void { $throwable = ThrowableBuilder::from(new TestOutcome($event->message())); $this->state->add(TestResult::fromTestCase($event->test(), TestResult::NOTICE, $throwable)); } /** * Listen to the test warning triggered event. */ public function testWarningTriggered(WarningTriggered $event): void { $throwable = ThrowableBuilder::from(new TestOutcome($event->message())); $this->state->add(TestResult::fromTestCase($event->test(), TestResult::WARN, $throwable)); } /** * Listen to the test skipped event. */ public function testSkipped(Skipped $event): void { if ($event->message() === '__TODO__') { $this->state->add(TestResult::fromTestCase($event->test(), TestResult::TODO)); return; } $throwable = ThrowableBuilder::from(new SkippedWithMessageException($event->message())); $this->state->add(TestResult::fromTestCase($event->test(), TestResult::SKIPPED, $throwable)); } /** * Listen to the test finished event. */ public function testPassed(Passed $event): void { if (! $this->state->existsInTestCase($event->test())) { $this->state->add(TestResult::fromTestCase($event->test(), TestResult::PASS)); } } /** * Listen to the runner execution finished event. */ public function testRunnerExecutionFinished(ExecutionFinished $event): void { $result = Facade::result(); if (ResultReflection::numberOfTests(Facade::result()) === 0) { $this->output->writeln([ '', ' INFO No tests found.', '', ]); return; } $this->style->writeCurrentTestCaseSummary($this->state); if (self::$compact) { $this->output->writeln(['']); } if (class_exists(Result::class)) { $failed = Result::failed(Registry::get(), Facade::result()); } else { $failed = ! Facade::result()->wasSuccessful(); } $this->style->writeErrorsSummary($this->state); $this->style->writeRecap($this->state, $event->telemetryInfo(), $result); if (! $failed && count($this->profileSlowTests) > 0) { $this->style->writeSlowTests($this->profileSlowTests, $event->telemetryInfo()); } } /** * Reports the given throwable. */ public function report(Throwable $throwable): void { $this->style->writeError(ThrowableBuilder::from($throwable)); } } collision/src/Adapters/Phpunit/Printers/ReportablePrinter.php000064400000001277150364335730020522 0ustar00printer->$name(...$arguments); } catch (Throwable $throwable) { $this->printer->report($throwable); } exit(1); } } collision/src/Adapters/Phpunit/ConfigureIO.php000064400000001750150364335730015416 0ustar00 * * 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.php000064400000014733150364335730014332 0ustar00 */ public array $suiteTests = []; /** * The current test case class. */ public ?string $testCaseName; /** * The current test case tests. * * @var array */ public array $testCaseTests = []; /** * The current test case tests. * * @var array */ public array $toBePrintedCaseTests = []; /** * Header printed. */ public bool $headerPrinted = false; /** * The state constructor. */ public function __construct() { $this->testCaseName = ''; } /** * Checks if the given test already contains a result. */ public function existsInTestCase(Test $test): bool { return isset($this->testCaseTests[$test->id()]); } /** * Adds the given test to the State. */ public function add(TestResult $test): void { $this->testCaseName = $test->testCaseName; $levels = array_flip([ TestResult::PASS, TestResult::RUNS, TestResult::TODO, TestResult::SKIPPED, TestResult::WARN, TestResult::NOTICE, TestResult::DEPRECATED, TestResult::RISKY, TestResult::INCOMPLETE, TestResult::FAIL, ]); if (isset($this->testCaseTests[$test->id])) { $existing = $this->testCaseTests[$test->id]; if ($levels[$existing->type] >= $levels[$test->type]) { return; } } $this->testCaseTests[$test->id] = $test; $this->toBePrintedCaseTests[$test->id] = $test; $this->suiteTests[$test->id] = $test; } /** * Sets the duration of the given test, and returns the test result. */ public function setDuration(Test $test, float $duration): TestResult { $result = $this->testCaseTests[$test->id()]; $result->setDuration($duration); return $result; } /** * 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 && $test->type !== TestResult::TODO && $test->type !== TestResult::DEPRECATED && $test->type !== TestResult::NOTICE) { return 'WARN'; } } foreach ($this->testCaseTests as $test) { if ($test->type === TestResult::NOTICE) { return 'NOTI'; } } foreach ($this->testCaseTests as $test) { if ($test->type === TestResult::DEPRECATED) { return 'DEPR'; } } if ($this->todosCount() > 0 && (count($this->testCaseTests) === $this->todosCount())) { return 'TODO'; } return 'PASS'; } /** * Gets the number of tests that are todos. */ public function todosCount(): int { return count(array_values(array_filter($this->testCaseTests, function (TestResult $test): bool { return $test->type === TestResult::TODO; }))); } /** * Gets the test case title color. */ public function getTestCaseFontColor(): string { if ($this->getTestCaseTitleColor() === 'blue') { return 'white'; } return $this->getTestCaseTitle() === 'FAIL' ? 'default' : 'black'; } /** * 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 && $test->type !== TestResult::TODO && $test->type !== TestResult::DEPRECATED) { return 'yellow'; } } foreach ($this->testCaseTests as $test) { if ($test->type === TestResult::DEPRECATED) { return 'yellow'; } } foreach ($this->testCaseTests as $test) { if ($test->type === TestResult::TODO) { return 'blue'; } } 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(TestMethod $test): bool { return self::getPrintableTestCaseName($test) !== $this->testCaseName; } /** * Moves the an new test case. */ public function moveTo(TestMethod $test): void { $this->testCaseName = self::getPrintableTestCaseName($test); $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; })); } /** * Returns the printable test case name from the given `TestCase`. */ public static function getPrintableTestCaseName(TestMethod $test): string { $className = explode('::', $test->id())[0]; if (is_subclass_of($className, HasPrintableTestCaseName::class)) { return $className::getPrintableTestCaseName(); } return $className; } } collision/src/Adapters/Laravel/IgnitionSolutionsRepository.php000064400000001667150364335730021013 0ustar00solutionProviderRepository = $solutionProviderRepository; } /** * {@inheritdoc} */ public function getFromThrowable(Throwable $throwable): array { return $this->solutionProviderRepository->getSolutionsForThrowable($throwable); } } collision/src/Adapters/Laravel/Exceptions/RequirementsException.php000064400000000537150364335730021671 0ustar00 * * 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/Commands/TestCommand.php000064400000027557150364335730017200 0ustar00ignoreValidationErrors(); } /** * Execute the console command. * * @return mixed */ public function handle() { $phpunitVersion = Version::id(); if ($phpunitVersion[0].$phpunitVersion[1] !== '10') { throw new RequirementsException('Running Collision 7.x artisan test command requires at least PHPUnit 10.x.'); } $laravelVersion = \Illuminate\Foundation\Application::VERSION; if ($laravelVersion[0].$laravelVersion[1] !== '10') { // @phpstan-ignore-line throw new RequirementsException('Running Collision 7.x artisan test command requires at least Laravel 10.x.'); } if ($this->option('coverage') && ! Coverage::isAvailable()) { $this->output->writeln(sprintf( "\n ERROR Code coverage driver not available.%s", Coverage::usingXdebug() ? " Did you set Xdebug's coverage mode?" : ' Did you install Xdebug or PCOV?' )); $this->newLine(); return 1; } /** @var bool $usesParallel */ $usesParallel = $this->option('parallel'); if ($usesParallel && ! $this->isParallelDependenciesInstalled()) { throw new RequirementsException('Running Collision 7.x artisan test command in parallel requires at least ParaTest (brianium/paratest) 7.x.'); } $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 FAIL Code coverage below expected: %s %%. Minimum: %s %%.", number_format($coverage, 1), number_format((float) $this->option('min'), 1) )); } } 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(); } if ($this->option('ansi')) { $arguments[] = '--colors=always'; } elseif ($this->option('no-ansi')) { // @phpstan-ignore-line $arguments[] = '--colors=never'; } elseif ((new Console)->hasColorSupport()) { $arguments[] = '--colors=always'; } return $arguments; } /** * Determines if Pest is being used. * * @return bool */ protected function usingPest() { return function_exists('\Pest\\version'); } /** * Get the array of arguments for running PHPUnit. * * @param array $options * @return array */ protected function phpunitArguments($options) { $options = array_merge(['--no-output'], $options); $options = array_values(array_filter($options, function ($option) { return ! Str::startsWith($option, '--env=') && $option != '-q' && $option != '--quiet' && $option != '--coverage' && $option != '--compact' && $option != '--profile' && $option != '--ansi' && $option != '--no-ansi' && ! Str::startsWith($option, '--min'); })); return array_merge($this->commonArguments(), ['--configuration='.$this->getConfigurationFile()], $options); } /** * Get the configuration file. * * @return string */ protected function getConfigurationFile() { if (! file_exists($file = base_path('phpunit.xml'))) { $file = base_path('phpunit.xml.dist'); } return $file; } /** * 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' && $option != '--ansi' && $option != '--no-ansi' && ! Str::startsWith($option, '--min') && ! Str::startsWith($option, '-p') && ! Str::startsWith($option, '--parallel') && ! Str::startsWith($option, '--recreate-databases') && ! Str::startsWith($option, '--drop-databases') && ! Str::startsWith($option, '--without-databases'); })); $options = array_merge($this->commonArguments(), [ '--configuration='.$this->getConfigurationFile(), "--runner=\Illuminate\Testing\ParallelRunner", ], $options); $inputDefinition = new InputDefinition(); Options::setInputDefinition($inputDefinition); $input = new ArgvInput($options, $inputDefinition); /** @var non-empty-string $basePath */ $basePath = base_path(); $paraTestOptions = Options::fromConsoleInput( $input, $basePath, ); if (! $paraTestOptions->configuration->hasCoverageCacheDirectory()) { $cacheDirectory = sys_get_temp_dir().DIRECTORY_SEPARATOR.'__laravel_test_cache_directory'; $options[] = '--cache-directory'; $options[] = $cacheDirectory; } return $options; } /** * Get the array of environment variables for running PHPUnit. * * @return array */ protected function phpunitEnvironmentVariables() { $variables = [ 'COLLISION_PRINTER' => 'DefaultPrinter', ]; if ($this->option('compact')) { $variables['COLLISION_PRINTER_COMPACT'] = 'true'; } if ($this->option('profile')) { $variables['COLLISION_PRINTER_PROFILE'] = 'true'; } return $variables; } /** * 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'), 'LARAVEL_PARALLEL_TESTING_WITHOUT_DATABASES' => $this->option('without-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( $this->laravel->environmentPath(), $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\ParaTestCommand::class); } } collision/src/Adapters/Laravel/CollisionServiceProvider.php000064400000004351150364335730020173 0ustar00commands([ TestCommand::class, ]); } /** * {@inheritdoc} */ public function register(): void { if ($this->app->runningInConsole() && ! $this->app->runningUnitTests()) { $this->app->bind(Provider::class, function () { if ($this->app->has(SolutionProviderRepository::class)) { /** @var SolutionProviderRepository $solutionProviderRepository */ $solutionProviderRepository = $this->app->get(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 [Provider::class]; } } collision/src/Adapters/Laravel/ExceptionHandler.php000064400000005505150364335730016442 0ustar00container = $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 Provider $provider */ $provider = $this->container->make(Provider::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); } /** * Register a reportable callback. * * @return \Illuminate\Foundation\Exceptions\ReportableHandler */ public function reportable(callable $reportUsing) { return $this->appExceptionHandler->reportable($reportUsing); } /** * Register a renderable callback. * * @return $this */ public function renderable(callable $renderUsing) { $this->appExceptionHandler->renderable($renderUsing); return $this; } /** * Do not report duplicate exceptions. * * @return $this */ public function dontReportDuplicates() { $this->appExceptionHandler->dontReportDuplicates(); return $this; } } collision/src/ArgumentFormatter.php000064400000002257150364335730013524 0ustar00 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/Contracts/RenderableOnCollisionEditor.php000064400000000366150364335730017400 0ustar00 */ public function getFromThrowable(Throwable $throwable): array; } collision/README.md000064400000005603150364335730010033 0ustar00

Collision logo
Collision code example

Build Status Quality Score Total Downloads License

--- 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.1+](https://php.net/releases/)** Require Collision using [Composer](https://getcomposer.org): ```bash composer require nunomaduro/collision --dev ``` ## Version Compatibility Laravel | Collision | PHPUnit | Pest :---------|:----------|:----------|:---------- 6.x | 3.x | | 7.x | 4.x | | 8.x | 5.x | | 9.x | 6.x | | 10.x | 6.x | 9.x | 1.x 10.x | 7.x | 10.x | 2.x As an example, here is how to require Collision on Laravel 8.x: ```bash composer require nunomaduro/collision:^5.0 --dev ``` ## 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/LICENSE.md000064400000002111150364335730010147 0ustar00The MIT License (MIT) Copyright (c) Nuno Maduro 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. collision/.temp/.gitkeep000064400000000010150364335730011213 0ustar00.gitkeeptermwind/composer.json000064400000003423150364335730011132 0ustar00{ "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/src/Actions/StyleToMethod.php000064400000007513150364335730014060 0ustar00 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/Question.php000064400000004765150364335730011531 0ustar00helper = $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|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/Repositories/Styles.php000064400000002341150364335730013660 0ustar00 */ 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/Exceptions/InvalidColor.php000064400000000263150364335730014415 0ustar00getPrevious()); } /** * 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/InvalidChild.php000064400000000263150364335730014362 0ustar00getQuestion()); $output->write($text); } } termwind/src/Termwind.php000064400000022052150364335730011500 0ustar00|string $content * @param array $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|string $content * @param array $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|string $content * @param array $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|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|string $content * @param array $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 $content * @param array $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 $content * @param array $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|string $content * @param array $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 $content * @param array $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|string $content * @param array $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|string $content * @param array $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 $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 $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|string $elements * @return array */ 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/Html/CodeRenderer.php000064400000020100150364335730013144 0ustar00 */ 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 $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> */ 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 */ 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 $tokens * @param int $startLine * @return array> */ 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> $tokenLines * @return array */ 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 $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/Html/TableRenderer.php000064400000017036150364335730013337 0ustar00output = 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|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('//', "\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/PreRenderer.php000064400000001775150364335730013041 0ustar00getHtml()); 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.php000064400000014270150364335730013424 0ustar00 $elements * @return array */ 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 $elements * @return array */ 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 $elements * @return array */ 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 $elements * @return array */ 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 $elements * @return array */ 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 $elements * @return array */ 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 $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/Terminal.php000064400000001566150364335730011471 0ustar00terminal = $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/ValueObjects/Styles.php000064400000066756150364335730013603 0ustar00|\\e\[\d+m/"; /** @var array */ private array $styles = []; private ?Element $element = null; /** * Creates a Style formatter instance. * * @param array $properties * @param array, array): string> $textModifiers * @param array): 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 */ final public function getProperties(): array { return $this->properties; } /** * Sets the element's style properties. * * @param array $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 $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 $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 $styles * @param array> $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> $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 * `` 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.php000064400000003054150364335730013376 0ustar00callback; $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.php000064400000010157150364335730013165 0ustar00node->nodeValue ?? ''; } /** * Gets child nodes of the node. * * @return Generator */ 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/Enums/Color.php000064400000023030150364335730012051 0ustar00parse($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 = ''.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 $children */ private function toElement(Node $node, array $children): Components\Element|string { if ($node->isText() || $node->isComment()) { return (string) $node; } /** @var array $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/Functions.php000064400000002772150364335730011666 0ustar00render($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|null $autocomplete */ function ask(string $question, iterable $autocomplete = null): mixed { return (new Question)->ask($question, $autocomplete); } } termwind/src/Components/Dt.php000064400000000250150364335730012377 0ustar00content) ? implode('', $this->content) : $this->content; } } termwind/src/Components/Ul.php000064400000000250150364335730012410 0ustar00|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|string $content * @param array $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 $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|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/BreakLine.php000064400000000775150364335730013700 0ustar00styles->getProperties()['styles']['display'] ?? 'inline'; if ($display === 'hidden') { return ''; } if ($display === 'block') { return parent::toString(); } return parent::toString()."\r"; } } termwind/src/Laravel/TermwindServiceProvider.php000064400000000740150364335730016122 0ustar00app->resolving(OutputStyle::class, function ($style): void { Termwind::renderUsing($style->getOutput()); }); } } termwind/LICENSE.md000064400000002111150364335730010005 0ustar00The MIT License (MIT) Copyright (c) Nuno Maduro 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/playground.php000064400000000761150364335730011307 0ustar00
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. DONE
HTML); termwind/Makefile000064400000002201150364335730010041 0ustar00# Well documented Makefiles DEFAULT_GOAL := help help: @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\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 " 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/docker/Dockerfile000064400000000350150364335730011645 0ustar00FROM 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 termwind/docker-compose.yml000064400000000345150364335730012045 0ustar00version: '3' services: app: image: termwind-docker container_name: termwind-docker stdin_open: true tty: true build: context: . dockerfile: docker/Dockerfile volumes: - .:/usr/src/app