diff --git a/.gitignore b/.gitignore index a7f372d..ea1aea1 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,5 @@ docs phpunit.xml phpstan.neon testbench.yaml -vendor +/vendor node_modules diff --git a/README.md b/README.md index 545c311..e401baf 100644 --- a/README.md +++ b/README.md @@ -6,21 +6,12 @@ [![Total Downloads](https://img.shields.io/packagist/dt/fidum/laravel-translation-linter.svg?style=for-the-badge)](https://packagist.org/packages/fidum/laravel-translation-linter) [![Twitter Follow](https://img.shields.io/badge/follow-%40danmasonmp-1DA1F2?logo=twitter&style=for-the-badge)](https://twitter.com/danmasonmp) -This is where your description should go. Limit it to a paragraph or two. Consider adding a small example. - ## Installation You can install the package via composer: ```bash -composer require fidum/laravel-translation-linter -``` - -You can publish and run the migrations with: - -```bash -php artisan vendor:publish --tag="laravel-translation-linter-migrations" -php artisan migrate +composer require --dev fidum/laravel-translation-linter ``` You can publish the config file with: @@ -36,12 +27,6 @@ return [ ]; ``` -Optionally, you can publish the views using - -```bash -php artisan vendor:publish --tag="laravel-translation-linter-views" -``` - ## Usage ```php diff --git a/composer.json b/composer.json index ebfea74..bb2f4a0 100644 --- a/composer.json +++ b/composer.json @@ -34,8 +34,7 @@ }, "autoload": { "psr-4": { - "Fidum\\LaravelTranslationLinter\\": "src/", - "Fidum\\LaravelTranslationLinter\\Database\\Factories\\": "database/factories/" + "Fidum\\LaravelTranslationLinter\\": "src/" } }, "autoload-dev": { @@ -45,22 +44,14 @@ } }, "scripts": { - "post-autoload-dump": "@composer run prepare", - "clear": "@php vendor/bin/testbench package:purge-laravel-translation-linter --ansi", - "prepare": "@php vendor/bin/testbench package:discover --ansi", - "build": [ - "@composer run prepare", - "@php vendor/bin/testbench workbench:build --ansi" - ], - "start": [ - "Composer\\Config::disableProcessTimeout", - "@composer run build", - "@php vendor/bin/testbench serve" - ], "analyse": "vendor/bin/phpstan analyse", "test": "vendor/bin/pest", "test-coverage": "vendor/bin/pest --coverage", - "format": "vendor/bin/pint" + "format": "vendor/bin/pint", + "lint": [ + "@php vendor/bin/pint", + "@php vendor/bin/phpstan analyse" + ] }, "config": { "sort-packages": true, @@ -81,4 +72,4 @@ }, "minimum-stability": "dev", "prefer-stable": true -} \ No newline at end of file +} diff --git a/config/translation-linter.php b/config/translation-linter.php index d500221..c6101f3 100644 --- a/config/translation-linter.php +++ b/config/translation-linter.php @@ -1,6 +1,121 @@ [ + /* + |-------------------------------------------------------------------------- + | Code Directories + |-------------------------------------------------------------------------- + | + | The following array lists the "directories" that will be scanned + | for translations. The defaults below should cover most uses + | but if you need to add more make sure they are absolute paths. + | + */ + 'directories' => [ + app_path(), + resource_path(), + ], + /* + |-------------------------------------------------------------------------- + | Code Extensions + |-------------------------------------------------------------------------- + | + | The following array lists the file "extensions" that will be scanned for + | translations. Make sure that all files where you use translations are + | included here. + | + */ + 'extensions' => [ + 'php', + 'js', + 'vue', + ], + ], + + 'lang' => [ + /* + |-------------------------------------------------------------------------- + | Language Functions + |-------------------------------------------------------------------------- + | + | The following array lists the translation "functions" that will be used + | to find translation usage throughout your code. This is used in the + | regex pattern below to detect translations. + | + */ + 'functions' => [ + '__', + '_t', + '@lang', + '@choice', + 'trans', + 'trans_choice', + 'Lang::choice', + 'Lang::get', + 'Lang::has', + ], + + /* + |-------------------------------------------------------------------------- + | Language Function Regex Pattern + |-------------------------------------------------------------------------- + | + | The following contains the regex pattern used to find the functions + | configured above. The '[FUNCTIONS]' part will be replaced with a + | pipe delimited list of the functions defined above. + | + */ + 'regex' => '/([FUNCTIONS])\([\t\r\n\s]*[\'"](.+)[\'"][\),\t\r\n\s]/U', + + /* + |-------------------------------------------------------------------------- + | Language Locales + |-------------------------------------------------------------------------- + | + | The following array contains the language 'locales' to use. + | + */ + 'locales' => [env('LOCALE_DEFAULT', 'en')], + ], + + 'unused' => [ + /* + |-------------------------------------------------------------------------- + | Output Fields + |-------------------------------------------------------------------------- + | + | The following array lists the "fields" that are displayed by the command + | when unused 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' => [ + 'lang' => true, + 'namespace' => true, + 'key' => true, + 'value' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Unused Language Filters + |-------------------------------------------------------------------------- + | + | The following array lists the "filters" that will be used to filter out + | erroneously detected unused translations. For example, you may want to + | ignore laravel or vendor translations. + | + | All filters must implement the filter interface or they will be skipped: + | \Fidum\LaravelTranslationLinter\Contracts\Filter + | + | We have included some filters with this package which may be of use. + | + */ + 'filters' => [ + \Fidum\LaravelTranslationLinter\Filters\DefaultLanguageFilesFilter::class, + \Fidum\LaravelTranslationLinter\Filters\IgnoreNamespacedKeysFilter::class, + ], + ], ]; diff --git a/database/factories/ModelFactory.php b/database/factories/ModelFactory.php deleted file mode 100644 index b149112..0000000 --- a/database/factories/ModelFactory.php +++ /dev/null @@ -1,19 +0,0 @@ -id(); - - // add fields - - $table->timestamps(); - }); - } -}; diff --git a/phpstan.neon.dist b/phpstan.neon.dist index a91953b..e005ac7 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -6,7 +6,6 @@ parameters: paths: - src - config - - database tmpDir: build/phpstan checkOctaneCompatibility: true checkModelProperties: true diff --git a/resources/views/.gitkeep b/resources/views/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/Collections/UnusedFieldCollection.php b/src/Collections/UnusedFieldCollection.php new file mode 100644 index 0000000..1aac8dc --- /dev/null +++ b/src/Collections/UnusedFieldCollection.php @@ -0,0 +1,22 @@ +filter()->keys(); + } + + public function headers(): array + { + return $this->enabled() + ->map(fn ($v) => Str::headline($v)) + ->toArray(); + } +} diff --git a/src/Collections/UnusedFilterCollection.php b/src/Collections/UnusedFilterCollection.php new file mode 100644 index 0000000..febdaec --- /dev/null +++ b/src/Collections/UnusedFilterCollection.php @@ -0,0 +1,27 @@ +every(function (string $filterClass) use ($lang, $namespace, $key, $value) { + $interface = Filter::class; + + if (is_subclass_of($filterClass, $interface)) { + /** @var Filter $filter */ + $filter = app($filterClass); + + return $filter->shouldReport($lang, $namespace, $key, $value); + } + + throw new InvalidArgumentException("Filter [$filterClass] needs to implement [$interface]."); + }); + } +} diff --git a/src/Collections/UnusedResultCollection.php b/src/Collections/UnusedResultCollection.php new file mode 100644 index 0000000..4325195 --- /dev/null +++ b/src/Collections/UnusedResultCollection.php @@ -0,0 +1,26 @@ +push([ + 'lang' => $lang, + 'namespace' => $namespace, + 'key' => $key, + 'value' => $value, + ]); + } + + public function toCommandTableOutputArray(UnusedFieldCollectionContract $fields): array + { + return Arr::only($this->items, $fields->enabled()->toArray()); + } +} diff --git a/src/Commands/LaravelTranslationLinterCommand.php b/src/Commands/LaravelTranslationLinterCommand.php deleted file mode 100644 index a8b9087..0000000 --- a/src/Commands/LaravelTranslationLinterCommand.php +++ /dev/null @@ -1,19 +0,0 @@ -comment('All done'); - - return self::SUCCESS; - } -} diff --git a/src/Commands/UnusedCommand.php b/src/Commands/UnusedCommand.php new file mode 100644 index 0000000..a7349a4 --- /dev/null +++ b/src/Commands/UnusedCommand.php @@ -0,0 +1,44 @@ +execute() as $lang => $namespaces) { + foreach ($namespaces as $namespace => $translations) { + foreach ($translations as $key => $value) { + if ($filters->shouldReport($lang, $namespace, $key, $value)) { + $results->addUnusedLanguageKey($lang, $namespace, $key, $value); + } + } + } + } + + if ($results->isEmpty()) { + $this->comment('No unused translations found!'); + + return self::SUCCESS; + } + + $this->error(sprintf('%d unused translations found', $results->count())); + $this->table($fields->headers(), $results->toCommandTableOutputArray($fields)); + + return self::FAILURE; + } +} diff --git a/src/Contracts/Collections/UnusedFieldCollection.php b/src/Contracts/Collections/UnusedFieldCollection.php new file mode 100644 index 0000000..0188942 --- /dev/null +++ b/src/Contracts/Collections/UnusedFieldCollection.php @@ -0,0 +1,13 @@ +finder->execute(); + + // Get all translatable strings from files + foreach ($files as $file) { + $strings = $strings->merge($this->parser->execute($file)); + } + + return $strings->unique(); + } +} diff --git a/src/Facades/LaravelTranslationLinter.php b/src/Facades/LaravelTranslationLinter.php deleted file mode 100644 index bf05a16..0000000 --- a/src/Facades/LaravelTranslationLinter.php +++ /dev/null @@ -1,16 +0,0 @@ -directories as $directory) { + $files = $files->merge($this->filesystem->allFiles($directory)); + } + + return $files->filter(function (\SplFileInfo $file) { + return in_array($file->getExtension(), $this->extensions); + }); + } +} diff --git a/src/Finders/LanguageFileFinder.php b/src/Finders/LanguageFileFinder.php new file mode 100644 index 0000000..580e44f --- /dev/null +++ b/src/Finders/LanguageFileFinder.php @@ -0,0 +1,21 @@ +filesystem->allFiles($path)); + + return $files->filter(fn (\SplFileInfo $file) => in_array($file->getExtension(), $extensions)); + } +} diff --git a/src/Finders/LanguageNamespaceFinder.php b/src/Finders/LanguageNamespaceFinder.php new file mode 100644 index 0000000..5a0a773 --- /dev/null +++ b/src/Finders/LanguageNamespaceFinder.php @@ -0,0 +1,34 @@ +translator->getLoader(); + + foreach ($loader->namespaces() as $hint => $path) { + $namespacesCollection->put($hint, $path); + } + + // Add default namespace + $namespacesCollection->put('', app()->langPath()); + + // Return namespaces collection after removing non existing paths + return $namespacesCollection->filter(function ($path) { + return file_exists($path) ? $path : false; + }); + } +} diff --git a/src/LaravelTranslationLinter.php b/src/LaravelTranslationLinter.php deleted file mode 100755 index 230b142..0000000 --- a/src/LaravelTranslationLinter.php +++ /dev/null @@ -1,7 +0,0 @@ -name('laravel-translation-linter') ->hasConfigFile() - ->hasViews() - ->hasMigration('create_laravel-translation-linter_table') - ->hasCommand(LaravelTranslationLinterCommand::class); + ->hasCommand(UnusedCommand::class); + } + + public function registeringPackage() + { + $this->app->bind(ApplicationFileFinderContract::class, function (Application $app) { + return new ApplicationFileFinder( + $app->make('files'), + $app->make('config')->get('translation-linter.application.directories'), + $app->make('config')->get('translation-linter.application.extensions'), + ); + }); + + $this->app->bind(ExtractorContract::class, Extractor::class); + $this->app->bind(LanguageFileFinderContract::class, LanguageFileFinder::class); + $this->app->bind(LanguageNamespaceFinderContract::class, LanguageNamespaceFinder::class); + + $this->app->bind(ParserContract::class, function (Application $app) { + $regex = $app->make('config')->get('translation-linter.lang.regex'); + $functions = $app->make('config')->get('translation-linter.lang.functions'); + + return new Parser(str_replace('[FUNCTIONS]', implode('|', $functions), $regex)); + }); + + $this->app->bind(UnusedFieldCollectionContract::class, function (Application $app) { + return UnusedFieldCollection::wrap($app->make('config')->get('translation-linter.unused.fields')); + }); + + $this->app->bind(UnusedFilterCollectionContract::class, function (Application $app) { + return UnusedFilterCollection::wrap($app->make('config')->get('translation-linter.unused.filters')); + }); + + $this->app->bind(UnusedResultCollectionContract::class, UnusedResultCollection::class); + $this->app->bind(UnusedTranslationLinterContract::class, UnusedTranslationLinter::class); + } + + public function provides() + { + return [ + ApplicationFileFinderContract::class, + ExtractorContract::class, + LanguageFileFinderContract::class, + LanguageNamespaceFinderContract::class, + ParserContract::class, + UnusedFieldCollectionContract::class, + UnusedFilterCollectionContract::class, + UnusedResultCollectionContract::class, + UnusedTranslationLinterContract::class, + ]; } } diff --git a/src/Linters/UnusedTranslationLinter.php b/src/Linters/UnusedTranslationLinter.php new file mode 100644 index 0000000..03932b0 --- /dev/null +++ b/src/Linters/UnusedTranslationLinter.php @@ -0,0 +1,98 @@ +extractor->execute()->toArray(); + $registeredNamespaces = $this->namespaces->execute(); + + foreach ($this->languages as $language) { + $unusedStrings[$language] = []; + + foreach ($registeredNamespaces as $namespace => $path) { + $unusedStrings[$language][$namespace] = []; + + // TODO: Support json files + $files = $this->finder->execute($path, ['php']); + + /** @var SplFileInfo $file */ + foreach ($files as $file) { + $translations = $this->getTranslationsFromFile($file); + + foreach ($translations as $field => $value) { + $group = $this->getLanguageKey($file, $language, $field); + foreach (Arr::dot(Arr::wrap($value)) as $key => $val) { + $groupedKey = Str::of($group) + ->when(is_string($key), fn (Stringable $str) => $str->append(".$key")) + ->toString(); + + $namespacedKey = Str::of($namespace) + ->whenNotEmpty(fn (Stringable $str) => $str->append('::')) + ->append($groupedKey) + ->toString(); + + if (! in_array($namespacedKey, $usedStrings)) { + $unusedStrings[$language][$namespace][$groupedKey] = $val; + } + } + } + } + } + } + + return new Collection($unusedStrings); + } + + protected function getTranslationsFromFile(SplFileInfo $file): array + { + $translations = include $file->getPathname(); + + if ($file->getExtension() === 'json') { + $translations = json_decode($translations, true); + } + + if (! is_array($translations)) { + throw new InvalidArgumentException("Unable to extract an array from {$file->getPathname()}!"); + } + + return $translations; + } + + protected function getLanguageKey(SplFileInfo $file, string $language, string $key): string + { + if ($file->getExtension() === 'json') { + return $key; + } + + return Str::of($file->getPath()) + ->finish('/') + ->after("/$language/") + ->append($file->getFilenameWithoutExtension()) + ->append('.') + ->append($key) + ->toString(); + } +} diff --git a/src/Parsers/Parser.php b/src/Parsers/Parser.php new file mode 100644 index 0000000..d252735 --- /dev/null +++ b/src/Parsers/Parser.php @@ -0,0 +1,35 @@ +getContents(); + + if (! preg_match_all($this->pattern, $data, $matches, PREG_OFFSET_CAPTURE)) { + // If pattern not found return + return $strings; + } + + foreach (current($matches) as $match) { + preg_match($this->pattern, $match[0], $string); + + $strings->push($string[2]); + } + + // Remove duplicates. + return $strings->unique(); + } +} diff --git a/testbench.yml b/testbench.yml new file mode 100644 index 0000000..6e9b79b --- /dev/null +++ b/testbench.yml @@ -0,0 +1 @@ +laravel: ./workbench diff --git a/tests/.pest/snapshots/Commands/UnusedCommandTest/it_can_test_with_default_filters.snap b/tests/.pest/snapshots/Commands/UnusedCommandTest/it_can_test_with_default_filters.snap new file mode 100644 index 0000000..f243539 --- /dev/null +++ b/tests/.pest/snapshots/Commands/UnusedCommandTest/it_can_test_with_default_filters.snap @@ -0,0 +1,4 @@ +6 unused translations found ++------+-----------+-----+-------+ +| Lang | Namespace | Key | Value | ++------+-----------+-----+-------+ diff --git a/tests/.pest/snapshots/Commands/UnusedCommandTest/it_can_test_with_default_no_fields.snap b/tests/.pest/snapshots/Commands/UnusedCommandTest/it_can_test_with_default_no_fields.snap new file mode 100644 index 0000000..c51bfbd --- /dev/null +++ b/tests/.pest/snapshots/Commands/UnusedCommandTest/it_can_test_with_default_no_fields.snap @@ -0,0 +1 @@ +6 unused translations found diff --git a/tests/.pest/snapshots/Commands/UnusedCommandTest/it_can_test_with_default_no_filters.snap b/tests/.pest/snapshots/Commands/UnusedCommandTest/it_can_test_with_default_no_filters.snap new file mode 100644 index 0000000..e5a5bb8 --- /dev/null +++ b/tests/.pest/snapshots/Commands/UnusedCommandTest/it_can_test_with_default_no_filters.snap @@ -0,0 +1,4 @@ +12 unused translations found ++------+-----------+-----+-------+ +| Lang | Namespace | Key | Value | ++------+-----------+-----+-------+ diff --git a/tests/.pest/snapshots/Commands/UnusedCommandTest/it_can_test_with_default_restricted_fields.snap b/tests/.pest/snapshots/Commands/UnusedCommandTest/it_can_test_with_default_restricted_fields.snap new file mode 100644 index 0000000..e22adfe --- /dev/null +++ b/tests/.pest/snapshots/Commands/UnusedCommandTest/it_can_test_with_default_restricted_fields.snap @@ -0,0 +1,4 @@ +6 unused translations found ++-----------+-----+ +| Namespace | Key | ++-----------+-----+ diff --git a/tests/Commands/UnusedCommandTest.php b/tests/Commands/UnusedCommandTest.php new file mode 100644 index 0000000..b501e71 --- /dev/null +++ b/tests/Commands/UnusedCommandTest.php @@ -0,0 +1,43 @@ +toMatchSnapshot(); +}); + +it('can test with default no filters', function () { + config()->set('translation-linter.unused.filters', []); + withoutMockingConsoleOutput(); + artisan('translation:unused'); + + expect(Artisan::output())->toMatchSnapshot(); +}); + +it('can test with default restricted fields', function () { + config()->set('translation-linter.unused.fields.lang', false); + config()->set('translation-linter.unused.fields.value', false); + + withoutMockingConsoleOutput(); + artisan('translation:unused'); + + expect(Artisan::output())->toMatchSnapshot(); +}); + +it('can test with default no fields', function () { + config()->set('translation-linter.unused.fields.lang', false); + config()->set('translation-linter.unused.fields.namespace', false); + config()->set('translation-linter.unused.fields.key', false); + config()->set('translation-linter.unused.fields.value', false); + + withoutMockingConsoleOutput(); + artisan('translation:unused'); + + expect(Artisan::output())->toMatchSnapshot(); +}); diff --git a/tests/ExampleTest.php b/tests/ExampleTest.php deleted file mode 100644 index 5d36321..0000000 --- a/tests/ExampleTest.php +++ /dev/null @@ -1,5 +0,0 @@ -toBeTrue(); -}); diff --git a/tests/TestCase.php b/tests/TestCase.php index 4903d8a..c62d904 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -3,34 +3,23 @@ namespace Fidum\LaravelTranslationLinter\Tests; use Fidum\LaravelTranslationLinter\LaravelTranslationLinterServiceProvider; -use Illuminate\Database\Eloquent\Factories\Factory; use Orchestra\Testbench\TestCase as Orchestra; +use Workbench\App\Providers\WorkbenchServiceProvider; + +use function Orchestra\Testbench\workbench_path; class TestCase extends Orchestra { - protected function setUp(): void - { - parent::setUp(); - - Factory::guessFactoryNamesUsing( - fn (string $modelName) => 'Fidum\\LaravelTranslationLinter\\Database\\Factories\\'.class_basename($modelName).'Factory' - ); - } - protected function getPackageProviders($app) { return [ LaravelTranslationLinterServiceProvider::class, + WorkbenchServiceProvider::class, ]; } - public function getEnvironmentSetUp($app) + protected function getEnvironmentSetUp($app) { - config()->set('database.default', 'testing'); - - /* - $migration = include __DIR__.'/../database/migrations/create_laravel-translation-linter_table.php.stub'; - $migration->up(); - */ + $app->setBasePath(workbench_path()); } } diff --git a/workbench/app/Example.php b/workbench/app/Example.php new file mode 100644 index 0000000..e2fcdcc --- /dev/null +++ b/workbench/app/Example.php @@ -0,0 +1,18 @@ +loadTranslationsFrom(workbench_path('/vendor/example/lang'), 'example'); } } diff --git a/workbench/lang/en/example.php b/workbench/lang/en/example.php new file mode 100644 index 0000000..b9159bf --- /dev/null +++ b/workbench/lang/en/example.php @@ -0,0 +1,17 @@ + 'I am used', + 'unused' => 'I am unused', + + 'blade' => [ + 'choice' => [ + 'used' => 'I am used', + 'unused' => 'I am unused', + ], + 'lang' => [ + 'used' => 'I am used', + 'unused' => 'I am unused', + ], + ], +]; diff --git a/workbench/lang/en/folder/example.php b/workbench/lang/en/folder/example.php new file mode 100644 index 0000000..b9159bf --- /dev/null +++ b/workbench/lang/en/folder/example.php @@ -0,0 +1,17 @@ + 'I am used', + 'unused' => 'I am unused', + + 'blade' => [ + 'choice' => [ + 'used' => 'I am used', + 'unused' => 'I am unused', + ], + 'lang' => [ + 'used' => 'I am used', + 'unused' => 'I am unused', + ], + ], +]; diff --git a/workbench/resources/views/welcome.blade.php b/workbench/resources/views/welcome.blade.php new file mode 100644 index 0000000..d3bfef6 --- /dev/null +++ b/workbench/resources/views/welcome.blade.php @@ -0,0 +1,11 @@ +@lang('example.blade.lang.used') +@lang('folder/example.blade.lang.used') +@lang('example::example.blade.lang.used') +@lang('example::folder/example.blade.lang.used') + +@if(true) + @choice('example.blade.choice.used', 1) + @choice('folder/example.blade.choice.used', 1) + @choice('example::example.blade.choice.used', 1) + @choice('example::folder/example.blade.choice.used', 1) +@endif diff --git a/workbench/vendor/example/lang/en/example.php b/workbench/vendor/example/lang/en/example.php new file mode 100644 index 0000000..4d5c41d --- /dev/null +++ b/workbench/vendor/example/lang/en/example.php @@ -0,0 +1,17 @@ + 'I am used in vendor', + 'unused' => 'I am unused in vendor', + + 'blade' => [ + 'choice' => [ + 'used' => 'I am used in vendor', + 'unused' => 'I am unused in vendor', + ], + 'lang' => [ + 'used' => 'I am used in vendor', + 'unused' => 'I am unused in vendor', + ], + ] +]; diff --git a/workbench/vendor/example/lang/en/folder/example.php b/workbench/vendor/example/lang/en/folder/example.php new file mode 100644 index 0000000..6b4e2e9 --- /dev/null +++ b/workbench/vendor/example/lang/en/folder/example.php @@ -0,0 +1,17 @@ + 'I am used', + 'unused' => 'I am unused', + + 'blade' => [ + 'choice' => [ + 'used' => 'I am used', + 'unused' => 'I am unused', + ], + 'lang' => [ + 'used' => 'I am used', + 'unused' => 'I am unused', + ], + ] +];