Test Failed
Pull Request — master (#102)
by Sven
03:02
created

HtmlDiff::isOnlyWhitespace()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
nc 2
nop 1
dl 0
loc 5
rs 10
c 0
b 0
f 0
ccs 2
cts 2
cp 1
crap 2
1
<?php
2
3
namespace Caxy\HtmlDiff;
4
5
use Caxy\HtmlDiff\Table\TableDiff;
6
7
/**
8
 * Class HtmlDiff.
9
 */
10
class HtmlDiff extends AbstractDiff
11
{
12
    /**
13
     * @var array
14
     */
15
    protected $wordIndices;
16
17
    /**
18
     * @var array
19
     */
20
    protected $newIsolatedDiffTags;
21
22
    /**
23
     * @var array
24
     */
25
    protected $oldIsolatedDiffTags;
26
27
    /**
28
     * @param string              $oldText
29
     * @param string              $newText
30
     * @param HtmlDiffConfig|null $config
31
     *
32
     * @return self
33
     */
34
    public static function create($oldText, $newText, HtmlDiffConfig $config = null)
35
    {
36
        $diff = new self($oldText, $newText);
37
38
        if (null !== $config) {
39
            $diff->setConfig($config);
40 15
        }
41
42 15
        return $diff;
43
    }
44 15
45 15
    /**
46
     * @param $bool
47
     *
48 15
     * @return $this
49
     *
50
     * @deprecated since 0.1.0
51
     */
52
    public function setUseTableDiffing($bool)
53
    {
54
        $this->config->setUseTableDiffing($bool);
55
56
        return $this;
57
    }
58
59
    /**
60
     * @param bool $boolean
61
     *
62
     * @return HtmlDiff
63
     *
64
     * @deprecated since 0.1.0
65
     */
66
    public function setInsertSpaceInReplace($boolean)
67
    {
68
        $this->config->setInsertSpaceInReplace($boolean);
69
70
        return $this;
71
    }
72
73
    /**
74
     * @return bool
75
     *
76
     * @deprecated since 0.1.0
77
     */
78
    public function getInsertSpaceInReplace()
79
    {
80
        return $this->config->isInsertSpaceInReplace();
81
    }
82
83
    /**
84
     * @return string
85
     */
86
    public function build()
87
    {
88
        $this->prepare();
89
90
        if ($this->hasDiffCache() && $this->getDiffCache()->contains($this->oldText, $this->newText)) {
91
            $this->content = $this->getDiffCache()->fetch($this->oldText, $this->newText);
92 18
93
            return $this->content;
94 18
        }
95
96 18
        // Pre-processing Optimizations
97
98
        // 1. Equality
99
        if ($this->oldText == $this->newText) {
100
            return $this->newText;
101
        }
102
103
        $this->splitInputsToWords();
104
        $this->replaceIsolatedDiffTags();
105 18
        $this->indexNewWords();
106 11
107
        $operations = $this->operations();
108
109 18
        foreach ($operations as $item) {
110 18
            $this->performOperation($item);
111 18
        }
112
113 18
        if ($this->hasDiffCache()) {
114
            $this->getDiffCache()->save($this->oldText, $this->newText, $this->content);
115 18
        }
116 18
117
        return $this->content;
118
    }
119 18
120
    protected function indexNewWords() : void
121
    {
122
        $this->wordIndices = [];
123 18
124
        foreach ($this->newWords as $i => $word) {
125
            if ($this->isTag($word) === true) {
126 18
                $word = $this->stripTagAttributes($word);
127
            }
128 18
129 18
            if (isset($this->wordIndices[$word]) === false) {
130 18
                $this->wordIndices[$word] = [];
131 10
            }
132
133 18
            $this->wordIndices[$word][] = $i;
134 13
        }
135
    }
136 18
137
    protected function replaceIsolatedDiffTags()
138
    {
139 18
        $this->oldIsolatedDiffTags = $this->createIsolatedDiffTagPlaceholders($this->oldWords);
140
        $this->newIsolatedDiffTags = $this->createIsolatedDiffTagPlaceholders($this->newWords);
141 18
    }
142
143 18
    /**
144 18
     * @param array $words
145 18
     *
146
     * @return array
147
     */
148
    protected function createIsolatedDiffTagPlaceholders(&$words)
149
    {
150
        $openIsolatedDiffTags = 0;
151
        $isolatedDiffTagIndices = array();
152 18
        $isolatedDiffTagStart = 0;
153
        $currentIsolatedDiffTag = null;
154 18
        foreach ($words as $index => $word) {
155 18
            $openIsolatedDiffTag = $this->isOpeningIsolatedDiffTag($word, $currentIsolatedDiffTag);
156 18
            if ($openIsolatedDiffTag) {
157 18
                if ($this->isSelfClosingTag($word) || $this->stringUtil->stripos($word, '<img') !== false) {
158 18
                    if ($openIsolatedDiffTags === 0) {
159 18
                        $isolatedDiffTagIndices[] = array(
160 18
                            'start' => $index,
161 16
                            'length' => 1,
162
                            'tagType' => $openIsolatedDiffTag,
163
                        );
164
                        $currentIsolatedDiffTag = null;
165
                    }
166
                } else {
167
                    if ($openIsolatedDiffTags === 0) {
168
                        $isolatedDiffTagStart = $index;
169
                    }
170
                    ++$openIsolatedDiffTags;
171 16
                    $currentIsolatedDiffTag = $openIsolatedDiffTag;
172 16
                }
173
            } elseif ($openIsolatedDiffTags > 0 && $this->isClosingIsolatedDiffTag($word, $currentIsolatedDiffTag)) {
174 16
                --$openIsolatedDiffTags;
175 16
                if ($openIsolatedDiffTags == 0) {
176
                    $isolatedDiffTagIndices[] = array('start' => $isolatedDiffTagStart, 'length' => $index - $isolatedDiffTagStart + 1, 'tagType' => $currentIsolatedDiffTag);
177 18
                    $currentIsolatedDiffTag = null;
178 16
                }
179 16
            }
180 16
        }
181 16
        $isolatedDiffTagScript = array();
182
        $offset = 0;
183
        foreach ($isolatedDiffTagIndices as $isolatedDiffTagIndex) {
184
            $start = $isolatedDiffTagIndex['start'] - $offset;
185 18
            $placeholderString = $this->config->getIsolatedDiffTagPlaceholder($isolatedDiffTagIndex['tagType']);
186 18
            $isolatedDiffTagScript[$start] = array_splice($words, $start, $isolatedDiffTagIndex['length'], $placeholderString);
187 18
            $offset += $isolatedDiffTagIndex['length'] - 1;
188 16
        }
189 16
190 16
        return $isolatedDiffTagScript;
191 16
    }
192
193
    /**
194 18
     * @param string      $item
195
     * @param null|string $currentIsolatedDiffTag
196
     *
197
     * @return false|string
0 ignored issues
show
Documentation introduced by
Should the return type not be integer|string|false?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
198
     */
199
    protected function isOpeningIsolatedDiffTag($item, $currentIsolatedDiffTag = null)
200
    {
201
        $tagsToMatch = $currentIsolatedDiffTag !== null
202
            ? array($currentIsolatedDiffTag => $this->config->getIsolatedDiffTagPlaceholder($currentIsolatedDiffTag))
203 18
            : $this->config->getIsolatedDiffTags();
204
        $pattern = '#<%s(\s+[^>]*)?>#iUu';
205 18
        foreach ($tagsToMatch as $key => $value) {
206 16
            if (preg_match(sprintf($pattern, $key), $item)) {
207 18
                return $key;
208 18
            }
209 18
        }
210 18
211 16
        return false;
212
    }
213
214
    protected function isSelfClosingTag($text)
215 18
    {
216
        return (bool) preg_match('/<[^>]+\/\s*>/u', $text);
217
    }
218 16
219
    /**
220 16
     * @param string      $item
221
     * @param null|string $currentIsolatedDiffTag
222
     *
223
     * @return false|string
0 ignored issues
show
Documentation introduced by
Should the return type not be integer|string|false?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
224
     */
225
    protected function isClosingIsolatedDiffTag($item, $currentIsolatedDiffTag = null)
226
    {
227
        $tagsToMatch = $currentIsolatedDiffTag !== null
228
            ? array($currentIsolatedDiffTag => $this->config->getIsolatedDiffTagPlaceholder($currentIsolatedDiffTag))
229 16
            : $this->config->getIsolatedDiffTags();
230
        $pattern = '#</%s(\s+[^>]*)?>#iUu';
231 16
        foreach ($tagsToMatch as $key => $value) {
232 16
            if (preg_match(sprintf($pattern, $key), $item)) {
233 16
                return $key;
234 16
            }
235 16
        }
236 16
237 16
        return false;
238
    }
239
240
    /**
241 16
     * @param Operation $operation
242
     */
243
    protected function performOperation($operation)
244
    {
245
        switch ($operation->action) {
246
            case 'equal' :
0 ignored issues
show
Coding Style introduced by
There must be no space before the colon in a CASE statement

As per the PSR-2 coding standard, there must not be a space in front of the colon in case statements.

switch ($selector) {
    case "A": //right
        doSomething();
        break;
    case "B" : //wrong
        doSomethingElse();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
247 18
                $this->processEqualOperation($operation);
248
                break;
249 18
            case 'delete' :
0 ignored issues
show
Coding Style introduced by
There must be no space before the colon in a CASE statement

As per the PSR-2 coding standard, there must not be a space in front of the colon in case statements.

switch ($selector) {
    case "A": //right
        doSomething();
        break;
    case "B" : //wrong
        doSomethingElse();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
250 18
                $this->processDeleteOperation($operation, 'diffdel');
251 18
                break;
252 18
            case 'insert' :
0 ignored issues
show
Coding Style introduced by
There must be no space before the colon in a CASE statement

As per the PSR-2 coding standard, there must not be a space in front of the colon in case statements.

switch ($selector) {
    case "A": //right
        doSomething();
        break;
    case "B" : //wrong
        doSomethingElse();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
253 15
                $this->processInsertOperation($operation, 'diffins');
254 4
                break;
255 4
            case 'replace':
256 15
                $this->processReplaceOperation($operation);
257 10
                break;
258 10
            default:
259 11
                break;
260 11
        }
261 11
    }
262
263
    /**
264
     * @param Operation $operation
265 18
     */
266
    protected function processReplaceOperation($operation)
267
    {
268
        $this->processDeleteOperation($operation, 'diffmod');
269
        $this->processInsertOperation($operation, 'diffmod');
270 11
    }
271
272 11
    /**
273 11
     * @param Operation $operation
274 11
     * @param string    $cssClass
275
     */
276
    protected function processInsertOperation($operation, $cssClass)
277
    {
278
        $text = array();
279
        foreach ($this->newWords as $pos => $s) {
280 15
            if ($pos >= $operation->startInNew && $pos < $operation->endInNew) {
281
                if ($this->config->isIsolatedDiffTagPlaceholder($s) && isset($this->newIsolatedDiffTags[$pos])) {
282 15
                    foreach ($this->newIsolatedDiffTags[$pos] as $word) {
283 15
                        $text[] = $word;
284 15
                    }
285 15
                } else {
286 4
                    $text[] = $s;
287 4
                }
288
            }
289
        }
290 15
291
        $this->insertTag('ins', $cssClass, $text);
292
    }
293
294
    /**
295 15
     * @param Operation $operation
296 15
     * @param string    $cssClass
297
     */
298
    protected function processDeleteOperation($operation, $cssClass)
299
    {
300
        $text = array();
301
        foreach ($this->oldWords as $pos => $s) {
302 13
            if ($pos >= $operation->startInOld && $pos < $operation->endInOld) {
303
                if ($this->config->isIsolatedDiffTagPlaceholder($s) && isset($this->oldIsolatedDiffTags[$pos])) {
304 13
                    foreach ($this->oldIsolatedDiffTags[$pos] as $word) {
305 13
                        $text[] = $word;
306 13
                    }
307 13
                } else {
308 6
                    $text[] = $s;
309 6
                }
310
            }
311
        }
312 13
        $this->insertTag('del', $cssClass, $text);
313
    }
314
315
    /**
316 13
     * @param Operation $operation
317 13
     * @param int       $pos
318
     * @param string    $placeholder
319
     * @param bool      $stripWrappingTags
320
     *
321
     * @return string
0 ignored issues
show
Documentation introduced by
Should the return type not be boolean|string?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
322
     */
323
    protected function diffIsolatedPlaceholder($operation, $pos, $placeholder, $stripWrappingTags = true)
324
    {
325
        $oldText = implode('', $this->findIsolatedDiffTagsInOld($operation, $pos));
326
        $newText = implode('', $this->newIsolatedDiffTags[$pos]);
327 15
328
        if ($this->isListPlaceholder($placeholder)) {
329 15
            return $this->diffList($oldText, $newText);
330 15
        } elseif ($this->config->isUseTableDiffing() && $this->isTablePlaceholder($placeholder)) {
331
            return $this->diffTables($oldText, $newText);
332 15
        } elseif ($this->isLinkPlaceholder($placeholder)) {
333 8
            return $this->diffElementsByAttribute($oldText, $newText, 'href', 'a');
334 11
        } elseif ($this->isImagePlaceholder($placeholder)) {
335 1
            return $this->diffElementsByAttribute($oldText, $newText, 'src', 'img');
336 10
        }
337 1
338 9
        return $this->diffElements($oldText, $newText, $stripWrappingTags);
339
    }
340
341
    /**
342 9
     * @param string $oldText
343
     * @param string $newText
344
     * @param bool   $stripWrappingTags
345
     *
346
     * @return string
347
     */
348
    protected function diffElements($oldText, $newText, $stripWrappingTags = true)
349
    {
350
        $wrapStart = '';
351
        $wrapEnd = '';
352 10
353
        if ($stripWrappingTags) {
354 10
            $pattern = '/(^<[^>]+>)|(<\/[^>]+>$)/iu';
355 10
            $matches = array();
356
357 10
            if (preg_match_all($pattern, $newText, $matches)) {
358 10
                $wrapStart = isset($matches[0][0]) ? $matches[0][0] : '';
359 10
                $wrapEnd = isset($matches[0][1]) ? $matches[0][1] : '';
360
            }
361 10
            $oldText = preg_replace($pattern, '', $oldText);
362 10
            $newText = preg_replace($pattern, '', $newText);
363 10
        }
364
365 10
        $diff = self::create($oldText, $newText, $this->config);
366 10
367
        return $wrapStart.$diff->build().$wrapEnd;
368
    }
369 10
370
    /**
371 10
     * @param string $oldText
372
     * @param string $newText
373
     *
374
     * @return string
0 ignored issues
show
Documentation introduced by
Should the return type not be boolean|string?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
375
     */
376
    protected function diffList($oldText, $newText)
377
    {
378
        $diff = ListDiffLines::create($oldText, $newText, $this->config);
379
380 8
        return $diff->build();
381
    }
382 8
383
    /**
384 8
     * @param string $oldText
385
     * @param string $newText
386
     *
387
     * @return string
388
     */
389
    protected function diffTables($oldText, $newText)
390
    {
391
        $diff = TableDiff::create($oldText, $newText, $this->config);
392
393 1
        return $diff->build();
394
    }
395 1
396
    protected function diffElementsByAttribute($oldText, $newText, $attribute, $element)
397 1
    {
398
        $oldAttribute = $this->getAttributeFromTag($oldText, $attribute);
399
        $newAttribute = $this->getAttributeFromTag($newText, $attribute);
400 1
401
        if ($oldAttribute !== $newAttribute) {
402 1
            $diffClass = sprintf('diffmod diff%s diff%s', $element, $attribute);
403 1
404
            return sprintf(
405 1
                '%s%s',
406 1
                $this->wrapText($oldText, 'del', $diffClass),
407
                $this->wrapText($newText, 'ins', $diffClass)
408 1
            );
409 1
        }
410 1
411 1
        return $this->diffElements($oldText, $newText);
412
    }
413
414
    /**
415 1
     * @param Operation $operation
416
     */
417
    protected function processEqualOperation($operation)
418
    {
419
        $result = array();
420
        foreach ($this->newWords as $pos => $s) {
421 18
            if ($pos >= $operation->startInNew && $pos < $operation->endInNew) {
422
                if ($this->config->isIsolatedDiffTagPlaceholder($s) && isset($this->newIsolatedDiffTags[$pos])) {
423 18
                    $result[] = $this->diffIsolatedPlaceholder($operation, $pos, $s);
424 18
                } else {
425 18
                    $result[] = $s;
426 18
                }
427 15
            }
428
        }
429 14
        $this->content .= implode('', $result);
430
    }
431
432
    /**
433 18
     * @param string $text
434 18
     * @param string $attribute
435
     *
436
     * @return null|string
437
     */
438
    protected function getAttributeFromTag($text, $attribute)
439
    {
440
        $matches = array();
441
        if (preg_match(sprintf('/<[^>]*\b%s\s*=\s*([\'"])(.*)\1[^>]*>/iu', $attribute), $text, $matches)) {
442 1
            return htmlspecialchars_decode($matches[2]);
443
        }
444 1
445 1
        return;
446 1
    }
447
448
    /**
449
     * @param string $text
450
     *
451
     * @return bool
452
     */
453
    protected function isListPlaceholder($text)
454
    {
455
        return $this->isPlaceholderType($text, array('ol', 'dl', 'ul'));
456
    }
457 15
458
    /**
459 15
     * @param string $text
460
     *
461
     * @return bool
462
     */
463
    public function isLinkPlaceholder($text)
464
    {
465
        return $this->isPlaceholderType($text, 'a');
466
    }
467 10
468
    /**
469 10
     * @param string $text
470
     *
471
     * @return bool
472
     */
473
    public function isImagePlaceholder($text)
474
    {
475
        return $this->isPlaceholderType($text, 'img');
476
    }
477 9
478
    /**
479 9
     * @param string       $text
480
     * @param array|string $types
481
     * @param bool         $strict
482
     *
483
     * @return bool
484
     */
485
    protected function isPlaceholderType($text, $types, $strict = true)
486
    {
487
        if (!is_array($types)) {
488
            $types = array($types);
489 15
        }
490
491 15
        $criteria = array();
492 11
        foreach ($types as $type) {
493
            if ($this->config->isIsolatedDiffTag($type)) {
494
                $criteria[] = $this->config->getIsolatedDiffTagPlaceholder($type);
495 15
            } else {
496 15
                $criteria[] = $type;
497 15
            }
498 15
        }
499
500
        return in_array($text, $criteria, $strict);
501
    }
502
503
    /**
504 15
     * @param string $text
505
     *
506
     * @return bool
507
     */
508
    protected function isTablePlaceholder($text)
509
    {
510
        return $this->isPlaceholderType($text, 'table');
511
    }
512 11
513
    /**
514 11
     * @param Operation $operation
515
     * @param int       $posInNew
516
     *
517
     * @return array
518
     */
519
    protected function findIsolatedDiffTagsInOld($operation, $posInNew)
520
    {
521
        $offset = $posInNew - $operation->startInNew;
522
523 15
        return $this->oldIsolatedDiffTags[$operation->startInOld + $offset];
524
    }
525 15
526
    /**
527 15
     * @param string $tag
528
     * @param string $cssClass
529
     * @param array  $words
530
     */
531
    protected function insertTag($tag, $cssClass, &$words)
532
    {
533
        while (true) {
534
            if (count($words) === 0) {
535 15
                break;
536
            }
537 15
538 15
            $nonTags = $this->extractConsecutiveWords($words, 'noTag');
539 9
540
            $specialCaseTagInjection = '';
541
            $specialCaseTagInjectionIsBefore = false;
542 15
543
            if (count($nonTags) !== 0) {
544 15
                $this->content .= $this->wrapText(implode('', $nonTags), $tag, $cssClass);
545 15
            } else {
546
                $firstOrDefault = false;
547 15
                foreach ($this->config->getSpecialCaseOpeningTags() as $x) {
548 15
                    if (preg_match($x, $words[ 0 ])) {
549
                        $firstOrDefault = $x;
550 7
                        break;
551 7
                    }
552
                }
553
                if ($firstOrDefault) {
554
                    $specialCaseTagInjection = '<ins class="mod">';
555
                    if ($tag === 'del') {
556
                        unset($words[ 0 ]);
557 7
                    }
558
                } elseif (array_search($words[ 0 ], $this->config->getSpecialCaseClosingTags()) !== false) {
559
                    $specialCaseTagInjection = '</ins>';
560
                    $specialCaseTagInjectionIsBefore = true;
561
                    if ($tag === 'del') {
562 7
                        unset($words[ 0 ]);
563
                    }
564
                }
565
            }
566
            if (count($words) == 0 && $this->stringUtil->strlen($specialCaseTagInjection) == 0) {
567
                break;
568
            }
569
            if ($specialCaseTagInjectionIsBefore) {
570 15
                $this->content .= $specialCaseTagInjection . implode('', $this->extractConsecutiveWords($words, 'tag'));
571 14
            } else {
572
                $workTag = $this->extractConsecutiveWords($words, 'tag');
573 9
574
                if (
575
                    isset($workTag[0]) === true &&
576 9
                    $this->isOpeningTag($workTag[0]) === true &&
577
                    $this->isClosingTag($workTag[0]) === false
578
                ) {
579 9
                    if ($this->stringUtil->strpos($workTag[0], 'class=')) {
580 9
                        $workTag[0] = str_replace('class="', 'class="diffmod ', $workTag[0]);
581 9
                    } else {
582
                        $isSelfClosing = $this->stringUtil->strpos($workTag[0], '/>') !== false;
583 9
584 2
                        if ($isSelfClosing === true) {
585
                            $workTag[0] = str_replace('/>', ' class="diffmod" />', $workTag[0]);
586 9
                        } else {
587
                            $workTag[0] = str_replace('>', ' class="diffmod">', $workTag[0]);
588 9
                        }
589 5
                    }
590
                }
591 8
592
                $appendContent = implode('', $workTag) . $specialCaseTagInjection;
593
594
                if (isset($workTag[0]) === true && $this->stringUtil->stripos($workTag[0], '<img') !== false) {
595
                    $appendContent = $this->wrapText($appendContent, $tag, $cssClass);
596 9
                }
597
598 9
                $this->content .= $appendContent;
599
            }
600
        }
601
    }
602 9
603
    /**
604
     * @param string $word
605 15
     * @param string $condition
606
     *
607
     * @return bool
608
     */
609
    protected function checkCondition($word, $condition)
610
    {
611
        return $condition == 'tag' ? $this->isTag($word) : !$this->isTag($word);
612
    }
613 15
614
    protected function wrapText(string $text, string $tagName, string $cssClass) : string
615 15
    {
616
        if (trim($text) === '') {
617
            return '';
618 16
        }
619
620 16
        return sprintf('<%1$s class="%2$s">%3$s</%1$s>', $tagName, $cssClass, $text);
621 7
    }
622
623
    /**
624 16
     * @param array  $words
625
     * @param string $condition
626
     *
627
     * @return array
628
     */
629
    protected function extractConsecutiveWords(&$words, $condition)
630
    {
631
        $indexOfFirstTag = null;
632
        $words = array_values($words);
633 15
        foreach ($words as $i => $word) {
634
            if (!$this->checkCondition($word, $condition)) {
635 15
                $indexOfFirstTag = $i;
636 15
                break;
637 15
            }
638 15
        }
639 9
        if ($indexOfFirstTag !== null) {
640 9
            $items = array();
641
            foreach ($words as $pos => $s) {
642
                if ($pos >= 0 && $pos < $indexOfFirstTag) {
643 15
                    $items[] = $s;
644 9
                }
645 9
            }
646 9
            if ($indexOfFirstTag > 0) {
647 9
                array_splice($words, 0, $indexOfFirstTag);
648
            }
649
650 9
            return $items;
651 9
        } else {
652
            $items = array();
653
            foreach ($words as $pos => $s) {
654 9
                if ($pos >= 0 && $pos <= count($words)) {
655
                    $items[] = $s;
656 15
                }
657 15
            }
658 15
            array_splice($words, 0, count($words));
659 15
660
            return $items;
661
        }
662 15
    }
663
664 15
    /**
665
     * @param string $item
666
     *
667
     * @return bool
668
     */
669
    protected function isTag($item)
670
    {
671
        return $this->isOpeningTag($item) || $this->isClosingTag($item);
672
    }
673 18
674
    protected function isOpeningTag($item) : bool
675 18
    {
676
        return preg_match('#<[^>]+>\\s*#iUu', $item) === 1;
677
    }
678 18
679
    protected function isClosingTag($item) : bool
680 18
    {
681
        return preg_match('#</[^>]+>\\s*#iUu', $item) === 1;
682
    }
683 18
684
    /**
685 18
     * @return Operation[]
686
     */
687
    protected function operations()
688
    {
689
        $positionInOld = 0;
690
        $positionInNew = 0;
691 18
        $operations = array();
692
693 18
        $matches   = $this->matchingBlocks();
694 18
        $matches[] = new MatchingBlock(count($this->oldWords), count($this->newWords), 0);
695 18
696
        foreach ($matches as $match) {
697 18
            $matchStartsAtCurrentPositionInOld = ($positionInOld === $match->startInOld);
698 18
            $matchStartsAtCurrentPositionInNew = ($positionInNew === $match->startInNew);
699
700 18
            if ($matchStartsAtCurrentPositionInOld === false && $matchStartsAtCurrentPositionInNew === false) {
701 18
                $action = 'replace';
702 18
            } elseif ($matchStartsAtCurrentPositionInOld === true && $matchStartsAtCurrentPositionInNew === false) {
703
                $action = 'insert';
704 18
            } elseif ($matchStartsAtCurrentPositionInOld === false && $matchStartsAtCurrentPositionInNew === true) {
705 11
                $action = 'delete';
706 18
            } else { // This occurs if the first few words are the same in both versions
707 10
                $action = 'none';
708 18
            }
709 4
710
            if ($action !== 'none') {
711 18
                $operations[] = new Operation($action, $positionInOld, $match->startInOld, $positionInNew, $match->startInNew);
712
            }
713
714 18
            if (count($match) !== 0) {
715 15
                $operations[] = new Operation('equal', $match->startInOld, $match->endInOld(), $match->startInNew, $match->endInNew());
716
            }
717
718 18
            $positionInOld = $match->endInOld();
719 18
            $positionInNew = $match->endInNew();
720
        }
721
722 18
        return $operations;
723 18
    }
724
725
    /**
726 18
     * @return MatchingBlock[]
727
     */
728
    protected function matchingBlocks()
729
    {
730
        $matchingBlocks = array();
731
        $this->findMatchingBlocks(0, count($this->oldWords), 0, count($this->newWords), $matchingBlocks);
732 18
733
        return $matchingBlocks;
734 18
    }
735 18
736
    /**
737 18
     * @param MatchingBlock[] $matchingBlocks
738
     */
739
    protected function findMatchingBlocks(int $startInOld, int $endInOld, int $startInNew, int $endInNew, array &$matchingBlocks) : void
740
    {
741
        $match = $this->findMatch($startInOld, $endInOld, $startInNew, $endInNew);
742
743
        if ($match === null) {
744
            return;
745
        }
746
747 18
        if ($startInOld < $match->startInOld && $startInNew < $match->startInNew) {
748
            $this->findMatchingBlocks($startInOld, $match->startInOld, $startInNew, $match->startInNew, $matchingBlocks);
749 18
        }
750
751 18
        $matchingBlocks[] = $match;
752 18
753 9
        if ($match->endInOld() < $endInOld && $match->endInNew() < $endInNew) {
754
            $this->findMatchingBlocks($match->endInOld(), $endInOld, $match->endInNew(), $endInNew, $matchingBlocks);
755
        }
756 18
    }
757
758 18
    /**
759 11
     * @param string $word
760
     *
761
     * @return string
762 18
     */
763
    protected function stripTagAttributes($word)
764
    {
765
        $space = $this->stringUtil->strpos($word, ' ', 1);
766
767
        if ($space > 0) {
768
            return '<' . $this->stringUtil->substr($word, 1, $space) . '>';
769 10
        }
770
771 10
        return trim($word, '<>');
772
    }
773 10
774 7
    protected function findMatch(int $startInOld, int $endInOld, int $startInNew, int $endInNew) : ?MatchingBlock
775
    {
776
        $groupDiffs     = $this->isGroupDiffs();
0 ignored issues
show
Deprecated Code introduced by
The method Caxy\HtmlDiff\AbstractDiff::isGroupDiffs() has been deprecated with message: since 0.1.0

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
777 7
        $bestMatchInOld = $startInOld;
778
        $bestMatchInNew = $startInNew;
779
        $bestMatchSize = 0;
780
        $matchLengthAt = [];
781
782
        for ($indexInOld = $startInOld; $indexInOld < $endInOld; ++$indexInOld) {
783
            $newMatchLengthAt = [];
784
785
            $index = $this->oldWords[ $indexInOld ];
786
787
            if ($this->isTag($index) === true) {
788 18
                $index = $this->stripTagAttributes($index);
789
            }
790 18
791 18
            if (isset($this->wordIndices[$index]) === false) {
792 18
                $matchLengthAt = $newMatchLengthAt;
793 18
794 18
                continue;
795
            }
796 18
797 18
            foreach ($this->wordIndices[$index] as $indexInNew) {
798 18
                if ($indexInNew < $startInNew) {
799 18
                    continue;
800 7
                }
801
802 18
                if ($indexInNew >= $endInNew) {
803 13
                    break;
804 13
                }
805
806 18
                $newMatchLength =
807 18
                    (isset($matchLengthAt[$indexInNew - 1]) === true ? ($matchLengthAt[$indexInNew - 1] + 1) : 1);
808 8
809
                $newMatchLengthAt[$indexInNew] = $newMatchLength;
810 18
811 9
                if ($newMatchLength > $bestMatchSize ||
812
                    (
813
                        $groupDiffs === true &&
814 18
                        $bestMatchSize > 0 &&
815 18
                        $this->oldTextIsOnlyWhitespace($bestMatchInOld, $bestMatchSize) === true
816
                    )
817 18
                ) {
818
                    $bestMatchInOld = $indexInOld - $newMatchLength + 1;
819 13
                    $bestMatchInNew = $indexInNew - $newMatchLength + 1;
820 13
                    $bestMatchSize  = $newMatchLength;
821 18
                }
822
            }
823
824 18
            $matchLengthAt = $newMatchLengthAt;
825 18
        }
826 18
827
        // Skip match if none found or match consists only of whitespace
828
        if ($bestMatchSize !== 0 &&
829 18
            (
830
                $groupDiffs === false ||
831
                $this->oldTextIsOnlyWhitespace($bestMatchInOld, $bestMatchSize) === false
832
            )
833 18
        ) {
834
            return new MatchingBlock($bestMatchInOld, $bestMatchInNew, $bestMatchSize);
835 18
        }
836 18
837
        return null;
838
    }
839 18
840
    protected function oldTextIsOnlyWhitespace(int $startingAtWord, int $wordCount) : bool
841
    {
842 11
        $isWhitespace = true;
843
844
        // oldTextIsWhitespace get called consecutively by findMatch, with the same parameters.
845
        // by caching the previous result, we speed up the algorithm by more then 50%
846
        static $lastStartingWordOffset = null;
847
        static $lastWordCount          = null;
848
        static $cache                  = null;
849
850 18
        if ($this->resetCache === true) {
851
            $cache = null;
852
853 18
            $this->resetCache = false;
854
        }
855
856
        if (
857
            $cache !== null &&
858
            $lastWordCount === $wordCount &&
859
            $lastStartingWordOffset === $startingAtWord
860
        ) { // Hit
861
            return $cache;
862
        } // Miss
863
864
        for ($index = $startingAtWord; $index < ($startingAtWord + $wordCount); $index++) {
865
            // Assigning the oldWord to a variable is slightly faster then searching by reference twice
866
            // in the if statement
867
            $oldWord = $this->oldWords[$index];
868
869
            if ($oldWord !== '' && trim($oldWord) !== '') {
870
                $isWhitespace = false;
871 18
872
                break;
873 18
            }
874 18
        }
875 18
876
        $lastWordCount          = $wordCount;
877
        $lastStartingWordOffset = $startingAtWord;
878
879 18
        $cache = $isWhitespace;
880 18
881
        return $cache;
882 18
    }
883
}
884