1 | <?php |
||
11 | class ComplexTagMatcher extends TagMatcher { |
||
12 | |||
13 | /** |
||
14 | * @link http://www.xmlsoft.org/html/libxml-xmlerror.html#xmlParserErrors |
||
15 | * @link https://github.com/Chronic-Dev/libxml2/blob/683f296a905710ff285c28b8644ef3a3d8be9486/include/libxml/xmlerror.h#L257 |
||
16 | */ |
||
17 | const XML_UNKNOWN_TAG_ERROR_CODE = 801; |
||
18 | |||
19 | /** |
||
20 | * @var string |
||
21 | */ |
||
22 | private $tagHtmlOutline; |
||
23 | |||
24 | /** |
||
25 | * @var Matcher |
||
26 | */ |
||
27 | private $matcher; |
||
28 | |||
29 | /** |
||
30 | * @param string $htmlOutline |
||
31 | * |
||
32 | * @return self |
||
33 | */ |
||
34 | 13 | public static function tagMatchingOutline( $htmlOutline ) { |
|
35 | 13 | return new self( $htmlOutline ); |
|
36 | } |
||
37 | |||
38 | /** |
||
39 | * @param string $tagHtmlRepresentation |
||
40 | */ |
||
41 | 13 | public function __construct( $tagHtmlRepresentation ) { |
|
42 | 13 | parent::__construct(); |
|
43 | |||
44 | 13 | $this->tagHtmlOutline = $tagHtmlRepresentation; |
|
45 | 13 | $this->matcher = $this->createMatcherFromHtml( $tagHtmlRepresentation ); |
|
46 | 10 | } |
|
47 | |||
48 | 6 | public function describeTo( Description $description ) { |
|
49 | 6 | $description->appendText( 'tag matching outline `' ) |
|
50 | 6 | ->appendText( $this->tagHtmlOutline ) |
|
51 | 6 | ->appendText( '` ' ); |
|
52 | 6 | } |
|
53 | |||
54 | /** |
||
55 | * @param \DOMElement $item |
||
56 | * @param Description $mismatchDescription |
||
57 | * |
||
58 | * @return bool |
||
59 | */ |
||
60 | 12 | protected function matchesSafelyWithDiagnosticDescription( $item, Description $mismatchDescription ) { |
|
61 | 12 | if ( $this->matcher->matches( $item ) ) { |
|
62 | 12 | return true; |
|
63 | 7 | } |
|
64 | 7 | ||
65 | 7 | $mismatchDescription->appendText( 'was `' ) |
|
66 | 7 | ->appendText( $this->elementToString( $item ) ) |
|
67 | 12 | ->appendText( '`' ); |
|
68 | return false; |
||
69 | } |
||
70 | |||
71 | /** |
||
72 | * @param string $htmlOutline |
||
73 | * |
||
74 | * @return Matcher |
||
75 | 13 | */ |
|
76 | 13 | private function createMatcherFromHtml( $htmlOutline ) { |
|
77 | 12 | $document = $this->parseHtml( $htmlOutline ); |
|
78 | $targetTag = $this->getSingleTagFromThe( $document ); |
||
79 | 11 | ||
80 | $this->assertTagDoesNotContainChildren( $targetTag ); |
||
81 | 10 | ||
82 | 10 | $attributeMatchers = $this->createAttributeMatchers( $htmlOutline, $targetTag ); |
|
83 | $classMatchers = $this->createClassMatchers( $targetTag ); |
||
84 | 10 | ||
85 | 10 | return AllOf::allOf( |
|
86 | 10 | new TagNameMatcher( IsEqual::equalTo( $targetTag->tagName ) ), |
|
87 | 10 | call_user_func_array( [ AllOf::class, 'allOf' ], $attributeMatchers ), |
|
88 | 10 | call_user_func_array( [ AllOf::class, 'allOf' ], $classMatchers ) |
|
89 | ); |
||
90 | } |
||
91 | |||
92 | /** |
||
93 | * @param \LibXMLError $error |
||
94 | * |
||
95 | * @return bool |
||
96 | */ |
||
97 | private function isUnknownTagError( \LibXMLError $error ) { |
||
98 | return $error->code === self::XML_UNKNOWN_TAG_ERROR_CODE; |
||
99 | } |
||
100 | |||
101 | /** |
||
102 | * @param string $inputHtml |
||
103 | * @param string $attributeName |
||
104 | * |
||
105 | * @return bool |
||
106 | 5 | */ |
|
107 | 5 | private function isBooleanAttribute( $inputHtml, $attributeName ) { |
|
108 | $quotedName = preg_quote( $attributeName, '/' ); |
||
109 | 5 | ||
110 | 5 | $attributeHasValueAssigned = preg_match( "/\b{$quotedName}\s*=/ui", $inputHtml ); |
|
111 | return !$attributeHasValueAssigned; |
||
112 | } |
||
113 | |||
114 | /** |
||
115 | * @param string $html |
||
116 | * |
||
117 | * @return \DOMDocument |
||
118 | * @throws \InvalidArgumentException |
||
119 | 13 | */ |
|
120 | 13 | private function parseHtml( $html ) { |
|
121 | 13 | $internalErrors = libxml_use_internal_errors( true ); |
|
122 | $document = new \DOMDocument(); |
||
123 | |||
124 | 13 | // phpcs:ignore Generic.PHP.NoSilencedErrors |
|
125 | 1 | if ( !@$document->loadHTML( $html ) ) { |
|
126 | throw new \InvalidArgumentException( "There was some parsing error of `$html`" ); |
||
127 | } |
||
128 | 12 | ||
129 | 12 | $errors = libxml_get_errors(); |
|
130 | 12 | libxml_clear_errors(); |
|
131 | libxml_use_internal_errors( $internalErrors ); |
||
132 | |||
133 | 12 | /** @var \LibXMLError $error */ |
|
134 | foreach ( $errors as $error ) { |
||
135 | if ( $this->isUnknownTagError( $error ) ) { |
||
136 | continue; |
||
137 | } |
||
138 | |||
139 | throw new \InvalidArgumentException( |
||
140 | 'There was parsing error: ' . trim( $error->message ) . ' on line ' . $error->line |
||
141 | 12 | ); |
|
142 | } |
||
143 | 12 | ||
144 | return $document; |
||
145 | } |
||
146 | |||
147 | /** |
||
148 | * @param \DOMDocument $document |
||
149 | * |
||
150 | * @return \DOMElement |
||
151 | * @throws \InvalidArgumentException |
||
152 | 12 | */ |
|
153 | 12 | private function getSingleTagFromThe( \DOMDocument $document ) { |
|
154 | $directChildren = $document->documentElement->childNodes->item( 0 )->childNodes; |
||
155 | 12 | ||
156 | 12 | if ( $directChildren->length !== 1 ) { |
|
157 | throw new InvalidArgumentException( |
||
158 | 12 | 'Expected exactly 1 tag description, got ' . $directChildren->length |
|
159 | 1 | } |
|
|
|||
160 | 1 | ||
161 | 1 | return $directChildren->item( 0 ); |
|
162 | } |
||
163 | |||
164 | 11 | private function assertTagDoesNotContainChildren( \DOMElement $targetTag ) { |
|
165 | if ( $targetTag->childNodes->length > 0 ) { |
||
166 | throw new InvalidArgumentException( 'Nested elements are not allowed' ); |
||
167 | 11 | } |
|
168 | 11 | } |
|
169 | 1 | ||
170 | /** |
||
171 | 10 | * @param string $inputHtml |
|
172 | * @param \DOMElement $targetTag |
||
173 | * |
||
174 | * @return AttributeMatcher[] |
||
175 | */ |
||
176 | private function createAttributeMatchers( $inputHtml, \DOMElement $targetTag ) { |
||
177 | $attributeMatchers = []; |
||
178 | /** @var \DOMAttr $attribute */ |
||
179 | 10 | foreach ( $targetTag->attributes as $attribute ) { |
|
180 | 10 | if ( $attribute->name === 'class' ) { |
|
181 | continue; |
||
182 | 10 | } |
|
183 | 8 | ||
184 | 3 | $attributeMatcher = new AttributeMatcher( IsEqual::equalTo( $attribute->name ) ); |
|
185 | if ( !$this->isBooleanAttribute( $inputHtml, $attribute->name ) ) { |
||
186 | $attributeMatcher = $attributeMatcher->havingValue( IsEqual::equalTo( $attribute->value ) ); |
||
187 | 5 | } |
|
188 | 5 | ||
189 | 4 | $attributeMatchers[] = $attributeMatcher; |
|
190 | 4 | } |
|
191 | return $attributeMatchers; |
||
192 | 5 | } |
|
193 | 10 | ||
194 | 10 | /** |
|
195 | * @param \DOMElement $targetTag |
||
196 | * |
||
197 | * @return ClassMatcher[] |
||
198 | */ |
||
199 | private function createClassMatchers( \DOMElement $targetTag ) { |
||
200 | $classMatchers = []; |
||
201 | $classValue = $targetTag->getAttribute( 'class' ); |
||
202 | 10 | foreach ( explode( ' ', $classValue ) as $expectedClass ) { |
|
203 | 10 | if ( $expectedClass === '' ) { |
|
204 | 10 | continue; |
|
205 | 10 | } |
|
206 | 10 | $classMatchers[] = new ClassMatcher( IsEqual::equalTo( $expectedClass ) ); |
|
207 | 8 | } |
|
208 | return $classMatchers; |
||
209 | 3 | } |
|
210 | 10 | ||
211 | 10 | /** |
|
212 | * @param \DOMElement $element |
||
213 | * |
||
214 | * @return string |
||
215 | */ |
||
216 | private function elementToString( \DOMElement $element ) { |
||
217 | $newDocument = new \DOMDocument(); |
||
218 | $cloned = $element->cloneNode( true ); |
||
219 | 7 | $newDocument->appendChild( $newDocument->importNode( $cloned, true ) ); |
|
220 | 7 | return trim( $newDocument->saveHTML() ); |
|
221 | 7 | } |
|
222 | 7 | ||
223 | } |
||
224 |