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