Passed
Push — master ( 48c70a...6b0fb0 )
by Josh
02:34
created

HtmlDiff::performOperation()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 17
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 14
CRAP Score 5.0073

Importance

Changes 0
Metric Value
cc 5
eloc 15
nc 5
nop 1
dl 0
loc 17
ccs 14
cts 15
cp 0.9333
crap 5.0073
rs 9.4555
c 0
b 0
f 0
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 13
    public static function create($oldText, $newText, HtmlDiffConfig $config = null)
41
    {
42 13
        $diff = new self($oldText, $newText);
43
44 13
        if (null !== $config) {
45 13
            $diff->setConfig($config);
46
        }
47
48 13
        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 15
    public function build()
93
    {
94 15
        $this->prepare();
95
96 15
        if ($this->hasDiffCache() && $this->getDiffCache()->contains($this->oldText, $this->newText)) {
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 15
        if ($this->oldText == $this->newText) {
106 10
            return $this->newText;
107
        }
108
109 15
        $this->splitInputsToWords();
110 15
        $this->replaceIsolatedDiffTags();
111 15
        $this->indexNewWords();
112
113 15
        $operations = $this->operations();
114
115 15
        foreach ($operations as $item) {
116 15
            $this->performOperation($item);
117
        }
118
119 15
        if ($this->hasDiffCache()) {
120
            $this->getDiffCache()->save($this->oldText, $this->newText, $this->content);
121
        }
122
123 15
        return $this->content;
124
    }
125
126 15
    protected function indexNewWords()
127
    {
128 15
        $this->wordIndices = array();
129 15
        foreach ($this->newWords as $i => $word) {
130 15
            if ($this->isTag($word)) {
131 8
                $word = $this->stripTagAttributes($word);
132
            }
133 15
            if (isset($this->wordIndices[ $word ])) {
134 11
                $this->wordIndices[ $word ][] = $i;
135
            } else {
136 15
                $this->wordIndices[ $word ] = array($i);
137
            }
138
        }
139 15
    }
140
141 15
    protected function replaceIsolatedDiffTags()
142
    {
143 15
        $this->oldIsolatedDiffTags = $this->createIsolatedDiffTagPlaceholders($this->oldWords);
144 15
        $this->newIsolatedDiffTags = $this->createIsolatedDiffTagPlaceholders($this->newWords);
145 15
    }
146
147
    /**
148
     * @param array $words
149
     *
150
     * @return array
151
     */
152 15
    protected function createIsolatedDiffTagPlaceholders(&$words)
153
    {
154 15
        $openIsolatedDiffTags = 0;
155 15
        $isolatedDiffTagIndices = array();
156 15
        $isolatedDiffTagStart = 0;
157 15
        $currentIsolatedDiffTag = null;
158 15
        foreach ($words as $index => $word) {
159 15
            $openIsolatedDiffTag = $this->isOpeningIsolatedDiffTag($word, $currentIsolatedDiffTag);
160 15
            if ($openIsolatedDiffTag) {
161 14
                if ($this->isSelfClosingTag($word) || mb_stripos($word, '<img') !== false) {
162
                    if ($openIsolatedDiffTags === 0) {
163
                        $isolatedDiffTagIndices[] = array(
164
                            'start' => $index,
165
                            'length' => 1,
166
                            'tagType' => $openIsolatedDiffTag,
167
                        );
168
                        $currentIsolatedDiffTag = null;
169
                    }
170
                } else {
171 14
                    if ($openIsolatedDiffTags === 0) {
172 14
                        $isolatedDiffTagStart = $index;
173
                    }
174 14
                    ++$openIsolatedDiffTags;
175 14
                    $currentIsolatedDiffTag = $openIsolatedDiffTag;
176
                }
177 15
            } elseif ($openIsolatedDiffTags > 0 && $this->isClosingIsolatedDiffTag($word, $currentIsolatedDiffTag)) {
178 14
                --$openIsolatedDiffTags;
179 14
                if ($openIsolatedDiffTags == 0) {
180 14
                    $isolatedDiffTagIndices[] = array('start' => $isolatedDiffTagStart, 'length' => $index - $isolatedDiffTagStart + 1, 'tagType' => $currentIsolatedDiffTag);
181 14
                    $currentIsolatedDiffTag = null;
182
                }
183
            }
184
        }
185 15
        $isolatedDiffTagScript = array();
186 15
        $offset = 0;
187 15
        foreach ($isolatedDiffTagIndices as $isolatedDiffTagIndex) {
188 14
            $start = $isolatedDiffTagIndex['start'] - $offset;
189 14
            $placeholderString = $this->config->getIsolatedDiffTagPlaceholder($isolatedDiffTagIndex['tagType']);
190 14
            $isolatedDiffTagScript[$start] = array_splice($words, $start, $isolatedDiffTagIndex['length'], $placeholderString);
191 14
            $offset += $isolatedDiffTagIndex['length'] - 1;
192
        }
193
194 15
        return $isolatedDiffTagScript;
195
    }
196
197
    /**
198
     * @param string      $item
199
     * @param null|string $currentIsolatedDiffTag
200
     *
201
     * @return false|string
202
     */
203 15
    protected function isOpeningIsolatedDiffTag($item, $currentIsolatedDiffTag = null)
204
    {
205 15
        $tagsToMatch = $currentIsolatedDiffTag !== null
206 14
            ? array($currentIsolatedDiffTag => $this->config->getIsolatedDiffTagPlaceholder($currentIsolatedDiffTag))
207 15
            : $this->config->getIsolatedDiffTags();
208 15
        $pattern = '#<%s(\s+[^>]*)?>#iUu';
209 15
        foreach ($tagsToMatch as $key => $value) {
210 15
            if (preg_match(sprintf($pattern, $key), $item)) {
211 14
                return $key;
212
            }
213
        }
214
215 15
        return false;
216
    }
217
218 14
    protected function isSelfClosingTag($text)
219
    {
220 14
        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 14
    protected function isClosingIsolatedDiffTag($item, $currentIsolatedDiffTag = null)
230
    {
231 14
        $tagsToMatch = $currentIsolatedDiffTag !== null
232 14
            ? array($currentIsolatedDiffTag => $this->config->getIsolatedDiffTagPlaceholder($currentIsolatedDiffTag))
233 14
            : $this->config->getIsolatedDiffTags();
234 14
        $pattern = '#</%s(\s+[^>]*)?>#iUu';
235 14
        foreach ($tagsToMatch as $key => $value) {
236 14
            if (preg_match(sprintf($pattern, $key), $item)) {
237 14
                return $key;
238
            }
239
        }
240
241 14
        return false;
242
    }
243
244
    /**
245
     * @param Operation $operation
246
     */
247 15
    protected function performOperation($operation)
248
    {
249 15
        switch ($operation->action) {
250 15
            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...
251 15
            $this->processEqualOperation($operation);
252 15
            break;
253 12
            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...
254 4
            $this->processDeleteOperation($operation, 'diffdel');
255 4
            break;
256 12
            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...
257 8
            $this->processInsertOperation($operation, 'diffins');
258 8
            break;
259 9
            case 'replace':
260 9
            $this->processReplaceOperation($operation);
261 9
            break;
262
            default:
263
            break;
264
        }
265 15
    }
266
267
    /**
268
     * @param Operation $operation
269
     */
270 9
    protected function processReplaceOperation($operation)
271
    {
272 9
        $this->processDeleteOperation($operation, 'diffmod');
273 9
        $this->processInsertOperation($operation, 'diffmod');
274 9
    }
275
276
    /**
277
     * @param Operation $operation
278
     * @param string    $cssClass
279
     */
280 12
    protected function processInsertOperation($operation, $cssClass)
281
    {
282 12
        $text = array();
283 12
        foreach ($this->newWords as $pos => $s) {
284 12
            if ($pos >= $operation->startInNew && $pos < $operation->endInNew) {
285 12
                if ($this->config->isIsolatedDiffTagPlaceholder($s) && isset($this->newIsolatedDiffTags[$pos])) {
286 3
                    foreach ($this->newIsolatedDiffTags[$pos] as $word) {
287 3
                        $text[] = $word;
288
                    }
289
                } else {
290 12
                    $text[] = $s;
291
                }
292
            }
293
        }
294 12
        $this->insertTag('ins', $cssClass, $text);
295 12
    }
296
297
    /**
298
     * @param Operation $operation
299
     * @param string    $cssClass
300
     */
301 11
    protected function processDeleteOperation($operation, $cssClass)
302
    {
303 11
        $text = array();
304 11
        foreach ($this->oldWords as $pos => $s) {
305 11
            if ($pos >= $operation->startInOld && $pos < $operation->endInOld) {
306 11
                if ($this->config->isIsolatedDiffTagPlaceholder($s) && isset($this->oldIsolatedDiffTags[$pos])) {
307 6
                    foreach ($this->oldIsolatedDiffTags[$pos] as $word) {
308 6
                        $text[] = $word;
309
                    }
310
                } else {
311 11
                    $text[] = $s;
312
                }
313
            }
314
        }
315 11
        $this->insertTag('del', $cssClass, $text);
316 11
    }
317
318
    /**
319
     * @param Operation $operation
320
     * @param int       $pos
321
     * @param string    $placeholder
322
     * @param bool      $stripWrappingTags
323
     *
324
     * @return string
325
     */
326 13
    protected function diffIsolatedPlaceholder($operation, $pos, $placeholder, $stripWrappingTags = true)
327
    {
328 13
        $oldText = implode('', $this->findIsolatedDiffTagsInOld($operation, $pos));
329 13
        $newText = implode('', $this->newIsolatedDiffTags[$pos]);
330
331 13
        if ($this->isListPlaceholder($placeholder)) {
332 7
            return $this->diffList($oldText, $newText);
333 10
        } elseif ($this->config->isUseTableDiffing() && $this->isTablePlaceholder($placeholder)) {
334 1
            return $this->diffTables($oldText, $newText);
335 9
        } elseif ($this->isLinkPlaceholder($placeholder)) {
336 1
            return $this->diffElementsByAttribute($oldText, $newText, 'href', 'a');
337 8
        } elseif ($this->isImagePlaceholder($placeholder)) {
338
            return $this->diffElementsByAttribute($oldText, $newText, 'src', 'img');
339
        }
340
341 8
        return $this->diffElements($oldText, $newText, $stripWrappingTags);
342
    }
343
344
    /**
345
     * @param string $oldText
346
     * @param string $newText
347
     * @param bool   $stripWrappingTags
348
     *
349
     * @return string
350
     */
351 9
    protected function diffElements($oldText, $newText, $stripWrappingTags = true)
352
    {
353 9
        $wrapStart = '';
354 9
        $wrapEnd = '';
355
356 9
        if ($stripWrappingTags) {
357 9
            $pattern = '/(^<[^>]+>)|(<\/[^>]+>$)/iu';
358 9
            $matches = array();
359
360 9
            if (preg_match_all($pattern, $newText, $matches)) {
361 9
                $wrapStart = isset($matches[0][0]) ? $matches[0][0] : '';
362 9
                $wrapEnd = isset($matches[0][1]) ? $matches[0][1] : '';
363
            }
364 9
            $oldText = preg_replace($pattern, '', $oldText);
365 9
            $newText = preg_replace($pattern, '', $newText);
366
        }
367
368 9
        $diff = self::create($oldText, $newText, $this->config);
369
370 9
        return $wrapStart.$diff->build().$wrapEnd;
371
    }
372
373
    /**
374
     * @param string $oldText
375
     * @param string $newText
376
     *
377
     * @return string
378
     */
379 7
    protected function diffList($oldText, $newText)
380
    {
381 7
        $diff = ListDiffLines::create($oldText, $newText, $this->config);
382
383 7
        return $diff->build();
384
    }
385
386
    /**
387
     * @param string $oldText
388
     * @param string $newText
389
     *
390
     * @return string
391
     */
392 1
    protected function diffTables($oldText, $newText)
393
    {
394 1
        $diff = TableDiff::create($oldText, $newText, $this->config);
395
396 1
        return $diff->build();
397
    }
398
399 1
    protected function diffElementsByAttribute($oldText, $newText, $attribute, $element)
400
    {
401 1
        $oldAttribute = $this->getAttributeFromTag($oldText, $attribute);
402 1
        $newAttribute = $this->getAttributeFromTag($newText, $attribute);
403
404 1
        if ($oldAttribute !== $newAttribute) {
405 1
            $diffClass = sprintf('diffmod diff%s diff%s', $element, $attribute);
406
407 1
            return sprintf(
408 1
                '%s%s',
409 1
                $this->wrapText($oldText, 'del', $diffClass),
410 1
                $this->wrapText($newText, 'ins', $diffClass)
411
            );
412
        }
413
414 1
        return $this->diffElements($oldText, $newText);
415
    }
416
417
    /**
418
     * @param Operation $operation
419
     */
420 15
    protected function processEqualOperation($operation)
421
    {
422 15
        $result = array();
423 15
        foreach ($this->newWords as $pos => $s) {
424 15
            if ($pos >= $operation->startInNew && $pos < $operation->endInNew) {
425 15
                if ($this->config->isIsolatedDiffTagPlaceholder($s) && isset($this->newIsolatedDiffTags[$pos])) {
426 13
                    $result[] = $this->diffIsolatedPlaceholder($operation, $pos, $s);
427
                } else {
428 11
                    $result[] = $s;
429
                }
430
            }
431
        }
432 15
        $this->content .= implode('', $result);
433 15
    }
434
435
    /**
436
     * @param string $text
437
     * @param string $attribute
438
     *
439
     * @return null|string
440
     */
441 1
    protected function getAttributeFromTag($text, $attribute)
442
    {
443 1
        $matches = array();
444 1
        if (preg_match(sprintf('/<[^>]*\b%s\s*=\s*([\'"])(.*)\1[^>]*>/iu', $attribute), $text, $matches)) {
445 1
            return htmlspecialchars_decode($matches[2]);
446
        }
447
448
        return;
449
    }
450
451
    /**
452
     * @param string $text
453
     *
454
     * @return bool
455
     */
456 13
    protected function isListPlaceholder($text)
457
    {
458 13
        return $this->isPlaceholderType($text, array('ol', 'dl', 'ul'));
459
    }
460
461
    /**
462
     * @param string $text
463
     *
464
     * @return bool
465
     */
466 9
    public function isLinkPlaceholder($text)
467
    {
468 9
        return $this->isPlaceholderType($text, 'a');
469
    }
470
471
    /**
472
     * @param string $text
473
     *
474
     * @return bool
475
     */
476 8
    public function isImagePlaceholder($text)
477
    {
478 8
        return $this->isPlaceholderType($text, 'img');
479
    }
480
481
    /**
482
     * @param string       $text
483
     * @param array|string $types
484
     * @param bool         $strict
485
     *
486
     * @return bool
487
     */
488 13
    protected function isPlaceholderType($text, $types, $strict = true)
489
    {
490 13
        if (!is_array($types)) {
491 10
            $types = array($types);
492
        }
493
494 13
        $criteria = array();
495 13
        foreach ($types as $type) {
496 13
            if ($this->config->isIsolatedDiffTag($type)) {
497 13
                $criteria[] = $this->config->getIsolatedDiffTagPlaceholder($type);
498
            } else {
499
                $criteria[] = $type;
500
            }
501
        }
502
503 13
        return in_array($text, $criteria, $strict);
504
    }
505
506
    /**
507
     * @param string $text
508
     *
509
     * @return bool
510
     */
511 10
    protected function isTablePlaceholder($text)
512
    {
513 10
        return $this->isPlaceholderType($text, 'table');
514
    }
515
516
    /**
517
     * @param Operation $operation
518
     * @param int       $posInNew
519
     *
520
     * @return array
521
     */
522 13
    protected function findIsolatedDiffTagsInOld($operation, $posInNew)
523
    {
524 13
        $offset = $posInNew - $operation->startInNew;
525
526 13
        return $this->oldIsolatedDiffTags[$operation->startInOld + $offset];
527
    }
528
529
    /**
530
     * @param string $tag
531
     * @param string $cssClass
532
     * @param array  $words
533
     */
534 12
    protected function insertTag($tag, $cssClass, &$words)
535
    {
536 12
        while (true) {
537 12
            if (count($words) == 0) {
538 8
                break;
539
            }
540
541 12
            $nonTags = $this->extractConsecutiveWords($words, 'noTag');
542
543 12
            $specialCaseTagInjection = '';
544 12
            $specialCaseTagInjectionIsBefore = false;
545
546 12
            if (count($nonTags) != 0) {
547 12
                $text = $this->wrapText(implode('', $nonTags), $tag, $cssClass);
548 12
                $this->content .= $text;
549
            } else {
550 6
                $firstOrDefault = false;
551 6
                foreach ($this->config->getSpecialCaseOpeningTags() as $x) {
552
                    if (preg_match($x, $words[ 0 ])) {
553
                        $firstOrDefault = $x;
554
                        break;
555
                    }
556
                }
557 6
                if ($firstOrDefault) {
558
                    $specialCaseTagInjection = '<ins class="mod">';
559
                    if ($tag == 'del') {
560
                        unset($words[ 0 ]);
561
                    }
562 6
                } 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 12
            if (count($words) == 0 && mb_strlen($specialCaseTagInjection) == 0) {
571 11
                break;
572
            }
573 8
            if ($specialCaseTagInjectionIsBefore) {
574
                $this->content .= $specialCaseTagInjection.implode('', $this->extractConsecutiveWords($words, 'tag'));
575
            } else {
576 8
                $workTag = $this->extractConsecutiveWords($words, 'tag');
577 8
                if (isset($workTag[ 0 ]) && $this->isOpeningTag($workTag[ 0 ]) && !$this->isClosingTag($workTag[ 0 ])) {
578 8
                    if (mb_strpos($workTag[ 0 ], 'class=')) {
579 2
                        $workTag[ 0 ] = str_replace('class="', 'class="diffmod ', $workTag[ 0 ]);
580 2
                        $workTag[ 0 ] = str_replace("class='", 'class="diffmod ', $workTag[ 0 ]);
581
                    } else {
582 8
                        $workTag[ 0 ] = str_replace('>', ' class="diffmod">', $workTag[ 0 ]);
583
                    }
584
                }
585
586 8
                $appendContent = implode('', $workTag).$specialCaseTagInjection;
587 8
                if (isset($workTag[0]) && false !== mb_stripos($workTag[0], '<img')) {
588
                    $appendContent = $this->wrapText($appendContent, $tag, $cssClass);
589
                }
590 8
                $this->content .= $appendContent;
591
            }
592
        }
593 12
    }
594
595
    /**
596
     * @param string $word
597
     * @param string $condition
598
     *
599
     * @return bool
600
     */
601 12
    protected function checkCondition($word, $condition)
602
    {
603 12
        return $condition == 'tag' ? $this->isTag($word) : !$this->isTag($word);
604
    }
605
606
    /**
607
     * @param string $text
608
     * @param string $tagName
609
     * @param string $cssClass
610
     *
611
     * @return string
612
     */
613 13
    protected function wrapText($text, $tagName, $cssClass)
614
    {
615 13
        return sprintf('<%1$s class="%2$s">%3$s</%1$s>', $tagName, $cssClass, $text);
616
    }
617
618
    /**
619
     * @param array  $words
620
     * @param string $condition
621
     *
622
     * @return array
623
     */
624 12
    protected function extractConsecutiveWords(&$words, $condition)
625
    {
626 12
        $indexOfFirstTag = null;
627 12
        $words = array_values($words);
628 12
        foreach ($words as $i => $word) {
629 12
            if (!$this->checkCondition($word, $condition)) {
630 8
                $indexOfFirstTag = $i;
631 8
                break;
632
            }
633
        }
634 12
        if ($indexOfFirstTag !== null) {
635 8
            $items = array();
636 8
            foreach ($words as $pos => $s) {
637 8
                if ($pos >= 0 && $pos < $indexOfFirstTag) {
638 8
                    $items[] = $s;
639
                }
640
            }
641 8
            if ($indexOfFirstTag > 0) {
642 8
                array_splice($words, 0, $indexOfFirstTag);
643
            }
644
645 8
            return $items;
646
        } else {
647 12
            $items = array();
648 12
            foreach ($words as $pos => $s) {
649 12
                if ($pos >= 0 && $pos <= count($words)) {
650 12
                    $items[] = $s;
651
                }
652
            }
653 12
            array_splice($words, 0, count($words));
654
655 12
            return $items;
656
        }
657
    }
658
659
    /**
660
     * @param string $item
661
     *
662
     * @return bool
663
     */
664 15
    protected function isTag($item)
665
    {
666 15
        return $this->isOpeningTag($item) || $this->isClosingTag($item);
667
    }
668
669
    /**
670
     * @param string $item
671
     *
672
     * @return bool
673
     */
674 15
    protected function isOpeningTag($item)
675
    {
676 15
        return preg_match('#<[^>]+>\\s*#iUu', $item);
677
    }
678
679
    /**
680
     * @param string $item
681
     *
682
     * @return bool
683
     */
684 15
    protected function isClosingTag($item)
685
    {
686 15
        return preg_match('#</[^>]+>\\s*#iUu', $item);
687
    }
688
689
    /**
690
     * @return Operation[]
691
     */
692 15
    protected function operations()
693
    {
694 15
        $positionInOld = 0;
695 15
        $positionInNew = 0;
696 15
        $operations = array();
697
698 15
        $matches   = $this->matchingBlocks();
699 15
        $matches[] = new Match(count($this->oldWords), count($this->newWords), 0);
700
701 15
        foreach ($matches as $i => $match) {
702 15
            $matchStartsAtCurrentPositionInOld = ($positionInOld === $match->startInOld);
703 15
            $matchStartsAtCurrentPositionInNew = ($positionInNew === $match->startInNew);
704
705 15
            if ($matchStartsAtCurrentPositionInOld === false && $matchStartsAtCurrentPositionInNew === false) {
706 9
                $action = 'replace';
707 15
            } elseif ($matchStartsAtCurrentPositionInOld === true && $matchStartsAtCurrentPositionInNew === false) {
708 8
                $action = 'insert';
709 15
            } elseif ($matchStartsAtCurrentPositionInOld === false && $matchStartsAtCurrentPositionInNew === true) {
710 4
                $action = 'delete';
711
            } else { // This occurs if the first few words are the same in both versions
712 15
                $action = 'none';
713
            }
714
715 15
            if ($action !== 'none') {
716 12
                $operations[] = new Operation($action, $positionInOld, $match->startInOld, $positionInNew, $match->startInNew);
717
            }
718
719 15
            if (count($match) !== 0) {
720 15
                $operations[] = new Operation('equal', $match->startInOld, $match->endInOld(), $match->startInNew, $match->endInNew());
721
            }
722
723 15
            $positionInOld = $match->endInOld();
724 15
            $positionInNew = $match->endInNew();
725
        }
726
727 15
        return $operations;
728
    }
729
730
    /**
731
     * @return Match[]
732
     */
733 15
    protected function matchingBlocks()
734
    {
735 15
        $matchingBlocks = array();
736 15
        $this->findMatchingBlocks(0, count($this->oldWords), 0, count($this->newWords), $matchingBlocks);
737
738 15
        return $matchingBlocks;
739
    }
740
741
    /**
742
     * @param int   $startInOld
743
     * @param int   $endInOld
744
     * @param int   $startInNew
745
     * @param int   $endInNew
746
     * @param array $matchingBlocks
747
     */
748 15
    protected function findMatchingBlocks($startInOld, $endInOld, $startInNew, $endInNew, &$matchingBlocks)
749
    {
750 15
        $match = $this->findMatch($startInOld, $endInOld, $startInNew, $endInNew);
751
752 15
        if ($match !== null) {
753 15
            if ($startInOld < $match->startInOld && $startInNew < $match->startInNew) {
754 7
                $this->findMatchingBlocks($startInOld, $match->startInOld, $startInNew, $match->startInNew, $matchingBlocks);
755
            }
756
757 15
            $matchingBlocks[] = $match;
758
759 15
            if ($match->endInOld() < $endInOld && $match->endInNew() < $endInNew) {
760 9
                $this->findMatchingBlocks($match->endInOld(), $endInOld, $match->endInNew(), $endInNew, $matchingBlocks);
761
            }
762
        }
763 15
    }
764
765
    /**
766
     * @param string $word
767
     *
768
     * @return string
769
     */
770 8
    protected function stripTagAttributes($word)
771
    {
772 8
        $space = mb_strpos($word, ' ', 1);
773
774 8
        if ($space) {
775 5
            return '<' . mb_substr($word, 1, $space) . '>';
776
        }
777
778 6
        return trim($word, '<>');
779
    }
780
781
    /**
782
     * @param int $startInOld
783
     * @param int $endInOld
784
     * @param int $startInNew
785
     * @param int $endInNew
786
     *
787
     * @return Match|null
788
     */
789 15
    protected function findMatch($startInOld, $endInOld, $startInNew, $endInNew)
790
    {
791 15
        $bestMatchInOld = $startInOld;
792 15
        $bestMatchInNew = $startInNew;
793 15
        $bestMatchSize = 0;
794 15
        $matchLengthAt = array();
795
796 15
        for ($indexInOld = $startInOld; $indexInOld < $endInOld; ++$indexInOld) {
797 15
            $newMatchLengthAt = array();
798 15
            $index = $this->oldWords[ $indexInOld ];
799 15
            if ($this->isTag($index)) {
800 6
                $index = $this->stripTagAttributes($index);
801
            }
802 15
            if (!isset($this->wordIndices[ $index ])) {
803 11
                $matchLengthAt = $newMatchLengthAt;
804 11
                continue;
805
            }
806 15
            foreach ($this->wordIndices[ $index ] as $indexInNew) {
807 15
                if ($indexInNew < $startInNew) {
808 7
                    continue;
809
                }
810 15
                if ($indexInNew >= $endInNew) {
811 7
                    break;
812
                }
813
814 15
                $newMatchLength = (isset($matchLengthAt[ $indexInNew - 1 ]) ? $matchLengthAt[ $indexInNew - 1 ] : 0) + 1;
815 15
                $newMatchLengthAt[ $indexInNew ] = $newMatchLength;
816
817 15
                if ($newMatchLength > $bestMatchSize ||
818
                    (
819 11
                        $this->isGroupDiffs() &&
0 ignored issues
show
Deprecated Code introduced by
The function Caxy\HtmlDiff\AbstractDiff::isGroupDiffs() has been deprecated: since 0.1.0 ( Ignorable by Annotation )

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

819
                        /** @scrutinizer ignore-deprecated */ $this->isGroupDiffs() &&

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

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

Loading history...
820 11
                        $bestMatchSize > 0 &&
821 11
                        $this->isOnlyWhitespace($this->array_slice_cached($this->oldWords, $bestMatchInOld, $bestMatchSize))
822
                    )
823
                ) {
824 15
                    $bestMatchInOld = $indexInOld - $newMatchLength + 1;
825 15
                    $bestMatchInNew = $indexInNew - $newMatchLength + 1;
826 15
                    $bestMatchSize = $newMatchLength;
827
                }
828
            }
829 15
            $matchLengthAt = $newMatchLengthAt;
830
        }
831
832
        // Skip match if none found or match consists only of whitespace
833 15
        if ($bestMatchSize != 0 &&
834
            (
835 15
                !$this->isGroupDiffs() ||
0 ignored issues
show
Deprecated Code introduced by
The function Caxy\HtmlDiff\AbstractDiff::isGroupDiffs() has been deprecated: since 0.1.0 ( Ignorable by Annotation )

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

835
                !/** @scrutinizer ignore-deprecated */ $this->isGroupDiffs() ||

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

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

Loading history...
836 15
                !$this->isOnlyWhitespace($this->array_slice_cached($this->oldWords, $bestMatchInOld, $bestMatchSize))
837
            )
838
        ) {
839 15
            return new Match($bestMatchInOld, $bestMatchInNew, $bestMatchSize);
840
        }
841
842 9
        return null;
843
    }
844
845
    /**
846
     * @param string $str
847
     *
848
     * @return bool
849
     */
850 15
    protected function isOnlyWhitespace($str)
851
    {
852
        //  Slightly faster then using preg_match
853 15
        return $str !== '' && (mb_strlen(trim($str)) === 0);
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
870
     */
871 15
    protected function array_slice_cached(&$array, $offset, $length = null)
0 ignored issues
show
Coding Style introduced by
Method name "HtmlDiff::array_slice_cached" is not in camel caps format
Loading history...
872
    {
873 15
        static $lastOffset = null;
874 15
        static $lastLength = null;
875 15
        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 15
        if ($this->resetCache === true) {
880 15
            $cache = null;
881
882 15
            $this->resetCache = false;
883
        }
884
885
        if (
886 15
            $cache !== null &&
887 11
            $lastLength === $length &&
888 11
            $lastOffset === $offset
889
        ) { // Hit
890 11
            return $cache;
891
        } // Miss
892
893 15
        $lastOffset = $offset;
894 15
        $lastLength = $length;
895
896 15
        $cache = implode('', array_slice($array, $offset, $length));
897
898 15
        return $cache;
899
    }
900
}
901