Skip to content

Commit 63e50dc

Browse files
committed
Share Twig call argument resolution across features
1 parent 4507e6e commit 63e50dc

32 files changed

Lines changed: 304 additions & 143 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Unreleased
44

5+
- Recognize named arguments in Twig route, template and PHP symbol calls
56
- Honor named Twig translation arguments
67
- Resolve YAML merge keys during configuration diagnostics
78
- Treat conventional routing files as routes during configuration diagnostics

docs/features/routing.rst

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,12 @@ Controller helpers remain recognized when an application controller inherits
1919
from ``AbstractController`` through one or more project base classes. Twig's
2020
``path()`` and ``url()`` functions are also supported. The server avoids
2121
suggestions when it can't establish that a similarly named method belongs to a
22-
Symfony API. Static Twig route names and quoted parameter keys use Twig's string
23-
escape semantics. Twig parameter mappings support explicit entries such as
24-
``{slug: article.slug}`` and shorthand entries such as ``{year, month}``.
22+
Symfony API. Twig route functions recognize positional arguments and the named
23+
``name`` and ``parameters`` arguments. Completion expects named arguments in
24+
their declared order; navigation and diagnostics also recognize reordered
25+
named arguments. Static Twig route names and quoted parameter keys use Twig's
26+
string escape semantics. Twig parameter mappings support explicit entries such
27+
as ``{slug: article.slug}`` and shorthand entries such as ``{year, month}``.
2528

2629
Route Name Completion
2730
---------------------

docs/features/templates.rst

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,11 @@ calls, in the ``#[Template]`` attribute and in these Twig contexts:
1919
* the ``include()`` and ``source()`` functions.
2020

2121
Both regular names such as ``article/show.html.twig`` and namespaced names such
22-
as ``@Admin/dashboard.html.twig`` are supported. Static names use Twig's string
23-
escape semantics. Completion and navigation resolve a leading ``./`` on regular
22+
as ``@Admin/dashboard.html.twig`` are supported. The ``include()`` and
23+
``source()`` functions recognize positional and named template arguments.
24+
Completion expects named arguments in their declared order; navigation also
25+
recognizes reordered named arguments. Static names use Twig's string escape
26+
semantics. Completion and navigation resolve a leading ``./`` on regular
2427
loader-root names. Before an ``@`` prefix, it keeps the name in Twig's main
2528
namespace.
2629

docs/features/twig-constants-enums.rst

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ function. Enum classes and cases are also recognized through ``enum()`` and
2121
{{ status.value }}
2222
{% endfor %}
2323

24-
String arguments use Twig's escape semantics before PHP class and member names
25-
are resolved.
24+
Arguments are recognized positionally or by the ``constant`` and ``enum``
25+
names declared by the Twig functions. String arguments use Twig's escape
26+
semantics before PHP class and member names are resolved.
2627

2728
Completion
2829
----------

resources/services.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
use Symfony\Lsp\Parser\TreeSitter\LastResultTreeSitterParser;
7373
use Symfony\Lsp\Parser\TreeSitter\NativeTreeSitterParser;
7474
use Symfony\Lsp\Parser\TreeSitter\TreeSitterParserInterface;
75+
use Symfony\Lsp\Parser\Twig\TwigCallArgumentResolver;
7576
use Symfony\Lsp\Parser\Twig\TwigQuotedArgumentMatcher;
7677
use Symfony\Lsp\Parser\Xml\XmlCommentParser;
7778
use Symfony\Lsp\Parser\Yaml\YamlCommentParser;
@@ -195,6 +196,7 @@
195196
$services->load('Symfony\\Lsp\\Parser\\', '../src/Parser/**/*{Parser,Decoder,Locator}.php');
196197
$services->set(BalancedDelimiterMatcher::class);
197198
$services->set(QuotedArgumentMatcher::class);
199+
$services->set(TwigCallArgumentResolver::class);
198200
$services->set(TwigQuotedArgumentMatcher::class);
199201
$services->set(TranslationParameterAnalyzer::class);
200202
$services->set(XmlCommentParser::class);

src/Feature/Route/TwigRouteCompletionContext.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public static function fromTwig(string $text, Position $position, PositionConver
1919
$cursor = $positionConverter->toByteOffset($text, $position);
2020
$beforeCursor = substr($text, 0, $cursor);
2121
if (!preg_match(
22-
'/\b(?:path|url)\s*\(\s*([\'\"])([^\'\"]*)$/s',
22+
'/\b(?:path|url)\s*\(\s*(?:name\s*[:=]\s*)?([\'\"])([^\'\"]*)$/s',
2323
$beforeCursor,
2424
$matches,
2525
\PREG_OFFSET_CAPTURE,

src/Feature/Route/TwigRouteParameterCompletionContext.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ public static function fromTwig(string $text, Position $position, PositionConver
2424
$cursor = $positionConverter->toByteOffset($text, $position);
2525
$beforeCursor = substr($text, 0, $cursor);
2626
if (!preg_match(
27-
'/\b(?:path|url)\s*\(\s*([\'\"])([^\'\"]+)\1\s*,\s*\{([^}]*?)([\'\"])([^\'\"]*)$/s',
27+
'/\b(?:path|url)\s*\(\s*(?:name\s*[:=]\s*)?([\'\"])([^\'\"]+)\1\s*,\s*(?:parameters\s*[:=]\s*)?\{([^}]*?)([\'\"])([^\'\"]*)$/s',
2828
$beforeCursor,
2929
$matches,
3030
\PREG_OFFSET_CAPTURE,

src/Feature/Route/TwigRouteReferenceExtractor.php

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use Symfony\Lsp\Document\PositionConverter;
66
use Symfony\Lsp\Document\Range;
77
use Symfony\Lsp\Parser\TreeSitter\TreeSitterNode;
8+
use Symfony\Lsp\Parser\Twig\TwigCallArgumentResolver;
89
use Symfony\Lsp\Parser\Twig\TwigDocument;
910
use Symfony\Lsp\Parser\Twig\TwigDocumentParser;
1011

@@ -13,6 +14,7 @@ final class TwigRouteReferenceExtractor
1314
public function __construct(
1415
private readonly PositionConverter $positionConverter,
1516
private readonly TwigDocumentParser $parser,
17+
private readonly TwigCallArgumentResolver $arguments,
1618
) {
1719
}
1820

@@ -26,17 +28,9 @@ public function extract(string $text): array
2628
if (null === $function || !\in_array($document->text($function), ['path', 'url'], true)) {
2729
continue;
2830
}
29-
$argumentsNode = $document->directChild($call, 'arguments');
30-
if (null === $argumentsNode) {
31-
continue;
32-
}
33-
$arguments = [];
34-
foreach ($document->children($argumentsNode) as $argument) {
35-
if ('argument' === $argument->type) {
36-
$arguments[] = $argument;
37-
}
38-
}
39-
$route = isset($arguments[0]) ? $document->soleStringLiteral($arguments[0]) : null;
31+
$arguments = $this->arguments->resolve($document, $call);
32+
$routeArgument = $arguments->get(0, 'name');
33+
$route = null === $routeArgument ? null : $document->soleStringLiteral($routeArgument);
4034
if (null === $route) {
4135
continue;
4236
}
@@ -47,7 +41,7 @@ public function extract(string $text): array
4741
$this->positionConverter->toPosition($text, $route->startOffset),
4842
$this->positionConverter->toPosition($text, $route->endOffset),
4943
),
50-
$this->providedParameters($document, $arguments[1] ?? null),
44+
$this->providedParameters($document, $arguments->get(1, 'parameters')),
5145
);
5246
}
5347

src/Feature/Translation/TwigTranslationReferenceExtractor.php

Lines changed: 9 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
use Symfony\Lsp\Document\PositionConverter;
66
use Symfony\Lsp\Parser\TreeSitter\TreeSitterNode;
7-
use Symfony\Lsp\Parser\Twig\TwigArgumentParser;
7+
use Symfony\Lsp\Parser\Twig\TwigCallArgumentResolver;
88
use Symfony\Lsp\Parser\Twig\TwigCommentParser;
99
use Symfony\Lsp\Parser\Twig\TwigDocument;
1010
use Symfony\Lsp\Parser\Twig\TwigDocumentParser;
@@ -16,7 +16,7 @@ final class TwigTranslationReferenceExtractor
1616
public function __construct(
1717
private readonly PositionConverter $converter,
1818
private readonly TwigDocumentParser $parser,
19-
private readonly TwigArgumentParser $argumentParser,
19+
private readonly TwigCallArgumentResolver $arguments,
2020
private readonly TwigCommentParser $comments,
2121
private readonly TranslationParameterAnalyzer $parameters,
2222
) {
@@ -74,8 +74,8 @@ private function filterReferences(string $uri, string $text, string $masked, Twi
7474
if (null === $key) {
7575
continue;
7676
}
77-
$arguments = $this->arguments($masked, $document, $filter);
78-
$domain = $this->domain($document, $this->argument($arguments, 1, ['domain']), $defaultDomain);
77+
$arguments = $this->arguments->resolve($document, $filter);
78+
$domain = $this->domain($document, $arguments->get(1, 'domain'), $defaultDomain);
7979
if (null === $domain) {
8080
continue;
8181
}
@@ -84,7 +84,7 @@ private function filterReferences(string $uri, string $text, string $masked, Twi
8484
$domain,
8585
$uri,
8686
$text,
87-
$this->parameters->twig($document, $this->argument($arguments, 0, ['arguments', 'parameters'])),
87+
$this->parameters->twig($document, $arguments->get(0, 'arguments', 'parameters')),
8888
);
8989
}
9090

@@ -122,10 +122,10 @@ private function functionReferences(string $uri, string $text, string $masked, T
122122
if (null === $identifier || !\in_array($document->text($identifier), ['trans', 't'], true)) {
123123
continue;
124124
}
125-
$arguments = $this->arguments($masked, $document, $call);
126-
$keyArgument = $this->argument($arguments, 0, ['id', 'message']);
125+
$arguments = $this->arguments->resolve($document, $call);
126+
$keyArgument = $arguments->get(0, 'id', 'message');
127127
$key = null === $keyArgument ? null : $document->soleStringLiteral($keyArgument);
128-
$domain = $this->domain($document, $this->argument($arguments, 2, ['domain']), $defaultDomain);
128+
$domain = $this->domain($document, $arguments->get(2, 'domain'), $defaultDomain);
129129
if (null === $key || null === $domain) {
130130
continue;
131131
}
@@ -134,70 +134,13 @@ private function functionReferences(string $uri, string $text, string $masked, T
134134
$domain,
135135
$uri,
136136
$text,
137-
$this->parameters->twig($document, $this->argument($arguments, 1, ['arguments', 'parameters'])),
137+
$this->parameters->twig($document, $arguments->get(1, 'arguments', 'parameters')),
138138
);
139139
}
140140

141141
return $references;
142142
}
143143

144-
/** @return list<array{name: string|null, value: TreeSitterNode}> */
145-
private function arguments(string $masked, TwigDocument $document, TreeSitterNode $call): array
146-
{
147-
$container = $document->directChild($call, 'arguments');
148-
if (null === $container) {
149-
return [];
150-
}
151-
152-
$text = substr($masked, $container->startByte, $container->endByte - $container->startByte);
153-
$offset = $container->startByte;
154-
if (str_starts_with($text, '(')) {
155-
$text = substr($text, 1);
156-
++$offset;
157-
}
158-
if (str_ends_with($text, ')')) {
159-
$text = substr($text, 0, -1);
160-
}
161-
162-
$parsed = $this->argumentParser->parse($text, $offset);
163-
$arguments = [];
164-
foreach ($document->children($container) as $child) {
165-
if ('argument' !== $child->type) {
166-
continue;
167-
}
168-
$value = $document->directChild($child, 'argument_value') ?? $child;
169-
$name = null;
170-
foreach ($parsed as $argument) {
171-
if ($value->startByte >= $argument->offset && $value->startByte < $argument->offset + \strlen($argument->text)) {
172-
$name = $argument->name;
173-
174-
break;
175-
}
176-
}
177-
$arguments[] = ['name' => $name, 'value' => $value];
178-
}
179-
180-
return $arguments;
181-
}
182-
183-
/**
184-
* @param list<array{name: string|null, value: TreeSitterNode}> $arguments
185-
* @param list<string> $names
186-
*/
187-
private function argument(array $arguments, int $position, array $names): ?TreeSitterNode
188-
{
189-
$positional = [];
190-
foreach ($arguments as $argument) {
191-
if (null === $argument['name']) {
192-
$positional[] = $argument['value'];
193-
} elseif (\in_array($argument['name'], $names, true)) {
194-
return $argument['value'];
195-
}
196-
}
197-
198-
return $positional[$position] ?? null;
199-
}
200-
201144
private function domain(TwigDocument $document, ?TreeSitterNode $argument, string $defaultDomain): ?string
202145
{
203146
return null === $argument ? $defaultDomain : $document->soleStringLiteral($argument)?->value;

src/Feature/Twig/TemplateCompletionContext.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public static function create(string $languageId, string $text, Position $positi
1919
$pattern = 'php' === $languageId
2020
? '/(?:(?:->|::)(?:render|renderView)\s*\(|#\[\s*(?:[^\[\]]*,\s*)?\\\\?(?:Symfony\\\\Bridge\\\\Twig\\\\Attribute\\\\)?Template\s*\(\s*(?:template\s*:)?)\s*([\'\"])([^\'\"]*)$/s'
2121
: ('twig' === $languageId
22-
? '/(?:(?:{%\s*(?:extends|include|embed|import|from|use)\s+)|(?:\b(?:include|source)\s*\())([\'\"])([^\'\"]*)$/s'
22+
? '/(?:(?:{%\s*(?:extends|include|embed|import|from|use)\s+)|(?:\binclude\s*\(\s*(?:template\s*[:=]\s*)?)|(?:\bsource\s*\(\s*(?:name\s*[:=]\s*)?))([\'\"])([^\'\"]*)$/s'
2323
: null);
2424
if (null === $pattern || !preg_match($pattern, $before, $matches, \PREG_OFFSET_CAPTURE)) {
2525
return null;

0 commit comments

Comments
 (0)