Passed
Pull Request — master (#102)
by Sven
02:57
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 14
    public static function create($oldText, $newText, HtmlDiffConfig $config = null)
35
    {
36 14
        $diff = new self($oldText, $newText);
37
38 14
        if (null !== $config) {
39 14
            $diff->setConfig($config);
40
        }
41
42 14
        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 17
    public function build()
87
    {
88 17
        $this->prepare();
89
90 17
        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 17
        if ($this->oldText == $this->newText) {
100 11
            return $this->newText;
101
        }
102
103 17
        $this->splitInputsToWords();
104 17
        $this->replaceIsolatedDiffTags();
105 17
        $this->indexNewWords();
106
107 17
        $operations = $this->operations();
108
109 17
        foreach ($operations as $item) {
110 17
            $this->performOperation($item);
111
        }
112
113 17
        if ($this->hasDiffCache()) {
114
            $this->getDiffCache()->save($this->oldText, $this->newText, $this->content);
115
        }
116
117 17
        return $this->content;
118
    }
119
120 17
    protected function indexNewWords() : void
121
    {
122 17
        $this->wordIndices = [];
123
124 17
        foreach ($this->newWords as $i => $word) {
125 17
            if ($this->isTag($word) === true) {
126 9
                $word = $this->stripTagAttributes($word);
127
            }
128
129 17
            if (isset($this->wordIndices[$word]) === false) {
130 17
                $this->wordIndices[$word] = [];
131
            }
132
133 17
            $this->wordIndices[$word][] = $i;
134
        }
135 17
    }
136
137 17
    protected function replaceIsolatedDiffTags()
138
    {
139 17
        $this->oldIsolatedDiffTags = $this->createIsolatedDiffTagPlaceholders($this->oldWords);
140 17
        $this->newIsolatedDiffTags = $this->createIsolatedDiffTagPlaceholders($this->newWords);
141 17
    }
142
143
    /**
144
     * @param array $words
145
     *
146
     * @return array
147
     */
148 17
    protected function createIsolatedDiffTagPlaceholders(&$words)
149
    {
150 17
        $openIsolatedDiffTags = 0;
151 17
        $isolatedDiffTagIndices = array();
152 17
        $isolatedDiffTagStart = 0;
153 17
        $currentIsolatedDiffTag = null;
154 17
        foreach ($words as $index => $word) {
155 17
            $openIsolatedDiffTag = $this->isOpeningIsolatedDiffTag($word, $currentIsolatedDiffTag);
156 17
            if ($openIsolatedDiffTag) {
157 15
                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 15
                    if ($openIsolatedDiffTags === 0) {
168 15
                        $isolatedDiffTagStart = $index;
169
                    }
170 15
                    ++$openIsolatedDiffTags;
171 15
                    $currentIsolatedDiffTag = $openIsolatedDiffTag;
172
                }
173 17
            } elseif ($openIsolatedDiffTags > 0 && $this->isClosingIsolatedDiffTag($word, $currentIsolatedDiffTag)) {
174 15
                --$openIsolatedDiffTags;
175 15
                if ($openIsolatedDiffTags == 0) {
176 15
                    $isolatedDiffTagIndices[] = array('start' => $isolatedDiffTagStart, 'length' => $index - $isolatedDiffTagStart + 1, 'tagType' => $currentIsolatedDiffTag);
177 15
                    $currentIsolatedDiffTag = null;
178
                }
179
            }
180
        }
181 17
        $isolatedDiffTagScript = array();
182 17
        $offset = 0;
183 17
        foreach ($isolatedDiffTagIndices as $isolatedDiffTagIndex) {
184 15
            $start = $isolatedDiffTagIndex['start'] - $offset;
185 15
            $placeholderString = $this->config->getIsolatedDiffTagPlaceholder($isolatedDiffTagIndex['tagType']);
186 15
            $isolatedDiffTagScript[$start] = array_splice($words, $start, $isolatedDiffTagIndex['length'], $placeholderString);
187 15
            $offset += $isolatedDiffTagIndex['length'] - 1;
188
        }
189
190 17
        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 17
    protected function isOpeningIsolatedDiffTag($item, $currentIsolatedDiffTag = null)
200
    {
201 17
        $tagsToMatch = $currentIsolatedDiffTag !== null
202 15
            ? array($currentIsolatedDiffTag => $this->config->getIsolatedDiffTagPlaceholder($currentIsolatedDiffTag))
203 17
            : $this->config->getIsolatedDiffTags();
204 17
        $pattern = '#<%s(\s+[^>]*)?>#iUu';
205 17
        foreach ($tagsToMatch as $key => $value) {
206 17
            if (preg_match(sprintf($pattern, $key), $item)) {
207 15
                return $key;
208
            }
209
        }
210
211 17
        return false;
212
    }
213
214 15
    protected function isSelfClosingTag($text)
215
    {
216 15
        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 15
    protected function isClosingIsolatedDiffTag($item, $currentIsolatedDiffTag = null)
226
    {
227 15
        $tagsToMatch = $currentIsolatedDiffTag !== null
228 15
            ? array($currentIsolatedDiffTag => $this->config->getIsolatedDiffTagPlaceholder($currentIsolatedDiffTag))
229 15
            : $this->config->getIsolatedDiffTags();
230 15
        $pattern = '#</%s(\s+[^>]*)?>#iUu';
231 15
        foreach ($tagsToMatch as $key => $value) {
232 15
            if (preg_match(sprintf($pattern, $key), $item)) {
233 15
                return $key;
234
            }
235
        }
236
237 15
        return false;
238
    }
239
240
    /**
241
     * @param Operation $operation
242
     */
243 17
    protected function performOperation($operation)
244
    {
245 17
        switch ($operation->action) {
246 17
            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 17
                $this->processEqualOperation($operation);
248 17
                break;
249 14
            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 14
            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 10
            case 'replace':
256 10
                $this->processReplaceOperation($operation);
257 10
                break;
258
            default:
259
                break;
260
        }
261 17
    }
262
263
    /**
264
     * @param Operation $operation
265
     */
266 10
    protected function processReplaceOperation($operation)
267
    {
268 10
        $this->processDeleteOperation($operation, 'diffmod');
269 10
        $this->processInsertOperation($operation, 'diffmod');
270 10
    }
271
272
    /**
273
     * @param Operation $operation
274
     * @param string    $cssClass
275
     */
276 14
    protected function processInsertOperation($operation, $cssClass)
277
    {
278 14
        $text = array();
279 14
        foreach ($this->newWords as $pos => $s) {
280 14
            if ($pos >= $operation->startInNew && $pos < $operation->endInNew) {
281 14
                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 14
                    $text[] = $s;
287
                }
288
            }
289
        }
290
291 14
        $this->insertTag('ins', $cssClass, $text);
292 14
    }
293
294
    /**
295
     * @param Operation $operation
296
     * @param string    $cssClass
297
     */
298 12
    protected function processDeleteOperation($operation, $cssClass)
299
    {
300 12
        $text = array();
301 12
        foreach ($this->oldWords as $pos => $s) {
302 12
            if ($pos >= $operation->startInOld && $pos < $operation->endInOld) {
303 12
                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 12
                    $text[] = $s;
309
                }
310
            }
311
        }
312 12
        $this->insertTag('del', $cssClass, $text);
313 12
    }
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 14
    protected function diffIsolatedPlaceholder($operation, $pos, $placeholder, $stripWrappingTags = true)
324
    {
325 14
        $oldText = implode('', $this->findIsolatedDiffTagsInOld($operation, $pos));
326 14
        $newText = implode('', $this->newIsolatedDiffTags[$pos]);
327
328 14
        if ($this->isListPlaceholder($placeholder)) {
329 8
            return $this->diffList($oldText, $newText);
330 10
        } elseif ($this->config->isUseTableDiffing() && $this->isTablePlaceholder($placeholder)) {
331 1
            return $this->diffTables($oldText, $newText);
332 9
        } elseif ($this->isLinkPlaceholder($placeholder)) {
333 1
            return $this->diffElementsByAttribute($oldText, $newText, 'href', 'a');
334 8
        } elseif ($this->isImagePlaceholder($placeholder)) {
335
            return $this->diffElementsByAttribute($oldText, $newText, 'src', 'img');
336
        }
337
338 8
        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 9
    protected function diffElements($oldText, $newText, $stripWrappingTags = true)
349
    {
350 9
        $wrapStart = '';
351 9
        $wrapEnd = '';
352
353 9
        if ($stripWrappingTags) {
354 9
            $pattern = '/(^<[^>]+>)|(<\/[^>]+>$)/iu';
355 9
            $matches = array();
356
357 9
            if (preg_match_all($pattern, $newText, $matches)) {
358 9
                $wrapStart = isset($matches[0][0]) ? $matches[0][0] : '';
359 9
                $wrapEnd = isset($matches[0][1]) ? $matches[0][1] : '';
360
            }
361 9
            $oldText = preg_replace($pattern, '', $oldText);
362 9
            $newText = preg_replace($pattern, '', $newText);
363
        }
364
365 9
        $diff = self::create($oldText, $newText, $this->config);
366
367 9
        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 17
    protected function processEqualOperation($operation)
418
    {
419 17
        $result = array();
420 17
        foreach ($this->newWords as $pos => $s) {
421 17
            if ($pos >= $operation->startInNew && $pos < $operation->endInNew) {
422 17
                if ($this->config->isIsolatedDiffTagPlaceholder($s) && isset($this->newIsolatedDiffTags[$pos])) {
423 14
                    $result[] = $this->diffIsolatedPlaceholder($operation, $pos, $s);
424
                } else {
425 13
                    $result[] = $s;
426
                }
427
            }
428
        }
429 17
        $this->content .= implode('', $result);
430 17
    }
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 14
    protected function isListPlaceholder($text)
454
    {
455 14
        return $this->isPlaceholderType($text, array('ol', 'dl', 'ul'));
456
    }
457
458
    /**
459
     * @param string $text
460
     *
461
     * @return bool
462
     */
463 9
    public function isLinkPlaceholder($text)
464
    {
465 9
        return $this->isPlaceholderType($text, 'a');
466
    }
467
468
    /**
469
     * @param string $text
470
     *
471
     * @return bool
472
     */
473 8
    public function isImagePlaceholder($text)
474
    {
475 8
        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 14
    protected function isPlaceholderType($text, $types, $strict = true)
486
    {
487 14
        if (!is_array($types)) {
488 10
            $types = array($types);
489
        }
490
491 14
        $criteria = array();
492 14
        foreach ($types as $type) {
493 14
            if ($this->config->isIsolatedDiffTag($type)) {
494 14
                $criteria[] = $this->config->getIsolatedDiffTagPlaceholder($type);
495
            } else {
496
                $criteria[] = $type;
497
            }
498
        }
499
500 14
        return in_array($text, $criteria, $strict);
501
    }
502
503
    /**
504
     * @param string $text
505
     *
506
     * @return bool
507
     */
508 10
    protected function isTablePlaceholder($text)
509
    {
510 10
        return $this->isPlaceholderType($text, 'table');
511
    }
512
513
    /**
514
     * @param Operation $operation
515
     * @param int       $posInNew
516
     *
517
     * @return array
518
     */
519 14
    protected function findIsolatedDiffTagsInOld($operation, $posInNew)
520
    {
521 14
        $offset = $posInNew - $operation->startInNew;
522
523 14
        return $this->oldIsolatedDiffTags[$operation->startInOld + $offset];
524
    }
525
526
    /**
527
     * @param string $tag
528
     * @param string $cssClass
529
     * @param array  $words
530
     */
531 14
    protected function insertTag($tag, $cssClass, &$words)
532
    {
533 14
        while (true) {
534 14
            if (count($words) === 0) {
535 9
                break;
536
            }
537
538 14
            $nonTags = $this->extractConsecutiveWords($words, 'noTag');
539
540 14
            $specialCaseTagInjection = '';
541 14
            $specialCaseTagInjectionIsBefore = false;
542
543 14
            if (count($nonTags) !== 0) {
544 14
                $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 14
            if (count($words) == 0 && $this->stringUtil->strlen($specialCaseTagInjection) == 0) {
567 13
                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 14
    }
602
603
    /**
604
     * @param string $word
605
     * @param string $condition
606
     *
607
     * @return bool
608
     */
609 14
    protected function checkCondition($word, $condition)
610
    {
611 14
        return $condition == 'tag' ? $this->isTag($word) : !$this->isTag($word);
612
    }
613
614 15
    protected function wrapText(string $text, string $tagName, string $cssClass) : string
615
    {
616 15
        if (trim($text) === '') {
617 7
            return '';
618
        }
619
620 15
        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 14
    protected function extractConsecutiveWords(&$words, $condition)
630
    {
631 14
        $indexOfFirstTag = null;
632 14
        $words = array_values($words);
633 14
        foreach ($words as $i => $word) {
634 14
            if (!$this->checkCondition($word, $condition)) {
635 9
                $indexOfFirstTag = $i;
636 9
                break;
637
            }
638
        }
639 14
        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 14
            $items = array();
653 14
            foreach ($words as $pos => $s) {
654 14
                if ($pos >= 0 && $pos <= count($words)) {
655 14
                    $items[] = $s;
656
                }
657
            }
658 14
            array_splice($words, 0, count($words));
659
660 14
            return $items;
661
        }
662
    }
663
664
    /**
665
     * @param string $item
666
     *
667
     * @return bool
668
     */
669 17
    protected function isTag($item)
670
    {
671 17
        return $this->isOpeningTag($item) || $this->isClosingTag($item);
672
    }
673
674 17
    protected function isOpeningTag($item) : bool
675
    {
676 17
        return preg_match('#<[^>]+>\\s*#iUu', $item) === 1;
677
    }
678
679 17
    protected function isClosingTag($item) : bool
680
    {
681 17
        return preg_match('#</[^>]+>\\s*#iUu', $item) === 1;
682
    }
683
684
    /**
685
     * @return Operation[]
686
     */
687 17
    protected function operations()
688
    {
689 17
        $positionInOld = 0;
690 17
        $positionInNew = 0;
691 17
        $operations = array();
692
693 17
        $matches   = $this->matchingBlocks();
694 17
        $matches[] = new MatchingBlock(count($this->oldWords), count($this->newWords), 0);
695
696 17
        foreach ($matches as $match) {
697 17
            $matchStartsAtCurrentPositionInOld = ($positionInOld === $match->startInOld);
698 17
            $matchStartsAtCurrentPositionInNew = ($positionInNew === $match->startInNew);
699
700 17
            if ($matchStartsAtCurrentPositionInOld === false && $matchStartsAtCurrentPositionInNew === false) {
701 10
                $action = 'replace';
702 17
            } elseif ($matchStartsAtCurrentPositionInOld === true && $matchStartsAtCurrentPositionInNew === false) {
703 10
                $action = 'insert';
704 17
            } 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 17
                $action = 'none';
708
            }
709
710 17
            if ($action !== 'none') {
711 14
                $operations[] = new Operation($action, $positionInOld, $match->startInOld, $positionInNew, $match->startInNew);
712
            }
713
714 17
            if (count($match) !== 0) {
715 17
                $operations[] = new Operation('equal', $match->startInOld, $match->endInOld(), $match->startInNew, $match->endInNew());
716
            }
717
718 17
            $positionInOld = $match->endInOld();
719 17
            $positionInNew = $match->endInNew();
720
        }
721
722 17
        return $operations;
723
    }
724
725
    /**
726
     * @return MatchingBlock[]
727
     */
728 17
    protected function matchingBlocks()
729
    {
730 17
        $matchingBlocks = array();
731 17
        $this->findMatchingBlocks(0, count($this->oldWords), 0, count($this->newWords), $matchingBlocks);
732
733 17
        return $matchingBlocks;
734
    }
735
736
    /**
737
     * @param MatchingBlock[] $matchingBlocks
738
     */
739 17
    protected function findMatchingBlocks(int $startInOld, int $endInOld, int $startInNew, int $endInNew, array &$matchingBlocks) : void
740
    {
741 17
        $match = $this->findMatch($startInOld, $endInOld, $startInNew, $endInNew);
742
743 17
        if ($match === null) {
744 10
            return;
745
        }
746
747 17
        if ($startInOld < $match->startInOld && $startInNew < $match->startInNew) {
748 8
            $this->findMatchingBlocks($startInOld, $match->startInOld, $startInNew, $match->startInNew, $matchingBlocks);
749
        }
750
751 17
        $matchingBlocks[] = $match;
752
753 17
        if ($match->endInOld() < $endInOld && $match->endInNew() < $endInNew) {
754 10
            $this->findMatchingBlocks($match->endInOld(), $endInOld, $match->endInNew(), $endInNew, $matchingBlocks);
755
        }
756 17
    }
757
758
    /**
759
     * @param string $word
760
     *
761
     * @return string
762
     */
763 9
    protected function stripTagAttributes($word)
764
    {
765 9
        $space = $this->stringUtil->strpos($word, ' ', 1);
766
767 9
        if ($space > 0) {
768 6
            return '<' . $this->stringUtil->substr($word, 1, $space) . '>';
769
        }
770
771 7
        return trim($word, '<>');
772
    }
773
774 17
    protected function findMatch(int $startInOld, int $endInOld, int $startInNew, int $endInNew) : ?MatchingBlock
775
    {
776 17
        $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 17
        $bestMatchInOld = $startInOld;
778 17
        $bestMatchInNew = $startInNew;
779 17
        $bestMatchSize = 0;
780 17
        $matchLengthAt = [];
781
782 17
        for ($indexInOld = $startInOld; $indexInOld < $endInOld; ++$indexInOld) {
783 17
            $newMatchLengthAt = [];
784
785 17
            $index = $this->oldWords[ $indexInOld ];
786
787 17
            if ($this->isTag($index) === true) {
788 6
                $index = $this->stripTagAttributes($index);
789
            }
790
791 17
            if (isset($this->wordIndices[$index]) === false) {
792 12
                $matchLengthAt = $newMatchLengthAt;
793
794 12
                continue;
795
            }
796
797 17
            foreach ($this->wordIndices[$index] as $indexInNew) {
798 17
                if ($indexInNew < $startInNew) {
799 7
                    continue;
800
                }
801
802 17
                if ($indexInNew >= $endInNew) {
803 8
                    break;
804
                }
805
806
                $newMatchLength =
807 17
                    (isset($matchLengthAt[$indexInNew - 1]) === true ? ($matchLengthAt[$indexInNew - 1] + 1) : 1);
808
809 17
                $newMatchLengthAt[$indexInNew] = $newMatchLength;
810
811 17
                if ($newMatchLength > $bestMatchSize ||
812
                    (
813 12
                        $groupDiffs === true &&
814 12
                        $bestMatchSize > 0 &&
815 17
                        $this->oldTextIsOnlyWhitespace($bestMatchInOld, $bestMatchSize) === true
816
                    )
817
                ) {
818 17
                    $bestMatchInOld = $indexInOld - $newMatchLength + 1;
819 17
                    $bestMatchInNew = $indexInNew - $newMatchLength + 1;
820 17
                    $bestMatchSize  = $newMatchLength;
821
                }
822
            }
823
824 17
            $matchLengthAt = $newMatchLengthAt;
825
        }
826
827
        // Skip match if none found or match consists only of whitespace
828 17
        if ($bestMatchSize !== 0 &&
829
            (
830 17
                $groupDiffs === false ||
831 17
                $this->oldTextIsOnlyWhitespace($bestMatchInOld, $bestMatchSize) === false
832
            )
833
        ) {
834 17
            return new MatchingBlock($bestMatchInOld, $bestMatchInNew, $bestMatchSize);
835
        }
836
837 10
        return null;
838
    }
839
840 17
    protected function oldTextIsOnlyWhitespace(int $startingAtWord, int $wordCount) : bool
841
    {
842 17
        $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 17
        static $lastStartingWordOffset = null;
847 17
        static $lastWordCount          = null;
848 17
        static $cache                  = null;
849
850 17
        if ($this->resetCache === true) {
851 17
            $cache = null;
852
853 17
            $this->resetCache = false;
854
        }
855
856
        if (
857 17
            $cache !== null &&
858 17
            $lastWordCount === $wordCount &&
859 17
            $lastStartingWordOffset === $startingAtWord
860
        ) { // Hit
861 12
            return $cache;
862
        } // Miss
863
864 17
        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 17
            $oldWord = $this->oldWords[$index];
868
869 17
            if ($oldWord !== '' && trim($oldWord) !== '') {
870 17
                $isWhitespace = false;
871
872 17
                break;
873
            }
874
        }
875
876 17
        $lastWordCount          = $wordCount;
877 17
        $lastStartingWordOffset = $startingAtWord;
878
879 17
        $cache = $isWhitespace;
880
881 17
        return $cache;
882
    }
883
}
884