From b5bf3bfc6819bbcc4a35c34e099c902152b1c038 Mon Sep 17 00:00:00 2001 From: Ruud Kamphuis Date: Sat, 6 Jul 2024 21:16:30 +0200 Subject: [PATCH] Support string literals This allows to write a string literal using backticks. When using a literal, you don't need to escape backslashes. This is ideal for situation where you want to reference a fully qualified class name. For example in a `constant` function. For example, before you had to write: ```twig {{ constant("App\\Entity\\Class::Constant") }} ``` Now you can write: ```twig {{ constant(`App\Entity\Class::Constant`) }} ``` Besides easier to read, there is another huge benefit: when doing a text search for a fully qualified class name, it will show up. --- src/Lexer.php | 32 ++++++++++++++++++++++++++++++ tests/Fixtures/tests/constant.test | 2 ++ tests/LexerTest.php | 15 ++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/src/Lexer.php b/src/Lexer.php index 93c9e52820d..62a04fb0ed3 100644 --- a/src/Lexer.php +++ b/src/Lexer.php @@ -57,12 +57,15 @@ class Lexer public const STATE_VAR = 2; public const STATE_STRING = 3; public const STATE_INTERPOLATION = 4; + public const STATE_STRING_LITERAL = 5; public const REGEX_NAME = '/[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/A'; public const REGEX_NUMBER = '/[0-9]+(?:\.[0-9]+)?([Ee][\+\-][0-9]+)?/A'; public const REGEX_STRING = '/"([^#"\\\\]*(?:\\\\.[^#"\\\\]*)*)"|\'([^\'\\\\]*(?:\\\\.[^\'\\\\]*)*)\'/As'; public const REGEX_DQ_STRING_DELIM = '/"/A'; public const REGEX_DQ_STRING_PART = '/[^#"\\\\]*(?:(?:\\\\.|#(?!\{))[^#"\\\\]*)*/As'; + public const REGEX_STRING_LITERAL_DELIM = '/`/A'; + public const REGEX_STRING_LITERAL_PART = '/[^`]+/As'; public const PUNCTUATION = '()[]{}?:.,|'; public function __construct(Environment $env, array $options = []) @@ -218,6 +221,10 @@ public function tokenize(Source $source): TokenStream case self::STATE_INTERPOLATION: $this->lexInterpolation(); break; + + case self::STATE_STRING_LITERAL: + $this->lexStringLiteral(); + break; } } @@ -390,6 +397,12 @@ private function lexExpression(): void $this->pushState(self::STATE_STRING); $this->moveCursor($match[0]); } + // opening string literal + elseif (preg_match(self::REGEX_STRING_LITERAL_DELIM, $this->code, $match, 0, $this->cursor)) { + $this->brackets[] = ['`', $this->lineno]; + $this->pushState(self::STATE_STRING_LITERAL); + $this->moveCursor($match[0]); + } // unlexable else { throw new SyntaxError(\sprintf('Unexpected character "%s".', $this->code[$this->cursor]), $this->lineno, $this->source); @@ -466,6 +479,25 @@ private function lexInterpolation(): void } } + private function lexStringLiteral(): void + { + if (preg_match(self::REGEX_STRING_LITERAL_PART, $this->code, $match, 0, $this->cursor) && '' !== $match[0]) { + $this->pushToken(/* Token::STRING_TYPE */ 7, $match[0]); + $this->moveCursor($match[0]); + } elseif (preg_match(self::REGEX_STRING_LITERAL_DELIM, $this->code, $match, 0, $this->cursor)) { + [$expect, $lineno] = array_pop($this->brackets); + if ('`' != $this->code[$this->cursor]) { + throw new SyntaxError(\sprintf('Unclosed "%s".', $expect), $lineno, $this->source); + } + + $this->popState(); + ++$this->cursor; + } else { + // unlexable + throw new SyntaxError(\sprintf('Unexpected character "%s".', $this->code[$this->cursor]), $this->lineno, $this->source); + } + } + private function pushToken($type, $value = ''): void { // do not push empty text tokens diff --git a/tests/Fixtures/tests/constant.test b/tests/Fixtures/tests/constant.test index d4a9be7764b..0211b6fba2e 100644 --- a/tests/Fixtures/tests/constant.test +++ b/tests/Fixtures/tests/constant.test @@ -4,6 +4,7 @@ {{ 8 is constant('E_NOTICE') ? 'ok' : 'no' }} {{ 'bar' is constant('Twig\\Tests\\TwigTestFoo::BAR_NAME') ? 'ok' : 'no' }} {{ value is constant('Twig\\Tests\\TwigTestFoo::BAR_NAME') ? 'ok' : 'no' }} +{{ value is constant(`Twig\Tests\TwigTestFoo::BAR_NAME`) ? 'ok' : 'no' }} {{ 2 is constant('ARRAY_AS_PROPS', object) ? 'ok' : 'no' }} --DATA-- return ['value' => 'bar', 'object' => new \ArrayObject(['hi'])] @@ -11,4 +12,5 @@ return ['value' => 'bar', 'object' => new \ArrayObject(['hi'])] ok ok ok +ok ok \ No newline at end of file diff --git a/tests/LexerTest.php b/tests/LexerTest.php index 2aad47ac9b3..7519639acf6 100644 --- a/tests/LexerTest.php +++ b/tests/LexerTest.php @@ -401,4 +401,19 @@ public function getTemplateForErrorsAtTheEndOfTheStream() yield ['{{ =']; yield ['{{ ..']; } + + public function testStringLiterals() + { + $template = '{{ `My\Name\Space` }}'; + + $lexer = new Lexer(new Environment($this->createMock(LoaderInterface::class))); + $stream = $lexer->tokenize(new Source($template, 'index')); + $stream->expect(Token::VAR_START_TYPE); + $stream->expect(Token::STRING_TYPE, 'My\Name\Space'); + $stream->expect(Token::VAR_END_TYPE); + + // add a dummy assertion here to satisfy PHPUnit, the only thing we want to test is that the code above + // can be executed without throwing any exceptions + $this->addToAssertionCount(1); + } }