diff --git a/src/Symfony/Component/Config/Loader/FileLoader.php b/src/Symfony/Component/Config/Loader/FileLoader.php index cdc4329d5215f..f0896a3b7b53e 100644 --- a/src/Symfony/Component/Config/Loader/FileLoader.php +++ b/src/Symfony/Component/Config/Loader/FileLoader.php @@ -32,7 +32,7 @@ abstract class FileLoader extends Loader */ protected $locator; - private $currentDir; + protected $currentDir; /** * Constructor. diff --git a/src/Symfony/Component/DependencyInjection/CHANGELOG.md b/src/Symfony/Component/DependencyInjection/CHANGELOG.md index e1aff752e1535..0461e74bee3bd 100644 --- a/src/Symfony/Component/DependencyInjection/CHANGELOG.md +++ b/src/Symfony/Component/DependencyInjection/CHANGELOG.md @@ -4,6 +4,7 @@ CHANGELOG 3.3.0 ----- + * [EXPERIMENTAL] added prototype services for PSR4-based discovery and registration * added `ContainerBuilder::getReflectionClass()` for retrieving and tracking reflection class info * deprecated `ContainerBuilder::getClassResource()`, use `ContainerBuilder::getReflectionClass()` or `ContainerBuilder::addObjectResource()` instead * added `ContainerBuilder::fileExists()` for checking and tracking file or directory existence diff --git a/src/Symfony/Component/DependencyInjection/Loader/FileLoader.php b/src/Symfony/Component/DependencyInjection/Loader/FileLoader.php index 90cd6bcfafa4d..c09ed5d2bdba3 100644 --- a/src/Symfony/Component/DependencyInjection/Loader/FileLoader.php +++ b/src/Symfony/Component/DependencyInjection/Loader/FileLoader.php @@ -12,8 +12,13 @@ namespace Symfony\Component\DependencyInjection\Loader; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; +use Symfony\Component\DependencyInjection\Exception\LogicException; use Symfony\Component\Config\Loader\FileLoader as BaseFileLoader; use Symfony\Component\Config\FileLocatorInterface; +use Symfony\Component\Finder\Finder; +use Symfony\Component\Finder\Glob; /** * FileLoader is the abstract class used by all built-in loaders that are file based. @@ -34,4 +39,108 @@ public function __construct(ContainerBuilder $container, FileLocatorInterface $l parent::__construct($locator); } + + /** + * Registers a set of classes as services using PSR-4 for discovery. + * + * @param Definition $prototype A definition to use as template + * @param string $namespace The namespace prefix of classes in the scanned directory + * @param string $resource The directory to look for classes, glob-patterns allowed + * + * @experimental in version 3.3 + */ + public function registerClasses(Definition $prototype, $namespace, $resource) + { + if ('\\' !== substr($namespace, -1)) { + throw new InvalidArgumentException(sprintf('Namespace prefix must end with a "\\": %s.', $namespace)); + } + if (!preg_match('/^(?:[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+\\\\)++$/', $namespace)) { + throw new InvalidArgumentException(sprintf('Namespace is not a valid PSR-4 prefix: %s.', $namespace)); + } + + $classes = $this->findClasses($namespace, $resource); + // prepare for deep cloning + $prototype = serialize($prototype); + + foreach ($classes as $class) { + $this->container->setDefinition($class, unserialize($prototype)); + } + } + + private function findClasses($namespace, $resource) + { + $classes = array(); + $extRegexp = defined('HHVM_VERSION') ? '/\\.(?:php|hh)$/' : '/\\.php$/'; + + foreach ($this->glob($resource, true, $prefixLen) as $path => $info) { + if (!preg_match($extRegexp, $path, $m) || !$info->isFile() || !$info->isReadable()) { + continue; + } + $class = $namespace.ltrim(str_replace('/', '\\', substr($path, $prefixLen, -strlen($m[0]))), '\\'); + + if (!preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+(?:\\\\[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+)*+$/', $class)) { + continue; + } + if (!$r = $this->container->getReflectionClass($class, true)) { + continue; + } + if (!$r->isInterface() && !$r->isTrait()) { + $classes[] = $class; + } + } + + return $classes; + } + + private function glob($resource, $recursive, &$prefixLen = null) + { + if (strlen($resource) === $i = strcspn($resource, '*?{[')) { + $resourcePrefix = $resource; + $resource = ''; + } elseif (0 === $i) { + $resourcePrefix = '.'; + $resource = '/'.$resource; + } else { + $resourcePrefix = dirname(substr($resource, 0, 1 + $i)); + $resource = substr($resource, strlen($resourcePrefix)); + } + + $resourcePrefix = $this->locator->locate($resourcePrefix, $this->currentDir, true); + $resourcePrefix = realpath($resourcePrefix) ?: $resourcePrefix; + $prefixLen = strlen($resourcePrefix); + + // track directories only for new & removed files + $this->container->fileExists($resourcePrefix, '/^$/'); + + if (false === strpos($resource, '/**/') && (defined('GLOB_BRACE') || false === strpos($resource, '{'))) { + foreach (glob($resourcePrefix.$resource, defined('GLOB_BRACE') ? GLOB_BRACE : 0) as $path) { + if ($recursive && is_dir($path)) { + $flags = \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS; + foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, $flags)) as $path => $info) { + yield $path => $info; + } + } else { + yield $path => new \SplFileInfo($path); + } + } + + return; + } + + if (!class_exists(Finder::class)) { + throw new LogicException(sprintf('Extended glob pattern "%s" cannot be used as the Finder component is not installed.', $resource)); + } + + $finder = new Finder(); + $regex = Glob::toRegex($resource); + if ($recursive) { + $regex = substr_replace($regex, '(/|$)', -2, 1); + } + + foreach ($finder->followLinks()->in($resourcePrefix) as $path => $info) { + if (preg_match($regex, substr($path, $prefixLen))) { + yield $path => $info; + } + } + } } diff --git a/src/Symfony/Component/DependencyInjection/Loader/XmlFileLoader.php b/src/Symfony/Component/DependencyInjection/Loader/XmlFileLoader.php index afc4b95baf586..a3c5f69f1456f 100644 --- a/src/Symfony/Component/DependencyInjection/Loader/XmlFileLoader.php +++ b/src/Symfony/Component/DependencyInjection/Loader/XmlFileLoader.php @@ -121,14 +121,19 @@ private function parseDefinitions(\DOMDocument $xml, $file) $xpath = new \DOMXPath($xml); $xpath->registerNamespace('container', self::NS); - if (false === $services = $xpath->query('//container:services/container:service')) { + if (false === $services = $xpath->query('//container:services/container:service|//container:services/container:prototype')) { return; } + $this->setCurrentDir(dirname($file)); $defaults = $this->getServiceDefaults($xml, $file); foreach ($services as $service) { if (null !== $definition = $this->parseDefinition($service, $file, $defaults)) { - $this->container->setDefinition((string) $service->getAttribute('id'), $definition); + if ('prototype' === $service->tagName) { + $this->registerClasses($definition, (string) $service->getAttribute('namespace'), (string) $service->getAttribute('resource')); + } else { + $this->container->setDefinition((string) $service->getAttribute('id'), $definition); + } } } } diff --git a/src/Symfony/Component/DependencyInjection/Loader/YamlFileLoader.php b/src/Symfony/Component/DependencyInjection/Loader/YamlFileLoader.php index fb7216e730252..f9e755990eae9 100644 --- a/src/Symfony/Component/DependencyInjection/Loader/YamlFileLoader.php +++ b/src/Symfony/Component/DependencyInjection/Loader/YamlFileLoader.php @@ -36,7 +36,7 @@ */ class YamlFileLoader extends FileLoader { - private static $keywords = array( + private static $serviceKeywords = array( 'alias' => 'alias', 'parent' => 'parent', 'class' => 'class', @@ -62,6 +62,32 @@ class YamlFileLoader extends FileLoader 'autowiring_types' => 'autowiring_types', ); + private static $prototypeKeywords = array( + 'resource' => 'resource', + 'parent' => 'parent', + 'shared' => 'shared', + 'lazy' => 'lazy', + 'public' => 'public', + 'abstract' => 'abstract', + 'deprecated' => 'deprecated', + 'factory' => 'factory', + 'arguments' => 'arguments', + 'properties' => 'properties', + 'getters' => 'getters', + 'configurator' => 'configurator', + 'calls' => 'calls', + 'tags' => 'tags', + 'inherit_tags' => 'inherit_tags', + 'autowire' => 'autowire', + ); + + private static $defaultsKeywords = array( + 'public' => 'public', + 'tags' => 'tags', + 'inherit_tags' => 'inherit_tags', + 'autowire' => 'autowire', + ); + private $yamlParser; /** @@ -98,6 +124,7 @@ public function load($resource, $type = null) $this->loadFromExtensions($content); // services + $this->setCurrentDir(dirname($path)); $this->parseDefinitions($content, $resource); } @@ -188,12 +215,11 @@ private function parseDefaults(array &$content, $file) return array(); } - $defaultKeys = array('public', 'tags', 'inherit_tags', 'autowire'); unset($content['services']['_defaults']); foreach ($defaults as $key => $default) { - if (!in_array($key, $defaultKeys)) { - throw new InvalidArgumentException(sprintf('The configuration key "%s" cannot be used to define a default value in "%s". Allowed keys are "%s".', $key, $file, implode('", "', $defaultKeys))); + if (!isset(self::$defaultsKeywords[$key])) { + throw new InvalidArgumentException(sprintf('The configuration key "%s" cannot be used to define a default value in "%s". Allowed keys are "%s".', $key, $file, implode('", "', self::$defaultsKeywords))); } } if (!isset($defaults['tags'])) { @@ -443,7 +469,14 @@ private function parseDefinition($id, $service, $file, array $defaults) } } - $this->container->setDefinition($id, $definition); + if (array_key_exists('resource', $service)) { + if (!is_string($service['resource'])) { + throw new InvalidArgumentException(sprintf('A "resource" attribute must be of type string for service "%s" in %s. Check your YAML syntax.', $id, $file)); + } + $this->registerClasses($definition, $id, $service['resource']); + } else { + $this->container->setDefinition($id, $definition); + } } /** @@ -660,13 +693,19 @@ private function loadFromExtensions(array $content) */ private static function checkDefinition($id, array $definition, $file) { + if ($throw = isset($definition['resource'])) { + $keywords = static::$prototypeKeywords; + } else { + $keywords = static::$serviceKeywords; + } + foreach ($definition as $key => $value) { - if (!isset(static::$keywords[$key])) { - @trigger_error(sprintf('The configuration key "%s" is unsupported for service definition "%s" in "%s". Allowed configuration keys are "%s". The YamlFileLoader object will raise an exception instead in Symfony 4.0 when detecting an unsupported service configuration key.', $key, $id, $file, implode('", "', static::$keywords)), E_USER_DEPRECATED); - // @deprecated Uncomment the following statement in Symfony 4.0 - // and also update the corresponding unit test to make it expect - // an InvalidArgumentException exception. - //throw new InvalidArgumentException(sprintf('The configuration key "%s" is unsupported for service definition "%s" in "%s". Allowed configuration keys are "%s".', $key, $id, $file, implode('", "', static::$keywords))); + if (!isset($keywords[$key])) { + if ($throw) { + throw new InvalidArgumentException(sprintf('The configuration key "%s" is unsupported for definition "%s" in "%s". Allowed configuration keys are "%s".', $key, $id, $file, implode('", "', $keywords))); + } + + @trigger_error(sprintf('The configuration key "%s" is unsupported for service definition "%s" in "%s". Allowed configuration keys are "%s". The YamlFileLoader object will raise an exception instead in Symfony 4.0 when detecting an unsupported service configuration key.', $key, $id, $file, implode('", "', $keywords)), E_USER_DEPRECATED); } } } diff --git a/src/Symfony/Component/DependencyInjection/Loader/schema/dic/services/services-1.0.xsd b/src/Symfony/Component/DependencyInjection/Loader/schema/dic/services/services-1.0.xsd index 2a90dae6cf8d4..fd5bb7b5d1da4 100644 --- a/src/Symfony/Component/DependencyInjection/Loader/schema/dic/services/services-1.0.xsd +++ b/src/Symfony/Component/DependencyInjection/Loader/schema/dic/services/services-1.0.xsd @@ -54,6 +54,7 @@ + @@ -136,6 +137,29 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/Prototype/Foo.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/Prototype/Foo.php new file mode 100644 index 0000000000000..1e4f283c8f16e --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/Prototype/Foo.php @@ -0,0 +1,7 @@ + + + + + + diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/yaml/services_prototype.yml b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/yaml/services_prototype.yml new file mode 100644 index 0000000000000..7113c9d957505 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/yaml/services_prototype.yml @@ -0,0 +1,3 @@ +services: + Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\: + resource: ../Prototype diff --git a/src/Symfony/Component/DependencyInjection/Tests/Loader/XmlFileLoaderTest.php b/src/Symfony/Component/DependencyInjection/Tests/Loader/XmlFileLoaderTest.php index b8673a54d94c7..623768ba4df68 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Loader/XmlFileLoaderTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Loader/XmlFileLoaderTest.php @@ -20,7 +20,10 @@ use Symfony\Component\DependencyInjection\Loader\IniFileLoader; use Symfony\Component\Config\Loader\LoaderResolver; use Symfony\Component\Config\FileLocator; +use Symfony\Component\Config\Resource\DirectoryResource; +use Symfony\Component\Config\Resource\FileResource; use Symfony\Component\DependencyInjection\Tests\Fixtures\CaseSensitiveClass; +use Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype; use Symfony\Component\ExpressionLanguage\Expression; class XmlFileLoaderTest extends \PHPUnit_Framework_TestCase @@ -608,6 +611,26 @@ public function testClassFromId() $this->assertEquals(CaseSensitiveClass::class, $container->getDefinition(CaseSensitiveClass::class)->getClass()); } + public function testPrototype() + { + $container = new ContainerBuilder(); + $loader = new XmlFileLoader($container, new FileLocator(self::$fixturesPath.'/xml')); + $loader->load('services_prototype.xml'); + + $ids = array_keys($container->getDefinitions()); + sort($ids); + $this->assertSame(array(Prototype\Foo::class, Prototype\Sub\Bar::class), $ids); + + $resources = $container->getResources(); + + $fixturesDir = dirname(__DIR__).DIRECTORY_SEPARATOR.'Fixtures'.DIRECTORY_SEPARATOR; + $this->assertTrue(false !== array_search(new FileResource($fixturesDir.'xml'.DIRECTORY_SEPARATOR.'services_prototype.xml'), $resources)); + $this->assertTrue(false !== array_search(new DirectoryResource($fixturesDir.'Prototype', '/^$/'), $resources)); + $resources = array_map('strval', $resources); + $this->assertContains('reflection.Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\Foo', $resources); + $this->assertContains('reflection.Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\Sub\Bar', $resources); + } + /** * @group legacy * @expectedDeprecation Using the attribute "class" is deprecated for the service "bar" which is defined as an alias %s. diff --git a/src/Symfony/Component/DependencyInjection/Tests/Loader/YamlFileLoaderTest.php b/src/Symfony/Component/DependencyInjection/Tests/Loader/YamlFileLoaderTest.php index 91686d90b781b..de417b59cc56d 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Loader/YamlFileLoaderTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Loader/YamlFileLoaderTest.php @@ -20,7 +20,10 @@ use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\Config\Loader\LoaderResolver; use Symfony\Component\Config\FileLocator; +use Symfony\Component\Config\Resource\DirectoryResource; +use Symfony\Component\Config\Resource\FileResource; use Symfony\Component\DependencyInjection\Tests\Fixtures\CaseSensitiveClass; +use Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype; use Symfony\Component\ExpressionLanguage\Expression; class YamlFileLoaderTest extends \PHPUnit_Framework_TestCase @@ -372,6 +375,26 @@ public function testClassFromId() $this->assertEquals(CaseSensitiveClass::class, $container->getDefinition(CaseSensitiveClass::class)->getClass()); } + public function testPrototype() + { + $container = new ContainerBuilder(); + $loader = new YamlFileLoader($container, new FileLocator(self::$fixturesPath.'/yaml')); + $loader->load('services_prototype.yml'); + + $ids = array_keys($container->getDefinitions()); + sort($ids); + $this->assertSame(array(Prototype\Foo::class, Prototype\Sub\Bar::class), $ids); + + $resources = $container->getResources(); + + $fixturesDir = dirname(__DIR__).DIRECTORY_SEPARATOR.'Fixtures'.DIRECTORY_SEPARATOR; + $this->assertTrue(false !== array_search(new FileResource($fixturesDir.'yaml'.DIRECTORY_SEPARATOR.'services_prototype.yml'), $resources)); + $this->assertTrue(false !== array_search(new DirectoryResource($fixturesDir.'Prototype', '/^$/'), $resources)); + $resources = array_map('strval', $resources); + $this->assertContains('reflection.Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\Foo', $resources); + $this->assertContains('reflection.Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\Sub\Bar', $resources); + } + public function testDefaults() { $container = new ContainerBuilder(); diff --git a/src/Symfony/Component/DependencyInjection/composer.json b/src/Symfony/Component/DependencyInjection/composer.json index f029accc314c4..3a2916635ffb8 100644 --- a/src/Symfony/Component/DependencyInjection/composer.json +++ b/src/Symfony/Component/DependencyInjection/composer.json @@ -27,12 +27,14 @@ "suggest": { "symfony/yaml": "", "symfony/config": "", + "symfony/finder": "For using double-star glob patterns or when GLOB_BRACE portability is required", "symfony/expression-language": "For using expressions in service container configuration", "symfony/proxy-manager-bridge": "Generate service proxies to lazy load them" }, "conflict": { - "symfony/yaml": "<3.3", - "symfony/config": "<3.3" + "symfony/config": "<3.3", + "symfony/finder": "<3.3", + "symfony/yaml": "<3.3" }, "provide": { "psr/container-implementation": "1.0" diff --git a/src/Symfony/Component/Finder/Glob.php b/src/Symfony/Component/Finder/Glob.php index 8a439411fbb6c..1fd508f2581f8 100644 --- a/src/Symfony/Component/Finder/Glob.php +++ b/src/Symfony/Component/Finder/Glob.php @@ -61,7 +61,7 @@ public static function toRegex($glob, $strictLeadingDot = true, $strictWildcardS $firstByte = '/' === $car; if ($firstByte && $strictWildcardSlash && isset($glob[$i + 3]) && '**/' === $glob[$i + 1].$glob[$i + 2].$glob[$i + 3]) { - $car = $strictLeadingDot ? '/((?=[^\.])[^/]+/)*' : '/([^/]+/)*'; + $car = $strictLeadingDot ? '/(?:(?=[^\.])[^/]++/)*' : '/(?:[^/]++/)*'; $i += 3; if ('/' === $delimiter) { $car = str_replace('/', '\\/', $car);