1: <?php declare(strict_types = 1);
2:
3: namespace PHPStan\DependencyInjection;
4:
5: use Nette\Bootstrap\Extensions\PhpExtension;
6: use Nette\DI\Config\Adapters\PhpAdapter;
7: use Nette\DI\Definitions\Statement;
8: use Nette\DI\Helpers;
9: use Nette\Schema\Context as SchemaContext;
10: use Nette\Schema\Elements\AnyOf;
11: use Nette\Schema\Elements\Structure;
12: use Nette\Schema\Elements\Type;
13: use Nette\Schema\Expect;
14: use Nette\Schema\Processor;
15: use Nette\Schema\Schema;
16: use Nette\Utils\Strings;
17: use Nette\Utils\Validators;
18: use Phar;
19: use PhpParser\Parser;
20: use PHPStan\Analyser\ExprHandlerRegistry;
21: use PHPStan\Analyser\StmtHandlerRegistry;
22: use PHPStan\BetterReflection\BetterReflection;
23: use PHPStan\BetterReflection\Reflector\Reflector;
24: use PHPStan\BetterReflection\SourceLocator\SourceStubber\PhpStormStubsSourceStubber;
25: use PHPStan\BetterReflection\SourceLocator\Type\SourceLocator;
26: use PHPStan\Command\CommandHelper;
27: use PHPStan\Command\Environment;
28: use PHPStan\File\FileHelper;
29: use PHPStan\Node\Printer\Printer;
30: use PHPStan\Php\PhpVersion;
31: use PHPStan\Reflection\PhpVersionStaticAccessor;
32: use PHPStan\Reflection\ReflectionProvider;
33: use PHPStan\Reflection\ReflectionProviderStaticAccessor;
34: use PHPStan\ShouldNotHappenException;
35: use PHPStan\Type\ObjectType;
36: use PHPStan\Type\TypeCombinator;
37: use WeakReference;
38: use function array_diff_key;
39: use function array_intersect;
40: use function array_key_exists;
41: use function array_keys;
42: use function array_map;
43: use function array_merge;
44: use function array_slice;
45: use function array_unique;
46: use function count;
47: use function dirname;
48: use function extension_loaded;
49: use function implode;
50: use function ini_get;
51: use function is_array;
52: use function is_file;
53: use function is_readable;
54: use function is_string;
55: use function sprintf;
56: use function str_ends_with;
57: use function substr;
58:
59: /**
60: * @api
61: */
62: final class ContainerFactory
63: {
64:
65: private FileHelper $fileHelper;
66:
67: private string $rootDirectory;
68:
69: private string $configDirectory;
70:
71: /** @var WeakReference<Container>|null */
72: private static ?WeakReference $lastInitializedContainer = null;
73:
74: private bool $journalContainer = false;
75:
76: /** @api */
77: public function __construct(private string $currentWorkingDirectory)
78: {
79: $this->fileHelper = new FileHelper($currentWorkingDirectory);
80:
81: $rootDir = __DIR__ . '/../..';
82: $originalRootDir = $this->fileHelper->normalizePath($rootDir);
83: if (extension_loaded('phar')) {
84: $pharPath = Phar::running(false);
85: if ($pharPath !== '') {
86: $rootDir = dirname($pharPath);
87: }
88: }
89: $this->rootDirectory = $this->fileHelper->normalizePath($rootDir);
90: $this->configDirectory = $originalRootDir . '/conf';
91: }
92:
93: public function setJournalContainer(): void
94: {
95: $this->journalContainer = true;
96: }
97:
98: /**
99: * @param string[] $additionalConfigFiles
100: * @param string[] $analysedPaths
101: * @param string[] $composerAutoloaderProjectPaths
102: * @param string[] $analysedPathsFromConfig
103: * @param array<mixed> $additionalParameters
104: */
105: public function create(
106: string $tempDirectory,
107: array $additionalConfigFiles,
108: array $analysedPaths,
109: array $composerAutoloaderProjectPaths = [],
110: array $analysedPathsFromConfig = [],
111: string $usedLevel = CommandHelper::DEFAULT_LEVEL,
112: ?string $generateBaselineFile = null,
113: ?string $cliAutoloadFile = null,
114: ?string $singleReflectionFile = null,
115: ?string $singleReflectionInsteadOfFile = null,
116: array $additionalParameters = [],
117: ): Container
118: {
119: [$allConfigFiles, $projectConfig] = $this->detectDuplicateIncludedFiles(
120: array_merge([__DIR__ . '/../../conf/parametersSchema.neon'], $additionalConfigFiles),
121: [
122: 'rootDir' => $this->rootDirectory,
123: 'currentWorkingDirectory' => $this->currentWorkingDirectory,
124: 'env' => Environment::getCleanedArray(),
125: ],
126: );
127:
128: $configurator = new Configurator(new LoaderFactory(
129: $this->fileHelper,
130: $this->rootDirectory,
131: $this->currentWorkingDirectory,
132: $generateBaselineFile,
133: $projectConfig['expandRelativePaths'],
134: ), $this->journalContainer);
135: $configurator->defaultExtensions = [
136: 'php' => PhpExtension::class,
137: // registers everything marked with #[ContainerExtension] and handles `extensions:` sections
138: 'extensions' => ContainerExtensionsExtension::class,
139: ];
140: $configurator->setDebugMode(true);
141: $configurator->setTempDirectory($tempDirectory);
142: $configurator->addParameters(array_merge([
143: 'rootDir' => $this->rootDirectory,
144: 'currentWorkingDirectory' => $this->currentWorkingDirectory,
145: 'cliArgumentsVariablesRegistered' => ini_get('register_argc_argv') === '1',
146: 'tmpDir' => $tempDirectory,
147: 'additionalConfigFiles' => $additionalConfigFiles,
148: 'allConfigFiles' => $allConfigFiles,
149: 'composerAutoloaderProjectPaths' => $composerAutoloaderProjectPaths,
150: 'generateBaselineFile' => $generateBaselineFile,
151: 'usedLevel' => $usedLevel,
152: 'cliAutoloadFile' => $cliAutoloadFile,
153: 'env' => Environment::getCleanedArray(),
154: ], $additionalParameters));
155: $configurator->addDynamicParameters([
156: 'singleReflectionFile' => $singleReflectionFile,
157: 'singleReflectionInsteadOfFile' => $singleReflectionInsteadOfFile,
158: 'analysedPaths' => $analysedPaths,
159: 'analysedPathsFromConfig' => $analysedPathsFromConfig,
160: ]);
161: $configurator->addConfig($this->configDirectory . '/config.neon');
162: foreach ($additionalConfigFiles as $additionalConfigFile) {
163: $configurator->addConfig($additionalConfigFile);
164: }
165:
166: $configurator->setAllConfigFiles($allConfigFiles);
167:
168: $container = $configurator->createContainer()->getByType(Container::class);
169: $this->validateParameters($container->getParameters(), $projectConfig['parametersSchema']);
170: self::postInitializeContainer($container);
171:
172: return $container;
173: }
174:
175: /** @internal */
176: public static function postInitializeContainer(Container $container): void
177: {
178: // Comparing spl_object_id()s would be wrong: an id is recycled as soon as its
179: // container is freed, so a fresh container could inherit the last initialized
180: // one's id and silently skip installing its own global state.
181: $lastInitializedContainer = self::$lastInitializedContainer !== null ? self::$lastInitializedContainer->get() : null;
182: if ($lastInitializedContainer === $container) {
183: return;
184: }
185:
186: self::$lastInitializedContainer = WeakReference::create($container);
187:
188: // Both registries memoize handler instances per container, keyed by the
189: // container's spl_object_id() - a recycled id would otherwise hand this
190: // container the freed one's handlers, which carry the other container's
191: // services and parameters.
192: ExprHandlerRegistry::clearCache();
193: StmtHandlerRegistry::clearCache();
194:
195: /** @var SourceLocator $sourceLocator */
196: $sourceLocator = $container->getService('betterReflectionSourceLocator');
197:
198: /** @var Reflector $reflector */
199: $reflector = $container->getService('betterReflectionReflector');
200:
201: /** @var Parser $phpParser */
202: $phpParser = $container->getService('phpParserDecorator');
203:
204: BetterReflection::populate(
205: $container->getByType(PhpVersion::class)->getVersionId(),
206: $sourceLocator,
207: $reflector,
208: $phpParser,
209: $container->getByType(PhpStormStubsSourceStubber::class),
210: $container->getByType(Printer::class),
211: );
212:
213: ReflectionProviderStaticAccessor::registerInstance($container->getByType(ReflectionProvider::class));
214: PhpVersionStaticAccessor::registerInstance($container->getByType(PhpVersion::class));
215: ObjectType::resetCaches();
216:
217: $container->getService('typeSpecifier');
218:
219: BleedingEdgeToggle::setBleedingEdge($container->getParameter('featureToggles')['bleedingEdge']);
220: ReportUnsafeArrayStringKeyCastingToggle::setLevel($container->getParameter('reportUnsafeArrayStringKeyCasting'));
221:
222: // Type operations read global state — the toggles above, the reflection provider,
223: // the PHP version — so a memoized result is only valid for the state it was computed
224: // under. Clearing must be the LAST step: building the typeSpecifier service runs
225: // extension constructors that can already perform type operations, and entries
226: // memoized before the toggles are set would encode the previous container's state.
227: TypeCombinator::clearCache();
228: }
229:
230: public function getCurrentWorkingDirectory(): string
231: {
232: return $this->currentWorkingDirectory;
233: }
234:
235: public function getRootDirectory(): string
236: {
237: return $this->rootDirectory;
238: }
239:
240: public function getConfigDirectory(): string
241: {
242: return $this->configDirectory;
243: }
244:
245: /**
246: * @param string[] $configFiles
247: * @param array<string, mixed> $loaderParameters
248: * @return array{list<string>, array<mixed>}
249: * @throws DuplicateIncludedFilesException
250: */
251: private function detectDuplicateIncludedFiles(
252: array $configFiles,
253: array $loaderParameters,
254: ): array
255: {
256: $neonAdapter = new NeonCachedFileReader([]);
257: $phpAdapter = new PhpAdapter();
258: $allConfigFiles = [];
259: $configArray = [];
260: foreach ($configFiles as $configFile) {
261: [$tmpConfigFiles, $tmpConfigArray] = self::getConfigFiles($this->fileHelper, $neonAdapter, $phpAdapter, $configFile, $loaderParameters, null);
262: $allConfigFiles = array_merge($allConfigFiles, $tmpConfigFiles);
263:
264: /** @var array<mixed> $configArray */
265: $configArray = \Nette\Schema\Helpers::merge($tmpConfigArray, $configArray);
266: }
267:
268: $normalized = array_map(fn (string $file): string => $this->fileHelper->normalizePath($file), $allConfigFiles);
269:
270: $deduplicated = array_unique($normalized);
271: if (count($normalized) <= count($deduplicated)) {
272: return [$normalized, $configArray];
273: }
274:
275: $duplicateFiles = array_unique(array_diff_key($normalized, $deduplicated));
276:
277: throw new DuplicateIncludedFilesException($duplicateFiles);
278: }
279:
280: /**
281: * @param array<string, string> $loaderParameters
282: * @return array{list<string>, array<mixed>}
283: */
284: private static function getConfigFiles(
285: FileHelper $fileHelper,
286: NeonCachedFileReader $neonAdapter,
287: PhpAdapter $phpAdapter,
288: string $configFile,
289: array $loaderParameters,
290: ?string $generateBaselineFile,
291: ): array
292: {
293: if ($generateBaselineFile === $fileHelper->normalizePath($configFile)) {
294: return [[], []];
295: }
296: if (!is_file($configFile) || !is_readable($configFile)) {
297: return [[], []];
298: }
299:
300: if (str_ends_with($configFile, '.php')) {
301: $data = $phpAdapter->load($configFile);
302: } else {
303: $data = $neonAdapter->load($configFile);
304: }
305: $allConfigFiles = [$configFile];
306: if (isset($data['includes'])) {
307: Validators::assert($data['includes'], 'list', sprintf("section 'includes' in file '%s'", $configFile));
308: $includes = Helpers::expand($data['includes'], $loaderParameters);
309: foreach ($includes as $include) {
310: $include = self::expandIncludedFile($include, $configFile);
311: [$tmpConfigFiles, $tmpConfigArray] = self::getConfigFiles($fileHelper, $neonAdapter, $phpAdapter, $include, $loaderParameters, $generateBaselineFile);
312: $allConfigFiles = array_merge($allConfigFiles, $tmpConfigFiles);
313:
314: /** @var array<mixed> $data */
315: $data = \Nette\Schema\Helpers::merge($tmpConfigArray, $data);
316: }
317: }
318:
319: return [$allConfigFiles, $data];
320: }
321:
322: private static function expandIncludedFile(string $includedFile, string $mainFile): string
323: {
324: return Strings::match($includedFile, '#([a-z]+:)?[/\\\\]#Ai') !== null // is absolute
325: ? $includedFile
326: : dirname($mainFile) . '/' . $includedFile;
327: }
328:
329: /**
330: * @param array<mixed> $parameters
331: * @param array<mixed> $parametersSchema
332: */
333: private function validateParameters(array $parameters, array $parametersSchema): void
334: {
335: if (!(bool) $parameters['__validate']) {
336: return;
337: }
338:
339: $schema = $this->processArgument(
340: new Statement('schema', [
341: new Statement('structure', [$parametersSchema]),
342: ]),
343: );
344: $processor = new Processor();
345: $processor->onNewContext[] = static function (SchemaContext $context): void {
346: $context->path = ['parameters'];
347: };
348: $processor->process($schema, $parameters);
349:
350: if (
351: array_key_exists('phpVersion', $parameters)
352: && is_array($parameters['phpVersion'])
353: ) {
354: $phpVersion = $parameters['phpVersion'];
355:
356: if ($phpVersion['max'] < $phpVersion['min']) {
357: throw new InvalidPhpVersionException('Invalid PHP version range: phpVersion.max should be greater or equal to phpVersion.min.');
358: }
359: }
360:
361: foreach ($parameters['ignoreErrors'] ?? [] as $ignoreError) {
362: if (is_string($ignoreError)) {
363: continue;
364: }
365:
366: $atLeastOneOf = ['message', 'messages', 'rawMessage', 'rawMessages', 'identifier', 'identifiers', 'path', 'paths'];
367: if (array_intersect($atLeastOneOf, array_keys($ignoreError)) === []) {
368: throw new InvalidIgnoredErrorException('An ignoreErrors entry must contain at least one of the following fields: ' . implode(', ', $atLeastOneOf) . '.');
369: }
370:
371: foreach ([
372: ['rawMessage', 'rawMessages', 'message', 'messages'],
373: ['identifier', 'identifiers'],
374: ['path', 'paths'],
375: ] as $incompatibleFields) {
376: foreach ($incompatibleFields as $index => $field1) {
377: $fieldsToCheck = array_slice($incompatibleFields, $index + 1);
378: foreach ($fieldsToCheck as $field2) {
379: if (array_key_exists($field1, $ignoreError) && array_key_exists($field2, $ignoreError)) {
380: throw new InvalidIgnoredErrorException(sprintf('An ignoreErrors entry cannot contain both %s and %s fields.', $field1, $field2));
381: }
382: }
383: }
384: }
385:
386: if (array_key_exists('count', $ignoreError) && !array_key_exists('path', $ignoreError)) {
387: throw new InvalidIgnoredErrorException('An ignoreErrors entry with count field must also contain path field.');
388: }
389: }
390: }
391:
392: /**
393: * @param Statement[] $statements
394: */
395: private function processSchema(array $statements, bool $required = true): Schema
396: {
397: if (count($statements) === 0) {
398: throw new ShouldNotHappenException();
399: }
400:
401: $parameterSchema = null;
402: foreach ($statements as $statement) {
403: $processedArguments = array_map(fn ($argument) => $this->processArgument($argument), $statement->arguments);
404: $entity = $statement->getEntity();
405: if (!is_string($entity)) {
406: throw new ShouldNotHappenException();
407: }
408: if ($parameterSchema === null) {
409: /** @var Type|AnyOf|Structure $parameterSchema */
410: $parameterSchema = Expect::{$entity}(...$processedArguments);
411: } else {
412: $parameterSchema->{$entity}(...$processedArguments);
413: }
414: }
415:
416: if ($required) {
417: $parameterSchema->required();
418: }
419:
420: return $parameterSchema;
421: }
422:
423: /**
424: * @param mixed $argument
425: * @return mixed
426: */
427: private function processArgument($argument, bool $required = true)
428: {
429: if ($argument instanceof Statement) {
430: if ($argument->entity === 'schema') {
431: $arguments = [];
432: foreach ($argument->arguments as $schemaArgument) {
433: if (!$schemaArgument instanceof Statement) {
434: throw new ShouldNotHappenException('schema() should contain another statement().');
435: }
436:
437: $arguments[] = $schemaArgument;
438: }
439:
440: if (count($arguments) === 0) {
441: throw new ShouldNotHappenException('schema() should have at least one argument.');
442: }
443:
444: return $this->processSchema($arguments, $required);
445: }
446:
447: return $this->processSchema([$argument], $required);
448: } elseif (is_array($argument)) {
449: $processedArray = [];
450: foreach ($argument as $key => $val) {
451: $required = $key[0] !== '?';
452: $key = $required ? $key : substr($key, 1);
453: $processedArray[$key] = $this->processArgument($val, $required);
454: }
455:
456: return $processedArray;
457: }
458:
459: return $argument;
460: }
461:
462: }
463: