Passed
Push — master ( e7628e...25bce7 )
by Sven
01:33 queued 14s
created

HtmlDiff::create()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10

Duplication

Lines 10
Ratio 100 %

Code Coverage

Tests 5
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
nc 2
nop 3
dl 10
loc 10
rs 9.9332
c 0
b 0
f 0
ccs 5
cts 5
cp 1
crap 2
1
<?php
2
3
namespace Caxy\HtmlDiff;
4
5
use Caxy\HtmlDiff\Table\TableDiff;
6
7
/**
8
 * Class HtmlDiff.
9
 */
10
class HtmlDiff extends AbstractDiff
11
{
12
    /**
13
     * @var array
14
     */
15
    protected $wordIndices;
16
    /**
17
     * @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 14 View Code Duplication
    public static function create($oldText, $newText, HtmlDiffConfig $config = null)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

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

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

Loading history...
41
    {
42 14
        $diff = new self($oldText, $newText);
43
44 14
        if (null !== $config) {
45 14
            $diff->setConfig($config);
46
        }
47
48 14
        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 17
    public function build()
93
    {
94 17
        $this->prepare();
95
96 17 View Code Duplication
        if ($this->hasDiffCache() && $this->getDiffCache()->contains($this->oldText, $this->newText)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Loading history...
870
     */
871 17
    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 17
        static $lastOffset = null;
874 17
        static $lastLength = null;
875 17
        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 17
        if ($this->resetCache === true) {
880 17
            $cache = null;
881
882 17
            $this->resetCache = false;
883
        }
884
885
        if (
886 17
            $cache !== null &&
887 17
            $lastLength === $length &&
888 17
            $lastOffset === $offset
889
        ) { // Hit
890 12
            return $cache;
891
        } // Miss
892
893 17
        $lastOffset = $offset;
894 17
        $lastLength = $length;
895
896 17
        $cache = implode('', array_slice($array, $offset, $length));
897
898 17
        return $cache;
899
    }
900
}
901