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