Skip to content
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
4 changes: 2 additions & 2 deletions src/AttributeRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
use AttributeRegistry\Service\AttributeScanner;
use AttributeRegistry\Service\CompiledCache;
use AttributeRegistry\Service\PathResolver;
use AttributeRegistry\Service\PluginPathResolver;
use AttributeRegistry\Service\PluginLocator;
use Cake\Core\Configure;

/**
Expand Down Expand Up @@ -88,7 +88,7 @@ private static function createFromConfig(): self
// The callback is only invoked when scanning is needed (cache miss)
$pathResolver = new PathResolver(
ROOT,
fn(): array => (new PluginPathResolver())->getEnabledPluginPaths(),
fn(): array => (new PluginLocator())->getEnabledPluginPaths(),
);

// Determine cache path from config
Expand Down
5 changes: 5 additions & 0 deletions src/Service/AttributeParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@ class AttributeParser
* Constructor for AttributeParser.
*
* @param array<string> $excludeAttributes List of attribute FQCNs to exclude (supports wildcards)
* @param \AttributeRegistry\Service\PluginLocator|null $pluginLocator Plugin locator for plugin detection
*/
public function __construct(
private array $excludeAttributes = [],
private ?PluginLocator $pluginLocator = null,
) {
}

Expand Down Expand Up @@ -333,6 +335,8 @@ private function createAttributeInfo(
string $fileHash,
AttributeTarget $target,
): AttributeInfo {
$pluginName = $this->pluginLocator?->getPluginNameFromPath($filePath);

return new AttributeInfo(
className: $className,
attributeName: $attribute->getName(),
Expand All @@ -341,6 +345,7 @@ className: $className,
lineNumber: $lineNumber,
target: $target,
fileHash: $fileHash,
pluginName: $pluginName,
);
}

Expand Down
3 changes: 3 additions & 0 deletions src/Service/CompiledCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ private function generateAttributeInfo(AttributeInfo $attr): string
"%s lineNumber: %d,\n" .
"%s target: %s,\n" .
"%s fileHash: %s,\n" .
"%s pluginName: %s,\n" .
'%s)',
$indent,
$indent,
Expand All @@ -275,6 +276,8 @@ private function generateAttributeInfo(AttributeInfo $attr): string
$indent,
$this->exportString($attr->fileHash),
$indent,
$attr->pluginName === null ? 'null' : $this->exportString($attr->pluginName),
$indent,
);
}

Expand Down
117 changes: 117 additions & 0 deletions src/Service/PluginLocator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);

namespace AttributeRegistry\Service;

use Cake\Core\Plugin;
use Cake\Core\PluginConfig;

/**
* Locates and identifies plugins for attribute scanning
*
* This class retrieves all enabled plugins (including CLI-only plugins)
* regardless of the current request context (CLI vs web).
*/
class PluginLocator
{
/**
* Cache for path to plugin name mapping
*
* @var array<string, string>|null
*/
private ?array $pathToPluginMap = null;

/**
* Get paths for all enabled plugins
*
* This method returns paths for ALL plugins that are configured to load,
* including those marked with 'onlyCli' => true. This ensures atomic
* discovery where the same attributes are discovered regardless of whether
* the discovery happens from CLI or web context.
*
* For local plugins without packagePath, falls back to Plugin::getCollection()
* to retrieve the path from the loaded plugin instance.
*
* @return array<string> Array of absolute plugin paths
*/
public function getEnabledPluginPaths(): array
{
return array_keys($this->getPluginPathMap());
}

/**
* Get mapping of plugin paths to plugin names
*
* Returns a mapping of absolute plugin paths to their corresponding plugin names.
* Results are cached for performance.
*
* @return array<string, string> ['path/to/plugin' => 'PluginName']
*/
public function getPluginPathMap(): array
{
if ($this->pathToPluginMap !== null) {
return $this->pathToPluginMap;
}

$map = [];
$allPlugins = PluginConfig::getAppConfig();
$pluginCollection = Plugin::getCollection();

foreach ($allPlugins as $config) {
if (($config['isLoaded'] ?? false) !== true) {
continue;
}

$pluginName = $config['name'] ?? null;

// Use packagePath from config if available
if (isset($config['packagePath']) && $pluginName) {
$map[$config['packagePath']] = $pluginName;
}
}

// Also check plugin collection for any plugins not in config
// This ensures we don't miss plugins loaded directly via Plugin::getCollection()->add()
foreach ($pluginCollection as $plugin) {
$pluginPath = $plugin->getPath();
$pluginName = $plugin->getName();

if (!isset($map[$pluginPath])) {
$map[$pluginPath] = $pluginName;
}
}

$this->pathToPluginMap = $map;

return $map;
}

/**
* Get plugin name from file path
*
* Determines which plugin a file belongs to by checking if its path
* starts with any known plugin path. Paths are checked in descending
* length order to ensure more specific (longer) paths match first,
* preventing issues with paths that are substrings of each other.
*
* @param string $filePath Absolute file path
* @return string|null Plugin name or null if file is in App namespace
*/
public function getPluginNameFromPath(string $filePath): ?string
{
$map = $this->getPluginPathMap();

// Sort paths by length descending to check more specific paths first
// This prevents '/plugins/Test' from matching before '/plugins/TestExtended'
uksort($map, fn(string $a, string $b): int => strlen($b) - strlen($a));

// Check if file path starts with any plugin path
foreach ($map as $pluginPath => $pluginName) {
if (str_starts_with($filePath, $pluginPath)) {
return $pluginName;
}
}

return null;
}
}
58 changes: 0 additions & 58 deletions src/Service/PluginPathResolver.php

This file was deleted.

4 changes: 4 additions & 0 deletions src/ValueObject/AttributeInfo.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
* @param int $lineNumber Line number where attribute was found
* @param \AttributeRegistry\ValueObject\AttributeTarget $target Target information
* @param string $fileHash File content hash (xxh3) for validation
* @param string|null $pluginName Plugin name or null for App namespace
*/
public function __construct(
public string $className,
Expand All @@ -27,6 +28,7 @@ public function __construct(
public int $lineNumber,
public AttributeTarget $target,
public string $fileHash = '',
public ?string $pluginName = null,
) {
}

Expand All @@ -45,6 +47,7 @@ public function toArray(): array
'lineNumber' => $this->lineNumber,
'target' => $this->target->toArray(),
'fileHash' => $this->fileHash,
'pluginName' => $this->pluginName,
];
}

Expand All @@ -63,6 +66,7 @@ className: (string)$data['className'],
lineNumber: (int)$data['lineNumber'],
target: AttributeTarget::fromArray((array)$data['target']),
fileHash: (string)($data['fileHash'] ?? ''),
pluginName: $data['pluginName'] ?? null,
);
}

Expand Down
6 changes: 3 additions & 3 deletions tests/TestCase/AttributeRegistryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
use AttributeRegistry\AttributeRegistry;
use AttributeRegistry\Collection\AttributeCollection;
use AttributeRegistry\Enum\AttributeTargetType;
use AttributeRegistry\Service\PluginPathResolver;
use AttributeRegistry\Service\PluginLocator;
use AttributeRegistry\Test\Data\TestAttributeArgument;
use AttributeRegistry\Test\Data\TestRoute;
use AttributeRegistry\Test\Data\TestWithObject;
Expand Down Expand Up @@ -393,8 +393,8 @@ public function testObjectArgumentsPreservedThroughCaching(): void
public function testDiscoverIncludesLocalPluginAttributes(): void
{
// Debug: Check if plugin paths are being picked up
$pluginPathResolver = new PluginPathResolver();
$paths = $pluginPathResolver->getEnabledPluginPaths();
$pluginLocator = new PluginLocator();
$paths = $pluginLocator->getEnabledPluginPaths();

// Discover all attributes
$results = $this->registry->discover();
Expand Down
6 changes: 3 additions & 3 deletions tests/TestCase/AttributeRegistryTestTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
use AttributeRegistry\Service\AttributeScanner;
use AttributeRegistry\Service\CompiledCache;
use AttributeRegistry\Service\PathResolver;
use AttributeRegistry\Service\PluginPathResolver;
use AttributeRegistry\Service\PluginLocator;

/**
* Trait providing factory methods for common test objects.
Expand Down Expand Up @@ -110,8 +110,8 @@ protected function createRegistryWithPlugins(
array $scannerConfig = [],
): AttributeRegistry {
// Build path string including test data + all loaded plugins
$pluginPathResolver = new PluginPathResolver();
$pluginPaths = $pluginPathResolver->getEnabledPluginPaths();
$pluginLocator = new PluginLocator();
$pluginPaths = $pluginLocator->getEnabledPluginPaths();
$allPaths = array_merge([$this->getTestDataPath()], $pluginPaths);
$pathString = implode(PATH_SEPARATOR, $allPaths);

Expand Down
39 changes: 39 additions & 0 deletions tests/TestCase/Service/AttributeParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

use AttributeRegistry\Enum\AttributeTargetType;
use AttributeRegistry\Service\AttributeParser;
use AttributeRegistry\Service\PluginLocator;
use AttributeRegistry\Test\Data\TestColumn;
use AttributeRegistry\Test\Data\TestConst;
use AttributeRegistry\Test\Data\TestController;
Expand Down Expand Up @@ -275,4 +276,42 @@ public function testParseFileGeneratesFileHash(): void
$this->assertEquals($firstHash, $attr->fileHash, 'All attributes from same file should have same hash');
}
}

public function testParseFileDetectsPluginNameForAppFiles(): void
{
// Test file is in tests/data - should be null (app file)
$attributes = $this->parser->parseFile($this->testFilePath);

$this->assertNotEmpty($attributes);

foreach ($attributes as $attr) {
$this->assertNull($attr->pluginName, 'App files should have null pluginName');
}
}

public function testParserAcceptsPluginLocatorInConstructor(): void
{
$locator = $this->createStub(PluginLocator::class);
$parser = new AttributeParser(pluginLocator: $locator);

$this->assertInstanceOf(AttributeParser::class, $parser);
}

public function testParseFileUsesPluginLocatorToDetectPluginName(): void
{
$locator = $this->createMock(PluginLocator::class);
$locator->expects($this->atLeastOnce())
->method('getPluginNameFromPath')
->with($this->testFilePath)
->willReturn('TestPlugin');

$parser = new AttributeParser(pluginLocator: $locator);
$attributes = $parser->parseFile($this->testFilePath);

$this->assertNotEmpty($attributes);

foreach ($attributes as $attr) {
$this->assertEquals('TestPlugin', $attr->pluginName);
}
}
}
2 changes: 1 addition & 1 deletion tests/TestCase/Service/AttributeScannerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ public function testScanFileLogsWarningOnException(): void
// The malformed file should not produce any valid attributes
$malformedAttrs = array_filter(
$attributes,
fn($attr) => str_contains($attr->filePath, 'malformed.php'),
fn(AttributeInfo $attr): bool => str_contains($attr->filePath, 'malformed.php'),
);
$this->assertEmpty($malformedAttrs);
} finally {
Expand Down
Loading
Loading