Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 155
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
CompareLanguageConverterOutput
0.00% covered (danger)
0.00%
0 / 155
0.00% covered (danger)
0.00%
0 / 13
462
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
20
 newPageRestHelperFactory
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
2
 getParserOptions
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getParserOutput
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 getParsoidOutput
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 getWords
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getBody
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 compareOutput
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getConverterUsed
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 mb_sprintf
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 outputSimilarity
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 outputDiff
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 * @ingroup Maintenance
20 */
21
22use MediaWiki\Config\ServiceOptions;
23use MediaWiki\Content\TextContent;
24use MediaWiki\Language\Language;
25use MediaWiki\Parser\ParserOptions;
26use MediaWiki\Parser\ParserOutput;
27use MediaWiki\Rest\Handler\Helper\PageRestHelperFactory;
28use MediaWiki\Revision\SlotRecord;
29use MediaWiki\Title\Title;
30use MediaWiki\User\User;
31use Wikimedia\Bcp47Code\Bcp47Code;
32use Wikimedia\Diff\ArrayDiffFormatter;
33use Wikimedia\Diff\ComplexityException;
34use Wikimedia\Diff\Diff;
35use Wikimedia\Stats\NullStatsdDataFactory;
36use Wikimedia\Stats\StatsFactory;
37
38// @codeCoverageIgnoreStart
39require_once __DIR__ . '/Maintenance.php';
40// @codeCoverageIgnoreEnd
41
42/**
43 * Maintenance script that compares variant conversion output between Parser and
44 * HtmlOutputRendererHelper.
45 *
46 * @ingroup Maintenance
47 */
48class CompareLanguageConverterOutput extends Maintenance {
49    public function __construct() {
50        parent::__construct();
51        $this->addDescription( 'Compares variant conversion output between Parser and HtmlOutputRendererHelper' );
52        $this->addArg(
53            'page-title',
54            'Name of the page to be parsed and compared',
55            true
56        );
57        $this->addArg(
58            'target-variant',
59            'Target variant language code to transform the content to',
60            true
61        );
62    }
63
64    public function execute() {
65        $mwInstance = $this->getServiceContainer();
66
67        $pageName = $this->getArg( 'page-title' );
68        $pageTitle = Title::newFromText( $pageName );
69
70        if ( !$pageTitle || !$pageTitle->exists() ) {
71            $this->fatalError( "Title with name $pageName not found" );
72        }
73
74        $targetVariantCode = $this->getArg( 'target-variant' );
75        $languageNameUtils = $mwInstance->getLanguageNameUtils();
76        if ( !$languageNameUtils->isValidBuiltInCode( $targetVariantCode ) ) {
77            $this->fatalError( "$targetVariantCode is not a supported variant" );
78        }
79        $targetVariant = $mwInstance->getLanguageFactory()->getLanguage(
80            $targetVariantCode
81        );
82
83        $user = User::newSystemUser( User::MAINTENANCE_SCRIPT_USER, [ 'steal' => true ] );
84        $baseLanguage = $pageTitle->getPageLanguage();
85
86        $parserOutput = $this->getParserOutput( $pageTitle, $baseLanguage, $targetVariant );
87        $parsoidOutput = $this->getParsoidOutput( $pageTitle, $targetVariant, $user );
88        $converterUsed = $this->getConverterUsed( $parsoidOutput );
89
90        $this->compareOutput( $parserOutput->getContentHolderText(), $parsoidOutput->getContentHolderText(),
91            $converterUsed );
92        return true;
93    }
94
95    private function newPageRestHelperFactory(): PageRestHelperFactory {
96        $services = $this->getServiceContainer();
97
98        $factory = new PageRestHelperFactory(
99            new ServiceOptions( PageRestHelperFactory::CONSTRUCTOR_OPTIONS, $services->getMainConfig() ),
100            $services->getRevisionLookup(),
101            $services->getRevisionRenderer(),
102            $services->getTitleFormatter(),
103            $services->getPageStore(),
104            $services->getParsoidOutputStash(),
105            new NullStatsdDataFactory(),
106            $services->getParserOutputAccess(),
107            $services->getParsoidSiteConfig(),
108            $services->getHtmlTransformFactory(),
109            $services->getContentHandlerFactory(),
110            $services->getLanguageFactory(),
111            $services->getRedirectStore(),
112            $services->getLanguageConverterFactory(),
113            $services->getTitleFactory(),
114            $services->getConnectionProvider(),
115            $services->getChangeTagsStore(),
116            StatsFactory::newNull()
117        );
118        return $factory;
119    }
120
121    private function getParserOptions( Language $language ): ParserOptions {
122        $parserOpts = ParserOptions::newFromAnon();
123        $parserOpts->setTargetLanguage( $language );
124        $parserOpts->disableContentConversion( false );
125        $parserOpts->disableTitleConversion( false );
126
127        return $parserOpts;
128    }
129
130    private function getParserOutput(
131        Title $pageTitle,
132        Language $baseLanguage,
133        Language $targetVariant
134    ): ParserOutput {
135        // We update the default language variant because we want Parser to
136        // perform variant conversion to it.
137        global $wgDefaultLanguageVariant;
138        $wgDefaultLanguageVariant = $targetVariant->getCode();
139
140        $mwInstance = $this->getServiceContainer();
141
142        $languageFactory = $mwInstance->getLanguageFactory();
143        $parser = $mwInstance->getParser();
144        $parserOptions = $this->getParserOptions(
145            $languageFactory->getParentLanguage( $baseLanguage )
146        );
147
148        $content = $mwInstance->getRevisionLookup()
149            ->getRevisionByTitle( $pageTitle )
150            ->getContent( SlotRecord::MAIN );
151        $wikiContent = ( $content instanceof TextContent ) ? $content->getText() : '';
152
153        $po = $parser->parse( $wikiContent, $pageTitle, $parserOptions );
154        // TODO T371008 consider if using the Content framework makes sense instead of creating the pipeline
155        $pipeline = $mwInstance->getDefaultOutputPipeline();
156        $options = [ 'deduplicateStyles' => false ];
157        return $pipeline->run( $po, $parserOptions, $options );
158    }
159
160    private function getParsoidOutput(
161        Title $pageTitle,
162        Bcp47Code $targetVariant,
163        User $user
164    ): ParserOutput {
165        $parserOptions = ParserOptions::newFromAnon();
166        $htmlOutputRendererHelper = $this->newPageRestHelperFactory()->newHtmlOutputRendererHelper( $pageTitle, [
167            'stash' => false,
168            'flavor' => 'view',
169        ], $user, null, false, $parserOptions );
170        $htmlOutputRendererHelper->setVariantConversionLanguage( $targetVariant );
171
172        $po = $htmlOutputRendererHelper->getHtml();
173        $pipeline = $this->getServiceContainer()->getDefaultOutputPipeline();
174        $options = [ 'deduplicateStyles' => false ];
175        return $pipeline->run( $po, $parserOptions, $options );
176    }
177
178    private function getWords( string $output ): array {
179        $tagsRemoved = strip_tags( $output );
180        $words = preg_split( '/\s+/', trim( $tagsRemoved ), -1, PREG_SPLIT_NO_EMPTY );
181        return $words;
182    }
183
184    private function getBody( string $output ): string {
185        $dom = new DOMDocument();
186        // phpcs:disable Generic.PHP.NoSilencedErrors.Discouraged
187        @$dom->loadHTML( $output );
188        $body = $dom->getElementsByTagName( 'body' )->item( 0 );
189        if ( $body === null ) {
190            // Body element not present
191            return $output;
192        }
193
194        return $body->textContent;
195    }
196
197    private function compareOutput(
198        string $parserText,
199        string $parsoidText,
200        string $converterUsed
201    ): void {
202        $parsoidWords = $this->getWords( $this->getBody( $parsoidText ) );
203        $parserWords = $this->getWords( $parserText );
204
205        $parserWordCount = count( $parserWords );
206        $parsoidWordCount = count( $parsoidWords );
207        $this->output( "Word count: Parsoid: $parsoidWordCount; Parser: $parserWordCount\n" );
208
209        $this->outputSimilarity( $parsoidWords, $parserWords );
210        $this->output( "\n" );
211        $this->outputDiff( $parsoidWords, $parserWords, $converterUsed );
212    }
213
214    private function getConverterUsed( ParserOutput $parsoidOutput ): string {
215        $isCoreConverterUsed = strpos(
216            $parsoidOutput->getRawText(),
217            'Variant conversion performed using the core LanguageConverter'
218        );
219
220        if ( $isCoreConverterUsed ) {
221            return 'Core LanguageConverter';
222        } else {
223            return 'Parsoid LanguageConverter';
224        }
225    }
226
227    // Inspired from: https://stackoverflow.com/a/55927237/903324
228    private function mb_sprintf( string $format, ...$args ): string {
229        $params = $args;
230
231        return sprintf(
232            preg_replace_callback(
233                '/(?<=%|%-)\d+(?=s)/',
234                static function ( array $matches ) use ( &$params ) {
235                    $value = array_shift( $params );
236
237                    return (string)( strlen( $value ) - mb_strlen( $value ) + $matches[0] );
238                },
239                $format
240            ),
241            ...$args
242        );
243    }
244
245    private function outputSimilarity( array $parsoidWords, array $parserWords ): void {
246        $parsoidOutput = implode( ' ', $parsoidWords );
247        $parserOutput = implode( ' ', $parserWords );
248        $this->output(
249            'Total characters: Parsoid: ' . strlen( $parsoidOutput ) .
250            '; Parser: ' . strlen( $parserOutput ) . "\n"
251        );
252
253        $similarityPercent = 0;
254        $similarCharacters = similar_text( $parsoidOutput, $parserOutput, $similarityPercent );
255        $similarityPercent = round( $similarityPercent, 2 );
256
257        $this->output(
258            "Similarity via similar_text(): $similarityPercent%; Similar characters: $similarCharacters"
259        );
260    }
261
262    private function outputDiff( array $parsoidWords, array $parserWords, string $converterUsed ): void {
263        $out = str_repeat( '-', 96 ) . "\n";
264        $out .= sprintf( "| %5s | %-35s | %-35s | %-8s |\n", 'Line', 'Parsoid', 'Parser', 'Diff' );
265        $out .= sprintf( "| %5s | %-35s | %-35s | %-8s |\n", '', "($converterUsed)", '', '' );
266        $out .= str_repeat( '-', 96 ) . "\n";
267
268        try {
269            $diff = new Diff( $parsoidWords, $parserWords );
270        } catch ( ComplexityException $e ) {
271            $this->output( $e->getMessage() );
272            $this->error( 'Encountered ComplexityException while computing diff' );
273        }
274
275        // Print the difference between the words
276        $wordDiffFormat = ( new ArrayDiffFormatter() )->format( $diff );
277        foreach ( $wordDiffFormat as $index => $wordDiff ) {
278            $action = $wordDiff['action'];
279            $old = $wordDiff['old'] ?? null;
280            $new = $wordDiff['new'] ?? null;
281
282            $out .= $this->mb_sprintf(
283                "| %5s | %-35s | %-35s | %-8s |\n",
284                str_pad( (string)( $index + 1 ), 5, ' ', STR_PAD_LEFT ),
285                mb_strimwidth( $old ?? '- N/A -', 0, 35, '…' ),
286                mb_strimwidth( $new ?? '- N/A -', 0, 35, '…' ),
287                $action
288            );
289        }
290
291        // Print the footer.
292        $out .= str_repeat( '-', 96 ) . "\n";
293        $this->output( "\n" . $out );
294    }
295}
296
297// @codeCoverageIgnoreStart
298$maintClass = CompareLanguageConverterOutput::class;
299require_once RUN_MAINTENANCE_IF_MAIN;
300// @codeCoverageIgnoreEnd