Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# 3.27.2 (2026-XX-XX)
# 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
* Make the `IntegrationTestCase` and `NodeTestCase` test helpers compatible with PHPUnit 11
Expand Down
8 changes: 4 additions & 4 deletions src/Environment.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@
*/
class Environment
{
public const VERSION = '3.27.2-DEV';
public const VERSION_ID = 32702;
public const VERSION = '3.28.0-DEV';
public const VERSION_ID = 32800;
public const MAJOR_VERSION = 3;
public const MINOR_VERSION = 27;
public const RELEASE_VERSION = 2;
public const MINOR_VERSION = 28;
public const RELEASE_VERSION = 0;
public const EXTRA_VERSION = 'DEV';

private $charset;
Expand Down
34 changes: 30 additions & 4 deletions src/Error/Error.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
class Error extends \Exception
{
private $lineno;
/** @var positive-int|null */
private ?int $columnno;
private $rawMessage;
private ?Source $source;
private string $phpFile;
Expand All @@ -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();
Expand All @@ -78,6 +82,25 @@ public function setTemplateLine(int $lineno): void
$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;
Expand Down Expand Up @@ -127,6 +150,9 @@ private function updateRepr(): void
}
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;
Expand Down
83 changes: 56 additions & 27 deletions src/Lexer.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ class Lexer
public const REGEX_INLINE_COMMENT = '/#[^\n]*/A';
public const PUNCTUATION = '()[]{}?:.,|';

private const REGEX_RAW_INLINE_COMMENT = '/#[^\r\n]*/A';

private const SPECIAL_CHARS = [
'f' => "\f",
'n' => "\n",
Expand Down Expand Up @@ -113,7 +115,7 @@ private function initialize(): void
'|'.
preg_quote($this->options['whitespace_line_trim'].$this->options['tag_block'][1], '#').'['.$this->options['whitespace_line_chars'].']*'. // ~%}[ \t\0\x0B]*
'|'.
preg_quote($this->options['tag_block'][1], '#').'\n?'. // %}\n?
preg_quote($this->options['tag_block'][1], '#').'(?:\r\n?|\n)?'. // %}(?:\r\n?|\n)?
')
}Ax',

Expand Down Expand Up @@ -143,7 +145,7 @@ private function initialize(): void
'|'.
preg_quote($this->options['whitespace_line_trim'].$this->options['tag_comment'][1], '#').'['.$this->options['whitespace_line_chars'].']*'. // ~#}[ \t\0\x0B]*
'|'.
preg_quote($this->options['tag_comment'][1], '#').'\n?'. // #}\n?
preg_quote($this->options['tag_comment'][1], '#').'(?:\r\n?|\n)?'. // #}(?:\r\n?|\n)?
')
}sx',

Expand Down Expand Up @@ -187,7 +189,7 @@ public function tokenize(Source $source): TokenStream
$this->initialize();

$this->source = $source;
$this->code = str_replace(["\r\n", "\r"], "\n", $source->getCode());
$this->code = $source->getCode();
$this->cursor = 0;
$this->lineno = 1;
$this->end = \strlen($this->code);
Expand Down Expand Up @@ -241,8 +243,9 @@ private function lexData(): void
{
// if no matches are left we return the rest of the template as simple text token
if ($this->position == \count($this->positions[0]) - 1) {
$this->pushToken(Token::TEXT_TYPE, substr($this->code, $this->cursor));
$this->cursor = $this->end;
$text = substr($this->code, $this->cursor);
$this->pushToken(Token::TEXT_TYPE, $this->normalizeNewlines($text));
$this->moveCursor($text);

return;
}
Expand Down Expand Up @@ -270,15 +273,20 @@ private function lexData(): void
$text = rtrim($text, " \t\0\x0B");
}
}
$this->pushToken(Token::TEXT_TYPE, $text);
$this->moveCursor($textContent.$position[0]);
$this->pushToken(Token::TEXT_TYPE, $this->normalizeNewlines($text));
$this->moveCursor($textContent);

switch ($this->positions[1][$this->position][0]) {
case $this->options['tag_comment'][0]:
$this->moveCursor($position[0]);
$this->lexComment();
break;

case $this->options['tag_block'][0]:
$lineno = $this->lineno;
$cursor = $this->cursor;
$this->moveCursor($position[0]);

// raw data?
if (preg_match($this->regexes['lex_block_raw'], $this->code, $match, 0, $this->cursor)) {
$this->moveCursor($match[0]);
Expand All @@ -288,14 +296,17 @@ private function lexData(): void
$this->moveCursor($match[0]);
$this->lineno = (int) $match[1];
} else {
$this->pushToken(Token::BLOCK_START_TYPE);
$this->pushToken(Token::BLOCK_START_TYPE, '', $cursor, $lineno);
$this->pushState(self::STATE_BLOCK);
$this->currentVarBlockLine = $this->lineno;
}
break;

case $this->options['tag_variable'][0]:
$this->pushToken(Token::VAR_START_TYPE);
$lineno = $this->lineno;
$cursor = $this->cursor;
$this->moveCursor($position[0]);
$this->pushToken(Token::VAR_START_TYPE, '', $cursor, $lineno);
$this->pushState(self::STATE_VAR);
$this->currentVarBlockLine = $this->lineno;
break;
Expand All @@ -305,8 +316,7 @@ private function lexData(): void
private function lexBlock(): void
{
if (!$this->brackets && preg_match($this->regexes['lex_block'], $this->code, $match, 0, $this->cursor)) {
$this->pushToken(Token::BLOCK_END_TYPE);
$this->moveCursor($match[0]);
$this->pushClosingToken(Token::BLOCK_END_TYPE, $match[0]);
$this->popState();
} else {
$this->lexExpression();
Expand All @@ -316,8 +326,7 @@ private function lexBlock(): void
private function lexVar(): void
{
if (!$this->brackets && preg_match($this->regexes['lex_var'], $this->code, $match, 0, $this->cursor)) {
$this->pushToken(Token::VAR_END_TYPE);
$this->moveCursor($match[0]);
$this->pushClosingToken(Token::VAR_END_TYPE, $match[0]);
$this->popState();
} else {
$this->lexExpression();
Expand Down Expand Up @@ -358,11 +367,11 @@ private function lexExpression(): void
elseif (str_contains(self::PUNCTUATION, $this->code[$this->cursor])) {
$this->checkBrackets($this->code[$this->cursor]);
$this->pushToken(Token::PUNCTUATION_TYPE, $this->code[$this->cursor]);
++$this->cursor;
$this->moveCursor($this->code[$this->cursor]);
}
// strings
elseif (preg_match(self::REGEX_STRING, $this->code, $match, 0, $this->cursor)) {
$this->pushToken(Token::STRING_TYPE, $this->stripcslashes(substr($match[0], 1, -1), substr($match[0], 0, 1)));
$this->pushToken(Token::STRING_TYPE, $this->stripcslashes($this->normalizeNewlines(substr($match[0], 1, -1)), substr($match[0], 0, 1)));
$this->moveCursor($match[0]);
}
// opening double quoted string
Expand All @@ -372,12 +381,12 @@ private function lexExpression(): void
$this->moveCursor($match[0]);
}
// inline comment
elseif (preg_match(self::REGEX_INLINE_COMMENT, $this->code, $match, 0, $this->cursor)) {
elseif (preg_match(self::REGEX_RAW_INLINE_COMMENT, $this->code, $match, 0, $this->cursor)) {
$this->moveCursor($match[0]);
}
// 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));
}
}

Expand Down Expand Up @@ -444,6 +453,7 @@ private function lexRawData(): void
throw new SyntaxError('Unexpected end of file: Unclosed "verbatim" block.', $this->lineno, $this->source);
}

$offset = $this->cursor;
$text = substr($this->code, $this->cursor, $match[0][1] - $this->cursor);
$this->moveCursor($text.$match[0][0]);

Expand All @@ -459,7 +469,7 @@ private function lexRawData(): void
}
}

$this->pushToken(Token::TEXT_TYPE, $text);
$this->pushToken(Token::TEXT_TYPE, $this->normalizeNewlines($text), $offset);
}

