GitHub Access Token became invalid

It seems like the GitHub access token used for retrieving details about this repository from GitHub became invalid. This might prevent certain types of inspections from being run (in particular, everything related to pull requests).
Please ask an admin of your repository to re-new the access token on this website.
Passed
Push — master ( cc39b3...2b121f )
by Anton
04:25 queued 01:05
created

Table::resolveStyle()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 2
eloc 3
c 2
b 0
f 0
nc 2
nop 1
dl 0
loc 7
rs 10
1
<?php
2
3
/*
4
 * This file is part of the Symfony package.
5
 *
6
 * (c) Fabien Potencier <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
namespace Symfony\Component\Console\Helper;
13
14
use Symfony\Component\Console\Exception\InvalidArgumentException;
15
use Symfony\Component\Console\Exception\RuntimeException;
16
use Symfony\Component\Console\Formatter\OutputFormatter;
17
use Symfony\Component\Console\Formatter\WrappableOutputFormatterInterface;
18
use Symfony\Component\Console\Output\ConsoleSectionOutput;
19
use Symfony\Component\Console\Output\OutputInterface;
20
21
/**
22
 * Provides helpers to display a table.
23
 *
24
 * @author Fabien Potencier <[email protected]>
25
 * @author Саша Стаменковић <[email protected]>
26
 * @author Abdellatif Ait boudad <[email protected]>
27
 * @author Max Grigorian <[email protected]>
28
 * @author Dany Maillard <[email protected]>
29
 */
30
class Table
31
{
32
    private const SEPARATOR_TOP = 0;
33
    private const SEPARATOR_TOP_BOTTOM = 1;
34
    private const SEPARATOR_MID = 2;
35
    private const SEPARATOR_BOTTOM = 3;
36
    private const BORDER_OUTSIDE = 0;
37
    private const BORDER_INSIDE = 1;
38
    private const DISPLAY_ORIENTATION_DEFAULT = 'default';
39
    private const DISPLAY_ORIENTATION_HORIZONTAL = 'horizontal';
40
    private const DISPLAY_ORIENTATION_VERTICAL = 'vertical';
41
42
    private ?string $headerTitle = null;
43
    private ?string $footerTitle = null;
44
    private array $headers = [];
45
    private array $rows = [];
46
    private array $effectiveColumnWidths = [];
47
    private int $numberOfColumns;
48
    private TableStyle $style;
49
    private array $columnStyles = [];
50
    private array $columnWidths = [];
51
    private array $columnMaxWidths = [];
52
    private bool $rendered = false;
53
    private string $displayOrientation = self::DISPLAY_ORIENTATION_DEFAULT;
54
55
    private static array $styles;
56
57
    public function __construct(
58
        private OutputInterface $output,
59
    ) {
60
        self::$styles ??= self::initStyles();
61
62
        $this->setStyle('default');
63
    }
64
65
    /**
66
     * Sets a style definition.
67
     */
68
    public static function setStyleDefinition(string $name, TableStyle $style): void
69
    {
70
        self::$styles ??= self::initStyles();
71
72
        self::$styles[$name] = $style;
73
    }
74
75
    /**
76
     * Gets a style definition by name.
77
     */
78
    public static function getStyleDefinition(string $name): TableStyle
79
    {
80
        self::$styles ??= self::initStyles();
81
82
        return self::$styles[$name] ?? throw new InvalidArgumentException(sprintf('Style "%s" is not defined.', $name));
83
    }
84
85
    /**
86
     * Sets table style.
87
     *
88
     * @return $this
89
     */
90
    public function setStyle(TableStyle|string $name): static
91
    {
92
        $this->style = $this->resolveStyle($name);
93
94
        return $this;
95
    }
96
97
    /**
98
     * Gets the current table style.
99
     */
100
    public function getStyle(): TableStyle
101
    {
102
        return $this->style;
103
    }
104
105
    /**
106
     * Sets table column style.
107
     *
108
     * @param TableStyle|string $name The style name or a TableStyle instance
109
     *
110
     * @return $this
111
     */
112
    public function setColumnStyle(int $columnIndex, TableStyle|string $name): static
113
    {
114
        $this->columnStyles[$columnIndex] = $this->resolveStyle($name);
115
116
        return $this;
117
    }
118
119
    /**
120
     * Gets the current style for a column.
121
     *
122
     * If style was not set, it returns the global table style.
123
     */
124
    public function getColumnStyle(int $columnIndex): TableStyle
125
    {
126
        return $this->columnStyles[$columnIndex] ?? $this->getStyle();
127
    }
128
129
    /**
130
     * Sets the minimum width of a column.
131
     *
132
     * @return $this
133
     */
134
    public function setColumnWidth(int $columnIndex, int $width): static
135
    {
136
        $this->columnWidths[$columnIndex] = $width;
137
138
        return $this;
139
    }
140
141
    /**
142
     * Sets the minimum width of all columns.
143
     *
144
     * @return $this
145
     */
146
    public function setColumnWidths(array $widths): static
147
    {
148
        $this->columnWidths = [];
149
        foreach ($widths as $index => $width) {
150
            $this->setColumnWidth($index, $width);
151
        }
152
153
        return $this;
154
    }
155
156
    /**
157
     * Sets the maximum width of a column.
158
     *
159
     * Any cell within this column which contents exceeds the specified width will be wrapped into multiple lines, while
160
     * formatted strings are preserved.
161
     *
162
     * @return $this
163
     */
164
    public function setColumnMaxWidth(int $columnIndex, int $width): static
165
    {
166
        if (!$this->output->getFormatter() instanceof WrappableOutputFormatterInterface) {
167
            throw new \LogicException(sprintf('Setting a maximum column width is only supported when using a "%s" formatter, got "%s".', WrappableOutputFormatterInterface::class, get_debug_type($this->output->getFormatter())));
168
        }
169
170
        $this->columnMaxWidths[$columnIndex] = $width;
171
172
        return $this;
173
    }
174
175
    /**
176
     * @return $this
177
     */
178
    public function setHeaders(array $headers): static
179
    {
180
        $headers = array_values($headers);
181
        if ($headers && !\is_array($headers[0])) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $headers of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
182
            $headers = [$headers];
183
        }
184
185
        $this->headers = $headers;
186
187
        return $this;
188
    }
189
190
    /**
191
     * @return $this
192
     */
193
    public function setRows(array $rows): static
194
    {
195
        $this->rows = [];
196
197
        return $this->addRows($rows);
198
    }
199
200
    /**
201
     * @return $this
202
     */
203
    public function addRows(array $rows): static
204
    {
205
        foreach ($rows as $row) {
206
            $this->addRow($row);
207
        }
208
209
        return $this;
210
    }
211
212
    /**
213
     * @return $this
214
     */
215
    public function addRow(TableSeparator|array $row): static
216
    {
217
        if ($row instanceof TableSeparator) {
0 ignored issues
show
introduced by
$row is never a sub-type of Symfony\Component\Console\Helper\TableSeparator.
Loading history...
218
            $this->rows[] = $row;
219
220
            return $this;
221
        }
222
223
        $this->rows[] = array_values($row);
224
225
        return $this;
226
    }
227
228
    /**
229
     * Adds a row to the table, and re-renders the table.
230
     *
231
     * @return $this
232
     */
233
    public function appendRow(TableSeparator|array $row): static
234
    {
235
        if (!$this->output instanceof ConsoleSectionOutput) {
236
            throw new RuntimeException(sprintf('Output should be an instance of "%s" when calling "%s".', ConsoleSectionOutput::class, __METHOD__));
237
        }
238
239
        if ($this->rendered) {
240
            $this->output->clear($this->calculateRowCount());
0 ignored issues
show
Bug introduced by
The method clear() does not exist on Symfony\Component\Console\Output\OutputInterface. It seems like you code against a sub-type of Symfony\Component\Console\Output\OutputInterface such as Symfony\Component\Consol...ut\ConsoleSectionOutput. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

240
            $this->output->/** @scrutinizer ignore-call */ 
241
                           clear($this->calculateRowCount());
Loading history...
241
        }
242
243
        $this->addRow($row);
244
        $this->render();
245
246
        return $this;
247
    }
248
249
    /**
250
     * @return $this
251
     */
252
    public function setRow(int|string $column, array $row): static
253
    {
254
        $this->rows[$column] = $row;
255
256
        return $this;
257
    }
258
259
    /**
260
     * @return $this
261
     */
262
    public function setHeaderTitle(?string $title): static
263
    {
264
        $this->headerTitle = $title;
265
266
        return $this;
267
    }
268
269
    /**
270
     * @return $this
271
     */
272
    public function setFooterTitle(?string $title): static
273
    {
274
        $this->footerTitle = $title;
275
276
        return $this;
277
    }
278
279
    /**
280
     * @return $this
281
     */
282
    public function setHorizontal(bool $horizontal = true): static
283
    {
284
        $this->displayOrientation = $horizontal ? self::DISPLAY_ORIENTATION_HORIZONTAL : self::DISPLAY_ORIENTATION_DEFAULT;
285
286
        return $this;
287
    }
288
289
    /**
290
     * @return $this
291
     */
292
    public function setVertical(bool $vertical = true): static
