diff --git a/.editorconfig b/.editorconfig index 131e6999..b05c40ab 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,9 +8,13 @@ charset = utf-8 insert_final_newline = true trim_trailing_whitespace = true -[*.{php,js}] +[*.php] indent_style = space indent_size = 4 +[*.js] +indent_style = space +indent_size = 2 + [*.md] trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes index d9303859..2595784f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,4 +5,4 @@ /.travis.yml export-ignore /phpunit.xml.dist export-ignore /tests export-ignore -/res export-ignore +/res/img export-ignore diff --git a/README.md b/README.md index 392a8bca..59490bd8 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ Table of Contents * [Usage with Alice](#usage-with-alice) * [Api-Platform](#api-platform) * [OpenApi / Swagger](#openapi--swagger) + * [JavaScript](#javascript) * [API](#api) * [Simple enum](#simple-enum) * [Readable enum](#readable-enum) @@ -123,6 +124,8 @@ Providing our own package inspired from the best ones, on which we'll apply our - Symfony HttpKernel component integration with an enum resolver for controller arguments. - Doctrine DBAL integration with abstract classes in order to persist your enumeration in database. - Faker enum provider to generate random enum instances. +- An API Platform OpenApi/Swagger type for documentation generation. +- JavaScript enums code generation. # Installation @@ -869,11 +872,54 @@ MyEntity: > 📝 `MISTER` in `` refers to a constant defined in the `Civility` enum class, not to a constant's value ('mister' string for instance). -## Api-Platform +## API Platform ### OpenApi / Swagger -The PhpEnums library provides an `Elao\Enum\Bridge\ApiPlatform\Core\JsonSchema\Type\ElaoEnumType` to generate a OpenApi (formally Swagger) documentation based on your enums. This decorator is automatically wired for you when using the Symfony bundle. +The library provides an `Elao\Enum\Bridge\ApiPlatform\Core\JsonSchema\Type\ElaoEnumType` to generate a OpenApi (formally Swagger) documentation based on your enums. This decorator is automatically wired for you when using the Symfony bundle. + +## JavaScript + +This library allows to generate JS code from your PHP enums using a command: + +```bash +bin/elao-enum-dump-js --lib-path "./assets/js/lib/enum.js" --base-dir="./assets/js/modules" \ + "App\Auth\Enum\Permissions:auth/Permissions.js" \ + "App\Common\Enum\Gender:common/Gender.js" +``` + +This command generates: +- [library sources](./res/js/Enum.js) at path `assets/js/lib/enums.js` containing the base JS classes +- enums in a base `/assets/js/modules` dir: + - `Permissions` in `/assets/js/modules/auth/Permissions.js` + - `Gender` in `/assets/js/modules/common/Gender.js` + +Simple enums, readables & flagged enums are supported. + +Note that this is not meant to be used as part of an automatic process updating your code. +There is no BC promise guaranteed on the generated code. Once generated, the code belongs to you. + +### In a Symfony app + +You can configure the library path, base dir and enum paths in the bundle configuration: + +```yaml +elao_enum: + elao_enum: + js: + base_dir: '%kernel.project_dir%/assets/js/modules' + lib_path: '%kernel.project_dir%/assets/js/lib' + paths: + App\Common\Enum\SimpleEnum: 'common/SimpleEnum.js' + App\Common\Enum\Gender: 'common/Gender.js' + App\Auth\Enum\Permissions: 'auth/Permissions.js' +``` + +Then, use the CLI command to generate the JS files: + +```bash +bin/console elao:enum:dump-js [--lib-path] [--base-dir] [...] +``` # API diff --git a/bin/elao-enum-dump-js b/bin/elao-enum-dump-js new file mode 100755 index 00000000..5148991e --- /dev/null +++ b/bin/elao-enum-dump-js @@ -0,0 +1,30 @@ +#!/usr/bin/env php +add($command = new DumpJsEnumsCommand()) + ->getApplication() + ->setDefaultCommand($command->getName(), true) + ->run() +; diff --git a/composer.json b/composer.json index ecc6d5ae..21ba66e2 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,9 @@ "homepage": "https://github.com/ogizanagi" } ], + "bin": [ + "bin/elao-enum-dump-js" + ], "autoload": { "psr-4": { "Elao\\Enum\\": "src/" diff --git a/res/js/Enum.js b/res/js/Enum.js new file mode 100644 index 00000000..317a124f --- /dev/null +++ b/res/js/Enum.js @@ -0,0 +1,289 @@ +/** + * @property {String|Number} value + * + * @see \Elao\Enum\EnumInterface PHP + */ +class Enum { + /** + * @param {String|Number} value One of the possible enum values + */ + constructor(value) { + if (!this.constructor.accepts(value)) { + throw new InvalidValueException(value, this.constructor.name); + } + + this.value = value; + } + + /** + * Default implementation uses static class properties as available values + * + * @returns {(String|Number)[]} The list of available enum values + */ + static get values() { + const enumClass = eval(this.name); + if (enumClass._values) { + return enumClass._values; + } + + return enumClass._values = Object.values(eval(this.name)).filter(v => Number.isInteger(v) || 'string' === typeof v); + } + + /** + * @returns {Enum[]} the list of all possible enum instances. + */ + static get instances() { + return this.values.map(value => new (eval(this.name))(value)); + } + + /** + * @param {String|Number} value + * + * @returns {Boolean} True if the value is an acceptable value for the enum type + */ + static accepts(value) { + return this.values.indexOf(value) !== -1; + } + + /** + * @param {String|Number} value + * + * @returns {Boolean} True if the enum instance has given value + */ + is(value) { + return value === this.value; + } +} + +/** + * @see \Elao\Enum\ReadableEnumInterface PHP + */ +class ReadableEnum extends Enum { + /** + * @returns {Object} labels indexed by enumerated value + */ + static get readables() { + throw new Error(`A readable enum must implement "ReadableEnum.readables" (either use "static get readables()" or "static readables = {...}"). Class "${this.name}" does not.`); + } + + /** + * @param {String|Number} value + * + * @returns {String} the human readable version for this value + */ + static readableFor(value) { + return this.readables[value]; + } + + /** + * @returns {String} the human readable version of the enum instance + */ + getReadable() { + return this.constructor.readableFor(this.value); + } + + /** + * @see ReadableEnum.getReadable + */ + toString() { + return this.getReadable(); + } +} + +/** + * @property {Number} value + * + * @see \Elao\Enum\FlaggedEnum PHP + */ +class FlaggedEnum extends ReadableEnum { + static NONE = 0; + + /** + * @type {Map} + * @private + **/ + static masks = new Map(); + + /** + * @type {Number[]} + * @private + **/ + flags = null; + + static get values() { + return super.values.filter(v => + // Filters out any non single bit flag: + Number.isInteger(v) && v > 0 && ((v % 2) === 0 || v === 1) + ); + } + + /** + * @param {Number} value + * + * @returns {Boolean} + */ + static accepts(value) { + if (!Number.isInteger(value)) { + return false; + } + + if (value === FlaggedEnum.NONE) { + return true; + } + + return value === (value & this.getBitmask()); + } + + /** + * Gets an integer value of the possible flags for enumeration. + * + * @returns {Number} + * + * @throws Error If the possibles values are not valid bit flags + */ + static getBitmask() { + const enumType = this.name; + + if (!this.masks[enumType]) { + let mask = 0; + this.values.forEach(flag => { + if (flag < 1 || (flag > 1 && (flag % 2) !== 0)) { + throw new Error(`Possible value ${flag} of the enumeration "${this.name}" is not a bit flag.`); + + } + mask |= flag; + }) + + this.masks[enumType] = mask; + } + + return this.masks[enumType]; + } + + /** + * Gets an array of bit flags of the value. + * + * @returns {Number[]} Array of individual bitflag for the current instances + */ + getFlags() { + if (this.flags === null) { + this.flags = this.constructor.values.filter(flag => { + return this.hasFlag(flag); + }); + } + + return this.flags; + } + + /** + * Determines whether the specified flag is set in a numeric value. + * + * @param {Number} bitFlag The bit flag or bit flags + * + * @return {Boolean} True if the bit flag or bit flags are also set in the current instance; otherwise, false + */ + hasFlag(bitFlag) { + if (bitFlag >= 1) { + return bitFlag === (bitFlag & this.value); + } + + return false; + } + + /** + * Computes a new value with given flags, and returns the corresponding instance. + * + * @param {Number} flags The bit flag or bit flags + * + * @throws {InvalidValueException} When flags is not acceptable for this enumeration type + * + * @returns {this} The enum instance for computed value + */ + withFlags(flags) { + if (!this.constructor.accepts(flags)) { + throw new InvalidValueException(flags, this.constructor.name); + } + + return new this.constructor(this.value | flags); + } + + /** + * Computes a new value without given flags, and returns the corresponding instance. + * + * @param {Number} flags The bit flag or bit flags + * + * @throws {InvalidValueException} When flags is not acceptable for this enumeration type + * + * @returns {this} The enum instance for computed value + */ + withoutFlags(flags) { + if (!this.constructor.accepts(flags)) { + throw new InvalidValueException(flags, this.constructor.name); + } + + return new this.constructor(this.value & ~flags); + } + + static readableForNone() { + return 'None'; + } + + /** + * @param {Number} value + * @param {String} separator A delimiter used between each bit flag's readable string + */ + static readableFor(value, separator = '; ') { + if (!this.accepts(value)) { + throw new InvalidValueException(value, this.name); + } + + if (value === this.NONE) { + return this.readableForNone(); + } + + const humanRepresentations = this.readables; + + if (humanRepresentations[value]) { + return humanRepresentations[value]; + } + + const parts = []; + + Object.entries(humanRepresentations).forEach(([flag, readableValue]) => { + flag = parseInt(flag); + if (flag === (flag & value)) { + parts.push(readableValue); + } + }) + + return parts.join(separator); + } + + /** + * @param {String} separator A delimiter used between each bit flag's readable string + * + * @returns {String} + */ + getReadable(separator = '; ') { + return this.constructor.readableFor(this.value, separator); + } +} + +class InvalidValueException extends Error { + constructor(value, enumClass) { + super( + `Invalid value for "${enumClass}" enum type. ` + + `Expected one of ${JSON.stringify(eval(enumClass).values)}` + + (eval(enumClass).prototype instanceof FlaggedEnum ? ' or a valid combination of those flags' : '') + + `. Got ${JSON.stringify(value)}.`, + ); + + this.name = 'InvalidValueException'; + this.enumClass = enumClass; + this.value = value; + } +} + +export { ReadableEnum, FlaggedEnum, InvalidValueException }; + +export default Enum; diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php index 7574df22..abf5ef79 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php @@ -11,6 +11,7 @@ namespace Elao\Enum\Bridge\Symfony\Bundle\DependencyInjection; use Elao\Enum\EnumInterface; +use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; use Symfony\Component\Serializer\SerializerInterface; @@ -24,52 +25,44 @@ public function getConfigTreeBuilder() $rootNode->children() ->arrayNode('argument_value_resolver')->canBeDisabled()->end() - ->arrayNode('doctrine') - ->addDefaultsIfNotSet() - ->fixXmlConfig('type') - ->children() - ->booleanNode('enum_sql_declaration') - ->defaultValue(false) - ->info('If true, by default for string enumerations, generate DBAL types with an ENUM SQL declaration with enum values instead of a VARCHAR (Your platform must support it)') - ->end() - ->arrayNode('types') - ->validate() - ->ifTrue(static function (array $v): bool { - $classes = array_keys($v); - foreach ($classes as $class) { - if (!is_a($class, EnumInterface::class, true)) { - return true; - } - } + ->arrayNode('serializer') + ->{interface_exists(SerializerInterface::class) ? 'canBeDisabled' : 'canBeEnabled'}() + ->end() + ->end(); - return false; - }) - ->then(static function (array $v) { - $classes = array_keys($v); - $invalids = []; - foreach ($classes as $class) { - if (!is_a($class, EnumInterface::class, true)) { - $invalids[] = $class; - } - } + $this->addDoctrineSection($rootNode); + $this->addTranslationExtractorSection($rootNode); + $this->addJsEnumsSection($rootNode); - throw new \InvalidArgumentException(sprintf( - 'Invalid classes %s. Expected instances of "%s"', - json_encode($invalids), - EnumInterface::class) - ); - }) - ->end() - ->useAttributeAsKey('class') - ->arrayPrototype() - ->beforeNormalization() - ->ifString()->then(static function (string $v): array { return ['name' => $v]; }) - ->end() - ->children() - ->scalarNode('name')->cannotBeEmpty()->end() - ->enumNode('type') - ->values(['enum', 'string', 'int']) - ->info(<<children() + ->arrayNode('doctrine') + ->addDefaultsIfNotSet() + ->fixXmlConfig('type') + ->children() + ->booleanNode('enum_sql_declaration') + ->defaultValue(false) + ->info('If true, by default for string enumerations, generate DBAL types with an ENUM SQL declaration with enum values instead of a VARCHAR (Your platform must support it)') + ->end() + ->arrayNode('types') + ->validate() + ->ifTrue(static function (array $v): bool {return self::hasNonEnumKeys($v); }) + ->then(static function (array $v) { self::throwsNonEnumKeysException($v); }) + ->end() + ->useAttributeAsKey('class') + ->arrayPrototype() + ->beforeNormalization() + ->ifString()->then(static function (string $v): array { return ['name' => $v]; }) + ->end() + ->children() + ->scalarNode('name')->cannotBeEmpty()->end() + ->enumNode('type') + ->values(['enum', 'string', 'int']) + ->info(<<cannotBeEmpty() - ->defaultValue(null) - ->end() - ->end() + ) + ->cannotBeEmpty() + ->defaultValue(null) ->end() ->end() ->end() ->end() - ->arrayNode('serializer') - ->{interface_exists(SerializerInterface::class) ? 'canBeDisabled' : 'canBeEnabled'}() - ->end() + ->end(); + } + + private function addTranslationExtractorSection(ArrayNodeDefinition $rootNode) + { + $rootNode->children() ->arrayNode('translation_extractor') - ->canBeEnabled() + ->canBeEnabled() + ->fixXmlConfig('path') + ->children() + ->arrayNode('paths') + ->example(['App\Enum' => '%kernel.project_dir%/src/Enum']) + ->useAttributeAsKey('namespace') + ->scalarPrototype() + ->end() + ->end() + ->scalarNode('domain') + ->defaultValue('messages') + ->cannotBeEmpty() + ->end() + ->scalarNode('filename_pattern') + ->example('*Enum.php') + ->defaultValue('*.php') + ->cannotBeEmpty() + ->end() + ->arrayNode('ignore') + ->example(['%kernel.project_dir%/src/Enum/Other/*']) + ->scalarPrototype()->cannotBeEmpty()->end() + ->end() + ->end() + ->end(); + } + + private function addJsEnumsSection(ArrayNodeDefinition $rootNode) + { + $rootNode->children() + ->arrayNode('js') + ->addDefaultsIfNotSet() ->fixXmlConfig('path') ->children() - ->arrayNode('paths') - ->example(['App\Enum' => '%kernel.project_dir%/src/Enum']) - ->useAttributeAsKey('namespace') - ->scalarPrototype() - ->end() - ->end() - ->scalarNode('domain') - ->defaultValue('messages') - ->cannotBeEmpty() + ->scalarNode('base_dir') + ->info('A prefixed dir used for relative paths supplied for each of the generated enums and library path') + ->example('%kernel.project_dir%/assets/js/modules') + ->defaultNull() ->end() - ->scalarNode('filename_pattern') - ->example('*Enum.php') - ->defaultValue('*.php') - ->cannotBeEmpty() + ->scalarNode('lib_path') + ->info('The path of the file were to place the javascript library sources used by the dumped enums.') + ->example('%kernel.project_dir%/assets/js/lib/enum.js') + ->defaultNull() ->end() - ->arrayNode('ignore') - ->example(['%kernel.project_dir%/src/Enum/Other/*']) - ->scalarPrototype()->cannotBeEmpty()->end() + ->arrayNode('paths') + ->defaultValue([]) + ->info('Path where to generate the javascript enums per enum class') + ->example(['App\Enum\SimpleEnum' => '"common/SimpleEnum.js"']) + ->useAttributeAsKey('class') + ->validate() + ->ifTrue(static function (array $v): bool {return self::hasNonEnumKeys($v); }) + ->then(static function (array $v) { self::throwsNonEnumKeysException($v); }) + ->end() + ->scalarPrototype()->end() ->end() ->end() ->end() ->end(); + } - return $treeBuilder; + private static function hasNonEnumKeys(array $values): bool + { + $classes = array_keys($values); + foreach ($classes as $class) { + if (!is_a($class, EnumInterface::class, true)) { + return true; + } + } + + return false; + } + + private static function throwsNonEnumKeysException(array $values) + { + $classes = array_keys($values); + $invalids = []; + foreach ($classes as $class) { + if (!is_a($class, EnumInterface::class, true)) { + $invalids[] = $class; + } + } + + throw new \InvalidArgumentException(sprintf( + 'Invalid classes %s. Expected instances of "%s"', + json_encode($invalids), + EnumInterface::class + )); } } diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ElaoEnumExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ElaoEnumExtension.php index 7d1fe99a..a4dc2a31 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ElaoEnumExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ElaoEnumExtension.php @@ -13,6 +13,7 @@ use ApiPlatform\Core\JsonSchema\TypeFactory; use Elao\Enum\Bridge\ApiPlatform\Core\JsonSchema\Type\ElaoEnumType; use Elao\Enum\Bridge\Doctrine\DBAL\Types\TypesDumper; +use Elao\Enum\Bridge\Symfony\Console\Command\DumpJsEnumsCommand; use Elao\Enum\Bridge\Symfony\HttpKernel\Controller\ArgumentResolver\EnumValueResolver; use Elao\Enum\Bridge\Symfony\Serializer\Normalizer\EnumNormalizer; use Elao\Enum\Bridge\Symfony\Translation\Extractor\EnumExtractor; @@ -93,6 +94,13 @@ public function load(array $configs, ContainerBuilder $container) }, array_keys($types), $types) ); } + + $jsEnums = $config['js']; + $container->getDefinition(DumpJsEnumsCommand::class) + ->replaceArgument(0, $jsEnums['paths']) + ->replaceArgument(1, $jsEnums['base_dir']) + ->replaceArgument(2, $jsEnums['lib_path']) + ; } public function getNamespace() diff --git a/src/Bridge/Symfony/Bundle/Resources/config/schema/elao_enum.xsd b/src/Bridge/Symfony/Bundle/Resources/config/schema/elao_enum.xsd index d19c7492..45e95715 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/schema/elao_enum.xsd +++ b/src/Bridge/Symfony/Bundle/Resources/config/schema/elao_enum.xsd @@ -13,6 +13,7 @@ + @@ -31,6 +32,21 @@ + + + + + + + + + + + + + + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/services.xml b/src/Bridge/Symfony/Bundle/Resources/config/services.xml index c5139d9a..1028e594 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/services.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/services.xml @@ -7,6 +7,13 @@ + + + + + + + diff --git a/src/Bridge/Symfony/Console/Command/DumpJsEnumsCommand.php b/src/Bridge/Symfony/Console/Command/DumpJsEnumsCommand.php new file mode 100644 index 00000000..247427bc --- /dev/null +++ b/src/Bridge/Symfony/Console/Command/DumpJsEnumsCommand.php @@ -0,0 +1,100 @@ + + */ + +namespace Elao\Enum\Bridge\Symfony\Console\Command; + +use Elao\Enum\JsDumper\JsDumper; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +class DumpJsEnumsCommand extends Command +{ + protected static $defaultName = 'elao:enum:dump-js'; + + /** + * @var array, string> Paths indexed by enum FQCN + */ + private $enums; + + /** @var string|null */ + private $baseDir; + + /** @var string|null */ + private $libPath; + + /** @var JsDumper */ + private $dumper; + + public function __construct(array $enums = [], string $baseDir = null, string $libPath = null) + { + $this->enums = $enums; + $this->baseDir = $baseDir; + $this->libPath = $libPath; + + parent::__construct(); + } + + protected function configure() + { + $this + ->setDescription('Generate javascript enums') + ->addArgument('enums', InputArgument::IS_ARRAY, 'The enums & paths of the files where to generate the javascript enums. Format: "enum FQCN:path"') + ->addOption('base-dir', null, InputOption::VALUE_REQUIRED, 'A prefixed dir used for relative paths supplied for each of the generated enums and library path', $this->baseDir) + ->addOption('lib-path', null, InputOption::VALUE_REQUIRED, 'The path of the file were to place the javascript library sources used by the dumped enums.', $this->libPath) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $io = new SymfonyStyle($input, $output); + + $io->title('Elao Enums Javascript generator'); + + $io->note(<<getOption('lib-path'); + + if (!$libPath) { + throw new \InvalidArgumentException('Please provide the "--lib-path" option'); + } + + $enums = $this->enums; + /** @var string[] $enumArgs */ + if ($enumArgs = $input->getArgument('enums')) { + $enums = []; + foreach ($enumArgs as $arg) { + list($fqcn, $path) = explode(':', $arg, 2); + $enums[$fqcn] = $path; + } + } + + $this->dumper = new JsDumper($libPath, $input->getOption('base-dir')); + + $io->comment("Generating library sources at path {$this->dumper->normalizePath($libPath)}"); + + $this->dumper->dumpLibrarySources(); + + foreach ($enums as $fqcn => $path) { + $shortName = (new \ReflectionClass($fqcn))->getShortName(); + $io->comment("Generating $shortName enum at path {$this->dumper->normalizePath($path)}"); + $this->dumper->dumpEnumToFile($fqcn, $path); + } + + return 0; + } +} diff --git a/src/JsDumper/JsDumper.php b/src/JsDumper/JsDumper.php new file mode 100644 index 00000000..8d92f67a --- /dev/null +++ b/src/JsDumper/JsDumper.php @@ -0,0 +1,312 @@ + + */ + +namespace Elao\Enum\JsDumper; + +use Elao\Enum\FlaggedEnum; +use Elao\Enum\ReadableEnumInterface; +use Symfony\Component\Filesystem\Filesystem; + +/** + * @internal + */ +class JsDumper +{ + const DISCLAIMER = <<fs = new Filesystem(); + $this->baseDir = $baseDir; + $this->libPath = $this->normalizePath($libPath); + } + + public function dumpLibrarySources() + { + $disclaimer = self::DISCLAIMER; + $sourceCode = file_get_contents(__DIR__ . '/../../res/js/Enum.js'); + $this->baseDir !== null && $this->fs->mkdir($this->baseDir); + $this->fs->dumpFile($this->libPath, "$disclaimer\n\n$sourceCode"); + } + + /** + * @param class-string $enumFqcn + */ + public function dumpEnumToFile(string $enumFqcn, string $path) + { + !file_exists($this->libPath) && $this->dumpLibrarySources(); + $this->baseDir !== null && $this->fs->mkdir($this->baseDir); + + $path = $this->normalizePath($path); + + $disclaimer = self::DISCLAIMER; + $this->fs->dumpFile($path, "$disclaimer\n\n"); + + $code = ''; + $code .= $this->dumpImports($path, $enumFqcn); + $code .= $this->dumpEnumClass($enumFqcn); + // End file with export + $code .= "\nexport default {$this->getShortName($enumFqcn)}\n"; + + // Dump to file: + $this->fs->appendToFile($path, $code); + } + + /** + * @param class-string $enumFqcn + */ + public function dumpEnumClass(string $enumFqcn): string + { + $code = ''; + $code .= $this->startClass($enumFqcn); + $code .= $this->generateEnumerableValues($enumFqcn); + $code .= $this->generateMasks($enumFqcn); + $code .= $this->generateReadables($enumFqcn); + $code .= "}\n"; // End class + + return $code; + } + + private function dumpImports(string $path, string $enumFqcn): string + { + $relativeLibPath = preg_replace('#.js$#', '', rtrim( + $this->fs->makePathRelative(realpath($this->libPath), realpath(\dirname($path))), + DIRECTORY_SEPARATOR + )); + + if (is_a($enumFqcn, FlaggedEnum::class, true)) { + return "import { FlaggedEnum } from '$relativeLibPath';\n\n"; + } + + if (is_a($enumFqcn, ReadableEnumInterface::class, true)) { + return "import { ReadableEnum } from '$relativeLibPath';\n\n"; + } + + return "import Enum from '$relativeLibPath';\n\n"; + } + + private function startClass(string $enumFqcn): string + { + $shortName = $this->getShortName($enumFqcn); + $baseClass = 'Enum'; + + if (is_a($enumFqcn, FlaggedEnum::class, true)) { + $baseClass = 'FlaggedEnum'; + } elseif (is_a($enumFqcn, ReadableEnumInterface::class, true)) { + $baseClass = 'ReadableEnum'; + } + + return "class $shortName extends $baseClass {\n"; + } + + private function generateEnumerableValues(string $enumFqcn): string + { + $code = ''; + foreach ($this->getEnumerableValues($enumFqcn) as $constant => $value) { + $jsValue = \is_string($value) ? "'$value'" : $value; + $code .= " static $constant = $jsValue\n"; + } + + return $code; + } + + private function generateMasks(string $enumFqcn) + { + if (!is_a($enumFqcn, FlaggedEnum::class, true)) { + return ''; + } + + $code = "\n // Named masks\n"; + foreach ($this->getMasks($enumFqcn) as $constant => $value) { + $jsValue = \is_string($value) ? "'$value'" : $value; + $code .= " static $constant = $jsValue\n"; + } + + return $code; + } + + private function generateReadables(string $enumFqcn): string + { + if (!is_a($enumFqcn, ReadableEnumInterface::class, true)) { + return ''; + } + + $shortName = $this->getShortName($enumFqcn); + // Get readable entries + $readablesCode = ''; + $readables = $enumFqcn::readables(); + + // Generate all values + foreach ($this->getEnumerableValues($enumFqcn) as $constant => $value) { + if (!$readable = $readables[$value] ?? false) { + continue; + } + + $readablesCode .= + <<getMasks($enumFqcn) as $constant => $value) { + if (!$readable = $readables[$value] ?? false) { + continue; + } + + $readablesCode .= + << $enumFqcn + */ + private function getEnumerableValues(string $enumFqcn): array + { + $constants = $this->getConstants($enumFqcn); + + $enumerableValues = []; + foreach ($constants as $constant) { + $value = \constant("$enumFqcn::$constant"); + + if (is_a($enumFqcn, FlaggedEnum::class, true)) { + // Continue if not a bit flag: + if (!(\is_int($value) && 0 === ($value & $value - 1) && $value > 0)) { + continue; + } + } elseif (!\is_int($value) && !\is_string($value)) { + // Continue if not an int or string: + continue; + } + + $enumerableValues[$constant] = $value; + } + + return $enumerableValues; + } + + /** + * @param class-string $enumFqcn + */ + private function getMasks(string $enumFqcn): array + { + if (!is_a($enumFqcn, FlaggedEnum::class, true)) { + return []; + } + + $constants = $this->getConstants($enumFqcn); + + $masks = []; + foreach ($constants as $constant) { + $value = \constant("$enumFqcn::$constant"); + + // Continue if it's not part of the flagged enum bitmask: + if (!\is_int($value) || $value <= 0 || !$enumFqcn::accepts($value)) { + continue; + } + + // Continue it's a single bit flag: + if (\in_array($value, $enumFqcn::values(), true)) { + continue; + } + + $masks[$constant] = $value; + } + + return $masks; + } + + /** + * @param class-string $enumFqcn + */ + private function getShortName(string $enumFqcn): string + { + static $cache = []; + + return $cache[$enumFqcn] = $cache[$enumFqcn] ?? (new \ReflectionClass($enumFqcn))->getShortName(); + } + + /** + * @param class-string $enumFqcn + */ + private function getConstants(string $enumFqcn): array + { + $r = new \ReflectionClass($enumFqcn); + $constants = array_filter( + $r->getConstants(), + static function (string $k) use ($r, $enumFqcn) { + if (PHP_VERSION_ID >= 70100) { + // ReflectionClass::getReflectionConstant() is only available since PHP 7.1 + $rConstant = $r->getReflectionConstant($k); + $public = $rConstant->isPublic(); + $value = $rConstant->getValue(); + } else { + $public = true; + $value = \constant("{$r->getName()}::$k"); + } + + // Only keep public constants, for which value matches enumerable values set: + return $public && $enumFqcn::accepts($value); + }, + ARRAY_FILTER_USE_KEY + ); + + $constants = array_flip($constants); + + return $constants; + } + + public function normalizePath(string $path): string + { + if (null === $this->baseDir) { + return $path; + } + + if ($this->fs->isAbsolutePath($path)) { + return $path; + } + + if (0 === strpos($path, './')) { + return $path; + } + + return rtrim($this->baseDir, DIRECTORY_SEPARATOR) . '/' . $path; + } +} diff --git a/tests/Fixtures/Bridge/Symfony/Bundle/DependencyInjection/ElaoEnumExtension/xml/js_enums.xml b/tests/Fixtures/Bridge/Symfony/Bundle/DependencyInjection/ElaoEnumExtension/xml/js_enums.xml new file mode 100644 index 00000000..4707c113 --- /dev/null +++ b/tests/Fixtures/Bridge/Symfony/Bundle/DependencyInjection/ElaoEnumExtension/xml/js_enums.xml @@ -0,0 +1,15 @@ + + + + + + + common/SimpleEnum.js + common/Gender.js + auth/Permissions.js + + + diff --git a/tests/Fixtures/Bridge/Symfony/Bundle/DependencyInjection/ElaoEnumExtension/yaml/js_enums.yaml b/tests/Fixtures/Bridge/Symfony/Bundle/DependencyInjection/ElaoEnumExtension/yaml/js_enums.yaml new file mode 100644 index 00000000..f5113536 --- /dev/null +++ b/tests/Fixtures/Bridge/Symfony/Bundle/DependencyInjection/ElaoEnumExtension/yaml/js_enums.yaml @@ -0,0 +1,8 @@ +elao_enum: + js: + base_dir: 'assets/js/modules' + lib_path: 'assets/js/lib' + paths: + Elao\Enum\Tests\Fixtures\Enum\SimpleEnum: 'common/SimpleEnum.js' + Elao\Enum\Tests\Fixtures\Enum\Gender: 'common/Gender.js' + Elao\Enum\Tests\Fixtures\Enum\Permissions: 'auth/Permissions.js' diff --git a/tests/Fixtures/JsDumper/expected/flagged_enum.js b/tests/Fixtures/JsDumper/expected/flagged_enum.js new file mode 100644 index 00000000..36988ec1 --- /dev/null +++ b/tests/Fixtures/JsDumper/expected/flagged_enum.js @@ -0,0 +1,19 @@ +class Permissions extends FlaggedEnum { + static EXECUTE = 1 + static WRITE = 2 + static READ = 4 + + // Named masks + static ALL = 7 + + static get readables() { + return { + [Permissions.EXECUTE]: 'Execute', + [Permissions.WRITE]: 'Write', + [Permissions.READ]: 'Read', + + // Named masks + [Permissions.ALL]: 'All permissions', + }; + } +} diff --git a/tests/Fixtures/JsDumper/expected/readable_enum.js b/tests/Fixtures/JsDumper/expected/readable_enum.js new file mode 100644 index 00000000..fe60b54b --- /dev/null +++ b/tests/Fixtures/JsDumper/expected/readable_enum.js @@ -0,0 +1,13 @@ +class Gender extends ReadableEnum { + static UNKNOW = 'unknown' + static MALE = 'male' + static FEMALE = 'female' + + static get readables() { + return { + [Gender.UNKNOW]: 'Unknown', + [Gender.MALE]: 'Male', + [Gender.FEMALE]: 'Female', + }; + } +} diff --git a/tests/Fixtures/JsDumper/expected/simple_enum.js b/tests/Fixtures/JsDumper/expected/simple_enum.js new file mode 100644 index 00000000..63387db8 --- /dev/null +++ b/tests/Fixtures/JsDumper/expected/simple_enum.js @@ -0,0 +1,5 @@ +class SimpleEnum extends Enum { + static ZERO = 0 + static FIRST = 1 + static SECOND = 2 +} diff --git a/tests/Unit/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Unit/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 42defa24..bc898f13 100644 --- a/tests/Unit/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Unit/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -37,21 +37,10 @@ public function testDisabledConfig() 'translation_extractor' => false, ]]); - $this->assertEquals([ + $this->assertEquals(array_merge($this->getDefaultConfig(), [ 'argument_value_resolver' => ['enabled' => false], 'serializer' => ['enabled' => false], - 'translation_extractor' => [ - 'enabled' => false, - 'paths' => [], - 'domain' => 'messages', - 'filename_pattern' => '*.php', - 'ignore' => [], - ], - 'doctrine' => [ - 'enum_sql_declaration' => false, - 'types' => [], - ], - ], $config); + ]), $config); } public function testDoctrineConfig() @@ -117,6 +106,30 @@ public function testTranslationExtractorConfig() ], $config['translation_extractor']); } + public function testJsEnumConfig() + { + $processor = new Processor(); + $config = $processor->processConfiguration(new Configuration(), [[ + 'js' => [ + 'lib_path' => 'assets/lib', + 'base_dir' => 'assets/modules', + 'paths' => [ + Gender::class => 'common/Gender.js', + Permissions::class => 'auth/Permissions.js', + ], + ], + ]]); + + $this->assertEquals([ + 'lib_path' => 'assets/lib', + 'base_dir' => 'assets/modules', + 'paths' => [ + Gender::class => 'common/Gender.js', + Permissions::class => 'auth/Permissions.js', + ], + ], $config['js']); + } + private function getDefaultConfig(): array { return [ @@ -133,6 +146,11 @@ private function getDefaultConfig(): array 'enum_sql_declaration' => false, 'types' => [], ], + 'js' => [ + 'base_dir' => null, + 'lib_path' => null, + 'paths' => [], + ], ]; } } diff --git a/tests/Unit/Bridge/Symfony/Bundle/DependencyInjection/ElaoEnumExtensionTest.php b/tests/Unit/Bridge/Symfony/Bundle/DependencyInjection/ElaoEnumExtensionTest.php index f62c91a5..4e74e8b2 100644 --- a/tests/Unit/Bridge/Symfony/Bundle/DependencyInjection/ElaoEnumExtensionTest.php +++ b/tests/Unit/Bridge/Symfony/Bundle/DependencyInjection/ElaoEnumExtensionTest.php @@ -12,12 +12,14 @@ use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Elao\Enum\Bridge\Symfony\Bundle\DependencyInjection\ElaoEnumExtension; +use Elao\Enum\Bridge\Symfony\Console\Command\DumpJsEnumsCommand; use Elao\Enum\Bridge\Symfony\HttpKernel\Controller\ArgumentResolver\EnumValueResolver; use Elao\Enum\Bridge\Symfony\Serializer\Normalizer\EnumNormalizer; use Elao\Enum\Bridge\Symfony\Translation\Extractor\EnumExtractor; use Elao\Enum\Tests\Fixtures\Enum\AnotherEnum; use Elao\Enum\Tests\Fixtures\Enum\Gender; use Elao\Enum\Tests\Fixtures\Enum\Permissions; +use Elao\Enum\Tests\Fixtures\Enum\SimpleEnum; use PHPUnit\Framework\TestCase; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ParameterBag\EnvPlaceholderParameterBag; @@ -34,6 +36,7 @@ public function testDefaults() self::assertTrue($container->hasDefinition(EnumValueResolver::class), 'arg resolver is loaded'); self::assertFalse($container->hasParameter('.elao_enum.doctrine_types'), 'no doctrine type are registered'); self::assertFalse($container->hasDefinition(EnumExtractor::class), 'translation extractor is removed'); + self::assertTrue($container->hasDefinition(DumpJsEnumsCommand::class), 'dump js command is loaded'); } public function testDisabledArgValueResolver() @@ -122,6 +125,22 @@ public function testTranslationExtractor() ], $container->getDefinition(EnumExtractor::class)->getArguments()); } + public function testJsEnumsExtractor() + { + $container = $this->createContainerFromFile('js_enums'); + + self::assertTrue($container->hasDefinition(DumpJsEnumsCommand::class), 'dump js command is loaded'); + self::assertEquals([ + [ + SimpleEnum::class => 'common/SimpleEnum.js', + Gender::class => 'common/Gender.js', + Permissions::class => 'auth/Permissions.js', + ], + 'assets/js/modules', + 'assets/js/lib', + ], $container->getDefinition(DumpJsEnumsCommand::class)->getArguments()); + } + protected function createContainerFromFile(string $file, bool $compile = true): ContainerBuilder { $container = $this->createContainer(); diff --git a/tests/Unit/JsDumper/JsDumperTest.php b/tests/Unit/JsDumper/JsDumperTest.php new file mode 100644 index 00000000..48191596 --- /dev/null +++ b/tests/Unit/JsDumper/JsDumperTest.php @@ -0,0 +1,164 @@ + + */ + +namespace Elao\Enum\Tests\Unit\JsDumper; + +use Elao\Enum\JsDumper\JsDumper; +use Elao\Enum\Tests\Fixtures\Enum\Gender; +use Elao\Enum\Tests\Fixtures\Enum\Permissions; +use Elao\Enum\Tests\Fixtures\Enum\SimpleEnum; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Filesystem\Filesystem; + +class JsDumperTest extends TestCase +{ + /** @var string */ + private static $dumpDir; + + /** @var Filesystem */ + private $fs; + + public static function setUpBeforeClass(): void + { + self::$dumpDir = self::getDumpDir(); + } + + protected function setUp(): void + { + $this->fs = new Filesystem(); + $this->fs->remove(self::$dumpDir); + } + + /** + * @dataProvider provide testNormalizePath data + */ + public function testNormalizePath(string $path, string $baseDir = null, string $expectedPath) + { + $dumper = new JsDumper('lib-path', $baseDir); + + self::assertSame($expectedPath, $dumper->normalizePath($path)); + } + + public function provide testNormalizePath data() + { + yield 'no base dir' => ['foo/enum.js', null, 'foo/enum.js']; + yield 'with base dir' => ['enum.js', 'foo', 'foo/enum.js']; + yield 'with absolute path' => ['/bar/enum.js', 'foo', '/bar/enum.js']; + yield 'with ../ path' => ['../enum.js', 'foo/bar/', 'foo/bar/../enum.js']; + yield 'with ./ path' => ['./enum.js', 'foo', './enum.js']; + } + + /** + * @dataProvider provide testDumpLibrary data + */ + public function testDumpLibrary(string $libPath, string $baseDir = null, string $expectedPath) + { + $dumper = new JsDumper($libPath, $baseDir); + $dumper->dumpLibrarySources(); + + self::assertStringEqualsFile( + $expectedPath, + JsDumper::DISCLAIMER . "\n\n" . file_get_contents(PACKAGE_ROOT_DIR . '/res/js/Enum.js') + ); + } + + public function provide testDumpLibrary data() + { + $dumpDir = self::getDumpDir(); + + yield 'no base dir' => ["$dumpDir/enum.js", null, "$dumpDir/enum.js"]; + yield 'with base dir' => ['enum.js', $dumpDir, "$dumpDir/enum.js"]; + } + + /** + * @dataProvider provide testDumpEnumToFile data + */ + public function testDumpEnumToFile( + string $enumClass, + string $path, + string $libPath, + string $baseDir = null, + string $expectedPath, + string $expectedImportPath + ) { + $dumper = new JsDumper($libPath, $baseDir); + $dumper->dumpLibrarySources(); + $dumper->dumpEnumToFile($enumClass, $path); + + self::assertFileExists($expectedPath); + self::assertStringContainsString("import Enum from '$expectedImportPath'", file_get_contents($expectedPath)); + } + + public function provide testDumpEnumToFile data() + { + $dumpDir = self::getDumpDir(); + + yield 'no base dir' => [ + 'enumClass' => SimpleEnum::class, + 'path' => "$dumpDir/module/common/simple_enum.js", + 'libPath' => "$dumpDir/lib/enum.js", + 'baseDir' => null, + 'expectedPath' => "$dumpDir/module/common/simple_enum.js", + 'expectedImportPath' => '../../lib/enum', + ]; + + yield 'with base dir' => [ + 'enumClass' => SimpleEnum::class, + 'path' => 'simple_enum.js', + 'libPath' => "$dumpDir/lib/enum.js", + 'baseDir' => "$dumpDir/module/common", + 'expectedPath' => "$dumpDir/module/common/simple_enum.js", + 'expectedImportPath' => '../../lib/enum', + ]; + + yield 'with same base dir for lib' => [ + 'enumClass' => SimpleEnum::class, + 'path' => 'enums/simple_enum.js', + 'libPath' => 'lib/enum.js', + 'baseDir' => "$dumpDir/assets/js", + 'expectedPath' => "$dumpDir/assets/js/enums/simple_enum.js", + 'expectedImportPath' => '../lib/enum', + ]; + } + + /** + * @dataProvider provide testDumpEnumClass data + */ + public function testDumpEnumClass(string $enumClass, string $expectationFilePath) + { + $dumper = new JsDumper('enum.js'); + $js = $dumper->dumpEnumClass($enumClass); + + self::assertStringEqualsFile(FIXTURES_DIR . "/JsDumper/expected/$expectationFilePath", $js); + } + + public function provide testDumpEnumClass data() + { + yield 'simple enum' => [ + 'enumClass' => SimpleEnum::class, + 'expectationFilePath' => 'simple_enum.js', + ]; + + yield 'readable enum' => [ + 'enumClass' => Gender::class, + 'expectationFilePath' => 'readable_enum.js', + ]; + + yield 'flagged enum' => [ + 'enumClass' => Permissions::class, + 'expectationFilePath' => 'flagged_enum.js', + ]; + } + + private static function getDumpDir(): string + { + return sys_get_temp_dir() . '/ElaoEnum/JsDumperTest'; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 9851d54b..387395db 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -19,6 +19,13 @@ $loader = require __DIR__ . '/../vendor/autoload.php'; +if (file_exists($varDumper = __DIR__ . '/../vendor/symfony/symfony/src/Symfony/Component/VarDumper/Resources/functions/dump.php')) { + require_once $varDumper; +} else { + require_once __DIR__ . '/../vendor/symfony/var-dumper/Resources/functions/dump.php'; +} + +const PACKAGE_ROOT_DIR = __DIR__ . '/..'; const FIXTURES_DIR = __DIR__ . '/Fixtures'; // Prepare integration tests: