Report columns in syntax errors

This commit is contained in:
Fabien Potencier
2026-06-05 20:20:32 +02:00
parent 4943bb405c
commit a82782ac30
13 changed files with 149 additions and 20 deletions
+1
View File
@@ -1,5 +1,6 @@
# 3.28.0 (2026-XX-XX)
* Report the column number in syntax errors and expose it via `Error::getTemplateColumn()`
* Track the source offset of each token and expose it via `Token::getOffset()`
* Fix nested `block()` calls to resolve against the overriding template when a block rendered through `block(name, template)` calls `parent()`
* Stop reporting a skipped test in `IntegrationTestCase` when there is no legacy test to run
+30 -4
View File
@@ -36,6 +36,8 @@ use Twig\Template;
class Error extends \Exception
{
private $lineno;
/** @var positive-int|null */
private ?int $columnno;
private $rawMessage;
private ?Source $source;
private string $phpFile;
@@ -46,17 +48,19 @@ class Error extends \Exception
*
* By default, automatic guessing is enabled.
*
* @param string $message The error message
* @param int $lineno The template line where the error occurred
* @param Source|null $source The source context where the error occurred
* @param string $message The error message
* @param int $lineno The template line where the error occurred
* @param Source|null $source The source context where the error occurred
* @param positive-int|null $columnno The template column where the error occurred
*/
public function __construct(string $message, int $lineno = -1, ?Source $source = null, ?\Throwable $previous = null)
public function __construct(string $message, int $lineno = -1, ?Source $source = null, ?\Throwable $previous = null, ?int $columnno = null)
{
parent::__construct('', 0, $previous);
$this->phpFile = $this->getFile();
$this->phpLine = $this->getLine();
$this->lineno = $lineno;
$this->columnno = $columnno;
$this->source = $source;
$this->rawMessage = $message;
$this->updateRepr();
@@ -78,6 +82,25 @@ class Error extends \Exception
$this->updateRepr();
}
/**
* Returns the 1-based column where the error occurred, or null if unknown.
*
* @return positive-int|null
*/
public function getTemplateColumn(): ?int
{
return $this->columnno;
}
/**
* @param positive-int|null $columnno
*/
public function setTemplateColumn(?int $columnno): void
{
$this->columnno = $columnno;
$this->updateRepr();
}
public function getSourceContext(): ?Source
{
return $this->source;
@@ -127,6 +150,9 @@ class Error extends \Exception
}
if ($this->lineno > 0) {
$this->message .= \sprintf(' at line %d', $this->lineno);
if (null !== $this->columnno) {
$this->message .= \sprintf(' column %d', $this->columnno);
}
}
if ($punctuation) {
$this->message .= $punctuation;
+3 -3
View File
@@ -386,7 +386,7 @@ class Lexer
}
// unlexable
else {
throw new SyntaxError(\sprintf('Unexpected character "%s".', $this->code[$this->cursor]), $this->lineno, $this->source);
throw new SyntaxError(\sprintf('Unexpected character "%s".', $this->code[$this->cursor]), $this->lineno, $this->source, columnno: $this->source->getColumn($this->cursor));
}
}
@@ -501,7 +501,7 @@ class Lexer
$this->moveCursor($match[0]);
} else {
// unlexable
throw new SyntaxError(\sprintf('Unexpected character "%s".', $this->code[$this->cursor]), $this->lineno, $this->source);
throw new SyntaxError(\sprintf('Unexpected character "%s".', $this->code[$this->cursor]), $this->lineno, $this->source, columnno: $this->source->getColumn($this->cursor));
}
}
@@ -608,7 +608,7 @@ class Lexer
} elseif (\in_array($code, $this->closingBrackets, true)) {
// closing bracket
if (!$this->brackets) {
throw new SyntaxError(\sprintf('Unexpected "%s".', $code), $this->lineno, $this->source);
throw new SyntaxError(\sprintf('Unexpected "%s".', $code), $this->lineno, $this->source, columnno: $this->source->getColumn($this->cursor));
}
[$expect, $lineno] = array_pop($this->brackets);
+19
View File
@@ -44,4 +44,23 @@ final class Source
{
return $this->path;
}
/**
* Returns the 1-based column for a 0-based byte offset in the source code.
*
* A negative offset means the position is unknown and yields null.
*
* @return positive-int|null
*/
public function getColumn(int $offset): ?int
{
if ($offset < 0) {
return null;
}
$before = str_replace(["\r\n", "\r"], "\n", substr($this->code, 0, $offset));
$lineStart = strrpos($before, "\n");
return false === $lineStart ? \strlen($before) + 1 : \strlen($before) - $lineStart;
}
}
+2 -1
View File
@@ -83,7 +83,8 @@ final class TokenStream
$token->getValue() ? \sprintf(' of value "%s"', $token->getValue()) : '',
Token::typeToEnglish($type), $value ? \sprintf(' with value "%s"', $value) : ''),
$line,
$this->source
$this->source,
columnno: $this->source->getColumn($token->getOffset() ?? -1),
);
}
$this->next();
+1 -1
View File
@@ -414,7 +414,7 @@ class ExpressionParserTest extends TestCase
$parser = new Parser($env);
$this->expectException(SyntaxError::class);
$this->expectExceptionMessage('An argument must be a name. Unexpected token "string" of value "a" ("name" expected) in "index" at line 1.');
$this->expectExceptionMessage('An argument must be a name. Unexpected token "string" of value "a" ("name" expected) in "index" at line 1 column 14.');
$parser->parse($env->tokenize(new Source('{% macro foo("a") %}{% endmacro %}', 'index')));
}
@@ -7,4 +7,4 @@ Exception for syntax error in reused template
{% do node.data 5 %}
{% endblock %}
--EXCEPTION--
Twig\Error\SyntaxError: Unexpected token "number" of value "5" ("end of statement block" expected) in "foo.twig" at line 3.
Twig\Error\SyntaxError: Unexpected token "number" of value "5" ("end of statement block" expected) in "foo.twig" at line 3 column 21.
@@ -5,4 +5,4 @@ Twig does not allow to use 2 underscored between digits in numbers
--DATA--
return []
--EXCEPTION--
Twig\Error\SyntaxError: Unexpected token "name" of value "__2" ("end of print statement" expected) in "index.twig" at line 2.
Twig\Error\SyntaxError: Unexpected token "name" of value "__2" ("end of print statement" expected) in "index.twig" at line 2 column 5.
@@ -7,4 +7,4 @@
--DATA--
return []
--EXCEPTION--
Twig\Error\SyntaxError: Arguments must be separated by a comma. Unexpected token "punctuation" of value ":" ("punctuation" expected with value ",") in "index.twig" at line 2.
Twig\Error\SyntaxError: Arguments must be separated by a comma. Unexpected token "punctuation" of value ":" ("punctuation" expected with value ",") in "index.twig" at line 2 column 18.
@@ -5,4 +5,4 @@
--DATA--
return []
--EXCEPTION--
Twig\Error\SyntaxError: Unexpected token "end of statement block" ("name" expected with value "import") in "index.twig" at line 2.
Twig\Error\SyntaxError: Unexpected token "end of statement block" ("name" expected with value "import") in "index.twig" at line 2 column 22.
@@ -7,4 +7,4 @@
--DATA--
return []
--EXCEPTION--
Twig\Error\SyntaxError: Unexpected token "end of statement block" ("name" expected with value "as") in "index.twig" at line 2.
Twig\Error\SyntaxError: Unexpected token "end of statement block" ("name" expected with value "as") in "index.twig" at line 2 column 24.
+50 -6
View File
@@ -717,22 +717,22 @@ bar
* @dataProvider getTemplateForUnexpectedBracketInExpression
*/
#[DataProvider('getTemplateForUnexpectedBracketInExpression')]
public function testUnexpectedBracketInExpression(string $template, string $bracket)
public function testUnexpectedBracketInExpression(string $template, string $bracket, int $column)
{
$lexer = new Lexer(new Environment(new ArrayLoader()));
$this->expectException(SyntaxError::class);
$this->expectExceptionMessage(\sprintf('Unexpected "%s" in "index" at line 1.', $bracket));
$this->expectExceptionMessage(\sprintf('Unexpected "%s" in "index" at line 1 column %d.', $bracket, $column));
$lexer->tokenize(new Source($template, 'index'));
}
public static function getTemplateForUnexpectedBracketInExpression()
{
yield ['{{ 1 + 3) }}', ')'];
yield ['{{ obj] }}', ']'];
yield ['{{ { a: 1 }}', '}'];
yield ['{{ ([1] + 3)) }}', ')'];
yield ['{{ 1 + 3) }}', ')', 9];
yield ['{{ obj] }}', ']', 7];
yield ['{{ { a: 1 }}', '}', 12];
yield ['{{ ([1] + 3)) }}', ')', 13];
}
public function testTokensCarryTheirSourceOffset()
@@ -819,8 +819,52 @@ bar
$this->assertSame('%}', substr($template, $end, 2));
}
public function testClosingDelimiterLineMatchesTheMarkerLine()
{
$template = "{% from 'forms.twig'\n %}";
$env = new Environment(new ArrayLoader());
try {
$env->parse($env->tokenize(new Source($template, 'index')));
$this->fail('A SyntaxError should have been thrown.');
} catch (SyntaxError $e) {
$this->assertSame(2, $e->getTemplateLine());
$this->assertSame(3, $e->getTemplateColumn());
$this->assertStringEndsWith('at line 2 column 3.', $e->getMessage());
}
}
public function testSyntheticTokensHaveNoOffset()
{
$this->assertNull((new Token(Token::NAME_TYPE, 'foo', 1))->getOffset());
}
public function testSyntaxErrorReportsTheColumn()
{
$lexer = new Lexer(new Environment(new ArrayLoader()));
try {
$lexer->tokenize(new Source("{{ 1 + 3) }}\n{{ ok }}", 'index'));
$this->fail('A SyntaxError should have been thrown.');
} catch (SyntaxError $e) {
$this->assertSame(1, $e->getTemplateLine());
$this->assertSame(9, $e->getTemplateColumn());
$this->assertStringEndsWith('at line 1 column 9.', $e->getMessage());
}
}
public function testSyntaxErrorColumnUsesOriginalSourceOffsets()
{
$template = "x\r\n{{ 1__2 }}";
$env = new Environment(new ArrayLoader());
try {
$env->parse($env->tokenize(new Source($template, 'index')));
$this->fail('A SyntaxError should have been thrown.');
} catch (SyntaxError $e) {
$this->assertSame(2, $e->getTemplateLine());
$this->assertSame(5, $e->getTemplateColumn());
$this->assertStringEndsWith('at line 2 column 5.', $e->getMessage());
}
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Twig\Tests;
use PHPUnit\Framework\TestCase;
use Twig\Source;
class SourceTest extends TestCase
{
public function testGetColumn()
{
$source = new Source("foo\nbarbaz\nqux", 'index');
// first line: column is the 1-based offset
$this->assertSame(1, $source->getColumn(0));
$this->assertSame(3, $source->getColumn(2));
// a "\n" closes the line; the next character starts a new line at column 1
$this->assertSame(1, $source->getColumn(4));
$this->assertSame(4, $source->getColumn(7));
// "\r\n" and "\r" also close the line
$this->assertSame(1, (new Source("foo\r\nbar", 'index'))->getColumn(5));
$this->assertSame(1, (new Source("foo\rbar", 'index'))->getColumn(4));
// an unknown offset yields null
$this->assertNull($source->getColumn(-1));
}
}