From e75be11701ac5b6b6ef56d025cfb9eb27c40b786 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sun, 27 Sep 2026 18:23:10 -0400 Subject: [PATCH 1/6] feat: fluent colour, emoji and layout API on ConsoleUIFormatter Adds txtColor()/bgColor() for 38 named colours, fg()/bg(), message() with emoji shortcodes via Support\Emoji, icon(), padding(), lineHeight(), addSpaceBefore()/addSpaceAfter(), markup(), capabilities(), write() and toAnsi(). A plain message renders exactly as before. Fixes found while building it, each reproduced first: - BRIGHT_* constants threw: the sanitiser stripped the hyphen - colorize() ignored BRIGHT_* (bright-red vs bright_red keys) - Color names such as orange threw at write time; now converted to hex - unknown colours and text styles now fail where they are set - a coloured link dropped its colour - message text is escaped, so in it cannot end the style early --- src/Tools/Formatting/ConsoleUIFormatter.php | 485 ++++++++++++++++-- .../Formatting/ConsoleUIFormatterTest.php | 248 +++++++++ 2 files changed, 703 insertions(+), 30 deletions(-) diff --git a/src/Tools/Formatting/ConsoleUIFormatter.php b/src/Tools/Formatting/ConsoleUIFormatter.php index a9cc41f..53be575 100644 --- a/src/Tools/Formatting/ConsoleUIFormatter.php +++ b/src/Tools/Formatting/ConsoleUIFormatter.php @@ -5,25 +5,129 @@ namespace Simtabi\Laranail\Console\Tools\Formatting; use Stringable; +use BadMethodCallException; +use InvalidArgumentException; +use Simtabi\Laranail\Console\Tools\Support\Color; +use Simtabi\Laranail\Console\Tools\Support\Emoji; use Simtabi\Laranail\Console\Tools\Support\Hyperlink; +use Symfony\Component\Console\Output\OutputInterface; use Simtabi\Laranail\Console\Tools\Support\Capabilities; +use Simtabi\Laranail\Console\Tools\Support\DisplayWidth; use Symfony\Component\Console\Formatter\OutputFormatter; +use Simtabi\Laranail\Console\Tools\Exceptions\InvalidColorException; /** - * Fluent helper class for formatting Symfony Console output with badge support + * Fluent builder for one styled string of Symfony Console markup + * (`message`), with badge and link support. + * + * The builder is `Stringable`: pass it where `string|Stringable` is accepted, cast + * it with `(string)` where a plain `string` is declared (under `strict_types`, + * Symfony's `writeln()` rejects the object), or call `write($output)` directly. * * @example - * // Regular formatting - * $output = ConsoleUIFormatter::create() - * ->addMessage('Processing...') - * ->addTextColor(ConsoleUIFormatter::GREEN) - * ->render(); + * // Fluent + * $text = ConsoleUIFormatter::create() + * ->txtColorRed()->bgColorWhite()->bold() + * ->icon('rocket') // πŸš€ (or its ASCII fallback), via Support\Emoji + * ->message('Deployed :tada:') // shortcodes resolve the same way + * ->padding(2) // two spaces either side, inside the background + * ->lineHeight(3) // one blank background line above and one below + * ->addSpaceBefore(2) // outside the background: indent by two spaces + * ->addSpaceAfter(1, ConsoleUIFormatter::TAB); + * $text->write($output); // or $output->writeln((string) $text) * - * // Badge formatting + * // Badge * $badge = ConsoleUIFormatter::create() * ->addMessage('NEW') * ->isBadge(ConsoleUIFormatter::BADGE_STYLE_SUCCESS) * ->render(); + * + * Emoji: `message()` and `icon()` resolve `:shortcodes:` through {@see Emoji}, which + * uses the full `laranail/emojis` catalogue when that optional package is installed + * and degrades to ASCII on a terminal without Unicode. `addMessage()` keeps the text + * exactly as given. + * + * Colours: Symfony's own names (`red`, `bright-red`, `default`, …) pass through + * unchanged; anything else {@see Color::parse()} accepts (`orange`, `#7c3aed`, + * `rgb(…)`, `hsl(…)`, `@196`) is converted to hex. An unknown colour throws + * {@see InvalidColorException} when it is set, not when the string is written. + * + * @method self txtColorBlack() + * @method self txtColorRed() + * @method self txtColorGreen() + * @method self txtColorYellow() + * @method self txtColorBlue() + * @method self txtColorMagenta() + * @method self txtColorCyan() + * @method self txtColorWhite() + * @method self txtColorDefault() + * @method self txtColorGray() + * @method self txtColorBrightRed() + * @method self txtColorBrightGreen() + * @method self txtColorBrightYellow() + * @method self txtColorBrightBlue() + * @method self txtColorBrightMagenta() + * @method self txtColorBrightCyan() + * @method self txtColorBrightWhite() + * @method self txtColorLime() + * @method self txtColorGrey() + * @method self txtColorSilver() + * @method self txtColorMaroon() + * @method self txtColorOlive() + * @method self txtColorNavy() + * @method self txtColorTeal() + * @method self txtColorPurple() + * @method self txtColorOrange() + * @method self txtColorPink() + * @method self txtColorBrown() + * @method self txtColorGold() + * @method self txtColorSlate() + * @method self txtColorIndigo() + * @method self txtColorViolet() + * @method self txtColorCrimson() + * @method self txtColorCoral() + * @method self txtColorSalmon() + * @method self txtColorTurquoise() + * @method self txtColorAqua() + * @method self txtColorMint() + * @method self bgColorBlack() + * @method self bgColorRed() + * @method self bgColorGreen() + * @method self bgColorYellow() + * @method self bgColorBlue() + * @method self bgColorMagenta() + * @method self bgColorCyan() + * @method self bgColorWhite() + * @method self bgColorDefault() + * @method self bgColorGray() + * @method self bgColorBrightRed() + * @method self bgColorBrightGreen() + * @method self bgColorBrightYellow() + * @method self bgColorBrightBlue() + * @method self bgColorBrightMagenta() + * @method self bgColorBrightCyan() + * @method self bgColorBrightWhite() + * @method self bgColorLime() + * @method self bgColorGrey() + * @method self bgColorSilver() + * @method self bgColorMaroon() + * @method self bgColorOlive() + * @method self bgColorNavy() + * @method self bgColorTeal() + * @method self bgColorPurple() + * @method self bgColorOrange() + * @method self bgColorPink() + * @method self bgColorBrown() + * @method self bgColorGold() + * @method self bgColorSlate() + * @method self bgColorIndigo() + * @method self bgColorViolet() + * @method self bgColorCrimson() + * @method self bgColorCoral() + * @method self bgColorSalmon() + * @method self bgColorTurquoise() + * @method self bgColorAqua() + * @method self bgColorMint() */ class ConsoleUIFormatter implements Stringable { @@ -180,6 +284,31 @@ class ConsoleUIFormatter implements Stringable 'reset' => "\033[0m", ]; + /** + * Colour names Symfony's formatter understands natively. These pass through + * as names, so they keep following the terminal's own palette; every other + * colour is converted to hex through {@see Color::parse()}. + */ + public const array SYMFONY_COLORS = [ + 'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', 'default', + 'gray', 'bright-red', 'bright-green', 'bright-yellow', 'bright-blue', + 'bright-magenta', 'bright-cyan', 'bright-white', + ]; + + /** + * The options Symfony's formatter accepts. Anything else makes it throw at + * write time, far from the call that set it, so they are checked when set. + * `underline` and `hidden` are accepted as aliases, matching the constants. + */ + public const array TEXT_OPTIONS = ['bold', 'underscore', 'blink', 'reverse', 'conceal']; + + /** Whitespace {@see addSpaceBefore()} and {@see addSpaceAfter()} accept. */ + public const string SPACE = ' '; + + public const string TAB = "\t"; + + private const array OPTION_ALIASES = ['underline' => 'underscore', 'hidden' => 'conceal']; + // Badge color schemes private const array BADGE_SCHEMES = [ self::BADGE_STYLE_PRIMARY => [ @@ -243,6 +372,20 @@ class ConsoleUIFormatter implements Stringable private string $badgePadding = ' '; + private int $padding = 0; + + private int $lineHeight = 1; + + private bool $allowMarkup = false; + + private string $spaceBefore = ''; + + private string $spaceAfter = ''; + + private ?Capabilities $capabilities = null; + + private string $icon = ''; + // Terminal capability detection private readonly bool $supportsColor; @@ -263,8 +406,28 @@ public function __toString(): string } /** - * Create a new instance with fresh state + * `txtColor()` and `bgColor()` for every name in the class + * docblock: `txtColorBrightRed()` sets `bright-red`, `bgColorOrange()` sets + * the hex `Color` knows as orange. + * + * @param array $arguments + * + * @throws InvalidColorException for an unknown colour name */ + public function __call(string $method, array $arguments): self + { + foreach (['txtColor' => false, 'bgColor' => true] as $prefix => $background) { + if (str_starts_with($method, $prefix) && strlen($method) > strlen($prefix)) { + // StudlyCase to kebab: BrightRed -> bright-red + $name = strtolower((string) preg_replace('/(?bg($name) : $this->fg($name); + } + } + + throw new BadMethodCallException(sprintf('Call to undefined method %s::%s()', self::class, $method)); + } + public static function create(): self { return new self; @@ -432,6 +595,23 @@ public static function hex(string $message, ?string $hexFg = null, ?string $hexB return $formatter->render(); } + /** + * Normalise a colour to what Symfony's formatter accepts: one of its own + * names, or a `#rrggbb` hex. + * + * @throws InvalidColorException + */ + public static function resolveColor(string $color): string + { + $lower = strtolower(trim($color)); + + if (in_array($lower, self::SYMFONY_COLORS, true)) { + return $lower; + } + + return Color::parseStrict($color); + } + /** * Get all available badge styles */ @@ -459,6 +639,174 @@ public function addMessage(string $message): self return $this; } + /** + * Set the message: text, emoji, icons, or several lines. Formatter tags in + * it are shown literally unless {@see markup()} is on. + */ + public function message(string|Stringable $message): self + { + return $this->addMessage($this->emoji()->render((string) $message)); + } + + /** + * Put an emoji in front of the message, by name (`rocket`, `check`, `tada`), + * with one space between. Call it before or after {@see message()}; an + * unknown name adds nothing. + */ + public function icon(string $name): self + { + $this->icon = self::sanitizeText($this->emoji()->get($name)); + + return $this; + } + + /** + * Use these capabilities for emoji fallback and {@see toAnsi()}, instead of + * detecting the terminal. + */ + public function capabilities(Capabilities $capabilities): self + { + $this->capabilities = $capabilities; + + return $this; + } + + /** + * Whitespace before the message, outside its background. Calls add up, so + * `addSpaceBefore()->addSpaceBefore(1, self::TAB)` is a space then a tab. + * + * @param string $with {@see SPACE} or {@see TAB} + */ + public function addSpaceBefore(int $count = 1, string $with = self::SPACE): self + { + $this->spaceBefore .= $this->whitespace($count, $with); + + return $this; + } + + /** + * Whitespace after the message, outside its background. Calls add up. + * + * @param string $with {@see SPACE} or {@see TAB} + */ + public function addSpaceAfter(int $count = 1, string $with = self::SPACE): self + { + $this->spaceAfter .= $this->whitespace($count, $with); + + return $this; + } + + /** + * Treat formatter tags inside the message as markup instead of text. Off by + * default, so a message carrying `` or `` cannot restyle the output. + */ + public function markup(bool $allow = true): self + { + $this->allowMarkup = $allow; + + return $this; + } + + /** + * Foreground colour: a Symfony name, a {@see Color} name, hex, rgb(), hsl() or @N. + * + * @throws InvalidColorException + */ + public function fg(string $color): self + { + return $this->addTextColor($color); + } + + /** + * Background colour, accepting the same forms as {@see fg()}. + * + * @throws InvalidColorException + */ + public function bg(string $color): self + { + return $this->addBackgroundColor($color); + } + + public function bold(): self + { + return $this->addTextStyles(self::BOLD); + } + + public function underline(): self + { + return $this->addTextStyles(self::UNDERSCORE); + } + + public function blink(): self + { + return $this->addTextStyles(self::BLINK); + } + + public function reverse(): self + { + return $this->addTextStyles(self::REVERSE); + } + + public function conceal(): self + { + return $this->addTextStyles(self::CONCEAL); + } + + public function href(string $url): self + { + return $this->setHref($url); + } + + /** + * Spaces either side of the message, inside its background. + */ + public function padding(int $spaces): self + { + if ($spaces < 0) { + throw new InvalidArgumentException("Padding cannot be negative, got {$spaces}."); + } + + $this->padding = $spaces; + + return $this; + } + + /** + * Total height in lines. The message sits in the middle, and the extra lines + * are blank lines carrying the background, so `lineHeight(3)` puts one line + * above and one below. Terminals have no line spacing; this is the nearest + * thing, and it only shows when a background is set. + */ + public function lineHeight(int $lines): self + { + if ($lines < 1) { + throw new InvalidArgumentException("Line height must be at least 1, got {$lines}."); + } + + $this->lineHeight = $lines; + + return $this; + } + + /** + * Render and write to an output, one call per line. + */ + public function write(OutputInterface $output): void + { + $output->writeln($this->render()); + } + + /** + * The rendered string as raw ANSI, ready to echo. Plain text when the + * terminal has no colour support. + */ + public function toAnsi(?Capabilities $capabilities = null): string + { + $decorated = ($capabilities ?? $this->capabilities ?? Capabilities::detect())->supportsColor(); + + return new OutputFormatter($decorated)->format($this->render()) ?? ''; + } + /** * Add text/foreground color with optional predefined style tag or clickable link * @@ -473,7 +821,7 @@ public function addTextColor( bool $isClickable = false, ?string $href = null, ): self { - $this->foregroundColor = $text; + $this->foregroundColor = self::resolveColor($text); $this->styleTag = $styleTag; $this->isClickable = false; $this->href = null; @@ -494,7 +842,7 @@ public function addTextColor( */ public function addBackgroundColor(string $text): self { - $this->backgroundColor = $text; + $this->backgroundColor = self::resolveColor($text); return $this; } @@ -510,6 +858,20 @@ public function addTextStyles(string|array $styles): self $styles = [$styles]; } + foreach ($styles as $i => $style) { + $style = self::OPTION_ALIASES[$style] ?? $style; + + if (! in_array($style, self::TEXT_OPTIONS, true)) { + throw new InvalidArgumentException(sprintf( + 'Invalid text style "%s". Expected one of: %s.', + $style, + implode(', ', [...self::TEXT_OPTIONS, ...array_keys(self::OPTION_ALIASES)]), + )); + } + + $styles[$i] = $style; + } + $this->textStyles = array_unique(array_merge($this->textStyles, $styles)); return $this; @@ -579,6 +941,12 @@ public function reset(): self $this->isClickable = false; $this->badgeMode = false; $this->badgePadding = ' '; + $this->padding = 0; + $this->lineHeight = 1; + $this->allowMarkup = false; + $this->spaceBefore = ''; + $this->spaceAfter = ''; + $this->icon = ''; return $this; } @@ -592,6 +960,9 @@ public function colorize(string $text, string $color, bool $bold = false, ?strin return $text; } + // The colour constants spell bright colours `bright-red`; ANSI_COLORS keys them `bright_red`. + $color = str_replace('-', '_', $color); + $background = $background !== null ? str_replace('-', '_', $background) : null; $colorCode = self::ANSI_COLORS[$color] ?? ''; $boldCode = $bold ? self::ANSI_COLORS['bold'] : ''; // Background colour tokens (e.g. BG_RED = 'red') map to the '*_bg' ANSI key. @@ -605,7 +976,7 @@ public function colorize(string $text, string $color, bool $bold = false, ?strin } /** - * Render the formatted string + * Render the formatted string: Symfony markup, one tag per line. */ public function render(): string { @@ -613,24 +984,63 @@ public function render(): string return ''; } - // Format message as badge if enabled - $displayMessage = $this->badgeMode - ? $this->badgePadding . mb_strtoupper($this->message) . $this->badgePadding - : $this->message; + $message = $this->badgeMode ? mb_strtoupper($this->message) : $this->message; + $lines = explode("\n", $this->icon === '' ? $message : $this->icon . ' ' . $message); + $width = max(array_map($this->visibleWidth(...), $lines)); + $pad = ($this->badgeMode ? $this->badgePadding : '') . str_repeat(' ', $this->padding); - // Use predefined style tag if set and not in badge mode - if ($this->styleTag && ! $this->badgeMode) { - return sprintf('<%s>%s', $this->styleTag, $displayMessage); + $rendered = []; + + foreach ($lines as $line) { + $fill = str_repeat(' ', $width - $this->visibleWidth($line)); + $text = $this->allowMarkup ? $line : OutputFormatter::escape($line); + $rendered[] = $this->spaceBefore . $this->wrap($pad . $text . $fill . $pad, link: true) . $this->spaceAfter; } - // Use clickable href if set. Escape the URL so it can't inject formatter - // tags (e.g. a URL containing ``); Hyperlink vets the scheme, but - // `<`/`>` must still be neutralised for the Symfony tag syntax. - if ($this->isClickable && $this->href) { - return sprintf('%s', OutputFormatter::escape($this->href), $displayMessage); + // Extra height is blank lines carrying the background, split around the message. + $blank = $this->spaceBefore . $this->wrap(str_repeat(' ', $width + 2 * DisplayWidth::of($pad)), link: false) . $this->spaceAfter; + $above = intdiv($this->lineHeight - 1, 2); + $below = $this->lineHeight - 1 - $above; + + return implode("\n", [ + ...array_fill(0, $above, $blank), + ...$rendered, + ...array_fill(0, $below, $blank), + ]); + } + + /** + * Build the whitespace for addSpaceBefore()/addSpaceAfter(). + */ + private function whitespace(int $count, string $with): string + { + if ($count < 1) { + throw new InvalidArgumentException("Space count must be positive, got {$count}."); + } + + if ($with !== self::SPACE && $with !== self::TAB) { + throw new InvalidArgumentException('Space must be ConsoleUIFormatter::SPACE or ConsoleUIFormatter::TAB.'); + } + + return str_repeat($with, $count); + } + + private function emoji(): Emoji + { + return Emoji::make($this->capabilities); + } + + /** + * Wrap one line in the configured tag. `$link` is false for the blank + * lineHeight rows, which carry the background but are not clickable. + */ + private function wrap(string $line, bool $link): string + { + // A predefined style tag wins over custom colours, except in badge mode. + if ($this->styleTag && ! $this->badgeMode) { + return sprintf('<%s>%s', $this->styleTag, $line); } - // Build custom formatting $tags = []; if ($this->foregroundColor) { @@ -645,13 +1055,28 @@ public function render(): string $tags[] = 'options=' . implode(',', $this->textStyles); } - // No formatting needed - if ($tags === []) { - return $displayMessage; + // Escape the URL so it can't inject formatter tags (e.g. a URL containing + // ``); Hyperlink vets the scheme, but `<`/`>` must still be + // neutralised for the Symfony tag syntax. (Hyperlink already strips `;`, + // which would otherwise end the attribute.) + if ($link && $this->isClickable && $this->href) { + $tags[] = 'href=' . OutputFormatter::escape($this->href); + } + + return $tags === [] ? $line : sprintf('<%s>%s', implode(';', $tags), $line); + } + + /** + * Columns a line occupies once written: emoji and wide glyphs count as two, + * and markup tags (when markup is allowed) count as nothing. + */ + private function visibleWidth(string $line): int + { + if ($this->allowMarkup) { + $line = new OutputFormatter(false)->format($line) ?? ''; } - // Build formatted string - return sprintf('<%s>%s', implode(';', $tags), $displayMessage); + return DisplayWidth::of($line); } /** @@ -660,7 +1085,7 @@ public function render(): string */ private function sanitizeColorToken(string $color): string { - return (string) preg_replace('/[^A-Za-z0-9#]/', '', $color); + return (string) preg_replace('/[^A-Za-z0-9#-]/', '', $color); } /** diff --git a/tests/Tools/Unit/Formatting/ConsoleUIFormatterTest.php b/tests/Tools/Unit/Formatting/ConsoleUIFormatterTest.php index 8feb68b..c26b277 100644 --- a/tests/Tools/Unit/Formatting/ConsoleUIFormatterTest.php +++ b/tests/Tools/Unit/Formatting/ConsoleUIFormatterTest.php @@ -4,8 +4,17 @@ namespace Simtabi\Laranail\Console\Tools\Tests\Unit\Formatting; +use ReflectionClass; +use BadMethodCallException; +use ReflectionClassConstant; +use InvalidArgumentException; use PHPUnit\Framework\TestCase; +use Simtabi\Laranail\Console\Tools\Support\Color; +use Symfony\Component\Console\Output\BufferedOutput; +use Simtabi\Laranail\Console\Tools\Support\Capabilities; +use Symfony\Component\Console\Formatter\OutputFormatter; use Simtabi\Laranail\Console\Tools\Formatting\ConsoleUIFormatter; +use Simtabi\Laranail\Console\Tools\Exceptions\InvalidColorException; final class ConsoleUIFormatterTest extends TestCase { @@ -38,4 +47,243 @@ public function test_colorize_applies_a_background_colour(): void $previous === false ? putenv('FORCE_COLOR') : putenv("FORCE_COLOR={$previous}"); } } + + public function test_the_fluent_chain_builds_a_padded_block(): void + { + $text = ConsoleUIFormatter::create() + ->txtColorRed()->bgColorWhite()->bold() + ->message('βœ“ Deployed πŸš€') + ->padding(2) + ->lineHeight(3); + + // 13 columns of text (the rocket is two wide) plus 2 + 2 padding. + $blank = '' . str_repeat(' ', 17) . ''; + + self::assertSame( + $blank . "\n βœ“ Deployed πŸš€ \n" . $blank, + (string) $text, + ); + } + + public function test_line_height_puts_the_odd_line_below(): void + { + $lines = explode("\n", ConsoleUIFormatter::create()->bg('blue')->message('x')->lineHeight(2)->render()); + + self::assertSame(['x', ' '], $lines); + } + + public function test_multi_line_messages_are_padded_into_a_rectangle(): void + { + $lines = explode("\n", ConsoleUIFormatter::create()->bg('blue')->message("ab\nabcd")->render()); + + self::assertSame(['ab ', 'abcd'], $lines); + } + + public function test_a_plain_message_renders_as_before(): void + { + self::assertSame('hello', ConsoleUIFormatter::create()->message('hello')->fg('green')->render()); + self::assertSame('hello', ConsoleUIFormatter::create()->message('hello')->render()); + } + + public function test_every_declared_colour_method_resolves(): void + { + preg_match_all('/@method self ((?:txt|bg)Color\w+)\(\)/', (string) new ReflectionClass(ConsoleUIFormatter::class)->getDocComment(), $m); + + // A sweep over nothing passes; pin the size of what was inspected. + self::assertCount(76, $m[1]); + + foreach ($m[1] as $method) { + $rendered = ConsoleUIFormatter::create()->message('x')->{$method}()->render(); + + // Must survive a real formatter, which throws on anything it does not know. + self::assertSame('x', new OutputFormatter(false)->format($rendered), $method); + } + } + + public function test_every_known_colour_name_has_a_declared_method(): void + { + $doc = (string) new ReflectionClass(ConsoleUIFormatter::class)->getDocComment(); + $named = array_keys(new ReflectionClassConstant(Color::class, 'NAMES')->getValue()); + $names = array_unique([...ConsoleUIFormatter::SYMFONY_COLORS, ...$named]); + + self::assertGreaterThan(30, count($names)); + + foreach ($names as $name) { + $studly = str_replace(' ', '', ucwords(str_replace('-', ' ', $name))); + + self::assertStringContainsString("@method self txtColor{$studly}()", $doc, $name); + self::assertStringContainsString("@method self bgColor{$studly}()", $doc, $name); + } + } + + public function test_bright_colour_constants_render(): void + { + $constants = array_filter( + new ReflectionClass(ConsoleUIFormatter::class)->getConstants(), + static fn (string $name): bool => str_contains($name, 'BRIGHT_'), + ARRAY_FILTER_USE_KEY, + ); + + self::assertCount(14, $constants); + + foreach ($constants as $name => $color) { + $rendered = ConsoleUIFormatter::create()->message('x')->fg($color)->bg($color)->render(); + + self::assertSame("x", $rendered, $name); + self::assertStringContainsString('x', new OutputFormatter(true)->format($rendered)); + } + } + + public function test_non_symfony_colours_are_converted_to_hex(): void + { + self::assertSame('x', ConsoleUIFormatter::create()->message('x')->txtColorOrange()->render()); + self::assertSame('x', ConsoleUIFormatter::create()->message('x')->bg('#7C3AED')->render()); + } + + public function test_unknown_colours_and_methods_fail_where_they_are_set(): void + { + $this->expectException(InvalidColorException::class); + + ConsoleUIFormatter::create()->txtColorNotAColour(); + } + + public function test_unknown_methods_are_not_swallowed(): void + { + $this->expectException(BadMethodCallException::class); + + ConsoleUIFormatter::create()->frobnicate(); + } + + public function test_unknown_text_styles_fail_where_they_are_set(): void + { + self::assertSame('x', ConsoleUIFormatter::create()->message('x')->addTextStyles('underline')->render()); + + $this->expectException(InvalidArgumentException::class); + + ConsoleUIFormatter::create()->addTextStyles('italic'); + } + + public function test_message_markup_is_shown_literally_unless_allowed(): void + { + $formatter = new OutputFormatter(true); + + $escaped = $formatter->format(ConsoleUIFormatter::create()->fg('green')->message('50% done')->render()); + self::assertStringContainsString('50% done', (string) $escaped); + self::assertStringNotContainsString("\033[31m", (string) $escaped); + + $allowed = $formatter->format(ConsoleUIFormatter::create()->message('a b')->markup()->render()); + self::assertStringContainsString("\033[31mb", (string) $allowed); + } + + public function test_a_coloured_link_keeps_its_colour(): void + { + $rendered = ConsoleUIFormatter::create()->message('docs')->fg('green')->href('https://example.com/a')->render(); + + self::assertSame('docs', $rendered); + } + + public function test_colorize_accepts_the_bright_constants(): void + { + $previous = getenv('FORCE_COLOR'); + putenv('FORCE_COLOR=1'); + + try { + $out = ConsoleUIFormatter::create()->colorize('x', ConsoleUIFormatter::BRIGHT_RED, false, ConsoleUIFormatter::BG_BRIGHT_BLUE); + + self::assertStringContainsString("\033[91m", $out); + self::assertStringContainsString("\033[104m", $out); + } finally { + $previous === false ? putenv('FORCE_COLOR') : putenv("FORCE_COLOR={$previous}"); + } + } + + public function test_it_writes_and_converts_to_ansi(): void + { + $text = ConsoleUIFormatter::create()->fg('red')->message('x'); + + $output = new BufferedOutput(decorated: false); + $text->write($output); + self::assertSame("x\n", $output->fetch()); + + self::assertSame("\033[31mx\033[39m", $text->toAnsi(Capabilities::fake(colors: true))); + self::assertSame('x', $text->toAnsi(Capabilities::fake(colors: false))); + } + + public function test_padding_and_line_height_reject_nonsense(): void + { + $this->expectException(InvalidArgumentException::class); + + ConsoleUIFormatter::create()->lineHeight(0); + } + + public function test_reset_clears_the_layout_too(): void + { + $formatter = ConsoleUIFormatter::create()->message('x')->padding(3)->lineHeight(3)->markup(); + + self::assertSame('y', $formatter->reset()->message('y')->render()); + } + + public function test_the_full_chain_with_spacing_and_emoji(): void + { + $text = ConsoleUIFormatter::create() + ->capabilities(Capabilities::fake(unicode: true)) + ->txtColorRed()->bgColorWhite()->bold() + ->message('βœ“ Deployed πŸš€') + ->padding(2) + ->lineHeight(3) + ->addSpaceBefore() + ->addSpaceAfter(2, ConsoleUIFormatter::TAB); + + $blank = ' ' . str_repeat(' ', 17) . "\t\t"; + + self::assertSame( + $blank . "\n βœ“ Deployed πŸš€ \t\t\n" . $blank, + (string) $text, + ); + } + + public function test_spacing_calls_add_up_and_sit_outside_the_background(): void + { + $rendered = ConsoleUIFormatter::create()->bg('blue')->message('x') + ->addSpaceBefore(2)->addSpaceBefore(1, ConsoleUIFormatter::TAB) + ->addSpaceAfter(3) + ->render(); + + self::assertSame(" \tx ", $rendered); + } + + public function test_spacing_must_be_positive(): void + { + $this->expectException(InvalidArgumentException::class); + + ConsoleUIFormatter::create()->addSpaceBefore(0); + } + + public function test_spacing_accepts_only_space_or_tab(): void + { + $this->expectException(InvalidArgumentException::class); + + ConsoleUIFormatter::create()->addSpaceAfter(1, '-'); + } + + public function test_message_resolves_shortcodes_and_icon_prefixes_an_emoji(): void + { + $unicode = ConsoleUIFormatter::create()->capabilities(Capabilities::fake(unicode: true)); + self::assertSame('πŸš€ Deployed πŸŽ‰', $unicode->icon('rocket')->message('Deployed :tada:')->render()); + + // ASCII fallback, escaped so `->` cannot read as a tag. + $ascii = ConsoleUIFormatter::create()->capabilities(Capabilities::fake(unicode: false)); + self::assertSame('-\\> Deployed \\o/', $ascii->icon('rocket')->message('Deployed :tada:')->render()); + + // addMessage() keeps the text exactly as given. + self::assertSame(':tada:', ConsoleUIFormatter::create()->addMessage(':tada:')->render()); + self::assertSame('x', ConsoleUIFormatter::create()->icon('no-such-emoji-name')->message('x')->render()); + } + + public function test_reset_clears_spacing_and_icon(): void + { + $formatter = ConsoleUIFormatter::create()->icon('rocket')->message('x')->addSpaceBefore()->addSpaceAfter(); + + self::assertSame('y', $formatter->reset()->message('y')->render()); + } } From 46eed12059f56201ff443b3c61ed1bf43372f550 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sun, 27 Sep 2026 18:23:11 -0400 Subject: [PATCH 2/6] docs: document the ConsoleUIFormatter fluent API Rewrites docs/tools/formatting.md around the builder, adds a runnable examples/tools/formatting.php to the composer examples script, and records the additions, fixes and the escaping change under [Unreleased]. --- CHANGELOG.md | 46 ++++++++++++++ composer.json | 1 + docs/architecture.md | 2 +- docs/tools/formatting.md | 112 +++++++++++++++++++++++++++------- examples/tools/formatting.php | 46 ++++++++++++++ 5 files changed, 185 insertions(+), 22 deletions(-) create mode 100644 examples/tools/formatting.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ecec79..ad6d594 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,52 @@ All notable changes to `laranail/console` are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **A fluent API on `ConsoleUIFormatter`.** You can now build a whole style as one chain: + + ```php + ConsoleUIFormatter::create()->txtColorRed()->bgColorWhite()->bold() + ->message('βœ“ Deployed πŸš€')->padding(2)->lineHeight(3) + ->addSpaceBefore()->addSpaceAfter(1, ConsoleUIFormatter::TAB); + ``` + + - **Colours:** `txtColor()` and `bgColor()` for 38 named colours, each declared + as an `@method`. `fg()` and `bg()` take any colour `Support\Color` parses. + - **Text:** `message()` resolves emoji shortcodes through `Support\Emoji`, which uses + `laranail/emojis` when it is installed. `icon()` adds an emoji in front of the message. + `bold()`, `underline()`, `blink()`, `reverse()`, `conceal()` and `href()` set the style. + - **Layout:** `padding()` adds space inside the background, and `lineHeight()` adds blank + background lines above and below. `addSpaceBefore()` and `addSpaceAfter()` add spaces or + tabs outside it; each count must be positive, and repeated calls add up. + - **Output:** `write()` renders straight to an output, and `toAnsi()` returns raw ANSI. + - **Control:** `markup()` lets tags inside the message act as markup, and `capabilities()` + pins emoji and colour support. + + The existing methods are unchanged, and a plain message renders exactly as before. See + `docs/tools/formatting.md` and `examples/tools/formatting.php`. + +### Fixed + +- **The 14 `BRIGHT_*` and `BG_BRIGHT_*` constants threw when written.** The colour sanitiser + stripped the hyphen, so `bright-red` reached Symfony as `brightred`. +- **`colorize()` emitted no colour for the `BRIGHT_*` constants**, because it looked them up + as `bright-red` in a map keyed `bright_red`. +- **Colour names that `Support\Color` accepts** (`orange`, `slate`, `crimson`, …) threw when + written. They are now converted to hex when set. An unknown colour throws + `InvalidColorException` at the call that sets it, not later when the string is written. +- **An unknown text style**, such as `italic`, which Symfony does not support, now throws when + it is set rather than when the string is written. +- **A clickable text colour lost its colour.** The link path returned before adding `fg`/`bg`. + +### Changed + +- **Message text is escaped.** A message containing `` used to end the style early, and + `` inside it took effect. Both now print literally. Call `markup()` to keep the old + behaviour for trusted markup. + ## [0.1.3] - 2026-09-26 ### Added diff --git a/composer.json b/composer.json index bf94641..68e283a 100644 --- a/composer.json +++ b/composer.json @@ -93,6 +93,7 @@ "php examples/tools/barchart.php > /dev/null", "php examples/tools/charts.php > /dev/null", "php examples/tools/emoji.php > /dev/null", + "php examples/tools/formatting.php > /dev/null", "php examples/tools/symbols.php > /dev/null", "php examples/tools/writing.php > /dev/null 2>&1", "php examples/tools/interactive.php > /dev/null", diff --git a/docs/architecture.md b/docs/architecture.md index c012c51..176a651 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,7 +10,7 @@ Console β”œβ”€β”€ Providers\ConsoleServiceProvider # config + lang + registers child providers β”œβ”€β”€ Exceptions\ConsoleException # base, fromKey() with safe fallback β”œβ”€β”€ Tools\ # OUTPUT -β”‚ β”œβ”€β”€ Formatting\ # ConsoleUIFormatter (colour/badge/link primitives) +β”‚ β”œβ”€β”€ Formatting\ # ConsoleUIFormatter (fluent markup builder: colour/emoji/badge/link/layout) β”‚ β”œβ”€β”€ Widgets\ # Spinner, ProgressBar, Box, Tree, Table, TaskProgress, β”‚ β”‚ # Summary, Header, Banner, Panel/PanelBlock, Menu\… β”‚ β”œβ”€β”€ Contracts\ # Renderable, Interactive (panel composition / live) diff --git a/docs/tools/formatting.md b/docs/tools/formatting.md index a43f873..ca24dab 100644 --- a/docs/tools/formatting.md +++ b/docs/tools/formatting.md @@ -16,40 +16,110 @@ the shared [Capabilities](support.md#capabilities). > context statuses and emoji), see the [Console writer](writing.md) > (`Console::writer()`). `ConsoleUIFormatter` returns strings; `ConsoleWriter` writes them. -## Two output modes +## Fluent builder -- **Markup** β€” `success()`, `error()`, `warning()`, `info()`, `format()`, - `badge()`, `badges()`, `link()`, `hex()` return Symfony Console markup - (`…`). Write them **through an output** so colour renders - (echoing prints literal tags). (`render()` is an *instance* method on a - configured formatter, not one of these static helpers.) +Every method returns the builder, so a whole style reads as one chain: - ```php - use Simtabi\Laranail\Console\Tools\Formatting\ConsoleUIFormatter; +```php +use Simtabi\Laranail\Console\Tools\Formatting\ConsoleUIFormatter; + +$text = ConsoleUIFormatter::create() + ->txtColorRed()->bgColorWhite()->bold() + ->message('βœ“ Deployed πŸš€') + ->padding(2) // inside the background + ->lineHeight(3) // blank background lines above and below + ->addSpaceBefore(2) // outside the background + ->addSpaceAfter(1, ConsoleUIFormatter::TAB); + +$text->write($output); // or $output->writeln((string) $text) +``` + +The builder is `Stringable`. Pass it wherever `string|Stringable` is accepted. +Where a plain `string` parameter is declared, cast it with `(string)`: under +`strict_types`, Symfony's `writeln()` rejects the object itself. + +| Method | Does | +|---|---| +| `message(string\|Stringable)` | Sets the text. `:shortcodes:` resolve through [`Emoji`](support.md#emoji). | +| `addMessage(string)` | Sets the text exactly as given, without shortcode resolution. | +| `icon(string $name)` | Puts an emoji, by name, in front of the message with one space. An unknown name adds nothing. | +| `txtColor()` / `bgColor()` | Foreground or background from a named colour, e.g. `txtColorBrightRed()`, `bgColorSlate()`. | +| `fg(string)` / `bg(string)` | Any colour: a Symfony name, a [`Color`](colors.md) name, `#hex`, `rgb()`, `hsl()` or `@N`. | +| `bold()`, `underline()`, `blink()`, `reverse()`, `conceal()` | Text options. `addTextStyles()` takes the same names, plus the `underline` and `hidden` aliases. | +| `padding(int)` | Spaces either side of the message, inside its background. | +| `lineHeight(int)` | Total height in lines. The message sits in the middle; the odd extra line goes below. | +| `addSpaceBefore(int = 1, string = SPACE)` | Whitespace before the message, outside its background. Pass `ConsoleUIFormatter::TAB` for tabs. Calls add up. | +| `addSpaceAfter(int = 1, string = SPACE)` | The same, after the message. | +| `href(string)` | Makes the text a link. The URL goes through the [`Hyperlink`](support.md#hyperlink) scheme allow-list. | +| `markup(bool = true)` | Treats formatter tags inside the message as markup. Off by default. | +| `capabilities(Capabilities)` | Pins the emoji fallback and `toAnsi()` instead of detecting the terminal. | +| `render()` / `(string)` | Symfony markup, one tag per line. | +| `write(OutputInterface)` | Renders and writes. | +| `toAnsi(?Capabilities)` | Raw ANSI, ready to `echo`. Plain text without colour support. | +| `reset()` | Clears everything, layout and spacing included. | + +### Names for `txtColor()` and `bgColor()` + +There are 38 names, each available both as `txtColor()` and as `bgColor()`. + +- **Symfony's own palette (17).** These pass through as names, so they follow the terminal's theme: + `Black`, `Red`, `Green`, `Yellow`, `Blue`, `Magenta`, `Cyan`, `White`, `Default`, `Gray`, + and `BrightRed` through `BrightWhite`. +- **The [`Color`](colors.md) palette (21).** These are sent as hex: + `Lime`, `Grey`, `Silver`, `Maroon`, `Olive`, `Navy`, `Teal`, `Purple`, `Orange`, `Pink`, + `Brown`, `Gold`, `Slate`, `Indigo`, `Violet`, `Crimson`, `Coral`, `Salmon`, `Turquoise`, + `Aqua`, `Mint`. + +Each one is declared as an `@method`, so IDEs complete them. `SYMFONY_COLORS` lists the +names that pass through. + +### Errors surface where they are set +An unknown colour throws `InvalidColorException` when you set it. An unknown text style, +`padding(-1)`, `lineHeight(0)`, or a space count below 1 throws +`InvalidArgumentException`. Symfony's formatter would otherwise throw only when the +string is finally written, far from the call that caused it. + +### Emoji + +`message()` and `icon()` resolve names through [`Emoji`](support.md#emoji). It serves +every Unicode shortcode when the optional `laranail/emojis` package is installed, +and console's built-in map otherwise. On a terminal without Unicode it falls back to +ASCII (`πŸš€` becomes `->`). Widths are measured per grapheme, so a two-column emoji +still lines up with `padding()` and `lineHeight()`. + +### Message text is escaped + +Tags inside the message are shown literally, so a message carrying `` or +`` cannot end or change the style. Call `markup()` when the message is +trusted markup of your own. + +## Static helpers + +- **Markup**: `success()`, `error()`, `warning()`, `info()`, `format()`, + `badge()`, `badges()`, `link()` and `hex()` return Symfony Console markup + (`…`). Write them **through an output** so the colour renders; + echoing them prints the tags literally. + + ```php $output->writeln(ConsoleUIFormatter::success('Done!')); $output->writeln(ConsoleUIFormatter::badge('NEW', ConsoleUIFormatter::BADGE_STYLE_SUCCESS)); $output->writeln(ConsoleUIFormatter::link('Docs', 'https://example.com')); // OSC-8, scheme allow-listed - - $output->writeln( - ConsoleUIFormatter::create() - ->addMessage('Processing') - ->addTextColor(ConsoleUIFormatter::GREEN) - ->addTextStyles(ConsoleUIFormatter::BOLD) - ->render() - ); ``` -- **Raw ANSI (echo-safe)** β€” `colorize()` emits a ready-to-print coloured string: +- **Raw ANSI (echo-safe)**: `colorize()` emits a coloured string ready to print. It + accepts the `BRIGHT_*` constants. ```php echo ConsoleUIFormatter::create()->colorize('OK', ConsoleUIFormatter::GREEN, bold: true); ``` -Colour/style/badge constants (`GREEN`, `RED`, `BOLD`, `BADGE_STYLE_*`, …) and the -`ANSI_COLORS` map are public on the class. `getBadgeStyles()` lists the badge -styles. All input is sanitised of terminal control characters via -`sanitizeText()`. +The colour, style and badge constants (`GREEN`, `BRIGHT_RED`, `BOLD`, `BADGE_STYLE_*`, +`SPACE`, `TAB`, and so on) and the `ANSI_COLORS` map are public on the class. +`getBadgeStyles()` lists the badge styles. All input is stripped of terminal control +characters by `sanitizeText()`. + +A runnable tour is in `examples/tools/formatting.php`. ## Progress, reports, status lines diff --git a/examples/tools/formatting.php b/examples/tools/formatting.php new file mode 100644 index 0000000..551acf0 --- /dev/null +++ b/examples/tools/formatting.php @@ -0,0 +1,46 @@ +writeln( + (string) ConsoleUIFormatter::create() + ->txtColorRed()->bgColorWhite()->bold() + ->message('βœ“ Deployed πŸš€') + ->padding(2) + ->lineHeight(3) + ->addSpaceBefore(2) + ->addSpaceAfter(), +); + +$output->writeln(''); + +// Emoji by name, and :shortcodes: in the message, with an ASCII fallback. +ConsoleUIFormatter::create()->txtColorBrightGreen()->icon('rocket')->message('Shipped :tada:')->write($output); + +// Any colour Support\Color understands: names, hex, rgb(), hsl(), @N. +ConsoleUIFormatter::create()->fg('#7c3aed')->bgColorSlate()->padding(1)->message('hex on slate')->write($output); + +// Tags inside the message are text, unless markup() says otherwise. +ConsoleUIFormatter::create()->txtColorCyan()->message('literal: ')->write($output); +ConsoleUIFormatter::create()->message('markup: red plain')->markup()->write($output); + +// Indent with a tab, badge, and raw ANSI for echo. +ConsoleUIFormatter::create()->addSpaceBefore(1, ConsoleUIFormatter::TAB)->addMessage('new') + ->isBadge(ConsoleUIFormatter::BADGE_STYLE_SUCCESS)->write($output); + +echo ConsoleUIFormatter::create()->txtColorOrange()->message('echoed as ANSI')->toAnsi(), "\n"; From 09a8ecf5a2db6fa13c4ff489e29a834aba16e099 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sun, 27 Sep 2026 18:25:32 -0400 Subject: [PATCH 3/6] test: expect PHP_EOL from writeln() so the Windows job passes --- tests/Tools/Unit/Formatting/ConsoleUIFormatterTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Tools/Unit/Formatting/ConsoleUIFormatterTest.php b/tests/Tools/Unit/Formatting/ConsoleUIFormatterTest.php index c26b277..b022daf 100644 --- a/tests/Tools/Unit/Formatting/ConsoleUIFormatterTest.php +++ b/tests/Tools/Unit/Formatting/ConsoleUIFormatterTest.php @@ -203,7 +203,7 @@ public function test_it_writes_and_converts_to_ansi(): void $output = new BufferedOutput(decorated: false); $text->write($output); - self::assertSame("x\n", $output->fetch()); + self::assertSame("x" . PHP_EOL, $output->fetch()); self::assertSame("\033[31mx\033[39m", $text->toAnsi(Capabilities::fake(colors: true))); self::assertSame('x', $text->toAnsi(Capabilities::fake(colors: false))); From 78d2763dd38438670dac8afc1bf9c43a30972834 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sun, 27 Sep 2026 18:25:49 -0400 Subject: [PATCH 4/6] style: single quotes in the formatter test --- tests/Tools/Unit/Formatting/ConsoleUIFormatterTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Tools/Unit/Formatting/ConsoleUIFormatterTest.php b/tests/Tools/Unit/Formatting/ConsoleUIFormatterTest.php index b022daf..512aa4d 100644 --- a/tests/Tools/Unit/Formatting/ConsoleUIFormatterTest.php +++ b/tests/Tools/Unit/Formatting/ConsoleUIFormatterTest.php @@ -203,7 +203,7 @@ public function test_it_writes_and_converts_to_ansi(): void $output = new BufferedOutput(decorated: false); $text->write($output); - self::assertSame("x" . PHP_EOL, $output->fetch()); + self::assertSame('x' . PHP_EOL, $output->fetch()); self::assertSame("\033[31mx\033[39m", $text->toAnsi(Capabilities::fake(colors: true))); self::assertSame('x', $text->toAnsi(Capabilities::fake(colors: false))); From e67a4d5c7b6bdaa2ecb5aaa9644699c4d1896edf Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sun, 27 Sep 2026 18:47:07 -0400 Subject: [PATCH 5/6] fix: resolve formatter emoji at render time message() and icon() resolved shortcodes when called, so a capabilities() call later in the chain was ignored: asking for ASCII after message() still printed the Unicode emoji. Both now resolve in render(). The formatter tests also cleared neither Capabilities::fake() nor EmojisBridge::fake(), which are process-wide, so they leaked into later tests; tearDown resets both. Adds a test proving names resolve through the laranail/emojis catalogue when it is installed. --- src/Tools/Formatting/ConsoleUIFormatter.php | 21 +++++++++--- .../Formatting/ConsoleUIFormatterTest.php | 34 +++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/Tools/Formatting/ConsoleUIFormatter.php b/src/Tools/Formatting/ConsoleUIFormatter.php index 53be575..ba61c1b 100644 --- a/src/Tools/Formatting/ConsoleUIFormatter.php +++ b/src/Tools/Formatting/ConsoleUIFormatter.php @@ -386,6 +386,8 @@ class ConsoleUIFormatter implements Stringable private string $icon = ''; + private bool $resolveShortcodes = false; + // Terminal capability detection private readonly bool $supportsColor; @@ -635,6 +637,7 @@ public static function getBadgeStyles(): array public function addMessage(string $message): self { $this->message = self::sanitizeText($message); + $this->resolveShortcodes = false; return $this; } @@ -645,7 +648,12 @@ public function addMessage(string $message): self */ public function message(string|Stringable $message): self { - return $this->addMessage($this->emoji()->render((string) $message)); + $this->addMessage((string) $message); + + // Resolved at render time, so a capabilities() call later in the chain still applies. + $this->resolveShortcodes = true; + + return $this; } /** @@ -655,7 +663,8 @@ public function message(string|Stringable $message): self */ public function icon(string $name): self { - $this->icon = self::sanitizeText($this->emoji()->get($name)); + // Stored by name and resolved at render time, like message() shortcodes. + $this->icon = $name; return $this; } @@ -947,6 +956,7 @@ public function reset(): self $this->spaceBefore = ''; $this->spaceAfter = ''; $this->icon = ''; + $this->resolveShortcodes = false; return $this; } @@ -984,8 +994,11 @@ public function render(): string return ''; } - $message = $this->badgeMode ? mb_strtoupper($this->message) : $this->message; - $lines = explode("\n", $this->icon === '' ? $message : $this->icon . ' ' . $message); + $emoji = $this->emoji(); + $message = $this->resolveShortcodes ? self::sanitizeText($emoji->render($this->message)) : $this->message; + $message = $this->badgeMode ? mb_strtoupper($message) : $message; + $icon = $this->icon === '' ? '' : self::sanitizeText($emoji->get($this->icon)); + $lines = explode("\n", $icon === '' ? $message : $icon . ' ' . $message); $width = max(array_map($this->visibleWidth(...), $lines)); $pad = ($this->badgeMode ? $this->badgePadding : '') . str_repeat(' ', $this->padding); diff --git a/tests/Tools/Unit/Formatting/ConsoleUIFormatterTest.php b/tests/Tools/Unit/Formatting/ConsoleUIFormatterTest.php index 512aa4d..7018560 100644 --- a/tests/Tools/Unit/Formatting/ConsoleUIFormatterTest.php +++ b/tests/Tools/Unit/Formatting/ConsoleUIFormatterTest.php @@ -12,12 +12,23 @@ use Simtabi\Laranail\Console\Tools\Support\Color; use Symfony\Component\Console\Output\BufferedOutput; use Simtabi\Laranail\Console\Tools\Support\Capabilities; +use Simtabi\Laranail\Console\Tools\Support\EmojisBridge; use Symfony\Component\Console\Formatter\OutputFormatter; use Simtabi\Laranail\Console\Tools\Formatting\ConsoleUIFormatter; use Simtabi\Laranail\Console\Tools\Exceptions\InvalidColorException; +use Simtabi\Laranail\Console\Tools\Tests\Support\Fixtures\FakeEmojiCatalogue; final class ConsoleUIFormatterTest extends TestCase { + protected function tearDown(): void + { + // Capabilities::fake() and EmojisBridge::fake() are process-wide; never leak them. + Capabilities::clearFake(); + EmojisBridge::reset(); + + parent::tearDown(); + } + public function test_static_message_helpers_return_strings_containing_text(): void { self::assertStringContainsString('done', ConsoleUIFormatter::success('done')); @@ -286,4 +297,27 @@ public function test_reset_clears_spacing_and_icon(): void self::assertSame('y', $formatter->reset()->message('y')->render()); } + + public function test_capabilities_set_after_the_message_still_apply(): void + { + Capabilities::fake(unicode: true); + $formatter = ConsoleUIFormatter::create()->message(':tada:')->icon('rocket'); + + self::assertSame('-\\> \\o/', $formatter->capabilities(Capabilities::fake(unicode: false))->render()); + } + + public function test_names_resolve_through_the_emojis_catalogue_when_installed(): void + { + // unicorn is not in console's own map: only laranail/emojis knows it. + EmojisBridge::fake(new FakeEmojiCatalogue); + + $unicode = ConsoleUIFormatter::create()->capabilities(Capabilities::fake(unicode: true)); + self::assertSame('πŸ¦„ hello πŸ¦„', $unicode->icon('unicorn')->message('hello :unicorn:')->render()); + + // The map still wins for its own names, as it does in Emoji. + self::assertSame('❌ x', ConsoleUIFormatter::create()->capabilities(Capabilities::fake(unicode: true))->icon('cross')->message('x')->render()); + + EmojisBridge::fake(null); + self::assertSame('hello :unicorn:', ConsoleUIFormatter::create()->capabilities(Capabilities::fake(unicode: true))->message('hello :unicorn:')->render()); + } } From 5df6088e8d27b8a5a0499d65adf4a8bf316075a6 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sun, 27 Sep 2026 18:47:07 -0400 Subject: [PATCH 6/6] docs: formatter emoji resolution and the laranail/emojis catalogue --- CHANGELOG.md | 1 + docs/tools/formatting.md | 9 +++++++++ docs/tools/support.md | 3 +++ 3 files changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad6d594..3f3ed7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 as an `@method`. `fg()` and `bg()` take any colour `Support\Color` parses. - **Text:** `message()` resolves emoji shortcodes through `Support\Emoji`, which uses `laranail/emojis` when it is installed. `icon()` adds an emoji in front of the message. + Names resolve at render time, so `capabilities()` applies wherever it sits in the chain. `bold()`, `underline()`, `blink()`, `reverse()`, `conceal()` and `href()` set the style. - **Layout:** `padding()` adds space inside the background, and `lineHeight()` adds blank background lines above and below. `addSpaceBefore()` and `addSpaceAfter()` add spaces or diff --git a/docs/tools/formatting.md b/docs/tools/formatting.md index ca24dab..e7402f4 100644 --- a/docs/tools/formatting.md +++ b/docs/tools/formatting.md @@ -88,6 +88,15 @@ and console's built-in map otherwise. On a terminal without Unicode it falls bac ASCII (`πŸš€` becomes `->`). Widths are measured per grapheme, so a two-column emoji still lines up with `padding()` and `lineHeight()`. +Names resolve when the string is rendered, not when `message()` or `icon()` is called, +so `capabilities()` applies wherever it sits in the chain. With `laranail/emojis` +installed, a name only its catalogue knows (`:unicorn:`) resolves too; console's own +map still wins for the names it defines, as in `Emoji`. + +```bash +composer require laranail/emojis # optional: the full Unicode catalogue +``` + ### Message text is escaped Tags inside the message are shown literally, so a message carrying `` or diff --git a/docs/tools/support.md b/docs/tools/support.md index ed58866..9e84f55 100644 --- a/docs/tools/support.md +++ b/docs/tools/support.md @@ -132,6 +132,9 @@ Console::emoji()->strip('Done :tada:'); // 'Done' (for plain logs) leaves unknown `:shortcodes:` untouched; `with()` adds/overrides (`[unicode, ascii]` or a single string); `has()`/`all()` introspect the set. +`ConsoleUIFormatter::message()` and `icon()` resolve names through this same helper, so the +formatter gets the catalogue and the ASCII fallback too; see [Formatting](formatting.md#emoji). + ## Symbols One Unicode↔ASCII glyph map (status icons + tree connectors), chosen once from