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