Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
11.11% covered (danger)
11.11%
7 / 63
20.00% covered (danger)
20.00%
1 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
DOMTraverser
11.11% covered (danger)
11.11%
7 / 63
20.00% covered (danger)
20.00%
1 / 5
895.36
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 addHandler
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 callHandlers
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
156
 traverse
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 traverseInternal
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
380
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Utils;
5
6use Wikimedia\Parsoid\Config\SiteConfig;
7use Wikimedia\Parsoid\Core\DOMCompat;
8use Wikimedia\Parsoid\DOM\DocumentFragment;
9use Wikimedia\Parsoid\DOM\Element;
10use Wikimedia\Parsoid\DOM\Node;
11
12/**
13 * Class for helping us traverse the DOM.
14 *
15 * This class currently does a pre-order depth-first traversal.
16 * See {@link DOMPostOrder} for post-order traversal.
17 */
18class DOMTraverser {
19    /**
20     * List of handlers to call on each node. Each handler is an array with the following fields:
21     * - action: a callable to call
22     * - nodeName: if set, only call it on nodes with this name
23     * These handlers are called before attribute-embedded HTML.
24     * @var array<array{action:callable,nodeName:string}>
25     * @see addHandler()
26     */
27    private $beforeAttributeHandlers = [];
28    /**
29     * List of handlers to call on each node. Each handler is an array with the following fields:
30     * - action: a callable to call
31     * - nodeName: if set, only call it on nodes with this name
32     * These handlers are called after attribute-embedded HTML.
33     * @var array<array{action:callable,nodeName:string}>
34     * @see addHandler()
35     */
36    private $afterAttributeHandlers = [];
37
38    /**
39     * Should the handlers be called on attribute-embedded-HTML strings?
40     */
41    private bool $applyToAttributeEmbeddedHTML;
42
43    /**
44     * @var bool
45     */
46    private $traverseWithTplInfo;
47
48    /**
49     * @param bool $traverseWithTplInfo
50     * @param bool $applyToAttributeEmbeddedHTML
51     */
52    public function __construct( bool $traverseWithTplInfo = false, bool $applyToAttributeEmbeddedHTML = false ) {
53        $this->traverseWithTplInfo = $traverseWithTplInfo;
54        $this->applyToAttributeEmbeddedHTML = $applyToAttributeEmbeddedHTML;
55    }
56
57    /**
58     * Add a handler to the DOM traverser.
59     *
60     * If you want traversal to continue into the children of this node,
61     * typically you will return true.  If you want traversal to skip the
62     * children of this node, typically you will return $node->nextSibling
63     * (which may be null).
64     *
65     * @param ?string $nodeName An optional node name filter
66     * @param callable $action A callback, called on each node we traverse
67     *   that matches nodeName, with the following parameters:
68     *   - Node $node: the node being processed
69     *   - DTState $state: State.
70     *   Return value: Node|null|true.
71     *   - true: proceed normally (aka recurse into children and attributes)
72     *   - Node: traversal will continue on the new node (further handlers will not be called
73     *     on the current node); after processing it and its siblings, it will continue with the
74     *     next sibling of the closest ancestor which has one.
75     *   - null: like the Node case, except there is no new node to process before continuing.
76     * @param bool $beforeAttributes If true, this node is visited
77     *   *before* the contents of any attribute-embedded HTML. If false
78     *   (the default), this node is visited *after* the contents of
79     *   attribute-embedded HTML.
80     */
81    public function addHandler( ?string $nodeName, callable $action, bool $beforeAttributes = false ): void {
82        $handler = [
83            'action' => $action,
84            'nodeName' => $nodeName,
85        ];
86        if ( $beforeAttributes ) {
87            $this->beforeAttributeHandlers[] = $handler;
88        } else {
89            $this->afterAttributeHandlers[] = $handler;
90        }
91    }
92
93    /**
94     * @param Node $node
95     * @param ?SiteConfig $siteConfig
96     * @param DTState|null $state
97     * @return bool|mixed
98     */
99    private function callHandlers( Node $node, ?SiteConfig $siteConfig, ?DTState $state ) {
100        $name = DOMUtils::nodeName( $node );
101
102        foreach ( $this->beforeAttributeHandlers as $handler ) {
103            if ( $handler['nodeName'] === null || $handler['nodeName'] === $name ) {
104                $result = $handler['action']( $node, $state );
105                if ( $result !== true ) {
106                    // Abort processing for this node
107                    return $result;
108                }
109            }
110        }
111
112        // Process embedded HTML first since the handlers below might
113        // return a different node which aborts processing. By processing
114        // attributes first, we ensure attribute are always processed.
115        if ( $node instanceof Element && $this->applyToAttributeEmbeddedHTML ) {
116            ContentUtils::processAttributeEmbeddedDom(
117                $siteConfig,
118                $node,
119                function ( DocumentFragment $dom ) use ( $siteConfig, $state ) {
120                    // We are processing a nested document (which by definition
121                    // is not a top-level document).
122                    // FIXME:
123                    // 1. This argument replicates existing behavior but is it sound?
124                    //    In any case, we should first replicate existing behavior
125                    //    and revisit this later.
126                    // 2. It is not clear if creating a *new* state is the right thing
127                    //    or if reusing *parts* of the old state is the right thing.
128                    //    One of the places where this matters is around the use of
129                    //    $state->tplInfo. One could probably find arguments for either
130                    //    direction. But, "independent parsing" semantics which Parsoid
131                    //    is aiming for would lead us to use a new state or even a new
132                    //    traversal object here and that feels a little bit "more correct"
133                    //    than reusing partial state.
134                    $newState = $state ? new DTState( $state->env, $state->options, false ) : null;
135                    $this->traverse( $siteConfig, $dom, $newState );
136                    return true; // $dom might have been changed
137                }
138            );
139        }
140
141        foreach ( $this->afterAttributeHandlers as $handler ) {
142            if ( $handler['nodeName'] === null || $handler['nodeName'] === $name ) {
143                $result = $handler['action']( $node, $state );
144                if ( $result !== true ) {
145                    // Abort processing for this node
146                    return $result;
147                }
148            }
149        }
150        return true;
151    }
152
153    /**
154     * Traverse the DOM and fire the handlers that are registered.
155     *
156     * Handlers can return
157     * - the next node to process: aborts processing for current node (ie. no further handlers are
158     *   called) and continues processing on returned node. Essentially, that node and its siblings
159     *   replace the current node and its siblings for the purposes of the traversal; after they
160     *   are fully processed, the algorithm moves back to the parent of $workNode to look for
161     *   the next sibling.
162     * - `null`: same as above, except it continues from the next sibling of the parent (or if
163     *   that does not exist, the next sibling of the grandparent etc). This is so that returning
164     *   `$workNode->nextSibling` works even when workNode is a last child of its parent.
165     * - `true`: continues regular processing on current node.
166     *
167     * @param ?SiteConfig $siteConfig
168     * @param Node $workNode The starting node for the traversal.
169     *   The traversal could go beyond the subtree rooted at $workNode if
170     *   the handlers called during traversal return an arbitrary node elsewhere
171     *   in the DOM in which case the traversal scope can be pretty much the whole
172     *   DOM that $workNode is present in. This behavior would be confusing but
173     *   there is nothing in the traversal code to prevent that.
174     * @param DTState|null $state
175     */
176    public function traverse( ?SiteConfig $siteConfig, Node $workNode, ?DTState $state = null ): void {
177        $this->traverseInternal( true, $siteConfig, $workNode, $state );
178    }
179
180    /**
181     * @param bool $isRootNode
182     * @param ?SiteConfig $siteConfig
183     * @param Node $workNode
184     * @param DTState|null $state
185     */
186    private function traverseInternal(
187        bool $isRootNode, ?SiteConfig $siteConfig, Node $workNode, ?DTState $state
188    ): void {
189        while ( $workNode !== null ) {
190            if ( $this->traverseWithTplInfo && $workNode instanceof Element ) {
191                // Identify the first template/extension node.
192                // You'd think the !tplInfo check isn't necessary since
193                // we don't have nested transclusions, however, you can
194                // get extensions in transclusions.
195                if (
196                    !( $state->tplInfo ?? null ) && WTUtils::isFirstEncapsulationWrapperNode( $workNode )
197                    // Ensure this isn't just a meta marker, since we might
198                    // not be traversing after encapsulation.  Note that the
199                    // nonempty data-mw assertion is the same test as used in
200                    // cleanup.
201                    && ( !WTUtils::isTplMarkerMeta( $workNode ) || !DOMDataUtils::getDataMw( $workNode )->isEmpty() )
202                    // Encapsulation info on sections should not be used to
203                    // traverse with since it's designed to be dropped and
204                    // may have expanded ranges.
205                    && !WTUtils::isParsoidSectionTag( $workNode )
206                ) {
207                    $about = DOMCompat::getAttribute( $workNode, 'about' );
208                    $aboutSiblings = WTUtils::getAboutSiblings( $workNode, $about );
209                    $state->tplInfo = (object)[
210                        'first' => $workNode,
211                        'last' => end( $aboutSiblings ),
212                        'clear' => false,
213                    ];
214                }
215            }
216
217            // Call the handlers on this workNode
218            if ( $workNode instanceof DocumentFragment ) {
219                $possibleNext = true;
220            } else {
221                $possibleNext = $this->callHandlers( $workNode, $siteConfig, $state );
222            }
223
224            // We may have walked passed the last about sibling or want to
225            // ignore the template info in future processing.
226            // In any case, it's up to the handler returning a possible next
227            // to figure out.
228            if ( $this->traverseWithTplInfo && ( $state->tplInfo->clear ?? false ) ) {
229                $state->tplInfo = null;
230            }
231
232            if ( $possibleNext === true ) {
233                // The 'continue processing' case
234                if ( $workNode->hasChildNodes() ) {
235                    $this->traverseInternal(
236                        false, $siteConfig, $workNode->firstChild, $state
237                    );
238                }
239                if ( $isRootNode ) {
240                    // Confine the traverse to the tree rooted as the root node.
241                    // `$workNode->nextSibling` would take us outside that.
242                    $possibleNext = null;
243                } else {
244                    $possibleNext = $workNode->nextSibling;
245                }
246            } elseif ( $isRootNode && $possibleNext !== $workNode ) {
247                $isRootNode = false;
248            }
249
250            // Clear the template info after reaching the last about sibling.
251            if (
252                $this->traverseWithTplInfo &&
253                ( ( $state->tplInfo->last ?? null ) === $workNode )
254            ) {
255                $state->tplInfo = null;
256            }
257
258            $workNode = $possibleNext;
259        }
260    }
261}