Completed
Push — dependabot/composer/symfony/ht... ( 5f4f25...9dd149 )
by
unknown
11:20 queued 05:11
created

TableNode   F

Complexity

Total Complexity 78

Size/Duplication

Total Lines 507
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 9

Importance

Changes 0
Metric Value
dl 0
loc 507
rs 2.16
c 0
b 0
f 0
wmc 78
lcom 1
cbo 9

14 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 8 1
A getCols() 0 13 3
A getRows() 0 8 2
A getData() 0 8 2
A getHeaders() 0 8 2
A pushSeparatorLine() 0 9 2
A pushContentLine() 0 9 2
B finalize() 0 32 6
A compile() 0 10 2
F compileSimpleTable() 0 98 17
F compilePrettyTable() 0 200 30
A getTableAsString() 0 16 4
A addError() 0 4 1
A findColumnInPreviousRows() 0 21 4

How to fix   Complexity   

Complex Class

Complex classes like TableNode often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use TableNode, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
namespace phpDocumentor\Guides\Nodes;
6
7
use Exception;
8
use LogicException;
9
use phpDocumentor\Guides\Nodes\Table\TableColumn;
10
use phpDocumentor\Guides\Nodes\Table\TableRow;
11
use phpDocumentor\Guides\RestructuredText\Exception\InvalidTableStructure;
12
use phpDocumentor\Guides\RestructuredText\Parser;
13
use phpDocumentor\Guides\RestructuredText\Parser\LineChecker;
14
use phpDocumentor\Guides\RestructuredText\Parser\TableSeparatorLineConfig;
15
use function array_keys;
16
use function array_reverse;
17
use function array_values;
18
use function count;
19
use function explode;
20
use function implode;
21
use function ksort;
22
use function max;
23
use function preg_match;
24
use function sprintf;
25
use function str_repeat;
26
use function strlen;
27
use function strpos;
28
use function substr;
29
use function trim;
30
use function utf8_decode;
31
32
class TableNode extends Node
33
{
34
    public const TYPE_SIMPLE = 'simple';
35
    public const TYPE_PRETTY = 'pretty';
36
37
    /** @var TableSeparatorLineConfig[] */
38
    private $separatorLineConfigs = [];
39
40
    /** @var string[] */
41
    private $rawDataLines = [];
42
43
    /** @var int */
44
    private $currentLineNumber = 0;
45
46
    /** @var bool */
47
    private $isCompiled = false;
48
49
    /** @var TableRow[] */
50
    protected $data = [];
51
52
    /** @var bool[] */
53
    protected $headers = [];
54
55
    /** @var string[] */
56
    private $errors = [];
57
58
    /** @var string */
59
    protected $type;
60
61
    /** @var LineChecker */
62
    private $lineChecker;
63
64
    public function __construct(TableSeparatorLineConfig $separatorLineConfig, string $type, LineChecker $lineChecker)
65
    {
66
        parent::__construct();
67
68
        $this->pushSeparatorLine($separatorLineConfig);
69
        $this->type        = $type;
70
        $this->lineChecker = $lineChecker;
71
    }
72
73
    public function getCols() : int
74
    {
75
        if ($this->isCompiled === false) {
76
            throw new LogicException('Call compile() first.');
77
        }
78
79
        $columns = 0;
80
        foreach ($this->data as $row) {
81
            $columns = max($columns, count($row->getColumns()));
82
        }
83
84
        return $columns;
85
    }
86
87
    public function getRows() : int
88
    {
89
        if ($this->isCompiled === false) {
90
            throw new LogicException('Call compile() first.');
91
        }
92
93
        return count($this->data);
94
    }
95
96
    /**
97
     * @return TableRow[]
98
     */
99
    public function getData() : array
100
    {
101
        if ($this->isCompiled === false) {
102
            throw new LogicException('Call compile() first.');
103
        }
104
105
        return $this->data;
106
    }
107
108
    /**
109
     * Returns an of array of which rows should be headers,
110
     * where the row index is the key of the array and
111
     * the value is always true.
112
     *
113
     * @return bool[]
114
     */
115
    public function getHeaders() : array
116
    {
117
        if ($this->isCompiled === false) {
118
            throw new LogicException('Call compile() first.');
119
        }
120
121
        return $this->headers;
122
    }
123
124
    public function pushSeparatorLine(TableSeparatorLineConfig $separatorLineConfig) : void
125
    {
126
        if ($this->isCompiled === true) {
127
            throw new LogicException('Cannot push data after TableNode is compiled');
128
        }
129
130
        $this->separatorLineConfigs[$this->currentLineNumber] = $separatorLineConfig;
131
        $this->currentLineNumber++;
132
    }
133
134
    public function pushContentLine(string $line) : void
135
    {
136
        if ($this->isCompiled === true) {
137
            throw new LogicException('Cannot push data after TableNode is compiled');
138
        }
139
140
        $this->rawDataLines[$this->currentLineNumber] = utf8_decode($line);
141
        $this->currentLineNumber++;
142
    }
143
144
    public function finalize(Parser $parser) : void
145
    {
146
        if ($this->isCompiled === false) {
147
            $this->compile();
148
        }
149
150
        $tableAsString = $this->getTableAsString();
151
152
        if (count($this->errors) > 0) {
153
            $parser->getEnvironment()
154
                ->addError(sprintf("%s\nin file %s\n\n%s", $this->errors[0], $parser->getFilename(), $tableAsString));
155
156
            $this->data    = [];
157
            $this->headers = [];
158
159
            return;
160
        }
161
162
        foreach ($this->data as $i => $row) {
163
            foreach ($row->getColumns() as $col) {
164
                $lines = explode("\n", $col->getContent());
165
166
                if ($this->lineChecker->isListLine($lines[0], false)) {
167
                    $node = $parser->parseFragment($col->getContent())->getNodes()[0];
168
                } else {
169
                    $node = $parser->createSpanNode($col->getContent());
170
                }
171
172
                $col->setNode($node);
173
            }
174
        }
175
    }
176
177
    /**
178
     * Looks at all the raw data and finally populates the data
179
     * and headers.
180
     */
181
    private function compile() : void
182
    {
183
        $this->isCompiled = true;
184
185
        if ($this->type === self::TYPE_SIMPLE) {
186
            $this->compileSimpleTable();
187
        } else {
188
            $this->compilePrettyTable();
189
        }
190
    }
191
192
    private function compileSimpleTable() : void
193
    {
194
        // determine if there is second === separator line (other than
195
        // the last line): this would mean there are header rows
196
        $finalHeadersRow = 0;
197
        foreach ($this->separatorLineConfigs as $i => $separatorLine) {
198
            // skip the first line: we're looking for the *next* line
199
            if ($i === 0) {
200
                continue;
201
            }
202
203
            // we found the next ==== line
204
            if ($separatorLine->getLineCharacter() === '=') {
205
                // found the end of the header rows
206
                $finalHeadersRow = $i;
207
208
                break;
209
            }
210
        }
211
212
        // if the final header row is *after* the last data line, it's not
213
        // really a header "ending" and so there are no headers
214
        $lastDataLineNumber = array_keys($this->rawDataLines)[count($this->rawDataLines)-1];
215
        if ($finalHeadersRow > $lastDataLineNumber) {
216
            $finalHeadersRow = 0;
217
        }
218
219
        // todo - support "---" in the future for colspan
220
        $columnRanges       = $this->separatorLineConfigs[0]->getPartRanges();
221
        $lastColumnRangeEnd = array_values($columnRanges)[count($columnRanges)-1][1];
222
        foreach ($this->rawDataLines as $i => $line) {
223
            $row = new TableRow();
224
            // loop over where all the columns should be
225
226
            $previousColumnEnd = null;
227
            foreach ($columnRanges as $columnRange) {
228
                $isRangeBeyondText = $columnRange[0] >= strlen($line);
229
                // check for content in the "gap"
230
                if ($previousColumnEnd !== null && ! $isRangeBeyondText) {
231
                    $gapText = substr($line, $previousColumnEnd, $columnRange[0] - $previousColumnEnd);
232
                    if (strlen(trim($gapText)) !== 0) {
233
                        $this->addError(sprintf('Malformed table: content "%s" appears in the "gap" on row "%s"', $gapText, $line));
234
                    }
235
                }
236
237
                if ($isRangeBeyondText) {
238
                    // the text for this line ended earlier. This column should be blank
239
240
                    $content = '';
241
                } elseif ($lastColumnRangeEnd === $columnRange[1]) {
242
                    // this is the last column, so get the rest of the line
243
                    // this is because content can go *beyond* the table legally
244
                    $content = substr(
245
                        $line,
246
                        $columnRange[0]
247
                    );
248
                } else {
249
                    $content = substr(
250
                        $line,
251
                        $columnRange[0],
252
                        $columnRange[1] - $columnRange[0]
253
                    );
254
                }
255
256
                $content = trim($content);
257
                $row->addColumn($content, 1);
258
259
                $previousColumnEnd = $columnRange[1];
260
            }
261
262
            // is header row?
263
            if ($i <= $finalHeadersRow) {
264
                $this->headers[$i] = true;
265
            }
266
267
            $this->data[$i] = $row;
268
        }
269
270
        /** @var TableRow|null $previousRow */
271
        $previousRow = null;
272
        // check for empty first columns, which means this is
273
        // not a new row, but the continuation of the previous row
274
        foreach ($this->data as $i => $row) {
275
            if ($row->getFirstColumn()->isCompletelyEmpty() && $previousRow !== null) {
276
                try {
277
                    $previousRow->absorbRowContent($row);
278
                } catch (InvalidTableStructure $e) {
279
                    $this->addError($e->getMessage());
280
                }
281
282
                unset($this->data[$i]);
283
284
                continue;
285
            }
286
287
            $previousRow = $row;
288
        }
289
    }
290
291
    private function compilePrettyTable() : void
292
    {
293
        // loop over ALL separator lines to find ALL of the column ranges
294
        $columnRanges    = [];
295
        $finalHeadersRow = 0;
296
        foreach ($this->separatorLineConfigs as $rowIndex => $separatorLine) {
297
            if ($separatorLine->isHeader()) {
298
                if ($finalHeadersRow !== 0) {
299
                    $this->addError(sprintf('Malformed table: multiple "header rows" using "===" were found. See table lines "%d" and "%d"', $finalHeadersRow + 1, $rowIndex));
300
                }
301
302
                // indicates that "=" was used
303
                $finalHeadersRow = $rowIndex - 1;
304
            }
305
306
            foreach ($separatorLine->getPartRanges() as $columnRange) {
307
                $colStart = $columnRange[0];
308
                $colEnd   = $columnRange[1];
309
310
                // we don't have this "start" yet? just add it
311
                // in theory, should only happen for the first row
312
                if (! isset($columnRanges[$colStart])) {
313
                    $columnRanges[$colStart] = $colEnd;
314
315
                    continue;
316
                }
317
318
                // an exact column range we've already seen
319
                // OR, this new column goes beyond what we currently
320
                // have recorded, which means its a colspan, and so
321
                // we already have correctly recorded the "smallest"
322
                // current column ranges
323
                if ($columnRanges[$colStart] <= $colEnd) {
324
                    continue;
325
                }
326
327
                // this is not a new "start", but it is a new "end"
328
                // this means that we've found a "shorter" column that
329
                // we've seen before. We need to update the "end" of
330
                // the existing column, and add a "new" column
331
                $previousEnd = $columnRanges[$colStart];
332
333
                // A) update the end of this column to the new end
334
                $columnRanges[$colStart] = $colEnd;
335
                // B) add a new column from this new end, to the previous end
336
                $columnRanges[$colEnd + 1] = $previousEnd;
337
                ksort($columnRanges);
338
            }
339
        }
340
341
        /** @var TableRow[] $rows */
342
        $rows                 = [];
343
        $partialSeparatorRows = [];
344
        foreach ($this->rawDataLines as $rowIndex => $line) {
345
            $row = new TableRow();
346
347
            // if the row is part separator row, part content, this
348
            // is a rowspan situation - e.g.
349
            // |           +----------------+----------------------------+
350
            // look for +-----+ pattern
351
            if (preg_match('/\+[-]+\+/', $this->rawDataLines[$rowIndex]) === 1) {
352
                $partialSeparatorRows[$rowIndex] = true;
353
            }
354
355
            $currentColumnStart = null;
356
            $currentSpan        = 1;
357
            $previousColumnEnd  = null;
358
            foreach ($columnRanges as $start => $end) {
359
                // a content line that ends before it should
360
                if ($end >= strlen($line)) {
361
                    $this->errors[] = sprintf("Malformed table: Line\n\n%s\n\ndoes not appear to be a complete table row", $line);
362
363
                    break;
364
                }
365
366
                if ($currentColumnStart !== null) {
367
                    if ($previousColumnEnd === null) {
368
                        throw new LogicException('The previous column end is not set yet');
369
                    }
370
371
                    $gapText = substr($line, $previousColumnEnd, $start - $previousColumnEnd);
372
                    if (strpos($gapText, '|') === false && strpos($gapText, '+') === false) {
373
                        // text continued through the "gap". This is a colspan
374
                        // "+" is an odd character - it's usually "|", but "+" can
375
                        // happen in row-span situations
376
                        $currentSpan++;
377
                    } else {
378
                        // we just hit a proper "gap" record the line up until now
379
                        $row->addColumn(
380
                            substr($line, $currentColumnStart, $previousColumnEnd - $currentColumnStart),
381
                            $currentSpan
382
                        );
383
                        $currentSpan        = 1;
384
                        $currentColumnStart = null;
385
                    }
386
                }
387
388
                // if the current column start is null, then set it
389
                // other wise, leave it - this is a colspan, and eventually
390
                // we want to get all the text starting here
391
                if ($currentColumnStart === null) {
392
                    $currentColumnStart = $start;
393
                }
394
                $previousColumnEnd = $end;
395
            }
396
397
            // record the last column
398
            if ($currentColumnStart !== null) {
399
                if ($previousColumnEnd === null) {
400
                    throw new LogicException('The previous column end is not set yet');
401
                }
402
403
                $row->addColumn(
404
                    substr($line, $currentColumnStart, $previousColumnEnd - $currentColumnStart),
405
                    $currentSpan
406
                );
407
            }
408
409
            $rows[$rowIndex] = $row;
410
        }
411
412
        $columnIndexesCurrentlyInRowspan = [];
413
        foreach ($rows as $rowIndex => $row) {
414
            if (isset($partialSeparatorRows[$rowIndex])) {
415
                // this row is part content, part separator due to a rowspan
416
                // for each column that contains content, we need to
417
                // push it onto the last real row's content and record
418
                // that this column in the next row should also be
419
                // included in that previous row's content
420
                foreach ($row->getColumns() as $columnIndex => $column) {
421
                    if (! $column->isCompletelyEmpty() && str_repeat('-', strlen($column->getContent())) === $column->getContent()) {
422
                        // only a line separator in this column - not content!
423
                        continue;
424
                    }
425
426
                    $prevTargetColumn = $this->findColumnInPreviousRows((int) $columnIndex, $rows, (int) $rowIndex);
427
                    $prevTargetColumn->addContent("\n" . $column->getContent());
428
                    $prevTargetColumn->incrementRowSpan();
429
                    // mark that this column on the next row should also be added
430
                    // to the previous row
431
                    $columnIndexesCurrentlyInRowspan[] = $columnIndex;
432
                }
433
434
                // remove the row - it's not real
435
                unset($rows[$rowIndex]);
436
437
                continue;
438
            }
439
440
            // check if the previous row was a partial separator row, and
441
            // we need to take some columns and add them to a previous row's content
442
            foreach ($columnIndexesCurrentlyInRowspan as $columnIndex) {
443
                $prevTargetColumn = $this->findColumnInPreviousRows($columnIndex, $rows, (int) $rowIndex);
444
                $columnInRowspan  = $row->getColumn($columnIndex);
445
                if ($columnInRowspan === null) {
446
                    throw new LogicException('Cannot find column for index "%s"', $columnIndex);
447
                }
448
                $prevTargetColumn->addContent("\n" . $columnInRowspan->getContent());
449
450
                // now this column actually needs to be removed from this row,
451
                // as it's not a real column that needs to be printed
452
                $row->removeColumn($columnIndex);
453
            }
454
            $columnIndexesCurrentlyInRowspan = [];
455
456
            // if the next row is just $i+1, it means there
457
            // was no "separator" and this is really just a
458
            // continuation of this row.
459
            $nextRowCounter = 1;
460
            while (isset($rows[(int) $rowIndex + $nextRowCounter])) {
461
                // but if the next line is actually a partial separator, then
462
                // it is not a continuation of the content - quit now
463
                if (isset($partialSeparatorRows[(int) $rowIndex + $nextRowCounter])) {
464
                    break;
465
                }
466
467
                $targetRow = $rows[(int) $rowIndex + $nextRowCounter];
468
                unset($rows[(int) $rowIndex + $nextRowCounter]);
469
470
                try {
471
                    $row->absorbRowContent($targetRow);
472
                } catch (InvalidTableStructure $e) {
473
                    $this->addError($e->getMessage());
474
                }
475
476
                $nextRowCounter++;
477
            }
478
        }
479
480
        // one more loop to set headers
481
        foreach ($rows as $rowIndex => $row) {
482
            if ($rowIndex > $finalHeadersRow) {
483
                continue;
484
            }
485
486
            $this->headers[$rowIndex] = true;
487
        }
488
489
        $this->data = $rows;
490
    }
491
492
    private function getTableAsString() : string
493
    {
494
        $lines = [];
495
        $i     = 0;
496
        while (isset($this->separatorLineConfigs[$i]) || isset($this->rawDataLines[$i])) {
497
            if (isset($this->separatorLineConfigs[$i])) {
498
                $lines[] = $this->separatorLineConfigs[$i]->getRawContent();
499
            } else {
500
                $lines[] = $this->rawDataLines[$i];
501
            }
502
503
            $i++;
504
        }
505
506
        return implode("\n", $lines);
507
    }
508
509
    private function addError(string $message) : void
510
    {
511
        $this->errors[] = $message;
512
    }
513
514
    /**
515
     * @param TableRow[] $rows
516
     */
517
    private function findColumnInPreviousRows(int $columnIndex, array $rows, int $currentRowIndex) : TableColumn
518
    {
519
        /** @var TableRow[] $reversedRows */
520
        $reversedRows = array_reverse($rows, true);
521
522
        // go through the rows backwards to find the last/previous
523
        // row that actually had a real column at this position
524
        foreach ($reversedRows as $k => $row) {
525
            // start by skipping any future rows, as we go backward
526
            if ($k >= $currentRowIndex) {
527
                continue;
528
            }
529
530
            $prevTargetColumn = $row->getColumn($columnIndex);
531
            if ($prevTargetColumn !== null) {
532
                return $prevTargetColumn;
533
            }
534
        }
535
536
        throw new Exception('Could not find column in any previous rows');
537
    }
538
}
539