Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.65% covered (success)
93.65%
59 / 63
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
ResolutionSuggester
93.65% covered (success)
93.65%
59 / 63
75.00% covered (warning)
75.00%
6 / 8
33.28
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isIdenticalCopyBlock
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isAddition
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 getResolutionSuggestion
91.89% covered (success)
91.89%
34 / 37
0.00% covered (danger)
0.00%
0 / 1
14.10
 getBaseRevisionLines
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 countNewlines
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 moveNewlinesUp
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 moveNewlinesDown
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3namespace TwoColConflict\TalkPageConflict;
4
5use MediaWiki\Revision\RevisionRecord;
6use MediaWiki\Revision\SlotRecord;
7use TwoColConflict\AnnotatedHtmlDiffFormatter;
8use TwoColConflict\SplitConflictUtils;
9use Wikimedia\Diff\ComplexityException;
10
11/**
12 * @license GPL-2.0-or-later
13 * @author Christoph Jauera <christoph.jauera@wikimedia.de>
14 */
15class ResolutionSuggester {
16
17    private ?RevisionRecord $baseRevision;
18    private string $contentFormat;
19
20    public function __construct( ?RevisionRecord $baseRevision, string $contentFormat ) {
21        $this->baseRevision = $baseRevision;
22        $this->contentFormat = $contentFormat;
23    }
24
25    /**
26     * @param array $a First block to compare
27     * @param array $b Second block
28     * @return bool True if the blocks are both copy blocks, with identical content.
29     */
30    private function isIdenticalCopyBlock( array $a, array $b ): bool {
31        return $a['action'] === 'copy' && $a === $b;
32    }
33
34    private function isAddition( array $change ): bool {
35        return $change['action'] === 'add'
36            || ( $change['action'] === 'change' && $change['oldtext'] === '' );
37    }
38
39    /**
40     * @param string[] $storedLines
41     * @param string[] $yourLines
42     * @return TalkPageResolution|null
43     */
44    public function getResolutionSuggestion(
45        array $storedLines,
46        array $yourLines
47    ): ?TalkPageResolution {
48        $baseLines = $this->getBaseRevisionLines();
49        $formatter = new AnnotatedHtmlDiffFormatter();
50
51        try {
52            // TODO: preSaveTransform $yourLines, but not $storedLines
53            $diffYourLines = $formatter->format( $baseLines, $yourLines, $yourLines );
54            $diffStoredLines = $formatter->format( $baseLines, $storedLines, $storedLines );
55        } catch ( ComplexityException $ex ) {
56            return null;
57        }
58
59        if ( count( $diffYourLines ) !== count( $diffStoredLines ) ) {
60            return null;
61        }
62
63        foreach ( $diffStoredLines as $i => $stored ) {
64            $unsaved = $diffYourLines[$i];
65
66            // We only care about copies that are *almost* identical, except for extra newlines
67            if ( !isset( $stored['copytext'] )
68                || !isset( $unsaved['copytext'] )
69                || $stored['copytext'] === $unsaved['copytext']
70                || trim( $stored['copytext'], "\n" ) !== trim( $unsaved['copytext'], "\n" )
71            ) {
72                continue;
73            }
74
75            [ $beforeStored, $afterStored ] = $this->countNewlines( $stored['copytext'] );
76            [ $beforeUnsafed, $afterUnsafed ] = $this->countNewlines( $unsaved['copytext'] );
77
78            $this->moveNewlinesUp( $diffStoredLines, $i, $beforeStored - $beforeUnsafed );
79            $this->moveNewlinesUp( $diffYourLines, $i, $beforeUnsafed - $beforeStored );
80            $this->moveNewlinesDown( $diffStoredLines, $i, $afterStored - $afterUnsafed );
81            $this->moveNewlinesDown( $diffYourLines, $i, $afterUnsafed - $afterStored );
82        }
83
84        // only diffs that contain exactly one addition, that is optionally
85        // preceded and/or succeeded by one identical copy line, are
86        // candidates for the resolution suggestion
87
88        $diff = [];
89        /** @var ?int $spliceIndex */
90        $spliceIndex = null;
91        // Copy over identical blocks, and splice the two alternatives.
92        foreach ( $diffYourLines as $index => $yourLine ) {
93            $otherLine = $diffStoredLines[$index];
94            if ( $this->isIdenticalCopyBlock( $yourLine, $otherLine ) ) {
95                // Copy
96                $diff[] = $otherLine;
97            } elseif ( $this->isAddition( $yourLine )
98                && $this->isAddition( $otherLine )
99                && $spliceIndex === null
100            ) {
101                // Splice alternatives
102                $spliceIndex = count( $diff );
103                $diff[] = [ 'action' => 'add' ] + $otherLine;
104                $diff[] = [ 'action' => 'add' ] + $yourLine;
105            } else {
106                return null;
107            }
108        }
109        if ( $spliceIndex === null ) {
110            // TODO: I'm not sure yet, but this might be a logic error and should be logged.
111            return null;
112        }
113
114        // @phan-suppress-next-line SecurityCheck-DoubleEscaped
115        return new TalkPageResolution( $diff, $spliceIndex, $spliceIndex + 1 );
116    }
117
118    /**
119     * @return string[]
120     */
121    private function getBaseRevisionLines(): array {
122        if ( !$this->baseRevision ) {
123            return [];
124        }
125
126        $baseContent = $this->baseRevision->getContent( SlotRecord::MAIN );
127        if ( !$baseContent ) {
128            return [];
129        }
130
131        $baseText = $baseContent->serialize( $this->contentFormat );
132        if ( !$baseText ) {
133            return [];
134        }
135
136        return SplitConflictUtils::splitText( $baseText );
137    }
138
139    private function countNewlines( string $text ): array {
140        // Start from the end, because we want "\n" to be reported as [ 0, 1 ]
141        $endOfText = strlen( rtrim( $text, "\n" ) );
142        $after = strlen( $text ) - $endOfText;
143        $before = strspn( $text, "\n", 0, $endOfText );
144        return [ $before, $after ];
145    }
146
147    private function moveNewlinesUp( array &$diff, int $i, int $count ): void {
148        if ( $count < 1 || !isset( $diff[$i - 1] ) || $diff[$i - 1]['action'] !== 'add' ) {
149            return;
150        }
151
152        $diff[$i - 1]['newtext'] .= str_repeat( "\n", $count );
153        // The current row is guaranteed to be a copy
154        $diff[$i]['copytext'] = substr( $diff[$i]['copytext'], $count );
155    }
156
157    private function moveNewlinesDown( array &$diff, int $i, int $count ): void {
158        if ( $count < 1 || !isset( $diff[$i + 1] ) || $diff[$i + 1]['action'] !== 'add' ) {
159            return;
160        }
161
162        $diff[$i + 1]['newtext'] = str_repeat( "\n", $count ) . $diff[$i + 1]['newtext'];
163        // The current row is guaranteed to be a copy
164        $diff[$i]['copytext'] = substr( $diff[$i]['copytext'], 0, -$count );
165    }
166
167}