From fdefdd31ff175eb6da013f5c1e8bd3d7fd669a96 Mon Sep 17 00:00:00 2001 From: Guy Marriott Date: Fri, 24 May 2019 11:23:56 +1200 Subject: [PATCH] NEW Add AnnotationTransformer to allow configuration through PHP docblock annotations --- docs/manifesto.md | 2 +- docs/transformers.md | 32 +++ src/Transformer/AnnotationTransformer.php | 214 ++++++++++++++++++ .../AnnotationDefinitionInterface.php | 60 +++++ tests/AnnotationTransformer/TestClass.php | 13 ++ .../TestClassConstructor.php | 15 ++ .../AnnotationTransformer/TestDefinition.php | 56 +++++ .../AnnotationTransformer/TestEverything.php | 33 +++ tests/AnnotationTransformer/TestMethods.php | 16 ++ .../Transformer/AnnotationTransformerTest.php | 95 ++++++++ .../PrivateStaticTransformerTest.php | 2 +- 11 files changed, 536 insertions(+), 2 deletions(-) create mode 100644 src/Transformer/AnnotationTransformer.php create mode 100644 src/Transformer/AnnotationTransformer/AnnotationDefinitionInterface.php create mode 100644 tests/AnnotationTransformer/TestClass.php create mode 100644 tests/AnnotationTransformer/TestClassConstructor.php create mode 100644 tests/AnnotationTransformer/TestDefinition.php create mode 100644 tests/AnnotationTransformer/TestEverything.php create mode 100644 tests/AnnotationTransformer/TestMethods.php create mode 100644 tests/Transformer/AnnotationTransformerTest.php diff --git a/docs/manifesto.md b/docs/manifesto.md index 14a2bf2..3072238 100644 --- a/docs/manifesto.md +++ b/docs/manifesto.md @@ -2,7 +2,7 @@ ## Simplicity over Complexity -We will strive to simplyfy the code and document its features and concepts at every level +We will strive to simplify the code and document its features and concepts at every level making it easier for others to understand and contribute. ## Performance over Features diff --git a/docs/transformers.md b/docs/transformers.md index c0797cd..860e104 100644 --- a/docs/transformers.md +++ b/docs/transformers.md @@ -76,3 +76,35 @@ ->getClassConfig() the private statics of a class and returns them as an array ``` + + +## Annotation Transformer + +``` ++--------------------------------+ +--------------------------------+ +| | | | +| Array of Classes | | Array of | +| (eg. SilverStripe ClassLoader) | | AnnotationDefinitionInterfaces | +| | | | ++---------------+----------------+ +---------------+----------------+ + | | + |-------------------------------------+ + | + v +----------------------+ + +--------------+-----------+ | | + | | | PHP config keyed | + | Annotation transformer +----------> transform() +---------->+ by priority. | + | | ^ | | + +--------------------------+ | +----------------------+ + | + | + | + | + | + + + Uses reflection to lookup + ->getClassConfig() the annotations in docblocks + and returns them as an array +``` + +See `AnnotationDefinitionInterface` for more detail. diff --git a/src/Transformer/AnnotationTransformer.php b/src/Transformer/AnnotationTransformer.php new file mode 100644 index 0000000..9a51ba6 --- /dev/null +++ b/src/Transformer/AnnotationTransformer.php @@ -0,0 +1,214 @@ +classResolver = $classResolver; + + foreach ($annotationDefinitions as $annotationDefinition) { + if (!$annotationDefinition instanceof AnnotationDefinitionInterface) { + throw new InvalidArgumentException(sprintf( + 'Annotation definitions provided to %s must implement %s', + __CLASS__, + AnnotationDefinitionInterface::class + )); + } + } + + $this->annotationDefinitions = $annotationDefinitions; + } + + /** + * This is responsible for parsing a single yaml file and returning it into a format + * that Config can understand. Config will then be responsible for turning thie + * output into the final merged config. + * + * @param MutableConfigCollectionInterface $collection + * @return MutableConfigCollectionInterface + * @throws \ReflectionException + */ + public function transform(MutableConfigCollectionInterface $collection) + { + if (empty($this->annotationDefinitions)) { + return $collection; + } + + foreach ($this->getClasses() as $className) { + // Skip classes that don't exist + if (!class_exists($className)) { + continue; + } + + $config = []; + + foreach ($this->annotationDefinitions as $definition) { + // Check if this class should be affected at all + if (!$definition->shouldCollect($className)) { + continue; + } + + $classReflector = new ReflectionClass($className); + $scopes = $definition->defineCollectionScopes(); + + $config = []; + + // Collect from class docblocks + if ( + $scopes & AnnotationDefinitionInterface::COLLECT_CLASS + && ($doc = $classReflector->getDocComment()) + ) { + $config = $this->augmentConfigForBlock( + $config, + $doc, + $definition, + AnnotationDefinitionInterface::COLLECT_CLASS + ); + } + + // Collect from constructors separately to other methods + if ( + $scopes & AnnotationDefinitionInterface::COLLECT_CONSTRUCTOR + && ($constructor = $classReflector->getConstructor()) + && ($doc = $constructor->getDocComment()) + ) { + $config = array_merge($config, $this->augmentConfigForBlock( + $config, + $doc, + $definition, + AnnotationDefinitionInterface::COLLECT_CONSTRUCTOR + )); + } + + // Collect from methods + if ($scopes & AnnotationDefinitionInterface::COLLECT_METHODS) { + foreach ($classReflector->getMethods() as $method) { + if ( + $method->isConstructor() + || !$definition->shouldCollectFromMethod($classReflector, $method) + || !($docBlock = $method->getDocComment()) + ) { + continue; + } + + $config = array_merge($config, $this->augmentConfigForBlock( + $config, + $docBlock, + $definition, + AnnotationDefinitionInterface::COLLECT_METHODS, + $method->getName() + )); + } + } + } + + // Add the config to the collection + foreach ($config as $name => $item) { + $collection->set($className, $name, $item); + } + } + + return $collection; + } + + /** + * Returns the list of classnames - executing the class resolver for them if it has not yet been called + * + * @return array + */ + protected function getClasses(): array + { + if (!$this->classes) { + $this->classes = call_user_func($this->classResolver) ?: []; + } + + return $this->classes; + } + + /** + * Parse a string of arguments added to an annotation (eg. @Annotation(something=1,b)) into an array + * + * @param string $arguments + * @return array + */ + protected function parseAnnotationArguments(string $arguments): array + { + if (empty($arguments)) { + return []; + } + + $parts = explode(',', $arguments); + + $arguments = []; + foreach ($parts as $part) { + if (!strpos($part, '=')) { + // Trim in the case of `=arg` (0th index `=`) + $arguments[] = trim($part, '='); + continue; + } + + list ($key, $value) = explode('=', $part, 2); + + $arguments[$key] = $value; + } + + return $arguments; + } + + protected function augmentConfigForBlock( + array $config, + string $docBlock, + AnnotationDefinitionInterface $definition, + int $context, + ?string $contextDetail = null + ): array { + $annotationMatches = implode('|', array_map('preg_quote', $definition->getAnnotationStrings())); + $pattern = '/^\s*\*\s*@(' . $annotationMatches . ')(?:\(([^)]+)\))?\s*$/m'; + + $matchCount = preg_match_all($pattern, $docBlock, $matches); + + for ($i = 0; $i < $matchCount; $i++) { + $config = array_merge_recursive($config, $definition->createConfigForAnnotation( + $matches[1][$i], + $this->parseAnnotationArguments($matches[2][$i]), + $context, + $contextDetail + )); + } + + return $config; + } +} diff --git a/src/Transformer/AnnotationTransformer/AnnotationDefinitionInterface.php b/src/Transformer/AnnotationTransformer/AnnotationDefinitionInterface.php new file mode 100644 index 0000000..f59470f --- /dev/null +++ b/src/Transformer/AnnotationTransformer/AnnotationDefinitionInterface.php @@ -0,0 +1,60 @@ + true]; + break; + case 'Bar': + $config = ['Bar' => reset($arguments)]; + break; + default: + return []; + } + + if ($context === AnnotationDefinitionInterface::COLLECT_METHODS && is_string($contextDetail)) { + return [$contextDetail => $config]; + } + + return $config; + } +} diff --git a/tests/AnnotationTransformer/TestEverything.php b/tests/AnnotationTransformer/TestEverything.php new file mode 100644 index 0000000..48a38fe --- /dev/null +++ b/tests/AnnotationTransformer/TestEverything.php @@ -0,0 +1,33 @@ +transform([$transformer]); + + $this->assertTrue($collection->get(TestClass::class, 'Foo')); + $this->assertSame('123', $collection->get(TestClass::class, 'Bar')); + } + + public function testAnnotationsAreCollectedFromConstructorDocBlocks() + { + $classResolver = function() { + return [TestClassConstructor::class]; + }; + + $collection = new MemoryConfigCollection; + $transformer = new AnnotationTransformer($classResolver, [new TestDefinition()]); + + $collection->transform([$transformer]); + + $this->assertTrue($collection->get(TestClassConstructor::class, 'Foo')); + $this->assertSame('123', $collection->get(TestClassConstructor::class, 'Bar')); + } + + public function testAnnotationsAreCollectedFromMethodDocBlocks() + { + $classResolver = function() { + return [TestMethods::class]; + }; + + $collection = new MemoryConfigCollection; + $transformer = new AnnotationTransformer($classResolver, [new TestDefinition()]); + + $collection->transform([$transformer]); + + $config = $collection->get(TestMethods::class, 'someMethod'); + + $this->assertInternalType('array', $config); + $this->assertSame([ + 'Foo' => true, + 'Bar' => '123', + ], $config); + } + + public function testClassesCanHaveManyAnnotations() + { + $classResolver = function() { + return [TestEverything::class]; + }; + + $collection = new MemoryConfigCollection; + $transformer = new AnnotationTransformer($classResolver, [new TestDefinition()]); + + $collection->transform([$transformer]); + + $foo = $collection->get(TestEverything::class, 'Foo'); + $this->assertTrue($foo); + + $bar = $collection->get(TestEverything::class, 'Bar'); + $this->assertSame(['class', '123'], $bar); + + $a = $collection->get(TestEverything::class, 'a'); + $this->assertInternalType('array', $a); + $this->assertSame([ + 'Foo' => true, + ], $a); + + $b = $collection->get(TestEverything::class, 'b'); + $this->assertInternalType('array', $b); + $this->assertSame([ + 'Bar' => 'b', + ], $b); + } +} diff --git a/tests/Transformer/PrivateStaticTransformerTest.php b/tests/Transformer/PrivateStaticTransformerTest.php index 99e668b..23f557e 100644 --- a/tests/Transformer/PrivateStaticTransformerTest.php +++ b/tests/Transformer/PrivateStaticTransformerTest.php @@ -42,7 +42,7 @@ public function testLookup() } /** - * Ensure that two classes merge to diplsay the correct config. + * Ensure that two classes merge to display the correct config. */ public function testMerge() {