293
    {
294
        $this->displayOrientation = $vertical ? self::DISPLAY_ORIENTATION_VERTICAL : self::DISPLAY_ORIENTATION_DEFAULT;
295
296
        return $this;
297
    }
298
299
    /**
300
     * Renders table to output.
301
     *
302
     * Example:
303
     *
304
     *     +---------------+-----------------------+------------------+
305
     *     | ISBN          | Title                 | Author           |
306
     *     +---------------+-----------------------+------------------+
307
     *     | 99921-58-10-7 | Divine Comedy         | Dante Alighieri  |
308
     *     | 9971-5-0210-0 | A Tale of Two Cities  | Charles Dickens  |
309
     *     | 960-425-059-0 | The Lord of the Rings | J. R. R. Tolkien |
310
     *     +---------------+-----------------------+------------------+
311
     */
312
    public function render(): void
313
    {
314
        $divider = new TableSeparator();
315
        $isCellWithColspan = static fn ($cell) => $cell instanceof TableCell && $cell->getColspan() >= 2;
316
317
        $horizontal = self::DISPLAY_ORIENTATION_HORIZONTAL === $this->displayOrientation;
318
        $vertical = self::DISPLAY_ORIENTATION_VERTICAL === $this->displayOrientation;
319
320
        $rows = [];
321
        if ($horizontal) {
322
            foreach ($this->headers[0] ?? [] as $i => $header) {
323
                $rows[$i] = [$header];
324
                foreach ($this->rows as $row) {
325
                    if ($row instanceof TableSeparator) {
326
                        continue;
327
                    }
328
                    if (isset($row[$i])) {
329
                        $rows[$i][] = $row[$i];
330
                    } elseif ($isCellWithColspan($rows[$i][0])) {
331
                        // Noop, there is a "title"
332
                    } else {
333
                        $rows[$i][] = null;
334
                    }
335
                }
336
            }
337
        } elseif ($vertical) {
338
            $formatter = $this->output->getFormatter();
339
            $maxHeaderLength = array_reduce($this->headers[0] ?? [], static fn ($max, $header) => max($max, Helper::width(Helper::removeDecoration($formatter, $header))), 0);
340
341
            foreach ($this->rows as $row) {
342
                if ($row instanceof TableSeparator) {
343
                    continue;
344
                }
345
346
                if ($rows) {
347
                    $rows[] = [$divider];
348
                }
349
350
                $containsColspan = false;
351
                foreach ($row as $cell) {
352
                    if ($containsColspan = $isCellWithColspan($cell)) {
353
                        break;
354
                    }
355
                }
356
357
                $headers = $this->headers[0] ?? [];
358
                $maxRows = max(\count($headers), \count($row));
359
                for ($i = 0; $i < $maxRows; ++$i) {
360
                    $cell = (string) ($row[$i] ?? '');
361
362
                    $eol = str_contains($cell, "\r\n") ? "\r\n" : "\n";
363
                    $parts = explode($eol, $cell);
364
                    foreach ($parts as $idx => $part) {
365
                        if ($headers && !$containsColspan) {
366
                            if (0 === $idx) {
367
                                $rows[] = [sprintf(
368
                                    '<comment>%s%s</>: %s',
369
                                    str_repeat(' ', $maxHeaderLength - Helper::width(Helper::removeDecoration($formatter, $headers[$i] ?? ''))),
370
                                    $headers[$i] ?? '',
371
                                    $part
372
                                )];
373
                            } else {
374
                                $rows[] = [sprintf(
375
                                    '%s  %s',
376
                                    str_pad('', $maxHeaderLength, ' ', \STR_PAD_LEFT),
377
                                    $part
378
                                )];
379
                            }
380
                        } elseif ('' !== $cell) {
381
                            $rows[] = [$part];
382
                        }
383
                    }
384
                }
385
            }
386
        } else {
387
            $rows = array_merge($this->headers, [$divider], $this->rows);
388
        }
389
390
        $this->calculateNumberOfColumns($rows);
391
392
        $rowGroups = $this->buildTableRows($rows);
393
        $this->calculateColumnsWidth($rowGroups);
394
395
        $isHeader = !$horizontal;
396
        $isFirstRow = $horizontal;
397
        $hasTitle = (bool) $this->headerTitle;
398
399
        foreach ($rowGroups as $rowGroup) {
400
            $isHeaderSeparatorRendered = false;
401
402
            foreach ($rowGroup as $row) {
403
                if ($divider === $row) {
404
                    $isHeader = false;
405
                    $isFirstRow = true;
406
407
                    continue;
408
                }
409
410
                if ($row instanceof TableSeparator) {
411
                    $this->renderRowSeparator();
412
413
                    continue;
414
                }
415
416
                if (!$row) {
417
                    continue;
418
                }
419
420
                if ($isHeader && !$isHeaderSeparatorRendered) {
421
                    $this->renderRowSeparator(
422
                        self::SEPARATOR_TOP,
423
                        $hasTitle ? $this->headerTitle : null,
424
                        $hasTitle ? $this->style->getHeaderTitleFormat() : null
425
                    );
426
                    $hasTitle = false;
427
                    $isHeaderSeparatorRendered = true;
428
                }
429
430
                if ($isFirstRow) {
431
                    $this->renderRowSeparator(
432
                        $horizontal ? self::SEPARATOR_TOP : self::SEPARATOR_TOP_BOTTOM,
433
                        $hasTitle ? $this->headerTitle : null,
434
                        $hasTitle ? $this->style->getHeaderTitleFormat() : null
435
                    );
436
                    $isFirstRow = false;
437
                    $hasTitle = false;
438
                }
439
440
                if ($vertical) {
441
                    $isHeader = false;
442
                    $isFirstRow = false;
443
                }
444
445
                if ($horizontal) {
446
                    $this->renderRow($row, $this->style->getCellRowFormat(), $this->style->getCellHeaderFormat());
447
                } else {
448
                    $this->renderRow($row, $isHeader ? $this->style->getCellHeaderFormat() : $this->style->getCellRowFormat());
449
                }
450
            }
451
        }
452
        $this->renderRowSeparator(self::SEPARATOR_BOTTOM, $this->footerTitle, $this->style->getFooterTitleFormat());
453
454
        $this->cleanup();
455
        $this->rendered = true;
456
    }