private function lexComment(): void
Expand All @@ -479,7 +489,7 @@ private function lexString(): void
$this->moveCursor($match[0]);
$this->pushState(self::STATE_INTERPOLATION);
} elseif (preg_match(self::REGEX_DQ_STRING_PART, $this->code, $match, 0, $this->cursor) && '' !== $match[0]) {
$this->pushToken(Token::STRING_TYPE, $this->stripcslashes($match[0], '"'));
$this->pushToken(Token::STRING_TYPE, $this->stripcslashes($this->normalizeNewlines($match[0]), '"'));
$this->moveCursor($match[0]);
} elseif (preg_match(self::REGEX_DQ_STRING_DELIM, $this->code, $match, 0, $this->cursor)) {
[$expect, $lineno] = array_pop($this->brackets);
Expand All @@ -488,10 +498,10 @@ private function lexString(): void
}

$this->popState();
++$this->cursor;
$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));
}
}

Expand All @@ -500,28 +510,47 @@ private function lexInterpolation(): void
$bracket = end($this->brackets);
if ($this->options['interpolation'][0] === $bracket[0] && preg_match($this->regexes['interpolation_end'], $this->code, $match, 0, $this->cursor)) {
array_pop($this->brackets);
$this->pushToken(Token::INTERPOLATION_END_TYPE);
$this->moveCursor($match[0]);
$this->pushClosingToken(Token::INTERPOLATION_END_TYPE, $match[0]);
$this->popState();
} else {
$this->lexExpression();
}
}

private function pushToken($type, $value = ''): void
private function pushToken($type, $value = '', ?int $offset = null, ?int $lineno = null): void
{
// do not push empty text tokens
if (Token::TEXT_TYPE === $type && '' === $value) {
return;
}

$this->tokens[] = new Token($type, $value, $this->lineno);
// by default the token starts at the current cursor; callers that
// emit a token after consuming it must pass an explicit offset
$this->tokens[] = new Token($type, $value, $lineno ?? $this->lineno, $offset ?? $this->cursor);
}

private function moveCursor($text): void
{
$this->cursor += \strlen($text);
$this->lineno += substr_count($text, "\n");
// count "\r\n" and "\r" as a single newline without allocating a
// normalized copy when the chunk has no carriage return (common case)
$this->lineno += str_contains($text, "\r") ? substr_count($this->normalizeNewlines($text), "\n") : substr_count($text, "\n");
}

private function normalizeNewlines(string $text): string
{
return str_replace(["\r\n", "\r"], "\n", $text);
}

private function pushClosingToken(int $type, string $match): void
{
$leadingWhitespaceLength = \strlen($match) - \strlen(ltrim($match));
if ($leadingWhitespaceLength) {
$this->moveCursor(substr($match, 0, $leadingWhitespaceLength));
}

$this->pushToken($type);
$this->moveCursor(substr($match, $leadingWhitespaceLength));
}

private function getOperatorRegex(): string
Expand Down Expand Up @@ -580,7 +609,7 @@ private function checkBrackets(string $code): void
} 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);
Expand Down
19 changes: 19 additions & 0 deletions src/Source.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,23 @@ public function getPath(): string
{
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;
}
}
17 changes: 17 additions & 0 deletions src/Token.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,14 @@ final class Token
*/
public const SPREAD_TYPE = 13;

/**
* @param non-negative-int|null $offset
*/
public function __construct(
private int $type,
private $value,
private int $lineno,
private ?int $offset = null,
) {
if (self::ARROW_TYPE === $type) {
trigger_deprecation('twig/twig', '3.21', 'The "%s" token type is deprecated, "arrow" is now an operator.', self::ARROW_TYPE);
Expand Down Expand Up @@ -124,6 +128,19 @@ public function getLine(): int
return $this->lineno;
}

/**
* Returns the 0-based byte offset of the token in the source code.
*
* Returns null for tokens that are not tied to a source position (e.g.
* tokens synthesized by a token parser).
*
* @return non-negative-int|null
*/
public function getOffset(): ?int
{
return $this->offset;
}

/**
* @deprecated since Twig 3.19
*/
Expand Down
Loading
Loading