Passed
Push — master ( 6cbc63...08e8a6 )
by Sven
01:10 queued 10s
created

lib/Caxy/HtmlDiff/HtmlDiff.php (13 issues)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

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
     * @var array
18
     */
19
    protected $oldTables;
20
    /**
21
     * @var array
22
     */
23
    protected $newTables;
24
    /**
25
     * @var array
26
     */
27
    protected $newIsolatedDiffTags;
28
    /**
29
     * @var array
30
     */
31
    protected $oldIsolatedDiffTags;
32
33
    /**
34
     * @param string              $oldText
35
     * @param string              $newText
36
     * @param HtmlDiffConfig|null $config
37
     *
38
     * @return self
39
     */
40 15 View Code Duplication
    public static function create($oldText, $newText, HtmlDiffConfig $config = null)
0 ignored issues
show
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
41
    {
42 15
        $diff = new self($oldText, $newText);
43
44 15
        if (null !== $config) {
45 15
            $diff->setConfig($config);
46
        }
47
48 15
        return $diff;
49
    }
50
51
    /**
52
     * @param $bool
53
     *
54
     * @return $this
55
     *
56
     * @deprecated since 0.1.0
57
     */
58
    public function setUseTableDiffing($bool)
59
    {
60
        $this->config->setUseTableDiffing($bool);
61
62
        return $this;
63
    }
64
65
    /**
66
     * @param bool $boolean
67
     *
68
     * @return HtmlDiff
69
     *
70
     * @deprecated since 0.1.0
71
     */
72
    public function setInsertSpaceInReplace($boolean)
73
    {
74
        $this->config->setInsertSpaceInReplace($boolean);
75
76
        return $this;
77
    }
78
79
    /**
80
     * @return bool
81
     *
82
     * @deprecated since 0.1.0
83
     */
84
    public function getInsertSpaceInReplace()
85
    {
86
        return $this->config->isInsertSpaceInReplace();
87
    }
88
89
    /**
90
     * @return string
91
     */
92 18
    public function build()
93
    {
94 18
        $this->prepare();
95
96 18 View Code Duplication
        if ($this->hasDiffCache() && $this->getDiffCache()->contains($this->oldText, $this->newText)) {
0 ignored issues
show
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
97
            $this->content = $this->getDiffCache()->fetch($this->oldText, $this->newText);
98
99
            return $this->content;
100
        }
101
102
        // Pre-processing Optimizations
103
104
        // 1. Equality
105 18
        if ($this->oldText == $this->newText) {
106 11
            return $this->newText;
107
        }
108
109 18
        $this->splitInputsToWords();
110 18
        $this->replaceIsolatedDiffTags();
111 18
        $this->indexNewWords();
112
113 18
        $operations = $this->operations();
114
115 18
        foreach ($operations as $item) {
116 18
            $this->performOperation($item);
117
        }
118
119 18
        if ($this->hasDiffCache()) {
120
            $this->getDiffCache()->save($this->oldText, $this->newText, $this->content);
121
        }
122
123 18
        return $this->content;
124
    }
125
126 18
    protected function indexNewWords()
127
    {
128 18
        $this->wordIndices = array();
129 18
        foreach ($this->newWords as $i => $word) {
130 18
            if ($this->isTag($word)) {
131 10
                $word = $this->stripTagAttributes($word);
132
            }
133 18
            if (isset($this->wordIndices[ $word ])) {
134 13
                $this->wordIndices[ $word ][] = $i;
135
            } else {
136 18
                $this->wordIndices[ $word ] = array($i);
137
            }
138
        }
139 18
    }
140
141 18
    protected function replaceIsolatedDiffTags()
142
    {
143 18
        $this->oldIsolatedDiffTags = $this->createIsolatedDiffTagPlaceholders($this->oldWords);
144 18
        $this->newIsolatedDiffTags = $this->createIsolatedDiffTagPlaceholders($this->newWords);
145 18
    }
146
147
    /**
148
     * @param array $words
149
     *
150
     * @return array
151
     */
152 18
    protected function createIsolatedDiffTagPlaceholders(&$words)
153
    {
154 18
        $openIsolatedDiffTags = 0;
155 18
        $isolatedDiffTagIndices = array();
156 18
        $isolatedDiffTagStart = 0;
157 18
        $currentIsolatedDiffTag = null;
158 18
        foreach ($words as $index => $word) {
159 18
            $openIsolatedDiffTag = $this->isOpeningIsolatedDiffTag($word, $currentIsolatedDiffTag);
160 18
            if ($openIsolatedDiffTag) {
161 16
                if ($this->isSelfClosingTag($word) || $this->stringUtil->stripos($word, '<img') !== false) {
162 View Code Duplication
                    if ($openIsolatedDiffTags === 0) {
0 ignored issues
show
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
163
                        $isolatedDiffTagIndices[] = array(
164
                            'start' => $index,
165
                            'length' => 1,
166
                            'tagType' => $openIsolatedDiffTag,
167
                        );
168
                        $currentIsolatedDiffTag = null;
169
                    }
170
                } else {
171 16
                    if ($openIsolatedDiffTags === 0) {
172 16
                        $isolatedDiffTagStart = $index;
173
                    }
174 16
                    ++$openIsolatedDiffTags;
175 16
                    $currentIsolatedDiffTag = $openIsolatedDiffTag;
176
                }
177 18
            } elseif ($openIsolatedDiffTags > 0 && $this->isClosingIsolatedDiffTag($word, $currentIsolatedDiffTag)) {
178 16
                --$openIsolatedDiffTags;
179 16 View Code Duplication
                if ($openIsolatedDiffTags == 0) {
0 ignored issues
show
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
180 16
                    $isolatedDiffTagIndices[] = array('start' => $isolatedDiffTagStart, 'length' => $index - $isolatedDiffTagStart + 1, 'tagType' => $currentIsolatedDiffTag);
181 16
                    $currentIsolatedDiffTag = null;
182
                }
183
            }
184
        }
185 18
        $isolatedDiffTagScript = array();
186 18
        $offset = 0;
187 18
        foreach ($isolatedDiffTagIndices as $isolatedDiffTagIndex) {
188 16
            $start = $isolatedDiffTagIndex['start'] - $offset;
189 16
            $placeholderString = $this->config->getIsolatedDiffTagPlaceholder($isolatedDiffTagIndex['tagType']);
190 16
            $isolatedDiffTagScript[$start] = array_splice($words, $start, $isolatedDiffTagIndex['length'], $placeholderString);
191 16
            $offset += $isolatedDiffTagIndex['length'] - 1;
192
        }
193
194 18
        return $isolatedDiffTagScript;
195
    }
196
197
    /**
198
     * @param string      $item
199
     * @param null|string $currentIsolatedDiffTag
200
     *
201
     * @return false|string
202
     */
203 18 View Code Duplication
    protected function isOpeningIsolatedDiffTag($item, $currentIsolatedDiffTag = null)
0 ignored issues
show
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
204
    {
205 18
        $tagsToMatch = $currentIsolatedDiffTag !== null
206 16
            ? array($currentIsolatedDiffTag => $this->config->getIsolatedDiffTagPlaceholder($currentIsolatedDiffTag))
207 18
            : $this->config->getIsolatedDiffTags();
208 18
        $pattern = '#<%s(\s+[^>]*)?>#iUu';
209 18
        foreach ($tagsToMatch as $key => $value) {
210 18
            if (preg_match(sprintf($pattern, $key), $item)) {
211 16
                return $key;
212
            }
213
        }
214
215 18
        return false;
216
    }
217
218 16
    protected function isSelfClosingTag($text)
219
    {
220 16
        return (bool) preg_match('/<[^>]+\/\s*>/u', $text);
221
    }
222
223
    /**
224
     * @param string      $item
225
     * @param null|string $currentIsolatedDiffTag
226
     *
227
     * @return false|string
228
     */
229 16 View Code Duplication
    protected function isClosingIsolatedDiffTag($item, $currentIsolatedDiffTag = null)
