Skip to content

Commit 7316daa

Browse files
committed
Fix translation diagnostics for global parameters and PHP catalogs
1 parent 5722041 commit 7316daa

14 files changed

Lines changed: 487 additions & 33 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 PHP heredoc translation messages and global parameters
56
- Buffer persistent source index rewrites
67
- Record runtime bridge phase timings in dogfood reports
78
- Run up to four dogfood projects concurrently by default

docs/features/translations.rst

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@ Resources
2121
---------
2222

2323
Definitions are read from YAML, JSON, XLIFF and PHP resources under a
24-
``translations/`` directory. Nested YAML and JSON keys use dot notation.
25-
INI catalogs using a locale directory, such as
26-
``Translations/en_US/messages.ini``, are recognized too. Unsaved
27-
resource changes are available immediately, and changes made by external tools
28-
are picked up while the server is running.
24+
``translations/`` directory. Nested YAML and JSON keys use dot notation. PHP
25+
messages can use quoted strings, heredocs or nowdocs. INI catalogs using a
26+
locale directory, such as ``Translations/en_US/messages.ini``, are recognized
27+
too. Unsaved resource changes are available immediately, and changes made by
28+
external tools are picked up while the server is running.
2929

3030
ICU brace placeholders such as ``{name}`` are only interpreted in ICU
3131
catalogs, identified by the ``+intl-icu`` domain suffix. In plain catalogs,
@@ -42,11 +42,13 @@ Diagnostics
4242
-----------
4343

4444
Placeholders the message expects but a supplied literal parameter map doesn't
45-
provide are reported. Extra parameters are accepted. Calls without a parameter
46-
map, with dynamic expressions or with unpacked parameter arrays aren't
47-
diagnosed.
48-
Missing-key diagnostics are disabled by default because external translation
49-
providers can make the runtime catalogue incomplete.
45+
provide are reported. Extra parameters and literal global parameters registered
46+
with ``addGlobalParameter()`` are accepted. If a global parameter name is
47+
dynamic, placeholder diagnostics are suppressed because the available names
48+
can't be determined. Calls without a parameter map, with dynamic expressions or
49+
with unpacked parameter arrays aren't diagnosed. Missing-key diagnostics are
50+
disabled by default because external translation providers can make the runtime
51+
catalogue incomplete.
5052

