Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 141
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 / 138
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 / 19
0.00% covered (danger)
0.00%
0 / 1
20
 newHtmlOutputRendererHelper
0.00% covered (danger)
0.00%
0 / 10
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 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 getParsoidOutput
0.00% covered (danger)
0.00%
0 / 7
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 / 10
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\Parser\ParserOutput;
23use MediaWiki\Rest\Handler\Helper\HtmlOutputRendererHelper;
24use MediaWiki\Revision\SlotRecord;
25use MediaWiki\Title\Title;
26use MediaWiki\User\User;
27use Wikimedia\Bcp47Code\Bcp47Code;
28use Wikimedia\Diff\ArrayDiffFormatter;
29use Wikimedia\Diff\ComplexityException;
30use Wikimedia\Diff\Diff;
31
32require_once __DIR__ . '/Maintenance.php';
33
34/**
35 * Maintenance script that compares variant conversion output between Parser and
36 * HtmlOutputRendererHelper.
37 *
38 * @ingroup Maintenance
39 */
40class CompareLanguageConverterOutput extends Maintenance {
41    public function __construct() {
42        parent::__construct();
43        $this->addDescription( 'Compares variant conversion output between Parser and HtmlOutputRendererHelper' );
44        $this->addArg(
45            'page-title',
46            'Name of the page to be parsed and compared',
47            true
48        );
49        $this->addArg(
50            'target-variant',
51            'Target variant language code to transform the content to',
52            true
53        );
54    }
55
56    public function execute() {
57        $mwInstance = $this->getServiceContainer();
58
59        $pageName = $this->getArg( 'page-title' );
60        $pageTitle = Title::newFromText( $pageName );
61
62        if ( !$pageTitle || !$pageTitle->exists() ) {
63            $this->fatalError( "Title with name $pageName not found" );
64        }
65
66        $targetVariantCode = $this->getArg( 'target-variant' );
67        $languageNameUtils = $mwInstance->getLanguageNameUtils();
68        if ( !$languageNameUtils->isValidBuiltInCode( $targetVariantCode ) ) {
69            $this->fatalError( "$targetVariantCode is not a supported variant" );
70        }
71        $targetVariant = $mwInstance->getLanguageFactory()->getLanguage(
72            $targetVariantCode
73        );
74
75        $user = User::newSystemUser( User::MAINTENANCE_SCRIPT_USER, [ 'steal' => true ] );
76        $baseLanguage = $pageTitle->getPageLanguage();
77
78        $parserOutput = $this->getParserOutput( $pageTitle, $baseLanguage, $targetVariant );
79        $parsoidOutput = $this->getParsoidOutput( $pageTitle, $targetVariant, $user );
80        $converterUsed = $this->getConverterUsed( $parsoidOutput );
81
82        $this->compareOutput( $parserOutput, $parsoidOutput, $converterUsed );
83
84        return true;
85    }
86
87    private function newHtmlOutputRendererHelper(): HtmlOutputRendererHelper {
88        $services = $this->getServiceContainer();
89
90        $helper = new HtmlOutputRendererHelper(
91            $services->getParsoidOutputStash(),
92            new NullStatsdDataFactory(),
93            $services->getParsoidOutputAccess(),
94            $services->getHtmlTransformFactory(),
95            $services->getContentHandlerFactory(),
96            $services->getLanguageFactory()
97        );
98        return $helper;
99    }
100
101    private function getParserOptions( Language $language ): ParserOptions {
102        $parserOpts = ParserOptions::newFromAnon();
103        $parserOpts->setTargetLanguage( $language );
104        $parserOpts->disableContentConversion( false );
105        $parserOpts->disableTitleConversion( false );
106
107        return $parserOpts;
108    }
109
110    private function getParserOutput(
111        Title $pageTitle,
112        Language $baseLanguage,
113        Language $targetVariant
114    ): ParserOutput {
115        // We update the default language variant because we want Parser to
116        // perform variant conversion to it.
117        global $wgDefaultLanguageVariant;
118        $wgDefaultLanguageVariant = $targetVariant->getCode();
119
120        $mwInstance = $this->getServiceContainer();
121
122        $languageFactory = $mwInstance->getLanguageFactory();
123        $parser = $mwInstance->getParser();
124        $parserOptions = $this->getParserOptions(
125            $languageFactory->getParentLanguage( $baseLanguage )
126        );
127
128        $content = $mwInstance->getRevisionLookup()
129            ->getRevisionByTitle( $pageTitle )
130            ->getContent( SlotRecord::MAIN );
131        $wikiContent = ( $content instanceof TextContent ) ? $content->getText() : '';
132
133        return $parser->parse( $wikiContent, $pageTitle, $parserOptions );
134    }
135
136    private function getParsoidOutput(
137        Title $pageTitle,
138        Bcp47Code $targetVariant,
139        User $user
140    ): ParserOutput {
141        $htmlOutputRendererHelper = $this->newHtmlOutputRendererHelper();
142        $htmlOutputRendererHelper->init( $pageTitle, [
143            'stash' => false,
144            'flavor' => 'view',
145        ], $user );
146        $htmlOutputRendererHelper->setVariantConversionLanguage( $targetVariant );
147
148        return $htmlOutputRendererHelper->getHtml();
149    }
150
151    private function getWords( string $output ): array {
152        $tagsRemoved = strip_tags( $output );
153        $words = preg_split( '/\s+/', trim( $tagsRemoved ), -1, PREG_SPLIT_NO_EMPTY );
154        return $words;
155    }
156
157    private function getBody( string $output ): string {
158        $dom = new DOMDocument();
159        // phpcs:disable Generic.PHP.NoSilencedErrors.Discouraged
160        @$dom->loadHTML( $output );
161        $body = $dom->getElementsByTagName( 'body' )->item( 0 );
162        if ( $body === null ) {
163            // Body element not present
164            return $output;
165        }
166
167        return $body->textContent;
168    }
169
170    private function compareOutput(
171        ParserOutput $parserOutput,
172        ParserOutput $parsoidOutput,
173        string $converterUsed
174    ): void {
175        $parsoidText = $parsoidOutput->getText( [ 'deduplicateStyles' => false ] );
176        $parserText = $parserOutput->getText( [ 'deduplicateStyles' => false ] );
177
178        $parsoidWords = $this->getWords( $this->getBody( $parsoidText ) );
179        $parserWords = $this->getWords( $parserText );
180
181        $parserWordCount = count( $parserWords );
182        $parsoidWordCount = count( $parsoidWords );
183        $this->output( "Word count: Parsoid: $parsoidWordCount; Parser: $parserWordCount\n" );
184
185        $this->outputSimilarity( $parsoidWords, $parserWords );
186        $this->output( "\n" );
187        $this->outputDiff( $parsoidWords, $parserWords, $converterUsed );
188    }
189
190    private function getConverterUsed( ParserOutput $parsoidOutput ): string {
191        $isCoreConverterUsed = strpos(
192            $parsoidOutput->getRawText(),
193            'Variant conversion performed using the core LanguageConverter'
194        );
195
196        if ( $isCoreConverterUsed ) {
197            return 'Core LanguageConverter';
198        } else {
199            return 'Parsoid LanguageConverter';
200        }
201    }
202
203    // Inspired from: https://stackoverflow.com/a/55927237/903324
204    private function mb_sprintf( string $format, ...$args ): string {
205        $params = $args;
206
207        return sprintf(
208            preg_replace_callback(
209                '/(?<=%|%-)\d+(?=s)/',
210                static function ( array $matches ) use ( &$params ) {
211                    $value = array_shift( $params );
212
213                    return (string)( strlen( $value ) - mb_strlen( $value ) + $matches[0] );
214                },
215                $format
216            ),
217            ...$args
218        );
219    }
220
221    private function outputSimilarity( array $parsoidWords, array $parserWords ): void {
222        $parsoidOutput = implode( ' ', $parsoidWords );
223        $parserOutput = implode( ' ', $parserWords );
224        $this->output(
225            'Total characters: Parsoid: ' . strlen( $parsoidOutput ) .
226            '; Parser: ' . strlen( $parserOutput ) . "\n"
227        );
228
229        $similarityPercent = 0;
230        $similarCharacters = similar_text( $parsoidOutput, $parserOutput, $similarityPercent );
231        $similarityPercent = round( $similarityPercent, 2 );
232
233        $this->output(
234            "Similarity via similar_text(): $similarityPercent%; Similar characters: $similarCharacters"
235        );
236    }
237
238    private function outputDiff( array $parsoidWords, array $parserWords, string $converterUsed ): void {
239        $out = str_repeat( '-', 96 ) . "\n";
240        $out .= sprintf( "| %5s | %-35s | %-35s | %-8s |\n", 'Line', 'Parsoid', 'Parser', 'Diff' );
241        $out .= sprintf( "| %5s | %-35s | %-35s | %-8s |\n", '', "($converterUsed)", '', '' );
242        $out .= str_repeat( '-', 96 ) . "\n";
243
244        try {
245            $diff = new Diff( $parsoidWords, $parserWords );
246        } catch ( ComplexityException $e ) {
247            $this->output( $e->getMessage() );
248            $this->error( 'Encountered ComplexityException while computing diff' );
249        }
250
251        // Print the difference between the words
252        $wordDiffFormat = ( new ArrayDiffFormatter() )->format( $diff );
253        foreach ( $wordDiffFormat as $index => $wordDiff ) {
254            $action = $wordDiff['action'];
255            $old = $wordDiff['old'] ?? null;
256            $new = $wordDiff['new'] ?? null;
257
258            $out .= $this->mb_sprintf(
259                "| %5s | %-35s | %-35s | %-8s |\n",
260                str_pad( (string)( $index + 1 ), 5, ' ', STR_PAD_LEFT ),
261                mb_strimwidth( $old ?? '- N/A -', 0, 35, '…' ),
262                mb_strimwidth( $new ?? '- N/A -', 0, 35, '…' ),
263                $action
264            );
265        }
266
267        // Print the footer.
268        $out .= str_repeat( '-', 96 ) . "\n";
269        $this->output( "\n" . $out );
270    }
271}
272
273$maintClass = CompareLanguageConverterOutput::class;
274require_once RUN_MAINTENANCE_IF_MAIN;