Skip to content

Commit

Permalink
NEW Add AnnotationTransformer to allow configuration through PHP docb…
Browse files Browse the repository at this point in the history
…lock annotations
  • Loading branch information
ScopeyNZ committed May 23, 2019
1 parent 772049f commit 2872165
Show file tree
Hide file tree
Showing 13 changed files with 538 additions and 3 deletions.
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.
214 changes: 214 additions & 0 deletions src/Transformer/AnnotationTransformer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
<?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
*/
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)
{
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;
}
}
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
{
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

0 comments on commit 2872165

Please sign in to comment.