457
458
    /**
459
     * Renders horizontal header separator.
460
     *
461
     * Example:
462
     *
463
     *     +-----+-----------+-------+
464
     */
465
    private function renderRowSeparator(int $type = self::SEPARATOR_MID, ?string $title = null, ?string $titleFormat = null): void
466
    {
467
        if (!$count = $this->numberOfColumns) {
468
            return;
469
        }
470
471
        $borders = $this->style->getBorderChars();
472
        if (!$borders[0] && !$borders[2] && !$this->style->getCrossingChar()) {
473
            return;
474
        }
475
476
        $crossings = $this->style->getCrossingChars();
477
        if (self::SEPARATOR_MID === $type) {
478
            [$horizontal, $leftChar, $midChar, $rightChar] = [$borders[2], $crossings[8], $crossings[0], $crossings[4]];
479
        } elseif (self::SEPARATOR_TOP === $type) {
480
            [$horizontal, $leftChar, $midChar, $rightChar] = [$borders[0], $crossings[1], $crossings[2], $crossings[3]];
481
        } elseif (self::SEPARATOR_TOP_BOTTOM === $type) {
482
            [$horizontal, $leftChar, $midChar, $rightChar] = [$borders[0], $crossings[9], $crossings[10], $crossings[11]];
483
        } else {
484
            [$horizontal, $leftChar, $midChar, $rightChar] = [$borders[0], $crossings[7], $crossings[6], $crossings[5]];
485
        }
486
487
        $markup = $leftChar;
488
        for ($column = 0; $column < $count; ++$column) {
489
            $markup .= str_repeat($horizontal, $this->effectiveColumnWidths[$column]);
490
            $markup .= $column === $count - 1 ? $rightChar : $midChar;
491
        }
492
493
        if (null !== $title) {
494
            $titleLength = Helper::width(Helper::removeDecoration($formatter = $this->output->getFormatter(), $formattedTitle = sprintf($titleFormat, $title)));
0 ignored issues
show
Bug introduced by
It seems like $titleFormat can also be of type null; however, parameter $format of sprintf() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

494
            $titleLength = Helper::width(Helper::removeDecoration($formatter = $this->output->getFormatter(), $formattedTitle = sprintf(/** @scrutinizer ignore-type */ $titleFormat, $title)));
Loading history...
495
            $markupLength = Helper::width($markup);
496
            if ($titleLength > $limit = $markupLength - 4) {
497
                $titleLength = $limit;
498
                $formatLength = Helper::width(Helper::removeDecoration($formatter, sprintf($titleFormat, '')));
499
                $formattedTitle = sprintf($titleFormat, Helper::substr($title, 0, $limit - $formatLength - 3).'...');
500
            }
501
502
            $titleStart = intdiv($markupLength - $titleLength, 2);
503
            if (false === mb_detect_encoding($markup, null, true)) {
504
                $markup = substr_replace($markup, $formattedTitle, $titleStart, $titleLength);
505
            } else {
506
                $markup = mb_substr($markup, 0, $titleStart).$formattedTitle.mb_substr($markup, $titleStart + $titleLength);
507
            }
508
        }
509
510
        $this->output->writeln(sprintf($this->style->getBorderFormat(), $markup));
0 ignored issues
show
Bug introduced by
It seems like $markup can also be of type array; however, parameter $values of sprintf() does only seem to accept double|integer|string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

510
        $this->output->writeln(sprintf($this->style->getBorderFormat(), /** @scrutinizer ignore-type */ $markup));
Loading history...
511
    }
512
513
    /**
514
     * Renders vertical column separator.
515
     */
516
    private function renderColumnSeparator(int $type = self::BORDER_OUTSIDE): string
517
    {
518
        $borders = $this->style->getBorderChars();
519
520
        return sprintf($this->style->getBorderFormat(), self::BORDER_OUTSIDE === $type ? $borders[1] : $borders[3]);
521
    }
522
523
    /**
524
     * Renders table row.
525
     *
526
     * Example:
527
     *
528
     *     | 9971-5-0210-0 | A Tale of Two Cities  | Charles Dickens  |
529
     */
530
    private function renderRow(array $row, string $cellFormat, ?string $firstCellFormat = null): void
531
    {
532
        $rowContent = $this->renderColumnSeparator(self::BORDER_OUTSIDE);
533
        $columns = $this->getRowColumns($row);
534
        $last = \count($columns) - 1;
535
        foreach ($columns as $i => $column) {
536
            if ($firstCellFormat && 0 === $i) {
537
                $rowContent .= $this->renderCell($row, $column, $firstCellFormat);
538
            } else {
539
                $rowContent .= $this->renderCell($row, $column, $cellFormat);
540
            }
541
            $rowContent .= $this->renderColumnSeparator($last === $i ? self::BORDER_OUTSIDE : self::BORDER_INSIDE);
542
        }
543
        $this->output->writeln($rowContent);
544
    }
545
546
    /**
547
     * Renders table cell with padding.
548
     */
549
    private function renderCell(array $row, int $column, string $cellFormat): string
550
    {
551
        $cell = $row[$column] ?? '';
552
        $width = $this->effectiveColumnWidths[$column];
553
        if ($cell instanceof TableCell && $cell->getColspan() > 1) {
554
            // add the width of the following columns(numbers of colspan).
555
            foreach (range($column + 1, $column + $cell->getColspan() - 1) as $nextColumn) {
556
                $width += $this->getColumnSeparatorWidth() + $this->effectiveColumnWidths[$nextColumn];
557
            }
558
        }
559
560
        // str_pad won't work properly with multi-byte strings, we need to fix the padding
561
        if (false !== $encoding = mb_detect_encoding($cell, null, true)) {
562
            $width += \strlen($cell) - mb_strwidth($cell, $encoding);
563
        }
564
565
        $style = $this->getColumnStyle($column);
566
567
        if ($cell instanceof TableSeparator) {
568
            return sprintf($style->getBorderFormat(), str_repeat($style->getBorderChars()[2], $width));
569
        }
570
571
        $width += Helper::length($cell) - Helper::length(Helper::removeDecoration($this->output->getFormatter(), $cell));
572
        $content = sprintf($style->getCellRowContentFormat(), $cell);
573
574
        $padType = $style->getPadType();
575
        if ($cell instanceof TableCell && $cell->getStyle() instanceof TableCellStyle) {
576
            $isNotStyledByTag = !preg_match('/^<(\w+|(\w+=[\w,]+;?)*)>.+<\/(\w+|(\w+=\w+;?)*)?>$/', $cell);
577
            if ($isNotStyledByTag) {
578
                $cellFormat = $cell->getStyle()->getCellFormat();
579
                if (!\is_string($cellFormat)) {
580
                    $tag = http_build_query($cell->getStyle()->getTagOptions(), '', ';');
581
                    $cellFormat = '<'.$tag.'>%s</>';
582
                }
583
584
                if (str_contains($content, '</>')) {
585
                    $content = str_replace('</>', '', $content);
586
                    $width -= 3;
587
                }
588
                if (str_contains($content, '<fg=default;bg=default>')) {
589
                    $content = str_replace('<fg=default;bg=default>', '', $content);
590
                    $width -= \strlen('<fg=default;bg=default>');
591
                }
592
            }
593
594
            $padType = $cell->getStyle()->getPadByAlign();
595
        }
596
597
        return sprintf($cellFormat, str_pad($content, $width, $style->getPaddingChar(), $padType));
598
    }
599
600
    /**
601
     * Calculate number of columns for this table.
602
     */