0 ignored issues
show
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
230
    {
231 16
        $tagsToMatch = $currentIsolatedDiffTag !== null
232 16
            ? array($currentIsolatedDiffTag => $this->config->getIsolatedDiffTagPlaceholder($currentIsolatedDiffTag))
233 16
            : $this->config->getIsolatedDiffTags();
234 16
        $pattern = '#</%s(\s+[^>]*)?>#iUu';
235 16
        foreach ($tagsToMatch as $key => $value) {
236 16
            if (preg_match(sprintf($pattern, $key), $item)) {
237 16
                return $key;
238
            }
239
        }
240
241 16
        return false;
242
    }
243
244
    /**
245
     * @param Operation $operation
246
     */
247 18
    protected function performOperation($operation)
248
    {
249 18
        switch ($operation->action) {
250 18
            case 'equal' :
251 18
            $this->processEqualOperation($operation);
252 18
            break;
253 15
            case 'delete' :
254 4
            $this->processDeleteOperation($operation, 'diffdel');
255 4
            break;
256 15
            case 'insert' :
257 10
            $this->processInsertOperation($operation, 'diffins');
258 10
            break;
259 11
            case 'replace':
260 11
            $this->processReplaceOperation($operation);
261 11
            break;
262
            default:
263
            break;
264
        }
265 18
    }
266
267
    /**
268
     * @param Operation $operation
269
     */
270 11
    protected function processReplaceOperation($operation)
271
    {
272 11
        $this->processDeleteOperation($operation, 'diffmod');
273 11
        $this->processInsertOperation($operation, 'diffmod');
274 11
    }
275
276
    /**
277
     * @param Operation $operation
278
     * @param string    $cssClass
279
     */
280 15 View Code Duplication
    protected function processInsertOperation($operation, $cssClass)
0 ignored issues
show
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
281
    {
282 15
        $text = array();
283 15
        foreach ($this->newWords as $pos => $s) {
284 15
            if ($pos >= $operation->startInNew && $pos < $operation->endInNew) {
285 15
                if ($this->config->isIsolatedDiffTagPlaceholder($s) && isset($this->newIsolatedDiffTags[$pos])) {
286 4
                    foreach ($this->newIsolatedDiffTags[$pos] as $word) {
287 4
                        $text[] = $word;
288
                    }
289
                } else {
290 15
                    $text[] = $s;
291
                }
292
            }
293
        }
294
295 15
        $this->insertTag('ins', $cssClass, $text);
296 15
    }
297
298
    /**
299
     * @param Operation $operation
300
     * @param string    $cssClass
301
     */
302 13 View Code Duplication
    protected function processDeleteOperation($operation, $cssClass)
0 ignored issues
show
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
303
    {
304 13
        $text = array();
305 13
        foreach ($this->oldWords as $pos => $s) {
306 13
            if ($pos >= $operation->startInOld && $pos < $operation->endInOld) {
307 13
                if ($this->config->isIsolatedDiffTagPlaceholder($s) && isset($this->oldIsolatedDiffTags[$pos])) {
308 6
                    foreach ($this->oldIsolatedDiffTags[$pos] as $word) {
309 6
                        $text[] = $word;
310
                    }
311
                } else {
312 13
                    $text[] = $s;
313
                }
314
            }
315
        }
316 13
        $this->insertTag('del', $cssClass, $text);
317 13
    }
318
319
    /**
320
     * @param Operation $operation
321
     * @param int       $pos
322
     * @param string    $placeholder
323
     * @param bool      $stripWrappingTags
324
     *
325
     * @return string
326
     */
327 15
    protected function diffIsolatedPlaceholder($operation, $pos, $placeholder, $stripWrappingTags = true)
328
    {
329 15
        $oldText = implode('', $this->findIsolatedDiffTagsInOld($operation, $pos));
330 15
        $newText = implode('', $this->newIsolatedDiffTags[$pos]);
331
332 15
        if ($this->isListPlaceholder($placeholder)) {
333 8
            return $this->diffList($oldText, $newText);
334 11
        } elseif ($this->config->isUseTableDiffing() && $this->isTablePlaceholder($placeholder)) {
335 1
            return $this->diffTables($oldText, $newText);
336 10
        } elseif ($this->isLinkPlaceholder($placeholder)) {
337 1
            return $this->diffElementsByAttribute($oldText, $newText, 'href', 'a');
338 9
        } elseif ($this->isImagePlaceholder($placeholder)) {
339
            return $this->diffElementsByAttribute($oldText, $newText, 'src', 'img');
340
        }
341
342 9
        return $this->diffElements($oldText, $newText, $stripWrappingTags);
343
    }
344
345
    /**
346
     * @param string $oldText
347
     * @param string $newText
348
     * @param bool   $stripWrappingTags
349
     *
350
     * @return string
351
     */
352 10
    protected function diffElements($oldText, $newText, $stripWrappingTags = true)
353
    {
354 10
        $wrapStart = '';
355 10
        $wrapEnd = '';
356
357 10
        if ($stripWrappingTags) {
358 10
            $pattern = '/(^<[^>]+>)|(<\/[^>]+>$)/iu';
359 10
            $matches = array();
360
361 10
            if (preg_match_all($pattern, $newText, $matches)) {
362 10
                $wrapStart = isset($matches[0][0]) ? $matches[0][0] : '';
363 10
                $wrapEnd = isset($matches[0][1]) ? $matches[0][1] : '';
364
            }
365 10
            $oldText = preg_replace($pattern, '', $oldText);
366 10
            $newText = preg_replace($pattern, '', $newText);
367
        }
368
369 10
        $diff = self::create($oldText, $newText, $this->config);
370
371 10
        return $wrapStart.$diff->build().$wrapEnd;
372
    }
373
374
    /**
375
     * @param string $oldText
376
     * @param string $newText
377
     *
378
     * @return string
379
     */
380 8
    protected function diffList($oldText, $newText)
381
    {
382 8
        $diff = ListDiffLines::create($oldText, $newText, $this->config);
383
384 8
        return $diff->build();
385
    }
386
387
    /**
388
     * @param string $oldText
389
     * @param string $newText
390
     *
391
     * @return string
392
     */
393 1
    protected function diffTables($oldText, $newText)
394
    {
395 1
        $diff = TableDiff::create($oldText, $newText, $this->config);
396
397 1
        return $diff->build();
398
    }
399
400 1
    protected function diffElementsByAttribute($oldText, $newText, $attribute, $element)
401
    {
402 1
        $oldAttribute = $this->getAttributeFromTag($oldText, $attribute);
403 1
        $newAttribute = $this->getAttributeFromTag($newText, $attribute);
404
405 1
        if ($oldAttribute !== $newAttribute) {
406 1
            $diffClass = sprintf('diffmod diff%s diff%s', $element, $attribute);
407
408 1
            return sprintf(
409 1
                '%s%s',
410 1
                $this->wrapText($oldText, 'del', $diffClass),
411 1
                $this->wrapText($newText, 'ins', $diffClass)
412
            );
413
        }
414
415 1
        return $this->diffElements($oldText, $newText);
416
    }
417
418
    /**
419
     * @param Operation $operation
420
     */
421 18 View Code Duplication
    protected function processEqualOperation($operation)
0 ignored issues
show
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
422
    {
423 18
        $result = array();
424 18
        foreach ($this->newWords as $pos => $s) {
425 18
            if ($pos >= $operation->startInNew && $pos < $operation->endInNew) {
426 18
                if ($this->config->isIsolatedDiffTagPlaceholder($s) && isset($this->newIsolatedDiffTags[$pos])) {
427 15
                    $result[] = $this->diffIsolatedPlaceholder($operation, $pos, $s);
428
                } else {
429 14
                    $result[] = $s;
430
                }
431
            }
432
        }
433 18
        $this->content .= implode('', $result);
434 18
    }
435
436
    /**
437
     * @param string $text
438
     * @param string $attribute
439
     *
440
     * @return null|string
441
     */
442 1
    protected function getAttributeFromTag($text, $attribute)
443
    {
444 1
        $matches = array();
445 1
        if (preg_match(sprintf('/<[^>]*\b%s\s*=\s*([\'"])(.*)\1[^>]*>/iu', $attribute), $text, $matches)) {
446 1
            return htmlspecialchars_decode($matches[2]);
447
        }
448
449
        return;
450
    }