5153
Enable missing-key diagnostics in ``.symfony-lsp.json``:
5254

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
<?php
2+
3+
namespace Symfony\Lsp\Feature\Translation;
4+
5+
use Symfony\Lsp\Parser\Php\PhpStringLiteralDecoder;
6+
7+
final class PhpTranslationCatalogParser
8+
{
9+
/**
10+
* @return list<array{key: string, message: string, keyOffset: int, keyLength: int}>
11+
*/
12+
public function parse(string $source): array
13+
{
14+
$tokens = $this->tokens($source);
15+
foreach ($tokens as $index => $token) {
16+
if (\T_RETURN !== $token['id']) {
17+
continue;
18+
}
19+
$opening = $this->nextSignificant($tokens, $index + 1);
20+
if (null === $opening) {
21+
continue;
22+
}
23+
if ('[' === $tokens[$opening]['text']) {
24+
return $this->arrayEntries($tokens, $opening, ']');
25+
}
26+
if (\T_ARRAY === $tokens[$opening]['id']) {
27+
$opening = $this->nextSignificant($tokens, $opening + 1);
28+
if (null !== $opening && '(' === $tokens[$opening]['text']) {
29+
return $this->arrayEntries($tokens, $opening, ')');
30+
}
31+
}
32+
}
33+
34+
return [];
35+
}
36+
37+
/**
38+
* @param list<array{id: int|null, text: string, offset: int}> $tokens
39+
*
40+
* @return list<array{key: string, message: string, keyOffset: int, keyLength: int}>
41+
*/
42+
private function arrayEntries(array $tokens, int $opening, string $closing): array
43+
{
44+
$entries = [];
45+
$start = $opening + 1;
46+
$stack = [$closing];
47+
for ($index = $start; isset($tokens[$index]); ++$index) {
48+
$text = $tokens[$index]['text'];
49+
if (\in_array($text, ['[', '(', '{'], true)) {
50+
$stack[] = match ($text) {
51+
'[' => ']',
52+
'(' => ')',
53+
'{' => '}',
54+
};
55+
56+
continue;
57+
}
58+
if ($text === $stack[array_key_last($stack)]) {
59+
array_pop($stack);
60+
if ([] === $stack) {
61+
if (null !== $entry = $this->entry($tokens, $start, $index)) {
62+
$entries[] = $entry;
63+
}
64+
65+
break;
66+
}
67+
68+
continue;
69+
}
70+
if (1 === \count($stack) && ',' === $text) {
71+
if (null !== $entry = $this->entry($tokens, $start, $index)) {
72+
$entries[] = $entry;
73+
}
74+
$start = $index + 1;
75+
}
76+
}
77+
78+
return $entries;
79+
}
80+
81+
/**
82+
* @param list<array{id: int|null, text: string, offset: int}> $tokens
83+
*
84+
* @return array{key: string, message: string, keyOffset: int, keyLength: int}|null
85+
*/
86+
private function entry(array $tokens, int $start, int $end): ?array
87+
{
88+
$keyIndex = $this->nextSignificant($tokens, $start, $end);
89+
if (null === $keyIndex || \T_CONSTANT_ENCAPSED_STRING !== $tokens[$keyIndex]['id']) {
90+
return null;
91+
}
92+
$arrowIndex = $this->nextSignificant($tokens, $keyIndex + 1, $end);
93+
if (null === $arrowIndex || \T_DOUBLE_ARROW !== $tokens[$arrowIndex]['id']) {
94+
return null;
95+
}
96+
$valueIndex = $this->nextSignificant($tokens, $arrowIndex + 1, $end);
97+
if (null === $valueIndex) {
98+
return null;
99+
}
100+
101+
$keyToken = $tokens[$keyIndex];
102+
$key = $this->quotedString($keyToken['text']);
103+
if (null === $key) {
104+
return null;
105+
}
106+
107+
return [
108+
'key' => $key,
109+
'message' => $this->message($tokens, $valueIndex, $end),
110+
'keyOffset' => $keyToken['offset'] + 1,
111+
'keyLength' => max(0, \strlen($keyToken['text']) - 2),
112+
];
113+
}
114+
115+
/**
116+
* @param list<array{id: int|null, text: string, offset: int}> $tokens
117+
*/
118+
private function message(array $tokens, int $start, int $end): string
119+
{
120+
$token = $tokens[$start];
121+
if (\T_CONSTANT_ENCAPSED_STRING === $token['id'] && null === $this->nextSignificant($tokens, $start + 1, $end)) {
122+
return $this->quotedString($token['text']) ?? '';
123+
}
124+
if (\T_START_HEREDOC !== $token['id']) {
125+
return '';
126+
}
127+
128+
$content = '';
129+
for ($index = $start + 1; $index < $end; ++$index) {
130+
if (\T_END_HEREDOC === $tokens[$index]['id']) {
131+
if (null !== $this->nextSignificant($tokens, $index + 1, $end)) {
132+
return '';
133+
}
134+
$content = $this->stripHeredocIndentation($content, $tokens[$index]['text']);
135+
$content = preg_replace('/\r?\n$/D', '', $content) ?? $content;
136+
137+
return str_starts_with($token['text'], "<<<'")
138+
? $content
139+
: PhpStringLiteralDecoder::decodeDoubleQuoted($content);
140+
}
141+
if (\T_ENCAPSED_AND_WHITESPACE !== $tokens[$index]['id']) {
142+
return '';
143+
}
144+
$content .= $tokens[$index]['text'];
145+
}
146+
147+
return '';
148+
}
149+
150+
private function quotedString(string $literal): ?string
151+
{
152+
$quote = $literal[0] ?? null;
153+
if (!\in_array($quote, ["'", '"'], true) || !str_ends_with($literal, $quote)) {
154+
return null;
155+
}
156+
157+
return PhpStringLiteralDecoder::decode($quote, substr($literal, 1, -1));
158+
}
159+
160+
private function stripHeredocIndentation(string $content, string $end): string
161+
{
162+
if (1 !== preg_match('/^([ \t]*)[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*$/D', $end, $matches) || '' === $matches[1]) {
163+
return $content;
164+
}
165+
166+
return preg_replace('/^'.preg_quote($matches[1], '/').'/m', '', $content) ?? $content;
167+
}
168+
169+
/**
170+
* @return list<array{id: int|null, text: string, offset: int}>
171+
*/
172+
private function tokens(string $source): array
173+
{
174+
$result = [];
175+
$offset = 0;
176+
foreach (token_get_all($source) as $token) {
177+
$text = \is_array($token) ? $token[1] : $token;
178+
$result[] = [
179+
'id' => \is_array($token) ? $token[0] : null,
180+
'text' => $text,
181+
'offset' => $offset,
182+
];
183+
$offset += \strlen($text);
184+
}
185+
186+
return $result;
187+
}
188+
189+
/**
190+
* @param list<array{id: int|null, text: string, offset: int}> $tokens
191+
*/
192+
private function nextSignificant(array $tokens, int $start, ?int $end = null): ?int
193+
{
194+
$end ??= \count($tokens);
195+
for ($index = $start; $index < $end; ++$index) {
196+
if (!\in_array($tokens[$index]['id'], [\T_WHITESPACE, \T_COMMENT, \T_DOC_COMMENT], true)) {
197+
return $index;
198+
}
199+
}
200+
201+
return null;
202+
}
203+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
namespace Symfony\Lsp\Feature\Translation;
4+
5+
final class PhpTranslationFacts
6+
{
7+
/**
8+
* @param list<TranslationReference> $references
9+
* @param list<string> $globalParameters
10+
*/
11+
public function __construct(
12+
public readonly array $references,
13+
public readonly array $globalParameters,
14+
public readonly bool $dynamicGlobalParameters,
15+
) {
16+
}
17+
}

src/Feature/Translation/PhpTranslationReferenceExtractor.php

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,22 @@
55
use Symfony\Lsp\Document\PositionConverter;
66
use Symfony\Lsp\Parser\Php\PhpArgument;
77
use Symfony\Lsp\Parser\Php\PhpCommentParserInterface;
8+
use Symfony\Lsp\Parser\Php\PhpDocument;
9+
use Symfony\Lsp\Parser\Php\PhpMethodCall;
810
use Symfony\Lsp\Parser\Php\PhpObjectCreation;
911
use Symfony\Lsp\Parser\Php\PhpParserInterface;
1012
use Symfony\Lsp\Parser\Php\PhpStringLiteral;
1113
use Symfony\Lsp\Parser\Php\PhpStringLiteralDecoder;
1214

1315
final class PhpTranslationReferenceExtractor
1416
{
17+
private const GLOBAL_PARAMETER_TRANSLATORS = [
18+
'Symfony\\Bundle\\FrameworkBundle\\Translation\\Translator',
19+
'Symfony\\Component\\Translation\\DataCollectorTranslator',
20+
'Symfony\\Component\\Translation\\LoggingTranslator',
21+
'Symfony\\Component\\Translation\\Translator',
22+
];
23+
1524
public function __construct(
1625
private readonly PositionConverter $converter,
1726
private readonly PhpParserInterface $parser,
@@ -20,12 +29,23 @@ public function __construct(
2029
) {
2130
}
2231

23-
/** @return list<TranslationReference> */
24-
public function extract(string $uri, string $text): array
32+
public function extract(string $uri, string $text): PhpTranslationFacts
2533
{
2634
$document = $this->parser->parse($text);
2735
$references = [];
36+
$globalParameters = [];
37+
$dynamicGlobalParameters = false;
2838
foreach ($document->methodCalls as $call) {
39+
if ('addGlobalParameter' === $call->method && $this->hasGlobalParameterReceiver($call, $document)) {
40+
$parameter = $call->argument('id') ?? $call->positionalArgument(0);
41+
if (null === $parameter?->stringLiteral) {
42+
$dynamicGlobalParameters = true;
43+
} else {
44+
$globalParameters[] = $parameter->stringLiteral->value;
45+
}
46+
47+
continue;
48+
}
2949
if ('trans' !== $call->method) {
3050
continue;
3151
}
@@ -65,8 +85,22 @@ public function extract(string $uri, string $text): array
6585
}
6686
array_push($references, ...$this->helperReferences($uri, $text));
6787
usort($references, static fn (array $left, array $right): int => $left['offset'] <=> $right['offset']);
88+
$globalParameters = array_values(array_unique($globalParameters));
89+
sort($globalParameters);
6890

69-
return array_column($references, 'reference');
91+
return new PhpTranslationFacts(
92+
array_column($references, 'reference'),
93+
$globalParameters,
94+
$dynamicGlobalParameters,
95+
);
96+
}
97+
98+
private function hasGlobalParameterReceiver(PhpMethodCall $call, PhpDocument $document): bool
99+
{
100+
return array_any(
101+
$document->receiverVariables($call),
102+
static fn ($variable): bool => [] !== array_intersect(self::GLOBAL_PARAMETER_TRANSLATORS, $variable->types),
103+
);
70104
}
71105

72106
private function domain(?PhpArgument $argument): ?string

src/Feature/Translation/TranslationCatalogExtractor.php

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ public function __construct(
1212
private readonly PositionConverter $converter,
1313
private readonly UriToPathConverter $uriToPathConverter,
1414
private readonly YamlDocumentParser $yamlParser,
15+
private readonly PhpTranslationCatalogParser $phpParser,
1516
) {
1617
}
1718

@@ -62,13 +63,19 @@ private function declarations(string $uri, string $text, string $domain, string
6263
return $this->xliffDeclarations($uri, $text, $domain, $locale);
6364
}
6465
if ('php' === $format) {
65-
preg_match_all('/([\'\"])([^\'\"]+)\1\s*=>\s*([\'\"])(.*?)\3/s', $text, $matches, \PREG_OFFSET_CAPTURE);
66-
$result = [];
67-
foreach ($matches[2] as $i => [$key, $offset]) {
68-
$result[] = $this->declaration($key, $matches[4][$i][0], $domain, $locale, $uri, $text, $offset);
69-
}
70-
71-
return $result;
66+
return array_map(
67+
fn (array $item): TranslationDeclaration => $this->declaration(
68+
$item['key'],
69+
$item['message'],
70+
$domain,
71+
$locale,
72+
$uri,
73+
$text,
74+
$item['keyOffset'],
75+
$item['keyLength'],
76+
),
77+
$this->phpParser->parse($text),
78+
);
7279
}
7380

7481
$result = [];

0 commit comments

Comments
 (0)