603
    private function calculateNumberOfColumns(array $rows): void
604
    {
605
        $columns = [0];
606
        foreach ($rows as $row) {
607
            if ($row instanceof TableSeparator) {
608
                continue;
609
            }
610
611
            $columns[] = $this->getNumberOfColumns($row);
612
        }
613
614
        $this->numberOfColumns = max($columns);
615
    }
616
617
    private function buildTableRows(array $rows): TableRows
618
    {
619
        /** @var WrappableOutputFormatterInterface $formatter */
620
        $formatter = $this->output->getFormatter();
621
        $unmergedRows = [];
622
        for ($rowKey = 0; $rowKey < \count($rows); ++$rowKey) {
0 ignored issues
show
Performance Best Practice introduced by
It seems like you are calling the size function count() as part of the test condition. You might want to compute the size beforehand, and not on each iteration.

If the size of the collection does not change during the iteration, it is generally a good practice to compute it beforehand, and not on each iteration:

for ($i=0; $i<count($array); $i++) { // calls count() on each iteration
}

// Better
for ($i=0, $c=count($array); $i<$c; $i++) { // calls count() just once
}
Loading history...
623
            $rows = $this->fillNextRows($rows, $rowKey);
624
625
            // Remove any new line breaks and replace it with a new line
626
            foreach ($rows[$rowKey] as $column => $cell) {
627
                $colspan = $cell instanceof TableCell ? $cell->getColspan() : 1;
628
629
                if (isset($this->columnMaxWidths[$column]) && Helper::width(Helper::removeDecoration($formatter, $cell)) > $this->columnMaxWidths[$column]) {
630
                    $cell = $formatter->formatAndWrap($cell, $this->columnMaxWidths[$column] * $colspan);
631
                }
632
                if (!str_contains($cell ?? '', "\n")) {
633
                    continue;
634
                }
635
                $eol = str_contains($cell ?? '', "\r\n") ? "\r\n" : "\n";
636
                $escaped = implode($eol, array_map(OutputFormatter::escapeTrailingBackslash(...), explode($eol, $cell)));
637
                $cell = $cell instanceof TableCell ? new TableCell($escaped, ['colspan' => $cell->getColspan()]) : $escaped;
638
                $lines = explode($eol, str_replace($eol, '<fg=default;bg=default></>'.$eol, $cell));
639
                foreach ($lines as $lineKey => $line) {
640
                    if ($colspan > 1) {
641
                        $line = new TableCell($line, ['colspan' => $colspan]);
642
                    }
643
                    if (0 === $lineKey) {
644
                        $rows[$rowKey][$column] = $line;
645
                    } else {
646
                        if (!\array_key_exists($rowKey, $unmergedRows) || !\array_key_exists($lineKey, $unmergedRows[$rowKey])) {
647
                            $unmergedRows[$rowKey][$lineKey] = $this->copyRow($rows, $rowKey);
648
                        }
649
                        $unmergedRows[$rowKey][$lineKey][$column] = $line;
650
                    }
651
                }
652
            }
653
        }
654
655
        return new TableRows(function () use ($rows, $unmergedRows): \Traversable {
656
            foreach ($rows as $rowKey => $row) {
657
                $rowGroup = [$row instanceof TableSeparator ? $row : $this->fillCells($row)];
658
659
                if (isset($unmergedRows[$rowKey])) {
660
                    foreach ($unmergedRows[$rowKey] as $row) {
0 ignored issues
show
Comprehensibility Bug introduced by
$row is overwriting a variable from outer foreach loop.
Loading history...
661
                        $rowGroup[] = $row instanceof TableSeparator ? $row : $this->fillCells($row);
662
                    }
663
                }
664
                yield $rowGroup;
665
            }
666
        });
667
    }
668
669
    private function calculateRowCount(): int
670
    {
671
        $numberOfRows = \count(iterator_to_array($this->buildTableRows(array_merge($this->headers, [new TableSeparator()], $this->rows))));
672
673
        if ($this->headers) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->headers of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
674
            ++$numberOfRows; // Add row for header separator
675
        }
676
677
        if ($this->rows) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->rows of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
678
            ++$numberOfRows; // Add row for footer separator
679
        }
680
681
        return $numberOfRows;
682
    }
683
684
    /**
685
     * fill rows that contains rowspan > 1.
686
     *
687
     * @throws InvalidArgumentException
688
     */
689
    private function fillNextRows(array $rows, int $line): array
