├── phpstan.neon.dist ├── .php-cs-fixer.php ├── src ├── Annotation │ ├── Ignore.php │ ├── Desc.php │ └── Translate.php ├── Visitor │ ├── Visitor.php │ ├── Php │ │ ├── Symfony │ │ │ ├── FormTrait.php │ │ │ ├── ContainerAwareTrans.php │ │ │ ├── ContainerAwareTransChoice.php │ │ │ ├── FormTypeHelp.php │ │ │ ├── FormTypeInvalidMessage.php │ │ │ ├── FlashMessage.php │ │ │ ├── FormTypeEmptyValue.php │ │ │ ├── FormTypeTitle.php │ │ │ ├── FormTypeLabelExplicit.php │ │ │ ├── ValidationAnnotation.php │ │ │ ├── FormTypePlaceholder.php │ │ │ ├── FormTypeLabelImplicit.php │ │ │ ├── AbstractFormType.php │ │ │ ├── Constraint.php │ │ │ └── FormTypeChoices.php │ │ ├── Knp │ │ │ └── Menu │ │ │ │ ├── ItemLabel.php │ │ │ │ ├── LinkTitle.php │ │ │ │ └── AbstractKnpMenuVisitor.php │ │ ├── BasePHPVisitor.php │ │ ├── TranslateAnnotationVisitor.php │ │ └── SourceLocationContainerVisitor.php │ ├── Twig │ │ ├── TwigVisitor.php │ │ └── Worker.php │ └── BaseVisitor.php ├── FileExtractor │ ├── FileExtractor.php │ ├── PHPFileExtractor.php │ ├── TwigFileExtractor.php │ └── BladeFileExtractor.php ├── Twig │ └── TranslationExtension.php ├── TranslationSourceLocationContainer.php ├── Model │ ├── Error.php │ ├── SourceLocation.php │ └── SourceCollection.php └── Extractor.php ├── Makefile ├── phpstan-baseline.neon ├── LICENSE ├── composer.json ├── Readme.md └── Changelog.md /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | includes: 2 | - phpstan-baseline.neon 3 | 4 | parameters: 5 | level: 3 6 | inferPrivatePropertyTypeFromConstructor: true 7 | paths: 8 | - src 9 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | in(__DIR__.'/src') 4 | ->in(__DIR__.'/tests') 5 | ->exclude('Resources') 6 | ->name('*.php') 7 | ; 8 | 9 | $config = new PhpCsFixer\Config(); 10 | $config 11 | ->setRiskyAllowed(true) 12 | ->setRules([ 13 | '@Symfony' => true, 14 | '@Symfony:risky' => true, 15 | ]) 16 | ->setFinder($finder) 17 | ; 18 | 19 | return $config; 20 | -------------------------------------------------------------------------------- /src/Annotation/Ignore.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Annotation; 13 | 14 | /** 15 | * @Annotation 16 | */ 17 | final class Ignore 18 | { 19 | } 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: cs-fix phpstan 2 | 3 | DIR := ${CURDIR} 4 | QA_IMAGE := jakzal/phpqa:php7.3-alpine 5 | 6 | cs-lint: 7 | @docker run --rm -v $(DIR):/project -w /project $(QA_IMAGE) php-cs-fixer fix --diff-format udiff --dry-run -vvv 8 | 9 | cs-fix: 10 | @docker run --rm -v $(DIR):/project -w /project $(QA_IMAGE) php-cs-fixer fix --diff-format udiff -vvv 11 | 12 | phpstan: 13 | @docker run --rm -v $(DIR):/project -w /project $(QA_IMAGE) phpstan analyse 14 | -------------------------------------------------------------------------------- /src/Annotation/Desc.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Annotation; 13 | 14 | /** 15 | * @Annotation 16 | */ 17 | final class Desc 18 | { 19 | /** 20 | * @var string 21 | */ 22 | public $text; 23 | } 24 | -------------------------------------------------------------------------------- /src/Visitor/Visitor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Visitor; 13 | 14 | use Symfony\Component\Finder\SplFileInfo; 15 | use Translation\Extractor\Model\SourceCollection; 16 | 17 | interface Visitor 18 | { 19 | public function init(SourceCollection $collection, SplFileInfo $file): void; 20 | } 21 | -------------------------------------------------------------------------------- /phpstan-baseline.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | - 4 | message: "#^Variable \\$trace might not be defined\\.$#" 5 | count: 2 6 | path: src/Model/SourceLocation.php 7 | 8 | - 9 | message: "#^Access to an undefined property PhpParser\\\\Node\\:\\:\\$var\\.$#" 10 | count: 1 11 | path: src/Visitor/Php/Symfony/AbstractFormType.php 12 | 13 | - 14 | message: "#^Access to an undefined property PhpParser\\\\Node\\:\\:\\$args\\.$#" 15 | count: 1 16 | path: src/Visitor/Php/Symfony/AbstractFormType.php 17 | -------------------------------------------------------------------------------- /src/FileExtractor/FileExtractor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\FileExtractor; 13 | 14 | use Symfony\Component\Finder\SplFileInfo; 15 | use Translation\Extractor\Model\SourceCollection; 16 | 17 | /** 18 | * Extract SourceLocations form a file. 19 | */ 20 | interface FileExtractor 21 | { 22 | public function getSourceLocations(SplFileInfo $file, SourceCollection $collection): void; 23 | 24 | public function supportsExtension(string $extension): bool; 25 | } 26 | -------------------------------------------------------------------------------- /src/Annotation/Translate.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Annotation; 13 | 14 | /** 15 | * @Annotation 16 | */ 17 | class Translate 18 | { 19 | /** 20 | * @var string 21 | */ 22 | private $domain = 'messages'; 23 | 24 | /** 25 | * Translate constructor. 26 | */ 27 | public function __construct(array $values) 28 | { 29 | if (isset($values['domain'])) { 30 | $this->domain = $values['domain']; 31 | } 32 | } 33 | 34 | public function getDomain(): string 35 | { 36 | return $this->domain; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Twig/TranslationExtension.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Twig; 13 | 14 | use Twig\Extension\AbstractExtension; 15 | use Twig\TwigFilter; 16 | 17 | final class TranslationExtension extends AbstractExtension 18 | { 19 | public function getFilters(): array 20 | { 21 | return [ 22 | new TwigFilter('desc', [$this, 'runDescFilter']), 23 | ]; 24 | } 25 | 26 | public function runDescFilter($v) 27 | { 28 | return $v; 29 | } 30 | 31 | public function getName(): string 32 | { 33 | return 'php-translation'; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Visitor/Php/Symfony/FormTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Visitor\Php\Symfony; 13 | 14 | use PhpParser\Node; 15 | use PhpParser\Node\Stmt; 16 | 17 | trait FormTrait 18 | { 19 | private bool $isFormType = false; 20 | 21 | /** 22 | * Check if this node is a form type. 23 | */ 24 | private function isFormType(Node $node): bool 25 | { 26 | // only Traverse *Type 27 | if ($node instanceof Stmt\Class_) { 28 | $this->isFormType = 'Type' === substr($node->name, -4); 29 | } 30 | 31 | return $this->isFormType; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/TranslationSourceLocationContainer.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor; 13 | 14 | use Translation\Extractor\Model\SourceLocation; 15 | 16 | /** 17 | * This interface is recognized by the extractors. Use this on your Form classes 18 | * or anywhere where you have dynamic translation strings. 19 | * 20 | * @author Tobias Nyholm 21 | */ 22 | interface TranslationSourceLocationContainer 23 | { 24 | /** 25 | * Return an array of source locations. 26 | * 27 | * @return SourceLocation[] 28 | */ 29 | public static function getTranslationSourceLocations(): array; 30 | } 31 | -------------------------------------------------------------------------------- /src/Model/Error.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Model; 13 | 14 | /** 15 | * An error with the source code that occurred when extracting. 16 | * 17 | * @author Tobias Nyholm 18 | */ 19 | final class Error 20 | { 21 | public function __construct( 22 | private readonly string $message, 23 | private readonly string $path, 24 | private readonly int $line, 25 | ) { 26 | } 27 | 28 | public function getMessage(): string 29 | { 30 | return $this->message; 31 | } 32 | 33 | public function getPath(): string 34 | { 35 | return $this->path; 36 | } 37 | 38 | public function getLine(): int 39 | { 40 | return $this->line; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) PHP Translation team 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "php-translation/extractor", 3 | "description": "Extract translations form the source code", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Tobias Nyholm", 8 | "email": "tobias.nyholm@gmail.com" 9 | } 10 | ], 11 | "require": { 12 | "php": "^8.1", 13 | "nikic/php-parser": "^5.0", 14 | "symfony/finder": "^5.4 || ^6.4 || ^7.0", 15 | "twig/twig": "^2.0 || ^3.0", 16 | "doctrine/annotations": "^1.7 || ^2.0" 17 | }, 18 | "require-dev": { 19 | "symfony/phpunit-bridge": "^5.4 || ^6.4 || ^7.0", 20 | "symfony/translation": "^5.4 || ^6.4 || ^7.0", 21 | "symfony/validator": "^5.4 || ^6.4 || ^7.0", 22 | "symfony/twig-bridge": "^5.4 || ^6.4 || ^7.0", 23 | "knplabs/knp-menu": "^3.1" 24 | }, 25 | "autoload": { 26 | "psr-4": { 27 | "Translation\\Extractor\\": "src/" 28 | } 29 | }, 30 | "autoload-dev": { 31 | "psr-4": { 32 | "Translation\\Extractor\\Tests\\": "tests/" 33 | } 34 | }, 35 | "scripts": { 36 | "test": "vendor/bin/phpunit", 37 | "test-ci": "vendor/bin/phpunit --coverage-text --coverage-clover=build/coverage.xml" 38 | }, 39 | "extra": { 40 | "branch-alias": { 41 | "dev-master": "2.0-dev" 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Translation extractor 2 | 3 | [![Latest Version](https://img.shields.io/github/release/php-translation/extractor.svg?style=flat-square)](https://github.com/php-translation/extractor/releases) 4 | [![Total Downloads](https://img.shields.io/packagist/dt/php-translation/extractor.svg?style=flat-square)](https://packagist.org/packages/php-translation/extractor) 5 | 6 | **Extract translation messages from source code** 7 | 8 | 9 | ## Install 10 | 11 | Via Composer: 12 | 13 | ```bash 14 | $ composer require php-translation/extractor 15 | ``` 16 | 17 | ## Usage 18 | 19 | ```php 20 | $extractor = new Extractor(); 21 | 22 | // Create an extractor for PHP files 23 | $fileExtractor = new PHPFileExtractor(); 24 | 25 | // Add visitors 26 | $fileExtractor->addVisitor(new ContainerAwareTrans()); 27 | $fileExtractor->addVisitor(new ContainerAwareTransChoice()); 28 | $fileExtractor->addVisitor(new FlashMessage()); 29 | $fileExtractor->addVisitor(new FormTypeChoices()); 30 | 31 | // Add the file extractor to Extactor 32 | $extractor->addFileExtractor($fileExtractor); 33 | 34 | // Define where the source code is 35 | $finder = new Finder(); 36 | $finder->in('/foo/bar'); 37 | 38 | //Start extracting files 39 | $sourceCollection = $extractor->extract($finder); 40 | ``` 41 | 42 | ## Found an issue? 43 | 44 | Is it something we do not extract? Please add it as a test. Add a new file with your example code in 45 | `tests/Resources/Github/Issue_XX.php`, then edit the `AllExtractorsTest` to make sure the translation 46 | key is found: 47 | 48 | ```php 49 | // ... 50 | $this->translationExists($sc, 'trans.issue_xx'); 51 | ``` 52 | -------------------------------------------------------------------------------- /src/Visitor/Twig/TwigVisitor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Visitor\Twig; 13 | 14 | use Translation\Extractor\Visitor\BaseVisitor; 15 | use Twig\Environment; 16 | use Twig\Node\Node; 17 | use Twig\NodeVisitor\NodeVisitorInterface; 18 | 19 | /** 20 | * @author Tobias Nyholm 21 | */ 22 | final class TwigVisitor extends BaseVisitor implements NodeVisitorInterface 23 | { 24 | private ?Worker $worker; 25 | 26 | public function __construct(?Worker $worker = null) 27 | { 28 | if (null === $worker) { 29 | $worker = new Worker(); 30 | } 31 | 32 | $this->worker = $worker; 33 | } 34 | 35 | public function enterNode(Node $node, Environment $env): Node 36 | { 37 | // If not initialized 38 | if (null === $this->collection) { 39 | // We have not executed BaseVisitor::init which means that we are not currently extracting 40 | return $node; 41 | } 42 | 43 | return $this->worker->work($node, $this->collection, function () { 44 | return $this->getAbsoluteFilePath(); 45 | }); 46 | } 47 | 48 | public function leaveNode(Node $node, Environment $env): ?Node 49 | { 50 | return $node; 51 | } 52 | 53 | public function getPriority(): int 54 | { 55 | return 0; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Visitor/Php/Knp/Menu/ItemLabel.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Visitor\Php\Knp\Menu; 13 | 14 | use PhpParser\Node; 15 | use PhpParser\NodeVisitor; 16 | 17 | /** 18 | * This class extracts knp menu item labels: 19 | * - $menu->addChild('foo') 20 | * - $menu['foo']->setLabel('bar'). 21 | */ 22 | final class ItemLabel extends AbstractKnpMenuVisitor implements NodeVisitor 23 | { 24 | public function enterNode(Node $node): ?Node 25 | { 26 | if (!$this->isKnpMenuBuildingMethod($node)) { 27 | return null; 28 | } 29 | 30 | parent::enterNode($node); 31 | 32 | if (!$node instanceof Node\Expr\MethodCall) { 33 | return null; 34 | } 35 | 36 | if (!\is_string($node->name) && !$node->name instanceof Node\Identifier) { 37 | return null; 38 | } 39 | 40 | $methodName = (string) $node->name; 41 | if (!\in_array($methodName, ['addChild', 'setLabel'], true)) { 42 | return null; 43 | } 44 | 45 | if (null !== $label = $this->getStringArgument($node, 0)) { 46 | $line = $node->getAttribute('startLine'); 47 | if (null !== $location = $this->getLocation($label, $line, $node)) { 48 | $this->lateCollect($location); 49 | } 50 | } 51 | 52 | return null; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Visitor/Php/BasePHPVisitor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Visitor\Php; 13 | 14 | use PhpParser\Node; 15 | use Translation\Extractor\Visitor\BaseVisitor; 16 | 17 | /** 18 | * Base class for PHP visitors. 19 | * 20 | * @author Tobias Nyholm 21 | */ 22 | abstract class BasePHPVisitor extends BaseVisitor 23 | { 24 | protected function getStringArgument(Node\Expr\MethodCall $node, int $index): ?string 25 | { 26 | if (!isset($node->args[$index])) { 27 | return null; 28 | } 29 | 30 | $label = $this->getStringValue($node->args[$index]->value); 31 | if (empty($label)) { 32 | return null; 33 | } 34 | 35 | return $label; 36 | } 37 | 38 | private function getStringValue(Node $node): ?string 39 | { 40 | if ($node instanceof Node\Scalar\String_) { 41 | return $node->value; 42 | } 43 | 44 | if ($node instanceof Node\Expr\BinaryOp\Concat) { 45 | $left = $this->getStringValue($node->left); 46 | if (null === $left) { 47 | return null; 48 | } 49 | 50 | $right = $this->getStringValue($node->right); 51 | if (null === $right) { 52 | return null; 53 | } 54 | 55 | return $left.$right; 56 | } 57 | 58 | return null; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Model/SourceLocation.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Model; 13 | 14 | /** 15 | * @author Tobias Nyholm 16 | */ 17 | final class SourceLocation 18 | { 19 | public function __construct( 20 | private readonly string $message, /** Translation key. */ 21 | private readonly string $path, 22 | private readonly int $line, 23 | private readonly array $context = [], 24 | ) { 25 | } 26 | 27 | /** 28 | * Create a source location from your current location. 29 | */ 30 | public static function createHere(string $message, array $context = []): self 31 | { 32 | foreach (debug_backtrace(\DEBUG_BACKTRACE_PROVIDE_OBJECT, 2) as $trace) { 33 | // File is not set if we call from an anonymous context like an array_map function. 34 | if (isset($trace['file'])) { 35 | break; 36 | } 37 | } 38 | 39 | return new self($message, $trace['file'], $trace['line'], $context); 40 | } 41 | 42 | public function getMessage(): string 43 | { 44 | return $this->message; 45 | } 46 | 47 | public function getPath(): string 48 | { 49 | return $this->path; 50 | } 51 | 52 | public function getLine(): int 53 | { 54 | return $this->line; 55 | } 56 | 57 | public function getContext(): array 58 | { 59 | return $this->context; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Visitor/Php/Knp/Menu/LinkTitle.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Visitor\Php\Knp\Menu; 13 | 14 | use PhpParser\Node; 15 | use PhpParser\NodeVisitor; 16 | 17 | /** 18 | * This class extracts knp menu item link titles: 19 | * - $menu['foo']->setLinkAttribute('title', 'my.title'). 20 | */ 21 | final class LinkTitle extends AbstractKnpMenuVisitor implements NodeVisitor 22 | { 23 | public function enterNode(Node $node): ?Node 24 | { 25 | if (!$this->isKnpMenuBuildingMethod($node)) { 26 | return null; 27 | } 28 | 29 | parent::enterNode($node); 30 | 31 | if (!$node instanceof Node\Expr\MethodCall) { 32 | return null; 33 | } 34 | 35 | if (!\is_string($node->name) && !$node->name instanceof Node\Identifier) { 36 | return null; 37 | } 38 | 39 | $methodName = (string) $node->name; 40 | if ('setLinkAttribute' !== $methodName) { 41 | return null; 42 | } 43 | 44 | $attributeKey = $this->getStringArgument($node, 0); 45 | $attributeValue = $this->getStringArgument($node, 1); 46 | if ('title' === $attributeKey && null !== $attributeValue) { 47 | $line = $node->getAttribute('startLine'); 48 | if (null !== $location = $this->getLocation($attributeValue, $line, $node)) { 49 | $this->lateCollect($location); 50 | } 51 | } 52 | 53 | return null; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Model/SourceCollection.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Model; 13 | 14 | /** 15 | * @author Tobias Nyholm 16 | */ 17 | final class SourceCollection implements \Countable, \IteratorAggregate 18 | { 19 | /** 20 | * @var SourceLocation[] 21 | */ 22 | private array $sourceLocations = []; 23 | 24 | /** 25 | * @var Error[] 26 | */ 27 | private array $errors = []; 28 | 29 | public function getIterator(): \Traversable 30 | { 31 | return new \ArrayIterator($this->sourceLocations); 32 | } 33 | 34 | public function count(): int 35 | { 36 | return \count($this->sourceLocations); 37 | } 38 | 39 | public function addLocation(SourceLocation $location): void 40 | { 41 | $this->sourceLocations[] = $location; 42 | } 43 | 44 | public function addError(Error $error): void 45 | { 46 | $this->errors[] = $error; 47 | } 48 | 49 | public function first(): ?SourceLocation 50 | { 51 | if (empty($this->sourceLocations)) { 52 | return null; 53 | } 54 | 55 | return reset($this->sourceLocations); 56 | } 57 | 58 | public function get(string $key): ?SourceLocation 59 | { 60 | if (!isset($this->sourceLocations[$key])) { 61 | return null; 62 | } 63 | 64 | return $this->sourceLocations[$key]; 65 | } 66 | 67 | public function getErrors(): array 68 | { 69 | return $this->errors; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Visitor/Php/Symfony/ContainerAwareTrans.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Visitor\Php\Symfony; 13 | 14 | use PhpParser\Node; 15 | use PhpParser\NodeVisitor; 16 | use Translation\Extractor\Visitor\Php\BasePHPVisitor; 17 | 18 | /** 19 | * @author Tobias Nyholm 20 | */ 21 | final class ContainerAwareTrans extends BasePHPVisitor implements NodeVisitor 22 | { 23 | public function beforeTraverse(array $nodes): ?Node 24 | { 25 | return null; 26 | } 27 | 28 | public function enterNode(Node $node): ?Node 29 | { 30 | if (!$node instanceof Node\Expr\MethodCall) { 31 | return null; 32 | } 33 | 34 | if (!\is_string($node->name) && !$node->name instanceof Node\Identifier) { 35 | return null; 36 | } 37 | $name = (string) $node->name; 38 | 39 | // If $this->get('translator')->trans('foobar') 40 | if ('trans' === $name) { 41 | $label = $this->getStringArgument($node, 0); 42 | if (null === $label) { 43 | return null; 44 | } 45 | $domain = $this->getStringArgument($node, 2); 46 | 47 | $this->addLocation($label, $node->getAttribute('startLine'), $node, ['domain' => $domain]); 48 | } 49 | 50 | return null; 51 | } 52 | 53 | public function leaveNode(Node $node): ?Node 54 | { 55 | return null; 56 | } 57 | 58 | public function afterTraverse(array $nodes): ?Node 59 | { 60 | return null; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Visitor/Php/Symfony/ContainerAwareTransChoice.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Visitor\Php\Symfony; 13 | 14 | use PhpParser\Node; 15 | use PhpParser\NodeVisitor; 16 | use Translation\Extractor\Visitor\Php\BasePHPVisitor; 17 | 18 | /** 19 | * @author Tobias Nyholm 20 | */ 21 | final class ContainerAwareTransChoice extends BasePHPVisitor implements NodeVisitor 22 | { 23 | public function beforeTraverse(array $nodes): ?Node 24 | { 25 | return null; 26 | } 27 | 28 | public function enterNode(Node $node): ?Node 29 | { 30 | if (!$node instanceof Node\Expr\MethodCall) { 31 | return null; 32 | } 33 | 34 | if (!\is_string($node->name) && !$node->name instanceof Node\Identifier) { 35 | return null; 36 | } 37 | $name = (string) $node->name; 38 | 39 | // If $this->get('translator')->transChoice('foobar') 40 | if ('transChoice' === $name) { 41 | $label = $this->getStringArgument($node, 0); 42 | if (null === $label) { 43 | return null; 44 | } 45 | $domain = $this->getStringArgument($node, 3); 46 | 47 | $this->addLocation($label, $node->getAttribute('startLine'), $node, ['domain' => $domain]); 48 | } 49 | 50 | return null; 51 | } 52 | 53 | public function leaveNode(Node $node): ?Node 54 | { 55 | return null; 56 | } 57 | 58 | public function afterTraverse(array $nodes): ?Node 59 | { 60 | return null; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/FileExtractor/PHPFileExtractor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\FileExtractor; 13 | 14 | use PhpParser\Error; 15 | use PhpParser\NodeTraverser; 16 | use PhpParser\NodeVisitor; 17 | use PhpParser\ParserFactory; 18 | use PhpParser\PhpVersion; 19 | use Symfony\Component\Finder\SplFileInfo; 20 | use Translation\Extractor\Model\SourceCollection; 21 | use Translation\Extractor\Visitor\Visitor; 22 | 23 | /** 24 | * @author Tobias Nyholm 25 | */ 26 | final class PHPFileExtractor implements FileExtractor 27 | { 28 | /** 29 | * @var Visitor[]|NodeVisitor[] 30 | */ 31 | private array $visitors = []; 32 | 33 | public function getSourceLocations(SplFileInfo $file, SourceCollection $collection): void 34 | { 35 | $path = $file->getRelativePath(); 36 | $parser = (new ParserFactory())->createForVersion(PhpVersion::fromString('8.1')); 37 | $traverser = new NodeTraverser(); 38 | foreach ($this->visitors as $v) { 39 | $v->init($collection, $file); 40 | $traverser->addVisitor($v); 41 | } 42 | 43 | try { 44 | $tokens = $parser->parse($file->getContents()); 45 | $traverser->traverse($tokens); 46 | } catch (Error $e) { 47 | trigger_error(\sprintf('Skipping file "%s" because of parse Error: %s. ', $path, $e->getMessage())); 48 | } 49 | } 50 | 51 | public function supportsExtension(string $extension): bool 52 | { 53 | return \in_array($extension, ['php', 'php5', 'phtml']); 54 | } 55 | 56 | public function addVisitor(NodeVisitor $visitor): void 57 | { 58 | $this->visitors[] = $visitor; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Visitor/Php/Symfony/FormTypeHelp.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Visitor\Php\Symfony; 13 | 14 | use PhpParser\Node; 15 | use PhpParser\NodeVisitor; 16 | 17 | final class FormTypeHelp extends AbstractFormType implements NodeVisitor 18 | { 19 | use FormTrait; 20 | 21 | public function enterNode(Node $node): ?Node 22 | { 23 | if (!$this->isFormType($node)) { 24 | return null; 25 | } 26 | 27 | parent::enterNode($node); 28 | 29 | if (!$node instanceof Node\Expr\Array_) { 30 | return null; 31 | } 32 | 33 | $helpNode = null; 34 | $domain = null; 35 | foreach ($node->items as $item) { 36 | if (!$item->key instanceof Node\Scalar\String_) { 37 | continue; 38 | } 39 | if ('translation_domain' === $item->key->value) { 40 | // Try to find translation domain 41 | if ($item->value instanceof Node\Scalar\String_) { 42 | $domain = $item->value->value; 43 | } 44 | } elseif ('help' === $item->key->value) { 45 | $helpNode = $item; 46 | } 47 | } 48 | 49 | if (null === $helpNode) { 50 | return null; 51 | } 52 | 53 | if ($helpNode->value instanceof Node\Scalar\String_) { 54 | $line = $helpNode->value->getAttribute('startLine'); 55 | if (null !== $location = $this->getLocation($helpNode->value->value, $line, $helpNode, ['domain' => $domain])) { 56 | $this->lateCollect($location); 57 | } 58 | } else { 59 | $this->addError($helpNode, 'Form help is not a scalar string'); 60 | } 61 | 62 | return null; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/FileExtractor/TwigFileExtractor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\FileExtractor; 13 | 14 | use Symfony\Component\Finder\SplFileInfo; 15 | use Translation\Extractor\Model\SourceCollection; 16 | use Translation\Extractor\Visitor\Visitor; 17 | use Twig\Environment; 18 | use Twig\Extension\AbstractExtension; 19 | use Twig\NodeVisitor\NodeVisitorInterface; 20 | use Twig\Source; 21 | 22 | /** 23 | * @author Tobias Nyholm 24 | */ 25 | final class TwigFileExtractor extends AbstractExtension implements FileExtractor 26 | { 27 | /** 28 | * @var NodeVisitorInterface[] 29 | */ 30 | private array $visitors = []; 31 | 32 | public function __construct(private readonly Environment $twig) 33 | { 34 | $twig->addExtension($this); 35 | } 36 | 37 | public function getSourceLocations(SplFileInfo $file, SourceCollection $collection): void 38 | { 39 | foreach ($this->visitors as $v) { 40 | if ($v instanceof Visitor) { 41 | $v->init($collection, $file); 42 | } 43 | } 44 | 45 | $path = $file->getRelativePath(); 46 | 47 | $stream = $this->twig->tokenize(new Source($file->getContents(), $file->getRelativePathname(), $path)); 48 | $this->twig->parse($stream); 49 | } 50 | 51 | public function supportsExtension(string $extension): bool 52 | { 53 | return 'twig' === $extension; 54 | } 55 | 56 | public function addVisitor(NodeVisitorInterface $visitor): void 57 | { 58 | $this->visitors[] = $visitor; 59 | } 60 | 61 | public function getNodeVisitors(): array 62 | { 63 | return $this->visitors; 64 | } 65 | 66 | public function getName(): string 67 | { 68 | return 'php.translation'; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Visitor/Php/Symfony/FormTypeInvalidMessage.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Visitor\Php\Symfony; 13 | 14 | use PhpParser\Node; 15 | use PhpParser\NodeVisitor; 16 | use Translation\Extractor\Visitor\Php\BasePHPVisitor; 17 | 18 | /** 19 | * @author Tobias Nyholm 20 | */ 21 | final class FormTypeInvalidMessage extends BasePHPVisitor implements NodeVisitor 22 | { 23 | use FormTrait; 24 | 25 | public function enterNode(Node $node): ?Node 26 | { 27 | if (!$this->isFormType($node)) { 28 | return null; 29 | } 30 | 31 | if (!$node instanceof Node\Expr\Array_) { 32 | return null; 33 | } 34 | 35 | foreach ($node->items as $item) { 36 | if (!$item->key instanceof Node\Scalar\String_) { 37 | continue; 38 | } 39 | 40 | if ('invalid_message' !== $item->key->value) { 41 | continue; 42 | } 43 | 44 | if (!$item->value instanceof Node\Scalar\String_) { 45 | $this->addError($item, 'Form label is not a scalar string'); 46 | 47 | continue; 48 | } 49 | 50 | $label = $item->value->value; 51 | if (empty($label)) { 52 | continue; 53 | } 54 | 55 | $this->addLocation($label, $node->getAttribute('startLine'), $node, ['domain' => 'validators']); 56 | } 57 | 58 | return null; 59 | } 60 | 61 | public function leaveNode(Node $node): ?Node 62 | { 63 | return null; 64 | } 65 | 66 | public function beforeTraverse(array $nodes): ?Node 67 | { 68 | return null; 69 | } 70 | 71 | public function afterTraverse(array $nodes): ?Node 72 | { 73 | return null; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Extractor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor; 13 | 14 | use Symfony\Component\Finder\Finder; 15 | use Symfony\Component\Finder\SplFileInfo; 16 | use Translation\Extractor\FileExtractor\FileExtractor; 17 | use Translation\Extractor\Model\SourceCollection; 18 | 19 | /** 20 | * Main class for all extractors. This is the service that will be loaded with file 21 | * extractors. 22 | * 23 | * @author Tobias Nyholm 24 | */ 25 | final class Extractor 26 | { 27 | /** 28 | * @var FileExtractor[] 29 | */ 30 | private array $fileExtractors = []; 31 | 32 | public function extract(Finder $finder): SourceCollection 33 | { 34 | return $this->doExtract($finder); 35 | } 36 | 37 | public function extractFromDirectory(string $dir): SourceCollection 38 | { 39 | $finder = new Finder(); 40 | $finder->files()->in($dir); 41 | 42 | return $this->doExtract($finder); 43 | } 44 | 45 | public function addFileExtractor(FileExtractor $fileExtractor): void 46 | { 47 | $this->fileExtractors[] = $fileExtractor; 48 | } 49 | 50 | private function doExtract(Finder $finder): SourceCollection 51 | { 52 | $collection = new SourceCollection(); 53 | foreach ($finder as $file) { 54 | if (null !== $extractor = $this->getRelevantExtractorForFile($file)) { 55 | $extractor->getSourceLocations($file, $collection); 56 | } 57 | } 58 | 59 | return $collection; 60 | } 61 | 62 | private function getRelevantExtractorForFile(SplFileInfo $file): ?FileExtractor 63 | { 64 | $filename = $file->getFilename(); 65 | if (preg_match('|.+\.blade\.php$|', $filename)) { 66 | $ext = 'blade.php'; 67 | } else { 68 | $ext = $file->getExtension(); 69 | } 70 | 71 | foreach ($this->fileExtractors as $extractor) { 72 | if ($extractor->supportsExtension($ext)) { 73 | return $extractor; 74 | } 75 | } 76 | 77 | return null; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Visitor/Php/Symfony/FlashMessage.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Visitor\Php\Symfony; 13 | 14 | use PhpParser\Node; 15 | use PhpParser\NodeVisitor; 16 | use Translation\Extractor\Visitor\Php\BasePHPVisitor; 17 | 18 | /** 19 | * @author Tobias Nyholm 20 | */ 21 | final class FlashMessage extends BasePHPVisitor implements NodeVisitor 22 | { 23 | public function beforeTraverse(array $nodes): ?Node 24 | { 25 | return null; 26 | } 27 | 28 | public function enterNode(Node $node): ?Node 29 | { 30 | if (!$node instanceof Node\Expr\MethodCall) { 31 | return null; 32 | } 33 | 34 | if (!\is_string($node->name) && !$node->name instanceof Node\Identifier) { 35 | return null; 36 | } 37 | 38 | $name = (string) $node->name; 39 | 40 | // This prevents dealing with some fatal edge cases when getting the callerName 41 | if (!\in_array($name, ['addFlash', 'add'])) { 42 | return null; 43 | } 44 | 45 | $caller = $node->var; 46 | // $caller might be "Node\Expr\New_" 47 | $callerName = isset($caller->name) ? (string) $caller->name : ''; 48 | 49 | /* 50 | * Make sure the caller is from a variable named "this" or a function called "getFlashbag" 51 | */ 52 | // If $this->addFlash() or xxx->getFlashbag()->add() 53 | if (('addFlash' === $name && 'this' === $callerName && $caller instanceof Node\Expr\Variable) 54 | || ('add' === $name && 'getFlashBag' === $callerName && $caller instanceof Node\Expr\MethodCall) 55 | ) { 56 | if (null !== $label = $this->getStringArgument($node, 1)) { 57 | $this->addLocation($label, $node->getAttribute('startLine'), $node); 58 | } 59 | } 60 | 61 | return null; 62 | } 63 | 64 | public function leaveNode(Node $node): ?Node 65 | { 66 | return null; 67 | } 68 | 69 | public function afterTraverse(array $nodes): ?Node 70 | { 71 | return null; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Visitor/Php/Symfony/FormTypeEmptyValue.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Visitor\Php\Symfony; 13 | 14 | use PhpParser\Node; 15 | use PhpParser\NodeVisitor; 16 | use Translation\Extractor\Visitor\Php\BasePHPVisitor; 17 | 18 | /** 19 | * @author Tobias Nyholm 20 | */ 21 | final class FormTypeEmptyValue extends BasePHPVisitor implements NodeVisitor 22 | { 23 | use FormTrait; 24 | 25 | public function enterNode(Node $node): ?Node 26 | { 27 | if (!$this->isFormType($node)) { 28 | return null; 29 | } 30 | 31 | if (!$node instanceof Node\Expr\Array_) { 32 | return null; 33 | } 34 | 35 | foreach ($node->items as $item) { 36 | if (!$item->key instanceof Node\Scalar\String_) { 37 | continue; 38 | } 39 | 40 | if ('empty_value' !== $item->key->value) { 41 | continue; 42 | } 43 | 44 | if ($item->value instanceof Node\Scalar\String_) { 45 | $this->storeValue($node, $item); 46 | } elseif ($item->value instanceof Node\Expr\Array_) { 47 | foreach ($item->value->items as $arrayItem) { 48 | $this->storeValue($node, $arrayItem); 49 | } 50 | } 51 | } 52 | 53 | return null; 54 | } 55 | 56 | private function storeValue(Node $node, $item): void 57 | { 58 | if (!$item->value instanceof Node\Scalar\String_) { 59 | $this->addError($item, 'Form label is not a scalar string'); 60 | 61 | return; 62 | } 63 | 64 | $label = $item->value->value; 65 | if (empty($label)) { 66 | return; 67 | } 68 | 69 | $this->addLocation($label, $node->getAttribute('startLine'), $node); 70 | } 71 | 72 | public function leaveNode(Node $node): ?Node 73 | { 74 | return null; 75 | } 76 | 77 | public function beforeTraverse(array $nodes): ?Node 78 | { 79 | return null; 80 | } 81 | 82 | public function afterTraverse(array $nodes): ?Node 83 | { 84 | return null; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/FileExtractor/BladeFileExtractor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\FileExtractor; 13 | 14 | use Symfony\Component\Finder\SplFileInfo; 15 | use Translation\Extractor\Model\SourceCollection; 16 | use Translation\Extractor\Model\SourceLocation; 17 | 18 | /** 19 | * @author Tobias Nyholm 20 | */ 21 | final class BladeFileExtractor implements FileExtractor 22 | { 23 | public function getSourceLocations(SplFileInfo $file, SourceCollection $collection): void 24 | { 25 | $realPath = $file->getRealPath(); 26 | $messages = $this->findTranslations($file); 27 | foreach ($messages as $message) { 28 | $collection->addLocation(new SourceLocation($message, $realPath, 0)); 29 | } 30 | } 31 | 32 | /** 33 | * @author uusa35 and contributors to {@link https://github.com/barryvdh/laravel-translation-manager} 34 | * 35 | * @return string[] 36 | */ 37 | public function findTranslations(SplFileInfo $file): array 38 | { 39 | $keys = []; 40 | $functions = ['trans', 'trans_choice', 'Lang::get', 'Lang::choice', 'Lang::trans', 'Lang::transChoice', '@lang', '@choice']; 41 | $pattern = // See http://regexr.com/392hu 42 | "[^\w|>]".// Must not have an alphanum or _ or > before real method 43 | '('.implode('|', $functions).')'.// Must start with one of the functions 44 | "\(".// Match opening parenthese 45 | "[\'\"]".// Match " or ' 46 | '('.// Start a new group to match: 47 | '[a-zA-Z0-9_-]+'.// Must start with group 48 | '([.][^\\1)]+)+'.// Be followed by one or more items/keys 49 | ')'.// Close group 50 | "[\'\"]".// Closing quote 51 | "[\),]"; // Close parentheses or new parameter 52 | 53 | // Search the current file for the pattern 54 | if (preg_match_all("/$pattern/siU", $file->getContents(), $matches)) { 55 | // Get all matches 56 | foreach ($matches[2] as $key) { 57 | $keys[] = $key; 58 | } 59 | } 60 | 61 | return $keys; 62 | } 63 | 64 | public function supportsExtension(string $extension): bool 65 | { 66 | return 'blade.php' === $extension; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Visitor/Php/TranslateAnnotationVisitor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Visitor\Php; 13 | 14 | use Doctrine\Common\Annotations\DocParser; 15 | use PhpParser\Comment; 16 | use PhpParser\Node; 17 | use PhpParser\NodeVisitor; 18 | use Translation\Extractor\Annotation\Translate; 19 | 20 | /** 21 | * Class TranslationAnnotationVisitor. 22 | * 23 | * Supports using @Translate annotation for marking string nodes to be added to the dictionary 24 | */ 25 | class TranslateAnnotationVisitor extends BasePHPVisitor implements NodeVisitor 26 | { 27 | protected ?DocParser $translateDocParser = null; 28 | 29 | private function getTranslateDocParser(): DocParser 30 | { 31 | if (null === $this->translateDocParser) { 32 | $this->translateDocParser = new DocParser(); 33 | 34 | $this->translateDocParser->setImports([ 35 | 'translate' => Translate::class, 36 | ]); 37 | $this->translateDocParser->setIgnoreNotImportedAnnotations(true); 38 | } 39 | 40 | return $this->translateDocParser; 41 | } 42 | 43 | public function enterNode(Node $node): ?Node 44 | { 45 | // look for strings 46 | if (!$node instanceof Node\Scalar\String_) { 47 | return null; 48 | } 49 | 50 | // look for string with comment 51 | $comments = $node->getAttribute('comments', []); 52 | if (!\count($comments)) { 53 | return null; 54 | } 55 | 56 | foreach ($comments as $comment) { 57 | if (!$comment instanceof Comment\Doc) { 58 | return null; 59 | } 60 | 61 | foreach ($this->getTranslateDocParser()->parse($comment->getText()) as $annotation) { 62 | // add phrase to dictionary 63 | $this->addLocation($node->value, $node->getAttribute('startLine'), $node, ['domain' => $annotation->getDomain()]); 64 | 65 | break; 66 | } 67 | } 68 | 69 | return null; 70 | } 71 | 72 | public function leaveNode(Node $node): ?Node 73 | { 74 | return null; 75 | } 76 | 77 | public function beforeTraverse(array $nodes): ?Node 78 | { 79 | return null; 80 | } 81 | 82 | public function afterTraverse(array $nodes): ?Node 83 | { 84 | return null; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Visitor/Php/Symfony/FormTypeTitle.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Visitor\Php\Symfony; 13 | 14 | use PhpParser\Node; 15 | use PhpParser\NodeVisitor; 16 | 17 | /** 18 | * @author Tobias Nyholm 19 | */ 20 | final class FormTypeTitle extends AbstractFormType implements NodeVisitor 21 | { 22 | use FormTrait; 23 | 24 | public function enterNode(Node $node): ?Node 25 | { 26 | if (!$this->isFormType($node)) { 27 | return null; 28 | } 29 | 30 | parent::enterNode($node); 31 | 32 | if (!$node instanceof Node\Expr\Array_) { 33 | return null; 34 | } 35 | 36 | $titleNode = null; 37 | $domain = null; 38 | foreach ($node->items as $item) { 39 | if (!$item->key instanceof Node\Scalar\String_) { 40 | continue; 41 | } 42 | if ('translation_domain' === $item->key->value) { 43 | // Try to find translation domain 44 | if ($item->value instanceof Node\Scalar\String_) { 45 | $domain = $item->value->value; 46 | } elseif ($item->value instanceof Node\Expr\ConstFetch && 'false' === $item->value->name->toString()) { 47 | $domain = false; 48 | } 49 | } elseif ('attr' === $item->key->value && $item->value instanceof Node\Expr\Array_) { 50 | foreach ($item->value->items as $attrValue) { 51 | if (!$attrValue->key instanceof Node\Scalar\String_) { 52 | continue; 53 | } 54 | if ('title' === $attrValue->key->value) { 55 | $titleNode = $attrValue; 56 | 57 | break; 58 | } 59 | } 60 | } 61 | } 62 | 63 | if (null === $titleNode) { 64 | return null; 65 | } 66 | 67 | if ($titleNode->value instanceof Node\Scalar\String_) { 68 | $line = $titleNode->value->getAttribute('startLine'); 69 | if (null !== $location = $this->getLocation($titleNode->value->value, $line, $titleNode, ['domain' => $domain])) { 70 | $this->lateCollect($location); 71 | } 72 | } else { 73 | $this->addError($titleNode, 'Form field title is not a scalar string'); 74 | } 75 | 76 | return null; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Visitor/Php/Symfony/FormTypeLabelExplicit.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Visitor\Php\Symfony; 13 | 14 | use PhpParser\Node; 15 | use PhpParser\NodeVisitor; 16 | 17 | /** 18 | * @author Rein Baarsma 19 | */ 20 | final class FormTypeLabelExplicit extends AbstractFormType implements NodeVisitor 21 | { 22 | use FormTrait; 23 | 24 | public function enterNode(Node $node): ?Node 25 | { 26 | if (!$this->isFormType($node)) { 27 | return null; 28 | } 29 | 30 | parent::enterNode($node); 31 | 32 | /* 33 | * We could have chosen to traverse specifically the buildForm function or ->add() 34 | * we will probably miss some easy to catch instances when the actual array of options 35 | * is provided statically or through another function. 36 | * 37 | * I don't see any disadvantages now to simply parsing arrays and JMSTranslationBundle has 38 | * been doing it like this for quite some time without major problems. 39 | */ 40 | if (!$node instanceof Node\Expr\Array_) { 41 | return null; 42 | } 43 | 44 | $labelNode = null; 45 | $domain = null; 46 | foreach ($node->items as $item) { 47 | if (!$item->key instanceof Node\Scalar\String_) { 48 | continue; 49 | } 50 | 51 | if ('translation_domain' === $item->key->value) { 52 | if ($item->value instanceof Node\Scalar\String_) { 53 | $domain = $item->value->value; 54 | } elseif ($item->value instanceof Node\Expr\ConstFetch && 'false' === $item->value->name->toString()) { 55 | $domain = false; 56 | } 57 | } 58 | 59 | if ('label' !== $item->key->value) { 60 | continue; 61 | } 62 | 63 | if ($item->value instanceof Node\Expr\ConstFetch) { 64 | // This might be boolean "false" 65 | if ('false' === $item->value->name->toString()) { 66 | continue; 67 | } 68 | } 69 | 70 | if (!$item->value instanceof Node\Scalar\String_) { 71 | $this->addError($item, 'Form label is not a scalar string'); 72 | 73 | continue; 74 | } 75 | 76 | $label = $item->value->value; 77 | if (empty($label)) { 78 | continue; 79 | } 80 | 81 | $labelNode = $item; 82 | } 83 | 84 | if ($labelNode && false !== $domain && !empty($label)) { 85 | if (null !== $location = $this->getLocation($label, $node->getAttribute('startLine'), $labelNode, ['domain' => $domain])) { 86 | $this->lateCollect($location); 87 | } 88 | } 89 | 90 | return null; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Visitor/Php/SourceLocationContainerVisitor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Visitor\Php; 13 | 14 | use PhpParser\Node; 15 | use PhpParser\NodeVisitor; 16 | use Translation\Extractor\Model\SourceLocation; 17 | 18 | /** 19 | * Extract translations from classes implementing 20 | * Translation\Extractor\Model\SourceLocation\TranslationSourceLocationContainer. 21 | * 22 | * @author Tobias Nyholm 23 | */ 24 | final class SourceLocationContainerVisitor extends BasePHPVisitor implements NodeVisitor 25 | { 26 | private string $namespace = ''; 27 | private array $useStatements = []; 28 | 29 | public function beforeTraverse(array $nodes): ?Node 30 | { 31 | return null; 32 | } 33 | 34 | public function enterNode(Node $node): ?Node 35 | { 36 | if ($node instanceof Node\Stmt\Namespace_) { 37 | if (isset($node->name)) { 38 | // Save namespace of this class for later. 39 | $this->namespace = implode('\\', $node->name->getParts()); 40 | } 41 | $this->useStatements = []; 42 | 43 | return null; 44 | } 45 | 46 | if ($node instanceof Node\Stmt\UseUse) { 47 | $key = $node->alias ?? $node->name->getParts()[\count($node->name->getParts()) - 1]; 48 | $this->useStatements[(string) $key] = implode('\\', $node->name->getParts()); 49 | 50 | return null; 51 | } 52 | 53 | if (!$node instanceof Node\Stmt\Class_) { 54 | return null; 55 | } 56 | 57 | $isContainer = false; 58 | foreach ($node->implements as $interface) { 59 | $name = implode('\\', $interface->getParts()); 60 | if (isset($this->useStatements[$name])) { 61 | $name = $this->useStatements[$name]; 62 | } 63 | 64 | if ('Translation\Extractor\TranslationSourceLocationContainer' === $name) { 65 | $isContainer = true; 66 | 67 | break; 68 | } 69 | } 70 | 71 | if (!$isContainer) { 72 | return null; 73 | } 74 | 75 | $sourceLocations = \call_user_func([$this->namespace.'\\'.$node->name, 'getTranslationSourceLocations']); 76 | 77 | foreach ($sourceLocations as $sourceLocation) { 78 | if (!$sourceLocation instanceof SourceLocation) { 79 | throw new \RuntimeException(\sprintf('%s::getTranslationSourceLocations() was expected to return an array of SourceLocations, but got an array which contains an item of type %s.', $this->namespace.'\\'.$node->name, \gettype($sourceLocation))); 80 | } 81 | 82 | $this->collection->addLocation($sourceLocation); 83 | } 84 | 85 | return null; 86 | } 87 | 88 | public function leaveNode(Node $node): ?Node 89 | { 90 | return null; 91 | } 92 | 93 | public function afterTraverse(array $nodes): ?Node 94 | { 95 | return null; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Visitor/BaseVisitor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Visitor; 13 | 14 | use Doctrine\Common\Annotations\DocParser; 15 | use PhpParser\Node; 16 | use Symfony\Component\Finder\SplFileInfo; 17 | use Translation\Extractor\Annotation\Desc; 18 | use Translation\Extractor\Annotation\Ignore; 19 | use Translation\Extractor\Model\Error; 20 | use Translation\Extractor\Model\SourceCollection; 21 | use Translation\Extractor\Model\SourceLocation; 22 | 23 | /** 24 | * Base class for any visitor. 25 | * 26 | * @author Tobias Nyholm 27 | */ 28 | abstract class BaseVisitor implements Visitor 29 | { 30 | private ?DocParser $docParser = null; 31 | 32 | protected ?SourceCollection $collection = null; 33 | protected SplFileInfo $file; 34 | 35 | public function init(SourceCollection $collection, SplFileInfo $file): void 36 | { 37 | $this->collection = $collection; 38 | $this->file = $file; 39 | } 40 | 41 | protected function getAbsoluteFilePath(): string 42 | { 43 | return $this->file->getRealPath(); 44 | } 45 | 46 | protected function addError(Node $node, string $errorMessage): void 47 | { 48 | $docComment = $node->getDocComment(); 49 | $file = $this->getAbsoluteFilePath(); 50 | 51 | if (property_exists($node, 'value')) { 52 | $line = $node->value->getAttribute('startLine'); 53 | } else { 54 | $line = $node->getAttribute('startLine'); 55 | } 56 | if (null !== $docComment) { 57 | $context = 'file '.$file.' near line '.$line; 58 | foreach ($this->getDocParser()->parse($docComment->getText(), $context) as $annotation) { 59 | if ($annotation instanceof Ignore) { 60 | return; 61 | } 62 | } 63 | } 64 | 65 | $this->collection->addError(new Error($errorMessage, $file, $line)); 66 | } 67 | 68 | protected function addLocation(string $text, int $line, ?Node $node = null, array $context = []): void 69 | { 70 | if (null === $location = $this->getLocation($text, $line, $node, $context)) { 71 | return; 72 | } 73 | 74 | $this->collection->addLocation($location); 75 | } 76 | 77 | protected function getLocation(string $text, int $line, ?Node $node = null, array $context = []): ?SourceLocation 78 | { 79 | $file = $this->getAbsoluteFilePath(); 80 | if (null !== $node && null !== $docComment = $node->getDocComment()) { 81 | $parserContext = 'file '.$file.' near line '.$line; 82 | foreach ($this->getDocParser()->parse($docComment->getText(), $parserContext) as $annotation) { 83 | if ($annotation instanceof Ignore) { 84 | return null; 85 | } elseif ($annotation instanceof Desc) { 86 | $context['desc'] = $annotation->text; 87 | } 88 | } 89 | } 90 | 91 | return new SourceLocation($text, $file, $line, $context); 92 | } 93 | 94 | private function getDocParser(): DocParser 95 | { 96 | if (null === $this->docParser) { 97 | $this->docParser = new DocParser(); 98 | 99 | $this->docParser->setImports([ 100 | 'ignore' => Ignore::class, 101 | 'desc' => Desc::class, 102 | ]); 103 | $this->docParser->setIgnoreNotImportedAnnotations(true); 104 | } 105 | 106 | return $this->docParser; 107 | } 108 | 109 | public function setDocParser(DocParser $docParser): void 110 | { 111 | $this->docParser = $docParser; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Visitor/Php/Symfony/ValidationAnnotation.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Visitor\Php\Symfony; 13 | 14 | use Doctrine\Common\Annotations\AnnotationException; 15 | use PhpParser\Node; 16 | use PhpParser\NodeVisitor; 17 | use Symfony\Component\Validator\Mapping\ClassMetadata; 18 | use Symfony\Component\Validator\Mapping\Factory\MetadataFactoryInterface; 19 | use Translation\Extractor\Visitor\Php\BasePHPVisitor; 20 | 21 | /** 22 | * @author Tobias Nyholm 23 | */ 24 | final class ValidationAnnotation extends BasePHPVisitor implements NodeVisitor 25 | { 26 | private MetadataFactoryInterface $metadataFactory; 27 | 28 | private string $namespace; 29 | 30 | public function __construct(MetadataFactoryInterface $metadataFactory) 31 | { 32 | $this->metadataFactory = $metadataFactory; 33 | } 34 | 35 | public function beforeTraverse(array $nodes): ?Node 36 | { 37 | $this->namespace = ''; 38 | 39 | return null; 40 | } 41 | 42 | public function enterNode(Node $node): ?Node 43 | { 44 | if ($node instanceof Node\Stmt\Namespace_) { 45 | if (isset($node->name)) { 46 | // save the namespace 47 | $this->namespace = implode('\\', $node->name->getParts()); 48 | } 49 | 50 | return null; 51 | } 52 | 53 | if (!$node instanceof Node\Stmt\Class_) { 54 | return null; 55 | } 56 | 57 | $name = '' === $this->namespace ? $node->name : $this->namespace.'\\'.$node->name; 58 | 59 | if (!class_exists($name)) { 60 | return null; 61 | } 62 | 63 | try { 64 | /** @var ClassMetadata $metadata */ 65 | $metadata = $this->metadataFactory->getMetadataFor($name); 66 | } catch (AnnotationException $e) { 67 | $this->addError($node, \sprintf('Could not parse class "%s" for annotations. %s', $this->namespace, $e->getMessage())); 68 | 69 | return null; 70 | } 71 | 72 | if (!$metadata->hasConstraints() && !\count($metadata->getConstrainedProperties())) { 73 | return null; 74 | } 75 | 76 | $this->extractFromConstraints($metadata->getConstraints()); 77 | foreach ($metadata->getConstrainedProperties() as $property) { 78 | foreach ($metadata->getPropertyMetadata($property) as $member) { 79 | $this->extractFromConstraints($member->getConstraints()); 80 | } 81 | } 82 | 83 | return null; 84 | } 85 | 86 | private function extractFromConstraints(array $constraints): void 87 | { 88 | foreach ($constraints as $constraint) { 89 | $ref = new \ReflectionClass($constraint); 90 | $defaultValues = $ref->getDefaultProperties(); 91 | 92 | $properties = $ref->getProperties(); 93 | 94 | foreach ($properties as $property) { 95 | $propName = $property->getName(); 96 | 97 | // If the property ends with 'Message' 98 | if ('message' === strtolower(substr($propName, -1 * \strlen('Message')))) { 99 | // If it is different from the default value 100 | if ($defaultValues[$propName] !== $constraint->{$propName}) { 101 | $this->addLocation($constraint->{$propName}, 0, null, ['domain' => 'validators']); 102 | } 103 | } 104 | } 105 | } 106 | } 107 | 108 | public function leaveNode(Node $node): ?Node 109 | { 110 | return null; 111 | } 112 | 113 | public function afterTraverse(array $nodes): ?Node 114 | { 115 | return null; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Visitor/Php/Symfony/FormTypePlaceholder.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Visitor\Php\Symfony; 13 | 14 | use PhpParser\Node; 15 | use PhpParser\NodeVisitor; 16 | 17 | /** 18 | * @author Tobias Nyholm 19 | */ 20 | final class FormTypePlaceholder extends AbstractFormType implements NodeVisitor 21 | { 22 | use FormTrait; 23 | 24 | private array $arrayNodeVisited = []; 25 | 26 | public function enterNode(Node $node): ?Node 27 | { 28 | if (!$this->isFormType($node)) { 29 | return null; 30 | } 31 | 32 | parent::enterNode($node); 33 | 34 | if (!$node instanceof Node\Expr\Array_) { 35 | return null; 36 | } 37 | 38 | $placeholderNode = null; 39 | $domain = null; 40 | foreach ($node->items as $item) { 41 | if (!$item->key instanceof Node\Scalar\String_) { 42 | continue; 43 | } 44 | if ('translation_domain' === $item->key->value) { 45 | // Try to find translation domain 46 | if ($item->value instanceof Node\Scalar\String_) { 47 | $domain = $item->value->value; 48 | } elseif ($item->value instanceof Node\Expr\ConstFetch && 'false' === $item->value->name->toString()) { 49 | $domain = false; 50 | } 51 | } elseif ('placeholder' === $item->key->value) { 52 | $placeholderNode = $item; 53 | } elseif ('attr' === $item->key->value && $item->value instanceof Node\Expr\Array_) { 54 | foreach ($item->value->items as $attrValue) { 55 | if (!$attrValue->key instanceof Node\Scalar\String_) { 56 | continue; 57 | } 58 | if ('placeholder' === $attrValue->key->value) { 59 | $placeholderNode = $attrValue; 60 | 61 | break; 62 | } 63 | } 64 | } 65 | } 66 | 67 | if (null === $placeholderNode) { 68 | return null; 69 | } 70 | 71 | /** 72 | * Make sure we do not visit the same placeholder node twice. 73 | * 74 | * The placeholder information is not always in the same place: 75 | * * it can be in Type options (for example when using `ChoiceType`) 76 | * * it can be in `attr` (for example when using `TextType`) 77 | * 78 | * @see https://github.com/php-translation/extractor/pull/114#issuecomment-400329507 79 | */ 80 | $hash = spl_object_hash($placeholderNode); 81 | if (isset($this->arrayNodeVisited[$hash])) { 82 | return null; 83 | } 84 | $this->arrayNodeVisited[$hash] = true; 85 | 86 | if ($placeholderNode->value instanceof Node\Scalar\String_) { 87 | $line = $placeholderNode->value->getAttribute('startLine'); 88 | if (null !== $location = $this->getLocation($placeholderNode->value->value, $line, $placeholderNode, ['domain' => $domain])) { 89 | $this->lateCollect($location); 90 | } 91 | } elseif ($placeholderNode->value instanceof Node\Expr\ConstFetch && 'false' === $placeholderNode->value->name->toString()) { 92 | // 'placeholder' => false, 93 | // Do noting 94 | } elseif ($placeholderNode->value instanceof Node\Expr\Array_) { 95 | foreach ($placeholderNode->value->items as $placeholderNode) { 96 | $line = $placeholderNode->value->getAttribute('startLine'); 97 | if (isset($placeholderNode->value->value)) { 98 | if (null !== $location = $this->getLocation($placeholderNode->value->value, $line, $placeholderNode, ['domain' => $domain])) { 99 | $this->lateCollect($location); 100 | } 101 | } 102 | } 103 | } else { 104 | $this->addError($placeholderNode, 'Form placeholder is not a scalar string'); 105 | } 106 | 107 | return null; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/Visitor/Php/Knp/Menu/AbstractKnpMenuVisitor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Visitor\Php\Knp\Menu; 13 | 14 | use PhpParser\Node; 15 | use PhpParser\NodeVisitor; 16 | use Translation\Extractor\Model\SourceLocation; 17 | use Translation\Extractor\Visitor\Php\BasePHPVisitor; 18 | 19 | /** 20 | * This class provides common functionality for KnpMenu extractors. 21 | */ 22 | abstract class AbstractKnpMenuVisitor extends BasePHPVisitor implements NodeVisitor 23 | { 24 | private bool $isKnpMenuBuildingMethod = false; 25 | 26 | private string|bool|null $domain = null; 27 | 28 | /** 29 | * @var SourceLocation[] 30 | */ 31 | private array $sourceLocations = []; 32 | 33 | public function beforeTraverse(array $nodes): ?Node 34 | { 35 | $this->sourceLocations = []; 36 | 37 | return null; 38 | } 39 | 40 | public function enterNode(Node $node): ?Node 41 | { 42 | if (!$this->isKnpMenuBuildingMethod($node)) { 43 | return null; 44 | } 45 | 46 | if (!$node instanceof Node\Expr\MethodCall) { 47 | return null; 48 | } 49 | 50 | if (!\is_string($node->name) && !$node->name instanceof Node\Identifier) { 51 | return null; 52 | } 53 | 54 | $methodName = (string) $node->name; 55 | if ('setExtra' !== $methodName) { 56 | return null; 57 | } 58 | 59 | $extraKey = $this->getStringArgument($node, 0); 60 | if ('translation_domain' === $extraKey) { 61 | if ( 62 | $node->args[1]->value instanceof Node\Expr\ConstFetch 63 | && 'false' === $node->args[1]->value->name->toString() 64 | ) { 65 | // translation disabled 66 | $this->domain = false; 67 | } else { 68 | $extraValue = $this->getStringArgument($node, 1); 69 | if (null !== $extraValue) { 70 | $this->domain = $extraValue; 71 | } 72 | } 73 | } 74 | 75 | return null; 76 | } 77 | 78 | /** 79 | * Checks if the given node is a class method returning a knp menu. 80 | */ 81 | protected function isKnpMenuBuildingMethod(Node $node): bool 82 | { 83 | if ($node instanceof Node\Stmt\ClassMethod) { 84 | if (null === $node->returnType) { 85 | $this->isKnpMenuBuildingMethod = false; 86 | } 87 | if ($node->returnType instanceof Node\Identifier) { 88 | $this->isKnpMenuBuildingMethod = false; 89 | } 90 | 91 | $returnType = $node->returnType; 92 | if ($returnType instanceof Node\NullableType) { 93 | $returnType = $returnType->type; 94 | } 95 | 96 | if (!$returnType instanceof Node\Name) { 97 | $this->isKnpMenuBuildingMethod = false; 98 | } else { 99 | $this->isKnpMenuBuildingMethod = 'ItemInterface' === $returnType->toString(); 100 | } 101 | } 102 | 103 | return $this->isKnpMenuBuildingMethod; 104 | } 105 | 106 | protected function lateCollect(SourceLocation $location): void 107 | { 108 | $this->sourceLocations[] = $location; 109 | } 110 | 111 | public function leaveNode(Node $node): ?Node 112 | { 113 | return null; 114 | } 115 | 116 | public function afterTraverse(array $nodes): ?Node 117 | { 118 | if (false === $this->domain) { 119 | // translation disabled 120 | return null; 121 | } 122 | 123 | foreach ($this->sourceLocations as $location) { 124 | if (null !== $this->domain) { 125 | $context = $location->getContext(); 126 | $context['domain'] = $this->domain; 127 | $location = new SourceLocation($location->getMessage(), $location->getPath(), $location->getLine(), $context); 128 | } 129 | $this->collection->addLocation($location); 130 | } 131 | $this->sourceLocations = []; 132 | 133 | return null; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/Visitor/Php/Symfony/FormTypeLabelImplicit.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Visitor\Php\Symfony; 13 | 14 | use PhpParser\Node; 15 | use PhpParser\NodeVisitor; 16 | 17 | /** 18 | * @author Rein Baarsma 19 | */ 20 | final class FormTypeLabelImplicit extends AbstractFormType implements NodeVisitor 21 | { 22 | use FormTrait; 23 | 24 | public function enterNode(Node $node): ?Node 25 | { 26 | if (!$this->isFormType($node)) { 27 | return null; 28 | } 29 | 30 | parent::enterNode($node); 31 | 32 | $domain = null; 33 | // use add() function and look at first argument and if that's a string 34 | if ($node instanceof Node\Expr\MethodCall 35 | && (!\is_object($node->name) || method_exists($node->name, '__toString')) 36 | && ('add' === (string) $node->name || 'create' === (string) $node->name) 37 | && $node->args[0]->value instanceof Node\Scalar\String_) { 38 | $skipLabel = false; 39 | // Check if the form type is "hidden" 40 | if (\count($node->args) >= 2) { 41 | $type = $node->args[1]->value; 42 | if ($type instanceof Node\Scalar\String_ && 'Symfony\Component\Form\Extension\Core\Type\HiddenType' === $type->value 43 | || $type instanceof Node\Expr\ClassConstFetch && 'HiddenType' === $type->class->getParts()[0]) { 44 | $skipLabel = true; 45 | } 46 | } 47 | 48 | // now make sure we don't have 'label' in the array of options 49 | if (\count($node->args) >= 3) { 50 | if ($node->args[2]->value instanceof Node\Expr\Array_) { 51 | foreach ($node->args[2]->value->items as $item) { 52 | if (isset($item->key) && $item->key instanceof Node\Scalar\String_ && 'label' === $item->key->value) { 53 | $skipLabel = true; 54 | } 55 | 56 | if (isset($item->key) && $item->key instanceof Node\Scalar\String_ && 'translation_domain' === $item->key->value) { 57 | if ($item->value instanceof Node\Scalar\String_) { 58 | $domain = $item->value->value; 59 | } elseif ($item->value instanceof Node\Expr\ConstFetch && 'false' === $item->value->name->toString()) { 60 | $domain = false; 61 | } 62 | } 63 | } 64 | } 65 | /* 66 | * Actually there's another case here: if the 3rd argument is anything else, it could well be 67 | * that label is set through a static array. This will not be a common use-case so yeah in this case 68 | * it may be the translation is double. 69 | */ 70 | } 71 | 72 | // only if no custom label was found, proceed 73 | if (false === $skipLabel && false !== $domain) { 74 | /* 75 | * Pass DocComment (if available) from first argument (name of Form field) allowing usage of Ignore 76 | * annotation to disable implicit add; use case: when form options are generated by external method. 77 | */ 78 | if ($node->args[0]->getDocComment()) { 79 | $node->setDocComment($node->args[0]->getDocComment()); 80 | } 81 | 82 | $label = ''; 83 | if ($node->args[0]->value instanceof Node\Scalar\String_) { 84 | $label = null == $node->args[0]->value->value ? '' : $node->args[0]->value->value; 85 | } 86 | 87 | if (!empty($label)) { 88 | $label = $this->humanize($label); 89 | if (null !== $location = $this->getLocation($label, $node->getAttribute('startLine'), $node, ['domain' => $domain])) { 90 | $this->lateCollect($location); 91 | } 92 | } 93 | } 94 | } 95 | 96 | return null; 97 | } 98 | 99 | /** 100 | * @see Symfony\Component\Form\FormRenderer::humanize() 101 | */ 102 | private function humanize(string $text): string 103 | { 104 | return ucfirst(strtolower(trim(preg_replace(['/([A-Z])/', '/[_\s]+/'], ['_$1', ' '], $text)))); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Visitor/Php/Symfony/AbstractFormType.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Visitor\Php\Symfony; 13 | 14 | use PhpParser\Node; 15 | use PhpParser\NodeVisitor; 16 | use Translation\Extractor\Model\SourceLocation; 17 | use Translation\Extractor\Visitor\Php\BasePHPVisitor; 18 | 19 | /** 20 | * @author Tobias Nyholm 21 | */ 22 | abstract class AbstractFormType extends BasePHPVisitor implements NodeVisitor 23 | { 24 | /** 25 | * @var SourceLocation[] 26 | */ 27 | private array $sourceLocations = []; 28 | 29 | private ?string $defaultDomain; 30 | 31 | public function enterNode(Node $node): ?Node 32 | { 33 | if ($node instanceof Node\Expr\MethodCall) { 34 | if (!\is_string($node->name) && !$node->name instanceof Node\Identifier) { 35 | return null; 36 | } 37 | 38 | $name = strtolower((string) $node->name); 39 | if ('setdefaults' === $name || 'replacedefaults' === $name || 'setdefault' === $name) { 40 | $this->parseDefaultsCall($node); 41 | 42 | return null; 43 | } 44 | } 45 | 46 | return null; 47 | } 48 | 49 | protected function lateCollect(SourceLocation $location): void 50 | { 51 | $this->sourceLocations[] = $location; 52 | } 53 | 54 | public function leaveNode(Node $node): ?Node 55 | { 56 | return null; 57 | } 58 | 59 | public function beforeTraverse(array $nodes): ?Node 60 | { 61 | $this->defaultDomain = null; 62 | $this->sourceLocations = []; 63 | 64 | return null; 65 | } 66 | 67 | /** 68 | * From JMS Translation bundle. 69 | */ 70 | private function parseDefaultsCall(Node $node): void 71 | { 72 | static $returningMethods = [ 73 | 'setdefaults' => true, 'replacedefaults' => true, 'setoptional' => true, 'setrequired' => true, 74 | 'setallowedvalues' => true, 'addallowedvalues' => true, 'setallowedtypes' => true, 75 | 'addallowedtypes' => true, 'setfilters' => true, 76 | ]; 77 | 78 | $var = $node->var; 79 | while ($var instanceof Node\Expr\MethodCall) { 80 | if (!isset($returningMethods[strtolower($var->name)])) { 81 | return; 82 | } 83 | 84 | $var = $var->var; 85 | } 86 | 87 | if (!$var instanceof Node\Expr\Variable) { 88 | return; 89 | } 90 | 91 | if (null === $node->args || false === \is_array($node->args) || 0 === \count($node->args)) { 92 | return; 93 | } 94 | 95 | // check if options were passed 96 | if (!isset($node->args[0])) { 97 | return; 98 | } 99 | 100 | if (isset($node->args[1]) 101 | && $node->args[0]->value instanceof Node\Scalar\String_ 102 | && $node->args[1]->value instanceof Node\Scalar\String_ 103 | && 'translation_domain' === $node->args[0]->value->value 104 | ) { 105 | $this->defaultDomain = $node->args[1]->value->value; 106 | 107 | return; 108 | } 109 | 110 | // ignore everything except an array 111 | if (!$node->args[0]->value instanceof Node\Expr\Array_) { 112 | return; 113 | } 114 | 115 | // check if a translation_domain is set as a default option 116 | foreach ($node->args[0]->value->items as $item) { 117 | if (!$item->key instanceof Node\Scalar\String_) { 118 | continue; 119 | } 120 | 121 | if ('translation_domain' === $item->key->value) { 122 | if (!$item->value instanceof Node\Scalar\String_) { 123 | continue; 124 | } 125 | 126 | $this->defaultDomain = $item->value->value; 127 | } 128 | } 129 | } 130 | 131 | public function afterTraverse(array $nodes): ?Node 132 | { 133 | foreach ($this->sourceLocations as $location) { 134 | if (null !== $this->defaultDomain) { 135 | $context = $location->getContext(); 136 | if (null === $context['domain']) { 137 | $context['domain'] = $this->defaultDomain; 138 | $location = new SourceLocation($location->getMessage(), $location->getPath(), $location->getLine(), $context); 139 | } 140 | } 141 | $this->collection->addLocation($location); 142 | } 143 | $this->sourceLocations = []; 144 | 145 | return null; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | The change log describes what is "Added", "Removed", "Changed" or "Fixed" between each release. 4 | 5 | ## UNRELEASED 6 | 7 | ## 2.1.0 8 | 9 | ### Added 10 | * Allow Symfony 6 by @bocharsky-bw in https://github.com/php-translation/extractor/pull/165 11 | * New extractor for constraints by @lukepass in https://github.com/php-translation/extractor/pull/160 12 | * Concatenated labels by @Stadly in https://github.com/php-translation/extractor/pull/127 13 | * Add support to placeholders as array by @cristoforocervino in https://github.com/php-translation/extractor/pull/158 14 | * Humanize the implicit label of symfony form types https://github.com/php-translation/extractor/pull/154 15 | 16 | ## 2.0.4 17 | 18 | ### Added 19 | * Add GitHub Actions CI config by @bocharsky-bw in https://github.com/php-translation/extractor/pull/164 20 | * Add return type to fix deprecation by @gimler in https://github.com/php-translation/extractor/pull/161 21 | 22 | ### Removed 23 | * Remove phpcs-fixer --diff-fomat option by @gimler in https://github.com/php-translation/extractor/pull/163 24 | * Delete Travis config - repo migrated to GitHub Actions by @bocharsky-bw in https://github.com/php-translation/extractor/pull/162 25 | 26 | ## 2.0.3 27 | 28 | ### Added 29 | 30 | - Add support for PHP 8 #157 31 | 32 | ## 2.0.2 33 | 34 | ### Fixed 35 | 36 | - Update image used in github actions to fix CS errors #153 37 | - Fixed error when using variable in transChoice() #151 38 | 39 | ### Added 40 | 41 | - Knp menu extractors #152 42 | 43 | ## 2.0.1 44 | 45 | ### Fixed 46 | 47 | - Avoid exception when calling `trans` with a variable. 48 | 49 | ### Added 50 | 51 | - Added extractor for form field titles 52 | 53 | ## 2.0.0 54 | 55 | - Add support of Symfony ^5.0 56 | - Add strict type hinting 57 | - Added `PHPFileExtractor::supportsExtension(): bool` 58 | - Removed `PHPFileExtractor::getType()` 59 | - Remove support of Twig 1.x (`Twig2Visitor` and `TwigVisitorFactory`) 60 | - Remove support of PHP < 7.2 61 | - Remove support of Symfony < 3.4 62 | 63 | ## 1.7.1 64 | 65 | ### Fixed 66 | 67 | - Error when getting caller name with the `FlashMessage` extractor. 68 | 69 | ## 1.7.0 70 | 71 | ### Added 72 | 73 | - Support for `@translate` annotation. 74 | - Better handle `@ignore` annotation on FormTypeChoices 75 | 76 | ## 1.6.0 77 | 78 | ### Added 79 | 80 | - Support for Symfony form help. 81 | 82 | ### Fixed 83 | 84 | - Fixed issue where using the `@ignore` annotation ignored the wrong property. 85 | - Do not run the Twig worker if we are not extracting. 86 | 87 | ## 1.5.2 88 | 89 | ### Fixed 90 | 91 | - Fixed Fatal Error in FormTypeImplicit when using method call from variable 92 | 93 | ## 1.5.1 94 | 95 | ### Fixed 96 | 97 | - Fixed bug where form option key `attr` is not an array. 98 | 99 | ## 1.5.0 100 | 101 | ### Added 102 | 103 | - Support for `nikic/php-parser:^4` 104 | - Support for `array_*` callback in `SourceLocation::createHere` 105 | - Support for global 'translation_domain' in forms 106 | - Support for `@Ignore` annotation in $builder->add to prevent implicit label 107 | 108 | ### Changes 109 | 110 | - Make sure we do not extract implicit labels form HiddenType 111 | 112 | ### Fixed 113 | 114 | - Added missing `sprintf` in `ValidationAnnotaion` 115 | - Do not generate an error on "placeholder=>false" 116 | 117 | ## 1.4.0 118 | 119 | ### Added 120 | 121 | - Support for `translation_domain` and `choice_translation_domain` 122 | 123 | ### Fixed 124 | 125 | - Respect `"label" => false` 126 | - Form type extractors will only operate on form type classes. 127 | 128 | ## 1.3.1 129 | 130 | ### Added 131 | 132 | - Symfony 2.7 support 133 | 134 | ## 1.3.0 135 | 136 | ### Added 137 | 138 | - Support for passing choice as variable 139 | - Support for Symfony 4 140 | - Support for `desc` annotation and twig filter 141 | - Support for .phtml 142 | 143 | ## 1.2.0 144 | 145 | ### Added 146 | 147 | - Support for PHPUnit6 148 | - Extract translation from form's "empty_value" 149 | - Extract translation from Validation messages 150 | 151 | ### Changed 152 | 153 | - Added TwigVisitorFactory to create a TwigVisitor. TwigVisitor::create has been deprecated. 154 | 155 | ## 1.1.2 156 | 157 | ### Fixed 158 | 159 | - Do not stop visiting a file when not class is not *Type. 160 | 161 | ### Added 162 | 163 | - More test to prove correctness. 164 | 165 | ## 1.1.1 166 | 167 | ### Fixed 168 | 169 | - Make sure we test with the lowest version on Travis 170 | - Fixed minor bugs for Twig 1.x. 171 | 172 | ## 1.1.0 173 | 174 | ### Added 175 | 176 | - Support for Twig 2.0. 177 | - Support for reporting errors and silence errors with `@Ignore`. 178 | 179 | ### Deprecated 180 | 181 | - `Twig\TranslationBlock` and `Twig\TranslationFilter`. Use `Twig\Twig1Visitor` instead. 182 | 183 | ## 1.0.0 184 | 185 | ## Added 186 | 187 | - Extractor for classes implementing `TranslationSourceLocationContainer` 188 | - Made classes final 189 | 190 | ## 0.1.1 191 | 192 | ### Added 193 | 194 | - Blade file type extractor 195 | - Placeholder extractor 196 | 197 | ## 0.1.0 198 | 199 | Init release 200 | 201 | -------------------------------------------------------------------------------- /src/Visitor/Twig/Worker.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Visitor\Twig; 13 | 14 | use Symfony\Bridge\Twig\Node\TransNode; 15 | use Translation\Extractor\Model\Error; 16 | use Translation\Extractor\Model\SourceCollection; 17 | use Translation\Extractor\Model\SourceLocation; 18 | use Twig\Node\Expression\ConstantExpression; 19 | use Twig\Node\Expression\FilterExpression; 20 | use Twig\Node\Node; 21 | 22 | /** 23 | * The Worker that actually extract the translations. 24 | * 25 | * @author Tobias Nyholm 26 | * @author Fabien Potencier 27 | */ 28 | final class Worker 29 | { 30 | public const UNDEFINED_DOMAIN = 'messages'; 31 | 32 | private array $stack = []; 33 | 34 | public function work(Node $node, SourceCollection $collection, callable $getAbsoluteFilePath): Node 35 | { 36 | $this->stack[] = $node; 37 | if ($node instanceof FilterExpression && $node->getNode('node') instanceof ConstantExpression) { 38 | $domain = null; 39 | if ('trans' === $node->getAttribute('twig_callable')->getName()) { 40 | $domain = $this->getReadDomainFromArguments($node->getNode('arguments'), 1); 41 | } elseif ('transchoice' === $node->getAttribute('twig_callable')->getName()) { 42 | $domain = $this->getReadDomainFromArguments($node->getNode('arguments'), 2); 43 | } 44 | 45 | if ($domain) { 46 | try { 47 | $context = $this->extractContextFromJoinedFilters(); 48 | } catch (\LogicException $e) { 49 | $collection->addError(new Error($e->getMessage(), $getAbsoluteFilePath(), $node->getTemplateLine())); 50 | } 51 | $context['domain'] = $domain; 52 | $collection->addLocation( 53 | new SourceLocation( 54 | $node->getNode('node')->getAttribute('value'), 55 | $getAbsoluteFilePath(), 56 | $node->getTemplateLine(), 57 | $context 58 | ) 59 | ); 60 | } 61 | } elseif ($node instanceof TransNode) { 62 | // extract trans nodes 63 | $domain = self::UNDEFINED_DOMAIN; 64 | if ($node->hasNode('domain') && null !== $node->getNode('domain')) { 65 | $domain = $this->getReadDomainFromNode($node->getNode('domain')); 66 | } 67 | 68 | $collection->addLocation(new SourceLocation( 69 | $node->getNode('body')->getAttribute('data'), 70 | $getAbsoluteFilePath(), 71 | $node->getTemplateLine(), 72 | ['domain' => $domain] 73 | )); 74 | } 75 | 76 | return $node; 77 | } 78 | 79 | private function extractContextFromJoinedFilters(): array 80 | { 81 | $context = []; 82 | for ($i = \count($this->stack) - 2; $i >= 0; --$i) { 83 | if (!$this->stack[$i] instanceof FilterExpression) { 84 | break; 85 | } 86 | $name = $this->stack[$i]->getAttribute('twig_callable')->getName(); 87 | if ('trans' === $name) { 88 | break; 89 | } elseif ('desc' === $name) { 90 | $arguments = $this->stack[$i]->getNode('arguments'); 91 | if (!$arguments->hasNode(0)) { 92 | throw new \LogicException(\sprintf('The "%s" filter requires exactly one argument, the description text.', $name)); 93 | } 94 | $text = $arguments->getNode(0); 95 | if (!$text instanceof ConstantExpression) { 96 | throw new \LogicException(\sprintf('The first argument of the "%s" filter must be a constant expression, such as a string.', $name)); 97 | } 98 | $context['desc'] = $text->getAttribute('value'); 99 | } 100 | } 101 | 102 | return $context; 103 | } 104 | 105 | private function getReadDomainFromArguments(Node $arguments, int $index): ?string 106 | { 107 | if ($arguments->hasNode('domain')) { 108 | $argument = $arguments->getNode('domain'); 109 | } elseif ($arguments->hasNode($index)) { 110 | $argument = $arguments->getNode($index); 111 | } else { 112 | return self::UNDEFINED_DOMAIN; 113 | } 114 | 115 | return $this->getReadDomainFromNode($argument); 116 | } 117 | 118 | private function getReadDomainFromNode(Node $node): ?string 119 | { 120 | if ($node instanceof ConstantExpression) { 121 | return $node->getAttribute('value'); 122 | } 123 | 124 | return self::UNDEFINED_DOMAIN; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Visitor/Php/Symfony/Constraint.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Visitor\Php\Symfony; 13 | 14 | use PhpParser\Node; 15 | use PhpParser\NodeVisitor; 16 | use Translation\Extractor\Visitor\Php\BasePHPVisitor; 17 | 18 | /** 19 | * @author Luca Passini 20 | */ 21 | final class Constraint extends BasePHPVisitor implements NodeVisitor 22 | { 23 | public const VALIDATORS_DOMAIN = 'validators'; 24 | 25 | public const CONSTRAINT_CLASS_NAMES = [ 26 | 'AbstractComparison', 27 | 'All', 28 | 'Bic', 29 | 'Blank', 30 | 'Callback', 31 | 'CardScheme', 32 | 'Choice', 33 | 'Collection', 34 | 'Composite', 35 | 'Count', 36 | 'Country', 37 | 'Currency', 38 | 'Date', 39 | 'DateTime', 40 | 'DisableAutoMapping', 41 | 'DivisibleBy', 42 | 'Email', 43 | 'EnableAutoMapping', 44 | 'EqualTo', 45 | 'Existence', 46 | 'Expression', 47 | 'File', 48 | 'GreaterThan', 49 | 'GreaterThanOrEqual', 50 | 'GroupSequence', 51 | 'GroupSequenceProvider', 52 | 'Iban', 53 | 'IdenticalTo', 54 | 'Image', 55 | 'Ip', 56 | 'Isbn', 57 | 'IsFalse', 58 | 'IsNull', 59 | 'Issn', 60 | 'IsTrue', 61 | 'Json', 62 | 'Language', 63 | 'Length', 64 | 'LessThan', 65 | 'LessThanOrEqual', 66 | 'Locale', 67 | 'Luhn', 68 | 'Negative', 69 | 'NegativeOrZero', 70 | 'NotBlank', 71 | 'NotCompromisedPassword', 72 | 'NotEqualTo', 73 | 'NotIdenticalTo', 74 | 'NotNull', 75 | 'NumberConstraintTrait', 76 | 'Optional', 77 | 'Positive', 78 | 'PositiveOrZero', 79 | 'Range', 80 | 'Regex', 81 | 'Required', 82 | 'Time', 83 | 'Timezone', 84 | 'Traverse', 85 | 'Type', 86 | 'Unique', 87 | 'Url', 88 | 'Uuid', 89 | 'Valid', 90 | ]; 91 | 92 | public function beforeTraverse(array $nodes): ?Node 93 | { 94 | return null; 95 | } 96 | 97 | public function enterNode(Node $node): ?Node 98 | { 99 | if (!$node instanceof Node\Expr\New_) { 100 | return null; 101 | } 102 | 103 | $className = $node->class; 104 | if (!$className instanceof Node\Name) { 105 | return null; 106 | } 107 | 108 | $parts = $className->getParts(); 109 | $isConstraintClass = false; 110 | 111 | // we need to check every part since `Assert\NotBlank` would be split in 2 different pieces 112 | foreach ($parts as $part) { 113 | if (\in_array($part, self::CONSTRAINT_CLASS_NAMES)) { 114 | $isConstraintClass = true; 115 | 116 | break; 117 | } 118 | } 119 | 120 | // unsupported class 121 | if (!$isConstraintClass) { 122 | return null; 123 | } 124 | 125 | $args = $node->args; 126 | if (0 === \count($args)) { 127 | return null; 128 | } 129 | 130 | $arg = $args[0]; 131 | if (!$arg instanceof Node\Arg) { 132 | return null; 133 | } 134 | 135 | $options = $arg->value; 136 | if (!$options instanceof Node\Expr\Array_) { 137 | return null; 138 | } 139 | 140 | $message = null; 141 | $messageNode = null; 142 | 143 | foreach ($options->items as $item) { 144 | if (!$item->key instanceof Node\Scalar\String_) { 145 | continue; 146 | } 147 | 148 | // there could be false positives, but it should catch most of the useful properties 149 | // (e.g. `message`, `minMessage`) 150 | if (false === stripos($item->key->value, 'message')) { 151 | continue; 152 | } 153 | 154 | if (!$item->value instanceof Node\Scalar\String_) { 155 | $this->addError($item, 'Constraint message is not a scalar string'); 156 | 157 | continue; 158 | } 159 | 160 | $message = $item->value->value; 161 | $messageNode = $item; 162 | 163 | break; 164 | } 165 | 166 | if (!empty($message) && null !== $messageNode) { 167 | $this->addLocation($message, $messageNode->getAttribute('startLine'), $messageNode, [ 168 | 'domain' => self::VALIDATORS_DOMAIN, 169 | ]); 170 | } 171 | 172 | return null; 173 | } 174 | 175 | public function leaveNode(Node $node): ?Node 176 | { 177 | return null; 178 | } 179 | 180 | public function afterTraverse(array $nodes): ?Node 181 | { 182 | return null; 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/Visitor/Php/Symfony/FormTypeChoices.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Translation\Extractor\Visitor\Php\Symfony; 13 | 14 | use Doctrine\Common\Annotations\DocParser; 15 | use PhpParser\Node; 16 | use PhpParser\NodeVisitor; 17 | use Translation\Extractor\Annotation\Ignore; 18 | use Translation\Extractor\Model\SourceLocation; 19 | 20 | /** 21 | * @author Rein Baarsma 22 | */ 23 | final class FormTypeChoices extends AbstractFormType implements NodeVisitor 24 | { 25 | use FormTrait; 26 | 27 | /** 28 | * @var int defaults to major version 3 29 | */ 30 | protected int $symfonyMajorVersion = 3; 31 | 32 | private array $variables = []; 33 | 34 | private ?string $state = null; 35 | 36 | public function setSymfonyMajorVersion(int $sfMajorVersion): void 37 | { 38 | $this->symfonyMajorVersion = $sfMajorVersion; 39 | } 40 | 41 | public function enterNode(Node $node): ?Node 42 | { 43 | if (!$this->isFormType($node)) { 44 | return null; 45 | } 46 | 47 | parent::enterNode($node); 48 | 49 | if (null === $this->state && $node instanceof Node\Expr\Assign) { 50 | $this->state = 'variable'; 51 | } elseif ('variable' === $this->state && $node instanceof Node\Expr\Variable) { 52 | $this->variables['__variable-name'] = $node->name; 53 | $this->state = 'value'; 54 | } elseif ('value' === $this->state && $node instanceof Node\Expr\Array_) { 55 | $this->variables[$this->variables['__variable-name']] = $node; 56 | $this->state = null; 57 | } else { 58 | $this->state = null; 59 | } 60 | 61 | // symfony 3 or 4 displays key by default, where symfony 2 displays value 62 | $useKey = 2 !== $this->symfonyMajorVersion; 63 | 64 | // remember choices in this node 65 | $choicesNodes = []; 66 | 67 | // loop through array 68 | if (!$node instanceof Node\Expr\Array_) { 69 | return null; 70 | } 71 | 72 | $domain = null; 73 | foreach ($node->items as $item) { 74 | if (!$item->key instanceof Node\Scalar\String_) { 75 | continue; 76 | } 77 | 78 | if ('choices_as_values' === $item->key->value) { 79 | $useKey = true; 80 | 81 | continue; 82 | } 83 | 84 | if ('choice_translation_domain' === $item->key->value) { 85 | if ($item->value instanceof Node\Scalar\String_) { 86 | $domain = $item->value->value; 87 | } elseif ($item->value instanceof Node\Expr\ConstFetch && 'false' === $item->value->name->toString()) { 88 | $domain = false; 89 | } 90 | 91 | continue; 92 | } 93 | 94 | if ('choices' !== $item->key->value) { 95 | continue; 96 | } 97 | 98 | // do not parse choices if the @Ignore annotation is attached 99 | if ($this->isIgnored($item)) { 100 | continue; 101 | } 102 | 103 | $choicesNodes[] = $item->value; 104 | } 105 | 106 | if (0 === \count($choicesNodes) || false === $domain) { 107 | return null; 108 | } 109 | 110 | // probably will be only 1, but who knows 111 | foreach ($choicesNodes as $choices) { 112 | if ($choices instanceof Node\Expr\Variable && isset($this->variables[$choices->name])) { 113 | $choices = $this->variables[$choices->name]; 114 | } elseif (!$choices instanceof Node\Expr\Array_) { 115 | $this->addError($choices, 'Form choice is not an array'); 116 | 117 | continue; 118 | } 119 | 120 | foreach ($choices->items as $cItem) { 121 | $labelNode = $useKey ? $cItem->key : $cItem->value; 122 | if (!$labelNode instanceof Node\Scalar\String_) { 123 | $this->addError($cItem, 'Choice label is not a scalar string'); 124 | 125 | continue; 126 | } 127 | 128 | $this->lateCollect(new SourceLocation($labelNode->value, $this->getAbsoluteFilePath(), $choices->getAttribute('startLine'), ['domain' => $domain])); 129 | } 130 | } 131 | 132 | return null; 133 | } 134 | 135 | protected function isIgnored(Node $node): bool 136 | { 137 | // because of getDocParser method is private, we have to create a new custom instance 138 | $docParser = new DocParser(); 139 | $docParser->setImports([ 140 | 'ignore' => Ignore::class, 141 | ]); 142 | $docParser->setIgnoreNotImportedAnnotations(true); 143 | if (null !== $docComment = $node->getDocComment()) { 144 | foreach ($docParser->parse($docComment->getText()) as $annotation) { 145 | if ($annotation instanceof Ignore) { 146 | return true; 147 | } 148 | } 149 | } 150 | 151 | return false; 152 | } 153 | } 154 | --------------------------------------------------------------------------------