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

HtmlDiff::oldTextIsOnlyWhitespace()   B

Complexity

Conditions 8
Paths 8

Size

Total Lines 43

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 21
CRAP Score 8

Importance

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