ComplexTagMatcher   A
last analyzed

Complexity

Total Complexity 24

Size/Duplication

Total Lines 214
Duplicated Lines 0 %

Coupling/Cohesion

Components 3
Dependencies 8

Test Coverage

Coverage 92.13%

Importance

Changes 0
Metric Value
wmc 24
lcom 3
cbo 8
dl 0
loc 214
ccs 82
cts 89
cp 0.9213
rs 10
c 0
b 0
f 0

13 Methods

Rating   Name   Duplication   Size   Complexity  
A tagMatchingOutline() 0 3 1
A __construct() 0 6 1
A describeTo() 0 5 1
A matchesSafelyWithDiagnosticDescription() 0 10 2
A createMatcherFromHtml() 0 15 1
A isUnknownTagError() 0 3 1
A isBooleanAttribute() 0 6 1
A parseHtml() 0 26 4
A getSingleTagFromThe() 0 11 2
A assertTagDoesNotContainChildren() 0 5 2
A createAttributeMatchers() 0 17 4
A createClassMatchers() 0 11 3
A elementToString() 0 6 1
1
<?php
2
3
namespace WMDE\HamcrestHtml;
4
5
use Hamcrest\Core\AllOf;
6
use Hamcrest\Core\IsEqual;
7
use InvalidArgumentException;
8
use Hamcrest\Description;
9
use Hamcrest\Matcher;
10
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 6
			return true;
63
		}
64
65 7
		$mismatchDescription->appendText( 'was `' )
66 7
			->appendText( $this->elementToString( $item ) )
67 7
			->appendText( '`' );
68 7
		return false;
69
	}
70
71
	/**
72
	 * @param string $htmlOutline
73
	 *
74
	 * @return Matcher
75
	 */
76 13
	private function createMatcherFromHtml( $htmlOutline ) {
77 13
		$document = $this->parseHtml( $htmlOutline );
78 12
		$targetTag = $this->getSingleTagFromThe( $document );
79
80 11
		$this->assertTagDoesNotContainChildren( $targetTag );
81
82 10
		$attributeMatchers = $this->createAttributeMatchers( $htmlOutline, $targetTag );
83 10
		$classMatchers = $this->createClassMatchers( $targetTag );
84
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 10
		);
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
	 */
107 5
	private function isBooleanAttribute( $inputHtml, $attributeName ) {
108 5
		$quotedName = preg_quote( $attributeName, '/' );
109
110 5
		$attributeHasValueAssigned = preg_match( "/\b{$quotedName}\s*=/ui", $inputHtml );
111 5
		return !$attributeHasValueAssigned;
112
	}
113
114
	/**
115
	 * @param string $html
116
	 *
117
	 * @return \DOMDocument
118
	 * @throws \InvalidArgumentException
119
	 */
120 13
	private function parseHtml( $html ) {
121 13
		$internalErrors = libxml_use_internal_errors( true );
122 13
		$document = new \DOMDocument();
123
124
		// phpcs:ignore Generic.PHP.NoSilencedErrors
125 13
		if ( [email protected]$document->loadHTML( $html ) ) {
126 1
			throw new \InvalidArgumentException( "There was some parsing error of `$html`" );
127
		}
128
129 12
		$errors = libxml_get_errors();
130 12
		libxml_clear_errors();
131 12
		libxml_use_internal_errors( $internalErrors );
132
133
		/** @var \LibXMLError $error */
134 12
		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
			);
142 12
		}
143
144 12
		return $document;
145
	}
146
147
	/**
148
	 * @param \DOMDocument $document
149
	 *
150
	 * @return \DOMElement
151
	 * @throws \InvalidArgumentException
152
	 */
153 12
	private function getSingleTagFromThe( \DOMDocument $document ) {
154 12
		$directChildren = $document->documentElement->childNodes->item( 0 )->childNodes;
155
156 12
		if ( $directChildren->length !== 1 ) {
157 1
			throw new InvalidArgumentException(
158 1
				'Expected exactly 1 tag description, got ' . $directChildren->length
159 1
			);
160
		}
161
162 11
		return $directChildren->item( 0 );
163
	}
164
165 11
	private function assertTagDoesNotContainChildren( \DOMElement $targetTag ) {
166 11
		if ( $targetTag->childNodes->length > 0 ) {
167 1
			throw new InvalidArgumentException( 'Nested elements are not allowed' );
168
		}
169 10
	}
170
171
	/**
172
	 * @param string $inputHtml
173
	 * @param \DOMElement $targetTag
174
	 *
175
	 * @return AttributeMatcher[]
176
	 */
177 10
	private function createAttributeMatchers( $inputHtml, \DOMElement $targetTag ) {
178 10
		$attributeMatchers = [];
179
		/** @var \DOMAttr $attribute */
180 10
		foreach ( $targetTag->attributes as $attribute ) {
181 8
			if ( $attribute->name === 'class' ) {
182 3
				continue;
183
			}
184
185 5
			$attributeMatcher = new AttributeMatcher( IsEqual::equalTo( $attribute->name ) );
186 5
			if ( !$this->isBooleanAttribute( $inputHtml, $attribute->name ) ) {
187 4
				$attributeMatcher = $attributeMatcher->havingValue( IsEqual::equalTo( $attribute->value ) );
188 4
			}
189
190 5
			$attributeMatchers[] = $attributeMatcher;
191 10
		}
192 10
		return $attributeMatchers;
193
	}
194
195
	/**
196
	 * @param \DOMElement $targetTag
197
	 *
198
	 * @return ClassMatcher[]
199
	 */
200 10
	private function createClassMatchers( \DOMElement $targetTag ) {
201 10
		$classMatchers = [];
202 10
		$classValue = $targetTag->getAttribute( 'class' );
203 10
		foreach ( explode( ' ', $classValue ) as $expectedClass ) {
204 10
			if ( $expectedClass === '' ) {
205 8
				continue;
206
			}
207 3
			$classMatchers[] = new ClassMatcher( IsEqual::equalTo( $expectedClass ) );
208 10
		}
209 10
		return $classMatchers;
210
	}
211
212
	/**
213
	 * @param \DOMElement $element
214
	 *
215
	 * @return string
216
	 */
217 7
	private function elementToString( \DOMElement $element ) {
218 7
		$newDocument = new \DOMDocument();
219 7
		$cloned = $element->cloneNode( true );
220 7
		$newDocument->appendChild( $newDocument->importNode( $cloned, true ) );
221 7
		return trim( $newDocument->saveHTML() );
222
	}
223
224
}
225