Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
50.93% covered (warning)
50.93%
55 / 108
35.00% covered (danger)
35.00%
7 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
WikitextPFragment
50.93% covered (warning)
50.93%
55 / 108
35.00% covered (danger)
35.00%
7 / 20
345.46
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
61.90% covered (warning)
61.90%
26 / 42
0.00% covered (danger)
0.00%
0 / 1
42.11
 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     * @param ?DomSourceRange $srcOffsets
56     * @param bool $unsafeConcat False by default; when true suppresses
57     *  the addition of <nowiki/> markers used to ensure adjacent wikitext
58     *  strings do not interfere with each other.
59     */
60    public static function newFromSplitWt(
61        array $pieces,
62        ?DomSourceRange $srcOffsets = null,
63        bool $unsafeConcat = false
64    ): WikitextPFragment {
65        $wikitext = [];
66        $isFirst = true;
67        $lastIsMarker = false;
68        $firstDSR = null;
69        $lastDSR = null;
70        $ss = StripState::new();
71        foreach ( $pieces as $p ) {
72            if ( $p instanceof PFragment && !$p->isAtomic() ) {
73                // Don't create a strip marker for non-atomic fragments,
74                // instead concatenate their wikitext components
75                $p = self::castFromPFragment( $p );
76            }
77            if ( $p === '' ) {
78                continue;
79            } elseif ( $p instanceof PFragment && $p->isEmpty() ) {
80                continue;
81            } elseif ( $p instanceof WikitextPFragment ) {
82                // XXX we could also avoid adding the <nowiki> if we notice
83                // that our source ranges are adjacent (ie, the wikitext
84                // strings were adjacent in the source document)
85                if ( !( $isFirst || $lastIsMarker || $p->startsWithMarker() ) ) {
86                    if ( !$unsafeConcat ) {
87                        $wikitext[] = '<nowiki/>';
88                    }
89                }
90                $wikitext[] = $p->value;
91                if ( $p->stripState !== null ) {
92                    $ss->addAllFrom( $p->stripState );
93                }
94                if ( $isFirst ) {
95                    $firstDSR = $p->getSrcOffsets();
96                }
97                $lastIsMarker = $p->endsWithMarker();
98                $lastDSR = $p->getSrcOffsets();
99            } elseif ( !is_string( $p ) ) {
100                // This is an atomic PFragment
101                $wikitext[] = $ss->addWtItem( $p );
102                if ( $isFirst ) {
103                    $firstDSR = $p->getSrcOffsets();
104                }
105                $lastIsMarker = true;
106                $lastDSR = $p->getSrcOffsets();
107            } else {
108                // This is a wikitext string
109                if ( !( $isFirst || $lastIsMarker ) ) {
110                    if ( !$unsafeConcat ) {
111                        $wikitext[] = '<nowiki/>';
112                    }
113                }
114                $wikitext[] = $p;
115                $lastIsMarker = false;
116                $lastDSR = null;
117            }
118            $isFirst = false;
119        }
120        return new self(
121            implode( '', $wikitext ),
122            // Create DSR if first and last pieces were fragments.
123            $srcOffsets ?? self::joinSourceRange( $firstDSR, $lastDSR ),
124            $ss->isEmpty() ? null : $ss
125        );
126    }
127
128    /**
129     * Returns a new WikitextPFragment from the given literal string
130     * and optional source offsets.
131     *
132     * Unlike LiteralStringPFragment, the resulting fragment is
133     * non-atomic -- it will not be an opaque strip marker but instead
134     * will consists of escaped wikitext that will evaluate to the
135     * desired string value.
136     *
137     * @see LiteralStringPFragment::newFromLiteral() for an atomic
138     *  fragment equivalent.
139     *
140     * @param string $value The literal string
141     * @param ?DomSourceRange $srcOffsets The source range corresponding to
142     *   this literal string, if there is one
143     */
144    public static function newFromLiteral( string $value, ?DomSourceRange $srcOffsets ): WikitextPFragment {
145        return self::newFromWt( Utils::escapeWt( $value ), $srcOffsets );
146    }
147
148    /**
149     * Return a WikitextPFragment corresponding to the given PFragment.
150     * If the fragment is not already a WikitextPFragment, this will convert
151     * it using PFragment::asMarkedWikitext().
152     */
153    public static function castFromPFragment( PFragment $fragment ): WikitextPFragment {
154        if ( $fragment instanceof WikitextPFragment ) {
155            return $fragment;
156        }
157        $ss = StripState::new();
158        $wikitext = $fragment->asMarkedWikitext( $ss );
159        return new self(
160            $wikitext, $fragment->srcOffsets, $ss->isEmpty() ? null : $ss
161        );
162    }
163
164    /** @inheritDoc */
165    public function isEmpty(): bool {
166        return $this->value === '';
167    }
168
169    /** @return false */
170    public function isAtomic(): bool {
171        return false;
172    }
173
174    /** @inheritDoc */
175    public function asDom( ParsoidExtensionAPI $extApi, bool $release = false ): DocumentFragment {
176        return $extApi->wikitextToDOM( $this, [], true );
177    }
178
179    /** @inheritDoc */
180    public function asMarkedWikitext( StripState $stripState ): string {
181        if ( $this->stripState !== null ) {
182            $stripState->addAllFrom( $this->stripState );
183        }
184        return $this->value;
185    }
186
187    private function startsWithMarker(): bool {
188        return StripState::startsWithStripMarker( $this->value );
189    }
190
191    private function endsWithMarker(): bool {
192        return StripState::endsWithStripMarker( $this->value );
193    }
194
195    /**
196     * Returns true if this fragment contains some non-wikitext content.
197     */
198    public function containsMarker(): bool {
199        return StripState::containsStripMarker( $this->value );
200    }
201
202    /**
203     * Split this fragment at its strip markers and return an array
204     * which alternates between string items and PFragment items.
205     * The first and last items are guaranteed to be strings, and the
206     * array length is guaranteed to be odd and at least 1.
207     * @return list<string|PFragment>
208     */
209    public function split(): array {
210        if ( $this->stripState === null ) {
211            return [ $this->value ];
212        }
213        return $this->stripState->splitWt( $this->value );
214    }
215
216    /**
217     * Return a version of this wikitext fragment with all strip markers
218     * removed.
219     * @return string
220     */
221    public function killMarkers(): string {
222        return implode( array_filter( $this->split(), "is_string" ) );
223    }
224
225    /** @inheritDoc */
226    public function markerSkipCallback( callable $callback ): PFragment {
227        return PFragment::fromSplitWt(
228            array_map(
229                static fn ( $el ) => is_string( $el ) ? $callback( $el ) : $el,
230                $this->split()
231            ), $this->srcOffsets
232        );
233    }
234
235    /**
236     * Trim leading and trailing whitespace from this fragment.
237     *
238     * If the result is just a strip marker, will return the fragment
239     * corresponding to that strip marker; that is, this method is
240     * not guaranteed to return a WikitextPFragment.
241     *
242     * @return PFragment
243     */
244    public function trim(): PFragment {
245        $pieces = $this->split();
246
247        $oldSize = strlen( $pieces[0] );
248        $pieces[0] = ltrim( $pieces[0] );
249        $startTrim = $oldSize - strlen( $pieces[0] );
250
251        $end = count( $pieces ) - 1;
252        $oldSize = strlen( $pieces[$end] );
253        $pieces[$end] = rtrim( $pieces[$end] );
254        $endTrim = $oldSize - strlen( $pieces[$end] );
255
256        $newDsr = null;
257        if ( $this->srcOffsets !== null ) {
258            [ $start, $end ] = [ $this->srcOffsets->start, $this->srcOffsets->end ];
259            if ( $start !== null ) {
260                $start += $startTrim;
261            }
262            if ( $end !== null ) {
263                $end -= $endTrim;
264            }
265            $newDsr = new DomSourceRange( $start, $end, null, null );
266        }
267        return PFragment::fromSplitWt( $pieces, $newDsr );
268    }
269
270    /**
271     * Return a WikitextPFragment representing the concatenation of
272     * the given fragments, as wikitext.
273     */
274    public static function concat( PFragment ...$fragments ): self {
275        return self::newFromSplitWt( $fragments );
276    }
277
278    // JsonCodecable implementation
279
280    /** @inheritDoc */
281    public function toJsonArray(): array {
282        $pieces = $this->split();
283        if ( count( $pieces ) === 1 ) {
284            $wt = $pieces[0];
285            $ret = [
286                self::TYPE_HINT => $wt,
287            ];
288        } else {
289            $ret = [
290                self::TYPE_HINT => $pieces,
291            ];
292        }
293        return $ret + parent::toJsonArray();
294    }
295
296    /** @inheritDoc */
297    public static function newFromJsonArray( array $json ): self {
298        $v = $json[self::TYPE_HINT];
299        if ( is_string( $v ) ) {
300            $v = [ $v ];
301        }
302        return self::newFromSplitWt( $v, $json['dsr'] ?? null );
303    }
304
305    /** @inheritDoc */
306    public static function jsonClassHintFor( string $keyName ) {
307        if ( $keyName === self::TYPE_HINT ) {
308            return Hint::build( PFragment::class, Hint::INHERITED, Hint::LIST );
309        }
310        return parent::jsonClassHintFor( $keyName );
311    }
312}