690
    {
691
        $unmergedRows = [];
692
        foreach ($rows[$line] as $column => $cell) {
693
            if (null !== $cell && !$cell instanceof TableCell && !\is_scalar($cell) && !$cell instanceof \Stringable) {
694
                throw new InvalidArgumentException(sprintf('A cell must be a TableCell, a scalar or an object implementing "__toString()", "%s" given.', get_debug_type($cell)));
695
            }
696
            if ($cell instanceof TableCell && $cell->getRowspan() > 1) {
697
                $nbLines = $cell->getRowspan() - 1;
698
                $lines = [$cell];
699
                if (str_contains($cell, "\n")) {
700
                    $eol = str_contains($cell, "\r\n") ? "\r\n" : "\n";
701
                    $lines = explode($eol, str_replace($eol, '<fg=default;bg=default>'.$eol.'</>', $cell));
702
                    $nbLines = \count($lines) > $nbLines ? substr_count($cell, $eol) : $nbLines;
703
704
                    $rows[$line][$column] = new TableCell($lines[0], ['colspan' => $cell->getColspan(), 'style' => $cell->getStyle()]);
705
                    unset($lines[0]);
706
                }
707
708
                // create a two dimensional array (rowspan x colspan)
709
                $unmergedRows = array_replace_recursive(array_fill($line + 1, $nbLines, []), $unmergedRows);
710
                foreach ($unmergedRows as $unmergedRowKey => $unmergedRow) {
711
                    $value = $lines[$unmergedRowKey - $line] ?? '';
712
                    $unmergedRows[$unmergedRowKey][$column] = new TableCell($value, ['colspan' => $cell->getColspan(), 'style' => $cell->getStyle()]);
713
                    if ($nbLines === $unmergedRowKey - $line) {
714
                        break;
715
                    }
716
                }
717
            }
718
        }
719
720
        foreach ($unmergedRows as $unmergedRowKey => $unmergedRow) {
721
            // we need to know if $unmergedRow will be merged or inserted into $rows
722
            if (isset($rows[$unmergedRowKey]) && \is_array($rows[$unmergedRowKey]) && ($this->getNumberOfColumns($rows[$unmergedRowKey]) + $this->getNumberOfColumns($unmergedRow) <= $this->numberOfColumns)) {
723
                foreach ($unmergedRow as $cellKey => $cell) {
724
                    // insert cell into row at cellKey position
725
                    array_splice($rows[$unmergedRowKey], $cellKey, 0, [$cell]);
726
                }
727
            } else {
728
                $row = $this->copyRow($rows, $unmergedRowKey - 1);
729
                foreach ($unmergedRow as $column => $cell) {
730
                    if ($cell) {
731
                        $row[$column] = $cell;
732
                    }
733
                }
734
                array_splice($rows, $unmergedRowKey, 0, [$row]);
735
            }
736
        }
737
738
        return $rows;
739
    }
740
741
    /**
742
     * fill cells for a row that contains colspan > 1.
743
     */
744
    private function fillCells(iterable $row): iterable
745
    {
746
        $newRow = [];
747
748
        foreach ($row as $column => $cell) {
749
            $newRow[] = $cell;
750
            if ($cell instanceof TableCell && $cell->getColspan() > 1) {
751
                foreach (range($column + 1, $column + $cell->getColspan() - 1) as $position) {
752
                    // insert empty value at column position
753
                    $newRow[] = '';
754
                }
755
            }
756
        }
757
758
        return $newRow ?: $row;
759
    }
760
761
    private function copyRow(array $rows, int $line): array
762
    {
763
        $row = $rows[$line];
764
        foreach ($row as $cellKey => $cellValue) {
765
            $row[$cellKey] = '';
766
            if ($cellValue instanceof TableCell) {
767
                $row[$cellKey] = new TableCell('', ['colspan' => $cellValue->getColspan()]);
768
            }
769
        }
770
771
        return $row;
772
    }
773
774
    /**
775
     * Gets number of columns by row.
776
     */
777
    private function getNumberOfColumns(array $row): int
778
    {
779
        $columns = \count($row);
780
        foreach ($row as $column) {
781
            $columns += $column instanceof TableCell ? ($column->getColspan() - 1) : 0;
782
        }
783
784
        return $columns;
785
    }
786
787
    /**
788
     * Gets list of columns for the given row.
789
     */
790
    private function getRowColumns(array $row): array
791
    {
792
        $columns = range(0, $this->numberOfColumns - 1);
793
        foreach ($row as $cellKey => $cell) {
794
            if ($cell instanceof TableCell && $cell->getColspan() > 1) {
795
                // exclude grouped columns.
796
                $columns = array_diff($columns, range($cellKey + 1, $cellKey + $cell->getColspan() - 1));
797
            }
798
        }
799
800
        return $columns;
801
    }
802
803
    /**
804
     * Calculates columns widths.
805
     */
