mirror of
https://github.com/twigphp/Twig.git
synced 2026-08-30 12:06:56 +00:00
Report columns in syntax errors
This commit is contained in:
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user