This commit is contained in:
icefox 2025-12-22 17:54:16 -03:00
commit a5ce423afe
No known key found for this signature in database
30 changed files with 1807 additions and 0 deletions

19
src/Aspect.php Normal file
View file

@ -0,0 +1,19 @@
<?php
namespace IceFox\Aspect;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
class Aspect
{
public function before(mixed ...$args): void
{
echo "before\n";
}
public function after(mixed $result): void
{
echo "after\n";
}
}

40
src/AspectBuilder.php Normal file
View file

@ -0,0 +1,40 @@
<?php
namespace IceFox\Aspect;
class AspectBuilder
{
public array $classes = [];
public array $namespaces = [];
public static function begin(): self
{
return new self();
}
/**
* @param array<int,class-string> $classes
*/
public function withClasses(array $classes): self
{
$this->classes = $classes;
return $this;
}
/**
* @param array<int,string> $namespaces
*/
public function withNamespaces(array $namespaces): self
{
$this->namespaces = $namespaces;
return $this;
}
/**
* @param array<int,class-string> $aspects
*/
public function build(AspectWeaver $weaver): AspectLoader
{
return new AspectLoader($weaver, $this->classes, $this->namespaces);
}
}

18
src/AspectException.php Normal file
View file

@ -0,0 +1,18 @@
<?php
namespace IceFox\Aspect;
use Exception;
use Throwable;
class AspectException extends Exception
{
public function __construct(
string $message,
public readonly string $aspectClass,
public readonly string $method,
?Throwable $previous = null
) {
parent::__construct($message, 0, $previous);
}
}

77
src/AspectLoader.php Normal file
View file

@ -0,0 +1,77 @@
<?php
namespace IceFox\Aspect;
class AspectLoader
{
/**
* @param array<int,class-string> $classes
* @param array<int,string> $namespaces
*/
public function __construct(
public readonly AspectWeaver $weaver,
public readonly array $classes,
public readonly array $namespaces,
) {
}
public function register(): self
{
spl_autoload_register([$this, 'load'], true, true);
return $this;
}
public function load(string $className): bool
{
if (!$this->shouldProcessClass($className)) {
return false;
}
$file = $this->findClassFile($className);
if ($file === null) {
return false;
}
if ($this->weaver->weave($className, $file)) {
return true;
}
return false;
}
private function shouldProcessClass(string $className): bool
{
if (!empty($this->classes)) {
return in_array($className, $this->classes, true);
}
if (!empty($this->namespaces)) {
foreach ($this->namespaces as $namespace) {
if (str_starts_with($className, $namespace)) {
return true;
}
}
}
return false;
}
private function findClassFile(string $className): ?string
{
$autoloaders = spl_autoload_functions();
foreach ($autoloaders as $autoloader) {
if (is_array($autoloader) && $autoloader[0] instanceof \Composer\Autoload\ClassLoader) {
$classLoader = $autoloader[0];
$file = $classLoader->findFile($className);
if ($file !== false) {
return $file;
}
}
}
return null;
}
}

446
src/AspectWeaver.php Normal file
View file

@ -0,0 +1,446 @@
<?php
namespace IceFox\Aspect;
use Psr\Log\LoggerInterface;
use ReflectionClass;
use ReflectionMethod;
use ReflectionNamedType;
use ReflectionType;
use ReflectionUnionType;
use ReflectionIntersectionType;
class AspectWeaver
{
/**
* @param array<int,class-string> $aspects
*/
public function __construct(
public readonly array $aspects,
public readonly string $cacheDir,
public readonly bool $useCache,
public readonly LoggerInterface $logger,
) {
if (!is_dir($this->cacheDir)) {
mkdir($this->cacheDir, 0755, true);
}
}
public function weave(string $className, string $filePath): ?string
{
$cacheKey = str_replace('\\', '_', $className);
$cachedFile = $this->cacheDir . '/' . $cacheKey . '.php';
if ($this->useCache && file_exists($cachedFile)) {
require $cachedFile;
return $cachedFile;
}
$sourceCode = file_get_contents($filePath);
if (!$this->hasAspectAttribute($sourceCode)) {
return null;
}
$result = $this->generateProxyWithReflection($className, $sourceCode);
if ($result === null) {
return null;
}
file_put_contents($cachedFile, $result);
$this->logger->debug('Generated proxy class file', [
'class' => $className,
'path' => $cachedFile,
]);
return $cachedFile;
}
private function hasAspectAttribute(string $sourceCode): bool
{
foreach ($this->aspects as $aspect) {
$useStatement = '/use\s+' . preg_quote($aspect, '/') . '\s*(?:as\s+\w+)?\s*;/';
if (preg_match($useStatement, $sourceCode) === 1) {
return true;
}
if (strpos($sourceCode, '#[\\' . $aspect . ']') !== false ||
strpos($sourceCode, '#[\\' . $aspect . '(') !== false) {
return true;
}
}
return false;
}
private function generateProxyWithReflection(string $className, string $sourceCode): ?string
{
$parts = explode('\\', $className);
$shortName = array_pop($parts);
$namespace = implode('\\', $parts);
$renamedClassName = '__AopOriginal_' . $shortName;
$fullRenamedClassName = $namespace ? "{$namespace}\\{$renamedClassName}" : $renamedClassName;
$modifiedSource = $this->renameClass($sourceCode, $shortName, $renamedClassName, $namespace);
if (!class_exists($fullRenamedClassName)) {
eval($modifiedSource);
if (!class_exists($fullRenamedClassName)) {
$this->logger->error('Failed to load renamed class after eval', [
'originalClass' => $className,
'renamedClass' => $fullRenamedClassName,
]);
return null;
}
}
$reflection = new ReflectionClass($fullRenamedClassName);
$methods = $this->getMethodsWithAspects($reflection);
if (empty($methods)) {
return null;
}
$proxyOnlyCode = $this->buildProxyClass($namespace, $shortName, $renamedClassName, $methods);
eval($proxyOnlyCode);
return $this->buildMergedProxyAndOriginalFile(
$namespace,
$shortName,
$renamedClassName,
$methods,
$modifiedSource,
);
}
private function renameClass(string $sourceCode, string $originalName, string $newName, string $namespace): string
{
$modified = preg_replace('/<\?php\s*/', '', $sourceCode, 1);
if ($namespace) {
$modified = preg_replace('/namespace\s+[\w\\\\]+\s*;/', '', $modified, 1);
}
$modified = preg_replace(
'/\bclass\s+' . preg_quote($originalName, '/') . '\b/',
'class ' . $newName,
$modified,
1
);
if ($namespace) {
$modified = "namespace {$namespace};\n\n" . $modified;
}
return $modified;
}
/**
* Map methods to their aspect classes and attributes for stacking support
* @param ReflectionClass<object> $reflection
* @return array<int, MethodMetadata>
*/
private function getMethodsWithAspects(ReflectionClass $reflection): array
{
$metadata = [];
foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_PROTECTED) as $method) {
if ($method->getDeclaringClass()->getName() !== $reflection->getName()) {
continue;
}
$methodAspects = array_map(
fn ($attribute) => new AttributeMetadata($attribute->getName(), $attribute),
array_filter(
$method->getAttributes(),
fn ($attribute) => in_array($attribute->getName(), $this->aspects, true)
)
);
if (!empty($methodAspects)) {
$metadata[] = new MethodMetadata($method, $methodAspects);
}
}
return $metadata;
}
/**
* @param array<int, MethodMetadata> $methodAspectsMap
*/
private function buildMergedProxyAndOriginalFile(
string $namespace,
string $shortName,
string $renamedClassName,
array $methodAspectsMap,
string $modifiedSource
): string {
$code = "<?php\n\n";
$code .= trim($modifiedSource) . "\n\n";
$code .= "class {$shortName} extends {$renamedClassName}\n{\n";
foreach ($methodAspectsMap as $methodMetadata) {
$code .= $this->generateProxyMethodFromReflection($methodMetadata->method, $methodMetadata->aspects);
}
$code .= "}\n";
return $code;
}
/**
* @param array<string, MethodMetadata> $methodAspectsMap
*/
private function buildProxyClass(
string $namespace,
string $shortName,
string $renamedClassName,
array $methodAspectsMap
): string {
$code = '';
if ($namespace) {
$code .= "namespace {$namespace};\n";
}
$code .= "class {$shortName} extends {$renamedClassName}\n{\n";
foreach ($methodAspectsMap as $methodMetadata) {
$code .= $this->generateProxyMethodFromReflection($methodMetadata->method, $methodMetadata->aspects);
}
$code .= "}\n";
return $code;
}
/**
* @param array<int, AttributeMetadata> $aspects
*/
private function generateProxyMethodFromReflection(ReflectionMethod $method, array $aspects): string
{
$visibility = $method->isPublic() ? 'public' : 'protected';
$static = $method->isStatic() ? 'static ' : '';
$isStatic = $method->isStatic();
$name = $method->getName();
$className = $method->getDeclaringClass()->getName();
$this->logger->info('Generating proxy method', [
'class' => $className,
'method' => $name,
'aspects' => array_map(fn ($a) => $a->aspectClass, $aspects),
]);
$params = $this->buildParameterList($method);
$paramNames = $this->buildParameterCallList($method);
$returnType = $this->buildReturnType($method);
$code = " {$visibility} {$static}function {$name}({$params}){$returnType}\n";
$code .= " {\n";
// Instantiate all aspects using their constructor arguments from attributes
foreach ($aspects as $index => $aspectMetadata) {
$aspectClass = $aspectMetadata->aspectClass;
$attribute = $aspectMetadata->attribute;
// Get constructor arguments from the attribute
$args = $attribute->getArguments();
$aspectConstructorArguments = $this->buildConstructorArguments($args);
$code .= " \$aspect{$index} = new \\{$aspectClass}({$aspectConstructorArguments});\n";
}
// Prepare target argument for aspect methods
$targetArg = $isStatic ? "static::class" : "\$this";
// Call all before methods in a single try-catch
$code .= " \$currentAspect = '';\n";
$code .= " try {\n";
foreach ($aspects as $index => $aspectMetadata) {
$aspectClass = $aspectMetadata->aspectClass;
$aspectReflection = new ReflectionClass($aspectClass);
if ($aspectReflection->hasMethod('before')) {
$code .= " \$currentAspect = \\{$aspectClass}::class;\n";
$beforeParams = $paramNames ? "{$targetArg}, {$paramNames}" : $targetArg;
$code .= " \$aspect{$index}->before({$beforeParams});\n";
}
}
$code .= " } catch (\\Throwable \$e) {\n";
$code .= " throw new \\IceFox\\Aspect\\AspectException(\n";
$code .= " 'Exception in before() method of aspect',\n";
$code .= " \$e instanceof \\IceFox\\Aspect\\AspectException ? \$e->aspectClass : \$currentAspect,\n";
$code .= " 'before',\n";
$code .= " \$e\n";
$code .= " );\n";
$code .= " }\n";
if ($returnType && $returnType !== ': void') {
$code .= " \$result = parent::{$name}({$paramNames});\n";
// Call all after methods in a single try-catch (in reverse order)
$code .= " \$currentAspect = '';\n";
$code .= " try {\n";
for ($i = count($aspects) - 1; $i >= 0; $i--) {
$aspectMetadata = $aspects[$i];
$aspectClass = $aspectMetadata->aspectClass;
$aspectReflection = new ReflectionClass($aspectClass);
if ($aspectReflection->hasMethod('after')) {
$code .= " \$currentAspect = \\{$aspectClass}::class;\n";
$code .= " \$result = \$aspect{$i}->after({$targetArg}, \$result);\n";
}
}
$code .= " } catch (\\Throwable \$e) {\n";
$code .= " throw new \\IceFox\\Aspect\\AspectException(\n";
$code .= " 'Exception in after() method of aspect',\n";
$code .= " \$e instanceof \\IceFox\\Aspect\\AspectException ? \$e->aspectClass : \$currentAspect,\n";
$code .= " 'after',\n";
$code .= " \$e\n";
$code .= " );\n";
$code .= " }\n";
$code .= " return \$result;\n";
} else {
$code .= " parent::{$name}({$paramNames});\n";
$code .= " \$currentAspect = '';\n";
$code .= " try {\n";
for ($i = count($aspects) - 1; $i >= 0; $i--) {
$aspectMetadata = $aspects[$i];
$aspectClass = $aspectMetadata->aspectClass;
$aspectReflection = new ReflectionClass($aspectClass);
if ($aspectReflection->hasMethod('after')) {
$code .= " \$currentAspect = \\{$aspectClass}::class;\n";
$code .= " \$aspect{$i}->after({$targetArg}, null);\n";
}
}
$code .= " } catch (\\Throwable \$e) {\n";
$code .= " throw new \\IceFox\\Aspect\\AspectException(\n";
$code .= " 'Exception in after() method of aspect',\n";
$code .= " \$e instanceof \\IceFox\\Aspect\\AspectException ? \$e->aspectClass : \$currentAspect,\n";
$code .= " 'after',\n";
$code .= " \$e\n";
$code .= " );\n";
$code .= " }\n";
}
$code .= " }\n\n";
return $code;
}
private function buildParameterList(ReflectionMethod $method): string
{
$params = [];
foreach ($method->getParameters() as $param) {
$paramStr = '';
if ($param->hasType()) {
$paramStr .= $this->formatType($param->getType()) . ' ';
}
if ($param->isPassedByReference()) {
$paramStr .= '&';
}
if ($param->isVariadic()) {
$paramStr .= '...';
}
$paramStr .= '$' . $param->getName();
if ($param->isDefaultValueAvailable()) {
$defaultValue = $param->getDefaultValue();
$paramStr .= ' = ' . var_export($defaultValue, true);
} elseif ($param->isOptional() && $param->allowsNull()) {
$paramStr .= ' = null';
}
$params[] = $paramStr;
}
return implode(', ', $params);
}
private function buildParameterCallList(ReflectionMethod $method): string
{
$params = [];
foreach ($method->getParameters() as $param) {
$name = '';
if ($param->isVariadic()) {
$name .= '...';
}
$name .= '$' . $param->getName();
$params[] = $name;
}
return implode(', ', $params);
}
private function buildReturnType(ReflectionMethod $method): string
{
if (!$method->hasReturnType()) {
return '';
}
return ': ' . $this->formatType($method->getReturnType());
}
private function formatType(\ReflectionType $type): string
{
if ($type instanceof ReflectionNamedType) {
$name = $type->getName();
// Don't add ? for nullable built-in types that already allow null
if ($type->allowsNull() && $name !== 'mixed' && $name !== 'null') {
return '?' . $name;
}
return $name;
}
if ($type instanceof ReflectionUnionType) {
$types = array_map(fn ($t) => $this->formatType($t), $type->getTypes());
return implode('|', $types);
}
if ($type instanceof ReflectionIntersectionType) {
$types = array_map(fn ($t) => $this->formatType($t), $type->getTypes());
return implode('&', $types);
}
return '';
}
/**
* Build constructor arguments code from attribute arguments
* @param array<int|string,mixed> $args
*/
private function buildConstructorArguments(array $args): string
{
if (empty($args)) {
return '';
}
$argParts = [];
foreach ($args as $key => $value) {
$valueCode = var_export($value, true);
if (is_string($key)) {
$argParts[] = "{$key}: {$valueCode}";
} else {
$argParts[] = $valueCode;
}
}
return implode(', ', $argParts);
}
}

21
src/AttributeMetadata.php Normal file
View file

@ -0,0 +1,21 @@
<?php
namespace IceFox\Aspect;
use ReflectionAttribute;
/**
* Metadata about an aspect attribute applied to a method
*/
final readonly class AttributeMetadata
{
/**
* @param class-string $aspectClass
* @param ReflectionAttribute<object> $attribute
*/
public function __construct(
public string $aspectClass,
public ReflectionAttribute $attribute,
) {
}
}

21
src/MethodMetadata.php Normal file
View file

@ -0,0 +1,21 @@
<?php
namespace IceFox\Aspect;
use ReflectionMethod;
/**
* Metadata about a method and its associated aspects
*/
final readonly class MethodMetadata
{
/**
* @param ReflectionMethod $method
* @param array<int, AttributeMetadata> $aspects
*/
public function __construct(
public ReflectionMethod $method,
public array $aspects,
) {
}
}