Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
5.13% covered (danger)
5.13%
2 / 39
11.76% covered (danger)
11.76%
2 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
PFragment
5.13% covered (danger)
5.13%
2 / 39
11.76% covered (danger)
11.76%
2 / 17
906.40
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isEmpty
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isAtomic
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isValid
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSrcOffsets
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
 asHtmlString
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 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 killMarkers
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 markerSkipCallback
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 fromSplitWt
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
56
 joinSourceRange
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 registerFragmentClass
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 toJsonArray
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 newFromJsonArray
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 jsonClassHintFor
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 hint
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Fragments;
5
6use JsonException;
7use Wikimedia\JsonCodec\Hint;
8use Wikimedia\JsonCodec\JsonCodecable;
9use Wikimedia\JsonCodec\JsonCodecableTrait;
10use Wikimedia\Parsoid\Core\DomSourceRange;
11use Wikimedia\Parsoid\DOM\DocumentFragment;
12use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI;
13
14/**
15 * A PFragment is a MediaWiki content fragment.
16 *
17 * PFragment is the input and output type for fragment generators in
18 * MediaWiki: magic variables, parser functions, templates, and
19 * extension tags.  You can imagine that the `P` stands for "Parsoid",
20 * "Page", or "MediaWiki Content" but in reality it simply
21 * disambiguates this fragment type from the DOM DocumentFragment and
22 * any other fragments you might encounter.
23 *
24 * PFragment is an abstract class, and content is lazily converted to the
25 * form demanded by a consumer.  Converting forms often loses information
26 * or introduces edge cases, so we avoid conversion to intermediate forms
27 * and defer conversion in general as late as possible.
28 *
29 * For example, in this invocation:
30 *   {{1x|'''bold''' <nowiki>fragment</nowiki>}}
31 *
32 * If we were to flatten this "as string" (traditionally) we would
33 * lose the bold face and the <nowiki> would get tunneled as strip
34 * state.  Alternatively we could ask for this "as a source string"
35 * which corresponds to the original "raw" form: "'''bold'''
36 * <nowiki>fragment</nowiki>", which is often used to pass literal
37 * arguments, bypassing wikitext processing.  Or we could
38 * ask for the argument "as HTML" or "as DOM" in which case it would
39 * get parsed as wikitext and returned as
40 * `<b>bold</b> <span>fragment</span>`, either as a possibly-unbalanced
41 * string ("as HTML") or as a balanced DOM tree ("as DOM").  These
42 * transformations can be irreversible: once we've converted to one
43 * representation we can't always recover the others.
44 *
45 * But now consider if `{{1x|...}}` simply wants to return its argument:
46 * it doesn't need to force a specific representation, instead
47 * it can return the PFragment directly without losing information
48 * and allow the downstream customer to chose the type it prefers.
49 * This also works for composition: a composite PFragment can be
50 * defined which defers evaluation of its components until demanded,
51 * and then applies the appropriate composition operation depending
52 * on the demanded result.
53 *
54 * (WikitextPFragment is one such composite fragment type, which uses
55 * Parsoid to do the composition of wikitext and other fragments.)
56 *
57 * Parsoid defines only those fragment types relevant to itself, and
58 * defines conversions (`as*()` methods) only for those formats it
59 * needs for HTML rendering.  Extensions should feel free to define
60 * their own fragment types: as long as they are JsonCodecable and
61 * define one of ::asDom() or ::asHtmlString() they will interoperate
62 * with Parsoid and other extensions, albeit possibly as an opaque
63 * strip marker.
64 *
65 * For example, Wikifunctions might define a PFragment for ZObjects,
66 * which would allow nested wikifunction invocations to transfer
67 * ZObjects between themselves without conversion through wikitext.
68 * For example, given:
69 *   {{#function:sum| {{#function:one}} }}
70 * then the `sum` function will be given a ZObjectPFragment containing
71 * the output of the `one` function, without forcing that value to
72 * serialize to a wikitext string and deserialize.  With its special
73 * knowledge of the ZObjectPFragment type, Wikifunctions can use this
74 * to (say) preserve type information of the values.  But if this
75 * same function is embedded into a wikitext template:
76 *   {{1x| {{#function:one}} }}
77 * then the value will be converted to wikitext or DOM as appropriate
78 * and composed onto the page in that form.
79 */
80abstract class PFragment implements JsonCodecable {
81    use JsonCodecableTrait;
82
83    /**
84     * The original wikitext source range for this fragment, or `null` for
85     * synthetic content that corresponds to no part of the original
86     * authored text.
87     */
88    protected ?DomSourceRange $srcOffsets;
89
90    /**
91     * Registry of known fragment types, used for serialization.
92     * @see ::registerFragmentClass()
93     * @var list<class-string<PFragment>>
94     */
95    protected static array $FRAGMENT_TYPES = [
96        WikitextPFragment::class,
97        HtmlPFragment::class,
98        DomPFragment::class,
99        LiteralStringPFragment::class,
100    ];
101
102    protected function __construct( ?DomSourceRange $srcOffsets ) {
103        $this->srcOffsets = $srcOffsets;
104    }
105
106    /**
107     * Returns true if this fragment is empty.  This enables optimizations
108     * if implemented, but returns false by default.
109     */
110    public function isEmpty(): bool {
111        return false;
112    }
113
114    /**
115     * Returns true if this fragment contains no wikitext elements; that is,
116     * if `::asMarkedWikitext()` given an empty strip state
117     * would return a single strip marker and add a single item to the
118     * strip state (representing $this).  Otherwise, returns false.
119     */
120    public function isAtomic(): bool {
121        // This is consistent with the default implementation of
122        // ::asMarkedWikitext()
123        return true;
124    }
125
126    /**
127     * As an optimization to avoid unnecessary copying, certain
128     * operations on fragments may be destructive or lead to aliasing.
129     * For ease of debugging, fragments so affected will return `false`
130     * from `::isValid()` and code is encouraged to assert the validity
131     * of fragments where convenient to do so.
132     *
133     * @see the $release parameter to `::asDom()` and `DomPFragment::concat`,
134     *  but other PFragment types with mutable non-value types might also
135     *  provide accessors with `$release` parameters that interact with
136     *  fragment validity.
137     */
138    public function isValid(): bool {
139        // By default, fragments are valid forever.
140
141        // See DomPFragment for an example of a fragment which may become
142        // invalid.
143        return true;
144    }
145
146    /**
147     * Return the region of the source document that corresponds to this
148     * fragment.
149     */
150    public function getSrcOffsets(): ?DomSourceRange {
151        return $this->srcOffsets;
152    }
153
154    /**
155     * Return the fragment as a (prepared and loaded) DOM
156     * DocumentFragment belonging to the Parsoid top-level document.
157     *
158     * If $release is true, then this PFragment will become invalid
159     * after this method returns.
160     *
161     * @note The default implementation of ::asDom() calls ::asHtmlString().
162     *  Subclassses must implement either ::asDom() or ::asHtmlString()
163     *  to avoid infinite mutual recursion.
164     */
165    public function asDom( ParsoidExtensionAPI $ext, bool $release = false ): DocumentFragment {
166        return $ext->htmlToDom( $this->asHtmlString( $ext ) );
167    }
168
169    /**
170     * Return the fragment as a string of HTML.  This method is very
171     * similar to asDom() but also supports fragmentary and unbalanced
172     * HTML, and therefore composition may yield unexpected results.
173     * This is a common type in legacy MediaWiki code, but use in
174     * new code should be discouraged.  Data attributes will be
175     * represented as inline attributes, which may be suboptimal.
176     * @note The default implementation of ::asHtmlString() calls ::asDom().
177     *  Subclassses must implement either ::asDom() or ::asHtmlString()
178     *  to avoid infinite mutual recursion.
179     */
180    public function asHtmlString( ParsoidExtensionAPI $ext ): string {
181        return $ext->domToHtml( $this->asDom( $ext ), true );
182    }
183
184    /**
185     * This method returns a "wikitext string" in the legacy format.
186     * Wikitext constructs will be parsed in the result.
187     * Constructs which are not representable in wikitext will be replaced
188     * with strip markers, and you will get a strip state which maps
189     * those markers back to PFragment objects.  When you (for example)
190     * compose two marked strings and then ask for the result `asDom`,
191     * the strip markers in the marked strings will first be conceptually
192     * replaced with the PFragment from the StripState, and then
193     * the resulting interleaved strings and fragments will be composed.
194     */
195    public function asMarkedWikitext( StripState $stripState ): string {
196        // By default just adds this fragment to the strip state and
197        // returns a strip marker.  Non-atomic fragments can be
198        // more clever.
199        return $stripState->addWtItem( $this );
200    }
201
202    /**
203     * Return a version of this fragment with all non-wikitext content
204     * removed.
205     * See Parser::killMarkers() and StripState::killMarkers() in core.
206     * @return string
207     */
208    public function killMarkers(): string {
209        // This is overridden in WikitextPFragment
210        return '';
211    }
212
213    /**
214     * Call a callback function on all regions of the given text that
215     * are wikitext content, replacing them with the return value of
216     * the callback.  Non-wikitext content is skipped but included
217     * in their proper places.
218     * @param callable(string):string $callback
219     * @return PFragment
220     */
221    public function markerSkipCallback( callable $callback ): PFragment {
222        // This is overridden in WikitextPFragment
223        return $this;
224    }
225
226    /**
227     * Helper function to create a new fragment from a mixed array of
228     * strings and fragments.
229     *
230     * Unlike WikitextPFragment::newFromSplitWt() this method will not
231     * always return a WikitextPFragment; for example if only one
232     * non-empty piece is provided this method will just return that
233     * piece without casting it to a WikitextPFragment.
234     *
235     * @param list<string|PFragment> $pieces
236     */
237    public static function fromSplitWt( array $pieces, ?DomSourceRange $srcOffset = null ): PFragment {
238        $result = [];
239        // Remove empty pieces
240        foreach ( $pieces as $p ) {
241            if ( $p === '' ) {
242                continue;
243            }
244            if ( $p instanceof PFragment && $p->isEmpty() ) {
245                continue;
246            }
247            $result[] = $p;
248        }
249        // Optimize!
250        if ( count( $result ) === 1 && $result[0] instanceof PFragment ) {
251            return $result[0];
252        }
253        return WikitextPFragment::newFromSplitWt( $result, $srcOffset );
254    }
255
256    /**
257     * Helper function to append two source ranges.
258     */
259    protected static function joinSourceRange( ?DomSourceRange $first, ?DomSourceRange $second ): ?DomSourceRange {
260        if ( $first === null || $second === null ) {
261            return null;
262        }
263        return new DomSourceRange( $first->start, $second->end, null, null );
264    }
265
266    // JsonCodec support
267
268    /**
269     * Register a fragment type with the JSON deserialization code.
270     *
271     * The given class should have a static constant named TYPE_HINT
272     * which gives the unique string property name which will distinguish
273     * serialized fragments of the given class.
274     * @param class-string<PFragment> $className
275     */
276    public function registerFragmentClass( string $className ): void {
277        if ( !in_array( $className, self::$FRAGMENT_TYPES, true ) ) {
278            self::$FRAGMENT_TYPES[] = $className;
279        }
280    }
281
282    /** @inheritDoc */
283    protected function toJsonArray(): array {
284        return $this->srcOffsets === null ? [] : [
285            'dsr' => $this->srcOffsets
286        ];
287    }
288
289    /** @inheritDoc */
290    public static function newFromJsonArray( array $json ): PFragment {
291        foreach ( self::$FRAGMENT_TYPES as $c ) {
292            if ( isset( $json[$c::TYPE_HINT] ) ) {
293                return $c::newFromJsonArray( $json );
294            }
295        }
296        throw new JsonException( "unknown fragment type" );
297    }
298
299    /** @inheritDoc */
300    public static function jsonClassHintFor( string $keyName ) {
301        if ( $keyName === 'dsr' ) {
302            return DomSourceRange::hint();
303        }
304        foreach ( self::$FRAGMENT_TYPES as $c ) {
305            if ( $keyName === $c::TYPE_HINT ) {
306                return $c::jsonClassHintFor( $keyName );
307            }
308        }
309        return null;
310    }
311
312    public static function hint(): Hint {
313        return Hint::build( self::class, Hint::INHERITED );
314    }
315}