806
    private function calculateColumnsWidth(iterable $groups): void
807
    {
808
        for ($column = 0; $column < $this->numberOfColumns; ++$column) {
809
            $lengths = [];
810
            foreach ($groups as $group) {
811
                foreach ($group as $row) {
812
                    if ($row instanceof TableSeparator) {
813
                        continue;
814
                    }
815
816
                    foreach ($row as $i => $cell) {
817
                        if ($cell instanceof TableCell) {
818
                            $textContent = Helper::removeDecoration($this->output->getFormatter(), $cell);
819
                            $textLength = Helper::width($textContent);
820
                            if ($textLength > 0) {
821
                                $contentColumns = mb_str_split($textContent, ceil($textLength / $cell->getColspan()));
0 ignored issues
show
Bug introduced by
ceil($textLength / $cell->getColspan()) of type double is incompatible with the type integer expected by parameter $length of mb_str_split(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

821
                                $contentColumns = mb_str_split($textContent, /** @scrutinizer ignore-type */ ceil($textLength / $cell->getColspan()));
Loading history...
822
                                foreach ($contentColumns as $position => $content) {
823
                                    $row[$i + $position] = $content;
824
                                }
825
                            }
826
                        }
827
                    }
828
829
                    $lengths[] = $this->getCellWidth($row, $column);
830
                }
831
            }
832
833
            $this->effectiveColumnWidths[$column] = max($lengths) + Helper::width($this->style->getCellRowContentFormat()) - 2;
834
        }
835
    }
836
837
    private function getColumnSeparatorWidth(): int
838
    {
839
        return Helper::width(sprintf($this->style->getBorderFormat(), $this->style->getBorderChars()[3]));
840
    }
841
842
    private function getCellWidth(array $row, int $column): int
843
    {
844
        $cellWidth = 0;
845
846
        if (isset($row[$column])) {
847
            $cell = $row[$column];
848
            $cellWidth = Helper::width(Helper::removeDecoration($this->output->getFormatter(), $cell));
849
        }
850
851
        $columnWidth = $this->columnWidths[$column] ?? 0;
852
        $cellWidth = max($cellWidth, $columnWidth);
853
854
        return isset($this->columnMaxWidths[$column]) ? min($this->columnMaxWidths[$column], $cellWidth) : $cellWidth;
855
    }
856
857
    /**
858
     * Called after rendering to cleanup cache data.
859
     */
860
    private function cleanup(): void
861
    {
862
        $this->effectiveColumnWidths = [];
863
        unset($this->numberOfColumns);
864
    }
865
866
    /**
867
     * @return array<string, TableStyle>
868
     */
869
    private static function initStyles(): array
870
    {
871
        $borderless = new TableStyle();
872
        $borderless
873
            ->setHorizontalBorderChars('=')
874
            ->setVerticalBorderChars(' ')
875
            ->setDefaultCrossingChar(' ')
876
        ;
877
878
        $compact = new TableStyle();
879
        $compact
880
            ->setHorizontalBorderChars('')
881
            ->setVerticalBorderChars('')
882
            ->setDefaultCrossingChar('')
883
            ->setCellRowContentFormat('%s ')
884
        ;
885
886
        $styleGuide = new TableStyle();
887
        $styleGuide
888
            ->setHorizontalBorderChars('-')
889
            ->setVerticalBorderChars(' ')
890
            ->setDefaultCrossingChar(' ')
891
            ->setCellHeaderFormat('%s')
892
        ;
893
894
        $box = (new TableStyle())
895
            ->setHorizontalBorderChars('─')
896
            ->setVerticalBorderChars('│')
897
            ->setCrossingChars('┼', '┌', '┬', '┐', '┤', '┘', '┴', '└', '├')
898
        ;
899
900
        $boxDouble = (new TableStyle())
901
            ->setHorizontalBorderChars('═', '─')
902
            ->setVerticalBorderChars('║', '│')
903
            ->setCrossingChars('┼', '╔', '╤', '╗', '╢', '╝', '╧', '╚', '╟', '╠', '╪', '╣')
904
        ;
905
906
        return [
907
            'default' => new TableStyle(),
908
            'borderless' => $borderless,
909
            'compact' => $compact,
910
            'symfony-style-guide' => $styleGuide,
911
            'box' => $box,
912
            'box-double' => $boxDouble,
913
        ];
914
    }
915
916
    private function resolveStyle(TableStyle|string $name): TableStyle
917
    {
918
        if ($name instanceof TableStyle) {
919
            return $name;
920
        }
921
922
        return self::$styles[$name] ?? throw new InvalidArgumentException(sprintf('Style "%s" is not defined.', $name));
923
    }
924
}
925