1: <?php declare(strict_types=1);
2:
3: namespace Salient\Console;
4:
5: use Salient\Console\Support\ConsoleLoopbackFormat as LoopbackFormat;
6: use Salient\Console\Support\ConsoleMessageAttributes as MessageAttributes;
7: use Salient\Console\Support\ConsoleMessageFormat as MessageFormat;
8: use Salient\Console\Support\ConsoleMessageFormats as MessageFormats;
9: use Salient\Console\Support\ConsoleTagAttributes as TagAttributes;
10: use Salient\Console\Support\ConsoleTagFormats as TagFormats;
11: use Salient\Contract\Console\ConsoleFormatInterface as Format;
12: use Salient\Contract\Console\ConsoleFormatterInterface as FormatterInterface;
13: use Salient\Contract\Console\ConsoleInterface as Console;
14: use Salient\Contract\Console\ConsoleTag as Tag;
15: use Salient\Core\Concern\ImmutableTrait;
16: use Salient\Utility\Regex;
17: use Salient\Utility\Str;
18: use LogicException;
19: use UnexpectedValueException;
20:
21: /**
22: * Formats messages for a console output target
23: */
24: final class ConsoleFormatter implements FormatterInterface
25: {
26: use ImmutableTrait;
27:
28: public const DEFAULT_LEVEL_PREFIX_MAP = [
29: Console::LEVEL_EMERGENCY => '! ', // U+0021
30: Console::LEVEL_ALERT => '! ', // U+0021
31: Console::LEVEL_CRITICAL => '! ', // U+0021
32: Console::LEVEL_ERROR => '! ', // U+0021
33: Console::LEVEL_WARNING => '^ ', // U+005E
34: Console::LEVEL_NOTICE => '➤ ', // U+27A4
35: Console::LEVEL_INFO => '- ', // U+002D
36: Console::LEVEL_DEBUG => ': ', // U+003A
37: ];
38:
39: public const DEFAULT_TYPE_PREFIX_MAP = [
40: Console::TYPE_PROGRESS => '⠿ ', // U+283F
41: Console::TYPE_GROUP_START => '» ', // U+00BB
42: Console::TYPE_GROUP_END => '« ', // U+00AB
43: Console::TYPE_SUMMARY => '» ', // U+00BB
44: Console::TYPE_SUCCESS => '✔ ', // U+2714
45: Console::TYPE_FAILURE => '✘ ', // U+2718
46: ];
47:
48: /** @link https://github.com/sindresorhus/cli-spinners */
49: private const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
50:
51: /**
52: * @var array<string,int&Tag::*>
53: */
54: private const TAG_MAP = [
55: '___' => Tag::HEADING,
56: '***' => Tag::HEADING,
57: '##' => Tag::HEADING,
58: '__' => Tag::BOLD,
59: '**' => Tag::BOLD,
60: '_' => Tag::ITALIC,
61: '*' => Tag::ITALIC,
62: '<' => Tag::UNDERLINE,
63: '~~' => Tag::LOW_PRIORITY,
64: ];
65:
66: /**
67: * Splits the subject into formattable paragraphs, fenced code blocks and
68: * code spans
69: */
70: private const MARKUP = <<<'REGEX'
71: /
72: (?(DEFINE)
73: (?<endofline> \h*+ \n )
74: (?<endofblock> ^ \k<indent> \k<fence> \h*+ $ )
75: (?<endofspan> \k<backtickstring> (?! ` ) )
76: )
77: # Do not allow gaps between matches
78: \G
79: # Do not allow empty matches
80: (?= . )
81: # Claim indentation early so horizontal whitespace before fenced code
82: # blocks is not mistaken for text
83: (?<indent> ^ \h*+ )?
84: (?:
85: # Whitespace before paragraphs
86: (?<breaks> (?&endofline)+ ) |
87: # Everything except unescaped backticks until the start of the next
88: # paragraph
89: (?<text> (?> (?: [^\\`\n]+ | \\ [-\\!"\#$%&'()*+,.\/:;<=>?@[\]^_`{|}~\n] | \\ | \n (?! (?&endofline) ) )+ (?&endofline)* ) ) |
90: # CommonMark-compliant fenced code blocks
91: (?> (?(indent)
92: (?> (?<fence> ```+ ) (?<infostring> [^\n]* ) \n )
93: # Match empty blocks--with no trailing newline--and blocks with an
94: # empty line by making the subsequent newline conditional on inblock
95: (?<block> (?> (?<inblock> (?: (?! (?&endofblock) ) (?: \k<indent> | (?= (?&endofline) ) ) [^\n]* (?: (?= \n (?&endofblock) ) | \n | \z ) )+ )? ) )
96: # Allow code fences to terminate at the end of the subject
97: (?: (?(inblock) \n ) (?&endofblock) | \z ) | \z
98: ) ) |
99: # CommonMark-compliant code spans
100: (?<backtickstring> (?> `+ ) ) (?<span> (?> (?: [^`]+ | (?! (?&endofspan) ) `+ )* ) ) (?&endofspan) |
101: # Unmatched backticks
102: (?<extra> `+ ) |
103: \z
104: ) /mxs
105: REGEX;
106:
107: /**
108: * Matches inline formatting tags used outside fenced code blocks and code
109: * spans
110: */
111: private const TAG = <<<'REGEX'
112: /
113: (?(DEFINE)
114: (?<esc> \\ [-\\!"\#$%&'()*+,.\/:;<=>?@[\]^_`{|}~] | \\ )
115: )
116: (?<! \\ ) (?: \\\\ )* \K (?|
117: \b (?<tag> _ {1,3}+ ) (?! \s ) (?> (?<text> (?: [^_\\]+ | (?&esc) | (?! (?<! \s ) \k<tag> \b ) _ + )* ) ) (?<! \s ) \k<tag> \b |
118: (?<tag> \* {1,3}+ ) (?! \s ) (?> (?<text> (?: [^*\\]+ | (?&esc) | (?! (?<! \s ) \k<tag> ) \* + )* ) ) (?<! \s ) \k<tag> |
119: (?<tag> < ) (?! \s ) (?> (?<text> (?: [^>\\]+ | (?&esc) | (?! (?<! \s ) > ) > + )* ) ) (?<! \s ) > |
120: (?<tag> ~~ ) (?! \s ) (?> (?<text> (?: [^~\\]+ | (?&esc) | (?! (?<! \s ) ~~ ) ~ + )* ) ) (?<! \s ) ~~ |
121: ^ (?<tag> \#\# ) \h+ (?> (?<text> (?: [^\#\s\\]+ | (?&esc) | \#+ (?! \h* $ ) | \h++ (?! (?: \#+ \h* )? $ ) )* ) ) (?: \h+ \#+ | \h* ) $
122: ) /mx
123: REGEX;
124:
125: /**
126: * Matches a CommonMark-compliant backslash escape, or an escaped line break
127: * with an optional leading space
128: */
129: private const ESCAPE = <<<'REGEX'
130: /
131: (?|
132: \\ ( [-\\ !"\#$%&'()*+,.\/:;<=>?@[\]^_`{|}~] ) |
133: # Lookbehind assertions are unnecessary because the first branch
134: # matches escaped spaces and backslashes
135: \ ? \\ ( \n )
136: ) /x
137: REGEX;
138:
139: private static ConsoleFormatter $DefaultFormatter;
140: private static TagFormats $DefaultTagFormats;
141: private static MessageFormats $DefaultMessageFormats;
142: private static TagFormats $LoopbackTagFormats;
143: private TagFormats $TagFormats;
144: private MessageFormats $MessageFormats;
145: /** @var callable(): (int|null) */
146: private $WidthCallback;
147: /** @var array<Console::LEVEL_*,string> */
148: private array $LevelPrefixMap;
149: /** @var array<Console::TYPE_*,string> */
150: private array $TypePrefixMap;
151: /** @var array{int<0,max>,float} */
152: private array $SpinnerState;
153:
154: /**
155: * @param (callable(): (int|null))|null $widthCallback
156: * @param array<Console::LEVEL_*,string> $levelPrefixMap
157: * @param array<Console::TYPE_*,string> $typePrefixMap
158: */
159: public function __construct(
160: ?TagFormats $tagFormats = null,
161: ?MessageFormats $messageFormats = null,
162: ?callable $widthCallback = null,
163: array $levelPrefixMap = ConsoleFormatter::DEFAULT_LEVEL_PREFIX_MAP,
164: array $typePrefixMap = ConsoleFormatter::DEFAULT_TYPE_PREFIX_MAP
165: ) {
166: $this->TagFormats = $tagFormats ?: $this->getDefaultTagFormats();
167: $this->MessageFormats = $messageFormats ?: $this->getDefaultMessageFormats();
168: $this->WidthCallback = $widthCallback ?: fn(): ?int => null;
169: $this->LevelPrefixMap = $levelPrefixMap;
170: $this->TypePrefixMap = $typePrefixMap;
171: }
172:
173: /**
174: * @inheritDoc
175: */
176: public function withSpinnerState(?array &$state)
177: {
178: if ($state === null) {
179: $state = [0, 0.0];
180: }
181: $clone = clone $this;
182: $clone->SpinnerState = &$state;
183: return $clone;
184: }
185:
186: /**
187: * @inheritDoc
188: */
189: public function withUnescape(bool $value = true)
190: {
191: return $this->with('TagFormats', $this->TagFormats->withUnescape($value));
192: }
193:
194: /**
195: * @inheritDoc
196: */
197: public function withWrapAfterApply(bool $value = true)
198: {
199: return $this->with('TagFormats', $this->TagFormats->withWrapAfterApply($value));
200: }
201:
202: /**
203: * @inheritDoc
204: */
205: public function getTagFormat(int $tag): Format
206: {
207: return $this->TagFormats->getFormat($tag);
208: }
209:
210: /**
211: * @inheritDoc
212: */
213: public function getMessageFormat(
214: int $level,
215: int $type = Console::TYPE_STANDARD
216: ): MessageFormat {
217: return $this->MessageFormats->get($level, $type);
218: }
219:
220: /**
221: * @inheritDoc
222: */
223: public function getUnescape(): bool
224: {
225: return $this->TagFormats->getUnescape();
226: }
227:
228: /**
229: * @inheritDoc
230: */
231: public function getWrapAfterApply(): bool
232: {
233: return $this->TagFormats->getWrapAfterApply();
234: }
235:
236: /**
237: * @inheritDoc
238: */
239: public function getMessagePrefix(
240: int $level,
241: int $type = Console::TYPE_STANDARD
242: ): string {
243: if ($type === Console::TYPE_UNFORMATTED || $type === Console::TYPE_UNDECORATED) {
244: return '';
245: }
246: if ($type === Console::TYPE_PROGRESS && isset($this->SpinnerState)) {
247: $frames = count(self::SPINNER);
248: $prefix = self::SPINNER[$this->SpinnerState[0] % $frames] . ' ';
249: $now = (float) (hrtime(true) / 1000);
250: if ($now - $this->SpinnerState[1] >= 80000) {
251: $this->SpinnerState[0]++;
252: $this->SpinnerState[0] %= $frames;
253: $this->SpinnerState[1] = $now;
254: }
255: }
256: return $prefix
257: ?? $this->TypePrefixMap[$type]
258: ?? $this->LevelPrefixMap[$level]
259: ?? '';
260: }
261:
262: /**
263: * @inheritDoc
264: */
265: public function format(
266: string $string,
267: bool $unwrap = false,
268: $wrapToWidth = null,
269: bool $unformat = false,
270: string $break = "\n"
271: ): string {
272: if ($string === '' || $string === "\r") {
273: return $string;
274: }
275:
276: // [ [ Offset, length, replacement ] ]
277: /** @var array<array{int,int,string}> */
278: $replace = [];
279: $append = '';
280: $unescape = $this->getUnescape();
281: $wrapAfterApply = $this->getWrapAfterApply();
282: $textFormats = $wrapAfterApply
283: ? $this->TagFormats
284: : $this->getDefaultTagFormats();
285: $formattedFormats = $unformat
286: ? $this->getLoopbackTagFormats()
287: : $this->TagFormats;
288:
289: // Preserve trailing carriage returns
290: if ($string[-1] === "\r") {
291: $append .= "\r";
292: $string = substr($string, 0, -1);
293: }
294:
295: // Normalise line endings and split the string into formattable text,
296: // fenced code blocks and code spans
297: if (!Regex::matchAll(
298: self::MARKUP,
299: Str::setEol($string),
300: $matches,
301: \PREG_SET_ORDER | \PREG_UNMATCHED_AS_NULL
302: )) {
303: throw new UnexpectedValueException(
304: sprintf('Unable to parse: %s', $string)
305: );
306: }
307:
308: $string = '';
309: foreach ($matches as $match) {
310: $indent = (string) $match['indent'];
311:
312: if ($match['breaks'] !== null) {
313: $breaks = $match['breaks'];
314: if ($unwrap && strpos($breaks, "\n") !== false) {
315: /** @var string */
316: $breaks = substr(Str::unwrap(".$breaks.", "\n", false, true, true), 1, -1);
317: }
318: $string .= $indent . $breaks;
319: continue;
320: }
321:
322: // Treat unmatched backticks as plain text
323: if ($match['extra'] !== null) {
324: $string .= $indent . $match['extra'];
325: continue;
326: }
327:
328: $baseOffset = strlen($string . $indent);
329:
330: if ($match['text'] !== null) {
331: $text = $match['text'];
332: if ($unwrap && strpos($text, "\n") !== false) {
333: /** @var string */
334: $text = substr(Str::unwrap(".$text.", "\n", false, true, true), 1, -1);
335: }
336:
337: $adjust = 0;
338: $text = Regex::replaceCallback(
339: self::TAG,
340: function ($matches) use (
341: &$replace,
342: $textFormats,
343: $formattedFormats,
344: $baseOffset,
345: &$adjust
346: ): string {
347: $text = $this->applyTags(
348: $matches,
349: true,
350: $textFormats->getUnescape(),
351: $textFormats
352: );
353: $placeholder = Regex::replace('/[^ ]/u', 'x', $text);
354: $formatted = $textFormats === $formattedFormats
355: ? $text
356: : $this->applyTags(
357: $matches,
358: true,
359: $formattedFormats->getUnescape(),
360: $formattedFormats
361: );
362: $replace[] = [
363: $baseOffset + $matches[0][1] + $adjust,
364: strlen($placeholder),
365: $formatted,
366: ];
367: $adjust += strlen($placeholder) - strlen($matches[0][0]);
368: return $placeholder;
369: },
370: $text,
371: -1,
372: $count,
373: \PREG_OFFSET_CAPTURE
374: );
375:
376: $string .= $indent . $text;
377: continue;
378: }
379:
380: if ($match['block'] !== null) {
381: // Reinstate unwrapped newlines before blocks
382: if ($unwrap && $string !== '' && $string[-1] !== "\n") {
383: $string[-1] = "\n";
384: }
385:
386: /** @var array{fence:string,infostring:string,block:string} $match */
387: $formatted = $formattedFormats->apply(
388: $match['block'],
389: new TagAttributes(
390: Tag::CODE_BLOCK,
391: $match['fence'],
392: 0,
393: false,
394: $indent,
395: Str::coalesce(trim($match['infostring']), null),
396: )
397: );
398: $placeholder = '?';
399: $replace[] = [
400: $baseOffset,
401: 1,
402: $formatted,
403: ];
404:
405: $string .= $indent . $placeholder;
406: continue;
407: }
408:
409: if ($match['span'] !== null) {
410: /** @var array{backtickstring:string,span:string} $match */
411: $span = $match['span'];
412: // As per CommonMark:
413: // - Convert line endings to spaces
414: // - If the string begins and ends with a space but doesn't
415: // consist entirely of spaces, remove both
416: $span = Regex::replace(
417: '/^ ((?> *[^ ]+).*) $/u',
418: '$1',
419: strtr($span, "\n", ' '),
420: );
421: $attributes = new TagAttributes(
422: Tag::CODE_SPAN,
423: $match['backtickstring'],
424: );
425: $text = $textFormats->apply($span, $attributes);
426: $placeholder = Regex::replace('/[^ ]/u', 'x', $text);
427: $formatted = $textFormats === $formattedFormats
428: ? $text
429: : $formattedFormats->apply($span, $attributes);
430: $replace[] = [
431: $baseOffset,
432: strlen($placeholder),
433: $formatted,
434: ];
435:
436: $string .= $indent . $placeholder;
437: continue;
438: }
439: }
440:
441: // Remove backslash escapes and adjust the offsets of any subsequent
442: // replacement strings
443: $replacements = count($replace);
444: $adjustable = [];
445: foreach ($replace as $i => [$offset]) {
446: $adjustable[$i] = $offset;
447: }
448: $adjust = 0;
449: $string = Regex::replaceCallback(
450: self::ESCAPE,
451: function ($matches) use (
452: $unformat,
453: $unescape,
454: $wrapAfterApply,
455: &$replace,
456: &$adjustable,
457: &$adjust
458: ): string {
459: // If the escape character is being wrapped, do nothing other
460: // than temporarily replace "\ " with "\x"
461: if ($wrapAfterApply && !$unescape) {
462: if ($matches[1][0] !== ' ') {
463: return $matches[0][0];
464: }
465: $placeholder = '\x';
466: $replace[] = [
467: $matches[0][1] + $adjust,
468: strlen($placeholder),
469: $matches[0][0],
470: ];
471: return $placeholder;
472: }
473:
474: $delta = strlen($matches[1][0]) - strlen($matches[0][0]);
475: foreach ($adjustable as $i => $offset) {
476: if ($offset < $matches[0][1]) {
477: continue;
478: }
479: $replace[$i][0] += $delta;
480: }
481:
482: $placeholder = null;
483: if ($matches[1][0] === ' ') {
484: $placeholder = 'x';
485: }
486:
487: if ($unformat || !$unescape || $placeholder !== null) {
488: // Use `$replace` to reinstate the escape after wrapping
489: $replace[] = [
490: $matches[0][1] + $adjust,
491: strlen($matches[1][0]),
492: $unformat || !$unescape ? $matches[0][0] : $matches[1][0],
493: ];
494: }
495:
496: $adjust += $delta;
497:
498: return $placeholder ?? $matches[1][0];
499: },
500: $string,
501: -1,
502: $count,
503: \PREG_OFFSET_CAPTURE
504: );
505:
506: if (is_array($wrapToWidth)) {
507: for ($i = 0; $i < 2; $i++) {
508: if ($wrapToWidth[$i] <= 0) {
509: $width ??= ($this->WidthCallback)();
510: if ($width === null) {
511: $wrapToWidth = null;
512: break;
513: }
514: $wrapToWidth[$i] = max(0, $wrapToWidth[$i] + $width);
515: }
516: }
517: } elseif (
518: is_int($wrapToWidth)
519: && $wrapToWidth <= 0
520: ) {
521: $width = ($this->WidthCallback)();
522: $wrapToWidth =
523: $width === null
524: ? null
525: : max(0, $wrapToWidth + $width);
526: }
527: if ($wrapToWidth !== null) {
528: if ($break === "\n") {
529: $string = Str::wrap($string, $wrapToWidth);
530: } else {
531: // Only replace new line breaks with `$break`
532: $wrapped = Str::wrap($string, $wrapToWidth);
533: $length = strlen($wrapped);
534: for ($i = 0; $i < $length; $i++) {
535: if ($wrapped[$i] === "\n" && $string[$i] !== "\n") {
536: $replace[] = [$i, 1, $break];
537: }
538: }
539: }
540: }
541:
542: // Get `$replace` in reverse offset order, sorting from scratch if any
543: // substitutions were made in the callbacks above
544: if (count($replace) !== $replacements) {
545: usort($replace, fn(array $a, array $b): int => $b[0] <=> $a[0]);
546: } else {
547: $replace = array_reverse($replace);
548: }
549:
550: foreach ($replace as [$offset, $length, $replacement]) {
551: $string = substr_replace($string, $replacement, $offset, $length);
552: }
553:
554: return $string . $append;
555: }
556:
557: /**
558: * @inheritDoc
559: */
560: public function formatMessage(
561: string $msg1,
562: ?string $msg2 = null,
563: int $level = Console::LEVEL_INFO,
564: int $type = Console::TYPE_STANDARD
565: ): string {
566: $attributes = new MessageAttributes($level, $type);
567:
568: if ($type === Console::TYPE_UNFORMATTED) {
569: return $this
570: ->getDefaultMessageFormats()
571: ->get($level, $type)
572: ->apply($msg1, $msg2, '', $attributes);
573: }
574:
575: $prefix = $this->getMessagePrefix($level, $type);
576:
577: return $this
578: ->MessageFormats
579: ->get($level, $type)
580: ->apply($msg1, $msg2, $prefix, $attributes);
581: }
582:
583: /**
584: * @inheritDoc
585: */
586: public function formatDiff(string $diff): string
587: {
588: $formats = [
589: '---' => $this->TagFormats->getFormat(Tag::DIFF_HEADER),
590: '+++' => $this->TagFormats->getFormat(Tag::DIFF_HEADER),
591: '@' => $this->TagFormats->getFormat(Tag::DIFF_RANGE),
592: '+' => $this->TagFormats->getFormat(Tag::DIFF_ADDITION),
593: '-' => $this->TagFormats->getFormat(Tag::DIFF_REMOVAL),
594: ];
595:
596: return Regex::replaceCallback(
597: '/^(-{3}|\+{3}|[-+@]).*/m',
598: fn(array $matches) => $formats[$matches[1]]->apply($matches[0]),
599: $diff,
600: );
601: }
602:
603: /**
604: * Escape special characters, optionally including newlines, in a string
605: */
606: public static function escapeTags(string $string, bool $newlines = false): string
607: {
608: // Only escape recognised tag delimiters to minimise the risk of
609: // PREG_JIT_STACKLIMIT_ERROR
610: $escaped = addcslashes($string, '\#*<>_`~');
611: return $newlines
612: ? str_replace("\n", "\\\n", $escaped)
613: : $escaped;
614: }
615:
616: /**
617: * Unescape special characters in a string
618: */
619: public static function unescapeTags(string $string): string
620: {
621: return Regex::replace(
622: self::ESCAPE,
623: '$1',
624: $string,
625: );
626: }
627:
628: /**
629: * Remove inline formatting tags from a string
630: */
631: public static function removeTags(string $string): string
632: {
633: return self::getDefaultFormatter()->format($string);
634: }
635:
636: private static function getDefaultFormatter(): self
637: {
638: return self::$DefaultFormatter ??= new self();
639: }
640:
641: private static function getDefaultTagFormats(): TagFormats
642: {
643: return self::$DefaultTagFormats ??= new TagFormats();
644: }
645:
646: private static function getDefaultMessageFormats(): MessageFormats
647: {
648: return self::$DefaultMessageFormats ??= new MessageFormats();
649: }
650:
651: private static function getLoopbackTagFormats(): TagFormats
652: {
653: return self::$LoopbackTagFormats ??= LoopbackFormat::getTagFormats();
654: }
655:
656: /**
657: * @param array<int|string,array{string,int}|string> $matches
658: */
659: private function applyTags(
660: array $matches,
661: bool $matchesHasOffset,
662: bool $unescape,
663: TagFormats $formats,
664: int $depth = 0
665: ): string {
666: /** @var string */
667: $text = $matchesHasOffset ? $matches['text'][0] : $matches['text'];
668: /** @var string */
669: $tag = $matchesHasOffset ? $matches['tag'][0] : $matches['tag'];
670:
671: $text = Regex::replaceCallback(
672: self::TAG,
673: fn(array $matches): string =>
674: $this->applyTags($matches, false, $unescape, $formats, $depth + 1),
675: $text,
676: -1,
677: $count,
678: );
679:
680: if ($unescape) {
681: $text = Regex::replace(
682: self::ESCAPE,
683: '$1',
684: $text,
685: );
686: }
687:
688: $tagId = self::TAG_MAP[$tag] ?? null;
689: if ($tagId === null) {
690: throw new LogicException(sprintf('Invalid tag: %s', $tag));
691: }
692:
693: return $formats->apply(
694: $text,
695: new TagAttributes($tagId, $tag, $depth, (bool) $count)
696: );
697: }
698: }
699: