Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
51.89% covered (warning)
51.89%
55 / 106
35.00% covered (danger)
35.00%
7 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
WikitextPFragment
51.89% covered (warning)
51.89%
55 / 106
35.00% covered (danger)
35.00%
7 / 20
304.61
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 newFromWt
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 newFromSplitWt
65.00% covered (warning)
65.00%
26 / 40
0.00% covered (danger)
0.00%
0 / 1
31.89
 newFromLiteral
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 castFromPFragment
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 isEmpty
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isAtomic
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 asDom
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 asMarkedWikitext
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 startsWithMarker
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 endsWithMarker
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 containsMarker
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 split
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 killMarkers
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 markerSkipCallback
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 trim
64.71% covered (warning)
64.71%
11 / 17
0.00% covered (danger)
0.00%
0 / 1
4.70
 concat
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 toJsonArray
60.00% covered (warning)
60.00%
6 / 10
0.00% covered (danger)
0.00%
0 / 1
2.26
 newFromJsonArray
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 jsonClassHintFor
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Fragments;
5
6use Wikimedia\JsonCodec\Hint;
7use Wikimedia\Parsoid\Core\DomSourceRange;
8use Wikimedia\Parsoid\DOM\DocumentFragment;
9use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI;
10use Wikimedia\Parsoid\Utils\Utils;
11
12/**
13 * A non-atomic fragment comprised of a wikitext string and a strip state.
14 *
15 * The wikitext string may contain strip markers, which are
16 * placeholders corresponding to other atomic fragments.  The internal
17 * StripState holds the mapping between placeholders and the
18 * corresponding atomic fragments.
19 *
20 * WikitextPFragments are not atomic, and so a WikitextPFragment
21 * should never contain strip markers corresponding to other
22 * WikitextPFragments.
23 */
24class WikitextPFragment extends PFragment {
25
26    public const TYPE_HINT = 'wt';
27
28    /** Wikitext value of this fragment, with embedded strip markers. */
29    private string $value;
30
31    /** The strip state giving the value of the embedded strip markers. */
32    private ?StripState $stripState;
33
34    private function __construct(
35        string $value, ?DomSourceRange $srcOffsets, ?StripState $stripState
36    ) {
37        parent::__construct( $srcOffsets );
38        $this->value = $value;
39        $this->stripState = $stripState;
40    }
41
42    /**
43     * Return a new WikitextPFragment consisting of the given fragment
44     * of wikitext, and an optional source string for it.
45     */
46    public static function newFromWt( string $wikitext, ?DomSourceRange $srcOffsets ): WikitextPFragment {
47        return new self( $wikitext, $srcOffsets, null );
48    }
49
50    /**
51     * Return a new WikitextPFragment consisting of the given array of
52     * pieces concatenated together.  Each piece can contain either
53     * a `string` of wikitext (without strip markers) or a PFragment.
54     * @param array<string|PFragment> $pieces
55     */
56    public static function newFromSplitWt( array $pieces, ?DomSourceRange $srcOffsets = null ): WikitextPFragment {
57        $wikitext = [];
58        $isFirst = true;
59        $lastIsMarker = false;
60        $firstDSR = null;
61        $lastDSR = null;
62        $ss = StripState::new();
63        foreach ( $pieces as $p ) {
64            if ( $p instanceof PFragment && !$p->isAtomic() ) {
65                // Don't create a strip marker for non-atomic fragments,
66                // instead concatenate their wikitext components
67                $p = self::castFromPFragment( $p );
68            }
69            if ( $p === '' ) {
70                continue;
71            } elseif ( $p instanceof PFragment && $p->isEmpty() ) {
72                continue;
73            } elseif ( $p instanceof WikitextPFragment ) {
74                // XXX we could also avoid adding the <nowiki> if we notice
75                // that our source ranges are adjacent (ie, the wikitext
76                // strings were adjacent in the source document)
77                if ( !( $isFirst || $lastIsMarker || $p->startsWithMarker() ) ) {
78                    $wikitext[] = '<nowiki/>';
79                }
80                $wikitext[] = $p->value;
81                if ( $p->stripState !== null ) {
82                    $ss->addAllFrom( $p->stripState );
83                }
84                if ( $isFirst ) {
85                    $firstDSR = $p->getSrcOffsets();
86                }
87                $lastIsMarker = $p->endsWithMarker();
88                $lastDSR = $p->getSrcOffsets();
89            } elseif ( !is_string( $p ) ) {
90                // This is an atomic PFragment
91                $wikitext[] = $ss->addWtItem( $p );
92                if ( $isFirst ) {
93                    $firstDSR = $p->getSrcOffsets();
94                }
95                $lastIsMarker = true;
96                $lastDSR = $p->getSrcOffsets();
97            } else {
98                // This is a wikitext string
99                if ( !( $isFirst || $lastIsMarker ) ) {
100                    $wikitext[] = '<nowiki/>';
101                }
102                $wikitext[] = $p;
103                $lastIsMarker = false;
104                $lastDSR = null;
105            }
106            $isFirst = false;
107        }
108        return new self(
109            implode( '', $wikitext ),
110            // Create DSR if first and last pieces were fragments.
111            $srcOffsets ?? self::joinSourceRange( $firstDSR, $lastDSR ),
112            $ss->isEmpty() ? null : $ss
113        );
114    }
115
116    /**
117     * Returns a new WikitextPFragment from the given literal string
118     * and optional source offsets.
119     *
120     * Unlike LiteralStringPFragment, the resulting fragment is
121     * non-atomic -- it will not be an opaque strip marked but instead
122     * will consists of escaped wikitext that will evaluate to the
123     * desired string value.
124     *
125     * @see LiteralStringPFragment::newFromLiteral() for an atomic
126     *  fragment equivalent.
127     *
128     * @param string $value The literal string
129     * @param ?DomSourceRange $srcOffsets The source range corresponding to
130     *   this literal string, if there is one
131     */
132    public static function newFromLiteral( string $value, ?DomSourceRange $srcOffsets ): WikitextPFragment {
133        return self::newFromWt( Utils::escapeWt( $value ), $srcOffsets );
134    }
135
136    /**
137     * Return a WikitextPFragment corresponding to the given PFragment.
138     * If the fragment is not already a WikitextPFragment, this will convert
139     * it using PFragment::asMarkedWikitext().
140     */
141    public static function castFromPFragment( PFragment $fragment ): WikitextPFragment {
142        if ( $fragment instanceof WikitextPFragment ) {
143            return $fragment;
144        }
145        $ss = StripState::new();
146        $wikitext = $fragment->asMarkedWikitext( $ss );
147        return new self(
148            $wikitext, $fragment->srcOffsets, $ss->isEmpty() ? null : $ss
149        );
150    }
151
152    /** @inheritDoc */
153    public function isEmpty(): bool {
154        return $this->value === '';
155    }
156
157    /** @return false */
158    public function isAtomic(): bool {
159        return false;
160    }
161
162    /** @inheritDoc */
163    public function asDom( ParsoidExtensionAPI $ext, bool $release = false ): DocumentFragment {
164        return $ext->wikitextToDOM( $this, [], true );
165    }
166
167    /** @inheritDoc */
168    public function asMarkedWikitext( StripState $stripState ): string {
169        if ( $this->stripState !== null ) {
170            $stripState->addAllFrom( $this->stripState );
171        }
172        return $this->value;
173    }
174
175    private function startsWithMarker(): bool {
176        return StripState::startsWithStripMarker( $this->value );
177    }
178
179    private function endsWithMarker(): bool {
180        return StripState::endsWithStripMarker( $this->value );
181    }
182
183    /**
184     * Returns true if this fragment contains some non-wikitext content.
185     */
186    public function containsMarker(): bool {
187        return StripState::containsStripMarker( $this->value );
188    }
189
190    /**
191     * Split this fragment at its strip markers and return an array
192     * which alternates between string items and PFragment items.
193     * The first and last items are guaranteed to be strings, and the
194     * array length is guaranteed to be odd and at least 1.
195     * @return list<string|PFragment>
196     */
197    public function split(): array {
198        if ( $this->stripState === null ) {
199            return [ $this->value ];
200        }
201        return $this->stripState->splitWt( $this->value );
202    }
203
204    /**
205     * Return a version of this wikitext fragment with all strip markers
206     * removed.
207     * @return string
208     */
209    public function killMarkers(): string {
210        return implode( array_filter( $this->split(), "is_string" ) );
211    }
212
213    /** @inheritDoc */
214    public function markerSkipCallback( callable $callback ): PFragment {
215        return PFragment::fromSplitWt(
216            array_map(
217                static fn ( $el ) => is_string( $el ) ? $callback( $el ) : $el,
218                $this->split()
219            ), $this->srcOffsets
220        );
221    }
222
223    /**
224     * Trim leading and trailing whitespace from this fragment.
225     *
226     * If the result is just a strip marker, will return the fragment
227     * corresponding to that strip marker; that is, this method is
228     * not guaranteed to return a WikitextPFragment.
229     *
230     * @return PFragment
231     */
232    public function trim(): PFragment {
233        $pieces = $this->split();
234
235        $oldSize = strlen( $pieces[0] );
236        $pieces[0] = ltrim( $pieces[0] );
237        $startTrim = $oldSize - strlen( $pieces[0] );
238
239        $end = count( $pieces ) - 1;
240        $oldSize = strlen( $pieces[$end] );
241        $pieces[$end] = rtrim( $pieces[$end] );
242        $endTrim = $oldSize - strlen( $pieces[$end] );
243
244        $newDsr = null;
245        if ( $this->srcOffsets !== null ) {
246            [ $start, $end ] = [ $this->srcOffsets->start, $this->srcOffsets->end ];
247            if ( $start !== null ) {
248                $start += $startTrim;
249            }
250            if ( $end !== null ) {
251                $end -= $endTrim;
252            }
253            $newDsr = new DomSourceRange( $start, $end, null, null );
254        }
255        return PFragment::fromSplitWt( $pieces, $newDsr );
256    }
257
258    /**
259     * Return a WikitextPFragment representing the concatenation of
260     * the given fragments, as wikitext.
261     */
262    public static function concat( PFragment ...$fragments ): self {
263        return self::newFromSplitWt( $fragments );
264    }
265
266    // JsonCodecable implementation
267
268    /** @inheritDoc */
269    public function toJsonArray(): array {
270        $pieces = $this->split();
271        if ( count( $pieces ) === 1 ) {
272            $wt = $pieces[0];
273            $ret = [
274                self::TYPE_HINT => $wt,
275            ];
276        } else {
277            $ret = [
278                self::TYPE_HINT => $pieces,
279            ];
280        }
281        return $ret + parent::toJsonArray();
282    }
283
284    /** @inheritDoc */
285    public static function newFromJsonArray( array $json ): self {
286        $v = $json[self::TYPE_HINT];
287        if ( is_string( $v ) ) {
288            $v = [ $v ];
289        }
290        return self::newFromSplitWt( $v, $json['dsr'] ?? null );
291    }
292
293    /** @inheritDoc */
294    public static function jsonClassHintFor( string $keyName ) {
295        if ( $keyName === self::TYPE_HINT ) {
296            return Hint::build( PFragment::class, Hint::INHERITED, Hint::LIST );
297        }
298        return parent::jsonClassHintFor( $keyName );
299    }
300}