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\Extensions\ExtensionsExtension;
9: use Nette\DI\Helpers;
10: use Nette\Schema\Context as SchemaContext;
11: use Nette\Schema\Elements\AnyOf;
12: use Nette\Schema\Elements\Structure;
13: use Nette\Schema\Elements\Type;
14: use Nette\Schema\Expect;
15: use Nette\Schema\Processor;
16: use Nette\Schema\Schema;
17: use Nette\Utils\Strings;
18: use Nette\Utils\Validators;
19: use Phar;
20: use PhpParser\Parser;
21: use PHPStan\BetterReflection\BetterReflection;
22: use PHPStan\BetterReflection\Reflector\Reflector;
23: use PHPStan\BetterReflection\SourceLocator\SourceStubber\PhpStormStubsSourceStubber;
24: use PHPStan\BetterReflection\SourceLocator\Type\SourceLocator;
25: use PHPStan\Command\CommandHelper;
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 function array_diff_key;
35: use function array_intersect;
36: use function array_key_exists;
37: use function array_keys;
38: use function array_map;
39: use function array_merge;
40: use function array_slice;
41: use function array_unique;
42: use function count;
43: use function dirname;
44: use function extension_loaded;
45: use function getenv;
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: */
101: public function create(
102: string $tempDirectory,
103: array $additionalConfigFiles,
104: array $analysedPaths,
105: array $composerAutoloaderProjectPaths = [],
106: array $analysedPathsFromConfig = [],
107: string $usedLevel = CommandHelper::DEFAULT_LEVEL,
108: ?string $generateBaselineFile = null,
109: ?string $cliAutoloadFile = null,
110: ?string $singleReflectionFile = null,
111: ?string $singleReflectionInsteadOfFile = null,
112: ): Container
113: {
114: [$allConfigFiles, $projectConfig] = $this->detectDuplicateIncludedFiles(
115: array_merge([__DIR__ . '/../../conf/parametersSchema.neon'], $additionalConfigFiles),
116: [
117: 'rootDir' => $this->rootDirectory,
118: 'currentWorkingDirectory' => $this->currentWorkingDirectory,
119: 'env' => getenv(),
120: ],
121: );
122:
123: $configurator = new Configurator(new LoaderFactory(
124: $this->fileHelper,
125: $this->rootDirectory,
126: $this->currentWorkingDirectory,
127: $generateBaselineFile,
128: $projectConfig['expandRelativePaths'],
129: ), $this->journalContainer);
130: $configurator->defaultExtensions = [
131: 'php' => PhpExtension::class,
132: 'extensions' => ExtensionsExtension::class,
133: ];
134: $configurator->setDebugMode(true);
135: $configurator->setTempDirectory($tempDirectory);
136: $configurator->addParameters([
137: 'rootDir' => $this->rootDirectory,
138: 'currentWorkingDirectory' => $this->currentWorkingDirectory,
139: 'cliArgumentsVariablesRegistered' => ini_get('register_argc_argv') === '1',
140: 'tmpDir' => $tempDirectory,
141: 'additionalConfigFiles' => $additionalConfigFiles,
142: 'allConfigFiles' => $allConfigFiles,
143: 'composerAutoloaderProjectPaths' => $composerAutoloaderProjectPaths,
144: 'generateBaselineFile' => $generateBaselineFile,
145: 'usedLevel' => $usedLevel,
146: 'cliAutoloadFile' => $cliAutoloadFile,
147: 'env' => getenv(),
148: ]);
149: $configurator->addDynamicParameters([
150: 'singleReflectionFile' => $singleReflectionFile,
151: 'singleReflectionInsteadOfFile' => $singleReflectionInsteadOfFile,
152: 'analysedPaths' => $analysedPaths,
153: 'analysedPathsFromConfig' => $analysedPathsFromConfig,
154: ]);
155: $configurator->addConfig($this->configDirectory . '/config.neon');
156: foreach ($additionalConfigFiles as $additionalConfigFile) {
157: $configurator->addConfig($additionalConfigFile);
158: }
159:
160: $configurator->setAllConfigFiles($allConfigFiles);
161:
162: $container = $configurator->createContainer()->getByType(Container::class);
163: $this->validateParameters($container->getParameters(), $projectConfig['parametersSchema']);
164: self::postInitializeContainer($container);
165:
166: return $container;
167: }
168:
169: /** @internal */
170: public static function postInitializeContainer(Container $container): void
171: {
172: $containerId = spl_object_id($container);
173: if ($containerId === self::$lastInitializedContainerId) {
174: return;
175: }
176:
177: self::$lastInitializedContainerId = $containerId;
178:
179: /** @var SourceLocator $sourceLocator */
180: $sourceLocator = $container->getService('betterReflectionSourceLocator');
181:
182: /** @var Reflector $reflector */
183: $reflector = $container->getService('betterReflectionReflector');
184:
185: /** @var Parser $phpParser */
186: $phpParser = $container->getService('phpParserDecorator');
187:
188: BetterReflection::populate(
189: $container->getByType(PhpVersion::class)->getVersionId(),
190: $sourceLocator,
191: $reflector,
192: $phpParser,
193: $container->getByType(PhpStormStubsSourceStubber::class),
194: $container->getByType(Printer::class),
195: );
196:
197: ReflectionProviderStaticAccessor::registerInstance($container->getByType(ReflectionProvider::class));
198: PhpVersionStaticAccessor::registerInstance($container->getByType(PhpVersion::class));
199: ObjectType::resetCaches();
200: $container->getService('typeSpecifier');
201:
202: BleedingEdgeToggle::setBleedingEdge($container->getParameter('featureToggles')['bleedingEdge']);
203: }
204:
205: public function getCurrentWorkingDirectory(): string
206: {
207: return $this->currentWorkingDirectory;
208: }
209:
210: public function getRootDirectory(): string
211: {
212: return $this->rootDirectory;
213: }
214:
215: public function getConfigDirectory(): string
216: {
217: return $this->configDirectory;
218: }
219:
220: /**
221: * @param string[] $configFiles
222: * @param array<string, mixed> $loaderParameters
223: * @return array{list<string>, array<mixed>}
224: * @throws DuplicateIncludedFilesException
225: */
226: private function detectDuplicateIncludedFiles(
227: array $configFiles,
228: array $loaderParameters,
229: ): array
230: {
231: $neonAdapter = new NeonCachedFileReader([]);
232: $phpAdapter = new PhpAdapter();
233: $allConfigFiles = [];
234: $configArray = [];
235: foreach ($configFiles as $configFile) {
236: [$tmpConfigFiles, $tmpConfigArray] = self::getConfigFiles($this->fileHelper, $neonAdapter, $phpAdapter, $configFile, $loaderParameters, null);
237: $allConfigFiles = array_merge($allConfigFiles, $tmpConfigFiles);
238:
239: /** @var array<mixed> $configArray */
240: $configArray = \Nette\Schema\Helpers::merge($tmpConfigArray, $configArray);
241: }
242:
243: $normalized = array_map(fn (string $file): string => $this->fileHelper->normalizePath($file), $allConfigFiles);
244:
245: $deduplicated = array_unique($normalized);
246: if (count($normalized) <= count($deduplicated)) {
247: return [$normalized, $configArray];
248: }
249:
250: $duplicateFiles = array_unique(array_diff_key($normalized, $deduplicated));
251:
252: throw new DuplicateIncludedFilesException($duplicateFiles);
253: }
254:
255: /**
256: * @param array<string, string> $loaderParameters
257: * @return array{list<string>, array<mixed>}
258: */
259: private static function getConfigFiles(
260: FileHelper $fileHelper,
261: NeonCachedFileReader $neonAdapter,
262: PhpAdapter $phpAdapter,
263: string $configFile,
264: array $loaderParameters,
265: ?string $generateBaselineFile,
266: ): array
267: {
268: if ($generateBaselineFile === $fileHelper->normalizePath($configFile)) {
269: return [[], []];
270: }
271: if (!is_file($configFile) || !is_readable($configFile)) {
272: return [[], []];
273: }
274:
275: if (str_ends_with($configFile, '.php')) {
276: $data = $phpAdapter->load($configFile);
277: } else {
278: $data = $neonAdapter->load($configFile);
279: }
280: $allConfigFiles = [$configFile];
281: if (isset($data['includes'])) {
282: Validators::assert($data['includes'], 'list', sprintf("section 'includes' in file '%s'", $configFile));
283: $includes = Helpers::expand($data['includes'], $loaderParameters);
284: foreach ($includes as $include) {
285: $include = self::expandIncludedFile($include, $configFile);
286: [$tmpConfigFiles, $tmpConfigArray] = self::getConfigFiles($fileHelper, $neonAdapter, $phpAdapter, $include, $loaderParameters, $generateBaselineFile);
287: $allConfigFiles = array_merge($allConfigFiles, $tmpConfigFiles);
288:
289: /** @var array<mixed> $data */
290: $data = \Nette\Schema\Helpers::merge($tmpConfigArray, $data);
291: }
292: }
293:
294: return [$allConfigFiles, $data];
295: }
296:
297: private static function expandIncludedFile(string $includedFile, string $mainFile): string
298: {
299: return Strings::match($includedFile, '#([a-z]+:)?[/\\\\]#Ai') !== null // is absolute
300: ? $includedFile
301: : dirname($mainFile) . '/' . $includedFile;
302: }
303:
304: /**
305: * @param array<mixed> $parameters
306: * @param array<mixed> $parametersSchema
307: */
308: private function validateParameters(array $parameters, array $parametersSchema): void
309: {
310: if (!(bool) $parameters['__validate']) {
311: return;
312: }
313:
314: $schema = $this->processArgument(
315: new Statement('schema', [
316: new Statement('structure', [$parametersSchema]),
317: ]),
318: );
319: $processor = new Processor();
320: $processor->onNewContext[] = static function (SchemaContext $context): void {
321: $context->path = ['parameters'];
322: };
323: $processor->process($schema, $parameters);
324:
325: if (
326: array_key_exists('phpVersion', $parameters)
327: && is_array($parameters['phpVersion'])
328: ) {
329: $phpVersion = $parameters['phpVersion'];
330:
331: if ($phpVersion['max'] < $phpVersion['min']) {
332: throw new InvalidPhpVersionException('Invalid PHP version range: phpVersion.max should be greater or equal to phpVersion.min.');
333: }
334: }
335:
336: foreach ($parameters['ignoreErrors'] ?? [] as $ignoreError) {
337: if (is_string($ignoreError)) {
338: continue;
339: }
340:
341: $atLeastOneOf = ['message', 'messages', 'rawMessage', 'rawMessages', 'identifier', 'identifiers', 'path', 'paths'];
342: if (array_intersect($atLeastOneOf, array_keys($ignoreError)) === []) {
343: throw new InvalidIgnoredErrorException('An ignoreErrors entry must contain at least one of the following fields: ' . implode(', ', $atLeastOneOf) . '.');
344: }
345:
346: foreach ([
347: ['rawMessage', 'rawMessages', 'message', 'messages'],
348: ['identifier', 'identifiers'],
349: ['path', 'paths'],
350: ] as $incompatibleFields) {
351: foreach ($incompatibleFields as $index => $field1) {
352: $fieldsToCheck = array_slice($incompatibleFields, $index + 1);
353: foreach ($fieldsToCheck as $field2) {
354: if (array_key_exists($field1, $ignoreError) && array_key_exists($field2, $ignoreError)) {
355: throw new InvalidIgnoredErrorException(sprintf('An ignoreErrors entry cannot contain both %s and %s fields.', $field1, $field2));
356: }
357: }
358: }
359: }
360:
361: if (array_key_exists('count', $ignoreError) && !array_key_exists('path', $ignoreError)) {
362: throw new InvalidIgnoredErrorException('An ignoreErrors entry with count field must also contain path field.');
363: }
364: }
365: }
366:
367: /**
368: * @param Statement[] $statements
369: */
370: private function processSchema(array $statements, bool $required = true): Schema
371: {
372: if (count($statements) === 0) {
373: throw new ShouldNotHappenException();
374: }
375:
376: $parameterSchema = null;
377: foreach ($statements as $statement) {
378: $processedArguments = array_map(fn ($argument) => $this->processArgument($argument), $statement->arguments);
379: if ($parameterSchema === null) {
380: /** @var Type|AnyOf|Structure $parameterSchema */
381: $parameterSchema = Expect::{$statement->getEntity()}(...$processedArguments);
382: } else {
383: $parameterSchema->{$statement->getEntity()}(...$processedArguments);
384: }
385: }
386:
387: if ($required) {
388: $parameterSchema->required();
389: }
390:
391: return $parameterSchema;
392: }
393:
394: /**
395: * @param mixed $argument
396: * @return mixed
397: */
398: private function processArgument($argument, bool $required = true)
399: {
400: if ($argument instanceof Statement) {
401: if ($argument->entity === 'schema') {
402: $arguments = [];
403: foreach ($argument->arguments as $schemaArgument) {
404: if (!$schemaArgument instanceof Statement) {
405: throw new ShouldNotHappenException('schema() should contain another statement().');
406: }
407:
408: $arguments[] = $schemaArgument;
409: }
410:
411: if (count($arguments) === 0) {
412: throw new ShouldNotHappenException('schema() should have at least one argument.');
413: }
414:
415: return $this->processSchema($arguments, $required);
416: }
417:
418: return $this->processSchema([$argument], $required);
419: } elseif (is_array($argument)) {
420: $processedArray = [];
421: foreach ($argument as $key => $val) {
422: $required = $key[0] !== '?';
423: $key = $required ? $key : substr($key, 1);
424: $processedArray[$key] = $this->processArgument($val, $required);
425: }
426:
427: return $processedArray;
428: }
429:
430: return $argument;
431: }
432:
433: }
434: