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

Add a function/closure analyzer #5

Merged
merged 7 commits into from
Feb 24, 2024
Merged
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ All notable changes to `AttributeUtils` will be documented in this file.

Updates should follow the [Keep a CHANGELOG](http://keepachangelog.com/) principles.

## 1.1.0 - 2024-02-24

### Added
- Added a separate analyzer for functions and closures.

## 1.0.0 - 2023-10-30

### Added
Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,46 @@ As a last resort, an attribute may also implement the [`CustomAnalysis`](src/Cus

The Analyzer is designed to be usable on its own without any setup. Making it available via a Dependency Injection Container is recommended. An appropriate cache wrapper should also be included in the DI configuration.

## Function analysis

There is also support for retrieving attributes on functions, via a separate analyzer (that works essentially the same way). The `FuncAnalyzer` class implements the `FunctionAnalyzer` interface.

```php
use Crell\AttributeUtils\FuncAnalyzer;

#[MyFunc]
function beep(int $a) {}

$closure = #[MyClosure] fn(int $a) => $a + 1;

// For functions...
$analyzer = new FuncAnalyzer();
$funcDef = $analyzer->analyze('beep', MyFunc::class);

// For closures
$analyzer = new FuncAnalyzer();
$funcDef = $analyzer->analyze($closure, MyFunc::class);
```

Sub-attributes, `ParseParameters`, and `Finalizable` all work on functions exactly as they do on classes and methods, as do scopes. There is also a corresponding `FromReflectionFunction` interface for receiving the `ReflectionFunction` object.

There are also cache wrappers available for the FuncAnalyzer as well. They work the same way as on the class analyzer.

```php
# In-memory cache.
$analyzer = new MemoryCacheFunctionAnalyzer(new FuncAnalyzer());

# PSR-6 cache.
$anaylzer = new Psr6CacheFunctionAnalyzer(new FuncAnalyzer(), $somePsr6CachePoolObject);

# Both caches.
$analyzer = new MemoryCacheFunctionAnalyzer(
new Psr6CacheFunctionAnalyzer(new FuncAnalyzer(), $psr6CachePool)
);
```

As with the class analyzer, it's best to wire these up in your DI container.

## The Reflect library

One of the many uses for `Analyzer` is to extract reflection information from a class. Sometimes you only need some of it, but there's no reason you can't grab all of it. The result is an attribute that can carry all the same information as reflection, but can be cached if desired while reflection objects cannot be.
Expand Down
21 changes: 21 additions & 0 deletions src/FromReflectionFunction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Crell\AttributeUtils;

/**
* Marks a function-targeting attribute as wanting reflection information.
*
* If a function-targeting attribute implements this interface, then after it
* is instantiated the reflection object for the function will be passed to this
* method. The attribute may then extract whatever information it desires
* and save it to object however it likes.
*
* Note that the attribute MUST NOT save the reflection object itself. That
* would make the attribute object unserializable, and thus uncacheable.
*/
interface FromReflectionFunction
{
public function fromReflection(\ReflectionFunction $subject): void;
}
64 changes: 64 additions & 0 deletions src/FuncAnalyzer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

namespace Crell\AttributeUtils;

class FuncAnalyzer implements FunctionAnalyzer
{
public function analyze(string|\Closure $function, string $attribute, array $scopes = []): object
{
$parser = new AttributeParser($scopes);
$defBuilder = new ReflectionDefinitionBuilder($parser);

try {
$subject = new \ReflectionFunction($function);

$funcDef = $parser->getAttribute($subject, $attribute) ?? new $attribute;

if ($funcDef instanceof FromReflectionFunction) {
$funcDef->fromReflection($subject);
}

$defBuilder->loadSubAttributes($funcDef, $subject);

if ($funcDef instanceof ParseParameters) {
$parameters = $defBuilder->getDefinitions(
$subject->getParameters(),
fn (\ReflectionParameter $p)
=> $defBuilder->getComponentDefinition($p, $funcDef->parameterAttribute(), $funcDef->includeParametersByDefault(), FromReflectionParameter::class, $funcDef)
);
$funcDef->setParameters($parameters);
}

if ($funcDef instanceof Finalizable) {
$funcDef->finalize();
}

return $funcDef;
} catch (\ArgumentCountError $e) {
$this->translateArgumentCountError($e);
}
}

/**
* Throws a domain-specific exception based on an ArgumentCountError.
*
* This is absolutely hideous, but this is what happens when your throwable
* puts all the useful information in the message text rather than as useful
* properties or methods or something.
*
* Conclusion: Write better, more debuggable exceptions than PHP does.
*/
protected function translateArgumentCountError(\ArgumentCountError $error): never
{
$message = $error->getMessage();
// PHPStan doesn't understand this syntax style of sscanf(), so skip it.
// @phpstan-ignore-next-line
[$classAndMethod, $passedCount, $file, $line, $expectedCount] = sscanf(
string: $message,
format: "Too few arguments to function %s::%s, %d passed in %s on line %d and exactly %d expected"
);
[$className, $methodName] = \explode('::', $classAndMethod ?? '');

throw RequiredAttributeArgumentsMissing::create($className, $error);
}
}
21 changes: 21 additions & 0 deletions src/FunctionAnalyzer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace Crell\AttributeUtils;

interface FunctionAnalyzer
{
/**
* Analyzes a function or closure for the specified attribute.
*
* @template T of object
* @param string|\Closure $function
* Either a fully qualified function name or a Closure to analyze.
* @param class-string<T> $attribute
* The fully qualified class name of the class attribute to analyze.
* @param array<string|null> $scopes
* The scopes for which this analysis should run.
* @return T
* The function attribute requested, including dependent data as appropriate.
*/
public function analyze(string|\Closure $function, string $attribute, array $scopes = []): object;
}
2 changes: 1 addition & 1 deletion src/MemoryCacheAnalyzer.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class MemoryCacheAnalyzer implements ClassAnalyzer
*/
private array $cache = [];

public function __construct(private ClassAnalyzer $analyzer)
public function __construct(private readonly ClassAnalyzer $analyzer)
{}

public function analyze(object|string $class, string $attribute, array $scopes = []): object
Expand Down
36 changes: 36 additions & 0 deletions src/MemoryCacheFunctionAnalyzer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace Crell\AttributeUtils;

/**
* A simple in-memory cache for function analyzers.
*/
class MemoryCacheFunctionAnalyzer implements FunctionAnalyzer
{
/**
* @var array<string, array<string, array<string, object>>>
*/
private array $cache = [];

public function __construct(
private readonly FunctionAnalyzer $analyzer,
) {}

public function analyze(string|\Closure $function, string $attribute, array $scopes = []): object
{
// We cannot cache a closure, as we have no reliable identifier for it.
if ($function instanceof \Closure) {
return $this->analyzer->analyze($function, $attribute, $scopes);
}

$scopekey = '';
if ($scopes) {
sort($scopes);
$scopekey = implode(',', $scopes);
}

return $this->cache[$function][$attribute][$scopekey] ??= $this->analyzer->analyze($function, $attribute, $scopes);
}
}
4 changes: 2 additions & 2 deletions src/Psr6CacheAnalyzer.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
class Psr6CacheAnalyzer implements ClassAnalyzer
{
public function __construct(
private ClassAnalyzer $analyzer,
private CacheItemPoolInterface $pool,
private readonly ClassAnalyzer $analyzer,
private readonly CacheItemPoolInterface $pool,
) {}

public function analyze(object|string $class, string $attribute, array $scopes = []): object
Expand Down
54 changes: 54 additions & 0 deletions src/Psr6FunctionCacheAnalyzer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

namespace Crell\AttributeUtils;

use Psr\Cache\CacheItemPoolInterface;

class Psr6FunctionCacheAnalyzer implements FunctionAnalyzer
{
public function __construct(
private readonly FunctionAnalyzer $analyzer,
private readonly CacheItemPoolInterface $pool,
) {}

public function analyze(\Closure|string $function, string $attribute, array $scopes = []): object
{
// We cannot cache a closure, as we have no reliable identifier for it.
if ($function instanceof \Closure) {
return $this->analyzer->analyze($function, $attribute, $scopes);
}

$key = $this->buildKey($function, $attribute, $scopes);

$item = $this->pool->getItem($key);
if ($item->isHit()) {
return $item->get();
}

// No expiration; the cached data would only need to change
// if the source code changes.
$value = $this->analyzer->analyze($function, $attribute, $scopes);
$item->set($value);
$this->pool->save($item);
return $value;
}

/**
* Generates the cache key for this request.
*
* @param array<string|null> $scopes
* The scopes for which this analysis should run.
*/
private function buildKey(string $function, string $attribute, array $scopes): string
{
$parts = [
$function,
$attribute,
implode(',', $scopes),
];

return str_replace('\\', '_', \implode('-', $parts));
}
}
6 changes: 3 additions & 3 deletions src/ReflectionDefinitionBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class ReflectionDefinitionBuilder
{
public function __construct(
protected readonly AttributeParser $parser,
protected readonly Analyzer $analyzer,
protected readonly ?Analyzer $analyzer = null,
) {}

/**
Expand Down Expand Up @@ -64,7 +64,7 @@ public function getComponentDefinition(\Reflector $reflection, string $attribute

$this->loadSubAttributes($def, $reflection);

if ($def instanceof CustomAnalysis) {
if ($def instanceof CustomAnalysis && $this->analyzer) {
$def->customAnalysis($this->analyzer);
}

Expand Down Expand Up @@ -105,7 +105,7 @@ public function getMethodDefinition(\ReflectionMethod $reflection, string $attri
$def->setParameters($parameters);
}

if ($def instanceof CustomAnalysis) {
if ($def instanceof CustomAnalysis && $this->analyzer) {
$def->customAnalysis($this->analyzer);
}

Expand Down
2 changes: 1 addition & 1 deletion src/RequiredAttributeArgumentsMissing.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

class RequiredAttributeArgumentsMissing extends \LogicException
{
public string $attributeType;
public readonly string $attributeType;

public static function create(string $attributeType, \Throwable $previous): self
{
Expand Down
32 changes: 32 additions & 0 deletions tests/Attributes/Functions/HasParameters.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

namespace Crell\AttributeUtils\Attributes\Functions;

use Crell\AttributeUtils\ParseParameters;

#[\Attribute(\Attribute::TARGET_FUNCTION)]
class HasParameters implements ParseParameters
{
public readonly array $parameters;

public function __construct(
public readonly string $parameter,
public readonly bool $parseParametersByDefault = true) {}

public function setParameters(array $parameters): void
{
$this->parameters = $parameters;
}

public function includeParametersByDefault(): bool
{
return $this->parseParametersByDefault;
}

public function parameterAttribute(): string
{
return $this->parameter;
}


}
16 changes: 16 additions & 0 deletions tests/Attributes/Functions/IncludesReflection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace Crell\AttributeUtils\Attributes\Functions;

use Crell\AttributeUtils\FromReflectionFunction;

#[\Attribute(\Attribute::TARGET_FUNCTION)]
class IncludesReflection implements FromReflectionFunction
{
public readonly string $name;

public function fromReflection(\ReflectionFunction $subject): void
{
$this->name = $subject->name;
}
}
9 changes: 9 additions & 0 deletions tests/Attributes/Functions/ParameterAttrib.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace Crell\AttributeUtils\Attributes\Functions;

#[\Attribute(\Attribute::TARGET_PARAMETER)]
class ParameterAttrib
{
public function __construct(public readonly string $a = 'default') {}
}
9 changes: 9 additions & 0 deletions tests/Attributes/Functions/RequiredArg.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace Crell\AttributeUtils\Attributes\Functions;

#[\Attribute(\Attribute::TARGET_FUNCTION)]
class RequiredArg
{
public function __construct(public readonly string $a) {}
}
9 changes: 9 additions & 0 deletions tests/Attributes/Functions/SubChild.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace Crell\AttributeUtils\Attributes\Functions;

#[\Attribute(\Attribute::TARGET_FUNCTION)]
class SubChild
{
public function __construct(public string $b = 'default') {}
}
Loading
Loading