diff --git a/src/Compiler.php b/src/Compiler.php index af27a3e2..aed40461 100644 --- a/src/Compiler.php +++ b/src/Compiler.php @@ -62,6 +62,12 @@ public function compile(AbstractModule $module, string $scriptDir): Scripts $compileVisitor = new CompileVisitor($container); $container->map(static function (DependencyInterface $dependency, string $key) use ($scripts, $compileVisitor): DependencyInterface { assert($dependency instanceof AcceptInterface); + if ($key === InstanceScript::RAY_DI_SCRIPT_DIR) { + $scripts->add($key, 'return __DIR__;'); + + return $dependency; + } + $script = $dependency->accept($compileVisitor); assert(is_string($script)); $scripts->add($key, $script); diff --git a/src/InstanceScript.php b/src/InstanceScript.php index 62469814..fd2f068b 100644 --- a/src/InstanceScript.php +++ b/src/InstanceScript.php @@ -32,6 +32,7 @@ final class InstanceScript { public const RAY_DI_INJECTOR_INTERFACE = 'Ray\Di\InjectorInterface-'; public const RAY_DI_INJECTION_POINT_INTERFACE = 'Ray\Di\InjectionPointInterface-'; + public const RAY_DI_SCRIPT_DIR = '-Ray\Di\Annotation\ScriptDir'; public const COMMENT = '// prototype'; /** @var array */ @@ -57,6 +58,14 @@ public function __construct(Container $container) /** @param mixed $defaultValue */ public function addArg(string $index, bool $isDefaultAvailable, $defaultValue, ReflectionParameter $parameter): void { + // The script dir is where the generated scripts live: resolve it at + // runtime, never bake the compile-time path. + if ($index === self::RAY_DI_SCRIPT_DIR) { + $this->args[] = '__DIR__'; + + return; + } + if (! isset($this->container[$index])) { if ($isDefaultAvailable) { $this->addInstanceArg($defaultValue); diff --git a/tests/Fake/FakeScriptDirAopConsumer.php b/tests/Fake/FakeScriptDirAopConsumer.php new file mode 100644 index 00000000..e1e6b3fe --- /dev/null +++ b/tests/Fake/FakeScriptDirAopConsumer.php @@ -0,0 +1,19 @@ +scriptDir; + } +} diff --git a/tests/Fake/FakeScriptDirConstructorConsumer.php b/tests/Fake/FakeScriptDirConstructorConsumer.php new file mode 100644 index 00000000..ad221db5 --- /dev/null +++ b/tests/Fake/FakeScriptDirConstructorConsumer.php @@ -0,0 +1,19 @@ +scriptDir; + } +} diff --git a/tests/Fake/FakeScriptDirConsumerInterface.php b/tests/Fake/FakeScriptDirConsumerInterface.php new file mode 100644 index 00000000..bf84eb00 --- /dev/null +++ b/tests/Fake/FakeScriptDirConsumerInterface.php @@ -0,0 +1,10 @@ +bind(FakeScriptDirConsumerInterface::class)->annotatedWith('ctor')->to(FakeScriptDirConstructorConsumer::class); + $this->bind(FakeScriptDirConsumerInterface::class)->annotatedWith('setter')->to(FakeScriptDirSetterConsumer::class); + $this->bind(FakeScriptDirConsumerInterface::class)->annotatedWith('provider')->toProvider(FakeScriptDirProvider::class); + $this->bind(FakeScriptDirConsumerInterface::class)->annotatedWith('aop')->to(FakeScriptDirAopConsumer::class); + $this->bindInterceptor( + $this->matcher->subclassesOf(FakeScriptDirAopConsumer::class), + $this->matcher->any(), + [FakeInterceptor::class], + ); + } +} diff --git a/tests/Fake/FakeScriptDirProvider.php b/tests/Fake/FakeScriptDirProvider.php new file mode 100644 index 00000000..6afcf02d --- /dev/null +++ b/tests/Fake/FakeScriptDirProvider.php @@ -0,0 +1,21 @@ + */ +class FakeScriptDirProvider implements ProviderInterface +{ + public function __construct(#[ScriptDir] private string $scriptDir) + { + } + + public function get(): FakeScriptDirConsumerInterface + { + return new FakeScriptDirConstructorConsumer($this->scriptDir); + } +} diff --git a/tests/Fake/FakeScriptDirSetterConsumer.php b/tests/Fake/FakeScriptDirSetterConsumer.php new file mode 100644 index 00000000..dcb0081d --- /dev/null +++ b/tests/Fake/FakeScriptDirSetterConsumer.php @@ -0,0 +1,24 @@ +scriptDir = $scriptDir; + } + + public function getScriptDir(): string + { + return $this->scriptDir; + } +} diff --git a/tests/ScriptDirRelocationTest.php b/tests/ScriptDirRelocationTest.php new file mode 100644 index 00000000..48e2e390 --- /dev/null +++ b/tests/ScriptDirRelocationTest.php @@ -0,0 +1,152 @@ +name() . '-' . preg_replace('/[^A-Za-z0-9_-]+/', '_', (string) $this->dataName()); + $this->buildDir = __DIR__ . '/tmp/script-dir-relocation-' . $case . '-build'; + $this->runtimeDir = __DIR__ . '/tmp/script-dir-relocation-' . $case . '-runtime'; + @mkdir($this->buildDir, 0777, true); + (new Compiler())->compile(new FakeScriptDirModule(), $this->buildDir); + } + + public function testGeneratedScriptDirScriptContainsNoBakedPath(): void + { + $script = file_get_contents($this->buildDir . '/-Ray_Di_Annotation_ScriptDir.php'); + $this->assertSame("buildDir . '/-Ray_Compiler_Annotation_Compile.php'); + $this->assertSame("findGeneratedPhpFiles($this->buildDir); + $this->assertNotEmpty($phpFiles, 'expected at least one generated *.php script to be scanned'); + + // Collect every offender instead of asserting inside the loop, so a + // single run reports all leaking scripts, not just the first one. + $leaking = array_values(array_filter( + $phpFiles, + fn (string $path): bool => str_contains((string) file_get_contents($path), $this->buildDir), + )); + + $this->assertSame([], $leaking, 'these generated scripts must not contain the compile-time path: ' . implode(', ', $leaking)); + } + + /** @return list */ + private function findGeneratedPhpFiles(string $dir): array + { + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)); + $files = []; + foreach ($iterator as $file) { + $path = (string) $file; + if (str_ends_with($path, '.php')) { + $files[] = $path; + } + } + + return $files; + } + + /** @return array */ + public static function provideBindingNames(): array + { + return [ + 'constructor injection' => ['ctor'], + 'setter injection' => ['setter'], + 'provider' => ['provider'], + 'AOP-woven class' => ['aop'], + ]; + } + + #[DataProvider('provideBindingNames')] + public function testRelocatedBindingResolvesToTheRuntimeDir(string $bindingName): void + { + [$injector, $runtimeDir] = $this->relocateAndCreateInjector(); + + $instance = $injector->getInstance(FakeScriptDirConsumerInterface::class, $bindingName); + $this->assertInstanceOf(FakeScriptDirConsumerInterface::class, $instance); + $this->assertSame($runtimeDir, $instance->getScriptDir()); + } + + public function testRelocatedScriptDirInstanceBindingResolvesToTheRuntimeDir(): void + { + [$injector, $runtimeDir] = $this->relocateAndCreateInjector(); + + $scriptDir = $injector->getInstance('', ScriptDir::class); + $this->assertSame($runtimeDir, $scriptDir); + } + + public function testRelocatedInjectorInterfaceBindingIsUsable(): void + { + [$injector, $runtimeDir] = $this->relocateAndCreateInjector(); + + $innerInjector = $injector->getInstance(InjectorInterface::class); + $this->assertInstanceOf(CompiledInjector::class, $innerInjector); + + $instance = $innerInjector->getInstance(FakeScriptDirConsumerInterface::class, 'ctor'); + $this->assertSame($runtimeDir, $instance->getScriptDir()); + } + + /** @return array{0: CompiledInjector, 1: string} */ + private function relocateAndCreateInjector(): array + { + $this->assertTrue(rename($this->buildDir, $this->runtimeDir), 'failed to relocate the compiled script dir'); + + $runtimeDir = $this->runtimeDir; + assert($runtimeDir !== ''); + + $realRuntimeDir = realpath($runtimeDir); + $this->assertIsString($realRuntimeDir); + + return [new CompiledInjector($runtimeDir), $realRuntimeDir]; + } +}