diff --git a/CHANGELOG b/CHANGELOG index d2ce81567..1dc3b49ae 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,7 @@ # 3.27.2 (2026-XX-XX) * Make the `IntegrationTestCase` and `NodeTestCase` test helpers compatible with PHPUnit 11 + * Cast printed expressions to string so values that cannot be converted to a string (arrays, non-`Stringable` objects, ...) report a usable stack trace at the print location * Skip the sandbox `__toString` check on arguments whose PHP parameter type cannot implicitly coerce to string # 3.27.1 (2026-05-30) diff --git a/src/Node/PrintNode.php b/src/Node/PrintNode.php index da94ab468..0eec95d1f 100644 --- a/src/Node/PrintNode.php +++ b/src/Node/PrintNode.php @@ -15,6 +15,8 @@ namespace Twig\Node; use Twig\Attribute\YieldReady; use Twig\Compiler; use Twig\Node\Expression\AbstractExpression; +use Twig\Node\Expression\ConstantExpression; +use Twig\Node\Expression\ReturnStringInterface; /** * Represents a node that outputs an expression. @@ -34,9 +36,18 @@ class PrintNode extends Node implements NodeOutputInterface, CoercesChildrenToSt /** @var AbstractExpression */ $expr = $this->getNode('expr'); + $compiler->addDebugInfo($this); + + if ($expr->isGenerator()) { + $compiler->write('yield from '); + } else { + $compiler->write('yield '); + if (!$this->isString($expr)) { + $compiler->raw('(string) '); + } + } + $compiler - ->addDebugInfo($this) - ->write($expr->isGenerator() ? 'yield from ' : 'yield ') ->subcompile($expr) ->raw(";\n") ; @@ -46,4 +57,13 @@ class PrintNode extends Node implements NodeOutputInterface, CoercesChildrenToSt { return ['expr']; } + + private function isString(AbstractExpression $expr): bool + { + if ($expr instanceof ReturnStringInterface) { + return true; + } + + return $expr instanceof ConstantExpression && !$expr->isDefinedTestEnabled() && \is_string($expr->getAttribute('value')); + } } diff --git a/tests/Node/ForTest.php b/tests/Node/ForTest.php index 2e33263e5..2fcf0fa7c 100644 --- a/tests/Node/ForTest.php +++ b/tests/Node/ForTest.php @@ -73,7 +73,7 @@ class ForTest extends NodeTestCase \$context['_parent'] = \$context; \$context['_seq'] = CoreExtension::ensureTraversable($itemsGetter); foreach (\$context['_seq'] as \$context["key"] => \$context["item"]) { - yield $fooGetter; + yield (string) $fooGetter; } \$_parent = \$context['_parent']; unset(\$context['_seq'], \$context['key'], \$context['item'], \$context['_parent']); @@ -107,7 +107,7 @@ if (is_array(\$context['_seq']) || (is_object(\$context['_seq']) && \$context['_ \$context['loop']['last'] = 1 === \$length; } foreach (\$context['_seq'] as \$context["k"] => \$context["v"]) { - yield $fooGetter; + yield (string) $fooGetter; ++\$context['loop']['index0']; ++\$context['loop']['index']; \$context['loop']['first'] = false; @@ -149,7 +149,7 @@ if (is_array(\$context['_seq']) || (is_object(\$context['_seq']) && \$context['_ \$context['loop']['last'] = 1 === \$length; } foreach (\$context['_seq'] as \$context["k"] => \$context["v"]) { - yield $fooGetter; + yield (string) $fooGetter; ++\$context['loop']['index0']; ++\$context['loop']['index']; \$context['loop']['first'] = false; @@ -192,7 +192,7 @@ if (is_array(\$context['_seq']) || (is_object(\$context['_seq']) && \$context['_ \$context['loop']['last'] = 1 === \$length; } foreach (\$context['_seq'] as \$context["k"] => \$context["v"]) { - yield $fooGetter; + yield (string) $fooGetter; \$context['_iterated'] = true; ++\$context['loop']['index0']; ++\$context['loop']['index']; @@ -206,7 +206,7 @@ foreach (\$context['_seq'] as \$context["k"] => \$context["v"]) { // line 5 if (!\$context['_iterated']) { // line 6 - yield $fooGetter; + yield (string) $fooGetter; } \$_parent = \$context['_parent']; unset(\$context['_seq'], \$context['k'], \$context['v'], \$context['_parent'], \$context['_iterated'], \$context['loop']); diff --git a/tests/Node/IfTest.php b/tests/Node/IfTest.php index ab76f50af..7ce06e817 100644 --- a/tests/Node/IfTest.php +++ b/tests/Node/IfTest.php @@ -63,7 +63,7 @@ class IfTest extends NodeTestCase $tests[] = [$node, <<assertNull(CoreExtension::getAttribute($twig, $template->getSourceContext(), $object, 'foo')); } + /** + * @dataProvider provideNonStringPrintValues + */ + public function testPrintingANonStringReportsTheErrorAtThePrintLocation($value, string $expectedMessage) + { + $twig = new Environment(new ArrayLoader(['index' => "foo\n{{ value }}\nbar"])); + + set_error_handler(static function (int $type, string $msg, string $file, int $line): bool { + throw new \ErrorException($msg, 0, $type, $file, $line); + }, \E_WARNING); + + try { + $twig->render('index', ['value' => $value]); + $this->fail('Printing a non-string value should fail.'); + } catch (RuntimeError $e) { + $this->assertSame('index', $e->getSourceContext()->getName()); + $this->assertSame(2, $e->getTemplateLine()); + $this->assertSame($expectedMessage, $e->getPrevious()->getMessage()); + } finally { + restore_error_handler(); + } + } + + public static function provideNonStringPrintValues(): iterable + { + yield 'array' => [['a', 'b'], 'Array to string conversion']; + yield 'non-Stringable object' => [new \stdClass(), 'Object of class stdClass could not be converted to string']; + } + public static function getGetAttributeTests() { $array = [