Skip the string cast in PrintNode when the expression is already a string and add tests

This commit is contained in:
Fabien Potencier
2026-06-02 22:51:56 +02:00
parent 86840f9351
commit 89f886e324
6 changed files with 75 additions and 12 deletions
+1
View File
@@ -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)
+22 -2
View File
@@ -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 (string) ')
->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'));
}
}
+5 -5
View File
@@ -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']);
+5 -5
View File
@@ -63,7 +63,7 @@ class IfTest extends NodeTestCase
$tests[] = [$node, <<<EOF
// line 1
if (true) {
yield $fooGetter;
yield (string) $fooGetter;
}
EOF
];
@@ -80,9 +80,9 @@ EOF
$tests[] = [$node, <<<EOF
// line 1
if (true) {
yield $fooGetter;
yield (string) $fooGetter;
} elseif (false) {
yield $barGetter;
yield (string) $barGetter;
}
EOF
];
@@ -97,9 +97,9 @@ EOF
$tests[] = [$node, <<<EOF
// line 1
if (true) {
yield $fooGetter;
yield (string) $fooGetter;
} else {
yield $barGetter;
yield (string) $barGetter;
}
EOF
];
+13
View File
@@ -20,6 +20,7 @@ namespace Twig\Tests\Node;
* file that was distributed with this source code.
*/
use Twig\Node\Expression\Binary\ConcatBinary;
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Expression\GetAttrExpression;
use Twig\Node\Expression\Variable\ContextVariable;
@@ -40,8 +41,20 @@ class PrintTest extends NodeTestCase
public static function provideTests(): iterable
{
$tests = [];
// a string literal is known to be a string, so the cast is skipped
$tests[] = [new PrintNode(new ConstantExpression('foo', 1), 1), "// line 1\nyield \"foo\";"];
// a non-string expression is cast to make the conversion happen here for a useful stack trace
$tests[] = [new PrintNode(new ContextVariable('foo', 1), 1), "// line 1\nyield (string) (\$context[\"foo\"] ?? null);"];
// a non-string constant is cast as well
$tests[] = [new PrintNode(new ConstantExpression(42, 1), 1), "// line 1\nyield (string) 42;"];
// a concatenation always returns a string, so the cast is skipped
$concat = new ConcatBinary(new ContextVariable('foo', 1), new ContextVariable('bar', 1), 1);
$tests[] = [new PrintNode($concat, 1), "// line 1\nyield ((\$context[\"foo\"] ?? null) . (\$context[\"bar\"] ?? null));"];
$expr = new ContextVariable('foo', 1);
$attr = new ConstantExpression('bar', 1);
$node = new GetAttrExpression($expr, $attr, null, Template::METHOD_CALL, 1);
+29
View File
@@ -376,6 +376,35 @@ class TemplateTest extends TestCase
$this->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 = [