diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c844a2d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml,json}] +indent_size = 2 + +[composer.json] +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e6712eb --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore all test and documentation with "export-ignore". +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml.dist export-ignore +/tests export-ignore +/workbench export-ignore +/.editorconfig export-ignore +/pint.json export-ignore +/testbench.yaml export-ignore +/UPGRADING.md export-ignore +/phpstan.neon.dist export-ignore diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..5242e5e --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: Hackel diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..fe4cfe6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,66 @@ +name: Bug Report +description: Report an Issue or Bug with the Package +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + We're sorry to hear you have a problem. Can you help us solve it by providing the following details. + - type: textarea + id: what-happened + attributes: + label: What happened? + description: What did you expect to happen? + placeholder: I cannot currently do X thing because when I do, it breaks X thing. + validations: + required: true + - type: textarea + id: how-to-reproduce + attributes: + label: How to reproduce the bug + description: How did this occur, please add any config values used and provide a set of reliable steps if possible. + placeholder: When I do X I see Y. + validations: + required: true + - type: input + id: package-version + attributes: + label: Package Version + description: What version of our Package are you running? Please be as specific as possible + placeholder: 2.0.0 + validations: + required: true + - type: input + id: php-version + attributes: + label: PHP Version + description: What version of PHP are you running? Please be as specific as possible + placeholder: 8.2.0 + validations: + required: true + - type: input + id: laravel-version + attributes: + label: Laravel Version + description: What version of Laravel are you running? Please be as specific as possible + placeholder: 9.0.0 + validations: + required: true + - type: dropdown + id: operating-systems + attributes: + label: Which operating systems does with happen with? + description: You may select more than one. + multiple: true + options: + - macOS + - Windows + - Linux + - type: textarea + id: notes + attributes: + label: Notes + description: Use this field to provide any other notes that you feel might be relevant to the issue. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..11c7394 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Ask a question + url: https://github.com/hackel/php-enum-to-js/discussions/new?category=q-a + about: Ask the community for help + - name: Request a feature + url: https://github.com/hackel/php-enum-to-js/discussions/new?category=ideas + about: Share ideas for new features + - name: Report a security issue + url: https://github.com/hackel/php-enum-to-js/security/policy + about: Learn how to notify us for sensitive bugs diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..39b1580 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" + + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..70d8e7b --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,33 @@ +name: dependabot-auto-merge +on: pull_request_target + +permissions: + pull-requests: write + contents: write + +jobs: + dependabot: + runs-on: ubuntu-latest + timeout-minutes: 5 + if: ${{ github.actor == 'dependabot[bot]' }} + steps: + + - name: Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@v1.6.0 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + + - name: Auto-merge Dependabot PRs for semver-minor updates + if: ${{steps.metadata.outputs.update-type == 'version-update:semver-minor'}} + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + + - name: Auto-merge Dependabot PRs for semver-patch updates + if: ${{steps.metadata.outputs.update-type == 'version-update:semver-patch'}} + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/fix-php-code-style-issues.yml b/.github/workflows/fix-php-code-style-issues.yml new file mode 100644 index 0000000..56d54d3 --- /dev/null +++ b/.github/workflows/fix-php-code-style-issues.yml @@ -0,0 +1,28 @@ +name: Fix PHP code style issues + +on: + push: + paths: + - '**.php' + +permissions: + contents: write + +jobs: + php-code-styling: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + + - name: Fix PHP code style issues + uses: aglipanci/laravel-pint-action@2.4 + + - name: Commit changes + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: Fix styling diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml new file mode 100644 index 0000000..f495e76 --- /dev/null +++ b/.github/workflows/phpstan.yml @@ -0,0 +1,28 @@ +name: PHPStan + +on: + push: + paths: + - '**.php' + - 'phpstan.neon.dist' + - '.github/workflows/phpstan.yml' + +jobs: + phpstan: + name: phpstan + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + coverage: none + + - name: Install composer dependencies + uses: ramsey/composer-install@v3 + + - name: Run PHPStan + run: ./vendor/bin/phpstan --error-format=github diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..565e187 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,58 @@ +name: run-tests + +on: + push: + paths: + - '**.php' + - '.github/workflows/run-tests.yml' + - 'phpunit.xml.dist' + - 'composer.json' + - 'composer.lock' + +jobs: + test: + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + strategy: + fail-fast: true + matrix: + os: [ubuntu-latest, windows-latest] + php: [8.3, 8.2, 8.1] + laravel: [10.*, 11.*] + stability: [prefer-lowest, prefer-stable] + include: + - laravel: 10.* + testbench: 8.* + carbon: ^2.63 + exclude: + - laravel: 11.* + php: 8.1 + + name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo + coverage: pcov + + - name: Setup problem matchers + run: | + echo "::add-matcher::${{ runner.tool_cache }}/php.json" + echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" "nesbot/carbon:${{ matrix.carbon }}" --no-interaction --no-update + composer update --${{ matrix.stability }} --prefer-dist --no-interaction + + - name: List Installed Dependencies + run: composer show -D + + - name: Execute tests + run: vendor/bin/pest --ci --coverage diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml new file mode 100644 index 0000000..39de30d --- /dev/null +++ b/.github/workflows/update-changelog.yml @@ -0,0 +1,32 @@ +name: "Update Changelog" + +on: + release: + types: [released] + +permissions: + contents: write + +jobs: + update: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: main + + - name: Update Changelog + uses: stefanzweifel/changelog-updater-action@v1 + with: + latest-version: ${{ github.event.release.name }} + release-notes: ${{ github.event.release.body }} + + - name: Commit updated CHANGELOG + uses: stefanzweifel/git-auto-commit-action@v5 + with: + branch: main + commit_message: Update CHANGELOG + file_pattern: CHANGELOG.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a7f372d --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.idea +.phpunit.cache +build +composer.lock +coverage +docs +phpunit.xml +phpstan.neon +testbench.yaml +vendor +node_modules diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..20f26c6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +All notable changes to `php-enum-to-js` will be documented in this file. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..8149ba5 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Hackel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c97e06d --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# PHP Enum to JS for Laravel + +[![Latest Version on Packagist](https://img.shields.io/packagist/v/hackel/php-enum-to-js.svg?style=flat-square)](https://packagist.org/packages/hackel/php-enum-to-js) +[![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/hackel/php-enum-to-js/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/hackel/php-enum-to-js/actions?query=workflow%3Arun-tests+branch%3Amain) +[![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/hackel/php-enum-to-js/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/hackel/php-enum-to-js/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) +[![Total Downloads](https://img.shields.io/packagist/dt/hackel/php-enum-to-js.svg?style=flat-square)](https://packagist.org/packages/hackel/php-enum-to-js) + +This package will scan and automatically convert your PHP enums into JavaScript objects. + +### Example: +Input: +```php +namespace App\Enums; + +enum Color: string +{ + case RED = 'red'; + case GREEN = 'green'; + case BLUE = 'blue'; +} +``` +Output: +```javascript +export default { + "RED": "red", + "GREEN": "green", + "BLUE": "blue" +}; +``` + +## Installation + +You can install the package via composer: + +```bash +composer require hackel/php-enum-to-js +``` + +Optionally, you can publish the stub file using: + +```bash +php artisan vendor:publish --tag="php-enum-to-js-stub" +``` + +This will create an `enum.stub` file in your `stubs` directory. You can then customize this file to decorate your JavaScript enum however you like. + +## Usage + +```bash +php artisan enum:to-js:convert +``` +### Options +- `--source` - The directory to scan for PHP enums. Defaults to `app/Enums`. +- `--dest` - The directory to write the JavaScript enums to. Defaults to `resources/js/enums`. +- `--clean` - If set, all files in the destination directory will be removed before writing the JavaScript enums. +- `--no-dump-autoload` Do not try to run `composer dump-autoload` prior to converting. + +## Testing + +```bash +composer test +``` + +## Changelog + +Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. + +## Contributing + +Please see [CONTRIBUTING](CONTRIBUTING.md) for details. + +## Security Vulnerabilities + +Please review [our security policy](../../security/policy) on how to report security vulnerabilities. + +## Credits + +- [Ryan Hayle](https://github.com/hackel) +- [All Contributors](../../contributors) + +## License + +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..efa8ac2 --- /dev/null +++ b/composer.json @@ -0,0 +1,78 @@ +{ + "name": "hackel/php-enum-to-js", + "description": "A Laravel command to convert PHP enums to Javascript objects", + "keywords": [ + "php", + "laravel", + "enum", + "javascript" + ], + "homepage": "https://github.com/hackel/php-enum-to-js", + "license": "MIT", + "authors": [ + { + "name": "Ryan Hayle", + "email": "hackel@users.noreply.github.com", + "role": "Developer" + } + ], + "require": { + "php": "^8.1", + "illuminate/filesystem": "*", + "illuminate/support": "^10.0||^11.0", + "symfony/finder": "^7.0" + }, + "require-dev": { + "larastan/larastan": "^2.0", + "laravel/pint": "^1.14", + "orchestra/testbench": "^9.0.0|^8.22.0", + "pestphp/pest": "^2.34", + "phpstan/phpstan": "^1.10" + }, + "autoload": { + "psr-4": { + "Hackel\\EnumToJs\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Hackel\\EnumToJs\\Tests\\": "tests/", + "Workbench\\App\\": "workbench/app/" + } + }, + "scripts": { + "post-autoload-dump": "@composer run prepare", + "clear": "@php vendor/bin/testbench package:purge-php-enum-to-js --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" + }, + "config": { + "sort-packages": true, + "preferred-install": "dist", + "optimize-autoloader": true, + "allow-plugins": { + "pestphp/pest-plugin": true + } + }, + "extra": { + "laravel": { + "providers": [ + "Hackel\\EnumToJs\\EnumToJsServiceProvider" + ] + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..e409631 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,12 @@ +includes: + - vendor/larastan/larastan/extension.neon + +parameters: + level: max + + paths: + - src + - tests + + ignoreErrors: + - '#^Undefined variable: \$this$#' diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..1f3853d --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,19 @@ + + + + + ./tests + + + + + ./app + ./src + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..5ad32f4 --- /dev/null +++ b/pint.json @@ -0,0 +1,3 @@ +{ + "preset": "per" +} diff --git a/src/Commands/ConvertEnumToJsCommand.php b/src/Commands/ConvertEnumToJsCommand.php new file mode 100644 index 0000000..0b4dcac --- /dev/null +++ b/src/Commands/ConvertEnumToJsCommand.php @@ -0,0 +1,130 @@ +option('source') ?? $this->laravel->basePath('app/Enums'); + + if (!$this->filesystem->isDirectory($source)) { + $this->error("The source path {$source} is not valid."); + + return 1; + } + + /** @var string $dest */ + $dest = $this->option('dest') ?? $this->laravel->basePath('resources/js/enums'); + $this->filesystem->ensureDirectoryExists($dest); + if (!$this->filesystem->isWritable($dest)) { + $this->error("The destination path {$dest} is not writable."); + + return 1; + } + + $this->info("Converting Enums from {$source} to JavaScript objects in {$dest}:"); + + if ($this->option('no-dump-autoload') === false) { + $this->composer->dumpOptimized(); + } + + $enums = $this->findEnums->findEnums($source); + + if ($enums->isEmpty()) { + $this->error("No Enums found in {$source}."); + + return 1; + } + + if ($this->option('clean')) { + $this->filesystem->cleanDirectory($dest); + } + + $this->withProgressBar( + $enums, + function (string $enum) use ($source, $dest) { + $this->newLine(); + /** @var class-string $enum */ + $this->convertEnumToJs($enum, $source, $dest); + } + ); + + $this->newLine()->info('Successfully converted ' . $enums->count() . ' Enums to JavaScript objects.'); + + return 0; + } + + /** + * @param class-string $enum + */ + private function convertEnumToJs(string $enum, string $source, string $dest): void + { + try { + $jsObject = $this->convertEnumToJs->convert($enum); + $outputPath = $this->getOutputPath($enum, $source, $dest); + $jsPath = $this->writer->write($enum, $jsObject, $outputPath); + } catch (FileAlreadyExistsException $e) { + $this->warn("Skipping {$enum} - {$e->getFileName()} already exists."); + + return; + } catch (\Throwable $e) { + $this->error("Failed to convert {$enum}: {$e->getMessage()}"); + + return; + } + $fileName = basename($jsPath); + $this->info("Converted {$enum} to {$fileName}"); + } + + /** + * @param class-string $enum + * @throws \ReflectionException + */ + private function getOutputPath(string $enum, string $source, string $dest): string + { + $reflector = new ReflectionEnum($enum); + $enumPath = $reflector->getFileName(); + + if ($enumPath === false) { + throw new \RuntimeException("Invalid Enum: {$enum}"); // @codeCoverageIgnore + } + + $relativePath = str_replace($source, '', $enumPath); + $dirname = dirname($relativePath); + + return $dest . ($dirname === '/' ? '' : $dirname); + } +} diff --git a/src/Contracts/EnumFinder.php b/src/Contracts/EnumFinder.php new file mode 100644 index 0000000..7d09cc4 --- /dev/null +++ b/src/Contracts/EnumFinder.php @@ -0,0 +1,18 @@ +> + */ + public function findEnums(string $path): Collection; +} diff --git a/src/Contracts/EnumToJson.php b/src/Contracts/EnumToJson.php new file mode 100644 index 0000000..1a54629 --- /dev/null +++ b/src/Contracts/EnumToJson.php @@ -0,0 +1,10 @@ + $enum + */ + public function write(string $enum, string $jsObject, string $outputPath): string; +} diff --git a/src/EnumFinder.php b/src/EnumFinder.php new file mode 100644 index 0000000..e061683 --- /dev/null +++ b/src/EnumFinder.php @@ -0,0 +1,88 @@ +> + */ + public function findEnums(string $path): Collection + { + $files = $this->getFiles($path); + + /** @var Collection> $enums */ + $enums = $files + ->map(function (SplFileInfo $file): ?string { + try { + /** @var class-string $class */ + $class = $this->getNamespace($file) . '\\' . $file->getBasename('.php'); + + return $class; + } catch (NamespaceNotFoundException) { + return null; + } + }) + ->filter($this->enumIsValid(...)) + ->values(); + + return $enums; + } + + /** + * Retrieve a collection of all PHP files in the given path. + * + * @return Collection + */ + private function getFiles(string $path): Collection + { + $results = $this->finder->files()->in($path)->name('*.php'); + + return new Collection($results); + } + + /** + * Extract the namespace from the given file. + */ + private function getNamespace(SplFileInfo $file): string + { + $matches = []; + if (preg_match('/\nnamespace (.*);/', $file->getContents(), $matches) !== 1) { + throw new NamespaceNotFoundException('Namespace not found in ' . $file->getRealPath()); + } + /** @var string $namespace */ + $namespace = $matches[1]; + + return $namespace; + } + + /** + * Test whether the given class-string is a valid Enum. + * + * @param ?class-string $enum + */ + private function enumIsValid(?string $enum): bool + { + if (!$enum || !\class_exists($enum)) { + return false; + } + + $reflector = new ReflectionClass($enum); + + return $reflector->isEnum(); + } +} diff --git a/src/EnumToJsServiceProvider.php b/src/EnumToJsServiceProvider.php new file mode 100644 index 0000000..1af2733 --- /dev/null +++ b/src/EnumToJsServiceProvider.php @@ -0,0 +1,34 @@ +app->runningInConsole()) { + $this->commands( + commands: [ + ConvertEnumToJsCommand::class, + ], + ); + } + } + + public function register() + { + $this->app->bind(EnumToJsContract::class, EnumToJson::class); + $this->app->bind(FilesystemContract::class, Filesystem::class); + $this->app->bind(EnumFinderContract::class, EnumFinder::class); + $this->app->bind(WriterContract::class, Writer::class); + } +} diff --git a/src/EnumToJson.php b/src/EnumToJson.php new file mode 100644 index 0000000..c27c2b3 --- /dev/null +++ b/src/EnumToJson.php @@ -0,0 +1,60 @@ + $enum + * @throws \ReflectionException + */ + public function convert(string $enum): string + { + $cases = $this->getCases($enum); + + return $this->getJsEnumContent($cases); + } + + /** + * @param class-string $enum + * @return Collection|Collection + * @throws \ReflectionException + */ + private function getCases(string $enum): Collection + { + $reflector = new ReflectionEnum($enum); + $cases = $reflector->getCases(); + + if ($reflector->isBacked()) { + return collect($cases)->flatMap( + /** @phpstan-ignore-next-line */ + fn(ReflectionEnumBackedCase $case): array => [ + $case->getName() => $case->getBackingValue(), + ], + ); + } + + return collect($cases)->flatMap( + fn(ReflectionEnumUnitCase $case): array => [ + $case->getName(), + ], + ); + } + + /** + * @param Collection|Collection $cases + */ + private function getJsEnumContent(Collection $cases): string + { + return $cases->toJson(JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); + } +} diff --git a/src/Exceptions/FileAlreadyExistsException.php b/src/Exceptions/FileAlreadyExistsException.php new file mode 100644 index 0000000..c208527 --- /dev/null +++ b/src/Exceptions/FileAlreadyExistsException.php @@ -0,0 +1,20 @@ +fileName; + } +} diff --git a/src/Exceptions/NamespaceNotFoundException.php b/src/Exceptions/NamespaceNotFoundException.php new file mode 100644 index 0000000..4e80eff --- /dev/null +++ b/src/Exceptions/NamespaceNotFoundException.php @@ -0,0 +1,9 @@ +getOutputFile($enum, $outputPath); + + if ($this->filesystem->exists($outputFile)) { + throw new FileAlreadyExistsException($outputFile); + } + + $content = $this->getStub(); + $content = str_replace('{{ $cases }}', $jsObject, $content); + $this->filesystem->ensureDirectoryExists($outputPath); + $this->filesystem->put($outputFile, $content); + + return $outputFile; + } + + private function getOutputFile(string $enum, string $outputPath): string + { + return $outputPath . '/' . class_basename($enum) . '.js'; + } + + private function getStub(): string + { + return $this->filesystem->get($this->resolveStubPath('stubs/enum.stub')); + } + + private function resolveStubPath(string $stub): string + { + return file_exists($customPath = $this->app->basePath(trim($stub, '/'))) + ? $customPath + : __DIR__ . "/../$stub"; + } + +} diff --git a/stubs/enum.stub b/stubs/enum.stub new file mode 100644 index 0000000..4e27f59 --- /dev/null +++ b/stubs/enum.stub @@ -0,0 +1 @@ +export default {{ $cases }}; diff --git a/tests/ConvertEnumsToJsCommandTest.php b/tests/ConvertEnumsToJsCommandTest.php new file mode 100644 index 0000000..ac9cd95 --- /dev/null +++ b/tests/ConvertEnumsToJsCommandTest.php @@ -0,0 +1,241 @@ +mock(Composer::class)->shouldReceive('dumpOptimized'); + $this->mock(Filesystem::class) + ->shouldReceive('isDirectory') + ->andReturn(true) + ->shouldReceive('ensureDirectoryExists') + ->shouldReceive('isWritable') + ->andReturn(true); + $this->mock(EnumFinder::class) + ->shouldReceive('findEnums') + ->andReturn(collect([ + Color::class, + Number::class, + DayOfWeek::class, + ])); + $this->mock(EnumToJson::class) + ->shouldReceive('convert') + ->andReturnValues([ + <<<'JSON' + { + "RED": "red", + "GREEN": "green", + "BLUE": "blue" + } + JSON, + <<<'JSON' + { + "ONE": 1, + "TWO": 2, + "THREE": 3 + } + JSON, + <<<'JSON' + { + "SUNDAY": "Sunday", + "MONDAY": "Monday", + "TUESDAY": "Tuesday", + "WEDNESDAY": "Wednesday", + "THURSDAY": "Thursday", + "FRIDAY": "Friday", + "SATURDAY": "Saturday" + } + JSON, + ]); + $this->mock(Writer::class) + ->shouldReceive('write') + ->times(3) + ->andReturnValues([ + 'resources/js/enums/Color.js', + 'resources/js/enums/Number.js', + 'resources/js/enums/Nested/Path/DayOfWeek.js', + ]); + + $source = __DIR__ . '/Enums'; + $dest = __DIR__ . '/out'; + + $response = $this + ->artisan(ConvertEnumToJsCommand::class, [ + '--source' => $source, + '--dest' => $dest, + ]); + + $response + ->expectsOutput("Converting Enums from {$source} to JavaScript objects in {$dest}:") + ->expectsOutput('Converted Hackel\EnumToJs\Tests\Enums\Color to Color.js') + ->expectsOutput('Converted Hackel\EnumToJs\Tests\Enums\Number to Number.js') + ->expectsOutput('Converted Hackel\EnumToJs\Tests\Enums\Nested\Path\DayOfWeek to DayOfWeek.js') + ->expectsOutput('Successfully converted 3 Enums to JavaScript objects.') + ->assertSuccessful(); + }); + + it('returns an error when the source is not a directory', function () { + $this->mock(Filesystem::class) + ->shouldReceive('isDirectory') + ->andReturn(false); + + $source = __DIR__; + + $response = $this->artisan(ConvertEnumToJsCommand::class, ['--source' => $source]); + + $response + ->assertFailed() + ->expectsOutput("The source path {$source} is not valid."); + }); + + it('returns an error when the source directory does not exist', function () { + $this->mock(Filesystem::class) + ->shouldReceive('isDirectory') + ->andReturn(true) + ->shouldReceive('ensureDirectoryExists') + ->shouldReceive('isWritable') + ->andReturn(false); + + $source = __DIR__; + $dest = __DIR__; + + $response = $this->artisan(ConvertEnumToJsCommand::class, [ + '--source' => $source, + '--dest' => $dest, + ]); + + $response + ->assertFailed() + ->expectsOutput("The destination path {$dest} is not writable."); + }); + + it('should return an error when no enums are found', function () { + $this->mock(Composer::class)->shouldReceive('dumpOptimized'); + $this->mock(Filesystem::class) + ->shouldReceive('isDirectory') + ->andReturn(true) + ->shouldReceive('ensureDirectoryExists') + ->shouldReceive('isWritable') + ->andReturn(true); + $this->mock(EnumFinder::class) + ->shouldReceive('findEnums') + ->andReturn(collect()); + + $this + ->artisan(ConvertEnumToJsCommand::class) + ->assertFailed() + ->expectsOutput("No Enums found in {$this->app->basePath('app/Enums')}."); + }); + + it('should clean the destination directory when the clean option is given', function () { + $this->mock(Composer::class)->shouldReceive('dumpOptimized'); + $this->mock(Filesystem::class) + ->shouldReceive('isDirectory') + ->andReturn(true) + ->shouldReceive('ensureDirectoryExists') + ->shouldReceive('isWritable') + ->andReturn(true) + ->shouldReceive('cleanDirectory') + ->andReturn(true); + $this->mock(EnumFinder::class) + ->shouldReceive('findEnums') + ->andReturn(collect([Color::class])); + $this->mock(EnumToJson::class) + ->shouldReceive('convert') + ->andReturn('test'); + $this->mock(Writer::class) + ->shouldReceive('write') + ->andReturn('resources/js/enums/Color.js'); + + $this + ->artisan(ConvertEnumToJsCommand::class, ['--clean' => true]) + ->assertSuccessful(); + }); + + it('should return an error when a destination file already exists', function () { + $this->mock(Composer::class)->shouldReceive('dumpOptimized'); + $this->mock(Filesystem::class) + ->shouldReceive('isDirectory') + ->andReturn(true) + ->shouldReceive('ensureDirectoryExists') + ->shouldReceive('isWritable') + ->andReturn(true); + $this->mock(EnumFinder::class) + ->shouldReceive('findEnums') + ->andReturn(collect([Color::class])); + $this->mock(EnumToJson::class) + ->shouldReceive('convert') + ->andReturn('test'); + $this->mock(Writer::class) + ->shouldReceive('write') + ->andThrow(new FileAlreadyExistsException('Color.js')); + + $this + ->artisan(ConvertEnumToJsCommand::class) + ->assertSuccessful() + ->expectsOutput("Skipping Hackel\EnumToJs\Tests\Enums\Color - Color.js already exists."); + }); + + it('should return an error when an unexpected exception is thrown', function () { + $this->mock(Composer::class)->shouldReceive('dumpOptimized'); + $this->mock(Filesystem::class) + ->shouldReceive('isDirectory') + ->andReturn(true) + ->shouldReceive('ensureDirectoryExists') + ->shouldReceive('isWritable') + ->andReturn(true); + $this->mock(EnumFinder::class) + ->shouldReceive('findEnums') + ->andReturn(collect([Color::class])); + $this->mock(EnumToJson::class) + ->shouldReceive('convert') + ->andReturn('test'); + $this->mock(Writer::class) + ->shouldReceive('write') + ->andThrow(Exception::class, 'test error'); + + $this + ->artisan(ConvertEnumToJsCommand::class) + ->assertSuccessful() + ->expectsOutput( + "Failed to convert Hackel\EnumToJs\Tests\Enums\Color: test error" + ); + }); + + it('should return an error when an internal enum is specified', function () { + $this->mock(Composer::class)->shouldReceive('dumpOptimized'); + $this->mock(Filesystem::class) + ->shouldReceive('isDirectory') + ->andReturn(true) + ->shouldReceive('ensureDirectoryExists') + ->shouldReceive('isWritable') + ->andReturn(true); + $this->mock(EnumFinder::class) + ->shouldReceive('findEnums') + ->andReturn(collect([Color::class])); + $this->mock(EnumToJson::class) + ->shouldReceive('convert') + ->andReturn('test'); + $this->mock(Writer::class) + ->shouldReceive('write') + ->andThrow(Exception::class, 'test error'); + + $this + ->artisan(ConvertEnumToJsCommand::class) + ->assertSuccessful() + ->expectsOutput( + "Failed to convert Hackel\EnumToJs\Tests\Enums\Color: test error" + ); + }); +}); diff --git a/tests/EnumFinderTest.php b/tests/EnumFinderTest.php new file mode 100644 index 0000000..27e1e7d --- /dev/null +++ b/tests/EnumFinderTest.php @@ -0,0 +1,52 @@ +findEnums(__DIR__ . '/Enums'); + + expect($enums)->toBeInstanceOf(Collection::class) + ->and($enums->count())->toBe(4) + ->and($enums->toArray())->toBe([ + Suit::class, + Color::class, + Number::class, + DayOfWeek::class, + ]); + }); + + it('skips a file if it does not have a namespace', function () { + $finder = $this->mock(Symfony\Component\Finder\Finder::class) + ->shouldReceive('files') + ->andReturnSelf() + ->shouldReceive('in') + ->andReturnSelf() + ->shouldReceive('name') + ->andReturnSelf() + ->shouldReceive('getIterator') + ->andReturn(new ArrayIterator([ + new SplFileInfo( + __DIR__ . '/Enums/NoNamespaceEnum.php', + __DIR__ . '/Enums', + __DIR__ . '/Enums/NoNamespaceEnum.php', + ), + ])) + ->getMock(); + + $enumFinder = new EnumFinder($finder); + $enums = $enumFinder->findEnums(__DIR__ . '/Enums'); + + expect($enums)->toBeEmpty(); + }); +}); diff --git a/tests/EnumToJsonTest.php b/tests/EnumToJsonTest.php new file mode 100644 index 0000000..2e9e4e9 --- /dev/null +++ b/tests/EnumToJsonTest.php @@ -0,0 +1,67 @@ +convert($enum); + + expect($jsObject)->toBe( + <<<'JSON' + { + "RED": "red", + "GREEN": "green", + "BLUE": "blue" + } + JSON + ); + }); + + it('converts an int-backed PHP Enum into a JSON object', function () { + $enum = Number::class; + + $action = app(EnumToJson::class); + + $jsObject = $action->convert($enum); + + expect($jsObject)->toBe( + <<<'JSON' + { + "ONE": 1, + "TWO": 2, + "THREE": 3 + } + JSON + ); + }); + + it('converts a pure PHP Enum into a JSON array', function () { + $enum = Suit::class; + + $action = app(EnumToJson::class); + + $jsObject = $action->convert($enum); + + expect($jsObject)->toBe( + <<<'JSON' + [ + "Hearts", + "Diamonds", + "Clubs", + "Spades" + ] + JSON + ); + }); +}); diff --git a/tests/Enums/Color.php b/tests/Enums/Color.php new file mode 100644 index 0000000..0c496a6 --- /dev/null +++ b/tests/Enums/Color.php @@ -0,0 +1,12 @@ +in(__DIR__); + +/* +|-------------------------------------------------------------------------- +| Expectations +|-------------------------------------------------------------------------- +| +| When you're writing tests, you often need to check that values meet certain conditions. The +| "expect()" function gives you access to a set of "expectations" methods that you can use +| to assert different things. Of course, you may extend the Expectation API at any time. +| +*/ + +expect()->extend('toBeOne', function () { + return $this->toBe(1); +}); + +/* +|-------------------------------------------------------------------------- +| Functions +|-------------------------------------------------------------------------- +| +| While Pest is very powerful out-of-the-box, you may have some testing code specific to your +| project that you don't want to repeat in every file. Here you can also expose helpers as +| global functions to help you to reduce the number of lines of code in your test files. +| +*/ diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..136cef7 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,18 @@ +mock(Application::class) + ->shouldReceive('basePath') + ->with('stubs/enum.stub') + ->andReturn('stubs/enum.stub') + ->getMock(); + + $filesystem = $this->mock(Filesystem::class) + ->shouldReceive('get') + ->with('stubs/enum.stub') + ->andReturn('stub content {{ $cases }}') + ->shouldReceive('exists') + ->with('output/path/Color.js') + ->andReturn(false) + ->shouldReceive('ensureDirectoryExists') + ->shouldReceive('put') + ->with('output/path/Color.js', 'stub content js object') + ->andReturn(1024) + ->getMock(); + + $writer = new Writer($app, $filesystem); + + $outputFile = $writer->write(Color::class, 'js object', 'output/path'); + + expect($outputFile)->toBe('output/path/Color.js'); + }); + + it('should throw an exception if the destination file already exists', function () { + $app = $this->mock(Application::class) + ->shouldReceive('basePath') + ->with('stubs/enum.stub') + ->andReturn('stubs/enum.stub') + ->getMock(); + + $filesystem = $this->mock(Filesystem::class) + ->shouldReceive('get') + ->with('stubs/enum.stub') + ->andReturn('stub content {{ $cases }}') + ->shouldReceive('exists') + ->with('output/path/Color.js') + ->andReturn(true) + ->getMock(); + + $writer = new Writer($app, $filesystem); + + $writer->write(Color::class, 'js object', 'output/path'); + })->throws(FileAlreadyExistsException::class, 'The file `output/path/Color.js` already exists.'); +}); diff --git a/workbench/app/Providers/WorkbenchServiceProvider.php b/workbench/app/Providers/WorkbenchServiceProvider.php new file mode 100644 index 0000000..001d06d --- /dev/null +++ b/workbench/app/Providers/WorkbenchServiceProvider.php @@ -0,0 +1,25 @@ +