CellWrapper::addCells()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 6
ccs 0
cts 4
cp 0
rs 9.4285
cc 2
eloc 3
nc 2
nop 1
crap 6
1
<?php
2
3
/*
4
 * This file is part of the webmozart/console package.
5
 *
6
 * (c) Bernhard Schussek <[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 Webmozart\Console\UI\Component;
13
14
use Webmozart\Console\Api\Formatter\Formatter;
15
use Webmozart\Console\Util\StringUtil;
16
17
/**
18
 * Wraps cells to fit a given screen width with a given number of columns.
19
 *
20
 * You can add data cells with {@link addCell()}. Call {@link fit()} to fit
21
 * the cells into a given maximum width and number of columns.
22
 *
23
 * You can access the rows with the wrapped cells with {@link getWrappedRows()}.
24
 *
25
 * @since  1.0
26
 *
27
 * @author Bernhard Schussek <[email protected]>
28
 */
29
class CellWrapper
30
{
31
    /**
32
     * @var string[]
33
     */
34
    private $cells = array();
35
36
    /**
37
     * @var int[][]
38
     */
39
    private $cellLengths = array();
40
41
    /**
42
     * @var string[][]
43
     */
44
    private $wrappedRows = array();
45
46
    /**
47
     * @var int
48
     */
49
    private $nbColumns = 0;
50
51
    /**
52
     * @var int[]
53
     */
54
    private $columnLengths;
55
56
    /**
57
     * @var bool
58
     */
59
    private $wordWraps = false;
60
61
    /**
62
     * @var bool
63
     */
64
    private $wordCuts = false;
65
66
    /**
67
     * @var int
68
     */
69
    private $maxTotalWidth = 0;
70
71
    /**
72
     * @var int
73
     */
74
    private $totalWidth = 0;
75
76
    /**
77
     * Adds a cell to the wrapper.
78
     *
79
     * @param string $cell The data cell.
80
     */
81 21
    public function addCell($cell)
82
    {
83 21
        $this->cells[] = rtrim($cell);
84 21
    }
85
86
    /**
87
     * Adds cells to the wrapper.
88
     *
89
     * @param string[] $cells The data cells.
90
     */
91
    public function addCells(array $cells)
92
    {
93
        foreach ($cells as $cell) {
94
            $this->cells[] = rtrim($cell);
95
        }
96
    }
97
98
    /**
99
     * Sets the data cells in the wrapper.
100
     *
101
     * @param string[] $cells The data cells.
102
     */
103
    public function setCells(array $cells)
104
    {
105
        $this->cells = array();
106
107
        $this->addCells($cells);
108
    }
109
110
    /**
111
     * Returns the data cells in the wrapper.
112
     *
113
     * @return string[] The data cells.
114
     */
115
    public function getCells()
116
    {
117
        return $this->cells;
118
    }
119
120
    /**
121
     * Returns the wrapped cells organized by rows and columns.
122
     *
123
     * The method {@link fit()} should be called before accessing this method.
124
     * Otherwise, an empty array is returned.
125
     *
126
     * @return string[][] An array of arrays. The first level represents rows,
127
     *                    the second level the cells in each row.
128
     */
129 21
    public function getWrappedRows()
130
    {
131 21
        return $this->wrappedRows;
132
    }
133
134
    /**
135
     * Returns the lengths of the wrapped columns.
136
     *
137
     * The method {@link fit()} should be called before accessing this method.
138
     * Otherwise, an empty array is returned.
139
     *
140
     * @return int[] The lengths of each column.
141
     */
142 21
    public function getColumnLengths()
143
    {
144 21
        return $this->columnLengths;
145
    }
146
147
    /**
148
     * Returns the number of wrapped columns.
149
     *
150
     * The method {@link fit()} should be called before accessing this method.
151
     * Otherwise this method returns zero.
152
     *
153
     * @return int The number of columns.
154
     */
155
    public function getNbColumns()
156
    {
157
        return $this->nbColumns;
158
    }
159
160
    /**
161
     * Returns an estimated number of columns for the given maximum width.
162
     *
163
     * @param int $maxTotalWidth The maximum total width of the columns.
164
     *
165
     * @return int The estimated number of columns.
166
     */
167 10
    public function getEstimatedNbColumns($maxTotalWidth)
168
    {
169 10
        $i = 0;
170 10
        $rowWidth = 0;
171
172 10
        while (isset($this->cells[$i])) {
173 10
            $rowWidth += StringUtil::getLength($this->cells[$i]);
174
175 10
            if ($rowWidth > $maxTotalWidth) {
176
                // Return previous number of columns
177 9
                return $i;
178
            }
179
180 10
            ++$i;
181
        }
182
183 1
        return $i;
184
    }
185
186
    /**
187
     * Returns the maximum allowed total width of the columns.
188
     *
189
     * The method {@link fit()} should be called before accessing this method.
190
     * Otherwise this method returns zero.
191
     *
192
     * @return int The maximum allowed total width.
193
     */
194
    public function getMaxTotalWidth()
195
    {
196
        return $this->maxTotalWidth;
197
    }
198
199
    /**
200
     * Returns the actual total column width.
201
     *
202
     * The method {@link fit()} should be called before accessing this method.
203
     * Otherwise this method returns zero.
204
     *
205
     * @return int The actual total column width.
206
     */
207
    public function getTotalWidth()
208
    {
209
        return $this->totalWidth;
210
    }
211
212
    /**
213
     * Returns whether any of the cells needed to be wrapped into multiple
214
     * lines.
215
     *
216
     * The method {@link fit()} should be called before accessing this method.
217
     * Otherwise this method returns `false`.
218
     *
219
     * @return bool Returns `true` if a cell was wrapped into multiple lines
220
     *              and `false` otherwise.
221
     */
222
    public function hasWordWraps()
223
    {
224
        return $this->wordWraps;
225
    }
226
227
    /**
228
     * Returns whether any of the cells contains words cut in two.
229
     *
230
     * The method {@link fit()} should be called before accessing this method.
231
     * Otherwise this method returns `false`.
232
     *
233
     * @return bool Returns `true` if a cell contains words cut in two and
234
     *              `false` otherwise.
235
     */
236 10
    public function hasWordCuts()
237
    {
238 10
        return $this->wordCuts;
239
    }
240
241
    /**
242
     * Fits the added cells into the given maximum total width with the given
243
     * number of columns.
244
     *
245
     * @param int       $maxTotalWidth The maximum total width of the columns.
246
     * @param int       $nbColumns     The number of columns to use.
247
     * @param Formatter $formatter     The formatter used to remove style tags.
248
     */
249 21
    public function fit($maxTotalWidth, $nbColumns, Formatter $formatter)
250
    {
251 21
        $this->resetState($maxTotalWidth, $nbColumns);
252 21
        $this->initRows($formatter);
253
254
        // If the cells fit within the max width we're good
255 21
        if ($this->totalWidth <= $maxTotalWidth) {
256 7
            return;
257
        }
258
259 14
        $this->wrapColumns($formatter);
260 14
    }
261
262 21
    private function resetState($maxTotalWidth, $nbColumns)
263
    {
264 21
        $this->wrappedRows = array();
265 21
        $this->nbColumns = $nbColumns;
266 21
        $this->cellLengths = array();
267 21
        $this->columnLengths = array_fill(0, $nbColumns, 0);
268 21
        $this->wordWraps = false;
269 21
        $this->wordCuts = false;
270 21
        $this->maxTotalWidth = $maxTotalWidth;
271 21
        $this->totalWidth = 0;
272 21
    }
273
274 21
    private function initRows(Formatter $formatter)
275
    {
276 21
        $row = null;
277 21
        $col = 0;
278
279 21
        foreach ($this->cells as $i => $cell) {
280 21
            if (0 === $col) {
281 21
                $this->wrappedRows[] = array();
282 21
                $this->cellLengths[] = array();
283
284 21
                $row = &$this->wrappedRows[count($this->wrappedRows) - 1];
285 21
                $cellLengths = &$this->cellLengths[count($this->cellLengths) - 1];
286
            }
287
288 21
            $row[$col] = $cell;
289 21
            $cellLengths[$col] = StringUtil::getLength($cell, $formatter);
0 ignored issues
show
Bug introduced by
The variable $cellLengths does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
290 21
            $this->columnLengths[$col] = max($this->columnLengths[$col], $cellLengths[$col]);
291
292 21
            $col = ($col + 1) % $this->nbColumns;
293
        }
294
295
        // Fill last row up
296 21
        if ($col > 0) {
297 7
            while ($col < $this->nbColumns) {
298 7
                $row[$col] = '';
299 7
                $cellLengths[$col] = 0;
300 7
                ++$col;
301
            }
302
        }
303
304 21
        $this->totalWidth = array_sum($this->columnLengths);
0 ignored issues
show
Documentation Bug introduced by
It seems like array_sum($this->columnLengths) can also be of type double. However, the property $totalWidth is declared as type integer. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
305 21
    }
306
307 14
    private function wrapColumns(Formatter $formatter)
308
    {
309 14
        $availableWidth = $this->maxTotalWidth;
310 14
        $longColumnLengths = $this->columnLengths;
311
312
        // Filter "short" column, i.e. columns that are not wrapped
313
        // We distribute the available screen width by the number of columns
314
        // and decide that all columns that are shorter than their share are
315
        // "short".
316
        // This process is repeated until no more "short" columns are found.
317
        do {
318 14
            $threshold = $availableWidth / count($longColumnLengths);
319 14
            $repeat = false;
320
321 14
            foreach ($longColumnLengths as $col => $length) {
322 14
                if ($length <= $threshold) {
323 12
                    $availableWidth -= $length;
324 12
                    unset($longColumnLengths[$col]);
325 14
                    $repeat = true;
326
                }
327
            }
328 14
        } while ($repeat);
329
330
        // Calculate actual and available width
331 14
        $actualWidth = 0;
332 14
        $lastAdaptedCol = 0;
333
334
        // "Long" columns, i.e. columns that need to be wrapped, are added to
335
        // the actual width
336 14
        foreach ($longColumnLengths as $col => $length) {
337 14
            $actualWidth += $length;
338 14
            $lastAdaptedCol = $col;
339
        }
340
341
        // Fit columns into available width
342 14
        foreach ($longColumnLengths as $col => $length) {
343
            // Keep ratios of column lengths and distribute them among the
344
            // available width
345 14
            $this->columnLengths[$col] = round(($length / $actualWidth) * $availableWidth);
346
347 14
            if ($col === $lastAdaptedCol) {
348
                // Fix rounding errors
349 14
                $this->columnLengths[$col] += $this->maxTotalWidth - array_sum($this->columnLengths);
350
            }
351
352 14
            $this->wrapColumn($col, $this->columnLengths[$col], $formatter);
353
354
            // Recalculate the column length based on the actual wrapped length
355 14
            $this->refreshColumnLength($col);
356
357
            // Recalculate the actual width based on the changed length.
358 14
            $actualWidth = $actualWidth - $length + $this->columnLengths[$col];
359
        }
360
361 14
        $this->totalWidth = array_sum($this->columnLengths);
0 ignored issues
show
Documentation Bug introduced by
It seems like array_sum($this->columnLengths) can also be of type double. However, the property $totalWidth is declared as type integer. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
362 14
    }
363
364 14
    private function wrapColumn($col, $columnLength, Formatter $formatter)
365
    {
366 14
        foreach ($this->wrappedRows as $i => $row) {
367 14
            $cell = $row[$col];
368 14
            $cellLength = $this->cellLengths[$i][$col];
369
370 14
            if ($cellLength > $columnLength) {
371 14
                $this->wordWraps = true;
372
373 14
                if (!$this->wordCuts) {
374 14
                    $minLengthWithoutCut = StringUtil::getMaxWordLength($cell, $formatter);
375
376 14
                    if ($minLengthWithoutCut > $columnLength) {
377 7
                        $this->wordCuts = true;
378
                    }
379
                }
380
381
                // TODO use format aware wrapper
382
                // true: Words may be cut in two
383 14
                $wrappedCell = wordwrap($cell, $columnLength, "\n", true);
384
385 14
                $this->wrappedRows[$i][$col] = $wrappedCell;
386
387
                // Refresh cell length
388 14
                $this->cellLengths[$i][$col] = StringUtil::getMaxLineLength($wrappedCell, $formatter);
389
            }
390
        }
391 14
    }
392
393 14
    private function refreshColumnLength($col)
394
    {
395 14
        $this->columnLengths[$col] = 0;
396
397 14
        foreach ($this->wrappedRows as $i => $row) {
398 14
            $this->columnLengths[$col] = max($this->columnLengths[$col], $this->cellLengths[$i][$col]);
399
        }
400 14
    }
401
}
402