From 6f6ee6a5d29d50d30c1297272415d4ab70491aab Mon Sep 17 00:00:00 2001 From: Daniel Mason Date: Thu, 23 Nov 2023 01:14:46 +0000 Subject: [PATCH] Adds command to find missing translations (#10) * Adds command to find missing translations * wip --- README.md | 46 ++++++--- config/translation-linter.php | 44 +++++++++ src/Collections/ApplicationFileCollection.php | 26 ++++++ src/Collections/Concerns/CollectsFields.php | 21 +++++ src/Collections/Concerns/CollectsFilters.php | 26 ++++++ src/Collections/MissingFieldCollection.php | 12 +++ src/Collections/MissingFilterCollection.php | 12 +++ src/Collections/UnusedFieldCollection.php | 15 +-- src/Collections/UnusedFilterCollection.php | 20 +--- src/Commands/MissingCommand.php | 60 ++++++++++++ .../Collections/ApplicationFileCollection.php | 18 ++++ .../Collections/MissingFieldCollection.php | 5 + .../Collections/MissingFilterCollection.php | 5 + .../Linters/MissingTranslationLinter.php | 5 + src/Contracts/Linters/TranslationLinter.php | 10 ++ .../Linters/UnusedTranslationLinter.php | 7 +- .../Parsers/ApplicationFileParser.php | 4 +- .../Readers/ApplicationFileReader.php | 4 +- src/Contracts/Readers/BaselineFileReader.php | 10 ++ .../Readers/MissingBaselineFileReader.php | 5 + .../Readers/UnusedBaselineFileReader.php | 7 +- src/Contracts/Writers/BaselineFileWriter.php | 10 ++ .../Writers/MissingBaselineFileWriter.php | 5 + .../Writers/UnusedBaselineFileWriter.php | 7 +- src/Data/ApplicationFileObject.php | 15 +++ src/Data/ResultObject.php | 6 +- ...gnoreKeysFromMissingBaselineFileFilter.php | 19 ++++ ...aravelTranslationLinterServiceProvider.php | 48 ++++++++++ src/Linters/MissingTranslationLinter.php | 45 +++++++++ src/Linters/UnusedTranslationLinter.php | 2 +- src/Parsers/ApplicationFileParser.php | 29 ++++-- src/Readers/ApplicationFileReader.php | 14 +-- src/Readers/Concerns/ReadsBaselineFile.php | 25 +++++ src/Readers/MissingBaselineFileReader.php | 17 ++++ src/Readers/UnusedBaselineFileReader.php | 19 +--- src/Writers/Concerns/WritesBaselineFile.php | 17 ++++ src/Writers/MissingBaselineFileWriter.php | 17 ++++ src/Writers/UnusedBaselineFileWriter.php | 13 +-- .../it_errors_with_default_config.snap | 28 ++++++ .../it_errors_with_default_no_fields.snap | 3 + .../it_errors_with_different_fields.snap | 28 ++++++ .../it_errors_with_multiple_locales.snap | 10 ++ .../it_errors_with_paths_argument.snap | 14 +++ ...en_successfully_ignores_baseline_keys.snap | 3 + ...successfully_ignores_baseline_keys__2.snap | 55 +++++++++++ ...successfully_ignores_baseline_keys__3.snap | 3 + ...ge_when_no_missing_translations_found.snap | 3 + tests/Commands/MissingCommandTest.php | 93 +++++++++++++++++++ workbench/app/Example.php | 7 +- workbench/app/ExampleJson.php | 3 + workbench/app/ExampleMissing.php | 16 ++++ workbench/app/ExampleMissingOther.php | 17 ++++ .../Providers/WorkbenchServiceProvider.php | 1 + workbench/lang/de.json | 1 + workbench/resources/js/MissingComponent.vue | 22 +++++ ...ampleComponent.vue => UnusedComponent.vue} | 0 workbench/resources/views/missing.blade.php | 21 +++++ .../{welcome.blade.php => unused.blade.php} | 0 58 files changed, 886 insertions(+), 112 deletions(-) create mode 100644 src/Collections/ApplicationFileCollection.php create mode 100644 src/Collections/Concerns/CollectsFields.php create mode 100644 src/Collections/Concerns/CollectsFilters.php create mode 100644 src/Collections/MissingFieldCollection.php create mode 100644 src/Collections/MissingFilterCollection.php create mode 100644 src/Commands/MissingCommand.php create mode 100644 src/Contracts/Collections/ApplicationFileCollection.php create mode 100644 src/Contracts/Collections/MissingFieldCollection.php create mode 100644 src/Contracts/Collections/MissingFilterCollection.php create mode 100644 src/Contracts/Linters/MissingTranslationLinter.php create mode 100644 src/Contracts/Linters/TranslationLinter.php create mode 100644 src/Contracts/Readers/BaselineFileReader.php create mode 100644 src/Contracts/Readers/MissingBaselineFileReader.php create mode 100644 src/Contracts/Writers/BaselineFileWriter.php create mode 100644 src/Contracts/Writers/MissingBaselineFileWriter.php create mode 100644 src/Data/ApplicationFileObject.php create mode 100644 src/Filters/IgnoreKeysFromMissingBaselineFileFilter.php create mode 100644 src/Linters/MissingTranslationLinter.php create mode 100644 src/Readers/Concerns/ReadsBaselineFile.php create mode 100644 src/Readers/MissingBaselineFileReader.php create mode 100644 src/Writers/Concerns/WritesBaselineFile.php create mode 100644 src/Writers/MissingBaselineFileWriter.php create mode 100644 tests/.pest/snapshots/Commands/MissingCommandTest/it_errors_with_default_config.snap create mode 100644 tests/.pest/snapshots/Commands/MissingCommandTest/it_errors_with_default_no_fields.snap create mode 100644 tests/.pest/snapshots/Commands/MissingCommandTest/it_errors_with_different_fields.snap create mode 100644 tests/.pest/snapshots/Commands/MissingCommandTest/it_errors_with_multiple_locales.snap create mode 100644 tests/.pest/snapshots/Commands/MissingCommandTest/it_errors_with_paths_argument.snap create mode 100644 tests/.pest/snapshots/Commands/MissingCommandTest/it_generates_baseline_file_then_successfully_ignores_baseline_keys.snap create mode 100644 tests/.pest/snapshots/Commands/MissingCommandTest/it_generates_baseline_file_then_successfully_ignores_baseline_keys__2.snap create mode 100644 tests/.pest/snapshots/Commands/MissingCommandTest/it_generates_baseline_file_then_successfully_ignores_baseline_keys__3.snap create mode 100644 tests/.pest/snapshots/Commands/MissingCommandTest/it_outputs_success_message_when_no_missing_translations_found.snap create mode 100644 tests/Commands/MissingCommandTest.php create mode 100644 workbench/app/ExampleMissing.php create mode 100644 workbench/app/ExampleMissingOther.php create mode 100644 workbench/resources/js/MissingComponent.vue rename workbench/resources/js/{ExampleComponent.vue => UnusedComponent.vue} (100%) create mode 100644 workbench/resources/views/missing.blade.php rename workbench/resources/views/{welcome.blade.php => unused.blade.php} (100%) diff --git a/README.md b/README.md index e588023..acd6bb3 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,38 @@ php artisan vendor:publish --tag="translation-linter-config" You should read through the config, which serves as additional documentation and make changes as needed. +## Missing Command +This reads through all your code and finds all your language function usage. +Then attempts to find matches in your language files and will output any +keys in your code that do not exist as a language key. + +```sh +$ php artisan translation:missing + + ERROR 3 missing translations found. + ++--------+--------------------------------+---------------------+ +| Locale | Key | File | ++--------+--------------------------------+---------------------+ +| en | Missing PHP Class | app/ExampleJson.php | +| en | Only Missing English PHP Class | app/ExampleJson.php | +| de | Missing PHP Class | app/ExampleJson.php | ++--------+--------------------------------+---------------------+ +``` + +You can generate a baseline file which will be used to ignore specific keys with the +`--generate-baseline` or `-b` command options: + +```sh +$ php artisan translation:missing --generate-baseline + + INFO Baseline file written with 49 translation keys. + +$ php artisan translation:missing + + INFO No missing translations found! +``` + ## Unused Command This reads through all your code and finds all your language function usage. Then attempts to find matches in your language files and will output any @@ -65,20 +97,6 @@ $ php artisan translation:unused INFO No unused translations found! ``` -## Roadmap -- [x] Supports JSON and PHP translation files - - You can enable / disable file types in the config - - You can add your own custom file readers -- [x] Supports multiple locales -- [x] Supports parsing many code types - - Default: php, js and vue - - You can add more file extensions in the config -- [x] [Unused Command](#unused-command) -- [ ] Missing Command - _coming soon_ -- [ ] Orphaned Command - _coming soon_ -- [ ] Lint Command - _coming soon_ - - This would run all of the other commands in a single command. - ## Testing ```bash diff --git a/config/translation-linter.php b/config/translation-linter.php index 6e2049c..b7203b8 100644 --- a/config/translation-linter.php +++ b/config/translation-linter.php @@ -88,6 +88,50 @@ ], ], + 'missing' => [ + /* + |-------------------------------------------------------------------------- + | Baseline File + |-------------------------------------------------------------------------- + | + | This is the location of the baseline file that is used to ignore specific + | translation keys. You can generate this file by using the `--generate-baseline` + | option when running the command. You should commit this file. + | + */ + 'baseline' => lang_path('.lint/missing.json'), + + /* + |-------------------------------------------------------------------------- + | Output Fields + |-------------------------------------------------------------------------- + | + | The following array lists the "fields" that are displayed by the command + | when missing translations are found. Set any of these to `false` to hide + | them from the output or change all to `false` to not show anything. + | + */ + 'fields' => [ + 'locale' => true, + 'key' => true, + 'file' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Missing Language Filters + |-------------------------------------------------------------------------- + | + | The following array lists the "filters" that will be used to filter out + | erroneously detected missing translations. + | + | All filters must implement the filter interface or they will be skipped: + | \Fidum\LaravelTranslationLinter\Contracts\Filter + | + */ + 'filters' => [], + ], + 'unused' => [ /* |-------------------------------------------------------------------------- diff --git a/src/Collections/ApplicationFileCollection.php b/src/Collections/ApplicationFileCollection.php new file mode 100644 index 0000000..4487faa --- /dev/null +++ b/src/Collections/ApplicationFileCollection.php @@ -0,0 +1,26 @@ +some(function (ApplicationFileObject $object) use ($key) { + return $object->namespaceHintedKey === $key; + }); + } + + public function doesntContainKey(string $key): bool + { + return ! $this->containsKey($key); + } +} diff --git a/src/Collections/Concerns/CollectsFields.php b/src/Collections/Concerns/CollectsFields.php new file mode 100644 index 0000000..2da7900 --- /dev/null +++ b/src/Collections/Concerns/CollectsFields.php @@ -0,0 +1,21 @@ +filter()->keys(); + } + + public function headers(): array + { + return $this->enabled() + ->map(fn ($v) => Str::headline($v)) + ->toArray(); + } +} diff --git a/src/Collections/Concerns/CollectsFilters.php b/src/Collections/Concerns/CollectsFilters.php new file mode 100644 index 0000000..2ce0b2a --- /dev/null +++ b/src/Collections/Concerns/CollectsFilters.php @@ -0,0 +1,26 @@ +every(function (string $filterClass) use ($object) { + $interface = Filter::class; + + if (is_subclass_of($filterClass, $interface)) { + /** @var Filter $filter */ + $filter = app($filterClass); + + return $filter->shouldReport($object); + } + + throw new InvalidArgumentException("Filter [$filterClass] needs to implement [$interface]."); + }); + } +} diff --git a/src/Collections/MissingFieldCollection.php b/src/Collections/MissingFieldCollection.php new file mode 100644 index 0000000..29f5ace --- /dev/null +++ b/src/Collections/MissingFieldCollection.php @@ -0,0 +1,12 @@ +filter()->keys(); - } - - public function headers(): array - { - return $this->enabled() - ->map(fn ($v) => Str::headline($v)) - ->toArray(); - } + use CollectsFields; } diff --git a/src/Collections/UnusedFilterCollection.php b/src/Collections/UnusedFilterCollection.php index f3fde61..db8fb67 100644 --- a/src/Collections/UnusedFilterCollection.php +++ b/src/Collections/UnusedFilterCollection.php @@ -2,27 +2,11 @@ namespace Fidum\LaravelTranslationLinter\Collections; +use Fidum\LaravelTranslationLinter\Collections\Concerns\CollectsFilters; use Fidum\LaravelTranslationLinter\Contracts\Collections\UnusedFilterCollection as UnusedFilterCollectionContract; -use Fidum\LaravelTranslationLinter\Contracts\Filters\Filter; -use Fidum\LaravelTranslationLinter\Data\ResultObject; -use http\Exception\InvalidArgumentException; use Illuminate\Support\Collection; class UnusedFilterCollection extends Collection implements UnusedFilterCollectionContract { - public function shouldReport(ResultObject $object): bool - { - return $this->every(function (string $filterClass) use ($object) { - $interface = Filter::class; - - if (is_subclass_of($filterClass, $interface)) { - /** @var Filter $filter */ - $filter = app($filterClass); - - return $filter->shouldReport($object); - } - - throw new InvalidArgumentException("Filter [$filterClass] needs to implement [$interface]."); - }); - } + use CollectsFilters; } diff --git a/src/Commands/MissingCommand.php b/src/Commands/MissingCommand.php new file mode 100644 index 0000000..54d90b9 --- /dev/null +++ b/src/Commands/MissingCommand.php @@ -0,0 +1,60 @@ +option('generate-baseline'); + $results = $linter->execute(); + + if ($baseline) { + $results = $results->whereShouldReport($filters); + + $writer->execute($results); + + $this->components->info("Baseline file written with {$results->count()} translation keys."); + + return self::SUCCESS; + } + + $filters->push(IgnoreKeysFromMissingBaselineFileFilter::class); + + $results = $results + ->when($this->argument('paths'), function (ResultObjectCollection $items, array $files) { + return $items->filter(fn (ResultObject $object) => in_array($object->file->getPathname(), $files)); + }) + ->whereShouldReport($filters); + + if ($results->isEmpty()) { + $this->components->info('No missing translations found!'); + + return self::SUCCESS; + } + + $this->components->error(sprintf('%d missing translations found', $results->count())); + $this->table($fields->headers(), $results->toCommandTableOutputArray($fields)); + + return self::FAILURE; + } +} diff --git a/src/Contracts/Collections/ApplicationFileCollection.php b/src/Contracts/Collections/ApplicationFileCollection.php new file mode 100644 index 0000000..2bdaa0e --- /dev/null +++ b/src/Contracts/Collections/ApplicationFileCollection.php @@ -0,0 +1,18 @@ + $this->locale, 'key' => $this->namespaceHintedKey, 'value' => $this->value, + 'file' => str($this->file->getPathname()) + ->replace(base_path(), '') + ->ltrim(DIRECTORY_SEPARATOR) + ->toString(), ]; } } diff --git a/src/Filters/IgnoreKeysFromMissingBaselineFileFilter.php b/src/Filters/IgnoreKeysFromMissingBaselineFileFilter.php new file mode 100644 index 0000000..733644e --- /dev/null +++ b/src/Filters/IgnoreKeysFromMissingBaselineFileFilter.php @@ -0,0 +1,19 @@ +reader + ->execute() + ->shouldReport($object->locale, $object->namespaceHintedKey); + } +} diff --git a/src/LaravelTranslationLinterServiceProvider.php b/src/LaravelTranslationLinterServiceProvider.php index 95faaf4..d527638 100644 --- a/src/LaravelTranslationLinterServiceProvider.php +++ b/src/LaravelTranslationLinterServiceProvider.php @@ -2,10 +2,17 @@ namespace Fidum\LaravelTranslationLinter; +use Fidum\LaravelTranslationLinter\Collections\ApplicationFileCollection; +use Fidum\LaravelTranslationLinter\Collections\MissingFieldCollection; +use Fidum\LaravelTranslationLinter\Collections\MissingFilterCollection; use Fidum\LaravelTranslationLinter\Collections\ResultObjectCollection; use Fidum\LaravelTranslationLinter\Collections\UnusedFieldCollection; use Fidum\LaravelTranslationLinter\Collections\UnusedFilterCollection; +use Fidum\LaravelTranslationLinter\Commands\MissingCommand; use Fidum\LaravelTranslationLinter\Commands\UnusedCommand; +use Fidum\LaravelTranslationLinter\Contracts\Collections\ApplicationFileCollection as ApplicationFileCollectionContract; +use Fidum\LaravelTranslationLinter\Contracts\Collections\MissingFieldCollection as MissingFieldCollectionContract; +use Fidum\LaravelTranslationLinter\Contracts\Collections\MissingFilterCollection as MissingFilterCollectionContract; use Fidum\LaravelTranslationLinter\Contracts\Collections\ResultObjectCollection as ResultObjectCollectionContract; use Fidum\LaravelTranslationLinter\Contracts\Collections\UnusedFieldCollection as UnusedFieldCollectionContract; use Fidum\LaravelTranslationLinter\Contracts\Collections\UnusedFilterCollection as UnusedFilterCollectionContract; @@ -14,23 +21,29 @@ use Fidum\LaravelTranslationLinter\Contracts\Finders\ApplicationFileFinder as ApplicationFileFinderContract; use Fidum\LaravelTranslationLinter\Contracts\Finders\LanguageFileFinder as LanguageFileFinderContract; use Fidum\LaravelTranslationLinter\Contracts\Finders\LanguageNamespaceFinder as LanguageNamespaceFinderContract; +use Fidum\LaravelTranslationLinter\Contracts\Linters\MissingTranslationLinter as MissingTranslationLinterContract; use Fidum\LaravelTranslationLinter\Contracts\Linters\UnusedTranslationLinter as UnusedTranslationLinterContract; use Fidum\LaravelTranslationLinter\Contracts\Parsers\ApplicationFileParser as ApplicationFileParserContract; use Fidum\LaravelTranslationLinter\Contracts\Readers\ApplicationFileReader as ApplicationFileReaderContract; use Fidum\LaravelTranslationLinter\Contracts\Readers\LanguageFileReader as LanguageFileReaderContract; +use Fidum\LaravelTranslationLinter\Contracts\Readers\MissingBaselineFileReader as MissingBaselineFileReaderContract; use Fidum\LaravelTranslationLinter\Contracts\Readers\UnusedBaselineFileReader as UnusedBaselineFileReaderContract; +use Fidum\LaravelTranslationLinter\Contracts\Writers\MissingBaselineFileWriter as MissingBaselineFileWriterContract; use Fidum\LaravelTranslationLinter\Contracts\Writers\UnusedBaselineFileWriter as UnusedBaselineFileWriterContract; use Fidum\LaravelTranslationLinter\Factories\LanguageKeyFactory; use Fidum\LaravelTranslationLinter\Factories\LanguageNamespaceKeyFactory; use Fidum\LaravelTranslationLinter\Finders\ApplicationFileFinder; use Fidum\LaravelTranslationLinter\Finders\LanguageFileFinder; use Fidum\LaravelTranslationLinter\Finders\LanguageNamespaceFinder; +use Fidum\LaravelTranslationLinter\Linters\MissingTranslationLinter; use Fidum\LaravelTranslationLinter\Linters\UnusedTranslationLinter; use Fidum\LaravelTranslationLinter\Managers\LanguageFileReaderManager; use Fidum\LaravelTranslationLinter\Parsers\ApplicationFileParser; use Fidum\LaravelTranslationLinter\Readers\ApplicationFileReader; use Fidum\LaravelTranslationLinter\Readers\LanguageFileReader; +use Fidum\LaravelTranslationLinter\Readers\MissingBaselineFileReader; use Fidum\LaravelTranslationLinter\Readers\UnusedBaselineFileReader; +use Fidum\LaravelTranslationLinter\Writers\MissingBaselineFileWriter; use Fidum\LaravelTranslationLinter\Writers\UnusedBaselineFileWriter; use Illuminate\Contracts\Support\DeferrableProvider; use Illuminate\Foundation\Application; @@ -44,11 +57,14 @@ public function configurePackage(Package $package): void $package ->name('laravel-translation-linter') ->hasConfigFile() + ->hasCommand(MissingCommand::class) ->hasCommand(UnusedCommand::class); } public function registeringPackage() { + $this->app->bind(ApplicationFileCollectionContract::class, ApplicationFileCollection::class); + $this->app->bind(ApplicationFileFinderContract::class, ApplicationFileFinder::class); $this->app->when(ApplicationFileFinder::class) @@ -86,6 +102,32 @@ public function registeringPackage() $this->app->bind(LanguageNamespaceKeyFactoryContract::class, LanguageNamespaceKeyFactory::class); + $this->app->scoped(MissingBaselineFileReaderContract::class, MissingBaselineFileReader::class); + + $this->app->when(MissingBaselineFileReader::class) + ->needs('$file') + ->giveConfig('translation-linter.missing.baseline'); + + $this->app->bind(MissingBaselineFileWriterContract::class, MissingBaselineFileWriter::class); + + $this->app->when(MissingBaselineFileWriter::class) + ->needs('$file') + ->giveConfig('translation-linter.missing.baseline'); + + $this->app->bind(MissingFieldCollectionContract::class, function (Application $app) { + return MissingFieldCollection::wrap($app->make('config')->get('translation-linter.missing.fields')); + }); + + $this->app->bind(MissingFilterCollectionContract::class, function (Application $app) { + return MissingFilterCollection::wrap($app->make('config')->get('translation-linter.missing.filters')); + }); + + $this->app->bind(MissingTranslationLinterContract::class, MissingTranslationLinter::class); + + $this->app->when(MissingTranslationLinter::class) + ->needs('$locales') + ->giveConfig('translation-linter.lang.locales'); + $this->app->bind(ResultObjectCollectionContract::class, ResultObjectCollection::class); $this->app->scoped(UnusedBaselineFileReaderContract::class, UnusedBaselineFileReader::class); @@ -118,6 +160,7 @@ public function registeringPackage() public function provides() { return [ + ApplicationFileCollectionContract::class, ApplicationFileFinderContract::class, ApplicationFileParserContract::class, ApplicationFileReaderContract::class, @@ -127,6 +170,11 @@ public function provides() LanguageKeyFactoryContract::class, LanguageNamespaceFinderContract::class, LanguageNamespaceKeyFactoryContract::class, + MissingBaselineFileReaderContract::class, + MissingBaselineFileWriterContract::class, + MissingFieldCollectionContract::class, + MissingFilterCollectionContract::class, + MissingTranslationLinterContract::class, ResultObjectCollectionContract::class, UnusedBaselineFileReaderContract::class, UnusedBaselineFileWriterContract::class, diff --git a/src/Linters/MissingTranslationLinter.php b/src/Linters/MissingTranslationLinter.php new file mode 100644 index 0000000..2875f34 --- /dev/null +++ b/src/Linters/MissingTranslationLinter.php @@ -0,0 +1,45 @@ +results->reset(); + $used = $this->used->execute(); + + foreach ($this->locales as $locale) { + /** @var ApplicationFileObject $object */ + foreach ($used as $object) { + if ($this->translator->hasForLocale($object->namespaceHintedKey, $locale)) { + continue; + } + + $this->results->push(new ResultObject( + file: $object->file, + key: $object->key, + locale: $locale, + namespaceHint: $object->namespaceHint, + namespaceHintedKey: $object->namespaceHintedKey, + )); + } + } + + return $this->results; + } +} diff --git a/src/Linters/UnusedTranslationLinter.php b/src/Linters/UnusedTranslationLinter.php index b53d2d4..40872b0 100644 --- a/src/Linters/UnusedTranslationLinter.php +++ b/src/Linters/UnusedTranslationLinter.php @@ -62,7 +62,7 @@ public function execute(): ResultObjectCollection key: $groupedKey ); - if ($used->doesntContain($namespacedKey)) { + if ($used->doesntContainKey($namespacedKey)) { $this->results->push(new ResultObject( file: $file, key: $groupedKey, diff --git a/src/Parsers/ApplicationFileParser.php b/src/Parsers/ApplicationFileParser.php index 316b059..9ebd0ee 100644 --- a/src/Parsers/ApplicationFileParser.php +++ b/src/Parsers/ApplicationFileParser.php @@ -2,8 +2,10 @@ namespace Fidum\LaravelTranslationLinter\Parsers; +use Fidum\LaravelTranslationLinter\Contracts\Collections\ApplicationFileCollection as ApplicationFileCollectionContract; use Fidum\LaravelTranslationLinter\Contracts\Parsers\ApplicationFileParser as ApplicationFileParserContract; -use Illuminate\Support\Collection; +use Fidum\LaravelTranslationLinter\Data\ApplicationFileObject; +use Illuminate\Support\Str; use Symfony\Component\Finder\SplFileInfo; readonly class ApplicationFileParser implements ApplicationFileParserContract @@ -12,29 +14,38 @@ protected string $pattern; - public function __construct(array $functions) - { + public function __construct( + protected ApplicationFileCollectionContract $collection, + array $functions + ) { $this->pattern = str_replace('[FUNCTIONS]', implode('|', $functions), static::REGEX); } - public function execute(SplFileInfo $file): Collection + public function execute(SplFileInfo $file): ApplicationFileCollectionContract { - $strings = new Collection(); - $data = $file->getContents(); if (! preg_match_all($this->pattern, $data, $matches, PREG_OFFSET_CAPTURE)) { // If pattern not found return - return $strings; + return $this->collection; } foreach (current($matches) as $match) { preg_match($this->pattern, $match[0], $string); - $strings->push($string[2]); + $namespaceHintedKey = $string[2]; + + $this->collection->push(new ApplicationFileObject( + file: $file, + key: Str::after($namespaceHintedKey, '::') ?: null, + namespaceHint: Str::before($namespaceHintedKey, '::') ?: null, + namespaceHintedKey: $namespaceHintedKey, + )); } // Remove duplicates. - return $strings->unique(); + return $this->collection->unique(function (ApplicationFileObject $object) { + return $object->namespaceHintedKey; + }); } } diff --git a/src/Readers/ApplicationFileReader.php b/src/Readers/ApplicationFileReader.php index c5301ab..039a2d2 100644 --- a/src/Readers/ApplicationFileReader.php +++ b/src/Readers/ApplicationFileReader.php @@ -2,30 +2,32 @@ namespace Fidum\LaravelTranslationLinter\Readers; +use Fidum\LaravelTranslationLinter\Contracts\Collections\ApplicationFileCollection as ApplicationFileCollectionContract; use Fidum\LaravelTranslationLinter\Contracts\Finders\ApplicationFileFinder; use Fidum\LaravelTranslationLinter\Contracts\Parsers\ApplicationFileParser; use Fidum\LaravelTranslationLinter\Contracts\Readers\ApplicationFileReader as ApplicationFileReaderContract; -use Illuminate\Support\Collection; +use Fidum\LaravelTranslationLinter\Data\ApplicationFileObject; class ApplicationFileReader implements ApplicationFileReaderContract { public function __construct( + protected ApplicationFileCollectionContract $collection, protected ApplicationFileFinder $finder, protected ApplicationFileParser $parser, ) {} - public function execute(): Collection + public function execute(): ApplicationFileCollectionContract { - $strings = new Collection(); - // List files $files = $this->finder->execute(); // Get all translatable strings from files foreach ($files as $file) { - $strings = $strings->merge($this->parser->execute($file)); + $this->collection->push(...$this->parser->execute($file)); } - return $strings->unique(); + return $this->collection->unique(function (ApplicationFileObject $object) { + return $object->namespaceHintedKey.$object->file->getPathname(); + }); } } diff --git a/src/Readers/Concerns/ReadsBaselineFile.php b/src/Readers/Concerns/ReadsBaselineFile.php new file mode 100644 index 0000000..39e7847 --- /dev/null +++ b/src/Readers/Concerns/ReadsBaselineFile.php @@ -0,0 +1,25 @@ +decoded) { + return BaselineCollection::wrap($this->decoded); + } + + if ($this->filesystem->exists($this->file)) { + $contents = $this->filesystem->get($this->file); + + $this->decoded = json_decode($contents, true); + } + + return BaselineCollection::wrap($this->decoded); + } +} diff --git a/src/Readers/MissingBaselineFileReader.php b/src/Readers/MissingBaselineFileReader.php new file mode 100644 index 0000000..1773055 --- /dev/null +++ b/src/Readers/MissingBaselineFileReader.php @@ -0,0 +1,17 @@ +decoded) { - return BaselineCollection::wrap($this->decoded); - } - - if ($this->filesystem->exists($this->file)) { - $contents = $this->filesystem->get($this->file); - - $this->decoded = json_decode($contents, true); - } - - return BaselineCollection::wrap($this->decoded); - } } diff --git a/src/Writers/Concerns/WritesBaselineFile.php b/src/Writers/Concerns/WritesBaselineFile.php new file mode 100644 index 0000000..0592c46 --- /dev/null +++ b/src/Writers/Concerns/WritesBaselineFile.php @@ -0,0 +1,17 @@ +filesystem->dirname($this->file); + + $this->filesystem->ensureDirectoryExists($path); + + $this->filesystem->put($this->file, $results->toBaselineJson()); + } +} diff --git a/src/Writers/MissingBaselineFileWriter.php b/src/Writers/MissingBaselineFileWriter.php new file mode 100644 index 0000000..fee86e7 --- /dev/null +++ b/src/Writers/MissingBaselineFileWriter.php @@ -0,0 +1,17 @@ +filesystem->dirname($this->file); - - $this->filesystem->ensureDirectoryExists($path); - - $this->filesystem->put($this->file, $results->toBaselineJson()); - } } diff --git a/tests/.pest/snapshots/Commands/MissingCommandTest/it_errors_with_default_config.snap b/tests/.pest/snapshots/Commands/MissingCommandTest/it_errors_with_default_config.snap new file mode 100644 index 0000000..6ac3a1b --- /dev/null +++ b/tests/.pest/snapshots/Commands/MissingCommandTest/it_errors_with_default_config.snap @@ -0,0 +1,28 @@ + + ERROR 21 missing translations found. + ++--------+----------------------------------------------+-----------------------------------+ +| Locale | Key | File | ++--------+----------------------------------------------+-----------------------------------+ +| en | example.missing | app/Example.php | +| en | Missing PHP Class | app/ExampleJson.php | +| en | Only Missing English PHP Class | app/ExampleJson.php | +| en | example::folder/example.missing | app/ExampleMissing.php | +| en | folder/example.missing | app/ExampleMissingOther.php | +| en | example::example.missing | app/ExampleMissingOther.php | +| en | example.vue.missing | resources/js/MissingComponent.vue | +| en | folder/example.vue.missing | resources/js/MissingComponent.vue | +| en | example::example.vue.missing | resources/js/MissingComponent.vue | +| en | example::folder/example.vue.missing | resources/js/MissingComponent.vue | +| en | Missing Vue Component | resources/js/MissingComponent.vue | +| en | Missing Vendor Vue Component | resources/js/MissingComponent.vue | +| en | example.blade.lang.missing | resources/views/missing.blade.php | +| en | folder/example.blade.lang.missing | resources/views/missing.blade.php | +| en | example::example.blade.lang.missing | resources/views/missing.blade.php | +| en | example::folder/example.blade.lang.missing | resources/views/missing.blade.php | +| en | Missing Blade File | resources/views/missing.blade.php | +| en | example.blade.choice.missing | resources/views/missing.blade.php | +| en | folder/example.blade.choice.missing | resources/views/missing.blade.php | +| en | example::example.blade.choice.missing | resources/views/missing.blade.php | +| en | example::folder/example.blade.choice.missing | resources/views/missing.blade.php | ++--------+----------------------------------------------+-----------------------------------+ diff --git a/tests/.pest/snapshots/Commands/MissingCommandTest/it_errors_with_default_no_fields.snap b/tests/.pest/snapshots/Commands/MissingCommandTest/it_errors_with_default_no_fields.snap new file mode 100644 index 0000000..87afbe2 --- /dev/null +++ b/tests/.pest/snapshots/Commands/MissingCommandTest/it_errors_with_default_no_fields.snap @@ -0,0 +1,3 @@ + + ERROR 21 missing translations found. + diff --git a/tests/.pest/snapshots/Commands/MissingCommandTest/it_errors_with_different_fields.snap b/tests/.pest/snapshots/Commands/MissingCommandTest/it_errors_with_different_fields.snap new file mode 100644 index 0000000..cc9316e --- /dev/null +++ b/tests/.pest/snapshots/Commands/MissingCommandTest/it_errors_with_different_fields.snap @@ -0,0 +1,28 @@ + + ERROR 21 missing translations found. + ++----------------------------------------------+-----------------------------------+ +| Key | File | ++----------------------------------------------+-----------------------------------+ +| example.missing | app/Example.php | +| Missing PHP Class | app/ExampleJson.php | +| Only Missing English PHP Class | app/ExampleJson.php | +| example::folder/example.missing | app/ExampleMissing.php | +| folder/example.missing | app/ExampleMissingOther.php | +| example::example.missing | app/ExampleMissingOther.php | +| example.vue.missing | resources/js/MissingComponent.vue | +| folder/example.vue.missing | resources/js/MissingComponent.vue | +| example::example.vue.missing | resources/js/MissingComponent.vue | +| example::folder/example.vue.missing | resources/js/MissingComponent.vue | +| Missing Vue Component | resources/js/MissingComponent.vue | +| Missing Vendor Vue Component | resources/js/MissingComponent.vue | +| example.blade.lang.missing | resources/views/missing.blade.php | +| folder/example.blade.lang.missing | resources/views/missing.blade.php | +| example::example.blade.lang.missing | resources/views/missing.blade.php | +| example::folder/example.blade.lang.missing | resources/views/missing.blade.php | +| Missing Blade File | resources/views/missing.blade.php | +| example.blade.choice.missing | resources/views/missing.blade.php | +| folder/example.blade.choice.missing | resources/views/missing.blade.php | +| example::example.blade.choice.missing | resources/views/missing.blade.php | +| example::folder/example.blade.choice.missing | resources/views/missing.blade.php | ++----------------------------------------------+-----------------------------------+ diff --git a/tests/.pest/snapshots/Commands/MissingCommandTest/it_errors_with_multiple_locales.snap b/tests/.pest/snapshots/Commands/MissingCommandTest/it_errors_with_multiple_locales.snap new file mode 100644 index 0000000..53c6105 --- /dev/null +++ b/tests/.pest/snapshots/Commands/MissingCommandTest/it_errors_with_multiple_locales.snap @@ -0,0 +1,10 @@ + + ERROR 3 missing translations found. + ++--------+--------------------------------+---------------------+ +| Locale | Key | File | ++--------+--------------------------------+---------------------+ +| en | Missing PHP Class | app/ExampleJson.php | +| en | Only Missing English PHP Class | app/ExampleJson.php | +| de | Missing PHP Class | app/ExampleJson.php | ++--------+--------------------------------+---------------------+ diff --git a/tests/.pest/snapshots/Commands/MissingCommandTest/it_errors_with_paths_argument.snap b/tests/.pest/snapshots/Commands/MissingCommandTest/it_errors_with_paths_argument.snap new file mode 100644 index 0000000..1a1674c --- /dev/null +++ b/tests/.pest/snapshots/Commands/MissingCommandTest/it_errors_with_paths_argument.snap @@ -0,0 +1,14 @@ + + ERROR 7 missing translations found. + ++--------+-------------------------------------+-----------------------------------+ +| Locale | Key | File | ++--------+-------------------------------------+-----------------------------------+ +| en | example.missing | app/Example.php | +| en | example.vue.missing | resources/js/MissingComponent.vue | +| en | folder/example.vue.missing | resources/js/MissingComponent.vue | +| en | example::example.vue.missing | resources/js/MissingComponent.vue | +| en | example::folder/example.vue.missing | resources/js/MissingComponent.vue | +| en | Missing Vue Component | resources/js/MissingComponent.vue | +| en | Missing Vendor Vue Component | resources/js/MissingComponent.vue | ++--------+-------------------------------------+-----------------------------------+ diff --git a/tests/.pest/snapshots/Commands/MissingCommandTest/it_generates_baseline_file_then_successfully_ignores_baseline_keys.snap b/tests/.pest/snapshots/Commands/MissingCommandTest/it_generates_baseline_file_then_successfully_ignores_baseline_keys.snap new file mode 100644 index 0000000..2e91978 --- /dev/null +++ b/tests/.pest/snapshots/Commands/MissingCommandTest/it_generates_baseline_file_then_successfully_ignores_baseline_keys.snap @@ -0,0 +1,3 @@ + + INFO Baseline file written with 49 translation keys. + diff --git a/tests/.pest/snapshots/Commands/MissingCommandTest/it_generates_baseline_file_then_successfully_ignores_baseline_keys__2.snap b/tests/.pest/snapshots/Commands/MissingCommandTest/it_generates_baseline_file_then_successfully_ignores_baseline_keys__2.snap new file mode 100644 index 0000000..01b0cef --- /dev/null +++ b/tests/.pest/snapshots/Commands/MissingCommandTest/it_generates_baseline_file_then_successfully_ignores_baseline_keys__2.snap @@ -0,0 +1,55 @@ +{ + "en": [ + "example.missing", + "Missing PHP Class", + "Only Missing English PHP Class", + "example::folder/example.missing", + "folder/example.missing", + "example::example.missing", + "example.vue.missing", + "folder/example.vue.missing", + "example::example.vue.missing", + "example::folder/example.vue.missing", + "Missing Vue Component", + "Missing Vendor Vue Component", + "example.blade.lang.missing", + "folder/example.blade.lang.missing", + "example::example.blade.lang.missing", + "example::folder/example.blade.lang.missing", + "Missing Blade File", + "example.blade.choice.missing", + "folder/example.blade.choice.missing", + "example::example.blade.choice.missing", + "example::folder/example.blade.choice.missing" + ], + "de": [ + "example::example.used", + "example::folder/example.used", + "example.missing", + "Missing PHP Class", + "example::folder/example.missing", + "folder/example.missing", + "example::example.missing", + "example.vue.missing", + "folder/example.vue.missing", + "example::example.vue.missing", + "example::folder/example.vue.missing", + "Missing Vue Component", + "Missing Vendor Vue Component", + "example::example.vue.used", + "example::folder/example.vue.used", + "example.blade.lang.missing", + "folder/example.blade.lang.missing", + "example::example.blade.lang.missing", + "example::folder/example.blade.lang.missing", + "Missing Blade File", + "example.blade.choice.missing", + "folder/example.blade.choice.missing", + "example::example.blade.choice.missing", + "example::folder/example.blade.choice.missing", + "example::example.blade.lang.used", + "example::folder/example.blade.lang.used", + "example::example.blade.choice.used", + "example::folder/example.blade.choice.used" + ] +} \ No newline at end of file diff --git a/tests/.pest/snapshots/Commands/MissingCommandTest/it_generates_baseline_file_then_successfully_ignores_baseline_keys__3.snap b/tests/.pest/snapshots/Commands/MissingCommandTest/it_generates_baseline_file_then_successfully_ignores_baseline_keys__3.snap new file mode 100644 index 0000000..46a9a10 --- /dev/null +++ b/tests/.pest/snapshots/Commands/MissingCommandTest/it_generates_baseline_file_then_successfully_ignores_baseline_keys__3.snap @@ -0,0 +1,3 @@ + + INFO No missing translations found! + diff --git a/tests/.pest/snapshots/Commands/MissingCommandTest/it_outputs_success_message_when_no_missing_translations_found.snap b/tests/.pest/snapshots/Commands/MissingCommandTest/it_outputs_success_message_when_no_missing_translations_found.snap new file mode 100644 index 0000000..46a9a10 --- /dev/null +++ b/tests/.pest/snapshots/Commands/MissingCommandTest/it_outputs_success_message_when_no_missing_translations_found.snap @@ -0,0 +1,3 @@ + + INFO No missing translations found! + diff --git a/tests/Commands/MissingCommandTest.php b/tests/Commands/MissingCommandTest.php new file mode 100644 index 0000000..499c4bf --- /dev/null +++ b/tests/Commands/MissingCommandTest.php @@ -0,0 +1,93 @@ +toBe(1) + ->and(Artisan::output()) + ->toMatchSnapshot(); +}); + +it('errors with paths argument', function () { + config()->set('translation-linter.missing.filters', []); + $firstFile = workbench_path('app/Example.php'); + $secondFile = resource_path('js/MissingComponent.vue'); + + withoutMockingConsoleOutput(); + expect(artisan("translation:missing \"$firstFile\" \"$secondFile\" \"/this/does/not/exist\"")) + ->toBe(1) + ->and(Artisan::output()) + ->toMatchSnapshot(); +}); + +it('errors with different fields', function () { + config()->set('translation-linter.missing.fields.locale', false); + + withoutMockingConsoleOutput(); + expect(artisan('translation:missing')) + ->toBe(1) + ->and(Artisan::output()) + ->toMatchSnapshot(); +}); + +it('errors with default no fields', function () { + config()->set('translation-linter.missing.fields.locale', false); + config()->set('translation-linter.missing.fields.key', false); + config()->set('translation-linter.missing.fields.file', false); + + withoutMockingConsoleOutput(); + expect(artisan('translation:missing')) + // ->toBe(1) + ->and(Artisan::output()) + ->toMatchSnapshot(); +}); + +it('errors with multiple locales', function () { + config()->set('translation-linter.lang.locales', ['en', 'de']); + $firstFile = workbench_path('app/ExampleJson.php'); + + withoutMockingConsoleOutput(); + expect(artisan("translation:missing \"$firstFile\"")) + ->toBe(1) + ->and(Artisan::output()) + ->toMatchSnapshot(); +}); + +it('generates baseline file then successfully ignores baseline keys', function () { + config()->set('translation-linter.lang.locales', ['en', 'de']); + + withoutMockingConsoleOutput(); + expect(artisan('translation:missing --generate-baseline')) + ->toBe(0) + ->and(Artisan::output()) + ->toMatchSnapshot(); + + expect($file = config('translation-linter.missing.baseline')) + ->toBeReadableFile() + ->and(file_get_contents($file)) + ->toMatchSnapshot(); + + expect(artisan('translation:missing')) + ->toBe(0) + ->and(Artisan::output()) + ->toMatchSnapshot(); +}); + +it('outputs success message when no missing translations found', function () { + config()->set('translation-linter.lang.locales', []); + withoutMockingConsoleOutput(); + expect(artisan('translation:missing')) + ->toBe(0) + ->and(Artisan::output()) + ->toMatchSnapshot(); +}); diff --git a/workbench/app/Example.php b/workbench/app/Example.php index dfc23dd..6cd471d 100644 --- a/workbench/app/Example.php +++ b/workbench/app/Example.php @@ -7,7 +7,7 @@ class Example { - public function handle(Validator $validator) + public function unused() { $example = __('example.used'); @@ -20,4 +20,9 @@ public function handle(Validator $validator) 'example::folder/example.used' )); } + + public function missing() + { + $example = __('example.missing'); + } } diff --git a/workbench/app/ExampleJson.php b/workbench/app/ExampleJson.php index 0d90c1a..b8505a5 100644 --- a/workbench/app/ExampleJson.php +++ b/workbench/app/ExampleJson.php @@ -10,5 +10,8 @@ public function handle(Validator $validator) { __('Used PHP Class'); __("Used Vendor PHP Class"); + + __('Missing PHP Class'); + __('Only Missing English PHP Class'); } } diff --git a/workbench/app/ExampleMissing.php b/workbench/app/ExampleMissing.php new file mode 100644 index 0000000..482087c --- /dev/null +++ b/workbench/app/ExampleMissing.php @@ -0,0 +1,16 @@ +when(fn () => Lang::get( + 'example::folder/example.missing' + )); + } +} diff --git a/workbench/app/ExampleMissingOther.php b/workbench/app/ExampleMissingOther.php new file mode 100644 index 0000000..846005a --- /dev/null +++ b/workbench/app/ExampleMissingOther.php @@ -0,0 +1,17 @@ +loadTranslationsFrom(workbench_path('/vendor/example/lang'), 'example'); + $this->loadJsonTranslationsFrom(workbench_path('/vendor/example/lang')); } } diff --git a/workbench/lang/de.json b/workbench/lang/de.json index 9a664b8..08daa39 100644 --- a/workbench/lang/de.json +++ b/workbench/lang/de.json @@ -1,6 +1,7 @@ { "Used PHP Class": "Ich werde in einer PHP-Klasse verwendet", "Unused PHP Class": "Ich werde in einer PHP-Klasse nicht verwendet", + "Only Missing English PHP Class": "-", "Used Blade File": "Ich werde in Blade verwendet", "Unused Blade File": "Ich werde in Blade nicht verwendet", "Used Vue Component": "Ich werde in einem Vue-Komponenten verwendet", diff --git a/workbench/resources/js/MissingComponent.vue b/workbench/resources/js/MissingComponent.vue new file mode 100644 index 0000000..051ef01 --- /dev/null +++ b/workbench/resources/js/MissingComponent.vue @@ -0,0 +1,22 @@ + + + diff --git a/workbench/resources/js/ExampleComponent.vue b/workbench/resources/js/UnusedComponent.vue similarity index 100% rename from workbench/resources/js/ExampleComponent.vue rename to workbench/resources/js/UnusedComponent.vue diff --git a/workbench/resources/views/missing.blade.php b/workbench/resources/views/missing.blade.php new file mode 100644 index 0000000..e569b90 --- /dev/null +++ b/workbench/resources/views/missing.blade.php @@ -0,0 +1,21 @@ +@lang('example.blade.lang.missing', ['foo' => 'bar']) +@lang( + 'folder/example.blade.lang.missing', + ['foo' => 'bar'], +) +@lang('example::example.blade.lang.missing') +@lang( + "example::folder/example.blade.lang.missing" +) + +{{ __('Missing Blade File') }} + +@if(true) + @choice('example.blade.choice.missing', 1) + @choice('folder/example.blade.choice.missing', 1) + @choice('example::example.blade.choice.missing', 1) + @choice( + "example::folder/example.blade.choice.missing", + 1, + ) +@endif diff --git a/workbench/resources/views/welcome.blade.php b/workbench/resources/views/unused.blade.php similarity index 100% rename from workbench/resources/views/welcome.blade.php rename to workbench/resources/views/unused.blade.php