Skip to content
Draft
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
14 changes: 11 additions & 3 deletions src/Console/Command/DebugWeavingCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,15 +99,21 @@ protected function execute(InputInterface $input, OutputInterface $output): int

/**
* Gets Go! AOP generated proxy classes (paths and their contents) from the cache.
* Proxy files are identified by the presence of `implements \Go\Aop\Proxy` in their content
* (covers both class and enum proxies). Woven trait files and function proxies are excluded.
*
* @return array<string, string>
*/
private function getProxies(CachePathManager $cachePathManager): array
{
$path = $cachePathManager->getCacheDir() . '/_proxies';
$cacheDir = $cachePathManager->getCacheDir();
if ($cacheDir === null || !is_dir($cacheDir)) {
return [];
}

$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator(
$path,
$cacheDir,
FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS
),
RecursiveIteratorIterator::CHILD_FIRST
Expand All @@ -121,7 +127,9 @@ private function getProxies(CachePathManager $cachePathManager): array
foreach ($iterator as $splFileInfo) {
if ($splFileInfo->isFile()) {
$content = file_get_contents($splFileInfo->getPathname());
if ($content !== false) {
// Only include files that implement Go\Aop\Proxy (class/enum proxies).
// Woven trait files and function proxies never reference Go\Aop\Proxy.
if ($content !== false && str_contains($content, 'Go\Aop\Proxy')) {
$proxies[$splFileInfo->getPathname()] = $content;
}
}
Expand Down
15 changes: 15 additions & 0 deletions src/Instrument/ClassLoading/AopComposerLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,21 @@ class AopComposerLoader
*/
private bool $isProduction = false;

/**
* Returns the original (pre-AOP) Composer ClassLoader from the registered autoload stack,
* or null if AOP has not been initialised yet.
*/
public static function getOriginalClassLoader(): ?ClassLoader
{
foreach (spl_autoload_functions() as $autoloader) {
if (is_array($autoloader) && isset($autoloader[0]) && $autoloader[0] instanceof self) {
return $autoloader[0]->original;
}
}

return null;
}

/**
* Constructs an wrapper for the composer loader
*
Expand Down
22 changes: 13 additions & 9 deletions src/Instrument/Transformer/CachingTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
namespace Go\Instrument\Transformer;

use Closure;
use Go\Core\AspectContainer;
use Go\Core\AspectKernel;
use Go\Instrument\ClassLoading\CachePathManager;
use Go\ParserReflection\ReflectionEngine;
Expand Down Expand Up @@ -66,30 +67,34 @@ public function transform(StreamMetaData $metadata): TransformerResultEnum
return TransformerResultEnum::RESULT_ABORTED;
}

$lastModified = filemtime($originalUri);
$cacheState = $this->cacheManager->queryCacheState($originalUri);
// Woven (trait) file is stored with the __AopProxied suffix before .php,
// mirroring the original directory structure under cacheDir.
$wovenCacheUri = substr($cacheUri, 0, -4) . AspectContainer::AOP_PROXIED_SUFFIX . '.php';

$lastModified = filemtime($originalUri);
$cacheState = $this->cacheManager->queryCacheState($originalUri);
$cacheFilemtime = $cacheState !== null ? ($cacheState['filemtime'] ?? 0) : 0;
$cacheModified = is_int($cacheFilemtime) ? $cacheFilemtime : 0;

if ($cacheModified < $lastModified
|| (isset($cacheState['cacheUri']) && $cacheState['cacheUri'] !== $cacheUri)
|| (isset($cacheState['cacheUri']) && !file_exists($wovenCacheUri))
|| !$this->container->hasAnyResourceChangedSince($cacheModified)
) {
$processingResult = $this->processTransformers($metadata);
if ($processingResult === TransformerResultEnum::RESULT_TRANSFORMED) {
$parentCacheDir = dirname($cacheUri);
$parentCacheDir = dirname($wovenCacheUri);
if (!is_dir($parentCacheDir)) {
mkdir($parentCacheDir, $this->cacheFileMode, true);
}
file_put_contents($cacheUri, $metadata->source, LOCK_EX);
file_put_contents($wovenCacheUri, $metadata->source, LOCK_EX);
// For cache files we don't want executable bits by default
chmod($cacheUri, $this->cacheFileMode & (~0111));
chmod($wovenCacheUri, $this->cacheFileMode & (~0111));
}
$this->cacheManager->setCacheState(
$originalUri,
[
'filemtime' => $_SERVER['REQUEST_TIME'] ?? time(),
'cacheUri' => ($processingResult === TransformerResultEnum::RESULT_TRANSFORMED) ? $cacheUri : null
'cacheUri' => ($processingResult === TransformerResultEnum::RESULT_TRANSFORMED) ? $wovenCacheUri : null
]
);

Expand All @@ -100,8 +105,7 @@ public function transform(StreamMetaData $metadata): TransformerResultEnum
$processingResult = isset($cacheState['cacheUri']) ? TransformerResultEnum::RESULT_TRANSFORMED : TransformerResultEnum::RESULT_ABORTED;
}
if ($processingResult === TransformerResultEnum::RESULT_TRANSFORMED) {
// Just replace all tokens in the stream
ReflectionEngine::parseFile($cacheUri);
ReflectionEngine::parseFile($wovenCacheUri);
$metadata->setTokenStreamFromRawTokens(
...ReflectionEngine::getParser()->getTokens()
);
Expand Down
72 changes: 67 additions & 5 deletions src/Instrument/Transformer/MagicConstantTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@

namespace Go\Instrument\Transformer;

use Composer\Autoload\ClassLoader;
use Go\Core\AspectContainer;
use Go\Core\AspectKernel;
use Go\Instrument\ClassLoading\AopComposerLoader;
use PhpParser\Node;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Scalar\MagicConst;
Expand All @@ -39,6 +42,11 @@ class MagicConstantTransformer extends BaseSourceTransformer
*/
protected static string $rewriteToPath = '';

/**
* Cached Composer ClassLoader instance, used for resolving proxy file paths to original sources.
*/
private static ?ClassLoader $composerLoader = null;

/**
* Class constructor
*/
Expand All @@ -62,18 +70,72 @@ public function transform(StreamMetaData $metadata): TransformerResultEnum
}

/**
* Resolves file name from the cache directory to the real application root dir
* Resolves file name from the cache directory to the real application root dir.
*
* Two cases are handled:
* 1. Woven (trait) cache files — identified by the {@see AspectContainer::AOP_PROXIED_SUFFIX}
* in their name. The cache-to-app directory substitution plus suffix stripping recovers
* the original source path.
* 2. Proxy class cache files — FQCN-based paths that may differ from the PSR-4 source path
* when the application's PSR-4 namespace root is not the same as `appDir`. In this case
* Composer's ClassLoader is used to resolve the original file.
*/
public static function resolveFileName(string $fileName): string
{
$suffix = '.php';
$pathParts = explode($suffix, str_replace(
[self::$rewriteToPath, DIRECTORY_SEPARATOR . '_proxies'],
[self::$rootPath, ''],
self::$rewriteToPath,
self::$rootPath,
$fileName
));
// throw away namespaced path from actual filename
return $pathParts[0] . $suffix;
$baseName = $pathParts[0];

// Case 1: woven trait file — strip the __AopProxied suffix to get the original source path.
if (str_ends_with($baseName, AspectContainer::AOP_PROXIED_SUFFIX)) {
return substr($baseName, 0, -strlen(AspectContainer::AOP_PROXIED_SUFFIX)) . $suffix;
}

// Case 2: proxy class file (FQCN-based path in the cache directory).
// Derive the FQCN from the path and ask Composer for the canonical source file.
if (str_starts_with($fileName, self::$rewriteToPath)) {
$relPath = ltrim(substr($fileName, strlen(self::$rewriteToPath)), '/\\');
// Remove .php extension and convert path separators to namespace separators
$fqcn = str_replace('/', '\\', substr($relPath, 0, -strlen($suffix)));
$loader = self::getComposerLoader();
if ($loader !== null) {
$file = $loader->findFile($fqcn);
if ($file !== false) {
return realpath($file) ?: $file;
}
}
}

return $baseName . $suffix;
}

/**
* Returns the Composer ClassLoader, cached after the first successful lookup.
* When AOP is active, the ClassLoader is wrapped by AopComposerLoader — in that case
* the original loader is accessed via {@see AopComposerLoader::getOriginalClassLoader()}.
*/
private static function getComposerLoader(): ?ClassLoader
{
if (self::$composerLoader !== null) {
return self::$composerLoader;
}
// When AOP is active, the original ClassLoader is wrapped by AopComposerLoader
$loader = AopComposerLoader::getOriginalClassLoader();
if ($loader !== null) {
return self::$composerLoader = $loader;
}
// When AOP is not yet active, find the ClassLoader directly in the autoload stack
foreach (spl_autoload_functions() as $autoloader) {
if (is_array($autoloader) && isset($autoloader[0]) && $autoloader[0] instanceof ClassLoader) {
return self::$composerLoader = $autoloader[0];
}
}

return null;
}

/**
Expand Down
26 changes: 16 additions & 10 deletions src/Instrument/Transformer/WeavingTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@
class WeavingTransformer extends BaseSourceTransformer
{
private const FUNCTIONS_CACHE_SUFFIX = '/_functions/';
private const PROXIES_CACHE_SUFFIX = '/_proxies/';

/**
* Advice matcher for class
Expand Down Expand Up @@ -704,35 +703,42 @@ private function processFunctions(
}

/**
* Save AOP proxy to the separate file anr returns the php source code for inclusion
* Save AOP proxy to the separate file and returns the php source code for inclusion.
* Proxy files are stored in a PSR-4 compatible layout under the cache root directory:
* <cacheDir>/<Namespace/ClassName>.php
*
* The woven (trait) file is written by CachingTransformer to a path derived from the
* original source URI with an {@see AspectContainer::AOP_PROXIED_SUFFIX} before .php,
* mirroring the original directory structure under cacheDir.
*/
private function saveProxyToCache(ReflectionClass $class, string $childCode): string
{
$cacheRootDir = $this->cachePathManager->getCacheDir();
$cacheRootDir = $this->cachePathManager->getCacheDir();
if ($cacheRootDir === null) {
return '';
}
$cacheDir = $cacheRootDir . self::PROXIES_CACHE_SUFFIX;
$classFileName = $class->getFileName();

$classFileName = $class->getFileName();
if ($classFileName === false) {
return '';
}
$relativePath = str_replace($this->options['appDir'] . DIRECTORY_SEPARATOR, '', $classFileName);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

use previous logic: combination of relative directories, we take the original filename and path, then map it relative to application root directory and then use this knowledge to generate the cache filename. Make an assumption, that all original source files are controlled by composer, therefore respects PSR-0/PSR-4 as well, this can give us necessary contract to add (with prepend=true) later again the same cache directory as PSR-4/PSR-0 roots for production mode completely bypassing the logic of checking the cache filter - app will use generated cache version with opcache effectively

$proxyRelativePath = str_replace('\\', '/', $relativePath . '/' . $class->getName() . '.php');
$proxyFileName = $cacheDir . $proxyRelativePath;

// Build a PSR-4 compatible relative path from the class FQCN, e.g. "Ns/Sub/ClassName.php"
$proxyRelativePath = str_replace('\\', '/', $class->getName()) . '.php';
$proxyFileName = $cacheRootDir . '/' . $proxyRelativePath;
$dirname = dirname($proxyFileName);
if (!file_exists($dirname)) {
mkdir($dirname, $this->options['cacheFileMode'], true);
}

$body = '<?php' . PHP_EOL . $childCode;
$body = '<?php' . PHP_EOL . $childCode . PHP_EOL;

$isVirtualSystem = strpos($proxyFileName, 'vfs') === 0;
file_put_contents($proxyFileName, $body, $isVirtualSystem ? 0 : LOCK_EX);
// For cache files we don't want executable bits by default
chmod($proxyFileName, $this->options['cacheFileMode'] & (~0111));

return 'include_once AOP_CACHE_DIR . ' . var_export(self::PROXIES_CACHE_SUFFIX . $proxyRelativePath, true) . ';';
return 'include_once AOP_CACHE_DIR . ' . var_export('/' . $proxyRelativePath, true) . ';';
}

/**
Expand Down
26 changes: 20 additions & 6 deletions src/Proxy/Generator/ClassGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -211,9 +211,7 @@ public function getNode(): ClassNode
// Build adaptations for all aliases
$adaptations = [];
foreach ($this->traitAliases as $info) {
$traitNameNode = str_contains($info['trait'], '\\')
? new Name\FullyQualified($info['trait'])
: new Name($info['trait']);
$traitNameNode = $this->resolveTraitName($info['trait']);
$adaptations[] = new TraitUseAdaptation\Alias(
$traitNameNode,
new Identifier($info['method']),
Expand All @@ -223,9 +221,7 @@ public function getNode(): ClassNode
}

$traitNames = array_map(
static fn(string $t) => str_contains($t, '\\')
? new Name\FullyQualified($t)
: new Name($t),
fn(string $t) => $this->resolveTraitName(ltrim($t, '\\')),
$traitFqcns
);
$builder->addStmt(new TraitUse($traitNames, $adaptations));
Expand Down Expand Up @@ -272,6 +268,24 @@ public function generate(): string
return self::getPrinter()->prettyPrint($stmts);
}

/**
* Resolves a trait FQCN to a Name AST node, using a relative (unqualified) name when
* the trait resides in the same namespace as the proxy class. This keeps the generated
* code readable: `use FooTrait` instead of `use \Ns\FooTrait`.
*/
private function resolveTraitName(string $traitFqcn): Name
{
$normalized = ltrim($traitFqcn, '\\');
if ($this->namespace !== null && $this->namespace !== '' && str_starts_with($normalized, $this->namespace . '\\')) {
// Trait is in the same namespace — use just the short name
return new Name(substr($normalized, strlen($this->namespace) + 1));
}

return str_contains($normalized, '\\')
? new Name\FullyQualified($normalized)
: new Name($normalized);
}

/**
* Maps ReflectionMethod visibility flag to PhpParser Modifiers constant.
* ReflectionMethod::IS_PUBLIC = 1, IS_PROTECTED = 2, IS_PRIVATE = 4 match Modifiers directly.
Expand Down
25 changes: 19 additions & 6 deletions src/Proxy/Generator/EnumGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,7 @@ public function getNode(): EnumNode

$adaptations = [];
foreach ($this->traitAliases as $info) {
$traitNameNode = str_contains($info['trait'], '\\')
? new Name\FullyQualified($info['trait'])
: new Name($info['trait']);
$traitNameNode = $this->resolveTraitName($info['trait']);
$adaptations[] = new TraitUseAdaptation\Alias(
$traitNameNode,
new Identifier($info['method']),
Expand All @@ -167,9 +165,7 @@ public function getNode(): EnumNode
}

$traitNames = array_map(
static fn(string $t) => str_contains($t, '\\')
? new Name\FullyQualified($t)
: new Name($t),
fn(string $t) => $this->resolveTraitName(ltrim($t, '\\')),
$traitFqcns
);
$stmts[] = new TraitUse($traitNames, $adaptations);
Expand Down Expand Up @@ -229,6 +225,23 @@ public function generate(): string
return self::getPrinter()->prettyPrint($stmts);
}

/**
* Resolves a trait FQCN to a Name AST node, using a relative (unqualified) name when
* the trait resides in the same namespace as the enum. This keeps the generated
* code readable: `use FooTrait` instead of `use \Ns\FooTrait`.
*/
private function resolveTraitName(string $traitFqcn): Name
{
$normalized = ltrim($traitFqcn, '\\');
if ($this->namespace !== null && $this->namespace !== '' && str_starts_with($normalized, $this->namespace . '\\')) {
return new Name(substr($normalized, strlen($this->namespace) + 1));
}

return str_contains($normalized, '\\')
? new Name\FullyQualified($normalized)
: new Name($normalized);
}

/**
* Maps ReflectionMethod visibility flag to PhpParser Modifiers constant.
*/
Expand Down
Loading