451
452
    /**
453
     * @param string $text
454
     *
455
     * @return bool
456
     */
457 15
    protected function isListPlaceholder($text)
458
    {
459 15
        return $this->isPlaceholderType($text, array('ol', 'dl', 'ul'));
460
    }
461
462
    /**
463
     * @param string $text
464
     *
465
     * @return bool
466
     */
467 10
    public function isLinkPlaceholder($text)
468
    {
469 10
        return $this->isPlaceholderType($text, 'a');
470
    }
471
472
    /**
473
     * @param string $text
474
     *
475
     * @return bool
476
     */
477 9
    public function isImagePlaceholder($text)
478
    {
479 9
        return $this->isPlaceholderType($text, 'img');
480
    }
481
482
    /**
483
     * @param string       $text
484
     * @param array|string $types
485
     * @param bool         $strict
486
     *
487
     * @return bool
488
     */
489 15
    protected function isPlaceholderType($text, $types, $strict = true)
490
    {
491 15
        if (!is_array($types)) {
492 11
            $types = array($types);
493
        }
494
495 15
        $criteria = array();
496 15
        foreach ($types as $type) {
497 15
            if ($this->config->isIsolatedDiffTag($type)) {
498 15
                $criteria[] = $this->config->getIsolatedDiffTagPlaceholder($type);
499
            } else {
500
                $criteria[] = $type;
501
            }
502
        }
503
504 15
        return in_array($text, $criteria, $strict);
505
    }
506
507
    /**
508
     * @param string $text
509
     *
510
     * @return bool
511
     */
512 11
    protected function isTablePlaceholder($text)
513
    {
514 11
        return $this->isPlaceholderType($text, 'table');
515
    }
516
517
    /**
518
     * @param Operation $operation
519
     * @param int       $posInNew
520
     *
521
     * @return array
522
     */
523 15
    protected function findIsolatedDiffTagsInOld($operation, $posInNew)
524
    {
525 15
        $offset = $posInNew - $operation->startInNew;
526
527 15
        return $this->oldIsolatedDiffTags[$operation->startInOld + $offset];
528
    }
529
530
    /**
531
     * @param string $tag
532
     * @param string $cssClass
533
     * @param array  $words
534
     */
535 15
    protected function insertTag($tag, $cssClass, &$words)
536
    {
537 15
        while (true) {
538 15
            if (count($words) === 0) {
539 9
                break;
540
            }
541
542 15
            $nonTags = $this->extractConsecutiveWords($words, 'noTag');
543
544 15
            $specialCaseTagInjection = '';
545 15
            $specialCaseTagInjectionIsBefore = false;
546
547 15
            if (count($nonTags) !== 0) {
548 15
                $this->content .= $this->wrapText(implode('', $nonTags), $tag, $cssClass);
549
            } else {
550 7
                $firstOrDefault = false;
551 7
                foreach ($this->config->getSpecialCaseOpeningTags() as $x) {
552
                    if (preg_match($x, $words[ 0 ])) {
553
                        $firstOrDefault = $x;
554
                        break;
555
                    }
556
                }
557 7
                if ($firstOrDefault) {
558
                    $specialCaseTagInjection = '<ins class="mod">';
559
                    if ($tag === 'del') {
560
                        unset($words[ 0 ]);
561
                    }
562 7
                } elseif (array_search($words[ 0 ], $this->config->getSpecialCaseClosingTags()) !== false) {
563
                    $specialCaseTagInjection = '</ins>';
564
                    $specialCaseTagInjectionIsBefore = true;
565
                    if ($tag === 'del') {
566
                        unset($words[ 0 ]);
567
                    }
568
                }
569
            }
570 15
            if (count($words) == 0 && $this->stringUtil->strlen($specialCaseTagInjection) == 0) {
571 14
                break;
572
            }
573 9
            if ($specialCaseTagInjectionIsBefore) {
574
                $this->content .= $specialCaseTagInjection . implode('', $this->extractConsecutiveWords($words, 'tag'));
575
            } else {
576 9
                $workTag = $this->extractConsecutiveWords($words, 'tag');
577
578
                if (
579 9
                    isset($workTag[0]) === true &&
580 9
                    $this->isOpeningTag($workTag[0]) === true &&
581 9
                    $this->isClosingTag($workTag[0]) === false
582
                ) {
583 9
                    if ($this->stringUtil->strpos($workTag[0], 'class=')) {
584 2
                        $workTag[0] = str_replace('class="', 'class="diffmod ', $workTag[0]);
585
                    } else {
586 9
                        $isSelfClosing = $this->stringUtil->strpos($workTag[0], '/>') !== false;
587
588 9
                        if ($isSelfClosing === true) {
589 5
                            $workTag[0] = str_replace('/>', ' class="diffmod" />', $workTag[0]);
590
                        } else {
591 8
                            $workTag[0] = str_replace('>', ' class="diffmod">', $workTag[0]);
592
                        }
593
                    }
594
                }
595
596 9
                $appendContent = implode('', $workTag) . $specialCaseTagInjection;
597
598 9
                if (isset($workTag[0]) === true && $this->stringUtil->stripos($workTag[0], '<img') !== false) {
599
                    $appendContent = $this->wrapText($appendContent, $tag, $cssClass);
600
                }
601
602 9
                $this->content .= $appendContent;
603
            }
604
        }
605 15
    }
