Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.09% covered (success)
99.09%
109 / 110
88.89% covered (warning)
88.89%
8 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
ReferenceListFormatter
99.09% covered (success)
99.09%
109 / 110
88.89% covered (warning)
88.89%
8 / 9
34
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 formatReferences
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 formatRefsList
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
10
 closeIndention
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 formatListItem
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
8
 referenceText
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 referencesFormatEntryNumericBacklinkLabel
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 referencesFormatEntryAlternateBacklinkLabel
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 listToText
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace Cite;
4
5use MediaWiki\Html\Html;
6use Parser;
7
8/**
9 * Renderer for the actual list of references in place of the <references /> tag at the end of an
10 * article.
11 *
12 * @license GPL-2.0-or-later
13 */
14class ReferenceListFormatter {
15
16    /**
17     * The backlinks, in order, to pass as $3 to
18     * 'cite_references_link_many_format', defined in
19     * 'cite_references_link_many_format_backlink_labels
20     *
21     * @var string[]|null
22     */
23    private ?array $backlinkLabels = null;
24    private ErrorReporter $errorReporter;
25    private AnchorFormatter $anchorFormatter;
26    private ReferenceMessageLocalizer $messageLocalizer;
27
28    public function __construct(
29        ErrorReporter $errorReporter,
30        AnchorFormatter $anchorFormatter,
31        ReferenceMessageLocalizer $messageLocalizer
32    ) {
33        $this->errorReporter = $errorReporter;
34        $this->anchorFormatter = $anchorFormatter;
35        $this->messageLocalizer = $messageLocalizer;
36    }
37
38    /**
39     * @param Parser $parser
40     * @param array<string|int,ReferenceStackItem> $groupRefs
41     * @param bool $responsive
42     * @param bool $isSectionPreview
43     *
44     * @return string HTML
45     */
46    public function formatReferences(
47        Parser $parser,
48        array $groupRefs,
49        bool $responsive,
50        bool $isSectionPreview
51    ): string {
52        if ( !$groupRefs ) {
53            return '';
54        }
55
56        $wikitext = $this->formatRefsList( $parser, $groupRefs, $isSectionPreview );
57        $html = $parser->recursiveTagParse( $wikitext );
58
59        if ( $responsive ) {
60            $wrapClasses = [ 'mw-references-wrap' ];
61            if ( count( $groupRefs ) > 10 ) {
62                $wrapClasses[] = 'mw-references-columns';
63            }
64            // Use a DIV wrap because column-count on a list directly is broken in Chrome.
65            // See https://bugs.chromium.org/p/chromium/issues/detail?id=498730.
66            return Html::rawElement( 'div', [ 'class' => $wrapClasses ], $html );
67        }
68
69        return $html;
70    }
71
72    /**
73     * @param Parser $parser
74     * @param array<string|int,ReferenceStackItem> $groupRefs
75     * @param bool $isSectionPreview
76     *
77     * @return string Wikitext
78     */
79    private function formatRefsList(
80        Parser $parser,
81        array $groupRefs,
82        bool $isSectionPreview
83    ): string {
84        // After sorting the list, we can assume that references are in the same order as their
85        // numbering.  Subreferences will come immediately after their parent.
86        uasort(
87            $groupRefs,
88            static function ( ReferenceStackItem $a, ReferenceStackItem $b ): int {
89                $cmp = ( $a->number ?? 0 ) - ( $b->number ?? 0 );
90                return $cmp ?: ( $a->extendsIndex ?? 0 ) - ( $b->extendsIndex ?? 0 );
91            }
92        );
93
94        // Add new lines between the list items (ref entries) to avoid confusing tidy (T15073).
95        // Note: This builds a string of wikitext, not html.
96        $parserInput = "\n";
97        /** @var string|bool $indented */
98        $indented = false;
99        foreach ( $groupRefs as $key => &$ref ) {
100            // Make sure the parent is not a subreference.
101            // FIXME: Move to a validation function.
102            $extends =& $ref->extends;
103            if ( isset( $extends ) && isset( $groupRefs[$extends]->extends ) ) {
104                $ref->warnings[] = [ 'cite_error_ref_nested_extends',
105                    $extends, $groupRefs[$extends]->extends ];
106            }
107
108            if ( !$indented && isset( $extends ) ) {
109                // The nested <ol> must be inside the parent's <li>
110                if ( preg_match( '#</li>\s*$#D', $parserInput, $matches, PREG_OFFSET_CAPTURE ) ) {
111                    $parserInput = substr( $parserInput, 0, $matches[0][1] );
112                }
113                $parserInput .= Html::openElement( 'ol', [ 'class' => 'mw-extended-references' ] );
114                $indented = $matches[0][0] ?? true;
115            } elseif ( $indented && !isset( $extends ) ) {
116                $parserInput .= $this->closeIndention( $indented );
117                $indented = false;
118            }
119            $parserInput .= $this->formatListItem( $parser, $key, $ref, $isSectionPreview ) . "\n";
120        }
121        $parserInput .= $this->closeIndention( $indented );
122        return Html::rawElement( 'ol', [ 'class' => 'references' ], $parserInput );
123    }
124
125    /**
126     * @param string|bool $closingLi
127     *
128     * @return string
129     */
130    private function closeIndention( $closingLi ): string {
131        if ( !$closingLi ) {
132            return '';
133        }
134
135        return Html::closeElement( 'ol' ) . ( is_string( $closingLi ) ? $closingLi : '' );
136    }
137
138    /**
139     * @param Parser $parser
140     * @param string|int $key The key of the reference
141     * @param ReferenceStackItem $ref
142     * @param bool $isSectionPreview
143     *
144     * @return string Wikitext, wrapped in a single <li> element
145     */
146    private function formatListItem(
147        Parser $parser, $key, ReferenceStackItem $ref, bool $isSectionPreview
148    ): string {
149        $text = $this->referenceText( $parser, $key, $ref, $isSectionPreview );
150        $extraAttributes = '';
151
152        if ( isset( $ref->dir ) ) {
153            // The following classes are generated here:
154            // * mw-cite-dir-ltr
155            // * mw-cite-dir-rtl
156            $extraAttributes = Html::expandAttributes( [ 'class' => 'mw-cite-dir-' . $ref->dir ] );
157        }
158
159        // Special case for an incomplete follow="…". This is valid e.g. in the Page:… namespace on
160        // Wikisource. Note this returns a <p>, not an <li> as expected!
161        if ( isset( $ref->follow ) ) {
162            return '<p id="' . $this->anchorFormatter->jumpLinkTarget( $ref->follow ) . '">' . $text . '</p>';
163        }
164
165        if ( $ref->count === 1 ) {
166            if ( !isset( $ref->name ) ) {
167                $id = $ref->key;
168                $backlinkId = $this->anchorFormatter->backLink( $ref->key );
169            } else {
170                $id = $key . '-' . $ref->key;
171                // TODO: Use count without decrementing.
172                $backlinkId = $this->anchorFormatter->backLink( $key, $ref->key . '-' . ( $ref->count - 1 ) );
173            }
174            return $this->messageLocalizer->msg(
175                'cite_references_link_one',
176                $this->anchorFormatter->jumpLinkTarget( $id ),
177                $backlinkId,
178                $text,
179                $extraAttributes
180            )->plain();
181        }
182
183        // Named references with >1 occurrences
184        $backlinks = [];
185        for ( $i = 0; $i < $ref->count; $i++ ) {
186            $backlinks[] = $this->messageLocalizer->msg(
187                'cite_references_link_many_format',
188                $this->anchorFormatter->backLink( $key, $ref->key . '-' . $i ),
189                $this->referencesFormatEntryNumericBacklinkLabel(
190                    $ref->number .
191                        ( isset( $ref->extendsIndex ) ? '.' . $ref->extendsIndex : '' ),
192                    $i,
193                    $ref->count
194                ),
195                $this->referencesFormatEntryAlternateBacklinkLabel( $parser, $i )
196            )->plain();
197        }
198        $linkTargetId = $ref->count > 0 ?
199            $this->anchorFormatter->jumpLinkTarget( $key . '-' . $ref->key ) : '';
200        return $this->messageLocalizer->msg(
201            'cite_references_link_many',
202            $linkTargetId,
203            $this->listToText( $backlinks ),
204            $text,
205            $extraAttributes
206        )->plain();
207    }
208
209    /**
210     * @param Parser $parser
211     * @param string|int $key
212     * @param ReferenceStackItem $ref
213     * @param bool $isSectionPreview
214     *
215     * @return string Wikitext
216     */
217    private function referenceText(
218        Parser $parser, $key, ReferenceStackItem $ref, bool $isSectionPreview
219    ): string {
220        $text = $ref->text ?? null;
221        if ( $text === null ) {
222            return $this->errorReporter->plain( $parser,
223                $isSectionPreview
224                    ? 'cite_warning_sectionpreview_no_text'
225                    : 'cite_error_references_no_text', $key );
226        }
227
228        foreach ( $ref->warnings as $warning ) {
229            // @phan-suppress-next-line PhanParamTooFewUnpack
230            $text .= ' ' . $this->errorReporter->plain( $parser, ...$warning );
231            // FIXME: We could use a StatusValue object to get rid of duplicates
232            break;
233        }
234
235        return '<span class="reference-text">' . rtrim( $text, "\n" ) . "</span>\n";
236    }
237
238    /**
239     * Generate a numeric backlink given a base number and an
240     * offset, e.g. $base = 1, $offset = 2; = 1.2
241     * Since bug #5525, it correctly does 1.9 -> 1.10 as well as 1.099 -> 1.100
242     *
243     * @param int|string $base
244     * @param int $offset
245     * @param int $max Maximum value expected.
246     *
247     * @return string
248     */
249    private function referencesFormatEntryNumericBacklinkLabel(
250        $base,
251        int $offset,
252        int $max
253    ): string {
254        return $this->messageLocalizer->localizeDigits( $base ) .
255            $this->messageLocalizer->localizeSeparators( '.' ) .
256            $this->messageLocalizer->localizeDigits(
257                str_pad( (string)$offset, strlen( (string)$max ), '0', STR_PAD_LEFT )
258            );
259    }
260
261    /**
262     * Generate a custom format backlink given an offset, e.g.
263     * $offset = 2; = c if $this->mBacklinkLabels = [ 'a',
264     * 'b', 'c', ...]. Return an error if the offset > the # of
265     * array items
266     */
267    private function referencesFormatEntryAlternateBacklinkLabel(
268        Parser $parser, int $offset
269    ): string {
270        $this->backlinkLabels ??= preg_split(
271            '/\s+/',
272            $this->messageLocalizer->msg( 'cite_references_link_many_format_backlink_labels' )
273                ->plain()
274        );
275
276        return $this->backlinkLabels[$offset]
277            ?? $this->errorReporter->plain( $parser, 'cite_error_references_no_backlink_label' );
278    }
279
280    /**
281     * This does approximately the same thing as
282     * Language::listToText() but due to this being used for a
283     * slightly different purpose (people might not want , as the
284     * first separator and not 'and' as the second, and this has to
285     * use messages from the content language) I'm rolling my own.
286     *
287     * @param string[] $arr The array to format
288     *
289     * @return string Wikitext
290     */
291    private function listToText( array $arr ): string {
292        $lastElement = array_pop( $arr );
293
294        if ( $arr === [] ) {
295            return (string)$lastElement;
296        }
297
298        $sep = $this->messageLocalizer->msg( 'cite_references_link_many_sep' )->plain();
299        $and = $this->messageLocalizer->msg( 'cite_references_link_many_and' )->plain();
300        return implode( $sep, $arr ) . $and . $lastElement;
301    }
302
303}