Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NEW Add AnnotationTransformer to allow configuration through PHP docblock annotations #35

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
language: php

php: 5.6
php: 7.1

before_script:
- composer validate
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"description": "SilverStripe configuration based on YAML and class statics",
"license": "BSD-3-Clause",
"require": {
"php": "^7.1",
"symfony/finder": "^2.8 || ^3.2",
"symfony/yaml": "^2.8 || ^3.2",
"marcj/topsort": "^1.0",
Expand Down
2 changes: 1 addition & 1 deletion docs/manifesto.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions docs/transformers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
215 changes: 215 additions & 0 deletions src/Transformer/AnnotationTransformer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
<?php declare(strict_types=1);

namespace SilverStripe\Config\Transformer;

use InvalidArgumentException;
use ReflectionClass;
use SilverStripe\Config\Collections\MutableConfigCollectionInterface;
use SilverStripe\Config\Transformer\AnnotationTransformer\AnnotationDefinitionInterface;

class AnnotationTransformer implements TransformerInterface
{
/**
* Set a callable that can be used to resolve the classes to collect annotations from
*
* @var callable
*/
protected $classResolver;

/**
* An array of @see AnnotationDefinitionInterface that indicate what annotations are collected from doc blocks and
* how those annotations are converted to config
*
* @var AnnotationDefinitionInterface[]
*/
protected $annotationDefinitions;

/**
* The list of resolved class names once the resolver has been called
*
* @var array|null
*/
protected $classes;

/**
* AnnotationTransformer constructor.
* @param callable $classResolver
* @param array $annotationDefinitions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could annotate AnnotationDefinitionInterface[] here to make it clearer what is expected. Also worth adding a @throws for the exception if one of the entries isn't one of these interfaces

*/
public function __construct(callable $classResolver, array $annotationDefinitions)
{
$this->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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public function transform(MutableConfigCollectionInterface $collection)
public function transform(MutableConfigCollectionInterface $collection): MutableConfigCollectionInterface

{
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(trim($part, '='));
continue;
}

list ($key, $value) = explode('=', $part, 2);

$arguments[trim($key)] = trim($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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php declare(strict_types=1);

namespace SilverStripe\Config\Transformer\AnnotationTransformer;

use ReflectionClass;
use ReflectionMethod;

interface AnnotationDefinitionInterface
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be worth a doc block to explain the purpose of classes implementing this interface

{
const COLLECT_CLASS = 1;
const COLLECT_CONSTRUCTOR = 2;
const COLLECT_METHODS = 4;

/**
* Return a bitwise integer combining COLLECT_* constants indicating what doc blocks to collect annotations from
*
* @return int
*/
public function defineCollectionScopes(): int;

/**
* Indicates whether annotations should be collected from the given class
*
* @param string $className
* @return bool
*/
public function shouldCollect(string $className): bool;

/**
* Indicates whether annotations should be collected from the given method within the given class
*
* @param ReflectionClass $class
* @param ReflectionMethod $method
* @return bool
*/
public function shouldCollectFromMethod(ReflectionClass $class, ReflectionMethod $method): bool;

/**
* Get an array of annotations to look for. For example 'Foo' would indicate that '@Foo' should be matched
*
* @return array
*/
public function getAnnotationStrings(): array;

/**
* Create config from a matched annotation.
*
* @param string $annotation The annotation string that was matched (defined in @see getAnnotationStrings)
* @param array $arguments An array of strings that were passed as arguments (eg. @Foo(argument1,argument2)
* @param int $context A COLLECT_* constant that indicates what context this annotation was found in
* @param string|null $contextDetail A method name, provided the context of the annotation was a method
* @return array
*/
public function createConfigForAnnotation(
string $annotation,
array $arguments,
int $context,
?string $contextDetail = null
): array;
}
13 changes: 13 additions & 0 deletions tests/AnnotationTransformer/TestClass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace SilverStripe\Config\Tests\AnnotationTransformer;

/**
* @package FooPackage
* @Foo
* @Bar(123)
*/
class TestClass
{

}
15 changes: 15 additions & 0 deletions tests/AnnotationTransformer/TestClassConstructor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php declare(strict_types=1);

namespace SilverStripe\Config\Tests\AnnotationTransformer;

class TestClassConstructor
{
/**
* @ignore some fake annotation
* @Foo
* @Bar(123)
*/
public function __construct()
{
}
}
Loading