606
607
    /**
608
     * @param string $word
609
     * @param string $condition
610
     *
611
     * @return bool
612
     */
613 15
    protected function checkCondition($word, $condition)
614
    {
615 15
        return $condition == 'tag' ? $this->isTag($word) : !$this->isTag($word);
616
    }
617
618 16
    protected function wrapText(string $text, string $tagName, string $cssClass) : string
619
    {
620 16
        if (trim($text) === '') {
621 7
            return '';
622
        }
623
624 16
        return sprintf('<%1$s class="%2$s">%3$s</%1$s>', $tagName, $cssClass, $text);
625
    }
626
627
    /**
628
     * @param array  $words
629
     * @param string $condition
630
     *
631
     * @return array
632
     */
633 15
    protected function extractConsecutiveWords(&$words, $condition)
634
    {
635 15
        $indexOfFirstTag = null;
636 15
        $words = array_values($words);
637 15
        foreach ($words as $i => $word) {
638 15
            if (!$this->checkCondition($word, $condition)) {
639 9
                $indexOfFirstTag = $i;
640 9
                break;
641
            }
642
        }
643 15
        if ($indexOfFirstTag !== null) {
644 9
            $items = array();
645 9 View Code Duplication
            foreach ($words as $pos => $s) {
0 ignored issues
show
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
646 9
                if ($pos >= 0 && $pos < $indexOfFirstTag) {
647 9
                    $items[] = $s;
648
                }
649
            }
650 9
            if ($indexOfFirstTag > 0) {
651 9
                array_splice($words, 0, $indexOfFirstTag);
652
            }
653
654 9
            return $items;
655
        } else {
656 15
            $items = array();
657 15 View Code Duplication
            foreach ($words as $pos => $s) {
0 ignored issues
show
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
658 15
                if ($pos >= 0 && $pos <= count($words)) {
659 15
                    $items[] = $s;
660
                }
661
            }
662 15
            array_splice($words, 0, count($words));
663
664 15
            return $items;
665
        }
666
    }
667
668
    /**
669
     * @param string $item
670
     *
671
     * @return bool
672
     */
673 18
    protected function isTag($item)
674
    {
675 18
        return $this->isOpeningTag($item) || $this->isClosingTag($item);
676
    }
677
678 18
    protected function isOpeningTag($item) : bool
679
    {
680 18
        return preg_match('#<[^>]+>\\s*#iUu', $item) === 1;
681
    }
682
683 18
    protected function isClosingTag($item) : bool
684
    {
685 18
        return preg_match('#</[^>]+>\\s*#iUu', $item) === 1;
686
    }
687
688
    /**
689
     * @return Operation[]
690
     */
691 18
    protected function operations()
