Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
144 / 144
100.00% covered (success)
100.00%
11 / 11
CRAP
100.00% covered (success)
100.00%
1 / 1
WikitextContentCleaner
100.00% covered (success)
100.00%
144 / 144
100.00% covered (success)
100.00%
11 / 11
45
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLatestNumberOfReplacements
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setSourceWikiLanguageTemplate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 cleanWikitext
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 cleanHeadings
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 cleanTemplates
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
5
 parseTemplate
100.00% covered (success)
100.00%
51 / 51
100.00% covered (success)
100.00%
1 / 1
17
 scanFormatSnippet
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 scanValue
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 renameTemplateParameters
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
8
 addRequiredTemplateParameters
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3namespace FileImporter\Services\Wikitext;
4
5use FileImporter\Data\WikitextConversions;
6
7/**
8 * @license GPL-2.0-or-later
9 * @author Thiemo Kreuz
10 */
11class WikitextContentCleaner {
12
13    /** @var int */
14    private $latestNumberOfReplacements = 0;
15    private WikitextConversions $wikitextConversions;
16    /** @var string|null Name of a language template to wrap parameters in, e.g. "de" for the {{de|…}} template */
17    private $sourceWikiLanguageTemplate = null;
18
19    public function __construct( WikitextConversions $conversions ) {
20        $this->wikitextConversions = $conversions;
21    }
22
23    public function getLatestNumberOfReplacements(): int {
24        return $this->latestNumberOfReplacements;
25    }
26
27    public function setSourceWikiLanguageTemplate( string $template ): void {
28        $this->sourceWikiLanguageTemplate = $template;
29    }
30
31    public function cleanWikitext( string $wikitext ): string {
32        $wikitext = $this->cleanHeadings( $wikitext );
33        $wikitext = $this->cleanTemplates( $wikitext );
34        return trim( $wikitext );
35    }
36
37    private function cleanHeadings( string $wikitext ): string {
38        return preg_replace_callback(
39            '/^
40                # Group 1
41                (
42                    # Group 2 captures any opening equal signs, the extra + avoids backtracking
43                    (=++)
44                    # Consume horizontal whitespace
45                    \h*+
46                )
47                # The ungreedy group 3 will capture the trimmed heading
48                (.*?)
49                # Look-ahead for what group 2 captured
50                (?=\h*\2\h*$)
51            /mx',
52            function ( array $matches ): string {
53                return $matches[1] . $this->wikitextConversions->swapHeading( $matches[3] );
54            },
55            $wikitext
56        );
57    }
58
59    private function cleanTemplates( string $wikitext ): string {
60        $this->latestNumberOfReplacements = 0;
61
62        preg_match_all(
63            // This intentionally only searches for the start of each template
64            '/(?<!{){{\s*+([^{|}]+?)\s*(?=\||}})/s',
65            $wikitext,
66            $matches,
67            PREG_OFFSET_CAPTURE
68        );
69
70        // Replacements must be applied in reverse order to not mess with the captured offsets!
71        for ( $i = count( $matches[1] ); $i-- > 0; ) {
72            [ $oldTemplateName, $offset ] = $matches[1][$i];
73
74            $isObsolete = $this->wikitextConversions->isObsoleteTemplate( $oldTemplateName );
75            $newTemplateName = $this->wikitextConversions->swapTemplate( $oldTemplateName );
76            if ( !$isObsolete && !$newTemplateName ) {
77                continue;
78            }
79
80            $endOfTemplateName = (int)$offset + strlen( $oldTemplateName );
81            $parseResult = $this->parseTemplate( $wikitext, $endOfTemplateName );
82
83            $this->latestNumberOfReplacements++;
84
85            if ( $isObsolete ) {
86                $start = $matches[0][$i][1];
87                $wikitext = substr_replace( $wikitext, '', $start, $parseResult['end'] - $start );
88                continue;
89            }
90            '@phan-var string $newTemplateName';
91
92            $wikitext = $this->renameTemplateParameters(
93                $wikitext,
94                $parseResult['parameters'],
95                $this->wikitextConversions->getTemplateParameters( $oldTemplateName ),
96                $this->sourceWikiLanguageTemplate
97            );
98
99            $wikitext = $this->addRequiredTemplateParameters(
100                $wikitext,
101                $this->wikitextConversions->getRequiredTemplateParameters( $oldTemplateName ),
102                $parseResult['parameters'],
103                $endOfTemplateName
104            );
105
106            $wikitext = substr_replace(
107                $wikitext,
108                $newTemplateName,
109                $offset,
110                strlen( $oldTemplateName )
111            );
112        }
113
114        // Collapse any amount of line breaks to a maximum of two (= one empty line)
115        return preg_replace( '/\n\s*\n\s*\n/', "\n\n", $wikitext );
116    }
117
118    /**
119     * @suppress PhanTypeInvalidDimOffset false positive with $p being -1
120     * @param string $wikitext
121     * @param int $startPosition Must be after the opening {{, and before or exactly at the first |
122     *
123     * @return array Parse result in the following format:
124     * [
125     *     'parameters' => [
126     *         [
127     *             'offset' => absolute position of the parameter name in the wikitext, or where the
128     *                 parameter name needs to be placed for unnamed parameters,
129     *             'number' => positive integer number, only present for unnamed parameters,
130     *             'name' => optional string name of the parameter,
131     *             'valueOffset' => int Absolute position of the value's first non-whitespace
132     *                 character in the wikitext
133     *             'value' => string Trimmed value, might be an empty string
134     *         ],
135     *         …
136     *     ]
137     * ]
138     */
139    private function parseTemplate( string $wikitext, int $startPosition ): array {
140        $max = strlen( $wikitext );
141        // Templates can be nested, but links can not
142        $inWikiLink = false;
143        $nesting = 0;
144        $params = [];
145        $p = -1;
146        $number = 0;
147
148        for ( $i = $startPosition; $i < $max; $i++ ) {
149            $currentChar = $wikitext[$i];
150            $currentPair = substr( $wikitext, $i, 2 );
151
152            if ( $currentPair === '[[' ) {
153                $inWikiLink = true;
154            } elseif ( $currentPair === ']]' ) {
155                $inWikiLink = false;
156            } elseif ( $currentPair === '{{' ) {
157                $nesting++;
158                // Skip the second bracket, it can't be the start of another pair
159                $i++;
160            } elseif ( $currentPair === '}}' || $currentPair === '}' ) {
161                if ( !$nesting ) {
162                    if ( isset( $params[$p] ) ) {
163                        $this->scanValue( $wikitext, $i, $params[$p] );
164                    }
165
166                    // Note this parser intentionally accepts incomplete, cut-off templates
167                    $max = min( $max, $i + 2 );
168                    break;
169                }
170
171                $nesting--;
172                // Skip the second bracket, it can't be the end of another pair
173                $i++;
174            } elseif ( $currentChar === '|' && !$inWikiLink && !$nesting ) {
175                if ( isset( $params[$p] ) ) {
176                    $this->scanValue( $wikitext, $i, $params[$p] );
177                }
178
179                $params[++$p] = [
180                    'number' => ++$number,
181                    'offset' => $i + 1,
182                    'format' => $this->scanFormatSnippet( $wikitext, $i ) . '_=',
183                    'valueOffset' => $i + 1,
184                    'value' => substr( $wikitext, $i + 1 ),
185                ];
186            } elseif ( $currentChar === '='
187                && !$nesting
188                && isset( $params[$p] )
189                && !isset( $params[$p]['name'] )
190            ) {
191                unset( $params[$p]['number'] );
192                $number--;
193
194                $offset = $params[$p]['offset'];
195                $name = rtrim( substr( $wikitext, $offset, $i - $offset ) );
196                $params[$p]['name'] = ltrim( $name );
197                // Skip (optional) whitespace between | and the parameter name
198                $params[$p]['offset'] += strlen( $name ) - strlen( $params[$p]['name'] );
199                // @phan-suppress-next-line PhanTypeMismatchArgumentInternal "format" is guaranteed
200                $params[$p]['format'] = rtrim( $params[$p]['format'], '=' )
201                    . $this->scanFormatSnippet( $wikitext, $i );
202                $params[$p]['valueOffset'] = $i + 1;
203            }
204        }
205
206        return [
207            'end' => $max,
208            'parameters' => $params,
209        ];
210    }
211
212    /**
213     * @return string Substring from $wikitext including the character at $offset, and all
214     *  whitespace left and right
215     */
216    private function scanFormatSnippet( string $wikitext, int $offset ): string {
217        $from = $offset;
218        while ( $from > 0 && ctype_space( $wikitext[$from - 1] ) ) {
219            $from--;
220        }
221
222        $to = $offset + 1;
223        $max = strlen( $wikitext );
224        while ( $to < $max && ctype_space( $wikitext[$to] ) ) {
225            $to++;
226        }
227
228        return substr( $wikitext, $from, $to - $from );
229    }
230
231    private function scanValue( string $wikitext, int $end, array &$param ): void {
232        // To not place replacements for empty values in the next line, we skip horizontal
233        // whitespace only
234        preg_match( '/(?!\h)/u', $wikitext, $matches, PREG_OFFSET_CAPTURE, $param['valueOffset'] );
235        $newOffset = $matches[0][1];
236        $param['valueOffset'] = $newOffset;
237        $param['value'] = rtrim( substr( $wikitext, $newOffset, $end - $newOffset ) );
238    }
239
240    /**
241     * @param string $wikitext
242     * @param array[] $parameters "parameters" list as returned by {@see parseTemplateParameters}
243     * @param array[] $replacements Array mapping old to new parameters, as returned by
244     *  {@see WikitextConversions::getTemplateParameters}
245     * @param string|null $languageTemplate Name of a language template to wrap parameters in,
246     *  e.g. "de" for the {{de|…}} template
247     */
248    private function renameTemplateParameters(
249        string $wikitext,
250        array $parameters,
251        array $replacements,
252        ?string $languageTemplate
253    ): string {
254        if ( $replacements === [] ) {
255            return $wikitext;
256        }
257
258        // Replacements must be applied in reverse order to not mess with the captured offsets!
259        for ( $i = count( $parameters ); $i-- > 0; ) {
260            $from = $parameters[$i]['name'] ?? $parameters[$i]['number'];
261
262            if ( isset( $replacements[$from] ) ) {
263                if ( $languageTemplate !== null && $replacements[$from]['addLanguageTemplate'] ) {
264                    $regex = '/\{\{\s*(' . preg_quote( $languageTemplate, '/' ) . '|[a-z]{2})\s*\|/A';
265                    $start = $parameters[$i]['valueOffset'];
266                    if ( !preg_match( $regex, $wikitext, $matches, 0, $start ) ) {
267                        $end = $start + strlen( $parameters[$i]['value'] );
268                        $wikitext = substr_replace( $wikitext, '}}', $end, 0 );
269                        $wikitext = substr_replace( $wikitext, '{{' . $languageTemplate . '|', $start, 0 );
270                    }
271                }
272
273                $to = $replacements[$from]['target'];
274                $offset = $parameters[$i]['offset'];
275                if ( isset( $parameters[$i]['name'] ) ) {
276                    $wikitext = substr_replace( $wikitext, $to, $offset, strlen( $from ) );
277                } else {
278                    // Insert parameter name when the source parameter was unnamed
279                    $wikitext = substr_replace( $wikitext, $to . '=', $offset, 0 );
280                }
281            }
282        }
283
284        return $wikitext;
285    }
286
287    /**
288     * @param string $wikitext
289     * @param string[] $required List of parameter name => string value pairs
290     * @param array[] $parameters "parameters" list as returned by {@see parseTemplateParameters}
291     * @param int $offset Exact position where to insert the new parameter
292     */
293    private function addRequiredTemplateParameters(
294        string $wikitext,
295        array $required,
296        array $parameters,
297        int $offset
298    ): string {
299        if ( !$required ) {
300            return $wikitext;
301        }
302
303        foreach ( $parameters as $param ) {
304            $name = $param['name'] ?? $param['number'];
305            unset( $required[$name] );
306        }
307
308        $format = $parameters[0]['format'] ?? '|_=';
309        $newWikitext = '';
310        foreach ( $required as $name => $value ) {
311            $newWikitext .= str_replace( '_', $name, $format ) . $value;
312        }
313
314        return substr_replace( $wikitext, $newWikitext, $offset, 0 );
315    }
316
317}