From 9754be20393b2260134c5af85aeb5091ec79b095 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 10 Mar 2026 16:50:05 +0100 Subject: [PATCH 1/3] Narrow DOMDocument::createElement() return type for valid constant names Fixes phpstan/phpstan#13792 --- ...reateElementDynamicReturnTypeExtension.php | 63 +++++++++++++++++++ .../nsrt/dom-document-create-element.php | 48 ++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 src/Type/Php/DomDocumentCreateElementDynamicReturnTypeExtension.php create mode 100644 tests/PHPStan/Analyser/nsrt/dom-document-create-element.php diff --git a/src/Type/Php/DomDocumentCreateElementDynamicReturnTypeExtension.php b/src/Type/Php/DomDocumentCreateElementDynamicReturnTypeExtension.php new file mode 100644 index 0000000000..2495a769e9 --- /dev/null +++ b/src/Type/Php/DomDocumentCreateElementDynamicReturnTypeExtension.php @@ -0,0 +1,63 @@ +getName() === 'createElement'; + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type + { + $args = $methodCall->getArgs(); + if (!isset($args[0])) { + return null; + } + + $argType = $scope->getType($args[0]->value); + + $doc = new DOMDocument(); + + foreach ($argType->getConstantStrings() as $constantString) { + try { + $doc->createElement($constantString->getValue()); + } catch (DOMException) { + return null; + } + + $argType = TypeCombinator::remove($argType, $constantString); + } + + if (!$argType instanceof NeverType) { + return null; + } + + $variant = ParametersAcceptorSelector::selectFromArgs($scope, $args, $methodReflection->getVariants()); + + return TypeCombinator::remove($variant->getReturnType(), new ConstantBooleanType(false)); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/dom-document-create-element.php b/tests/PHPStan/Analyser/nsrt/dom-document-create-element.php new file mode 100644 index 0000000000..616c80231e --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/dom-document-create-element.php @@ -0,0 +1,48 @@ +createElement($name)); + } + + public function validConstantNames(DOMDocument $doc): void + { + assertType('DOMElement', $doc->createElement('div')); + assertType('DOMElement', $doc->createElement('my-element')); + assertType('DOMElement', $doc->createElement('ns:tag')); + assertType('DOMElement', $doc->createElement('_private')); + assertType('DOMElement', $doc->createElement('h1')); + } + + public function invalidConstantNames(DOMDocument $doc): void + { + assertType('DOMElement|false', $doc->createElement('')); + assertType('DOMElement|false', $doc->createElement('123element')); + assertType('DOMElement|false', $doc->createElement('my element')); + } + + /** + * @param 'div'|'span' $validUnion + * @param 'div'|'' $mixedUnion + */ + public function unions(DOMDocument $doc, string $validUnion, string $mixedUnion): void + { + assertType('DOMElement', $doc->createElement($validUnion)); + assertType('DOMElement|false', $doc->createElement($mixedUnion)); + } + + public function localVariable(DOMDocument $doc): void + { + $name = 'paragraph'; + assertType('DOMElement', $doc->createElement($name)); + } + +} From a4de6e117f80be3ebf4d875866e9d2d7c95da921 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 10 Mar 2026 17:06:21 +0100 Subject: [PATCH 2/3] Fix test assertions: benevolent union uses parenthesized format --- .../Analyser/nsrt/dom-document-create-element.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/dom-document-create-element.php b/tests/PHPStan/Analyser/nsrt/dom-document-create-element.php index 616c80231e..a97aa48959 100644 --- a/tests/PHPStan/Analyser/nsrt/dom-document-create-element.php +++ b/tests/PHPStan/Analyser/nsrt/dom-document-create-element.php @@ -10,7 +10,7 @@ class Foo public function dynamicName(DOMDocument $doc, string $name): void { - assertType('DOMElement|false', $doc->createElement($name)); + assertType('(DOMElement|false)', $doc->createElement($name)); } public function validConstantNames(DOMDocument $doc): void @@ -24,9 +24,9 @@ public function validConstantNames(DOMDocument $doc): void public function invalidConstantNames(DOMDocument $doc): void { - assertType('DOMElement|false', $doc->createElement('')); - assertType('DOMElement|false', $doc->createElement('123element')); - assertType('DOMElement|false', $doc->createElement('my element')); + assertType('(DOMElement|false)', $doc->createElement('')); + assertType('(DOMElement|false)', $doc->createElement('123element')); + assertType('(DOMElement|false)', $doc->createElement('my element')); } /** @@ -36,7 +36,7 @@ public function invalidConstantNames(DOMDocument $doc): void public function unions(DOMDocument $doc, string $validUnion, string $mixedUnion): void { assertType('DOMElement', $doc->createElement($validUnion)); - assertType('DOMElement|false', $doc->createElement($mixedUnion)); + assertType('(DOMElement|false)', $doc->createElement($mixedUnion)); } public function localVariable(DOMDocument $doc): void From 332c1ae99ef4502f68f0d1a34d948f4b8bb59932 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 10 Mar 2026 19:32:39 +0100 Subject: [PATCH 3/3] Add DynamicMethodThrowTypeExtension for DOMDocument::createElement() Removes the implicit DOMException throw point when all constant string arguments are valid XML element names. --- ...CreateElementDynamicThrowTypeExtension.php | 57 +++++++++++++++++++ ...CheckedExceptionInMethodThrowsRuleTest.php | 4 ++ .../data/missing-exception-method-throws.php | 11 ++++ 3 files changed, 72 insertions(+) create mode 100644 src/Type/Php/DomDocumentCreateElementDynamicThrowTypeExtension.php diff --git a/src/Type/Php/DomDocumentCreateElementDynamicThrowTypeExtension.php b/src/Type/Php/DomDocumentCreateElementDynamicThrowTypeExtension.php new file mode 100644 index 0000000000..3d9e125ee2 --- /dev/null +++ b/src/Type/Php/DomDocumentCreateElementDynamicThrowTypeExtension.php @@ -0,0 +1,57 @@ +getDeclaringClass()->getName() === DOMDocument::class + && $methodReflection->getName() === 'createElement'; + } + + public function getThrowTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type + { + $args = $methodCall->getArgs(); + if (!isset($args[0])) { + return new ObjectType(DOMException::class); + } + + $argType = $scope->getType($args[0]->value); + + $doc = new DOMDocument(); + + foreach ($argType->getConstantStrings() as $constantString) { + try { + $doc->createElement($constantString->getValue()); + } catch (DOMException) { + return new ObjectType(DOMException::class); + } + + $argType = TypeCombinator::remove($argType, $constantString); + } + + if (!$argType instanceof NeverType) { + return new ObjectType(DOMException::class); + } + + return null; + } + +} diff --git a/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInMethodThrowsRuleTest.php b/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInMethodThrowsRuleTest.php index aee538052e..8afa426f91 100644 --- a/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInMethodThrowsRuleTest.php +++ b/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInMethodThrowsRuleTest.php @@ -67,6 +67,10 @@ public function testRule(): void 122, ]; } + $errors[] = [ + 'Method MissingExceptionMethodThrows\Foo::domCreateElementDoesThrow() throws checked exception DOMException but it\'s missing from the PHPDoc @throws tag.', + 133, + ]; $this->analyse([__DIR__ . '/data/missing-exception-method-throws.php'], $errors); } diff --git a/tests/PHPStan/Rules/Exceptions/data/missing-exception-method-throws.php b/tests/PHPStan/Rules/Exceptions/data/missing-exception-method-throws.php index 21cfdf1072..4052b7daef 100644 --- a/tests/PHPStan/Rules/Exceptions/data/missing-exception-method-throws.php +++ b/tests/PHPStan/Rules/Exceptions/data/missing-exception-method-throws.php @@ -122,4 +122,15 @@ public function dateTimeModifyDoesThrows(\DateTime $dt, \DateTimeImmutable $dti, $dti->modify($m); } + public function domCreateElementDoesNotThrow(\DOMDocument $doc): void + { + $doc->createElement('div'); + $doc->createElement('my-element'); + } + + public function domCreateElementDoesThrow(\DOMDocument $doc, string $name): void + { + $doc->createElement($name); + } + }