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