692
    {
693 18
        $positionInOld = 0;
694 18
        $positionInNew = 0;
695 18
        $operations = array();
696
697 18
        $matches   = $this->matchingBlocks();
698 18
        $matches[] = new MatchingBlock(count($this->oldWords), count($this->newWords), 0);
699
700 18
        foreach ($matches as $match) {
701 18
            $matchStartsAtCurrentPositionInOld = ($positionInOld === $match->startInOld);
702 18
            $matchStartsAtCurrentPositionInNew = ($positionInNew === $match->startInNew);
703
704 18
            if ($matchStartsAtCurrentPositionInOld === false && $matchStartsAtCurrentPositionInNew === false) {
705 11
                $action = 'replace';
706 18
            } elseif ($matchStartsAtCurrentPositionInOld === true && $matchStartsAtCurrentPositionInNew === false) {
707 10
                $action = 'insert';
708 18
            } elseif ($matchStartsAtCurrentPositionInOld === false && $matchStartsAtCurrentPositionInNew === true) {
709 4
                $action = 'delete';
710
            } else { // This occurs if the first few words are the same in both versions
711 18
                $action = 'none';
712
            }
713
714 18
            if ($action !== 'none') {
715 15
                $operations[] = new Operation($action, $positionInOld, $match->startInOld, $positionInNew, $match->startInNew);
716
            }
717
718 18
            if (count($match) !== 0) {
719 18
                $operations[] = new Operation('equal', $match->startInOld, $match->endInOld(), $match->startInNew, $match->endInNew());
720
            }
721
722 18
            $positionInOld = $match->endInOld();
723 18
            $positionInNew = $match->endInNew();
724
        }
725
726 18
        return $operations;
727
    }
728
729
    /**
730
     * @return MatchingBlock[]
731
     */
732 18
    protected function matchingBlocks()
733
    {
734 18
        $matchingBlocks = array();
735 18
        $this->findMatchingBlocks(0, count($this->oldWords), 0, count($this->newWords), $matchingBlocks);
736
737 18
        return $matchingBlocks;
738
    }
739
740
    /**
741
     * @param int   $startInOld
742
     * @param int   $endInOld
743
     * @param int   $startInNew
744
     * @param int   $endInNew
745
     * @param array $matchingBlocks
746
     */
747 18
    protected function findMatchingBlocks($startInOld, $endInOld, $startInNew, $endInNew, &$matchingBlocks)
748
    {
749 18
        $match = $this->findMatch($startInOld, $endInOld, $startInNew, $endInNew);
750
751 18
        if ($match !== null) {
752 18
            if ($startInOld < $match->startInOld && $startInNew < $match->startInNew) {
753 9
                $this->findMatchingBlocks($startInOld, $match->startInOld, $startInNew, $match->startInNew, $matchingBlocks);
754
            }
755
756 18
            $matchingBlocks[] = $match;
757
758 18
            if ($match->endInOld() < $endInOld && $match->endInNew() < $endInNew) {
759 11
                $this->findMatchingBlocks($match->endInOld(), $endInOld, $match->endInNew(), $endInNew, $matchingBlocks);
760
            }
761
        }
762 18
    }
763
764
    /**
765
     * @param string $word
766
     *
767
     * @return string
768
     */
769 10
    protected function stripTagAttributes($word)
770
    {
771 10
        $space = $this->stringUtil->strpos($word, ' ', 1);
772
773 10
        if ($space) {
774 7
            return '<' . $this->stringUtil->substr($word, 1, $space) . '>';
775
        }
776
777 7
        return trim($word, '<>');
778
    }
779
780
    /**
781
     * @param int $startInOld
782
     * @param int $endInOld
783
     * @param int $startInNew
784
     * @param int $endInNew
785
     *
786
     * @return MatchingBlock|null
787
     */
788 18
    protected function findMatch($startInOld, $endInOld, $startInNew, $endInNew)
789
    {
790 18
        $groupDiffs     = $this->isGroupDiffs();
791 18
        $bestMatchInOld = $startInOld;
792 18
        $bestMatchInNew = $startInNew;
793 18
        $bestMatchSize = 0;
794 18
        $matchLengthAt = array();
795
796 18
        for ($indexInOld = $startInOld; $indexInOld < $endInOld; ++$indexInOld) {
797 18
            $newMatchLengthAt = array();
798 18
            $index = $this->oldWords[ $indexInOld ];
799 18
            if ($this->isTag($index)) {
800 7
                $index = $this->stripTagAttributes($index);
801
            }
802 18
            if (!isset($this->wordIndices[ $index ])) {
803 13
                $matchLengthAt = $newMatchLengthAt;
804 13
                continue;
805
            }
806 18
            foreach ($this->wordIndices[ $index ] as $indexInNew) {
807 18
                if ($indexInNew < $startInNew) {
808 8
                    continue;
809
                }
810 18
                if ($indexInNew >= $endInNew) {
811 9
                    break;
812
                }
813
814 18
                $newMatchLength = (isset($matchLengthAt[ $indexInNew - 1 ]) ? $matchLengthAt[ $indexInNew - 1 ] : 0) + 1;
815 18
                $newMatchLengthAt[ $indexInNew ] = $newMatchLength;
816
817 18
                if ($newMatchLength > $bestMatchSize ||
818
                    (
819 13
                        $groupDiffs &&
820 13
                        $bestMatchSize > 0 &&
821 18
                        $this->isOnlyWhitespace($this->array_slice_cached($this->oldWords, $bestMatchInOld, $bestMatchSize))
822
                    )
823
                ) {
824 18
                    $bestMatchInOld = $indexInOld - $newMatchLength + 1;
825 18
                    $bestMatchInNew = $indexInNew - $newMatchLength + 1;
826 18
                    $bestMatchSize = $newMatchLength;
827
                }
828
            }
829 18
            $matchLengthAt = $newMatchLengthAt;
830
        }
831
832
        // Skip match if none found or match consists only of whitespace
833 18
        if ($bestMatchSize !== 0 &&
834
            (
835 18
                !$groupDiffs ||
836 18
                !$this->isOnlyWhitespace($this->array_slice_cached($this->oldWords, $bestMatchInOld, $bestMatchSize))
837
            )
838
        ) {
839 18
            return new MatchingBlock($bestMatchInOld, $bestMatchInNew, $bestMatchSize);
840
        }
841
842 11
        return null;
843
    }
844
845
    /**
846
     * @param string $str
847
     *
848
     * @return bool
849
     */
850 18
    protected function isOnlyWhitespace($str)
851
    {
852
        //  Slightly faster then using preg_match
853 18
        return $str !== '' && trim($str) === '';
854
    }
855
856
    /**
857
     * Special array_slice function that caches its last request.
858
     *
859
     * The diff algorithm seems to request the same information many times in a row.
860
     * by returning the previous answer the algorithm preforms way faster.
861
     *
862
     * The result is a string instead of an array, this way we safe on the amount of
863
     * memory intensive implode() calls.
864
     *
865
     * @param array         &$array
866
     * @param integer       $offset
867
     * @param integer|null  $length
868
     *
869
     * @return string
0 ignored issues
show
Should the return type not be null|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...
870
     */
871 18
    protected function array_slice_cached(&$array, $offset, $length = null)
0 ignored issues
show
Method name "HtmlDiff::array_slice_cached" is not in camel caps format
Loading history...
872
    {
873 18
        static $lastOffset = null;
874 18
        static $lastLength = null;
875 18
        static $cache      = null;
876
877
        // PHP has no support for by-reference comparing.
878
        // to prevent false positive hits, reset the cache when the oldWords or newWords is changed.
879 18
        if ($this->resetCache === true) {
880 18
            $cache = null;
881
882 18
            $this->resetCache = false;
883
        }
884
885
        if (
886 18
            $cache !== null &&
887 18
            $lastLength === $length &&
888 18
            $lastOffset === $offset
889
        ) { // Hit
890 13
            return $cache;
891
        } // Miss
892
893 18
        $lastOffset = $offset;
894 18
        $lastLength = $length;
895
896 18
        $cache = implode('', array_slice($array, $offset, $length));
897
898 18
        return $cache;
899
    }
900
}
901