diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2741465 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/.idea +/vendor +composer.phar +composer.lock +phpunit.xml +.phpunit.result.cache +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/README.md b/README.md index 6052787..0af844d 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# renum \ No newline at end of file +# Paragon \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..a244d4f --- /dev/null +++ b/composer.json @@ -0,0 +1,38 @@ +{ + "name": "kirschbaum-development/paragon", + "description": "A Laravel package for generating enum-like objects in typescript based on PHP enum classes.", + "keywords": [ + "laravel", + "actions", + "events" + ], + "homepage": "https://github.com/kirschbaum-development/paragon", + "license": "MIT", + "authors": [ + { + "name": "Brandon Ferens", + "email": "brandon@kirschbaumdevelopment.com", + "role": "Developer" + } + ], + "require": { + "php": "^8.0" + }, + "autoload": { + "psr-4": { + "Kirschbaum\\Paragon\\": "src/" + } + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "laravel": { + "providers": [ + "Kirschbaum\\Paragon\\ParagonServiceProvider" + ] + } + } +} diff --git a/config/paragon.php b/config/paragon.php new file mode 100644 index 0000000..41a210e --- /dev/null +++ b/config/paragon.php @@ -0,0 +1,13 @@ + [ + 'abstract-class' => 'Enum', + + 'paths' => [ + 'php' => app_path(), + 'generated' => 'js/enums', + 'methods' => 'js/vendors/paragon/enums', + ], + ], +]; diff --git a/src/Commands/GenerateEnumsCommand.php b/src/Commands/GenerateEnumsCommand.php new file mode 100644 index 0000000..15afbd9 --- /dev/null +++ b/src/Commands/GenerateEnumsCommand.php @@ -0,0 +1,43 @@ +enums() + ->map(function ($enum) { + return EnumGenerator::generate($enum); + }) + ->filter(); + + $this->components->info("{$generatedEnums->count()} enums have been (re)generated."); + + AbstractEnumGenerator::generate(); + + $this->components->info('Abstract enum class has been (re)generated.'); + + return self::SUCCESS; + } + + /** + * Gather all enum namespaces for searching. + */ + protected function enums(): Collection + { + return DiscoverEnums::within(config('paragon.enums.paths.php')) + ->values(); + } +} diff --git a/src/Commands/MakeEnumMethodCommand.php b/src/Commands/MakeEnumMethodCommand.php new file mode 100644 index 0000000..62bd834 --- /dev/null +++ b/src/Commands/MakeEnumMethodCommand.php @@ -0,0 +1,84 @@ +components + ->info("Abstract enum class has been rebuilt to include new [{$this->argument('name')}] method."); + + return self::SUCCESS; + } + + /** + * Get the console command arguments. + */ + protected function getArguments(): array + { + return [ + ['name', InputArgument::REQUIRED, 'The name of the enum method'], + ]; + } + + /** + * Interact further with the user if they were prompted for missing arguments. + */ + protected function promptForMissingArgumentsUsing(): array + { + return [ + 'name' => fn () => text( + label: 'What is the name of the new enum method?', + placeholder: 'e.g. asOptions', + ), + ]; + } + + /** + * Build the file with the given name. + * + * @throws FileNotFoundException + */ + protected function buildClass($name): string + { + $stub = $this->files->get($this->getStub()); + + return str_replace('{{ Method }}', $this->argument('name'), $stub); + } + + /** + * Get the destination class path. + */ + protected function getPath($name): string + { + return resource_path(config('paragon.enums.paths.methods')) . DIRECTORY_SEPARATOR . $this->argument('name') . '.ts'; + } +} diff --git a/src/Concerns/DiscoverEnums.php b/src/Concerns/DiscoverEnums.php new file mode 100644 index 0000000..bccd65a --- /dev/null +++ b/src/Concerns/DiscoverEnums.php @@ -0,0 +1,55 @@ +files()->in($path)); + } + + /** + * Filter the files down to only enums. + */ + protected static function getEnums($files): Collection + { + return collect($files) + ->mapWithKeys(function ($file) { + try { + $reflector = new ReflectionClass($enum = static::classFromFile($file)); + } catch (ReflectionException) { + return []; + } + + return $reflector->isEnum() + ? [$enum => $enum] + : []; + }) + ->filter(); + } + + /** + * Extract the class name from the given file path. + */ + protected static function classFromFile(SplFileInfo $file): string + { + $class = trim(Str::replaceFirst(base_path(), '', $file->getRealPath()), DIRECTORY_SEPARATOR); + + return str_replace( + [DIRECTORY_SEPARATOR, ucfirst(basename(app()->path())) . '\\'], + ['\\', app()->getNamespace()], + ucfirst(Str::replaceLast('.php', '', $class)) + ); + } +} diff --git a/src/Concerns/IgnoreWhenGeneratingTypescript.php b/src/Concerns/IgnoreWhenGeneratingTypescript.php new file mode 100644 index 0000000..7b83ea7 --- /dev/null +++ b/src/Concerns/IgnoreWhenGeneratingTypescript.php @@ -0,0 +1,8 @@ +files = Storage::createLocalDriver([ + 'root' => resource_path(config('paragon.enums.paths.generated')), + ]); + } + + public function __invoke(): void + { + $this->files->put($this->path(), $this->contents()); + } + + public static function generate() + { + return (new self())(); + } + + /** + * Inject all prepared data into the stub. + */ + protected function contents(): string + { + $imports = $this->imports(); + $suffix = $imports->count() ? PHP_EOL : ''; + + return str(file_get_contents($this->stubPath())) + ->replace('{{ Abstract }}', config('paragon.enums.abstract-class')) + ->replace('{{ Imports }}', "{$imports->join('')}{$suffix}") + ->replace('{{ Methods }}', "{$this->methods($imports->keys())}{$suffix}"); + } + + /** + * Get the path to the stubs. + */ + public function stubPath(): string + { + return __DIR__ . '/../../stubs/abstract-enum.stub'; + } + + /** + * Build out the actual enum case object including the name, value if needed, and any public methods. + */ + protected function imports(): Collection + { + try { + $files = Finder::create() + ->files() + ->in(resource_path(config('paragon.enums.paths.methods'))); + } catch (DirectoryNotFoundException) { + return collect(); + } + + return collect($files) + ->mapWithKeys(function ($file) { + $abstractPath = config('paragon.enums.paths.generated'); + $filePath = str($file->getPath()) + ->after(resource_path()) + ->ltrim('/') + ->explode('/'); + + $relativePath = collect(explode('/', $abstractPath)) + ->diff($filePath) + ->map(fn () => '..') + ->merge($filePath->diff(explode('/', $abstractPath))) + ->join('/'); + + $name = (string) str($file->getFileName())->before('.'); + + return [$name => "import {$name} from '{$relativePath}/{$file->getFilename()}';" . PHP_EOL]; + }) + ->sort(); + } + + /** + * Build out the actual enum case object including the name, value if needed, and any public methods. + */ + protected function methods(Collection $methods): string + { + return $methods->map(fn ($method) => PHP_EOL . "Enum.{$method} = {$method};") + ->join(''); + } + + /** + * Path where the enum will be saved. + */ + protected function path(): string + { + return config('paragon.enums.abstract-class') . '.ts'; + } +} diff --git a/src/Generators/EnumGenerator.php b/src/Generators/EnumGenerator.php new file mode 100644 index 0000000..4ea1f42 --- /dev/null +++ b/src/Generators/EnumGenerator.php @@ -0,0 +1,293 @@ +files = Storage::createLocalDriver([ + 'root' => resource_path(config('paragon.enums.paths.generated')), + ]); + + $this->cache = Storage::createLocalDriver([ + 'root' => storage_path('framework/cache/paragon'), + ]); + + $this->reflector = new ReflectionEnum($this->enum); + } + + public function __invoke(): bool + { + if ($this->generatedFileExists() && $this->cached()) { + return false; + } + + $this->files->put($this->path(), $this->contents()); + + $this->cacheEnum(); + + return true; + } + + /** + * Initiate enum file generation. + * + * @throws ReflectionException + */ + public static function generate(string $enum): bool + { + return (new self($enum))(); + } + + /** + * Typescript enum file contents. + */ + protected function contents(): string + { + $code = $this->prepareEnumCode(); + + return str(file_get_contents($this->stubPath())) + ->replace('{{ Path }}', $this->relativePath()) + ->replace('{{ Enum }}', class_basename($this->enum)) + ->replace('{{ Abstract }}', config('paragon.enums.abstract-class')) + ->replace('{{ TypeDefinition }}', $code->type) + ->replace('{{ Cases }}', $code->cases) + ->replace('{{ Getters }}', $code->getters); + } + + /** + * Get the path to the stubs. + */ + public function stubPath(): string + { + return __DIR__ . '/../../stubs/enum.stub'; + } + + /** + * Prepare all the data needed for each enum case object. + */ + protected function prepareEnumCode(): Fluent + { + $cases = collect($this->reflector->getCases()); + + return fluent([ + 'type' => $this->buildTypeDefinition(), + 'cases' => $this->buildCases($cases), + 'getters' => $this->buildGetters($cases), + ]); + } + + /** + * Relative path to the abstract enum class. + */ + protected function relativePath(): string + { + $depth = str($this->enum)->after('App\\Enums\\')->explode('\\')->count() - 1; + + return $depth + ? collect(range(1, $depth))->transform(fn () => '../')->join('') + : './'; + } + + /** + * Prepare the definition for each case's return type. + */ + protected function buildTypeDefinition(): string + { + return $this->methods() + ->map(function ($method) { + return PHP_EOL . " {$method->getName()}();"; + }) + ->sortDesc() + ->when( + $this->reflector->isBacked(), + fn ($collection) => $collection->push(PHP_EOL . " value: {$this->valueReturnType()};") + ) + ->reverse() + ->join(''); + } + + /** + * Determine the public methods available for the enum. + */ + protected function methods(): Collection + { + return collect($this->reflector->getMethods(ReflectionMethod::IS_PUBLIC)) + ->reject(function (ReflectionMethod $method) { + return $method->isStatic() || $method->getAttributes(IgnoreWhenGeneratingTypescript::class); + }) + ->sortBy(fn (ReflectionMethod $method) => $method->getName()); + } + + /** + * Determine the value return type for the type definition. + */ + protected function valueReturnType(): string + { + return $this->reflector->getBackingType()->getName() === 'int' ? 'number' : 'string'; + } + + /** + * Build all the case objects. + */ + protected function buildCases(Collection $cases): string + { + return $cases + ->map(function ($case) { + $value = $this->caseValueProperty($case); + + $methodValues = $this->methods()->map(function (ReflectionMethod $method) use ($case) { + return $this->caseMethods($method, $case); + }); + + return $this->assembleCaseObject($case, $value, $methodValues); + }) + ->join(',' . PHP_EOL); + } + + /** + * Prepare the value of the enum case object if it is a backed enum. + */ + protected function caseValueProperty($case): string + { + if ($this->reflector->isBacked()) { + return str('value: ') + ->prepend("{$this->linePrefix}") + ->when( + $this->reflector->getBackingType()->getName() === 'int', + fn ($string) => $string->append("{$case->getValue()->value}"), + fn ($string) => $string->append("'{$case->getValue()->value}'"), + ) + ->append(','); + } + + return ''; + } + + /** + * Prepare all the methods and their respective values so they can get injected into the case object. + */ + protected function caseMethods(ReflectionMethod $method, $case): string + { + $value = $case->getValue()->{$method->getName()}(); + $class = class_basename($method->getDeclaringClass()->getName()); + + return str("{$this->linePrefix}{$method->getName()}: (): ") + ->append(match (true) { + $value instanceof BackedEnum => "object => {$class}.{$value->name}", + is_numeric($value) => "number => {$value}", + is_null($value) => 'null => null', + default => "string => '{$value}'" + }) + ->append(','); + } + + /** + * Assemble the actual enum case object code including the name, value if needed, and any public methods. + */ + protected function assembleCaseObject($case, string $value, Collection $methodValues): string + { + $name = str('name: ')->append("'{$case->name}'")->append(','); + + return <<name}: Object.freeze({ + {$name}{$value}{$methodValues->join('')} + }) + JS; + } + + /** + * Build all case object getter methods. + */ + protected function buildGetters(Collection $cases): string + { + return $cases + ->map(function ($case) { + return $this->assembleCaseGetter($case); + }) + ->join(PHP_EOL . PHP_EOL); + } + + /** + * Assemble the static getter method code for the enum case object. + */ + protected function assembleCaseGetter($case): string + { + $class = class_basename($case->getDeclaringClass()->name); + + return <<name}(): {$class}Definition { + return this.items['{$case->name}']; + } + JS; + } + + /** + * Path where the enum will be saved. + */ + protected function path(): string + { + return str($this->enum) + ->after('App\\Enums\\') + ->replace('\\', '/') + ->append('.ts'); + } + + protected function generatedFileExists(): bool + { + return $this->files->exists($this->path()); + } + + /* + |-------------------------------------------------------------------------- + | Enum Caching + |-------------------------------------------------------------------------- + */ + + protected function cached(): bool + { + return $this->cache->get($this->cacheFilename()) === $this->cachedFile(); + } + + protected function cacheFilename(): string + { + return md5($this->reflector->getFileName()); + } + + protected function cachedFile(): string + { + return md5_file($this->reflector->getFileName()); + } + + protected function cacheEnum(): void + { + $this->cache->put($this->cacheFilename(), $this->cachedFile()); + } +} diff --git a/src/ParagonServiceProvider.php b/src/ParagonServiceProvider.php new file mode 100644 index 0000000..5dbeaf6 --- /dev/null +++ b/src/ParagonServiceProvider.php @@ -0,0 +1,37 @@ +mergeConfigFrom( + __DIR__ . '/../config/paragon.php', 'paragon' + ); + } + + /** + * Bootstrap services. + */ + public function boot(): void + { + $this->publishes([ + __DIR__ . '/../config/paragon.php' => config_path('paragon.php'), + ]); + + if ($this->app->runningInConsole()) { + $this->commands([ + GenerateEnumsCommand::class, + MakeEnumMethodCommand::class, + ]); + } + } +} diff --git a/stubs/abstract-enum.stub b/stubs/abstract-enum.stub new file mode 100644 index 0000000..24a27fa --- /dev/null +++ b/stubs/abstract-enum.stub @@ -0,0 +1,38 @@ +{{ Imports }}export class ValueError extends Error { + constructor(className: string, value: number | string) { + super(`${value} is not a valid backing value for enum ${className}`); + } +} + +export interface Enumerable { + cases(): array<{ name: string; values: any }>; + from(value: number | string): object; + tryFrom(value: number | string): object | null; +} + +abstract class {{ Abstract }} implements Enumerable { + public static cases() { + return Object.entries(this.items).map(([name, item]) => ({ + name, + value: item.value, + })); + } + + public static from(value: number | string) { + const enumCase = this.cases().find(item => item.value === value); + + if (enumCase === undefined) { + throw new ValueError(this.name, value); + } + + return this[enumCase.name]; + } + + public static tryFrom(value: number | string) { + const enumCase = this.cases().find(item => item.value === value); + + return enumCase ? this[enumCase.name] : null; + } +} +{{ Methods }} +export default {{ Abstract }}; diff --git a/stubs/enum.stub b/stubs/enum.stub new file mode 100644 index 0000000..c2cec9e --- /dev/null +++ b/stubs/enum.stub @@ -0,0 +1,15 @@ +import {{ Abstract }} from '{{ Path }}{{ Abstract }}.ts'; + +type {{ Enum }}Definition = { + name: string;{{ TypeDefinition }} +}; + +class {{ Enum }} extends {{ Abstract }} { + protected static items = Object.freeze({ +{{ Cases }}, + }); + +{{ Getters }} +} + +export default {{ Enum }}; diff --git a/stubs/method.stub b/stubs/method.stub new file mode 100644 index 0000000..a12182b --- /dev/null +++ b/stubs/method.stub @@ -0,0 +1,4 @@ +export default function {{ Method }}() { + // Start building your custom enum method! + return this.items; +}