Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
80.80% covered (warning)
80.80%
101 / 125
60.00% covered (warning)
60.00%
6 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Cite
80.80% covered (warning)
80.80%
101 / 125
60.00% covered (warning)
60.00%
6 / 10
51.32
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 ref
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 guardedRef
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
9
 parseArguments
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 references
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
4.01
 parseReferencesTagContent
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 formatReferencesErrors
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 formatReferences
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 checkRefsNoReferences
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
8
 __clone
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * A parser extension that adds two tags, <ref> and <references> for adding
5 * citations to pages
6 *
7 * @ingroup Extensions
8 *
9 * Documentation
10 * @link https://www.mediawiki.org/wiki/Extension:Cite/Cite.php
11 *
12 * <cite> definition in HTML
13 * @link http://www.w3.org/TR/html4/struct/text.html#edef-CITE
14 *
15 * <cite> definition in XHTML 2.0
16 * @link http://www.w3.org/TR/2005/WD-xhtml2-20050527/mod-text.html#edef_text_cite
17 *
18 * @bug https://phabricator.wikimedia.org/T6579
19 *
20 * @author Ævar Arnfjörð Bjarmason <avarab@gmail.com>
21 * @copyright Copyright © 2005, Ævar Arnfjörð Bjarmason
22 * @license GPL-2.0-or-later
23 */
24
25namespace Cite;
26
27use LogicException;
28use MediaWiki\Html\Html;
29use MediaWiki\MediaWikiServices;
30use MediaWiki\Parser\Sanitizer;
31use Parser;
32use StatusValue;
33
34/**
35 * @license GPL-2.0-or-later
36 */
37class Cite {
38
39    public const DEFAULT_GROUP = '';
40
41    /**
42     * Wikitext attribute name for Book Referencing.
43     */
44    public const BOOK_REF_ATTRIBUTE = 'extends';
45
46    /**
47     * Page property key for the Book Referencing `extends` attribute.
48     */
49    public const BOOK_REF_PROPERTY = 'ref-extends';
50
51    private bool $isSectionPreview;
52    private FootnoteMarkFormatter $footnoteMarkFormatter;
53    private ReferenceListFormatter $referenceListFormatter;
54    private ErrorReporter $errorReporter;
55
56    /**
57     * True when a <ref> tag is being processed.
58     * Used to avoid infinite recursion
59     */
60    private bool $inRefTag = false;
61
62    /**
63     * @var null|string The current group name while parsing nested <ref> in <references>. Null when
64     *  parsing <ref> outside of <references>. Warning, an empty string is a valid group name!
65     */
66    private ?string $inReferencesGroup = null;
67
68    /**
69     * Error stack used when defining refs in <references>
70     */
71    private StatusValue $mReferencesErrors;
72    private ReferenceStack $referenceStack;
73
74    public function __construct( Parser $parser ) {
75        $this->isSectionPreview = $parser->getOptions()->getIsSectionPreview();
76        $messageLocalizer = new ReferenceMessageLocalizer( $parser->getContentLanguage() );
77        $this->errorReporter = new ErrorReporter( $messageLocalizer );
78        $this->mReferencesErrors = StatusValue::newGood();
79        $this->referenceStack = new ReferenceStack();
80        $anchorFormatter = new AnchorFormatter();
81        $this->footnoteMarkFormatter = new FootnoteMarkFormatter(
82            $this->errorReporter,
83            $anchorFormatter,
84            $messageLocalizer
85        );
86        $this->referenceListFormatter = new ReferenceListFormatter(
87            $this->errorReporter,
88            $anchorFormatter,
89            $messageLocalizer
90        );
91    }
92
93    /**
94     * Callback function for <ref>
95     *
96     * @param Parser $parser
97     * @param ?string $text Raw, untrimmed wikitext content of the <ref> tag, if any
98     * @param string[] $argv Arguments as given in <ref name=…>, already trimmed
99     *
100     * @return string|null Null in case a <ref> tag is not allowed in the current context
101     */
102    public function ref( Parser $parser, ?string $text, array $argv ): ?string {
103        if ( $this->inRefTag ) {
104            return null;
105        }
106
107        $this->inRefTag = true;
108        $ret = $this->guardedRef( $parser, $text, $argv );
109        $this->inRefTag = false;
110
111        return $ret;
112    }
113
114    /**
115     * @param Parser $parser
116     * @param ?string $text Raw, untrimmed wikitext content of the <ref> tag, if any
117     * @param string[] $argv Arguments as given in <ref name=…>, already trimmed
118     *
119     * @return string HTML
120     */
121    private function guardedRef(
122        Parser $parser,
123        ?string $text,
124        array $argv
125    ): string {
126        // Tag every page where Book Referencing has been used, whether or not the ref tag is valid.
127        // This code and the page property will be removed once the feature is stable.  See T237531.
128        if ( array_key_exists( self::BOOK_REF_ATTRIBUTE, $argv ) ) {
129            $parser->getOutput()->setUnsortedPageProperty( self::BOOK_REF_PROPERTY );
130        }
131
132        $status = $this->parseArguments(
133            $argv,
134            [ 'group', 'name', self::BOOK_REF_ATTRIBUTE, 'follow', 'dir' ]
135        );
136        $arguments = $status->getValue();
137        // Use the default group, or the references group when inside one.
138        $arguments['group'] ??= $this->inReferencesGroup ?? self::DEFAULT_GROUP;
139
140        $validator = new Validator(
141            $this->referenceStack,
142            $this->inReferencesGroup,
143            $this->isSectionPreview,
144            MediaWikiServices::getInstance()->getMainConfig()->get( 'CiteBookReferencing' )
145        );
146        // @phan-suppress-next-line PhanParamTooFewUnpack No good way to document it.
147        $status->merge( $validator->validateRef( $text, ...array_values( $arguments ) ) );
148
149        // Validation cares about the difference between null and empty, but from here on we don't
150        if ( $text !== null && trim( $text ) === '' ) {
151            $text = null;
152        }
153
154        if ( $this->inReferencesGroup !== null ) {
155            if ( !$status->isGood() ) {
156                // We know we are in the middle of a <references> tag and can't display errors in place
157                $this->mReferencesErrors->merge( $status );
158            } elseif ( $text !== null ) {
159                // Validation made sure we always have group and name while in <references>
160                $this->referenceStack->listDefinedRef( $arguments['group'], $arguments['name'], $text );
161            }
162            return '';
163        }
164
165        if ( !$status->isGood() ) {
166            $this->referenceStack->pushInvalidRef();
167
168            // FIXME: If we ever have multiple errors, these must all be presented to the user,
169            //  so they know what to correct.
170            // TODO: Make this nicer, see T238061
171            return $this->errorReporter->firstError( $parser, $status );
172        }
173
174        // @phan-suppress-next-line PhanParamTooFewUnpack No good way to document it.
175        $ref = $this->referenceStack->pushRef(
176            $parser->getStripState(), $text, $argv, ...array_values( $arguments ) );
177        return $ref
178            ? $this->footnoteMarkFormatter->linkRef( $parser, $arguments['group'], $ref )
179            : '';
180    }
181
182    /**
183     * @param string[] $argv The argument vector
184     * @param string[] $allowedAttributes Allowed attribute names
185     *
186     * @return StatusValue Either an error, or has a value with the dictionary of field names and
187     * parsed or default values.  Missing attributes will be `null`.
188     */
189    private function parseArguments( array $argv, array $allowedAttributes ): StatusValue {
190        $expected = count( $allowedAttributes );
191        $allValues = array_merge( array_fill_keys( $allowedAttributes, null ), $argv );
192        if ( isset( $allValues['dir'] ) ) {
193            // @phan-suppress-next-line PhanTypeMismatchArgumentNullableInternal False positive
194            $allValues['dir'] = strtolower( $allValues['dir'] );
195        }
196
197        $status = StatusValue::newGood( array_slice( $allValues, 0, $expected ) );
198
199        if ( count( $allValues ) > $expected ) {
200            // A <ref> must have a name (can be null), but <references> can't have one
201            $status->fatal( in_array( 'name', $allowedAttributes, true )
202                ? 'cite_error_ref_too_many_keys'
203                : 'cite_error_references_invalid_parameters'
204            );
205        }
206
207        return $status;
208    }
209
210    /**
211     * Callback function for <references>
212     *
213     * @param Parser $parser
214     * @param ?string $text Raw, untrimmed wikitext content of the <references> tag, if any
215     * @param string[] $argv Arguments as given in <references …>, already trimmed
216     *
217     * @return string|null Null in case a <references> tag is not allowed in the current context
218     */
219    public function references( Parser $parser, ?string $text, array $argv ): ?string {
220        if ( $this->inRefTag || $this->inReferencesGroup !== null ) {
221            return null;
222        }
223
224        $status = $this->parseArguments( $argv, [ 'group', 'responsive' ] );
225        $arguments = $status->getValue();
226
227        $this->inReferencesGroup = $arguments['group'] ?? self::DEFAULT_GROUP;
228
229        $status->merge( $this->parseReferencesTagContent( $parser, $text ) );
230        if ( !$status->isGood() ) {
231            $ret = $this->errorReporter->firstError( $parser, $status );
232        } else {
233            $responsive = $arguments['responsive'];
234            $ret = $this->formatReferences( $parser, $this->inReferencesGroup, $responsive );
235            // Append errors collected while {@see parseReferencesTagContent} processed <ref> tags
236            // in <references>
237            $ret .= $this->formatReferencesErrors( $parser );
238        }
239
240        $this->inReferencesGroup = null;
241
242        return $ret;
243    }
244
245    /**
246     * @param Parser $parser
247     * @param ?string $text Raw, untrimmed wikitext content of the <references> tag, if any
248     *
249     * @return StatusValue
250     */
251    private function parseReferencesTagContent( Parser $parser, ?string $text ): StatusValue {
252        // Nothing to parse in an empty <references /> tag
253        if ( $text === null || trim( $text ) === '' ) {
254            return StatusValue::newGood();
255        }
256
257        if ( preg_match( '{' . preg_quote( Parser::MARKER_PREFIX ) . '-(?i:references)-}', $text ) ) {
258            return StatusValue::newFatal( 'cite_error_included_references' );
259        }
260
261        // Detect whether we were sent already rendered <ref>s. Mostly a side effect of using
262        // {{#tag:references}}. The following assumes that the parsed <ref>s sent within the
263        // <references> block were the most recent calls to <ref>. This assumption is true for
264        // all known use cases, but not strictly enforced by the parser. It is possible that
265        // some unusual combination of #tag, <references> and conditional parser functions could
266        // be created that would lead to malformed references here.
267        preg_match_all( '{' . preg_quote( Parser::MARKER_PREFIX ) . '-(?i:ref)-}', $text, $matches );
268        $count = count( $matches[0] );
269
270        // Undo effects of calling <ref> while unaware of being contained in <references>
271        foreach ( $this->referenceStack->rollbackRefs( $count ) as $call ) {
272            // Rerun <ref> call with the <references> context now being known
273            $this->guardedRef( $parser, ...$call );
274        }
275
276        // Parse the <references> content to process any unparsed <ref> tags, but drop the resulting
277        // HTML
278        $parser->recursiveTagParse( $text );
279
280        return StatusValue::newGood();
281    }
282
283    private function formatReferencesErrors( Parser $parser ): string {
284        $html = '';
285        foreach ( $this->mReferencesErrors->getErrors() as $error ) {
286            if ( $html ) {
287                $html .= "<br />\n";
288            }
289            $html .= $this->errorReporter->halfParsed( $parser, $error['message'], ...$error['params'] );
290        }
291        $this->mReferencesErrors = StatusValue::newGood();
292        return $html ? "\n$html" : '';
293    }
294
295    /**
296     * @param Parser $parser
297     * @param string $group
298     * @param string|null $responsive Defaults to $wgCiteResponsiveReferences when not set
299     *
300     * @return string HTML
301     */
302    private function formatReferences(
303        Parser $parser,
304        string $group,
305        string $responsive = null
306    ): string {
307        $responsiveReferences = MediaWikiServices::getInstance()->getMainConfig()->get( 'CiteResponsiveReferences' );
308
309        return $this->referenceListFormatter->formatReferences(
310            $parser,
311            $this->referenceStack->popGroup( $group ),
312            $responsive !== null ? $responsive !== '0' : $responsiveReferences,
313            $this->isSectionPreview
314        );
315    }
316
317    /**
318     * Called at the end of page processing to append a default references
319     * section, if refs were used without a main references tag. If there are references
320     * in a custom group, and there is no references tag for it, show an error
321     * message for that group.
322     * If we are processing a section preview, this adds the missing
323     * references tags and does not add the errors.
324     *
325     * @param Parser $parser
326     * @param bool $isSectionPreview
327     *
328     * @return string HTML
329     */
330    public function checkRefsNoReferences( Parser $parser, bool $isSectionPreview ): string {
331        $s = '';
332        foreach ( $this->referenceStack->getGroups() as $group ) {
333            if ( $group === self::DEFAULT_GROUP || $isSectionPreview ) {
334                $s .= $this->formatReferences( $parser, $group );
335            } else {
336                $s .= '<br />' . $this->errorReporter->halfParsed(
337                    $parser,
338                    'cite_error_group_refs_without_references',
339                    Sanitizer::safeEncodeAttribute( $group )
340                );
341            }
342        }
343        if ( $isSectionPreview && $s !== '' ) {
344            $headerMsg = wfMessage( 'cite_section_preview_references' );
345            if ( !$headerMsg->isDisabled() ) {
346                $s = Html::element(
347                    'h2',
348                    [ 'id' => 'mw-ext-cite-cite_section_preview_references_header' ],
349                    $headerMsg->text()
350                ) . $s;
351            }
352            // provide a preview of references in its own section
353            $s = Html::rawElement(
354                'div',
355                [ 'class' => 'mw-ext-cite-cite_section_preview_references' ],
356                $s
357            );
358        }
359        return $s !== '' ? "\n" . $s : '';
360    }
361
362    /**
363     * @see https://phabricator.wikimedia.org/T240248
364     * @return never
365     */
366    public function __clone() {
367        throw new LogicException( 'Create a new instance please' );
368    }
369
370}